From 5b097fe5fb48ebee6ca535f5731d205aa34a59e4 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Thu, 31 Aug 2023 19:38:50 +0200 Subject: [PATCH 001/328] Batch update for retention policiy --- CHANGELOG | 26 ++- npbackup/__main__.py | 19 ++- npbackup/core/runner.py | 86 +++++++--- npbackup/gui/config.py | 22 +++ npbackup/gui/main.py | 42 +++-- npbackup/gui/operations.py | 175 ++++++++++++++++++++ npbackup/restic_wrapper/__init__.py | 96 +++++++++-- npbackup/translations/config_gui.fr.yml | 2 +- npbackup/translations/generic.en.yml | 6 +- npbackup/translations/generic.fr.yml | 6 +- npbackup/translations/main_gui.en.yml | 3 +- npbackup/translations/main_gui.fr.yml | 3 +- npbackup/translations/operations_gui.en.yml | 11 ++ npbackup/translations/operations_gui.fr.yml | 11 ++ 14 files changed, 438 insertions(+), 70 deletions(-) create mode 100644 npbackup/gui/operations.py create mode 100644 npbackup/translations/operations_gui.en.yml create mode 100644 npbackup/translations/operations_gui.fr.yml diff --git a/CHANGELOG b/CHANGELOG index 2a5668b..76f1678 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,6 +1,28 @@ ## Current master - - ! Add --read-concurrency for better backup parallelism on NVME drives (already default to 2 in 0.16.0) + + ! - Add policy like restic forget --keep-within-daily 30d --keep-within-weekly 1m --keep-within-monthly 1y --keep-within-yearly 3y + default policy restic forget --keep-within-hourly 72h --keep-within-daily 30d --keep-within-weekly 1m --keep-within-monthly 1y --keep-within-yearly 3y + - With button to select --keep-x or --keep-within-x + - with button to select forget/prune operation + - With explanation link to restic + - prune operation: default max unsed ? + - Operation planifier: + - backups + - cleaner (forget / check / prune, each having different planification (at least for check --read-data)) + - Launch now + - NPBackup Operation mode + - manages multiple repos with generic key (restic key add) or specified key + +## 2.3.0 - XX + !- New operation planifier for backups / cleaning / checking repos + - Current backup state now shows more precise backup state, including last backup date when relevant + !- Implemented retention policies + ! - Optional time server update to make sure we don't drift before doing retention operations + ! - Optional repo check befoire doing retention operations + !- Implemented repo check / repair actions + - Added snapshot tag to snapshot list on main window + - Fix deletion failed message for en lang + - Fix Google cloud storage backend detection in repository uri ## 2.2.1 - 28/08/2023 - Added snapshot deletion option in GUI diff --git a/npbackup/__main__.py b/npbackup/__main__.py index 9e34f60..c3193dc 100644 --- a/npbackup/__main__.py +++ b/npbackup/__main__.py @@ -9,8 +9,8 @@ __description__ = "NetPerfect Backup Client" __copyright__ = "Copyright (C) 2022-2023 NetInvent" __license__ = "GPL-3.0-only" -__build__ = "2023082801" -__version__ = "2.2.1" +__build__ = "2023083101" +__version__ = "2.3.0-dev" import os @@ -52,6 +52,7 @@ if not _NO_GUI: from npbackup.gui.config import config_gui + from npbackup.gui.operations import operations_gui from npbackup.gui.main import main_gui from npbackup.gui.minimize_window import minimize_current_window @@ -155,6 +156,12 @@ def interface(): help="Show configuration GUI", ) + parser.add_argument( + "--operations-gui", + action="store_true", + help="Show operations GUI" + ) + parser.add_argument( "-l", "--list", action="store_true", help="Show current snapshots" ) @@ -245,6 +252,7 @@ def interface(): help="Show status of required modules for GUI to work", ) + args = parser.parse_args() version_string = "{} v{}{}{}-{} {} - {} - {}".format( @@ -299,7 +307,7 @@ def interface(): CONFIG_FILE = args.config_file # Program entry - if args.config_gui: + if args.config_gui or args.operations_gui: try: config_dict = configuration.load_config(CONFIG_FILE) if not config_dict: @@ -311,7 +319,10 @@ def interface(): ) config_dict = configuration.empty_config_dict - config_dict = config_gui(config_dict, CONFIG_FILE) + if args.config_gui: + config_dict = config_gui(config_dict, CONFIG_FILE) + if args.operations_gui: + config_dict = operations_gui(config_dict, CONFIG_FILE) sys.exit(0) if args.create_scheduled_task: diff --git a/npbackup/core/runner.py b/npbackup/core/runner.py index 7ddb70d..6e10cbf 100644 --- a/npbackup/core/runner.py +++ b/npbackup/core/runner.py @@ -7,14 +7,14 @@ __author__ = "Orsiris de Jong" __copyright__ = "Copyright (C) 2022-2023 NetInvent" __license__ = "GPL-3.0-only" -__build__ = "2023052801" +__build__ = "2023083101" from typing import Optional, Callable, Union, List import os import logging import queue -import datetime +from datetime import datetime, timedelta from functools import wraps from command_runner import command_runner from ofunctions.platform import os_arch @@ -141,21 +141,24 @@ class NPBackupRunner: # NPF-SEC-00002: password commands, pre_exec and post_exec commands will be executed with npbackup privileges # This can lead to a problem when the config file can be written by users other than npbackup - def __init__(self, config_dict): - self.config_dict = config_dict - - self._dry_run = False - self._verbose = False - self._stdout = None - self.restic_runner = None - self.minimum_backup_age = None - self._exec_time = None - - self.is_ready = False - # Create an instance of restic wrapper - self.create_restic_runner() - # Configure that instance - self.apply_config_to_restic_runner() + def __init__(self, config_dict: Optional[dict] = None): + if config_dict: + self.config_dict = config_dict + + self._dry_run = False + self._verbose = False + self._stdout = None + self.restic_runner = None + self.minimum_backup_age = None + self._exec_time = None + + self.is_ready = False + # Create an instance of restic wrapper + self.create_restic_runner() + # Configure that instance + self.apply_config_to_restic_runner() + else: + self.is_ready = False @property def backend_version(self) -> bool: @@ -224,10 +227,10 @@ def exec_timer(fn: Callable): """ def wrapper(self, *args, **kwargs): - start_time = datetime.datetime.utcnow() + start_time = datetime.utcnow() # pylint: disable=E1102 (not-callable) result = fn(self, *args, **kwargs) - self.exec_time = (datetime.datetime.utcnow() - start_time).total_seconds() + self.exec_time = (datetime.utcnow() - start_time).total_seconds() logger.info("Runner took {} seconds".format(self.exec_time)) return result @@ -438,20 +441,21 @@ def check_recent_backups(self) -> bool: return None logger.info( "Searching for a backup newer than {} ago.".format( - str(datetime.timedelta(minutes=self.minimum_backup_age)) + str(timedelta(minutes=self.minimum_backup_age)) ) ) self.restic_runner.verbose = False - result = self.restic_runner.has_snapshot_timedelta(self.minimum_backup_age) + result, backup_tz = self.restic_runner.has_snapshot_timedelta(self.minimum_backup_age) self.restic_runner.verbose = self.verbose if result: - logger.info("Most recent backup is from {}".format(result)) - return result + logger.info("Most recent backup is from {}".format(backup_tz)) + elif result is False and backup_tz == datetime(1,1,1,0,0): + logger.info("No snapshots found in repo.") elif result is False: - logger.info("No recent backup found.") + logger.info("No recent backup found. Newest is from {}".format(backup_tz)) elif result is None: logger.error("Cannot connect to repository or repository empty.") - return result + return result, backup_tz @exec_timer def backup(self, force: bool = False) -> bool: @@ -657,11 +661,39 @@ def forget(self, snapshot: str) -> bool: if not self.is_ready: return False logger.info("Forgetting snapshot {}".format(snapshot)) - result = self.restic_runner.forget(snapshot) + result= self.restic_runner.forget(snapshot) return result - + + @exec_timer + def check(self, read_data: bool = True) -> bool: + if not self.is_ready: + return False + logger.info("Checking repository") + result = self.restic_runner.check(read_data) + return result + + @exec_timer + def prune(self) -> bool: + if not self.is_ready: + return False + logger.info("Pruning snapshots") + result = self.restic_runner.prune() + return result + + @exec_timer + def repair(self, order: str) -> bool: + if not self.is_ready: + return False + logger.info("Repairing {} in repo".format(order)) + result = self.restic_runner.repair(order) + return result + @exec_timer def raw(self, command: str) -> bool: logger.info("Running raw command: {}".format(command)) result = self.restic_runner.raw(command=command) return result + + def group_runner(self, operations_config: dict, result_queue: Optional[queue.Queue]) -> bool: + print(operations_config) + print('run to the hills') diff --git a/npbackup/gui/config.py b/npbackup/gui/config.py index 1f749ea..63408ce 100644 --- a/npbackup/gui/config.py +++ b/npbackup/gui/config.py @@ -323,6 +323,18 @@ def update_config_dict(values, config_dict): ], ] + retention_col = [ + [sg.Text(_t("config_gui.retention_policy"))], + [ + sg.Text(_t("config_gui.custom_time_server_url"), size=(40, 1)), + sg.Input(key="retentionpolicy---custom_time_server", size=(50 ,1)), + ], + [ + sg.Text(_t("config_gui.keep"), size=(40, 1)), + sg.Text(_t("config_gui.hourly"), size=(10, 1)), + ] + ] + identity_col = [ [sg.Text(_t("config_gui.available_variables_id"))], [ @@ -482,6 +494,15 @@ def update_config_dict(values, config_dict): element_justification="C", ) ], + [ + sg.Tab( + _t("config_gui.retention_policy"), + retention_col, + font="helvetica 16", + key="--tab-retentino--", + element_justification='L', + ) + ], [ sg.Tab( _t("config_gui.machine_identification"), @@ -537,6 +558,7 @@ def update_config_dict(values, config_dict): window = sg.Window( "Configuration", layout, + size=(800,600), text_justification="C", auto_size_text=True, auto_size_buttons=False, diff --git a/npbackup/gui/main.py b/npbackup/gui/main.py index 613fcd6..8198f7b 100644 --- a/npbackup/gui/main.py +++ b/npbackup/gui/main.py @@ -7,7 +7,7 @@ __author__ = "Orsiris de Jong" __copyright__ = "Copyright (C) 2022-2023 NetInvent" __license__ = "GPL-3.0-only" -__build__ = "2023082801" +__build__ = "2023083101" from typing import List, Optional, Tuple @@ -15,6 +15,7 @@ import os from logging import getLogger import re +from datetime import datetime import dateutil import queue from time import sleep @@ -37,6 +38,7 @@ LICENSE_FILE, ) from npbackup.gui.config import config_gui +from npbackup.gui.operations import operations_gui from npbackup.core.runner import NPBackupRunner from npbackup.core.i18n_helper import _t from npbackup.core.upgrade_runner import run_upgrade, check_new_version @@ -109,7 +111,7 @@ def _about_gui(version_string: str, config_dict: dict) -> None: def _get_gui_data(config_dict: dict) -> Future: runner = NPBackupRunner(config_dict=config_dict) snapshots = runner.list() - current_state = runner.check_recent_backups() + current_state, backup_tz = runner.check_recent_backups() snapshot_list = [] if snapshots: snapshots.reverse() # Let's show newer snapshots first @@ -120,18 +122,20 @@ def _get_gui_data(config_dict: dict) -> Future: snapshot_username = snapshot["username"] snapshot_hostname = snapshot["hostname"] snapshot_id = snapshot["short_id"] + snapshot_tags = snapshot["tags"] snapshot_list.append( - "{} {} {} {}@{} [ID {}]".format( + "{} {} {} {}@{} [TAGS {}] [ID {}]".format( _t("main_gui.backup_from"), snapshot_date, _t("main_gui.run_as"), snapshot_username, snapshot_hostname, + snapshot_tags, snapshot_id, ) ) - return current_state, snapshot_list + return current_state, backup_tz, snapshot_list def get_gui_data(config_dict: dict) -> Tuple[bool, List[str]]: @@ -171,14 +175,18 @@ def get_gui_data(config_dict: dict) -> Tuple[bool, List[str]]: return thread.result() -def _gui_update_state(window, current_state: bool, snapshot_list: List[str]) -> None: +def _gui_update_state(window, current_state: bool, backup_tz: Optional[datetime], snapshot_list: List[str]) -> None: if current_state: + window["state-button"].Update("{}: {}".format( + _t("generic.up_to_date"), backup_tz), button_color=GUI_STATE_OK_BUTTON + ) + elif current_state is False and backup_tz == datetime(1,1,1,0,0): window["state-button"].Update( - _t("generic.up_to_date"), button_color=GUI_STATE_OK_BUTTON + _t("generic.no_snapshots"), button_color=GUI_STATE_OLD_BUTTON ) elif current_state is False: - window["state-button"].Update( - _t("generic.too_old"), button_color=GUI_STATE_OLD_BUTTON + window["state-button"].Update("{}: {}".format( + _t("generic.too_old"), backup_tz), button_color=GUI_STATE_OLD_BUTTON ) elif current_state is None: window["state-button"].Update( @@ -511,7 +519,7 @@ def main_gui(config_dict: dict, config_file: str, version_string: str): "SFTP", "SWIFT", "AZURE", - "GZ", + "GS", "RCLONE", ]: backup_destination = "{} {}".format( @@ -559,6 +567,7 @@ def main_gui(config_dict: dict, config_file: str, version_string: str): sg.Button(_t("main_gui.launch_backup"), key="launch-backup"), sg.Button(_t("main_gui.see_content"), key="see-content"), sg.Button(_t("generic.delete"), key="delete"), + sg.Button(_t("main_gui.operations"), key="operations"), sg.Button(_t("generic.configure"), key="configure"), sg.Button(_t("generic.about"), key="about"), sg.Button(_t("generic.quit"), key="exit"), @@ -586,8 +595,8 @@ def main_gui(config_dict: dict, config_file: str, version_string: str): ) window.read(timeout=1) - current_state, snapshot_list = get_gui_data(config_dict) - _gui_update_state(window, current_state, snapshot_list) + current_state, backup_tz, snapshot_list = get_gui_data(config_dict) + _gui_update_state(window, current_state, backup_tz, snapshot_list) while True: event, values = window.read(timeout=60000) @@ -635,8 +644,8 @@ def main_gui(config_dict: dict, config_file: str, version_string: str): exec_time = THREAD_SHARED_DICT["exec_time"] except KeyError: exec_time = "N/A" - current_state, snapshot_list = get_gui_data(config_dict) - _gui_update_state(window, current_state, snapshot_list) + current_state, backup_tz, snapshot_list = get_gui_data(config_dict) + _gui_update_state(window, current_state, backup_tz, snapshot_list) if not result: sg.PopupError( _t("main_gui.backup_failed", seconds=exec_time), keep_on_top=True @@ -659,6 +668,9 @@ def main_gui(config_dict: dict, config_file: str, version_string: str): delete_backup(config_dict, snapshot=values["snapshot-list"][0]) # Make sure we trigger a GUI refresh after deletions event = "state-button" + if event == "operations": + config_dict = operations_gui(config_dict, config_file) + event = "state-button" if event == "configure": config_dict = config_gui(config_dict, config_file) # Make sure we trigger a GUI refresh when configuration is changed @@ -678,7 +690,7 @@ def main_gui(config_dict: dict, config_file: str, version_string: str): if event == "about": _about_gui(version_string, config_dict) if event == "state-button": - current_state, snapshot_list = get_gui_data(config_dict) - _gui_update_state(window, current_state, snapshot_list) + current_state, backup_tz, snapshot_list = get_gui_data(config_dict) + _gui_update_state(window, current_state, backup_tz, snapshot_list) if current_state is None: sg.Popup(_t("main_gui.cannot_get_repo_status")) diff --git a/npbackup/gui/operations.py b/npbackup/gui/operations.py new file mode 100644 index 0000000..20a3ea4 --- /dev/null +++ b/npbackup/gui/operations.py @@ -0,0 +1,175 @@ +#! /usr/bin/env python +# -*- coding: utf-8 -*- +# +# This file is part of npbackup + +__intname__ = "npbackup.gui.operations" +__author__ = "Orsiris de Jong" +__copyright__ = "Copyright (C) 2023 NetInvent" +__license__ = "GPL-3.0-only" +__build__ = "2023083101" + + +from typing import Tuple +from logging import getLogger +import re +import queue +import PySimpleGUI as sg +import npbackup.configuration as configuration +from ofunctions.threading import threaded, Future +from npbackup.core.runner import NPBackupRunner +from npbackup.core.i18n_helper import _t +from npbackup.customization import ( + OEM_STRING, + OEM_LOGO, + GUI_LOADER_COLOR, + GUI_LOADER_TEXT_COLOR, + GUI_STATE_OK_BUTTON, + GUI_STATE_OLD_BUTTON, + GUI_STATE_UNKNOWN_BUTTON, + LOADER_ANIMATION, + FOLDER_ICON, + FILE_ICON, + LICENSE_TEXT, + LICENSE_FILE, +) + + +logger = getLogger(__intname__) + + +def add_repo(config_dict: dict) -> dict: + pass + + +def get_friendly_repo_name(repository: str) -> Tuple[str, str]: + backend_type = repository.split(":")[0].upper() + if backend_type.upper() in ["REST", "SFTP"]: + # Filter out user / password + res = re.match(r"(sftp|rest).*:\/\/(.*):?(.*)@(.*)", repository, re.IGNORECASE) + if res: + backend_uri = res.group(1) + res.group(2) + res.group(4) + backend_uri = repository + elif backend_type.upper() in [ + "S3", + "B2", + "SWIFT", + "AZURE", + "GS", + "RCLONE", + ]: + backend_uri = repository + else: + backend_type = 'LOCAL' + backend_uri = repository + return backend_type, backend_uri + +def gui_update_state(window, config_dict: dict) -> list: + repo_list = [] + try: + for repo_name in config_dict['repos']: + if config_dict['repos'][repo_name]['repository'] and config_dict['repos'][repo_name]['password']: + backend_type, repo = get_friendly_repo_name(config_dict['repos'][repo_name]['repository']) + repo_list.append("[{}] {}".format(backend_type, repo)) + else: + logger.warning("Incomplete operations repo {}".format(repo_name)) + except KeyError: + logger.info("No operations repos configured") + if config_dict['repo']['repository'] and config_dict['repo']['password']: + backend_type, repo = get_friendly_repo_name(config_dict['repo']['repository']) + repo_list.append("[{}] {}".format(backend_type, repo)) + window['repo-list'].update(repo_list) + return repo_list + +def operations_gui(config_dict: dict, config_file: str) -> dict: + """ + Operate on one or multiple repositories + """ + layout = [ + [ + sg.Column( + [ + [ + sg.Column( + [[sg.Image(data=OEM_LOGO, size=(64, 64))]], vertical_alignment="top" + ), + sg.Column( + [ + [sg.Text(OEM_STRING, font="Arial 14")], + ], + justification="C", + element_justification="C", + vertical_alignment="top", + ), + ], + [ + sg.Text(_t("operations_gui.configured_repositories")) + ], + [sg.Listbox(values=[], key="repo-list", size=(80, 15))], + [sg.Button(_t("operations_gui.add_repo"), key="add-repo"), sg.Button(_t("operations_gui.edit_repo"), key="edit-repo"), sg.Button(_t("operations_gui.remove_repo"), key="remove-repo")], + [sg.Button(_t("operations_gui.quick_check"), key="quick-check"), sg.Button(_t("operations_gui.full_check"), key="full-check")], + [sg.Button(_t("operations_gui.forget_using_retention_policy"), key="forget")], + [sg.Button(_t("operations_gui.standard_prune"), key="standard-prune"), sg.Button(_t("operations_gui.max_prune"), key="max-prune")], + [sg.Button(_t("generic.quit"), key="exit")], + ], + element_justification="C", + ) + ] +] + + + window = sg.Window( + "Configuration", + layout, + size=(600,600), + text_justification="C", + auto_size_text=True, + auto_size_buttons=True, + no_titlebar=False, + grab_anywhere=True, + keep_on_top=False, + alpha_channel=1.0, + default_button_element_size=(12, 1), + finalize=True, + ) + + full_repo_list = gui_update_state(window, config_dict) + window.Element('repo-list').Widget.config(selectmode = sg.LISTBOX_SELECT_MODE_EXTENDED) + while True: + event, values = window.read(timeout=60000) + + if event in (sg.WIN_CLOSED, "exit"): + break + if event == 'add-repo': + pass + if event in ['add-repo', 'remove-repo']: + if not values["repo-list"]: + sg.Popup(_t("main_gui.select_backup"), keep_on_top=True) + continue + if event == 'add-repo': + config_dict = add_repo(config_dict) + # Save to config here #TODO #WIP + event == 'state-update' + elif event == 'remove-repo': + result = sg.popup(_t("generic.are_you_sure"), custom_text = (_t("generic.yes"), _t("generic.no"))) + if result == _t("generic.yes"): + # Save to config here #TODO #WIP + event == 'state-update' + if event == 'forget': + pass + if event in ['forget', 'quick-check', 'full-check', 'standard-prune', 'max-prune']: + if not values["repo-list"]: + result = sg.popup(_t("operations_gui.apply_to_all"), custom_text = (_t("generic.yes"), _t("generic.no"))) + if not result == _t("generic.yes"): + continue + repos = full_repo_list + else: + repos = values["repo-list"] + result_queue = queue.Queue() + runner = NPBackupRunner() + runner.group_runner(repos, result_queue) + if event == 'state-update': + full_repo_list = gui_update_state(window, config_dict) + + + return config_dict \ No newline at end of file diff --git a/npbackup/restic_wrapper/__init__.py b/npbackup/restic_wrapper/__init__.py index 0372bd8..305537f 100644 --- a/npbackup/restic_wrapper/__init__.py +++ b/npbackup/restic_wrapper/__init__.py @@ -7,8 +7,8 @@ __author__ = "Orsiris de Jong" __copyright__ = "Copyright (C) 2022-2023 NetInvent" __license__ = "GPL-3.0-only" -__build__ = "2023082801" -__version__ = "1.7.2" +__build__ = "2023083101" +__version__ = "1.8.0" from typing import Tuple, List, Optional, Callable, Union @@ -561,7 +561,8 @@ def backup( cmd += ' {} "{}"'.format(source_parameter, path) else: # make sure path is a list and does not have trailing slashes - cmd = "backup {}".format( + # We don't need to scan files for ETA, so let's add --no-scan + cmd = "backup --no-scan {}".format( " ".join(['"{}"'.format(path.rstrip("/\\")) for path in paths]) ) @@ -575,7 +576,10 @@ def backup( cmd += ' --{}exclude "{}"'.format(case_ignore_param, exclude_pattern) for exclude_file in exclude_files: if exclude_file: - cmd += ' --{}exclude-file "{}"'.format(case_ignore_param, exclude_file) + if os.path.isfile(exclude_file): + cmd += ' --{}exclude-file "{}"'.format(case_ignore_param, exclude_file) + else: + logger.error("Exclude file \"{}\" not found".format(exclude_file)) if exclude_caches: cmd += " --exclude-caches" if one_file_system: @@ -649,24 +653,79 @@ def restore(self, snapshot: str, target: str, includes: List[str] = None): logger.critical("Data not restored: {}".format(output)) return False - def forget(self, snapshot: str) -> bool: + def forget(self, snapshot: Optional[str] = None, policy: Optional[dict] = None) -> bool: """ Execute forget command for given snapshot """ if not self.is_init: return None - cmd = "forget {}".format(snapshot) + if not snapshot and not policy: + logger.error("No valid snapshot or policy defined for pruning") + return False + + if snapshot: + cmd = "forget {}".format(snapshot) + if policy: + cmd = "format {}".format(policy) # TODO # WIP # We need to be verbose here since server errors will not stop client from deletion attempts verbose = self.verbose self.verbose = True result, output = self.executor(cmd) self.verbose = verbose if result: - logger.info("successfully forgot snapshot.") + logger.info("successfully forgot snapshots") + return True + logger.critical("Forget failed:\n{}".format(output)) + return False + + def prune(self) -> bool: + """ + Prune forgotten snapshots + """ + if not self.is_init: + return None + cmd = "prune" + verbose = self.verbose + self.verbose = True + result, output = self.executor(cmd) + self.verbose = verbose + if result: + logger.info("Successfully pruned repository:\n{}".format(output)) + return True + logger.critical("Could not prune repository:\n{}".format(output)) + return False + + def check(self, read_data: bool = True) -> bool: + """ + Check current repo status + """ + if not self.is_init: + return None + cmd = "check{}".format(' --read-data' if read_data else '') + result, output = self.executor(cmd) + if result: + logger.info("Repo checked successfully.") + return True + logger.critical("Repo check failed:\n {}".format(output)) + return False + + def repair(self, order: str) -> bool: + """ + Check current repo status + """ + if not self.is_init: + return None + if order not in ['index', 'snapshots']: + logger.error("Bogus repair order given: {}".format(order)) + cmd = "repair {}".format(order) + result, output = self.executor(cmd) + if result: + logger.info("Repo successfully repaired:\n{}".format(output)) return True - logger.critical("Could not forge snapshot: {}".format(output)) + logger.critical("Repo repair failed:\n {}".format(output)) return False + def raw(self, command: str) -> Tuple[bool, str]: """ Execute plain restic command without any interpretation" @@ -680,22 +739,25 @@ def raw(self, command: str) -> Tuple[bool, str]: logger.critical("Raw command failed.") return False, output - def has_snapshot_timedelta(self, delta: int = 1441) -> Optional[datetime]: + def has_snapshot_timedelta(self, delta: int = 1441) -> Tuple[bool, Optional[datetime]]: """ Checks if a snapshot exists that is newer that delta minutes Eg: if delta = -60 we expect a snapshot newer than an hour ago, and return True if exists - if delta = +60 we expect a snpashot newer than one hour in future (!), and return True if exists - returns False is too old snapshots exit - returns None if no info available + if delta = +60 we expect a snpashot newer than one hour in future (!) + + returns True, datetime if exists + returns False, datetime if exists but too old + returns False, datetime = 0001-01-01T00:00:00 if no snapshots found + Returns None, None on error """ if not self.is_init: return None try: snapshots = self.snapshots() if self.last_command_status is False: - return None + return None, None if not snapshots: - return False + return False, datetime(1,1,1,0,0) tz_aware_timestamp = datetime.now(timezone.utc).astimezone() has_recent_snapshot = False @@ -716,10 +778,10 @@ def has_snapshot_timedelta(self, delta: int = 1441) -> Optional[datetime]: ) has_recent_snapshot = True if has_recent_snapshot: - return backup_ts - return False + return True, backup_ts + return False, backup_ts except IndexError as exc: logger.debug("snapshot information missing: {}".format(exc)) logger.debug("Trace", exc_info=True) # No 'time' attribute in snapshot ? - return None + return None, None diff --git a/npbackup/translations/config_gui.fr.yml b/npbackup/translations/config_gui.fr.yml index 455852b..af32418 100644 --- a/npbackup/translations/config_gui.fr.yml +++ b/npbackup/translations/config_gui.fr.yml @@ -73,7 +73,7 @@ fr: auto_upgrade_disabled: Mise à niveau automatique désactivée ou serveur injoignable create_scheduled_task_every: Créer une tâche planifiée toutes les - scheduled_task_explanation: Uniquement sous Windows. Sous Linux, merci de créer une entrée crontab comme `npbackup --backup --config-file /etc/npbackup.conf` + scheduled_task_explanation: Sous Linux, merci de créer une entrée crontab comme `npbackup --backup --config-file /etc/npbackup.conf` scheduled_task_creation_success: Tâche planifiée crée avec succès scheduled_task_creation_failure: Impossible de créer la tâche planifiée. Veuillez consulter les journaux pour plus de détails diff --git a/npbackup/translations/generic.en.yml b/npbackup/translations/generic.en.yml index f7e1a63..950f38a 100644 --- a/npbackup/translations/generic.en.yml +++ b/npbackup/translations/generic.en.yml @@ -44,4 +44,8 @@ en: scheduled_task: Scheduled task delete: Delete - deleted: deleted \ No newline at end of file + deleted: deleted + + no_snapshots: No snapshots + + are_you_sure: Are you sure ? diff --git a/npbackup/translations/generic.fr.yml b/npbackup/translations/generic.fr.yml index 05250b1..07b344c 100644 --- a/npbackup/translations/generic.fr.yml +++ b/npbackup/translations/generic.fr.yml @@ -44,4 +44,8 @@ fr: scheduled_task: Tâche planifiée delete: Supprimer - deleted: éffacé \ No newline at end of file + deleted: éffacé + + no_snapshots: Aucun instantané + + are_you_sure: Etes-vous sûr ? \ No newline at end of file diff --git a/npbackup/translations/main_gui.en.yml b/npbackup/translations/main_gui.en.yml index 7fc4349..37c7a76 100644 --- a/npbackup/translations/main_gui.en.yml +++ b/npbackup/translations/main_gui.en.yml @@ -31,4 +31,5 @@ en: unknown_repo: Repo format unknown repository_not_configured: Repository not configured execute_operation: Executing operation - delete failed: Deletion failed. Please check the logs \ No newline at end of file + delete_failed: Deletion failed. Please check the logs + operations: Operations \ No newline at end of file diff --git a/npbackup/translations/main_gui.fr.yml b/npbackup/translations/main_gui.fr.yml index b0af2e0..31a6dbb 100644 --- a/npbackup/translations/main_gui.fr.yml +++ b/npbackup/translations/main_gui.fr.yml @@ -31,4 +31,5 @@ fr: unknown_repo: Format de dépot inconnu repository_not_configured: Dépot non configuré execute_operation: Opération en cours - delete_failed: Suppression impossible. Veuillez vérifier les journaux \ No newline at end of file + delete_failed: Suppression impossible. Veuillez vérifier les journaux + operations: Opérations \ No newline at end of file diff --git a/npbackup/translations/operations_gui.en.yml b/npbackup/translations/operations_gui.en.yml new file mode 100644 index 0000000..5ee0bbb --- /dev/null +++ b/npbackup/translations/operations_gui.en.yml @@ -0,0 +1,11 @@ +en: + configured_repositories: Configured repositories + quick_check: Quick check + full_check: Full check + forget_using_retention_policy: Forget using retention polic + standard_prune: Normal prune operation + max_prune: Prune with maximum efficiency + apply_to_all: Apply to all repos ? + add_repo: Add repo + edit_repo: Edit repo + remove_repo: Remove repo \ No newline at end of file diff --git a/npbackup/translations/operations_gui.fr.yml b/npbackup/translations/operations_gui.fr.yml new file mode 100644 index 0000000..ae5d99f --- /dev/null +++ b/npbackup/translations/operations_gui.fr.yml @@ -0,0 +1,11 @@ +fr: + configured_repositories: Dépots configurés + quick_check: Vérification rapide + full_check: Vérification complète + forget_using_retention_policy: Oublier en utilisant la stratégie de rétention + standard_prune: Opération de purge normale + max_prune: Opération de purge la plus efficace + appply_to_all: Appliquer à tous les dépots ? + add_repo: Ajouter dépot + edit_repo: Modifier dépot + remove_repo: Supprimer dépot \ No newline at end of file From 2efd3fc94302fb5056184e818aebfa673d5c6f2f Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Thu, 31 Aug 2023 22:01:24 +0200 Subject: [PATCH 002/328] Fix missing snapshot tags will fail return snapshot list --- npbackup/gui/main.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/npbackup/gui/main.py b/npbackup/gui/main.py index 8198f7b..449b4bf 100644 --- a/npbackup/gui/main.py +++ b/npbackup/gui/main.py @@ -122,9 +122,12 @@ def _get_gui_data(config_dict: dict) -> Future: snapshot_username = snapshot["username"] snapshot_hostname = snapshot["hostname"] snapshot_id = snapshot["short_id"] - snapshot_tags = snapshot["tags"] + try: + snapshot_tags = " [TAGS: {}]".format(snapshot["tags"]) + except KeyError: + snapshot_tags = "" snapshot_list.append( - "{} {} {} {}@{} [TAGS {}] [ID {}]".format( + "{} {} {} {}@{}{} [ID: {}]".format( _t("main_gui.backup_from"), snapshot_date, _t("main_gui.run_as"), @@ -303,7 +306,7 @@ def _ls_window(config: dict, snapshot_id: str) -> Future: def delete_backup(config: dict, snapshot: str) -> bool: - snapshot_id = re.match(r".*\[ID (.*)\].*", snapshot).group(1) + snapshot_id = re.match(r".*\[ID: (.*)\].*", snapshot).group(1) # We get a thread result, hence pylint will complain the thread isn't a tuple # pylint: disable=E1101 (no-member) thread = _delete_backup(config, snapshot_id) @@ -333,7 +336,7 @@ def delete_backup(config: dict, snapshot: str) -> bool: def ls_window(config: dict, snapshot: str) -> bool: - snapshot_id = re.match(r".*\[ID (.*)\].*", snapshot).group(1) + snapshot_id = re.match(r".*\[ID: (.*)\].*", snapshot).group(1) # We get a thread result, hence pylint will complain the thread isn't a tuple # pylint: disable=E1101 (no-member) thread = _ls_window(config, snapshot_id) From f3eda15bef08ea48cac2df98b6e7c74e1d04b46e Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Thu, 31 Aug 2023 22:02:36 +0200 Subject: [PATCH 003/328] Split npbackup into CLI and GUI --- bin/npbackup_cli | 19 ++++++++++++ bin/{npbackup => npbackup_gui} | 3 ++ npbackup/__main__.py | 54 +++++++++++++--------------------- npbackup/globvars.py | 4 +++ 4 files changed, 47 insertions(+), 33 deletions(-) create mode 100644 bin/npbackup_cli rename bin/{npbackup => npbackup_gui} (84%) mode change 100755 => 100644 create mode 100644 npbackup/globvars.py diff --git a/bin/npbackup_cli b/bin/npbackup_cli new file mode 100644 index 0000000..ae6857b --- /dev/null +++ b/bin/npbackup_cli @@ -0,0 +1,19 @@ +#! /usr/bin/env python3 +# -*- coding: utf-8 -*- +# +# This file is part of npbackup, and is really just a binary shortcut to launch npbackup.main + +import os +import sys + +sys.path.insert(0, os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))) + +from npbackup.globvars import Globvars +g = Globvars +g.GUI = False +from npbackup.__main__ import main + +del sys.path[0] + +if __name__ == "__main__": + main() diff --git a/bin/npbackup b/bin/npbackup_gui old mode 100755 new mode 100644 similarity index 84% rename from bin/npbackup rename to bin/npbackup_gui index 0adfd0e..5e33d07 --- a/bin/npbackup +++ b/bin/npbackup_gui @@ -8,6 +8,9 @@ import sys sys.path.insert(0, os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))) +from npbackup.globvars import Globvars +g = Globvars +g.GUI = True from npbackup.__main__ import main del sys.path[0] diff --git a/npbackup/__main__.py b/npbackup/__main__.py index c3193dc..bae4659 100644 --- a/npbackup/__main__.py +++ b/npbackup/__main__.py @@ -24,21 +24,25 @@ import ofunctions.logger_utils from ofunctions.platform import python_arch from ofunctions.process import kill_childs +from npbackup.globvars import Globvars -# This is needed so we get no GUI version messages -try: - import PySimpleGUI as sg - import _tkinter - - _NO_GUI_ERROR = None - _NO_GUI = False -except ImportError as exc: - _NO_GUI_ERROR = str(exc) - _NO_GUI = True +# This is needed so we get no GUI version messages +if Globvars.GUI: + try: + import PySimpleGUI as sg + import _tkinter + except ImportError as exc: + if not IS_COMPILED: + print(str(exc)) + else: + print("Missing packages in binary.") + sys.exit(1) + from npbackup.customization import ( + PYSIMPLEGUI_THEME, + OEM_ICON, + ) from npbackup.customization import ( - PYSIMPLEGUI_THEME, - OEM_ICON, LICENSE_TEXT, LICENSE_FILE, ) @@ -50,7 +54,7 @@ from npbackup.upgrade_client.upgrader import need_upgrade from npbackup.core.upgrade_runner import run_upgrade -if not _NO_GUI: +if Globvars.GUI: from npbackup.gui.config import config_gui from npbackup.gui.operations import operations_gui from npbackup.gui.main import main_gui @@ -246,23 +250,16 @@ def interface(): action="store_true", help="Add new configuration elements after upgrade", ) - parser.add_argument( - "--gui-status", - action="store_true", - help="Show status of required modules for GUI to work", - ) - args = parser.parse_args() - version_string = "{} v{}{}{}-{} {} - {} - {}".format( + version_string = "{} v{}{}{}-{} {} - {}".format( __intname__, __version__, "-PRIV" if configuration.IS_PRIV_BUILD else "", "-P{}".format(sys.version_info[1]), python_arch(), __build__, - "GUI disabled" if _NO_GUI else "GUI enabled", __copyright__, ) if args.version: @@ -278,15 +275,6 @@ def interface(): print(LICENSE_TEXT) sys.exit(0) - if args.gui_status: - logger.info("Can run GUI: {}, errors={}".format(not _NO_GUI, _NO_GUI_ERROR)) - # Don't bother to talk about package manager when compiled with Nuitka - if _NO_GUI and not IS_COMPILED: - logger.info( - 'You need tcl/tk 8.6+ and python-tkinter installed for GUI to work. Please use your package manager (example "yum install python-tkinter" or "apt install python3-tk") to install missing dependencies.' - ) - sys.exit(0) - if args.debug or os.environ.get("_DEBUG", "False").capitalize() == "True": _DEBUG = True logger.setLevel(ofunctions.logger_utils.logging.DEBUG) @@ -347,7 +335,7 @@ def interface(): message = _t("config_gui.no_config_available") logger.error(message) - if config_dict is None and not _NO_GUI: + if config_dict is None and Globvars.GUI: config_dict = configuration.empty_config_dict # If no arguments are passed, assume we are launching the GUI if len(sys.argv) == 1: @@ -372,7 +360,7 @@ def interface(): sys.exit(1) sys.exit(7) elif not config_dict: - if len(sys.argv) == 1 and not _NO_GUI: + if len(sys.argv) == 1 and Globvars.GUI: sg.Popup(_t("config_gui.bogus_config_file", config_file=CONFIG_FILE)) sys.exit(7) @@ -507,7 +495,7 @@ def interface(): # EXIT_CODE 21 = current backup process already running sys.exit(21) - if not _NO_GUI: + if Globvars.GUI: # When no argument is given, let's run the GUI # Also, let's minimize the commandline window so the GUI user isn't distracted minimize_current_window() diff --git a/npbackup/globvars.py b/npbackup/globvars.py new file mode 100644 index 0000000..6208cb3 --- /dev/null +++ b/npbackup/globvars.py @@ -0,0 +1,4 @@ +print("hello global") + +class Globvars(object): + GUI = None \ No newline at end of file From b8e62a15a5be91cc7ffd4211d1fa24d576a3d035 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Thu, 31 Aug 2023 22:04:36 +0200 Subject: [PATCH 004/328] Simplify has_snapshot_timedelta() --- npbackup/restic_wrapper/__init__.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/npbackup/restic_wrapper/__init__.py b/npbackup/restic_wrapper/__init__.py index 305537f..13805c4 100644 --- a/npbackup/restic_wrapper/__init__.py +++ b/npbackup/restic_wrapper/__init__.py @@ -760,7 +760,8 @@ def has_snapshot_timedelta(self, delta: int = 1441) -> Tuple[bool, Optional[date return False, datetime(1,1,1,0,0) tz_aware_timestamp = datetime.now(timezone.utc).astimezone() - has_recent_snapshot = False + # Begin with most recent snapshot + snapshots.reverse() for snapshot in snapshots: if re.match( r"[0-9]{4}-[0-1][0-9]-[0-3][0-9]T[0-2][0-9]:[0-5][0-9]:[0-5][0-9]\..*\+[0-2][0-9]:[0-9]{2}", @@ -776,9 +777,7 @@ def has_snapshot_timedelta(self, delta: int = 1441) -> Tuple[bool, Optional[date snapshot["short_id"], snapshot["time"] ) ) - has_recent_snapshot = True - if has_recent_snapshot: - return True, backup_ts + return True, backup_ts return False, backup_ts except IndexError as exc: logger.debug("snapshot information missing: {}".format(exc)) From 3ac68fb261cf2aff9d19cdbc05b42bec1ddd5e41 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Thu, 31 Aug 2023 23:24:13 +0200 Subject: [PATCH 005/328] Refactor get_anon_repo_uri() --- npbackup/gui/helpers.py | 40 ++++++++++++++++++++++++++++++++++++++ npbackup/gui/main.py | 24 ++++------------------- npbackup/gui/operations.py | 34 ++++++-------------------------- 3 files changed, 50 insertions(+), 48 deletions(-) create mode 100644 npbackup/gui/helpers.py diff --git a/npbackup/gui/helpers.py b/npbackup/gui/helpers.py new file mode 100644 index 0000000..b758937 --- /dev/null +++ b/npbackup/gui/helpers.py @@ -0,0 +1,40 @@ +#! /usr/bin/env python +# -*- coding: utf-8 -*- +# +# This file is part of npbackup + +__intname__ = "npbackup.gui.helpers" +__author__ = "Orsiris de Jong" +__copyright__ = "Copyright (C) 2023 NetInvent" +__license__ = "GPL-3.0-only" +__build__ = "2023083101" + + +from typing import Tuple +import re + + +def get_anon_repo_uri(repository: str) -> Tuple[str, str]: + """ + Remove user / password part from repository uri + """ + backend_type = repository.split(":")[0].upper() + if backend_type.upper() in ["REST", "SFTP"]: + res = re.match(r"(sftp|rest)(.*:\/\/)(.*):?(.*)@(.*)", repository, re.IGNORECASE) + if res: + backend_uri = res.group(1) + res.group(2) + res.group(5) + else: + backend_uri = repository + elif backend_type.upper() in [ + "S3", + "B2", + "SWIFT", + "AZURE", + "GS", + "RCLONE", + ]: + backend_uri = repository + else: + backend_type = 'LOCAL' + backend_uri = repository + return backend_type, backend_uri \ No newline at end of file diff --git a/npbackup/gui/main.py b/npbackup/gui/main.py index 449b4bf..7cde6a3 100644 --- a/npbackup/gui/main.py +++ b/npbackup/gui/main.py @@ -39,6 +39,7 @@ ) from npbackup.gui.config import config_gui from npbackup.gui.operations import operations_gui +from npbackup.gui.helpers import get_anon_repo_uri from npbackup.core.runner import NPBackupRunner from npbackup.core.i18n_helper import _t from npbackup.core.upgrade_runner import run_upgrade, check_new_version @@ -512,24 +513,7 @@ def _gui_backup(config_dict, stdout) -> Future: def main_gui(config_dict: dict, config_file: str, version_string: str): backup_destination = _t("main_gui.local_folder") - backend_type = None - try: - backend_type = config_dict["repo"]["repository"].split(":")[0].upper() - if backend_type in [ - "REST", - "S3", - "B2", - "SFTP", - "SWIFT", - "AZURE", - "GS", - "RCLONE", - ]: - backup_destination = "{} {}".format( - _t("main_gui.external_server"), backend_type - ) - except (KeyError, AttributeError, TypeError): - pass + backend_type, repo_uri = get_anon_repo_uri(config_dict["repo"]["repository"]) right_click_menu = ["", [_t("generic.destination")]] @@ -560,8 +544,8 @@ def main_gui(config_dict: dict, config_file: str, version_string: str): ], [ sg.Text( - "{} {}".format( - _t("main_gui.backup_list_to"), backup_destination + "{} {} {}".format( + _t("main_gui.backup_list_to"), backend_type, repo_uri ) ) ], diff --git a/npbackup/gui/operations.py b/npbackup/gui/operations.py index 20a3ea4..1b6b442 100644 --- a/npbackup/gui/operations.py +++ b/npbackup/gui/operations.py @@ -12,13 +12,13 @@ from typing import Tuple from logging import getLogger -import re import queue import PySimpleGUI as sg import npbackup.configuration as configuration from ofunctions.threading import threaded, Future from npbackup.core.runner import NPBackupRunner from npbackup.core.i18n_helper import _t +from npbackup.gui.helpers import get_anon_repo_uri from npbackup.customization import ( OEM_STRING, OEM_LOGO, @@ -42,42 +42,20 @@ def add_repo(config_dict: dict) -> dict: pass -def get_friendly_repo_name(repository: str) -> Tuple[str, str]: - backend_type = repository.split(":")[0].upper() - if backend_type.upper() in ["REST", "SFTP"]: - # Filter out user / password - res = re.match(r"(sftp|rest).*:\/\/(.*):?(.*)@(.*)", repository, re.IGNORECASE) - if res: - backend_uri = res.group(1) + res.group(2) + res.group(4) - backend_uri = repository - elif backend_type.upper() in [ - "S3", - "B2", - "SWIFT", - "AZURE", - "GS", - "RCLONE", - ]: - backend_uri = repository - else: - backend_type = 'LOCAL' - backend_uri = repository - return backend_type, backend_uri - def gui_update_state(window, config_dict: dict) -> list: repo_list = [] try: for repo_name in config_dict['repos']: if config_dict['repos'][repo_name]['repository'] and config_dict['repos'][repo_name]['password']: - backend_type, repo = get_friendly_repo_name(config_dict['repos'][repo_name]['repository']) - repo_list.append("[{}] {}".format(backend_type, repo)) + backend_type, repo_uri = get_anon_repo_uri(config_dict['repos'][repo_name]['repository']) + repo_list.append("[{}] {}".format(backend_type, repo_uri)) else: logger.warning("Incomplete operations repo {}".format(repo_name)) except KeyError: logger.info("No operations repos configured") if config_dict['repo']['repository'] and config_dict['repo']['password']: - backend_type, repo = get_friendly_repo_name(config_dict['repo']['repository']) - repo_list.append("[{}] {}".format(backend_type, repo)) + backend_type, repo_uri = get_anon_repo_uri(config_dict['repo']['repository']) + repo_list.append("[{}] {}".format(backend_type, reporepo_uri)) window['repo-list'].update(repo_list) return repo_list @@ -170,6 +148,6 @@ def operations_gui(config_dict: dict, config_file: str) -> dict: runner.group_runner(repos, result_queue) if event == 'state-update': full_repo_list = gui_update_state(window, config_dict) - + window.close() return config_dict \ No newline at end of file From 4314768cf761321d6c4eda1c2b8d3b637b34a60c Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Thu, 31 Aug 2023 23:24:24 +0200 Subject: [PATCH 006/328] Add missing header --- npbackup/globvars.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/npbackup/globvars.py b/npbackup/globvars.py index 6208cb3..fe6deae 100644 --- a/npbackup/globvars.py +++ b/npbackup/globvars.py @@ -1,4 +1,14 @@ -print("hello global") +#! /usr/bin/env python +# -*- coding: utf-8 -*- +# +# This file is part of npbackup + +__intname__ = "npbackup.globvars" +__author__ = "Orsiris de Jong" +__copyright__ = "Copyright (C) 2023 NetInvent" +__license__ = "GPL-3.0-only" +__build__ = "2023083101" + class Globvars(object): GUI = None \ No newline at end of file From 36d3c76d53da661e3c6235e025c13b9a8bd7d1d8 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Thu, 31 Aug 2023 23:59:59 +0200 Subject: [PATCH 007/328] Refactor main table view and forget process --- npbackup/gui/main.py | 88 ++++++++++++++------------- npbackup/translations/generic.en.yml | 5 +- npbackup/translations/generic.fr.yml | 5 +- npbackup/translations/main_gui.en.yml | 2 +- npbackup/translations/main_gui.fr.yml | 2 +- 5 files changed, 54 insertions(+), 48 deletions(-) diff --git a/npbackup/gui/main.py b/npbackup/gui/main.py index 7cde6a3..9ca4005 100644 --- a/npbackup/gui/main.py +++ b/npbackup/gui/main.py @@ -127,17 +127,10 @@ def _get_gui_data(config_dict: dict) -> Future: snapshot_tags = " [TAGS: {}]".format(snapshot["tags"]) except KeyError: snapshot_tags = "" - snapshot_list.append( - "{} {} {} {}@{}{} [ID: {}]".format( - _t("main_gui.backup_from"), - snapshot_date, - _t("main_gui.run_as"), - snapshot_username, - snapshot_hostname, - snapshot_tags, - snapshot_id, - ) - ) + snapshot_list.append([ + snapshot_id, snapshot_date, snapshot_hostname, snapshot_username, snapshot_tags + ]) + return current_state, backup_tz, snapshot_list @@ -196,8 +189,8 @@ def _gui_update_state(window, current_state: bool, backup_tz: Optional[datetime] window["state-button"].Update( _t("generic.not_connected_yet"), button_color=GUI_STATE_UNKNOWN_BUTTON ) - window["snapshot-list"].Update(snapshot_list) + window["snapshot-list"].Update(snapshot_list) @threaded def _make_treedata_from_json(ls_result: List[dict]) -> sg.TreeData: @@ -258,7 +251,7 @@ def _make_treedata_from_json(ls_result: List[dict]) -> sg.TreeData: @threaded -def _delete_backup(config: dict, snapshot_id: str) -> Future: +def _forget_snapshot(config: dict, snapshot_id: str) -> Future: runner = NPBackupRunner(config_dict=config) result = runner.forget(snapshot=snapshot_id) return result @@ -306,38 +299,41 @@ def _ls_window(config: dict, snapshot_id: str) -> Future: return backup_content, result -def delete_backup(config: dict, snapshot: str) -> bool: - snapshot_id = re.match(r".*\[ID: (.*)\].*", snapshot).group(1) - # We get a thread result, hence pylint will complain the thread isn't a tuple - # pylint: disable=E1101 (no-member) - thread = _delete_backup(config, snapshot_id) +def forget_snapshot(config: dict, snapshot_ids: List[str]) -> bool: + batch_result = True + for snapshot_id in snapshot_ids: + # We get a thread result, hence pylint will complain the thread isn't a tuple + # pylint: disable=E1101 (no-member) + thread = _forget_snapshot(config, snapshot_id) - while not thread.done() and not thread.cancelled(): - sg.PopupAnimated( - LOADER_ANIMATION, - message="{}. {}".format( - _t("main_gui.execute_operation"), - _t("main_gui.this_will_take_a_while"), - ), - time_between_frames=50, - background_color=GUI_LOADER_COLOR, - text_color=GUI_LOADER_TEXT_COLOR, - ) - sg.PopupAnimated(None) - result = thread.result() - if not result: - sg.PopupError(_t("main_gui.delete_failed"), keep_on_top=True) + while not thread.done() and not thread.cancelled(): + sg.PopupAnimated( + LOADER_ANIMATION, + message="{} {}. {}".format( + _t("generic.forgetting"), + snapshot_id, + _t("main_gui.this_will_take_a_while"), + ), + time_between_frames=50, + background_color=GUI_LOADER_COLOR, + text_color=GUI_LOADER_TEXT_COLOR, + ) + sg.PopupAnimated(None) + result = thread.result() + if not result: + batch_result = result + if not batch_result: + sg.PopupError(_t("main_gui.forget_failed"), keep_on_top=True) return False else: sg.Popup( "{} {} {}".format( - snapshot, _t("generic.deleted"), _t("generic.successfully") + snapshot_ids, _t("generic.forgotten"), _t("generic.successfully") ) ) -def ls_window(config: dict, snapshot: str) -> bool: - snapshot_id = re.match(r".*\[ID: (.*)\].*", snapshot).group(1) +def ls_window(config: dict, snapshot_id: str) -> bool: # We get a thread result, hence pylint will complain the thread isn't a tuple # pylint: disable=E1101 (no-member) thread = _ls_window(config, snapshot_id) @@ -516,6 +512,7 @@ def main_gui(config_dict: dict, config_file: str, version_string: str): backend_type, repo_uri = get_anon_repo_uri(config_dict["repo"]["repository"]) right_click_menu = ["", [_t("generic.destination")]] + headings = ['ID', 'Date', 'Hostname', 'User', 'Tags'] layout = [ [ @@ -549,11 +546,11 @@ def main_gui(config_dict: dict, config_file: str, version_string: str): ) ) ], - [sg.Listbox(values=[], key="snapshot-list", size=(80, 15))], + [sg.Table(values=[[]], headings=headings, auto_size_columns=False, key="snapshot-list", select_mode='extended')], [ sg.Button(_t("main_gui.launch_backup"), key="launch-backup"), sg.Button(_t("main_gui.see_content"), key="see-content"), - sg.Button(_t("generic.delete"), key="delete"), + sg.Button(_t("generic.forget"), key="forget"), sg.Button(_t("main_gui.operations"), key="operations"), sg.Button(_t("generic.configure"), key="configure"), sg.Button(_t("generic.about"), key="about"), @@ -647,13 +644,20 @@ def main_gui(config_dict: dict, config_file: str, version_string: str): if not values["snapshot-list"]: sg.Popup(_t("main_gui.select_backup"), keep_on_top=True) continue - ls_window(config_dict, snapshot=values["snapshot-list"][0]) - if event == "delete": + if len(values["snapshot-list"] > 1): + sg.Popup(_t("main_gui.select_only_one_snapshot")) + continue + snapshot_to_see = snapshot_list[values["snapshot-list"][0]][0] + ls_window(config_dict, snapshot_to_see) + if event == "forget": if not values["snapshot-list"]: sg.Popup(_t("main_gui.select_backup"), keep_on_top=True) continue - delete_backup(config_dict, snapshot=values["snapshot-list"][0]) - # Make sure we trigger a GUI refresh after deletions + snapshots_to_forget = [] + for row in values["snapshot-list"]: + snapshots_to_forget.append(snapshot_list[row][0]) + forget_snapshot(config_dict, snapshots_to_forget) + # Make sure we trigger a GUI refresh after forgetting snapshots event = "state-button" if event == "operations": config_dict = operations_gui(config_dict, config_file) diff --git a/npbackup/translations/generic.en.yml b/npbackup/translations/generic.en.yml index 950f38a..85013a1 100644 --- a/npbackup/translations/generic.en.yml +++ b/npbackup/translations/generic.en.yml @@ -43,8 +43,9 @@ en: scheduled_task: Scheduled task - delete: Delete - deleted: deleted + forget: Forget + forgotten: Forgotten + forgetting: Forgetting no_snapshots: No snapshots diff --git a/npbackup/translations/generic.fr.yml b/npbackup/translations/generic.fr.yml index 07b344c..7db7682 100644 --- a/npbackup/translations/generic.fr.yml +++ b/npbackup/translations/generic.fr.yml @@ -43,8 +43,9 @@ fr: scheduled_task: Tâche planifiée - delete: Supprimer - deleted: éffacé + forget: Oublier + forgotten: oublié + forgetting: Oubli de no_snapshots: Aucun instantané diff --git a/npbackup/translations/main_gui.en.yml b/npbackup/translations/main_gui.en.yml index 37c7a76..72cddca 100644 --- a/npbackup/translations/main_gui.en.yml +++ b/npbackup/translations/main_gui.en.yml @@ -31,5 +31,5 @@ en: unknown_repo: Repo format unknown repository_not_configured: Repository not configured execute_operation: Executing operation - delete_failed: Deletion failed. Please check the logs + forget_failed: Failed to forget. Please check the logs operations: Operations \ No newline at end of file diff --git a/npbackup/translations/main_gui.fr.yml b/npbackup/translations/main_gui.fr.yml index 31a6dbb..5aaa919 100644 --- a/npbackup/translations/main_gui.fr.yml +++ b/npbackup/translations/main_gui.fr.yml @@ -31,5 +31,5 @@ fr: unknown_repo: Format de dépot inconnu repository_not_configured: Dépot non configuré execute_operation: Opération en cours - delete_failed: Suppression impossible. Veuillez vérifier les journaux + forget_failed: Oubli impossible. Veuillez vérifier les journaux operations: Opérations \ No newline at end of file From 022c9776e30f6c5b00698e49e2fd914126d5ca17 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Fri, 1 Sep 2023 00:29:44 +0200 Subject: [PATCH 008/328] Update compiler script to support CLI/GUI versions of tool --- bin/compile.py | 79 ++++++++++++++++++++---------- bin/{npbackup_cli => npbackup-cli} | 0 bin/{npbackup_gui => npbackup-gui} | 0 3 files changed, 52 insertions(+), 27 deletions(-) rename bin/{npbackup_cli => npbackup-cli} (100%) rename bin/{npbackup_gui => npbackup-gui} (100%) diff --git a/bin/compile.py b/bin/compile.py index 1814e7f..bed246c 100644 --- a/bin/compile.py +++ b/bin/compile.py @@ -7,8 +7,8 @@ __author__ = "Orsiris de Jong" __copyright__ = "Copyright (C) 2023 NetInvent" __license__ = "GPL-3.0-only" -__build__ = "2023061101" -__version__ = "1.8.3" +__build__ = "2023090101" +__version__ = "1.9.0" """ @@ -29,6 +29,7 @@ from ofunctions.platform import python_arch, get_os AUDIENCES = ["public", "private"] +BUILD_TYPES = ["cli", "gui"] # Insert parent dir as path se we get to use npbackup as package sys.path.insert(0, os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))) @@ -173,19 +174,27 @@ def have_nuitka_commercial(): return False -def compile(arch, audience, no_gui=False): +def compile(arch: str, audience: str, build_type: str): + if build_type not in BUILD_TYPES: + print("CANNOT BUILD BOGUS BUILD TYPE") + sys.exit(1) + source_program = "bin/npbackup-{}".format(build_type) + suffix = "-{}-{}".format(build_type, arch) + + if audience == "private": + suffix += "-PRIV" if os.name == "nt": - program_executable = "npbackup.exe" + program_executable = "npbackup{}.exe".format(suffix) restic_executable = "restic.exe" platform = "windows" elif sys.platform.lower() == "darwin": platform = "darwin" - program_executable = "npbackup" + program_executable = "npbackup-{}{}".format(platform, suffix) restic_executable = "restic" else: - program_executable = "npbackup" - restic_executable = "restic" platform = "linux" + program_executable = "npbackup-{}{}".format(platform, suffix) + restic_executable = "restic" PACKAGE_DIR = "npbackup" @@ -249,11 +258,11 @@ def compile(arch, audience, no_gui=False): if "arm" in arch: NUITKA_OPTIONS += " --onefile-tempdir-spec=/var/tmp" - if no_gui: - NUITKA_OPTIONS += " --plugin-disable=tk-inter --nofollow-import-to=PySimpleGUI --nofollow-import-to=_tkinter --nofollow-import-to=npbackup.gui" + if build_type == "gui": + NUITKA_OPTIONS += " --plugin-enable=tk-inter --disable-console" else: - NUITKA_OPTIONS += " --plugin-enable=tk-inter" - + NUITKA_OPTIONS += " --plugin-disable=tk-inter --nofollow-import-to=PySimpleGUI --nofollow-import-to=_tkinter --nofollow-import-to=npbackup.gui" + if os.name != "nt": NUITKA_OPTIONS += " --nofollow-import-to=npbackup.windows" @@ -267,7 +276,7 @@ def compile(arch, audience, no_gui=False): TRADEMARKS, ) - CMD = '{} -m nuitka --python-flag=no_docstrings --python-flag=-O {} {} --onefile --include-data-dir="{}"="{}" --include-data-file="{}"="{}" --include-data-file="{}"="{}" --windows-icon-from-ico="{}" --output-dir="{}" bin/npbackup'.format( + CMD = '{} -m nuitka --python-flag=no_docstrings --python-flag=-O {} {} --onefile --include-data-dir="{}"="{}" --include-data-file="{}"="{}" --include-data-file="{}"="{}" --windows-icon-from-ico="{}" --output-dir="{}" --output-filename="{}" {}'.format( PYTHON_EXECUTABLE, NUITKA_OPTIONS, EXE_OPTIONS, @@ -279,6 +288,8 @@ def compile(arch, audience, no_gui=False): restic_dest_file, icon_file, OUTPUT_DIR, + program_executable, + source_program ) print(CMD) @@ -351,7 +362,12 @@ def __call__(self, parser, namespace, values, option_string=None): ) parser.add_argument( - "--no-gui", action="store_true", default=False, help="Don't compile GUI support" + "--build-type", + type=str, + dest="build_type", + default=None, + required=False, + help="Build CLI or GUI target" ) args = parser.parse_args() @@ -366,7 +382,15 @@ def __call__(self, parser, namespace, values, option_string=None): if args.audience.lower() == "all": audiences = AUDIENCES else: - audiences = [args.audience] + audiences = [args.audience.lower()] + + if args.build_type: + if args.build_type.lower() not in BUILD_TYPES: + build_types = BUILD_TYPES + else: + build_types = [args.build_type.lower()] + else: + build_types = BUILD_TYPES for audience in audiences: move_audience_files(audience) @@ -386,21 +410,22 @@ def __call__(self, parser, namespace, values, option_string=None): print("ERROR: Requested private build but no private data available") errors = True continue - result = compile(arch=python_arch(), audience=audience, no_gui=args.no_gui) - build_type = "private" if private_build else "public" - if result: - print( - "SUCCESS: MADE {} build for audience {}".format( - build_type, audience + for build_type in build_types: + result = compile(arch=python_arch(), audience=audience, build_type=build_type) + audience_build = "private" if private_build else "public" + if result: + print( + "SUCCESS: MADE {} build for audience {}".format( + audience_build, audience + ) ) - ) - else: - print( - "ERROR: Failed making {} build for audience {}".format( - build_type, audience + else: + print( + "ERROR: Failed making {} build for audience {}".format( + audience_build, audience + ) ) - ) - errors = True + errors = True if errors: print("ERRORS IN BUILD PROCESS") else: diff --git a/bin/npbackup_cli b/bin/npbackup-cli similarity index 100% rename from bin/npbackup_cli rename to bin/npbackup-cli diff --git a/bin/npbackup_gui b/bin/npbackup-gui similarity index 100% rename from bin/npbackup_gui rename to bin/npbackup-gui From 1ddf96d3d1ff8877c58f48ee58d20b4b17f8b611 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Fri, 1 Sep 2023 13:45:16 +0200 Subject: [PATCH 009/328] Improve table cosmetic --- npbackup/gui/main.py | 8 ++++++-- npbackup/gui/operations.py | 15 +++++++++++---- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/npbackup/gui/main.py b/npbackup/gui/main.py index 9ca4005..519ef4a 100644 --- a/npbackup/gui/main.py +++ b/npbackup/gui/main.py @@ -512,7 +512,7 @@ def main_gui(config_dict: dict, config_file: str, version_string: str): backend_type, repo_uri = get_anon_repo_uri(config_dict["repo"]["repository"]) right_click_menu = ["", [_t("generic.destination")]] - headings = ['ID', 'Date', 'Hostname', 'User', 'Tags'] + headings = ['ID ', 'Date ', 'Hostname ', 'User ', 'Tags '] layout = [ [ @@ -546,7 +546,7 @@ def main_gui(config_dict: dict, config_file: str, version_string: str): ) ) ], - [sg.Table(values=[[]], headings=headings, auto_size_columns=False, key="snapshot-list", select_mode='extended')], + [sg.Table(values=[[]], headings=headings, auto_size_columns=True, justification='left', key="snapshot-list", select_mode='extended')], [ sg.Button(_t("main_gui.launch_backup"), key="launch-backup"), sg.Button(_t("main_gui.see_content"), key="see-content"), @@ -578,6 +578,10 @@ def main_gui(config_dict: dict, config_file: str, version_string: str): finalize=True, ) + # Auto reisze table to window size + window['snapshot-list'].expand(True, True) + + window.read(timeout=1) current_state, backup_tz, snapshot_list = get_gui_data(config_dict) _gui_update_state(window, current_state, backup_tz, snapshot_list) diff --git a/npbackup/gui/operations.py b/npbackup/gui/operations.py index 1b6b442..b12f8f6 100644 --- a/npbackup/gui/operations.py +++ b/npbackup/gui/operations.py @@ -48,14 +48,14 @@ def gui_update_state(window, config_dict: dict) -> list: for repo_name in config_dict['repos']: if config_dict['repos'][repo_name]['repository'] and config_dict['repos'][repo_name]['password']: backend_type, repo_uri = get_anon_repo_uri(config_dict['repos'][repo_name]['repository']) - repo_list.append("[{}] {}".format(backend_type, repo_uri)) + repo_list.append([backend_type, repo_uri]) else: logger.warning("Incomplete operations repo {}".format(repo_name)) except KeyError: logger.info("No operations repos configured") if config_dict['repo']['repository'] and config_dict['repo']['password']: backend_type, repo_uri = get_anon_repo_uri(config_dict['repo']['repository']) - repo_list.append("[{}] {}".format(backend_type, reporepo_uri)) + repo_list.append("[{}] {}".format(backend_type, repo_uri)) window['repo-list'].update(repo_list) return repo_list @@ -63,6 +63,10 @@ def operations_gui(config_dict: dict, config_file: str) -> dict: """ Operate on one or multiple repositories """ + + # This is a stupid hack to make sure uri column is large enough + headings = ['Backend', 'URI '] + layout = [ [ sg.Column( @@ -83,7 +87,7 @@ def operations_gui(config_dict: dict, config_file: str) -> dict: [ sg.Text(_t("operations_gui.configured_repositories")) ], - [sg.Listbox(values=[], key="repo-list", size=(80, 15))], + [sg.Table(values=[[]], headings=headings, key="repo-list", auto_size_columns=True, justification='left')], [sg.Button(_t("operations_gui.add_repo"), key="add-repo"), sg.Button(_t("operations_gui.edit_repo"), key="edit-repo"), sg.Button(_t("operations_gui.remove_repo"), key="remove-repo")], [sg.Button(_t("operations_gui.quick_check"), key="quick-check"), sg.Button(_t("operations_gui.full_check"), key="full-check")], [sg.Button(_t("operations_gui.forget_using_retention_policy"), key="forget")], @@ -112,7 +116,10 @@ def operations_gui(config_dict: dict, config_file: str) -> dict: ) full_repo_list = gui_update_state(window, config_dict) - window.Element('repo-list').Widget.config(selectmode = sg.LISTBOX_SELECT_MODE_EXTENDED) + + # Auto reisze table to window size + window['repo-list'].expand(True, True) + while True: event, values = window.read(timeout=60000) From 2c07a6b2e49b483fb29bf6a71c4d801fb9d57c0a Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Fri, 1 Sep 2023 16:52:18 +0200 Subject: [PATCH 010/328] GUI rework --- npbackup/__main__.py | 32 ++++++++++++++------------------ 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/npbackup/__main__.py b/npbackup/__main__.py index bae4659..c4231d8 100644 --- a/npbackup/__main__.py +++ b/npbackup/__main__.py @@ -58,7 +58,6 @@ from npbackup.gui.config import config_gui from npbackup.gui.operations import operations_gui from npbackup.gui.main import main_gui - from npbackup.gui.minimize_window import minimize_current_window sg.theme(PYSIMPLEGUI_THEME) sg.SetOptions(icon=OEM_ICON) @@ -153,18 +152,19 @@ def interface(): help="Path to alternative configuration file", ) - parser.add_argument( - "--config-gui", - action="store_true", - default=False, - help="Show configuration GUI", - ) + if Globvars.GUI: + parser.add_argument( + "--config-gui", + action="store_true", + default=False, + help="Show configuration GUI", + ) - parser.add_argument( - "--operations-gui", - action="store_true", - help="Show operations GUI" - ) + parser.add_argument( + "--operations-gui", + action="store_true", + help="Show operations GUI" + ) parser.add_argument( "-l", "--list", action="store_true", help="Show current snapshots" @@ -295,7 +295,7 @@ def interface(): CONFIG_FILE = args.config_file # Program entry - if args.config_gui or args.operations_gui: + if Globvars.GUI and (args.config_gui or args.operations_gui): try: config_dict = configuration.load_config(CONFIG_FILE) if not config_dict: @@ -339,7 +339,6 @@ def interface(): config_dict = configuration.empty_config_dict # If no arguments are passed, assume we are launching the GUI if len(sys.argv) == 1: - minimize_current_window() try: result = sg.Popup( "{}\n\n{}".format(message, _t("config_gui.create_new_config")), @@ -359,6 +358,7 @@ def interface(): parser.print_help(sys.stderr) sys.exit(1) sys.exit(7) + elif not config_dict: if len(sys.argv) == 1 and Globvars.GUI: sg.Popup(_t("config_gui.bogus_config_file", config_file=CONFIG_FILE)) @@ -496,10 +496,6 @@ def interface(): sys.exit(21) if Globvars.GUI: - # When no argument is given, let's run the GUI - # Also, let's minimize the commandline window so the GUI user isn't distracted - minimize_current_window() - logger.info("Running GUI") try: with pidfile.PIDFile(PID_FILE): try: From 2107fa93f32653c6099e9430a306299912b48564 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Fri, 1 Sep 2023 16:53:13 +0200 Subject: [PATCH 011/328] Add hide_executor property --- CHANGELOG | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG b/CHANGELOG index 76f1678..3bd880a 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -14,13 +14,18 @@ - manages multiple repos with generic key (restic key add) or specified key ## 2.3.0 - XX + !- Multi repository support (everything except repo URI can be inherited from groups) + !- Group settings for repositories !- New operation planifier for backups / cleaning / checking repos - Current backup state now shows more precise backup state, including last backup date when relevant !- Implemented retention policies ! - Optional time server update to make sure we don't drift before doing retention operations ! - Optional repo check befoire doing retention operations !- Implemented repo check / repair actions + !- --check --repair + - Split npbackup into CLI and GUI so GUI doesn't have a console window anymore - Added snapshot tag to snapshot list on main window + - Show anonymized repo uri in GUI - Fix deletion failed message for en lang - Fix Google cloud storage backend detection in repository uri From 2efc2a972baebf07cbb6a4563e663e74a8861078 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Fri, 1 Sep 2023 18:09:17 +0200 Subject: [PATCH 012/328] Reformat files with black --- npbackup/__main__.py | 4 +- npbackup/core/runner.py | 22 ++-- npbackup/globvars.py | 2 +- npbackup/gui/config.py | 8 +- npbackup/gui/helpers.py | 22 ++-- npbackup/gui/main.py | 52 ++++++--- npbackup/gui/operations.py | 161 +++++++++++++++++----------- npbackup/restic_wrapper/__init__.py | 37 ++++--- 8 files changed, 191 insertions(+), 117 deletions(-) diff --git a/npbackup/__main__.py b/npbackup/__main__.py index c4231d8..3290429 100644 --- a/npbackup/__main__.py +++ b/npbackup/__main__.py @@ -161,9 +161,7 @@ def interface(): ) parser.add_argument( - "--operations-gui", - action="store_true", - help="Show operations GUI" + "--operations-gui", action="store_true", help="Show operations GUI" ) parser.add_argument( diff --git a/npbackup/core/runner.py b/npbackup/core/runner.py index 6e10cbf..c3e1129 100644 --- a/npbackup/core/runner.py +++ b/npbackup/core/runner.py @@ -445,11 +445,13 @@ def check_recent_backups(self) -> bool: ) ) self.restic_runner.verbose = False - result, backup_tz = self.restic_runner.has_snapshot_timedelta(self.minimum_backup_age) + result, backup_tz = self.restic_runner.has_snapshot_timedelta( + self.minimum_backup_age + ) self.restic_runner.verbose = self.verbose if result: logger.info("Most recent backup is from {}".format(backup_tz)) - elif result is False and backup_tz == datetime(1,1,1,0,0): + elif result is False and backup_tz == datetime(1, 1, 1, 0, 0): logger.info("No snapshots found in repo.") elif result is False: logger.info("No recent backup found. Newest is from {}".format(backup_tz)) @@ -661,9 +663,9 @@ def forget(self, snapshot: str) -> bool: if not self.is_ready: return False logger.info("Forgetting snapshot {}".format(snapshot)) - result= self.restic_runner.forget(snapshot) + result = self.restic_runner.forget(snapshot) return result - + @exec_timer def check(self, read_data: bool = True) -> bool: if not self.is_ready: @@ -671,7 +673,7 @@ def check(self, read_data: bool = True) -> bool: logger.info("Checking repository") result = self.restic_runner.check(read_data) return result - + @exec_timer def prune(self) -> bool: if not self.is_ready: @@ -679,7 +681,7 @@ def prune(self) -> bool: logger.info("Pruning snapshots") result = self.restic_runner.prune() return result - + @exec_timer def repair(self, order: str) -> bool: if not self.is_ready: @@ -687,13 +689,15 @@ def repair(self, order: str) -> bool: logger.info("Repairing {} in repo".format(order)) result = self.restic_runner.repair(order) return result - + @exec_timer def raw(self, command: str) -> bool: logger.info("Running raw command: {}".format(command)) result = self.restic_runner.raw(command=command) return result - def group_runner(self, operations_config: dict, result_queue: Optional[queue.Queue]) -> bool: + def group_runner( + self, operations_config: dict, result_queue: Optional[queue.Queue] + ) -> bool: print(operations_config) - print('run to the hills') + print("run to the hills") diff --git a/npbackup/globvars.py b/npbackup/globvars.py index fe6deae..1f14d49 100644 --- a/npbackup/globvars.py +++ b/npbackup/globvars.py @@ -11,4 +11,4 @@ class Globvars(object): - GUI = None \ No newline at end of file + GUI = None diff --git a/npbackup/gui/config.py b/npbackup/gui/config.py index 63408ce..502cede 100644 --- a/npbackup/gui/config.py +++ b/npbackup/gui/config.py @@ -327,12 +327,12 @@ def update_config_dict(values, config_dict): [sg.Text(_t("config_gui.retention_policy"))], [ sg.Text(_t("config_gui.custom_time_server_url"), size=(40, 1)), - sg.Input(key="retentionpolicy---custom_time_server", size=(50 ,1)), + sg.Input(key="retentionpolicy---custom_time_server", size=(50, 1)), ], [ sg.Text(_t("config_gui.keep"), size=(40, 1)), sg.Text(_t("config_gui.hourly"), size=(10, 1)), - ] + ], ] identity_col = [ @@ -500,7 +500,7 @@ def update_config_dict(values, config_dict): retention_col, font="helvetica 16", key="--tab-retentino--", - element_justification='L', + element_justification="L", ) ], [ @@ -558,7 +558,7 @@ def update_config_dict(values, config_dict): window = sg.Window( "Configuration", layout, - size=(800,600), + size=(800, 600), text_justification="C", auto_size_text=True, auto_size_buttons=False, diff --git a/npbackup/gui/helpers.py b/npbackup/gui/helpers.py index b758937..5a01906 100644 --- a/npbackup/gui/helpers.py +++ b/npbackup/gui/helpers.py @@ -20,21 +20,23 @@ def get_anon_repo_uri(repository: str) -> Tuple[str, str]: """ backend_type = repository.split(":")[0].upper() if backend_type.upper() in ["REST", "SFTP"]: - res = re.match(r"(sftp|rest)(.*:\/\/)(.*):?(.*)@(.*)", repository, re.IGNORECASE) + res = re.match( + r"(sftp|rest)(.*:\/\/)(.*):?(.*)@(.*)", repository, re.IGNORECASE + ) if res: backend_uri = res.group(1) + res.group(2) + res.group(5) else: backend_uri = repository elif backend_type.upper() in [ - "S3", - "B2", - "SWIFT", - "AZURE", - "GS", - "RCLONE", - ]: + "S3", + "B2", + "SWIFT", + "AZURE", + "GS", + "RCLONE", + ]: backend_uri = repository else: - backend_type = 'LOCAL' + backend_type = "LOCAL" backend_uri = repository - return backend_type, backend_uri \ No newline at end of file + return backend_type, backend_uri diff --git a/npbackup/gui/main.py b/npbackup/gui/main.py index 519ef4a..3018299 100644 --- a/npbackup/gui/main.py +++ b/npbackup/gui/main.py @@ -127,10 +127,15 @@ def _get_gui_data(config_dict: dict) -> Future: snapshot_tags = " [TAGS: {}]".format(snapshot["tags"]) except KeyError: snapshot_tags = "" - snapshot_list.append([ - snapshot_id, snapshot_date, snapshot_hostname, snapshot_username, snapshot_tags - ]) - + snapshot_list.append( + [ + snapshot_id, + snapshot_date, + snapshot_hostname, + snapshot_username, + snapshot_tags, + ] + ) return current_state, backup_tz, snapshot_list @@ -172,18 +177,22 @@ def get_gui_data(config_dict: dict) -> Tuple[bool, List[str]]: return thread.result() -def _gui_update_state(window, current_state: bool, backup_tz: Optional[datetime], snapshot_list: List[str]) -> None: +def _gui_update_state( + window, current_state: bool, backup_tz: Optional[datetime], snapshot_list: List[str] +) -> None: if current_state: - window["state-button"].Update("{}: {}".format( - _t("generic.up_to_date"), backup_tz), button_color=GUI_STATE_OK_BUTTON + window["state-button"].Update( + "{}: {}".format(_t("generic.up_to_date"), backup_tz), + button_color=GUI_STATE_OK_BUTTON, ) - elif current_state is False and backup_tz == datetime(1,1,1,0,0): + elif current_state is False and backup_tz == datetime(1, 1, 1, 0, 0): window["state-button"].Update( _t("generic.no_snapshots"), button_color=GUI_STATE_OLD_BUTTON ) elif current_state is False: - window["state-button"].Update("{}: {}".format( - _t("generic.too_old"), backup_tz), button_color=GUI_STATE_OLD_BUTTON + window["state-button"].Update( + "{}: {}".format(_t("generic.too_old"), backup_tz), + button_color=GUI_STATE_OLD_BUTTON, ) elif current_state is None: window["state-button"].Update( @@ -192,6 +201,7 @@ def _gui_update_state(window, current_state: bool, backup_tz: Optional[datetime] window["snapshot-list"].Update(snapshot_list) + @threaded def _make_treedata_from_json(ls_result: List[dict]) -> sg.TreeData: """ @@ -512,7 +522,13 @@ def main_gui(config_dict: dict, config_file: str, version_string: str): backend_type, repo_uri = get_anon_repo_uri(config_dict["repo"]["repository"]) right_click_menu = ["", [_t("generic.destination")]] - headings = ['ID ', 'Date ', 'Hostname ', 'User ', 'Tags '] + headings = [ + "ID ", + "Date ", + "Hostname ", + "User ", + "Tags ", + ] layout = [ [ @@ -546,7 +562,16 @@ def main_gui(config_dict: dict, config_file: str, version_string: str): ) ) ], - [sg.Table(values=[[]], headings=headings, auto_size_columns=True, justification='left', key="snapshot-list", select_mode='extended')], + [ + sg.Table( + values=[[]], + headings=headings, + auto_size_columns=True, + justification="left", + key="snapshot-list", + select_mode="extended", + ) + ], [ sg.Button(_t("main_gui.launch_backup"), key="launch-backup"), sg.Button(_t("main_gui.see_content"), key="see-content"), @@ -579,8 +604,7 @@ def main_gui(config_dict: dict, config_file: str, version_string: str): ) # Auto reisze table to window size - window['snapshot-list'].expand(True, True) - + window["snapshot-list"].expand(True, True) window.read(timeout=1) current_state, backup_tz, snapshot_list = get_gui_data(config_dict) diff --git a/npbackup/gui/operations.py b/npbackup/gui/operations.py index b12f8f6..a6a254c 100644 --- a/npbackup/gui/operations.py +++ b/npbackup/gui/operations.py @@ -45,106 +45,147 @@ def add_repo(config_dict: dict) -> dict: def gui_update_state(window, config_dict: dict) -> list: repo_list = [] try: - for repo_name in config_dict['repos']: - if config_dict['repos'][repo_name]['repository'] and config_dict['repos'][repo_name]['password']: - backend_type, repo_uri = get_anon_repo_uri(config_dict['repos'][repo_name]['repository']) + for repo_name in config_dict["repos"]: + if ( + config_dict["repos"][repo_name]["repository"] + and config_dict["repos"][repo_name]["password"] + ): + backend_type, repo_uri = get_anon_repo_uri( + config_dict["repos"][repo_name]["repository"] + ) repo_list.append([backend_type, repo_uri]) else: logger.warning("Incomplete operations repo {}".format(repo_name)) except KeyError: logger.info("No operations repos configured") - if config_dict['repo']['repository'] and config_dict['repo']['password']: - backend_type, repo_uri = get_anon_repo_uri(config_dict['repo']['repository']) + if config_dict["repo"]["repository"] and config_dict["repo"]["password"]: + backend_type, repo_uri = get_anon_repo_uri(config_dict["repo"]["repository"]) repo_list.append("[{}] {}".format(backend_type, repo_uri)) - window['repo-list'].update(repo_list) + window["repo-list"].update(repo_list) return repo_list + def operations_gui(config_dict: dict, config_file: str) -> dict: """ Operate on one or multiple repositories """ # This is a stupid hack to make sure uri column is large enough - headings = ['Backend', 'URI '] + headings = ["Backend", "URI "] layout = [ - [ - sg.Column( - [ - [ - sg.Column( - [[sg.Image(data=OEM_LOGO, size=(64, 64))]], vertical_alignment="top" - ), - sg.Column( - [ - [sg.Text(OEM_STRING, font="Arial 14")], - ], - justification="C", - element_justification="C", - vertical_alignment="top", - ), - ], + [ + sg.Column( [ - sg.Text(_t("operations_gui.configured_repositories")) + [ + sg.Column( + [[sg.Image(data=OEM_LOGO, size=(64, 64))]], + vertical_alignment="top", + ), + sg.Column( + [ + [sg.Text(OEM_STRING, font="Arial 14")], + ], + justification="C", + element_justification="C", + vertical_alignment="top", + ), + ], + [sg.Text(_t("operations_gui.configured_repositories"))], + [ + sg.Table( + values=[[]], + headings=headings, + key="repo-list", + auto_size_columns=True, + justification="left", + ) + ], + [ + sg.Button(_t("operations_gui.add_repo"), key="add-repo"), + sg.Button(_t("operations_gui.edit_repo"), key="edit-repo"), + sg.Button(_t("operations_gui.remove_repo"), key="remove-repo"), + ], + [ + sg.Button(_t("operations_gui.quick_check"), key="quick-check"), + sg.Button(_t("operations_gui.full_check"), key="full-check"), + ], + [ + sg.Button( + _t("operations_gui.forget_using_retention_policy"), + key="forget", + ) + ], + [ + sg.Button( + _t("operations_gui.standard_prune"), key="standard-prune" + ), + sg.Button(_t("operations_gui.max_prune"), key="max-prune"), + ], + [sg.Button(_t("generic.quit"), key="exit")], ], - [sg.Table(values=[[]], headings=headings, key="repo-list", auto_size_columns=True, justification='left')], - [sg.Button(_t("operations_gui.add_repo"), key="add-repo"), sg.Button(_t("operations_gui.edit_repo"), key="edit-repo"), sg.Button(_t("operations_gui.remove_repo"), key="remove-repo")], - [sg.Button(_t("operations_gui.quick_check"), key="quick-check"), sg.Button(_t("operations_gui.full_check"), key="full-check")], - [sg.Button(_t("operations_gui.forget_using_retention_policy"), key="forget")], - [sg.Button(_t("operations_gui.standard_prune"), key="standard-prune"), sg.Button(_t("operations_gui.max_prune"), key="max-prune")], - [sg.Button(_t("generic.quit"), key="exit")], - ], - element_justification="C", - ) + element_justification="C", + ) + ] ] -] - window = sg.Window( - "Configuration", - layout, - size=(600,600), - text_justification="C", - auto_size_text=True, - auto_size_buttons=True, - no_titlebar=False, - grab_anywhere=True, - keep_on_top=False, - alpha_channel=1.0, - default_button_element_size=(12, 1), - finalize=True, + "Configuration", + layout, + size=(600, 600), + text_justification="C", + auto_size_text=True, + auto_size_buttons=True, + no_titlebar=False, + grab_anywhere=True, + keep_on_top=False, + alpha_channel=1.0, + default_button_element_size=(12, 1), + finalize=True, ) full_repo_list = gui_update_state(window, config_dict) # Auto reisze table to window size - window['repo-list'].expand(True, True) + window["repo-list"].expand(True, True) while True: event, values = window.read(timeout=60000) if event in (sg.WIN_CLOSED, "exit"): break - if event == 'add-repo': + if event == "add-repo": pass - if event in ['add-repo', 'remove-repo']: + if event in ["add-repo", "remove-repo"]: if not values["repo-list"]: sg.Popup(_t("main_gui.select_backup"), keep_on_top=True) continue - if event == 'add-repo': + if event == "add-repo": config_dict = add_repo(config_dict) # Save to config here #TODO #WIP - event == 'state-update' - elif event == 'remove-repo': - result = sg.popup(_t("generic.are_you_sure"), custom_text = (_t("generic.yes"), _t("generic.no"))) + event == "state-update" + elif event == "remove-repo": + result = sg.popup( + _t("generic.are_you_sure"), + custom_text=(_t("generic.yes"), _t("generic.no")), + ) if result == _t("generic.yes"): # Save to config here #TODO #WIP - event == 'state-update' - if event == 'forget': + event == "state-update" + if event == "forget": pass - if event in ['forget', 'quick-check', 'full-check', 'standard-prune', 'max-prune']: + if event in [ + "forget", + "quick-check", + "full-check", + "standard-prune", + "max-prune", + ]: if not values["repo-list"]: - result = sg.popup(_t("operations_gui.apply_to_all"), custom_text = (_t("generic.yes"), _t("generic.no"))) + result = sg.popup( + _t("operations_gui.apply_to_all"), + custom_text=(_t("generic.yes"), _t("generic.no")), + ) if not result == _t("generic.yes"): continue repos = full_repo_list @@ -153,8 +194,8 @@ def operations_gui(config_dict: dict, config_file: str) -> dict: result_queue = queue.Queue() runner = NPBackupRunner() runner.group_runner(repos, result_queue) - if event == 'state-update': + if event == "state-update": full_repo_list = gui_update_state(window, config_dict) window.close() - return config_dict \ No newline at end of file + return config_dict diff --git a/npbackup/restic_wrapper/__init__.py b/npbackup/restic_wrapper/__init__.py index 13805c4..6b3227d 100644 --- a/npbackup/restic_wrapper/__init__.py +++ b/npbackup/restic_wrapper/__init__.py @@ -577,9 +577,11 @@ def backup( for exclude_file in exclude_files: if exclude_file: if os.path.isfile(exclude_file): - cmd += ' --{}exclude-file "{}"'.format(case_ignore_param, exclude_file) + cmd += ' --{}exclude-file "{}"'.format( + case_ignore_param, exclude_file + ) else: - logger.error("Exclude file \"{}\" not found".format(exclude_file)) + logger.error('Exclude file "{}" not found'.format(exclude_file)) if exclude_caches: cmd += " --exclude-caches" if one_file_system: @@ -653,7 +655,9 @@ def restore(self, snapshot: str, target: str, includes: List[str] = None): logger.critical("Data not restored: {}".format(output)) return False - def forget(self, snapshot: Optional[str] = None, policy: Optional[dict] = None) -> bool: + def forget( + self, snapshot: Optional[str] = None, policy: Optional[dict] = None + ) -> bool: """ Execute forget command for given snapshot """ @@ -662,11 +666,11 @@ def forget(self, snapshot: Optional[str] = None, policy: Optional[dict] = None) if not snapshot and not policy: logger.error("No valid snapshot or policy defined for pruning") return False - + if snapshot: cmd = "forget {}".format(snapshot) if policy: - cmd = "format {}".format(policy) # TODO # WIP + cmd = "format {}".format(policy) # TODO # WIP # We need to be verbose here since server errors will not stop client from deletion attempts verbose = self.verbose self.verbose = True @@ -677,7 +681,7 @@ def forget(self, snapshot: Optional[str] = None, policy: Optional[dict] = None) return True logger.critical("Forget failed:\n{}".format(output)) return False - + def prune(self) -> bool: """ Prune forgotten snapshots @@ -694,38 +698,37 @@ def prune(self) -> bool: return True logger.critical("Could not prune repository:\n{}".format(output)) return False - + def check(self, read_data: bool = True) -> bool: """ Check current repo status """ if not self.is_init: return None - cmd = "check{}".format(' --read-data' if read_data else '') - result, output = self.executor(cmd) + cmd = "check{}".format(" --read-data" if read_data else "") + result, output = self.executor(cmd) if result: logger.info("Repo checked successfully.") return True logger.critical("Repo check failed:\n {}".format(output)) return False - + def repair(self, order: str) -> bool: """ Check current repo status """ if not self.is_init: return None - if order not in ['index', 'snapshots']: + if order not in ["index", "snapshots"]: logger.error("Bogus repair order given: {}".format(order)) cmd = "repair {}".format(order) - result, output = self.executor(cmd) + result, output = self.executor(cmd) if result: logger.info("Repo successfully repaired:\n{}".format(output)) return True logger.critical("Repo repair failed:\n {}".format(output)) return False - def raw(self, command: str) -> Tuple[bool, str]: """ Execute plain restic command without any interpretation" @@ -739,12 +742,14 @@ def raw(self, command: str) -> Tuple[bool, str]: logger.critical("Raw command failed.") return False, output - def has_snapshot_timedelta(self, delta: int = 1441) -> Tuple[bool, Optional[datetime]]: + def has_snapshot_timedelta( + self, delta: int = 1441 + ) -> Tuple[bool, Optional[datetime]]: """ Checks if a snapshot exists that is newer that delta minutes Eg: if delta = -60 we expect a snapshot newer than an hour ago, and return True if exists if delta = +60 we expect a snpashot newer than one hour in future (!) - + returns True, datetime if exists returns False, datetime if exists but too old returns False, datetime = 0001-01-01T00:00:00 if no snapshots found @@ -757,7 +762,7 @@ def has_snapshot_timedelta(self, delta: int = 1441) -> Tuple[bool, Optional[date if self.last_command_status is False: return None, None if not snapshots: - return False, datetime(1,1,1,0,0) + return False, datetime(1, 1, 1, 0, 0) tz_aware_timestamp = datetime.now(timezone.utc).astimezone() # Begin with most recent snapshot From 658a0327f6fdd7409d2b32ed1a25979691d2cb55 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Sun, 10 Dec 2023 22:55:55 +0100 Subject: [PATCH 013/328] Refactor configuration loader Add repo group config inheritance too --- npbackup/configuration.py | 474 +++++++++++++++++++------------------- 1 file changed, 242 insertions(+), 232 deletions(-) diff --git a/npbackup/configuration.py b/npbackup/configuration.py index 252470d..c9a43d1 100644 --- a/npbackup/configuration.py +++ b/npbackup/configuration.py @@ -7,27 +7,30 @@ __author__ = "Orsiris de Jong" __copyright__ = "Copyright (C) 2022-2023 NetInvent" __license__ = "GPL-3.0-only" -__build__ = "2023052801" -__version__ = "1.7.1 for npbackup 2.2.0+" +__build__ = "2023121001" +__version__ = "2.0.0 for npbackup 2.3.0+" -from typing import Tuple, Optional, List +from typing import Tuple, Optional, List, Callable, Any import sys import os +from copy import deepcopy +from pathlib import Path from ruamel.yaml import YAML +from ruamel.yaml.compat import ordereddict from logging import getLogger import re import platform from cryptidy import symmetric_encryption as enc from ofunctions.random import random_string +from ofunctions.misc import deep_dict_update from npbackup.customization import ID_STRING -from npbackup.core.nuitka_helper import IS_COMPILED sys.path.insert(0, os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))) # Try to import a private key, if not available, fallback to the default key try: - from PRIVATE._private_secret_keys import AES_KEY, DEFAULT_BACKUP_ADMIN_PASSWORD + from PRIVATE._private_secret_keys import AES_KEY from PRIVATE._private_obfuscation import obfuscation AES_KEY = obfuscation(AES_KEY) @@ -40,7 +43,7 @@ EARLIER_AES_KEY = None except ImportError: try: - from npbackup.secret_keys import AES_KEY, DEFAULT_BACKUP_ADMIN_PASSWORD + from npbackup.secret_keys import AES_KEY IS_PRIV_BUILD = False try: @@ -54,42 +57,84 @@ logger = getLogger(__name__) + +# Monkeypatching ruamel.yaml ordreddict so we get to use pseudo dot notations +# eg data.g('my.array.keys') == data['my']['array']['keys'] +# and data.s('my.array.keys', 'new_value') +def g(self, path, sep='.', default=None, list_ok=False): + """ + Getter for dot notation in an a dict/OrderedDict + print(d.g('my.array.keys')) + """ + return self.mlget(path.split(sep), default=default, list_ok=list_ok) + +def s(self, path, value, sep='.'): + """ + Setter for dot notation in a dict/OrderedDict + d.s('my.array.keys', 'new_value') + """ + data = self + keys = path.split(sep) + lastkey = keys[-1] + for key in keys[:-1]: + data = data[key] + data[lastkey] = value + +ordereddict.g = g +ordereddict.s = s + # NPF-SEC-00003: Avoid password command divulgation ENCRYPTED_OPTIONS = [ - {"section": "repo", "name": "repository", "type": str}, - {"section": "repo", "name": "password", "type": str}, - {"section": "repo", "name": "password_command", "type": str}, - {"section": "prometheus", "name": "http_username", "type": str}, - {"section": "prometheus", "name": "http_password", "type": str}, - {"section": "env", "name": "encrypted_variables", "type": str}, - {"section": "options", "name": "auto_upgrade_server_username", "type": str}, - {"section": "options", "name": "auto_upgrade_server_password", "type": str}, -] - -# By default, backup_admin_password should never be encrypted on the fly, since -# one could simply change it in the config file -ENCRYPTED_OPTIONS_SECURE = [ - {"section": "options", "name": "backup_admin_password", "type": str}, + "repo_uri", "repo_password", "repo_password_command", "http_username", "http_password", "encrypted_variables", + "auto_upgrade_server_username", "auto_upgrade_server_password" ] +# This is what a config file looks like empty_config_dict = { - "backup": { - "compression": "auto", - "use_fs_snapshot": True, - "ignore_cloud_files": True, - "exclude_caches": True, - "exclude_case_ignore": False, - "one_file_system": True, - "priority": "low", + "repos": { + "default": { + "repo_uri": "", + "group": "default_group", + "backup_opts": {}, + "repo_opts": {}, + "prometheus": {}, + "env": {} + }, }, - "repo": { - "repository": "", - "password": "", - "password_command": "", - "minimum_backup_age": 1440, - "upload_speed": 0, - "download_speed": 0, - "backend_connections": 0, + "groups": { + "default_group": { + "backup_opts": { + "compression": "auto", + "use_fs_snapshot": True, + "ignore_cloud_files": True, + "exclude_caches": True, + "exclude_case_ignore": False, + "one_file_system": True, + "priority": "low" + }, + "repo_opts": { + "repo_password": "", + "repo_password_command": "", + # Minimum time between two backups, in minutes + # Set to zero in order to disable time checks + "minimum_backup_age": 1440, + "upload_speed": 1000000, # in KiB, use 0 for unlimited upload speed + "download_speed": 0, # in KiB, use 0 for unlimited download speed + "backend_connections": 0, # Fine tune simultaneous connections to backend, use 0 for standard configuration + "retention_strategy": { + "hourly": 72, + "daily": 30, + "weekly": 4, + "monthly": 12, + "yearly": 3 + } + }, + "prometheus": { + "backup_job": "${MACHINE_ID}", + "group": "${MACHINE_GROUP}", + }, + "env": {} + }, }, "identity": { "machine_id": "${HOSTNAME}__${RANDOM}[4]", @@ -98,15 +143,12 @@ "prometheus": { "metrics": False, "instance": "${MACHINE_ID}", - "backup_job": "${MACHINE_ID}", - "group": "${MACHINE_GROUP}", "destination": "", "http_username": "", "http_password": "", "additional_labels": "", }, - "env": {}, - "options": { + "global_options": { "auto_upgrade": True, "auto_upgrade_interval": 10, "auto_upgrade_server_url": "", @@ -114,146 +156,90 @@ "auto_upgrade_server_password": "", "auto_upgrade_host_identity": "${MACHINE_ID}", "auto_upgrade_group": "${MACHINE_GROUP}", + "env": {} }, } -def decrypt_data( - config_dict: dict, - encrypted_options: List[dict], - non_encrypted_data_is_fatal: bool = True, -) -> dict: - if not config_dict: - return None +def iter_over_keys(d: dict, fn: Callable) -> dict: + """ + Execute value=fn(value) on any key in a nested env + """ + for key, value in d.items(): + if isinstance(value, dict): + d[key] = iter_over_keys(value, fn) + else: + d[key] = fn(key, d[key]) + return d + + +def crypt_config(config: dict, aes_key: str, encrypted_options: List[str], operation: str): try: - for option in encrypted_options: - try: - if config_dict[option["section"]][option["name"]] and isinstance( - config_dict[option["section"]][option["name"]], str - ): - if config_dict[option["section"]][option["name"]].startswith( - ID_STRING - ): - ( - _, - config_dict[option["section"]][option["name"]], - ) = enc.decrypt_message_hf( - config_dict[option["section"]][option["name"]], - AES_KEY, - ID_STRING, - ID_STRING, + def _crypt_config(key: str, value: Any) -> Any: + if key in encrypted_options: + if operation == 'encrypt': + if isinstance(value, str) and not value.startswith("__NPBACKUP__") or not isinstance(value, str): + value = enc.encrypt_message_hf( + value, aes_key, ID_STRING, ID_STRING ) - else: - if non_encrypted_data_is_fatal: - logger.critical( - "SECURITY BREACH: Config file was altered in {}:{}".format( - option["section"], option["name"] - ) - ) - sys.exit(99) - except KeyError: - # NPF-SEC-00001 SECURITY-ADMIN-BACKUP-PASSWORD ONLY AVAILABLE ON PRIVATE COMPILED BUILDS - if ( - not option["section"] == "options" - and not option["name"] == "backup_admin_password" - and IS_COMPILED - and IS_PRIV_BUILD - ): - logger.info( - "No {}:{} available.".format(option["section"], option["name"]) - ) - except ValueError as exc: - logger.error( - 'Cannot decrypt this configuration file for option "{}". Has the AES key changed ? {}'.format( - option, exc - ) - ) - logger.debug("Trace:", exc_info=True) - return False - except TypeError: - logger.error( - "Cannot decrypt this configuration file. No base64 encoded strings available." - ) - logger.debug("Trace:", exc_info=True) - logger.critical("Won't run with a non properly encrypted config file.") - sys.exit(98) - return config_dict - - -def encrypt_data(config_dict: dict, encrypted_options: List[dict]) -> dict: - for option in encrypted_options: - try: - if config_dict[option["section"]][option["name"]]: - if not str(config_dict[option["section"]][option["name"]]).startswith( - ID_STRING - ): - config_dict[option["section"]][ - option["name"] - ] = enc.encrypt_message_hf( - config_dict[option["section"]][option["name"]], - AES_KEY, - ID_STRING, - ID_STRING, - ).decode( - "utf-8" - ) - except KeyError: - # NPF-SEC-00001 SECURITY-ADMIN-BACKUP-PASSWORD ONLY AVAILABLE ON PRIVATE COMPILED BUILDS - if ( - not option["section"] == "options" - and not option["name"] == "backup_admin_password" - and IS_COMPILED - and IS_PRIV_BUILD - ): - logger.error( - "No {}:{} available.".format(option["section"], option["name"]) - ) - return config_dict - - -def is_encrypted(config_dict: dict) -> bool: - try: - is_enc = True - for option in ENCRYPTED_OPTIONS: - try: - if config_dict[option["section"]][option["name"]] and not str( - config_dict[option["section"]][option["name"]] - ).startswith(ID_STRING): - is_enc = False - except (TypeError, KeyError): - # Don't care about encryption on missing items - # TypeError happens on empty files - pass - return is_enc - except AttributeError: - # NoneType + elif operation == 'decrypt': + if isinstance(value, str) and value.startswith("__NPBACKUP__"): + value = enc.decrypt_message_hf( + value, + aes_key, + ID_STRING, + ID_STRING, + ) + else: + raise ValueError(f"Bogus operation {operation} given") + return value + + return iter_over_keys(config, _crypt_config) + except Exception as exc: + logger.error(f"Cannot {operation} configuration.") return False -def has_random_variables(config_dict: dict) -> Tuple[bool, dict]: +def is_encrypted(config: dict) -> bool: + is_encrypted = True + + def _is_encrypted(key, value) -> Any: + nonlocal is_encrypted + + if key in ENCRYPTED_OPTIONS: + if isinstance(value, str) and not value.startswith("__NPBACKUP__"): + is_encrypted = True + return value + + iter_over_keys(config, _is_encrypted) + return is_encrypted + + +def has_random_variables(config: dict) -> Tuple[bool, dict]: """ - Replaces ${RANDOM}[n] with n random alphanumeric chars, directly in config_dict + Replaces ${RANDOM}[n] with n random alphanumeric chars, directly in config dict """ is_modified = False - for section in config_dict.keys(): - for entry in config_dict[section].keys(): - if isinstance(config_dict[section][entry], str): - matches = re.search(r"\${RANDOM}\[(.*)\]", config_dict[section][entry]) - if matches: - try: - char_quantity = int(matches.group(1)) - except (ValueError, TypeError): - char_quantity = 1 - config_dict[section][entry] = re.sub( - r"\${RANDOM}\[.*\]", - random_string(char_quantity), - config_dict[section][entry], - ) - is_modified = True - return is_modified, config_dict - - -def evaluate_variables(config_dict: dict, value: str) -> str: + + def _has_random_variables(key, value) -> Any: + nonlocal is_modified + + if isinstance(value, str): + matches = re.search(r"\${RANDOM}\[(.*)\]", value) + if matches: + try: + char_quantity = int(matches.group(1)) + except (ValueError, TypeError): + char_quantity = 1 + value = re.sub(r"\${RANDOM}\[.*\]", random_string(char_quantity), value) + is_modified = True + return value + + config = iter_over_keys(config, _has_random_variables) + return is_modified, config + + +def evaluate_variables(config: dict, value: str) -> str: """ Replaces various variables with their actual value in a string """ @@ -272,21 +258,21 @@ def evaluate_variables(config_dict: dict, value: str) -> str: value = value.replace("${HOSTNAME}", platform.node()) try: - new_value = config_dict["identity"]["machine_id"] + new_value = config["identity"]["machine_id"] # TypeError may happen if config_dict[x][y] is None except (KeyError, TypeError): new_value = None value = value.replace("${MACHINE_ID}", new_value if new_value else "") try: - new_value = config_dict["identity"]["machine_group"] + new_value = config["identity"]["machine_group"] # TypeError may happen if config_dict[x][y] is None except (KeyError, TypeError): new_value = None value = value.replace("${MACHINE_GROUP}", new_value if new_value else "") try: - new_value = config_dict["prometheus"]["backup_job"] + new_value = config["prometheus"]["backup_job"] # TypeError may happen if config_dict[x][y] is None except (KeyError, TypeError): new_value = None @@ -296,89 +282,113 @@ def evaluate_variables(config_dict: dict, value: str) -> str: return value -def load_config(config_file: str) -> Optional[dict]: +def get_repo_config(config: dict, repo_name: str = 'default') -> Tuple[dict, dict]: """ - Using ruamel.yaml preserves comments and order of yaml files + Created inherited repo config + Returns a dict containing the repo config + and a dict containing the repo interitance status """ - global AES_KEY - global EARLIER_AES_KEY + + def _is_inheritance(key, value): + return False + + repo_config = ordereddict() + config_inheritance = ordereddict() try: - logger.debug("Using configuration file {}".format(config_file)) + repo_config = deepcopy(config.g(f'repos.{repo_name}')) + # Let's make a copy of config since it's a "pointer object" + config_inheritance = iter_over_keys(deepcopy(config.g(f'repos.{repo_name}')), _is_inheritance) + except KeyError: + logger.error(f"No repo with name {repo_name} found in config") + return None + try: + repo_group = config.g(f'repos.{repo_name}.group') + except KeyError: + logger.warning(f"Repo {repo_name} has no group") + else: + sections = config.g(f'groups.{repo_group}') + if sections: + for section in sections: + # TODO: ordereddict.g() returns None when key doesn't exist instead of KeyError + # So we need this horrible hack + try: + if not repo_config.g(section): + repo_config.s(section, {}) + config_inheritance.s(section, {}) + except KeyError: + repo_config.s(section, {}) + config_inheritance.s(section, {}) + sub_sections = config.g(f'groups.{repo_group}.{section}') + if sub_sections: + for entries in sub_sections: + # Do not overwrite repo values already present + if not repo_config.g(f'{section}.{entries}'): + repo_config.s(f'{section}.{entries}', config.g(f'groups.{repo_group}.{section}.{entries}')) + config_inheritance.s(f'{section}.{entries}', True) + else: + config_inheritance.s(f'{section}.{entries}', False) + return repo_config, config_inheritance + +def load_config(config_file: Path) -> Optional[dict]: + logger.info(f"Loading configuration file {config_file}") + try: with open(config_file, "r", encoding="utf-8") as file_handle: - # RoundTrip loader is default and preserves comments and ordering + # Roundtrip loader is default and preserves comments and ordering yaml = YAML(typ="rt") - config_dict = yaml.load(file_handle) + config = yaml.load(file_handle) + config_file_is_updated = False - config_file_needs_save = False - # Check modifications before decrypting since ruamel object is a pointer !!! - is_modified, config_dict = has_random_variables(config_dict) - if is_modified: - logger.info("Handling random variables in configuration files") - config_file_needs_save = True - if not is_encrypted(config_dict): + # Check if we need to encrypt some variables + if not is_encrypted(config): logger.info("Encrypting non encrypted data in configuration file") - config_file_needs_save = True + config_file_is_updated = True - config_dict_decrypted = decrypt_data( - config_dict, ENCRYPTED_OPTIONS, non_encrypted_data_is_fatal=False - ) - if config_dict_decrypted == False: + # Decrypt variables + config = crypt_config(config, AES_KEY, ENCRYPTED_OPTIONS, operation='decrypt') + if config == False: if EARLIER_AES_KEY: - new_aes_key = AES_KEY - AES_KEY = EARLIER_AES_KEY - logger.info("Trying to migrate encryption key") - config_dict_decrypted = decrypt_data( - config_dict, - ENCRYPTED_OPTIONS, - non_encrypted_data_is_fatal=False, - ) - if config_dict_decrypted is not False: - AES_KEY = new_aes_key - logger.info("Migrated encryption") - config_file_needs_save = True - new_aes_key = None - EARLIER_AES_KEY = None - else: - logger.critical("Cannot decrypt config file.") + logger.warning("Trying to migrate encryption key") + config = crypt_config(config, EARLIER_AES_KEY, ENCRYPTED_OPTIONS, operation='decrypt') + if config == False: + logger.critical("Cannot decrypt config file with earlier key") sys.exit(12) + else: + config_file_is_updated = True + logger.warning("Successfully migrated encryption key") else: + logger.critical("Cannot decrypt config file") sys.exit(11) - if config_file_needs_save: + + + # Check if we need to expand random vars + is_modified, config = has_random_variables(config) + if is_modified: + config_file_is_updated = True + logger.info("Handling random variables in configuration files") + + # save config file if needed + if config_file_is_updated: logger.info("Updating config file") - save_config(config_file, config_dict) + save_config(config_file, config) + + return config - # Decrypt potential admin password separately - config_dict_decrypted = decrypt_data( - config_dict, ENCRYPTED_OPTIONS_SECURE, non_encrypted_data_is_fatal=True - ) - return config_dict except OSError: - logger.critical("Cannot load configuration file from %s", config_file) + logger.critical(f"Cannot load configuration file from {config_file}") return None -def save_config(config_file: str, config_dict: dict) -> bool: +def save_config(config_file: Path, config: dict) -> bool: try: with open(config_file, "w", encoding="utf-8") as file_handle: - if not is_encrypted(config_dict): - config_dict = encrypt_data( - config_dict, ENCRYPTED_OPTIONS + ENCRYPTED_OPTIONS_SECURE - ) + if not is_encrypted(config): + config = crypt_config(config, AES_KEY, ENCRYPTED_OPTIONS, operation='encrypt') yaml = YAML(typ="rt") - yaml.dump(config_dict, file_handle) - # Since we deal with global objects in ruamel.yaml, we need to decrypt after saving - config_dict = decrypt_data( - config_dict, ENCRYPTED_OPTIONS, non_encrypted_data_is_fatal=False - ) - config_dict = decrypt_data( - config_dict, ENCRYPTED_OPTIONS_SECURE, non_encrypted_data_is_fatal=True - ) + yaml.dump(config, file_handle) + # Since yaml is a "pointer object", we need to decrypt after saving + config = crypt_config(config, AES_KEY, ENCRYPTED_OPTIONS, operation='decrypt') return True except OSError: - logger.critical("Cannot save configuartion file to %s", config_file) + logger.critical(f"Cannot save configuration file to {config_file}") return False - - -def is_priv_build() -> bool: - return IS_PRIV_BUILD From f7a8cf9a89ef7907ba8bc5f3d6bec66a46f0c3e6 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Sun, 10 Dec 2023 23:11:04 +0100 Subject: [PATCH 014/328] Remove backup_admin_password variable --- COMPILE_INSTRUCTIONS.md | 1 - npbackup/gui/config.py | 26 ++++++++++++------------- npbackup/secret_keys.py | 3 +-- npbackup/translations/config_gui.en.yml | 2 +- npbackup/translations/config_gui.fr.yml | 2 +- 5 files changed, 15 insertions(+), 19 deletions(-) diff --git a/COMPILE_INSTRUCTIONS.md b/COMPILE_INSTRUCTIONS.md index 87dea6d..15e8d08 100644 --- a/COMPILE_INSTRUCTIONS.md +++ b/COMPILE_INSTRUCTIONS.md @@ -79,7 +79,6 @@ The output of the above command should be something like `b'\xa1JP\r\xff\x11u>?V Now copy that string into the file `npbackup/secret_keys.py`, which should look like: ``` AES_KEY = b'\xa1JP\r\xff\x11u>?V\x15\xa1\xfd\xaa&tD\xdd\xf9\xde\x07\x93\xd4\xdd\x87R\xd0eb\x10=/' -DEFAULT_BACKUP_ADMIN_PASSWORD = "MySuperSecretPassword123" ``` Note that we also changed the default backup admin password, which is used to see unencrypted configurations in the GUI. diff --git a/npbackup/gui/config.py b/npbackup/gui/config.py index 502cede..cf0dd2e 100644 --- a/npbackup/gui/config.py +++ b/npbackup/gui/config.py @@ -7,7 +7,7 @@ __author__ = "Orsiris de Jong" __copyright__ = "Copyright (C) 2022-2023 NetInvent" __license__ = "GPL-3.0-only" -__build__ = "2023042201" +__build__ = "2023121001" import os @@ -26,22 +26,20 @@ def ask_backup_admin_password(config_dict) -> bool: - # NPF-SEC-00001 SECURITY-ADMIN-BACKUP-PASSWORD ONLY AVAILABLE ON PRIVATE COMPILED BUILDS - if not IS_COMPILED or not configuration.IS_PRIV_BUILD: - sg.PopupError(_t("config_gui.not_allowed_on_not_compiled")) - return False try: backup_admin_password = config_dict["options"]["backup_admin_password"] - if not backup_admin_password: - backup_admin_password = configuration.DEFAULT_BACKUP_ADMIN_PASSWORD except KeyError: - backup_admin_password = configuration.DEFAULT_BACKUP_ADMIN_PASSWORD - if sg.PopupGetText( - _t("config_gui.enter_backup_admin_password"), password_char="*" - ) == str(backup_admin_password): - return True - sg.PopupError(_t("config_gui.wrong_password")) - return False + backup_admin_password = None + if backup_admin_password: + if sg.PopupGetText( + _t("config_gui.enter_backup_admin_password"), password_char="*" + ) == str(backup_admin_password): + return True + sg.PopupError(_t("config_gui.wrong_password")) + return False + else: + sg.PopupError(_t("config_gui.no_backup_admin_password_set")) + return False def config_gui(config_dict: dict, config_file: str): diff --git a/npbackup/secret_keys.py b/npbackup/secret_keys.py index 13cb43b..045cf36 100644 --- a/npbackup/secret_keys.py +++ b/npbackup/secret_keys.py @@ -7,7 +7,7 @@ __author__ = "Orsiris de Jong" __copyright__ = "Copyright (C) 2023 NetInvent" __license__ = "GPL-3.0-only" -__build__ = "2023032601" +__build__ = "2023120601" # Encryption key to keep repo settings safe in plain text yaml config file @@ -19,7 +19,6 @@ # print(generate_key(32)) AES_KEY = b"\xc3T\xdci\xe3[s\x87o\x96\x8f\xe5\xee.>\xf1,\x94\x8d\xfe\x0f\xea\x11\x05 \xa0\xe9S\xcf\x82\xad|" -DEFAULT_BACKUP_ADMIN_PASSWORD = "NPBackup_00" """ If someday we need to change the AES_KEY, copy it's content to EARLIER_AES_KEY and generate a new one diff --git a/npbackup/translations/config_gui.en.yml b/npbackup/translations/config_gui.en.yml index 2ef35c8..98773da 100644 --- a/npbackup/translations/config_gui.en.yml +++ b/npbackup/translations/config_gui.en.yml @@ -82,7 +82,7 @@ en: machine_group: Machine group show_decrypted: Show decrypted - not_allowed_on_not_compiled: Using this option is not allowed on non compiled or non private builds + no_backup_admin_password_set: No backup admin password set, cannot show unencrypted # compression auto: Automatic diff --git a/npbackup/translations/config_gui.fr.yml b/npbackup/translations/config_gui.fr.yml index af32418..80245d9 100644 --- a/npbackup/translations/config_gui.fr.yml +++ b/npbackup/translations/config_gui.fr.yml @@ -82,7 +82,7 @@ fr: machine_group: Groupe machine show_decrypted: Voir déchiffré - not_allowed_on_not_compiled: Cette option n'est pas permise sur une version non compilée ou non privée + no_backup_admin_password_set: Mot de passe admin backup non initialisé, ne peut montrer la version déchiffrée # compression auto: Automatique From 914fd532ebf0dd62790338e565b91d55d44a7ee6 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Mon, 11 Dec 2023 00:19:12 +0100 Subject: [PATCH 015/328] Move version to separate file --- npbackup/__version__.py | 13 +++++++++++++ npbackup/core/runner.py | 2 +- npbackup/core/upgrade_runner.py | 2 +- npbackup/upgrade_client/upgrader.py | 2 +- 4 files changed, 16 insertions(+), 3 deletions(-) create mode 100644 npbackup/__version__.py diff --git a/npbackup/__version__.py b/npbackup/__version__.py new file mode 100644 index 0000000..67b06eb --- /dev/null +++ b/npbackup/__version__.py @@ -0,0 +1,13 @@ +#! /usr/bin/env python +# -*- coding: utf-8 -*- +# +# This file is part of npbackup + +__intname__ = "npbackup" +__author__ = "Orsiris de Jong" +__site__ = "https://www.netperfect.fr/npbackup" +__description__ = "NetPerfect Backup Client" +__copyright__ = "Copyright (C) 2022-2023 NetInvent" +__license__ = "GPL-3.0-only" +__build__ = "2023121001" +__version__ = "2.3.0-dev" diff --git a/npbackup/core/runner.py b/npbackup/core/runner.py index c3e1129..680e479 100644 --- a/npbackup/core/runner.py +++ b/npbackup/core/runner.py @@ -22,7 +22,7 @@ from npbackup.restic_wrapper import ResticRunner from npbackup.core.restic_source_binary import get_restic_internal_binary from npbackup.path_helper import CURRENT_DIR, BASEDIR -from npbackup.__main__ import __intname__ as NAME, __version__ as VERSION +from npbackup.__version__ import __intname__ as NAME, __version__ as VERSION from npbackup import configuration diff --git a/npbackup/core/upgrade_runner.py b/npbackup/core/upgrade_runner.py index c6189b4..acf757b 100644 --- a/npbackup/core/upgrade_runner.py +++ b/npbackup/core/upgrade_runner.py @@ -13,7 +13,7 @@ from logging import getLogger from npbackup import configuration from npbackup.upgrade_client.upgrader import auto_upgrader, _check_new_version -from npbackup.__main__ import __version__ as npbackup_version +from npbackup.__version__ import __version__ as npbackup_version logger = getLogger(__intname__) diff --git a/npbackup/upgrade_client/upgrader.py b/npbackup/upgrade_client/upgrader.py index e61e24d..4fea555 100644 --- a/npbackup/upgrade_client/upgrader.py +++ b/npbackup/upgrade_client/upgrader.py @@ -23,7 +23,7 @@ from npbackup.upgrade_client.requestor import Requestor from npbackup.path_helper import CURRENT_DIR, CURRENT_EXECUTABLE from npbackup.core.nuitka_helper import IS_COMPILED -from npbackup.__main__ import __version__ as npbackup_version +from npbackup.__version__ import __version__ as npbackup_version logger = getLogger(__intname__) From e00d9cabf14bd2e0f32e5358a45b8851c0871bc9 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Mon, 11 Dec 2023 00:37:34 +0100 Subject: [PATCH 016/328] WIP: Full cli-gui refactoring --- bin/npbackup-cli | 5 +- bin/npbackup-gui | 9 +- npbackup/__main__.py | 364 ++----------------------- npbackup/gui/{main.py => __main__.py} | 25 +- npbackup/interface_entrypoint.py | 377 ++++++++++++++++++++++++++ 5 files changed, 423 insertions(+), 357 deletions(-) rename npbackup/gui/{main.py => __main__.py} (97%) create mode 100644 npbackup/interface_entrypoint.py diff --git a/bin/npbackup-cli b/bin/npbackup-cli index ae6857b..63ed4eb 100644 --- a/bin/npbackup-cli +++ b/bin/npbackup-cli @@ -1,16 +1,13 @@ #! /usr/bin/env python3 # -*- coding: utf-8 -*- # -# This file is part of npbackup, and is really just a binary shortcut to launch npbackup.main +# This file is part of npbackup, and is really just a binary shortcut to launch npbackup.__main__ import os import sys sys.path.insert(0, os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))) -from npbackup.globvars import Globvars -g = Globvars -g.GUI = False from npbackup.__main__ import main del sys.path[0] diff --git a/bin/npbackup-gui b/bin/npbackup-gui index 5e33d07..02e119c 100644 --- a/bin/npbackup-gui +++ b/bin/npbackup-gui @@ -1,19 +1,16 @@ #! /usr/bin/env python3 # -*- coding: utf-8 -*- # -# This file is part of npbackup, and is really just a binary shortcut to launch npbackup.main +# This file is part of npbackup, and is really just a binary shortcut to launch npbackup.gui.__main__ import os import sys sys.path.insert(0, os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))) -from npbackup.globvars import Globvars -g = Globvars -g.GUI = True -from npbackup.__main__ import main +from npbackup.gui.__main__ import main_gui del sys.path[0] if __name__ == "__main__": - main() + main_gui() diff --git a/npbackup/__main__.py b/npbackup/__main__.py index ef530b3..9ede4cc 100644 --- a/npbackup/__main__.py +++ b/npbackup/__main__.py @@ -3,131 +3,38 @@ # # This file is part of npbackup -__intname__ = "npbackup" -__author__ = "Orsiris de Jong" -__site__ = "https://www.netperfect.fr/npbackup" -__description__ = "NetPerfect Backup Client" -__copyright__ = "Copyright (C) 2022-2023 NetInvent" -__license__ = "GPL-3.0-only" -__build__ = "2023083101" -__version__ = "2.3.0-dev" +__intname__ = "npbackup.cli_interface" import os import sys -import atexit from argparse import ArgumentParser -import dateutil.parser -from datetime import datetime -import tempfile -import pidfile import ofunctions.logger_utils from ofunctions.platform import python_arch -from ofunctions.process import kill_childs -from npbackup.globvars import Globvars - - -# This is needed so we get no GUI version messages -if Globvars.GUI: - try: - import PySimpleGUI as sg - import _tkinter - except ImportError as exc: - if not IS_COMPILED: - print(str(exc)) - else: - print("Missing packages in binary.") - sys.exit(1) - from npbackup.customization import ( - PYSIMPLEGUI_THEME, - OEM_ICON, - ) +from npbackup.path_helper import CURRENT_DIR +from npbackup.configuration import IS_PRIV_BUILD from npbackup.customization import ( LICENSE_TEXT, LICENSE_FILE, ) -from npbackup import configuration -from npbackup.core.runner import NPBackupRunner -from npbackup.core.i18n_helper import _t -from npbackup.path_helper import CURRENT_DIR, CURRENT_EXECUTABLE -from npbackup.core.nuitka_helper import IS_COMPILED -from npbackup.upgrade_client.upgrader import need_upgrade -from npbackup.core.upgrade_runner import run_upgrade - -if Globvars.GUI: - from npbackup.gui.config import config_gui - from npbackup.gui.operations import operations_gui - from npbackup.gui.main import main_gui - - sg.theme(PYSIMPLEGUI_THEME) - sg.SetOptions(icon=OEM_ICON) - -if os.name == "nt": - from npbackup.windows.task import create_scheduled_task - - -# Nuitka compat, see https://stackoverflow.com/a/74540217 -try: - # pylint: disable=W0611 (unused-import) - from charset_normalizer import md__mypyc # noqa -except ImportError: - pass +from npbackup.interface_entrypoint import entrypoint +from npbackup.__version__ import __intname__ as intname, __version__, __build__, __copyright__, __description__ _DEBUG = False _VERBOSE = False LOG_FILE = os.path.join(CURRENT_DIR, "{}.log".format(__intname__)) -CONFIG_FILE = os.path.join(CURRENT_DIR, "{}.conf".format(__intname__)) -PID_FILE = os.path.join(tempfile.gettempdir(), "{}.pid".format(__intname__)) logger = ofunctions.logger_utils.logger_get_logger(LOG_FILE) - -def execution_logs(start_time: datetime) -> None: - """ - Try to know if logger.warning or worse has been called - logger._cache contains a dict of values like {10: boolean, 20: boolean, 30: boolean, 40: boolean, 50: boolean} - where - 10 = debug, 20 = info, 30 = warning, 40 = error, 50 = critical - so "if 30 in logger._cache" checks if warning has been triggered - ATTENTION: logger._cache does only contain cache of current main, not modules, deprecated in favor of - ofunctions.logger_utils.ContextFilterWorstLevel - - ATTENTION: For ofunctions.logger_utils.ContextFilterWorstLevel will only check current logger instance - So using logger = getLogger("anotherinstance") will create a separate instance from the one we can inspect - Makes sense ;) - """ - end_time = datetime.utcnow() - - logger_worst_level = 0 - for flt in logger.filters: - if isinstance(flt, ofunctions.logger_utils.ContextFilterWorstLevel): - logger_worst_level = flt.worst_level - - log_level_reached = "success" - try: - if logger_worst_level >= 40: - log_level_reached = "errors" - elif logger_worst_level >= 30: - log_level_reached = "warnings" - except AttributeError as exc: - logger.error("Cannot get worst log level reached: {}".format(exc)) - logger.info( - "ExecTime = {}, finished, state is: {}.".format( - end_time - start_time, log_level_reached - ) - ) - # using sys.exit(code) in a atexit function will swallow the exitcode and render 0 - - -def interface(): +def cli_interface(): global _DEBUG global _VERBOSE global CONFIG_FILE parser = ArgumentParser( - prog="{} {} - {}".format(__description__, __copyright__, __site__), + prog=f"{__description__}", description="""Portable Network Backup Client\n This program is distributed under the GNU General Public License and comes with ABSOLUTELY NO WARRANTY.\n This is free software, and you are welcome to redistribute it under certain conditions; Please type --license for more info.""", @@ -156,17 +63,14 @@ def interface(): help="Path to alternative configuration file", ) - if Globvars.GUI: - parser.add_argument( - "--config-gui", - action="store_true", - default=False, - help="Show configuration GUI", - ) - - parser.add_argument( - "--operations-gui", action="store_true", help="Show operations GUI" - ) + parser.add_argument( + "--repo-name", + dest="repo_name", + type=str, + default="default", + required=False, + help="Name of the repository to work with. Defaults to 'default'" + ) parser.add_argument( "-l", "--list", action="store_true", help="Show current snapshots" @@ -254,11 +158,10 @@ def interface(): ) args = parser.parse_args() - version_string = "{} v{}{}{}-{} {} - {}".format( - __intname__, + intname, __version__, - "-PRIV" if configuration.IS_PRIV_BUILD else "", + "-PRIV" if IS_PRIV_BUILD else "", "-P{}".format(sys.version_info[1]), python_arch(), __build__, @@ -284,246 +187,17 @@ def interface(): if args.verbose: _VERBOSE = True - # Make sure we log execution time and error state at the end of the program - if args.backup or args.restore or args.find or args.list or args.check: - atexit.register( - execution_logs, - datetime.utcnow(), - ) - if args.config_file: if not os.path.isfile(args.config_file): logger.critical("Given file {} cannot be read.".format(args.config_file)) CONFIG_FILE = args.config_file # Program entry - if Globvars.GUI and (args.config_gui or args.operations_gui): - try: - config_dict = configuration.load_config(CONFIG_FILE) - if not config_dict: - logger.error("Cannot load config file") - sys.exit(24) - except FileNotFoundError: - logger.warning( - 'No configuration file found. Please use --config-file "path" to specify one or put a config file into current directory. Will create fresh config file in current directory.' - ) - config_dict = configuration.empty_config_dict - - if args.config_gui: - config_dict = config_gui(config_dict, CONFIG_FILE) - if args.operations_gui: - config_dict = operations_gui(config_dict, CONFIG_FILE) - sys.exit(0) - - if args.create_scheduled_task: - try: - result = create_scheduled_task( - executable_path=CURRENT_EXECUTABLE, - interval_minutes=int(args.create_scheduled_task), - ) - if result: - sys.exit(0) - else: - sys.exit(22) - except ValueError: - sys.exit(23) - - try: - config_dict = configuration.load_config(CONFIG_FILE) - except FileNotFoundError: - config_dict = None - - if not config_dict: - message = _t("config_gui.no_config_available") - logger.error(message) - - if config_dict is None and Globvars.GUI: - config_dict = configuration.empty_config_dict - # If no arguments are passed, assume we are launching the GUI - if len(sys.argv) == 1: - try: - result = sg.Popup( - "{}\n\n{}".format(message, _t("config_gui.create_new_config")), - custom_text=(_t("generic._yes"), _t("generic._no")), - keep_on_top=True, - ) - if result == _t("generic._yes"): - config_dict = config_gui(config_dict, CONFIG_FILE) - sg.Popup(_t("config_gui.saved_initial_config")) - else: - logger.error("No configuration created via GUI") - sys.exit(7) - except _tkinter.TclError as exc: - logger.info( - 'Tkinter error: "{}". Is this a headless server ?'.format(exc) - ) - parser.print_help(sys.stderr) - sys.exit(1) - sys.exit(7) - - elif not config_dict: - if len(sys.argv) == 1 and Globvars.GUI: - sg.Popup(_t("config_gui.bogus_config_file", config_file=CONFIG_FILE)) - sys.exit(7) - - if args.upgrade_conf: - # Whatever we need to add here for future releases - # Eg: - - logger.info("Upgrading configuration file to version %s", __version__) - try: - config_dict["identity"] - except KeyError: - # Create new section identity, as per upgrade 2.2.0rc2 - config_dict["identity"] = {"machine_id": "${HOSTNAME}"} - configuration.save_config(CONFIG_FILE, config_dict) - sys.exit(0) - - # Try to perform an auto upgrade if needed - try: - auto_upgrade = config_dict["options"]["auto_upgrade"] - except KeyError: - auto_upgrade = True - try: - auto_upgrade_interval = config_dict["options"]["interval"] - except KeyError: - auto_upgrade_interval = 10 - - if (auto_upgrade and need_upgrade(auto_upgrade_interval)) or args.auto_upgrade: - if args.auto_upgrade: - logger.info("Running user initiated auto upgrade") - else: - logger.info("Running program initiated auto upgrade") - result = run_upgrade(config_dict) - if result: - sys.exit(0) - elif args.auto_upgrade: - sys.exit(23) - - dry_run = False - if args.dry_run: - dry_run = True - - npbackup_runner = NPBackupRunner(config_dict=config_dict) - npbackup_runner.dry_run = dry_run - npbackup_runner.verbose = _VERBOSE - if not npbackup_runner.backend_version: - logger.critical("No backend available. Cannot continue") - sys.exit(25) - logger.info("Backend: {}".format(npbackup_runner.backend_version)) - - if args.check: - if npbackup_runner.check_recent_backups(): - sys.exit(0) - else: - sys.exit(2) - - if args.list: - result = npbackup_runner.list() - if result: - for snapshot in result: - try: - tags = snapshot["tags"] - except KeyError: - tags = None - logger.info( - "ID: {} Hostname: {}, Username: {}, Tags: {}, source: {}, time: {}".format( - snapshot["short_id"], - snapshot["hostname"], - snapshot["username"], - tags, - snapshot["paths"], - dateutil.parser.parse(snapshot["time"]), - ) - ) - sys.exit(0) - else: - sys.exit(2) - - if args.ls: - result = npbackup_runner.ls(snapshot=args.ls) - if result: - logger.info("Snapshot content:") - for entry in result: - logger.info(entry) - sys.exit(0) - else: - logger.error("Snapshot could not be listed.") - sys.exit(2) - - if args.find: - result = npbackup_runner.find(path=args.find) - if result: - sys.exit(0) - else: - sys.exit(2) - try: - with pidfile.PIDFile(PID_FILE): - if args.backup: - result = npbackup_runner.backup(force=args.force) - if result: - logger.info("Backup finished.") - sys.exit(0) - else: - logger.error("Backup operation failed.") - sys.exit(2) - if args.restore: - result = npbackup_runner.restore( - snapshot=args.restore_from_snapshot, - target=args.restore, - restore_includes=args.restore_include, - ) - if result: - sys.exit(0) - else: - sys.exit(2) - - if args.forget: - result = npbackup_runner.forget(snapshot=args.forget) - if result: - sys.exit(0) - else: - sys.exit(2) - - if args.raw: - result = npbackup_runner.raw(command=args.raw) - if result: - sys.exit(0) - else: - sys.exit(2) - - except pidfile.AlreadyRunningError: - logger.warning("Backup process already running. Will not continue.") - # EXIT_CODE 21 = current backup process already running - sys.exit(21) - - if Globvars.GUI: - try: - with pidfile.PIDFile(PID_FILE): - try: - main_gui(config_dict, CONFIG_FILE, version_string) - except _tkinter.TclError as exc: - logger.info( - 'Tkinter error: "{}". Is this a headless server ?'.format(exc) - ) - parser.print_help(sys.stderr) - sys.exit(1) - except pidfile.AlreadyRunningError: - logger.warning("Backup GUI already running. Will not continue") - # EXIT_CODE 21 = current backup process already running - sys.exit(21) - else: - parser.print_help(sys.stderr) - + entrypoint() def main(): try: - # kill_childs normally would not be necessary, but let's just be foolproof here (kills restic subprocess in all cases) - atexit.register( - kill_childs, - os.getpid(), - ) - interface() + cli_interface() except KeyboardInterrupt as exc: logger.error("Program interrupted by keyboard. {}".format(exc)) logger.info("Trace:", exc_info=True) diff --git a/npbackup/gui/main.py b/npbackup/gui/__main__.py similarity index 97% rename from npbackup/gui/main.py rename to npbackup/gui/__main__.py index cb06911..531e788 100644 --- a/npbackup/gui/main.py +++ b/npbackup/gui/__main__.py @@ -7,12 +7,13 @@ __author__ = "Orsiris de Jong" __copyright__ = "Copyright (C) 2022-2023 NetInvent" __license__ = "GPL-3.0-only" -__build__ = "2023083101" +__build__ = "2023121001" from typing import List, Optional, Tuple import sys import os +from pathlib import Path from logging import getLogger import re from datetime import datetime @@ -20,6 +21,7 @@ import queue from time import sleep import PySimpleGUI as sg +import _tkinter from ofunctions.threading import threaded, Future from threading import Thread from ofunctions.misc import BytesConverter @@ -43,7 +45,18 @@ from npbackup.core.runner import NPBackupRunner from npbackup.core.i18n_helper import _t from npbackup.core.upgrade_runner import run_upgrade, check_new_version +from npbackup.interface_entrypoint import entrypoint +from npbackup.__version__ import __intname__ as intname, __version__, __build__, __copyright__ +from npbackup.gui.config import config_gui +from npbackup.gui.operations import operations_gui +from npbackup.customization import ( + PYSIMPLEGUI_THEME, + OEM_ICON, +) + +sg.theme(PYSIMPLEGUI_THEME) +sg.SetOptions(icon=OEM_ICON) logger = getLogger() @@ -517,7 +530,7 @@ def _gui_backup(config_dict, stdout) -> Future: return result -def main_gui(config_dict: dict, config_file: str, version_string: str): +def _main_gui(): backup_destination = _t("main_gui.local_folder") backend_type, repo_uri = get_anon_repo_uri(config_dict["repo"]["repository"]) @@ -713,3 +726,11 @@ def main_gui(config_dict: dict, config_file: str, version_string: str): _gui_update_state(window, current_state, backup_tz, snapshot_list) if current_state is None: sg.Popup(_t("main_gui.cannot_get_repo_status")) + + +def main_gui(): + try: + _main_gui() + except _tkinter.TclError as exc: + logger.critical(f'Tkinter error: "{exc}". Is this a headless server ?') + sys.exit(250) \ No newline at end of file diff --git a/npbackup/interface_entrypoint.py b/npbackup/interface_entrypoint.py new file mode 100644 index 0000000..245104d --- /dev/null +++ b/npbackup/interface_entrypoint.py @@ -0,0 +1,377 @@ +#! /usr/bin/env python +# -*- coding: utf-8 -*- +# +# This file is part of npbackup + +__intname__ = "npbackup" +__author__ = "Orsiris de Jong" +__site__ = "https://www.netperfect.fr/npbackup" +__description__ = "NetPerfect Backup Client" +__copyright__ = "Copyright (C) 2022-2023 NetInvent" +__license__ = "GPL-3.0-only" +__build__ = "2023083101" +__version__ = "2.3.0-dev" + + +import os +import sys +import atexit +import dateutil.parser +from datetime import datetime +import tempfile +import pidfile +import ofunctions.logger_utils +from ofunctions.platform import python_arch +from ofunctions.process import kill_childs + + + +from npbackup.customization import ( + LICENSE_TEXT, + LICENSE_FILE, +) +from npbackup import configuration +from npbackup.core.runner import NPBackupRunner +from npbackup.core.i18n_helper import _t +from npbackup.path_helper import CURRENT_DIR, CURRENT_EXECUTABLE +from npbackup.core.nuitka_helper import IS_COMPILED +from npbackup.upgrade_client.upgrader import need_upgrade +from npbackup.core.upgrade_runner import run_upgrade + + + +if os.name == "nt": + from npbackup.windows.task import create_scheduled_task + + +# Nuitka compat, see https://stackoverflow.com/a/74540217 +try: + # pylint: disable=W0611 (unused-import) + from charset_normalizer import md__mypyc # noqa +except ImportError: + pass + + +_DEBUG = False +_VERBOSE = False +LOG_FILE = os.path.join(CURRENT_DIR, "{}.log".format(__intname__)) +CONFIG_FILE = os.path.join(CURRENT_DIR, "{}.conf".format(__intname__)) +PID_FILE = os.path.join(tempfile.gettempdir(), "{}.pid".format(__intname__)) + + +logger = ofunctions.logger_utils.logger_get_logger(LOG_FILE) + + +def execution_logs(start_time: datetime) -> None: + """ + Try to know if logger.warning or worse has been called + logger._cache contains a dict of values like {10: boolean, 20: boolean, 30: boolean, 40: boolean, 50: boolean} + where + 10 = debug, 20 = info, 30 = warning, 40 = error, 50 = critical + so "if 30 in logger._cache" checks if warning has been triggered + ATTENTION: logger._cache does only contain cache of current main, not modules, deprecated in favor of + ofunctions.logger_utils.ContextFilterWorstLevel + + ATTENTION: For ofunctions.logger_utils.ContextFilterWorstLevel will only check current logger instance + So using logger = getLogger("anotherinstance") will create a separate instance from the one we can inspect + Makes sense ;) + """ + end_time = datetime.utcnow() + + logger_worst_level = 0 + for flt in logger.filters: + if isinstance(flt, ofunctions.logger_utils.ContextFilterWorstLevel): + logger_worst_level = flt.worst_level + + log_level_reached = "success" + try: + if logger_worst_level >= 40: + log_level_reached = "errors" + elif logger_worst_level >= 30: + log_level_reached = "warnings" + except AttributeError as exc: + logger.error("Cannot get worst log level reached: {}".format(exc)) + logger.info( + "ExecTime = {}, finished, state is: {}.".format( + end_time - start_time, log_level_reached + ) + ) + # using sys.exit(code) in a atexit function will swallow the exitcode and render 0 + + + + + + +def interface(): + version_string = "{} v{}{}{}-{} {} - {}".format( + __intname__, + __version__, + "-PRIV" if configuration.IS_PRIV_BUILD else "", + "-P{}".format(sys.version_info[1]), + python_arch(), + __build__, + __copyright__, + ) + if args.version: + print(version_string) + sys.exit(0) + + logger.info(version_string) + if args.license: + try: + with open(LICENSE_FILE, "r", encoding="utf-8") as file_handle: + print(file_handle.read()) + except OSError: + print(LICENSE_TEXT) + sys.exit(0) + + if args.debug or os.environ.get("_DEBUG", "False").capitalize() == "True": + _DEBUG = True + logger.setLevel(ofunctions.logger_utils.logging.DEBUG) + + if args.verbose: + _VERBOSE = True + + # Make sure we log execution time and error state at the end of the program + if args.backup or args.restore or args.find or args.list or args.check: + atexit.register( + execution_logs, + datetime.utcnow(), + ) + + if args.config_file: + if not os.path.isfile(args.config_file): + logger.critical("Given file {} cannot be read.".format(args.config_file)) + CONFIG_FILE = args.config_file + + # Program entry + if Globvars.GUI and (args.config_gui or args.operations_gui): + try: + config = configuration.load_config(CONFIG_FILE) + if not config: + logger.error("Cannot load config file") + sys.exit(24) + except FileNotFoundError: + logger.warning( + 'No configuration file found. Please use --config-file "path" to specify one or put a config file into current directory. Will create fresh config file in current directory.' + ) + config = configuration.empty_config_dict + + if args.config_gui: + config = config_gui(config, CONFIG_FILE) + if args.operations_gui: + config = operations_gui(config, CONFIG_FILE) + sys.exit(0) + + if args.create_scheduled_task: + try: + result = create_scheduled_task( + executable_path=CURRENT_EXECUTABLE, + interval_minutes=int(args.create_scheduled_task), + ) + if result: + sys.exit(0) + else: + sys.exit(22) + except ValueError: + sys.exit(23) + + try: + config = configuration.load_config(CONFIG_FILE) + repo_config = configuration.get_repo_config(config_dict, args.repo_name) + except FileNotFoundError: + config = None + + if not config: + message = _t("config_gui.no_config_available") + logger.error(message) + + if config_dict is None and Globvars.GUI: + config_dict = configuration.empty_config_dict + # If no arguments are passed, assume we are launching the GUI + if len(sys.argv) == 1: + try: + result = sg.Popup( + "{}\n\n{}".format(message, _t("config_gui.create_new_config")), + custom_text=(_t("generic._yes"), _t("generic._no")), + keep_on_top=True, + ) + if result == _t("generic._yes"): + config_dict = config_gui(config_dict, CONFIG_FILE) + sg.Popup(_t("config_gui.saved_initial_config")) + else: + logger.error("No configuration created via GUI") + sys.exit(7) + except _tkinter.TclError as exc: + logger.info( + 'Tkinter error: "{}". Is this a headless server ?'.format(exc) + ) + parser.print_help(sys.stderr) + sys.exit(1) + sys.exit(7) + + elif not config_dict: + if len(sys.argv) == 1 and Globvars.GUI: + sg.Popup(_t("config_gui.bogus_config_file", config_file=CONFIG_FILE)) + sys.exit(7) + + if args.upgrade_conf: + # Whatever we need to add here for future releases + # Eg: + + logger.info("Upgrading configuration file to version %s", __version__) + try: + config_dict["identity"] + except KeyError: + # Create new section identity, as per upgrade 2.2.0rc2 + config_dict["identity"] = {"machine_id": "${HOSTNAME}"} + configuration.save_config(CONFIG_FILE, config_dict) + sys.exit(0) + + # Try to perform an auto upgrade if needed + try: + auto_upgrade = config_dict["options"]["auto_upgrade"] + except KeyError: + auto_upgrade = True + try: + auto_upgrade_interval = config_dict["options"]["interval"] + except KeyError: + auto_upgrade_interval = 10 + + if (auto_upgrade and need_upgrade(auto_upgrade_interval)) or args.auto_upgrade: + if args.auto_upgrade: + logger.info("Running user initiated auto upgrade") + else: + logger.info("Running program initiated auto upgrade") + result = run_upgrade(config_dict) + if result: + sys.exit(0) + elif args.auto_upgrade: + sys.exit(23) + + dry_run = False + if args.dry_run: + dry_run = True + + npbackup_runner = NPBackupRunner(config_dict=config_dict) + npbackup_runner.dry_run = dry_run + npbackup_runner.verbose = _VERBOSE + if not npbackup_runner.backend_version: + logger.critical("No backend available. Cannot continue") + sys.exit(25) + logger.info("Backend: {}".format(npbackup_runner.backend_version)) + + if args.check: + if npbackup_runner.check_recent_backups(): + sys.exit(0) + else: + sys.exit(2) + + if args.list: + result = npbackup_runner.list() + if result: + for snapshot in result: + try: + tags = snapshot["tags"] + except KeyError: + tags = None + logger.info( + "ID: {} Hostname: {}, Username: {}, Tags: {}, source: {}, time: {}".format( + snapshot["short_id"], + snapshot["hostname"], + snapshot["username"], + tags, + snapshot["paths"], + dateutil.parser.parse(snapshot["time"]), + ) + ) + sys.exit(0) + else: + sys.exit(2) + + if args.ls: + result = npbackup_runner.ls(snapshot=args.ls) + if result: + logger.info("Snapshot content:") + for entry in result: + logger.info(entry) + sys.exit(0) + else: + logger.error("Snapshot could not be listed.") + sys.exit(2) + + if args.find: + result = npbackup_runner.find(path=args.find) + if result: + sys.exit(0) + else: + sys.exit(2) + try: + with pidfile.PIDFile(PID_FILE): + if args.backup: + result = npbackup_runner.backup(force=args.force) + if result: + logger.info("Backup finished.") + sys.exit(0) + else: + logger.error("Backup operation failed.") + sys.exit(2) + if args.restore: + result = npbackup_runner.restore( + snapshot=args.restore_from_snapshot, + target=args.restore, + restore_includes=args.restore_include, + ) + if result: + sys.exit(0) + else: + sys.exit(2) + + if args.forget: + result = npbackup_runner.forget(snapshot=args.forget) + if result: + sys.exit(0) + else: + sys.exit(2) + + if args.raw: + result = npbackup_runner.raw(command=args.raw) + if result: + sys.exit(0) + else: + sys.exit(2) + + except pidfile.AlreadyRunningError: + logger.warning("Backup process already running. Will not continue.") + # EXIT_CODE 21 = current backup process already running + sys.exit(21) + + + +def main(): + try: + # kill_childs normally would not be necessary, but let's just be foolproof here (kills restic subprocess in all cases) + atexit.register( + kill_childs, + os.getpid(), + ) + interface() + except KeyboardInterrupt as exc: + logger.error("Program interrupted by keyboard. {}".format(exc)) + logger.info("Trace:", exc_info=True) + # EXIT_CODE 200 = keyboard interrupt + sys.exit(200) + except Exception as exc: + logger.error("Program interrupted by error. {}".format(exc)) + logger.info("Trace:", exc_info=True) + # EXIT_CODE 201 = Non handled exception + sys.exit(201) + + +if __name__ == "__main__": + main() + + +def entrypoint(): + pass From 628e19e0a8b60544a616993099c401605f2755c7 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Mon, 11 Dec 2023 00:38:14 +0100 Subject: [PATCH 017/328] Remove unnecessary Globvars class --- npbackup/globvars.py | 14 -------------- 1 file changed, 14 deletions(-) delete mode 100644 npbackup/globvars.py diff --git a/npbackup/globvars.py b/npbackup/globvars.py deleted file mode 100644 index 1f14d49..0000000 --- a/npbackup/globvars.py +++ /dev/null @@ -1,14 +0,0 @@ -#! /usr/bin/env python -# -*- coding: utf-8 -*- -# -# This file is part of npbackup - -__intname__ = "npbackup.globvars" -__author__ = "Orsiris de Jong" -__copyright__ = "Copyright (C) 2023 NetInvent" -__license__ = "GPL-3.0-only" -__build__ = "2023083101" - - -class Globvars(object): - GUI = None From 245aa635b05b69f330d8a3f7e59bb9a35be0f03f Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Mon, 11 Dec 2023 10:40:39 +0100 Subject: [PATCH 018/328] Refactor NPBackupRunner --- npbackup/configuration.py | 34 +++- npbackup/core/runner.py | 283 ++++++++++------------------ npbackup/restic_wrapper/__init__.py | 14 +- 3 files changed, 136 insertions(+), 195 deletions(-) diff --git a/npbackup/configuration.py b/npbackup/configuration.py index 8f52aa7..75de07a 100644 --- a/npbackup/configuration.py +++ b/npbackup/configuration.py @@ -98,19 +98,44 @@ def s(self, path, value, sep='.'): "backup_opts": {}, "repo_opts": {}, "prometheus": {}, - "env": {} + "env": { + "variables": {}, + "encrypted_variables": {} + }, }, }, "groups": { "default_group": { "backup_opts": { + "paths": [], + "tags": [], "compression": "auto", "use_fs_snapshot": True, "ignore_cloud_files": True, "exclude_caches": True, "exclude_case_ignore": False, "one_file_system": True, - "priority": "low" + "priority": "low", + "exclude_caches": True, + "exclude_files": [ + "excludes/generic_excluded_extensions", + "excludes/generic_excludes", + "excludes/windows_excludes", + "excludes/linux_excludes" + ], + "exclude_patterns": None, + "exclude_patterns_source_type": "files_from_verbatim", + "exclude_patterns_case_ignore": False, + "additional_parameters": None, + "additional_backup_only_parameters": None, + "pre_exec_commands": [], + "pre_exec_per_command_timeout": 3600, + "pre_exec_failure_is_fatal": False, + "post_exec_commands": [], + "post_exec_per_command_timeout": 3600, + "post_exec_failure_is_fatal": False, + "post_exec_execute_even_on_error": True, # TODO + } }, "repo_opts": { "repo_password": "", @@ -133,7 +158,9 @@ def s(self, path, value, sep='.'): "backup_job": "${MACHINE_ID}", "group": "${MACHINE_GROUP}", }, - "env": {} + "env": { + "variables": {}, + "encrypted_variables": {} }, }, "identity": { @@ -156,7 +183,6 @@ def s(self, path, value, sep='.'): "auto_upgrade_server_password": "", "auto_upgrade_host_identity": "${MACHINE_ID}", "auto_upgrade_group": "${MACHINE_GROUP}", - "env": {} }, } diff --git a/npbackup/core/runner.py b/npbackup/core/runner.py index 6e8ccbf..c29424d 100644 --- a/npbackup/core/runner.py +++ b/npbackup/core/runner.py @@ -30,47 +30,19 @@ def metric_writer( - config_dict: dict, restic_result: bool, result_string: str, dry_run: bool + repo_config: dict, restic_result: bool, result_string: str, dry_run: bool ): try: labels = {} - if config_dict["prometheus"]["metrics"]: - try: - labels["instance"] = configuration.evaluate_variables( - config_dict, config_dict["prometheus"]["instance"] - ) - except (KeyError, AttributeError): - labels["instance"] = None - try: - labels["backup_job"] = configuration.evaluate_variables( - config_dict, config_dict["prometheus"]["backup_job"] - ) - except (KeyError, AttributeError): - labels["backup_job"] = None - try: - labels["group"] = configuration.evaluate_variables( - config_dict, config_dict["prometheus"]["group"] - ) - except (KeyError, AttributeError): - labels["group"] = None - try: - destination = configuration.evaluate_variables( - config_dict, config_dict["prometheus"]["destination"] - ) - except (KeyError, AttributeError): - destination = None - try: - no_cert_verify = config_dict["prometheus"]["no_cert_verify"] - except (KeyError, AttributeError): - no_cert_verify = False - try: - prometheus_additional_labels = config_dict["prometheus"][ - "additional_labels" - ] - if not isinstance(prometheus_additional_labels, list): - prometheus_additional_labels = [prometheus_additional_labels] - except (KeyError, AttributeError): - prometheus_additional_labels = None + if repo_config.g("prometheus.metrics"): + labels["instance"] = repo_config.g("prometheus.instance") + labels["backup_job"] = repo_config.g("prometheus.backup_job") + labels["group"] = repo_config.g("prometheus.group") + no_cert_verify = repo_config.g("prometheus.no_cert_verify") + destination = repo_config.g("prometheus.destination") + prometheus_additional_labels = repo_config.g("prometheus.additional_labels") + if not isinstance(prometheus_additional_labels, list): + prometheus_additional_labels = [prometheus_additional_labels] # Configure lables label_string = ",".join( @@ -106,8 +78,8 @@ def metric_writer( if destination.lower().startswith("http"): try: authentication = ( - config_dict["prometheus"]["http_username"], - config_dict["prometheus"]["http_password"], + repo_config.g("prometheus.http_username"), + repo_config.g("prometheus.http_password"), ) except KeyError: logger.info("No metrics authentication present.") @@ -141,9 +113,9 @@ class NPBackupRunner: # NPF-SEC-00002: password commands, pre_exec and post_exec commands will be executed with npbackup privileges # This can lead to a problem when the config file can be written by users other than npbackup - def __init__(self, config_dict: Optional[dict] = None): - if config_dict: - self.config_dict = config_dict + def __init__(self, repo_config: Optional[dict] = None): + if repo_config: + self.repo_config = repo_config self._dry_run = False self._verbose = False @@ -239,20 +211,20 @@ def wrapper(self, *args, **kwargs): def create_restic_runner(self) -> None: can_run = True try: - repository = self.config_dict["repo"]["repository"] + repository = self.repo_config.g("repo_uri") if not repository: raise KeyError except (KeyError, AttributeError): logger.error("Repo cannot be empty") can_run = False try: - password = self.config_dict["repo"]["password"] + password = self.repo_config.g("repo_opts.repo_password") except (KeyError, AttributeError): logger.error("Repo password cannot be empty") can_run = False if not password or password == "": try: - password_command = self.config_dict["repo"]["password_command"] + password_command = self.repo_config.g("repo_opts.repo_password_command") if password_command and password_command != "": # NPF-SEC-00003: Avoid password command divulgation cr_logger = logging.getLogger("command_runner") @@ -307,54 +279,44 @@ def apply_config_to_restic_runner(self) -> None: if not self.is_ready: return None try: - if self.config_dict["repo"]["upload_speed"]: - self.restic_runner.limit_upload = self.config_dict["repo"][ - "upload_speed" - ] + if self.repo_config.g("repo_opts.upload_speed"): + self.restic_runner.limit_upload = self.repo_config.g("repo_opts.upload_speed") except KeyError: pass except ValueError: logger.error("Bogus upload limit given.") try: - if self.config_dict["repo"]["download_speed"]: - self.restic_runner.limit_download = self.config_dict["repo"][ - "download_speed" - ] + if self.repo_config.g("repo_opts.download_speed"): + self.restic_runner.limit_download = self.repo_config.g("repo_opts.download_speed") except KeyError: pass except ValueError: logger.error("Bogus download limit given.") try: - if self.config_dict["repo"]["backend_connections"]: - self.restic_runner.backend_connections = self.config_dict["repo"][ - "backend_connections" - ] + if self.repo_config.g("repo_opts.backend_connections"): + self.restic_runner.backend_connections = self.repo_config.g("repo_opts.backend_connections") except KeyError: pass except ValueError: logger.error("Bogus backend connections value given.") try: - if self.config_dict["backup"]["priority"]: - self.restic_runner.priority = self.config_dict["backup"]["priority"] + if self.repo_config.g("backup_opts.priority"): + self.restic_runner.priority = self.repo_config.g("backup_opts.priority") except KeyError: pass except ValueError: logger.warning("Bogus backup priority in config file.") try: - if self.config_dict["backup"]["ignore_cloud_files"]: - self.restic_runner.ignore_cloud_files = self.config_dict["backup"][ - "ignore_cloud_files" - ] + if self.repo_config.g("backup_opts.ignore_cloud_files"): + self.restic_runner.ignore_cloud_files = self.repo_config.g("backup_opts.ignore_cloud_files") except KeyError: pass except ValueError: logger.warning("Bogus ignore_cloud_files value given") try: - if self.config_dict["backup"]["additional_parameters"]: - self.restic_runner.additional_parameters = self.config_dict["backup"][ - "additional_parameters" - ] + if self.repo_config.g("backup_opts.additional_parameters"): + self.restic_runner.additional_parameters = self.repo_config.g("backup_opts.additional_parameters") except KeyError: pass except ValueError: @@ -362,18 +324,19 @@ def apply_config_to_restic_runner(self) -> None: self.restic_runner.stdout = self.stdout try: - env_variables = self.config_dict["env"]["variables"] + env_variables = self.repo_config.g("env.variables") if not isinstance(env_variables, list): env_variables = [env_variables] except KeyError: env_variables = [] try: - encrypted_env_variables = self.config_dict["env"]["encrypted_variables"] + encrypted_env_variables = self.repo_config.g("env.encrypted_variables") if not isinstance(encrypted_env_variables, list): encrypted_env_variables = [encrypted_env_variables] except KeyError: encrypted_env_variables = [] + # TODO use "normal" YAML syntax env_variables += encrypted_env_variables expanded_env_vars = {} try: @@ -402,7 +365,7 @@ def apply_config_to_restic_runner(self) -> None: try: self.minimum_backup_age = int( - self.config_dict["repo"]["minimum_backup_age"] + self.repo_config.g("repo_opts.minimum_backup_age") ) except (KeyError, ValueError, TypeError): self.minimum_backup_age = 1440 @@ -448,8 +411,11 @@ def check_recent_backups(self) -> bool: """ if not self.is_ready: return None + if self.minimum_backup_age == 0: + logger.info("No minimal backup age set. Set for backup") + logger.info( - "Searching for a backup newer than {} ago.".format( + "Searching for a backup newer than {} ago".format( str(timedelta(minutes=self.minimum_backup_age)) ) ) @@ -476,9 +442,8 @@ def backup(self, force: bool = False) -> bool: if not self.is_ready: return False # Preflight checks - try: - paths = self.config_dict["backup"]["paths"] - except KeyError: + paths = self.repo_config.g("backup_opts.paths") + if not paths: logger.error("No backup paths defined.") return False @@ -489,7 +454,7 @@ def backup(self, force: bool = False) -> bool: paths = [paths] paths = [path.strip() for path in paths] for path in paths: - if path == self.config_dict["repo"]["repository"]: + if path == self.repo_config.g("repo_uri"): logger.critical( "You cannot backup source into it's own path. No inception allowed !" ) @@ -498,92 +463,40 @@ def backup(self, force: bool = False) -> bool: logger.error("No backup source given.") return False - try: - source_type = self.config_dict["backup"]["source_type"] - except KeyError: - source_type = None + exclude_patterns_source_type = self.repo_config.g("backup_opts.exclude_patterns_source_type") # MSWindows does not support one-file-system option - try: - exclude_patterns = self.config_dict["backup"]["exclude_patterns"] - if not isinstance(exclude_patterns, list): - exclude_patterns = [exclude_patterns] - except KeyError: - exclude_patterns = [] - try: - exclude_files = self.config_dict["backup"]["exclude_files"] - if not isinstance(exclude_files, list): - exclude_files = [exclude_files] - except KeyError: - exclude_files = [] - try: - exclude_case_ignore = self.config_dict["backup"]["exclude_case_ignore"] - except KeyError: - exclude_case_ignore = False - try: - exclude_caches = self.config_dict["backup"]["exclude_caches"] - except KeyError: - exclude_caches = False - try: - one_file_system = ( - self.config_dict["backup"]["one_file_system"] - if os.name != "nt" - else False - ) - except KeyError: - one_file_system = False - try: - use_fs_snapshot = self.config_dict["backup"]["use_fs_snapshot"] - except KeyError: - use_fs_snapshot = False - try: - pre_exec_command = self.config_dict["backup"]["pre_exec_command"] - except KeyError: - pre_exec_command = None - - try: - pre_exec_timeout = self.config_dict["backup"]["pre_exec_timeout"] - except KeyError: - pre_exec_timeout = 0 + exclude_patterns = self.repo_config.g("backup_opts.exclude_patterns") + if not isinstance(exclude_patterns, list): + exclude_patterns = [exclude_patterns] - try: - pre_exec_failure_is_fatal = self.config_dict["backup"][ - "pre_exec_failure_is_fatal" - ] - except KeyError: - pre_exec_failure_is_fatal = None + exclude_files = self.repo_config.g("backup_opts.exclude_files") + if not isinstance(exclude_files, list): + exclude_files = [exclude_files] - try: - post_exec_command = self.config_dict["backup"]["post_exec_command"] - except KeyError: - post_exec_command = None + exclude_patterns_case_ignore = self.repo_config.g("backup_opts.exclude_patterns_case_ignore") + exclude_caches = self.repo_config.g("backup_opts.exclude_caches") + one_file_system = self.config.g("backup_opts.one_file_system") if os.name != 'nt' else False + use_fs_snapshot = self.config.g("backup_opts.use_fs_snapshot") - try: - post_exec_timeout = self.config_dict["backup"]["post_exec_timeout"] - except KeyError: - post_exec_timeout = 0 + pre_exec_commands = self.config.g("backup_opts.pre_exec_commands") + pre_exec_per_command_timeout = self.config.g("backup_opts.pre_exec_per_command_timeout") + pre_exec_failure_is_fatal = self.config.g("backup_opts.pre_exec_failure_is_fatal") - try: - post_exec_failure_is_fatal = self.config_dict["backup"][ - "post_exec_failure_is_fatal" - ] - except KeyError: - post_exec_failure_is_fatal = None + post_exec_commands = self.config.g("backup_opts.post_exec_commands") + post_exec_per_command_timeout = self.config.g("backup_opts.post_exec_per_command_timeout") + post_exec_failure_is_fatal = self.config.g("backup_opts.post_exec_failure_is_fatal") # Make sure we convert tag to list if only one tag is given try: - tags = self.config_dict["backup"]["tags"] + tags = self.repo_config.g("backup_opts.tags") if not isinstance(tags, list): tags = [tags] except KeyError: tags = None - try: - additional_backup_only_parameters = self.config_dict["backup"][ - "additional_backup_only_parameters" - ] - except KeyError: - additional_backup_only_parameters = None + additional_backup_only_parameters = self.repo_config.g("backup_opts.additional_backup_only_parameters") + # Check if backup is required self.restic_runner.verbose = False @@ -597,37 +510,38 @@ def backup(self, force: bool = False) -> bool: self.restic_runner.verbose = self.verbose # Run backup here - if source_type not in ["folder_list", None]: + if exclude_patterns_source_type not in ["folder_list", None]: logger.info("Running backup of files in {} list".format(paths)) else: logger.info("Running backup of {}".format(paths)) - if pre_exec_command: - exit_code, output = command_runner( - pre_exec_command, shell=True, timeout=pre_exec_timeout - ) - if exit_code != 0: - logger.error( - "Pre-execution of command {} failed with:\n{}".format( - pre_exec_command, output - ) + if pre_exec_commands: + for pre_exec_command in pre_exec_commands: + exit_code, output = command_runner( + pre_exec_command, shell=True, timeout=pre_exec_per_command_timeout ) - if pre_exec_failure_is_fatal: - return False - else: - logger.info( - "Pre-execution of command {} success with:\n{}.".format( - pre_exec_command, output + if exit_code != 0: + logger.error( + "Pre-execution of command {} failed with:\n{}".format( + pre_exec_command, output + ) + ) + if pre_exec_failure_is_fatal: + return False + else: + logger.info( + "Pre-execution of command {} success with:\n{}.".format( + pre_exec_command, output + ) ) - ) self.restic_runner.dry_run = self.dry_run result, result_string = self.restic_runner.backup( paths=paths, - source_type=source_type, + exclude_patterns_source_type=exclude_patterns_source_type, exclude_patterns=exclude_patterns, exclude_files=exclude_files, - exclude_case_ignore=exclude_case_ignore, + exclude_patterns_case_ignore=exclude_patterns_case_ignore, exclude_caches=exclude_caches, one_file_system=one_file_system, use_fs_snapshot=use_fs_snapshot, @@ -636,27 +550,28 @@ def backup(self, force: bool = False) -> bool: ) logger.debug("Restic output:\n{}".format(result_string)) metric_writer( - self.config_dict, result, result_string, self.restic_runner.dry_run + self.repo_config, result, result_string, self.restic_runner.dry_run ) - if post_exec_command: - exit_code, output = command_runner( - post_exec_command, shell=True, timeout=post_exec_timeout - ) - if exit_code != 0: - logger.error( - "Post-execution of command {} failed with:\n{}".format( - post_exec_command, output - ) + if post_exec_commands: + for post_exec_command in post_exec_commands: + exit_code, output = command_runner( + post_exec_command, shell=True, timeout=post_exec_per_command_timeout ) - if post_exec_failure_is_fatal: - return False - else: - logger.info( - "Post-execution of command {} success with:\n{}.".format( - post_exec_command, output + if exit_code != 0: + logger.error( + "Post-execution of command {} failed with:\n{}".format( + post_exec_command, output + ) + ) + if post_exec_failure_is_fatal: + return False + else: + logger.info( + "Post-execution of command {} success with:\n{}.".format( + post_exec_command, output + ) ) - ) return result @exec_timer diff --git a/npbackup/restic_wrapper/__init__.py b/npbackup/restic_wrapper/__init__.py index d311fc2..97569aa 100644 --- a/npbackup/restic_wrapper/__init__.py +++ b/npbackup/restic_wrapper/__init__.py @@ -539,10 +539,10 @@ def snapshots(self) -> Optional[list]: def backup( self, paths: List[str], - source_type: str, + exclude_patterns_source_type: str, exclude_patterns: List[str] = [], exclude_files: List[str] = [], - exclude_case_ignore: bool = False, + exclude_patterns_case_ignore: bool = False, exclude_caches: bool = False, use_fs_snapshot: bool = False, tags: List[str] = [], @@ -556,13 +556,13 @@ def backup( return None, None # Handle various source types - if source_type in ["files_from", "files_from_verbatim", "files_from_raw"]: + if exclude_patterns_source_type in ["files_from", "files_from_verbatim", "files_from_raw"]: cmd = "backup" - if source_type == "files_from": + if exclude_patterns_source_type == "files_from": source_parameter = "--files-from" - elif source_type == "files_from_verbatim": + elif exclude_patterns_source_type == "files_from_verbatim": source_parameter = "--files-from-verbatim" - elif source_type == "files_from_raw": + elif exclude_patterns_source_type == "files_from_raw": source_parameter = "--files-from-raw" else: logger.error("Bogus source type given") @@ -584,7 +584,7 @@ def backup( case_ignore_param = "" # Always use case ignore excludes under windows - if os.name == "nt" or exclude_case_ignore: + if os.name == "nt" or exclude_patterns_case_ignore: case_ignore_param = "i" for exclude_pattern in exclude_patterns: From 17967bc77c2af1a2fe19614d44adfe967de59080 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Mon, 11 Dec 2023 16:10:53 +0100 Subject: [PATCH 019/328] Require command_runner >= 1.5.1 --- npbackup/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/npbackup/requirements.txt b/npbackup/requirements.txt index 98332ae..c84bd2b 100644 --- a/npbackup/requirements.txt +++ b/npbackup/requirements.txt @@ -1,4 +1,4 @@ -command_runner>=1.5.0 +command_runner>=1.5.1 cryptidy>=1.2.2 python-dateutil ofunctions.logger_utils>=2.3.0 From 49fbdceae5e4c737603d527ec3189595fe6051f6 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Mon, 11 Dec 2023 16:11:22 +0100 Subject: [PATCH 020/328] WIP Refactor main gui to new config format --- npbackup/core/runner.py | 16 ++++---- npbackup/gui/__main__.py | 82 +++++++++++++++++++++++----------------- 2 files changed, 56 insertions(+), 42 deletions(-) diff --git a/npbackup/core/runner.py b/npbackup/core/runner.py index c29424d..fc67a67 100644 --- a/npbackup/core/runner.py +++ b/npbackup/core/runner.py @@ -476,16 +476,16 @@ def backup(self, force: bool = False) -> bool: exclude_patterns_case_ignore = self.repo_config.g("backup_opts.exclude_patterns_case_ignore") exclude_caches = self.repo_config.g("backup_opts.exclude_caches") - one_file_system = self.config.g("backup_opts.one_file_system") if os.name != 'nt' else False - use_fs_snapshot = self.config.g("backup_opts.use_fs_snapshot") + one_file_system = self.repo_config.g("backup_opts.one_file_system") if os.name != 'nt' else False + use_fs_snapshot = self.repo_config.g("backup_opts.use_fs_snapshot") - pre_exec_commands = self.config.g("backup_opts.pre_exec_commands") - pre_exec_per_command_timeout = self.config.g("backup_opts.pre_exec_per_command_timeout") - pre_exec_failure_is_fatal = self.config.g("backup_opts.pre_exec_failure_is_fatal") + pre_exec_commands = self.repo_config.g("backup_opts.pre_exec_commands") + pre_exec_per_command_timeout = self.repo_config.g("backup_opts.pre_exec_per_command_timeout") + pre_exec_failure_is_fatal = self.repo_config.g("backup_opts.pre_exec_failure_is_fatal") - post_exec_commands = self.config.g("backup_opts.post_exec_commands") - post_exec_per_command_timeout = self.config.g("backup_opts.post_exec_per_command_timeout") - post_exec_failure_is_fatal = self.config.g("backup_opts.post_exec_failure_is_fatal") + post_exec_commands = self.repo_config.g("backup_opts.post_exec_commands") + post_exec_per_command_timeout = self.repo_config.g("backup_opts.post_exec_per_command_timeout") + post_exec_failure_is_fatal = self.repo_config.g("backup_opts.post_exec_failure_is_fatal") # Make sure we convert tag to list if only one tag is given try: diff --git a/npbackup/gui/__main__.py b/npbackup/gui/__main__.py index 531e788..2409a6c 100644 --- a/npbackup/gui/__main__.py +++ b/npbackup/gui/__main__.py @@ -25,6 +25,7 @@ from ofunctions.threading import threaded, Future from threading import Thread from ofunctions.misc import BytesConverter +import npbackup.configuration from npbackup.customization import ( OEM_STRING, OEM_LOGO, @@ -45,9 +46,9 @@ from npbackup.core.runner import NPBackupRunner from npbackup.core.i18n_helper import _t from npbackup.core.upgrade_runner import run_upgrade, check_new_version +from npbackup.path_helper import CURRENT_DIR from npbackup.interface_entrypoint import entrypoint from npbackup.__version__ import __intname__ as intname, __version__, __build__, __copyright__ - from npbackup.gui.config import config_gui from npbackup.gui.operations import operations_gui from npbackup.customization import ( @@ -65,10 +66,10 @@ THREAD_SHARED_DICT = {} -def _about_gui(version_string: str, config_dict: dict) -> None: +def _about_gui(version_string: str, repo_config: dict) -> None: license_content = LICENSE_TEXT - result = check_new_version(config_dict) + result = check_new_version(repo_config) if result: new_version = [ sg.Button( @@ -113,7 +114,7 @@ def _about_gui(version_string: str, config_dict: dict) -> None: ) if result == "OK": logger.info("Running GUI initiated upgrade") - sub_result = run_upgrade(config_dict) + sub_result = run_upgrade(repo_config) if sub_result: sys.exit(0) else: @@ -122,8 +123,8 @@ def _about_gui(version_string: str, config_dict: dict) -> None: @threaded -def _get_gui_data(config_dict: dict) -> Future: - runner = NPBackupRunner(config_dict=config_dict) +def _get_gui_data(repo_config: dict) -> Future: + runner = NPBackupRunner(repo_config=repo_config) snapshots = runner.list() current_state, backup_tz = runner.check_recent_backups() snapshot_list = [] @@ -153,11 +154,12 @@ def _get_gui_data(config_dict: dict) -> Future: return current_state, backup_tz, snapshot_list -def get_gui_data(config_dict: dict) -> Tuple[bool, List[str]]: +def get_gui_data(repo_config: dict) -> Tuple[bool, List[str]]: try: if ( - not config_dict["repo"]["repository"] - and not config_dict["repo"]["password"] + not repo_config.g("repo_uri") + and (not repo_config.g("repo_opts.repo_password") + and not repo_config.g("repo_opts.repo_password_command")) ): sg.Popup(_t("main_gui.repository_not_configured")) return None, None @@ -165,7 +167,7 @@ def get_gui_data(config_dict: dict) -> Tuple[bool, List[str]]: sg.Popup(_t("main_gui.repository_not_configured")) return None, None try: - runner = NPBackupRunner(config_dict=config_dict) + runner = NPBackupRunner(repo_config=repo_config) except ValueError: sg.Popup(_t("config_gui.no_runner")) return None, None @@ -177,7 +179,7 @@ def get_gui_data(config_dict: dict) -> Tuple[bool, List[str]]: return None, None # We get a thread result, hence pylint will complain the thread isn't a tuple # pylint: disable=E1101 (no-member) - thread = _get_gui_data(config_dict) + thread = _get_gui_data(repo_config) while not thread.done() and not thread.cancelled(): sg.PopupAnimated( LOADER_ANIMATION, @@ -274,15 +276,15 @@ def _make_treedata_from_json(ls_result: List[dict]) -> sg.TreeData: @threaded -def _forget_snapshot(config: dict, snapshot_id: str) -> Future: - runner = NPBackupRunner(config_dict=config) +def _forget_snapshot(repo_config: dict, snapshot_id: str) -> Future: + runner = NPBackupRunner(repo_config=repo_config) result = runner.forget(snapshot=snapshot_id) return result @threaded -def _ls_window(config: dict, snapshot_id: str) -> Future: - runner = NPBackupRunner(config_dict=config) +def _ls_window(repo_config: dict, snapshot_id: str) -> Future: + runner = NPBackupRunner(repo_config=repo_config) result = runner.ls(snapshot=snapshot_id) if not result: return result, None @@ -446,9 +448,9 @@ def ls_window(config: dict, snapshot_id: str) -> bool: @threaded def _restore_window( - config_dict: dict, snapshot: str, target: str, restore_includes: Optional[List] + repo_config: dict, snapshot: str, target: str, restore_includes: Optional[List] ) -> Future: - runner = NPBackupRunner(config_dict=config_dict) + runner = NPBackupRunner(repo_config=repo_config) runner.verbose = True result = runner.restore(snapshot, target, restore_includes) THREAD_SHARED_DICT["exec_time"] = runner.exec_time @@ -456,7 +458,7 @@ def _restore_window( def restore_window( - config_dict: dict, snapshot_id: str, restore_include: List[str] + repo_config: dict, snapshot_id: str, restore_include: List[str] ) -> None: left_col = [ [ @@ -484,7 +486,7 @@ def restore_window( # We get a thread result, hence pylint will complain the thread isn't a tuple # pylint: disable=E1101 (no-member) thread = _restore_window( - config_dict=config_dict, + repo_config=repo_config, snapshot=snapshot_id, target=values["-RESTORE-FOLDER-"], restore_includes=restore_include, @@ -517,8 +519,8 @@ def restore_window( @threaded -def _gui_backup(config_dict, stdout) -> Future: - runner = NPBackupRunner(config_dict=config_dict) +def _gui_backup(repo_config, stdout) -> Future: + runner = NPBackupRunner(repo_config=repo_config) runner.verbose = ( True # We must use verbose so we get progress output from ResticRunner ) @@ -531,8 +533,19 @@ def _gui_backup(config_dict, stdout) -> Future: def _main_gui(): + config_file = Path(f'{CURRENT_DIR}/npbackup.conf') + if config_file.exists(): + full_config = npbackup.configuration.load_config(config_file) + # TODO add a repo selector + repo_config, inherit_config = npbackup.configuration.get_repo_config(full_config) + else: + # TODO: Add config load directory + sg.Popup("Cannot load configuration file") + sys.exit(3) + + backup_destination = _t("main_gui.local_folder") - backend_type, repo_uri = get_anon_repo_uri(config_dict["repo"]["repository"]) + backend_type, repo_uri = get_anon_repo_uri(repo_config.g('repo_uri')) right_click_menu = ["", [_t("generic.destination")]] headings = [ @@ -620,7 +633,7 @@ def _main_gui(): window["snapshot-list"].expand(True, True) window.read(timeout=1) - current_state, backup_tz, snapshot_list = get_gui_data(config_dict) + current_state, backup_tz, snapshot_list = get_gui_data(repo_config) _gui_update_state(window, current_state, backup_tz, snapshot_list) while True: event, values = window.read(timeout=60000) @@ -647,7 +660,7 @@ def _main_gui(): # let's use a mutable so the backup thread can modify it # We get a thread result, hence pylint will complain the thread isn't a tuple # pylint: disable=E1101 (no-member) - thread = _gui_backup(config_dict=config_dict, stdout=stdout) + thread = _gui_backup(repo_config=repo_config, stdout=stdout) while not thread.done() and not thread.cancelled(): try: stdout_line = stdout.get(timeout=0.01) @@ -669,7 +682,7 @@ def _main_gui(): exec_time = THREAD_SHARED_DICT["exec_time"] except KeyError: exec_time = "N/A" - current_state, backup_tz, snapshot_list = get_gui_data(config_dict) + current_state, backup_tz, snapshot_list = get_gui_data(repo_config) _gui_update_state(window, current_state, backup_tz, snapshot_list) if not result: sg.PopupError( @@ -685,11 +698,12 @@ def _main_gui(): if not values["snapshot-list"]: sg.Popup(_t("main_gui.select_backup"), keep_on_top=True) continue - if len(values["snapshot-list"] > 1): + print(values["snapshot-list"]) + if len(values["snapshot-list"]) > 1: sg.Popup(_t("main_gui.select_only_one_snapshot")) continue snapshot_to_see = snapshot_list[values["snapshot-list"][0]][0] - ls_window(config_dict, snapshot_to_see) + ls_window(repo_config, snapshot_to_see) if event == "forget": if not values["snapshot-list"]: sg.Popup(_t("main_gui.select_backup"), keep_on_top=True) @@ -697,32 +711,32 @@ def _main_gui(): snapshots_to_forget = [] for row in values["snapshot-list"]: snapshots_to_forget.append(snapshot_list[row][0]) - forget_snapshot(config_dict, snapshots_to_forget) + forget_snapshot(repo_config, snapshots_to_forget) # Make sure we trigger a GUI refresh after forgetting snapshots event = "state-button" if event == "operations": - config_dict = operations_gui(config_dict, config_file) + repo_config = operations_gui(repo_config, config_file) event = "state-button" if event == "configure": - config_dict = config_gui(config_dict, config_file) + repo_config = config_gui(repo_config, config_file) # Make sure we trigger a GUI refresh when configuration is changed event = "state-button" if event == _t("generic.destination"): try: if backend_type: if backend_type in ["REST", "SFTP"]: - destination_string = config_dict["repo"]["repository"].split( + destination_string = repo_config.g("repo_uri").split( "@" )[-1] else: - destination_string = config_dict["repo"]["repository"] + destination_string = repo_config.g("repo_uri") sg.PopupNoFrame(destination_string) except (TypeError, KeyError): sg.PopupNoFrame(_t("main_gui.unknown_repo")) if event == "about": - _about_gui(version_string, config_dict) + _about_gui(version_string, repo_config) if event == "state-button": - current_state, backup_tz, snapshot_list = get_gui_data(config_dict) + current_state, backup_tz, snapshot_list = get_gui_data(repo_config) _gui_update_state(window, current_state, backup_tz, snapshot_list) if current_state is None: sg.Popup(_t("main_gui.cannot_get_repo_status")) From fe81eaf471514ff3f8c2902255595a5b8a0ce39a Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Mon, 11 Dec 2023 20:45:48 +0100 Subject: [PATCH 021/328] No need to evaluate variables anymore in runtime --- npbackup/core/upgrade_runner.py | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/npbackup/core/upgrade_runner.py b/npbackup/core/upgrade_runner.py index 6d13c7d..c30f3fa 100644 --- a/npbackup/core/upgrade_runner.py +++ b/npbackup/core/upgrade_runner.py @@ -40,18 +40,8 @@ def run_upgrade(config_dict): logger.error("Missing auto upgrade info: %s, cannot launch auto upgrade", exc) return False - try: - auto_upgrade_host_identity = configuration.evaluate_variables( - config_dict, config_dict["options"]["auto_upgrade_host_identity"] - ) - except KeyError: - auto_upgrade_host_identity = None - try: - group = configuration.evaluate_variables( - config_dict, config_dict["options"]["auto_upgrade_group"] - ) - except KeyError: - group = None + auto_upgrade_host_identity = config_dict.g("global_options.auto_upgrade_host_identity") + group = config_dict.g("global_options.auto_upgrade_group") result = auto_upgrader( upgrade_url=auto_upgrade_upgrade_url, From eec1fe5e991387e9e907320666d440c8e23f833a Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Mon, 11 Dec 2023 20:46:24 +0100 Subject: [PATCH 022/328] Refactor iter_over_keys into replace_in_iterable --- npbackup/configuration.py | 259 +++++++++++++++++++++++--------------- 1 file changed, 155 insertions(+), 104 deletions(-) diff --git a/npbackup/configuration.py b/npbackup/configuration.py index 75de07a..016f267 100644 --- a/npbackup/configuration.py +++ b/npbackup/configuration.py @@ -10,7 +10,9 @@ __build__ = "2023121001" __version__ = "2.0.0 for npbackup 2.3.0+" -from typing import Tuple, Optional, List, Callable, Any +CONF_VERSION = 2.3 + +from typing import Tuple, Optional, List, Callable, Any, Union import sys import os from copy import deepcopy @@ -22,7 +24,6 @@ import platform from cryptidy import symmetric_encryption as enc from ofunctions.random import random_string -from ofunctions.misc import deep_dict_update from npbackup.customization import ID_STRING @@ -91,6 +92,7 @@ def s(self, path, value, sep='.'): # This is what a config file looks like empty_config_dict = { + "conf_version": CONF_VERSION, "repos": { "default": { "repo_uri": "", @@ -191,15 +193,58 @@ def iter_over_keys(d: dict, fn: Callable) -> dict: """ Execute value=fn(value) on any key in a nested env """ - for key, value in d.items(): - if isinstance(value, dict): - d[key] = iter_over_keys(value, fn) - else: - d[key] = fn(key, d[key]) + if isinstance(d, dict): + for key, value in d.items(): + if isinstance(value, dict): + d[key] = iter_over_keys(value, fn) + else: + d[key] = fn(key, d[key]) return d -def crypt_config(config: dict, aes_key: str, encrypted_options: List[str], operation: str): +# TODO: use ofunctions.misc +def replace_in_iterable( + src: Union[dict, list], original: Union[str, Callable], replacement: Any = None, callable_wants_key: bool = False +): + """ + Recursive replace data in a struct + + Replaces every instance of string original with string replacement in a list/dict + + If original is a callable function, it will replace every instance of original with callable(original) + If original is a callable function and callable_wants_key == True, + it will replace every instance of original with callable(key, original) for dicts + and with callable(original) for any other data type + """ + + def _replace_in_iterable(key, _src): + if isinstance(_src, dict) or isinstance(_src, list): + _src = replace_in_iterable(_src, original, replacement, callable_wants_key) + elif isinstance(original, Callable): + if callable_wants_key: + _src = original(key, _src) + else: + _src = original(_src) + elif isinstance(_src, str) and isinstance(replacement, str): + _src = _src.replace(original, replacement) + else: + _src = replacement + return _src + + if isinstance(src, dict): + for key, value in src.items(): + src[key] = _replace_in_iterable(key, value) + elif isinstance(src, list): + result = [] + for entry in src: + result.append(_replace_in_iterable(None, entry)) + src = result + else: + src = _replace_in_iterable(None, src) + return src + + +def crypt_config(full_config: dict, aes_key: str, encrypted_options: List[str], operation: str): try: def _crypt_config(key: str, value: Any) -> Any: if key in encrypted_options: @@ -220,13 +265,13 @@ def _crypt_config(key: str, value: Any) -> Any: raise ValueError(f"Bogus operation {operation} given") return value - return iter_over_keys(config, _crypt_config) + return replace_in_iterable(full_config, _crypt_config, callable_wants_key=True) except Exception as exc: - logger.error(f"Cannot {operation} configuration.") + logger.error(f"Cannot {operation} configuration: {exc}.") return False -def is_encrypted(config: dict) -> bool: +def is_encrypted(full_config: dict) -> bool: is_encrypted = True def _is_encrypted(key, value) -> Any: @@ -237,17 +282,17 @@ def _is_encrypted(key, value) -> Any: is_encrypted = True return value - iter_over_keys(config, _is_encrypted) + replace_in_iterable(full_config, _is_encrypted, callable_wants_key=True) return is_encrypted -def has_random_variables(config: dict) -> Tuple[bool, dict]: +def has_random_variables(full_config: dict) -> Tuple[bool, dict]: """ Replaces ${RANDOM}[n] with n random alphanumeric chars, directly in config dict """ is_modified = False - def _has_random_variables(key, value) -> Any: + def _has_random_variables(value) -> Any: nonlocal is_modified if isinstance(value, str): @@ -261,79 +306,68 @@ def _has_random_variables(key, value) -> Any: is_modified = True return value - config = iter_over_keys(config, _has_random_variables) - return is_modified, config + full_config = replace_in_iterable(full_config, _has_random_variables) + return is_modified, full_config -def evaluate_variables(config: dict, value: str) -> str: +def evaluate_variables(repo_config: dict, full_config: dict) -> dict: """ - Replaces various variables with their actual value in a string + Replace runtime variables with their corresponding value """ - - # We need to make a loop to catch all nested variables + def _evaluate_variables(value): + if isinstance(value, str): + if "${MACHINE_ID}" in value: + machine_id = full_config.g("identity.machine_id") + value = value.replace("${MACHINE_ID}", machine_id if machine_id else "") + + if "${MACHINE_GROUP}" in value: + machine_group = full_config.g("identity.machine_group") + value = value.replace("${MACHINE_GROUP}", machine_group if machine_group else "") + + if "${BACKUP_JOB}" in value: + backup_job = repo_config.g("backup_opts.backup_job") + value = value.replace("${BACKUP_JOB}", backup_job if backup_job else "") + + if "${HOSTNAME}" in value: + value = value.replace("${HOSTNAME}", platform.node()) + return value + + # We need to make a loop to catch all nested variables (ie variable in a variable) # but we also need a max recursion limit # If each variable has two sub variables, we'd have max 4x2x2 loops + # While this is not the most efficient way, we still get to catch all nested variables + # and of course, we don't have thousands of lines to parse, so we're good count = 0 maxcount = 4 * 2 * 2 - while ( - "${MACHINE_ID}" in value - or "${MACHINE_GROUP}" in value - or "${BACKUP_JOB}" in value - or "${HOSTNAME}" in value - ) and count <= maxcount: - value = value.replace("${HOSTNAME}", platform.node()) - - try: - new_value = config["identity"]["machine_id"] - # TypeError may happen if config_dict[x][y] is None - except (KeyError, TypeError): - new_value = None - value = value.replace("${MACHINE_ID}", new_value if new_value else "") - - try: - new_value = config["identity"]["machine_group"] - # TypeError may happen if config_dict[x][y] is None - except (KeyError, TypeError): - new_value = None - value = value.replace("${MACHINE_GROUP}", new_value if new_value else "") - - try: - new_value = config["prometheus"]["backup_job"] - # TypeError may happen if config_dict[x][y] is None - except (KeyError, TypeError): - new_value = None - value = value.replace("${BACKUP_JOB}", new_value if new_value else "") - + while count < maxcount: + repo_config = replace_in_iterable(repo_config, _evaluate_variables) count += 1 - return value + return repo_config -def get_repo_config(config: dict, repo_name: str = 'default') -> Tuple[dict, dict]: +def get_repo_config(full_config: dict, repo_name: str = 'default') -> Tuple[dict, dict]: """ - Created inherited repo config - Returns a dict containing the repo config + Create inherited repo config + Returns a dict containing the repo config, with expanded variables and a dict containing the repo interitance status """ - def _is_inheritance(key, value): - return False - repo_config = ordereddict() config_inheritance = ordereddict() try: - repo_config = deepcopy(config.g(f'repos.{repo_name}')) + repo_config = deepcopy(full_config.g(f'repos.{repo_name}')) # Let's make a copy of config since it's a "pointer object" - config_inheritance = iter_over_keys(deepcopy(config.g(f'repos.{repo_name}')), _is_inheritance) + config_inheritance = replace_in_iterable(deepcopy(full_config.g(f'repos.{repo_name}')), False) except KeyError: logger.error(f"No repo with name {repo_name} found in config") return None try: - repo_group = config.g(f'repos.{repo_name}.group') + repo_group = full_config.g(f'repos.{repo_name}.group') except KeyError: logger.warning(f"Repo {repo_name} has no group") else: - sections = config.g(f'groups.{repo_group}') + sections = full_config.g(f'groups.{repo_group}') if sections: for section in sections: # TODO: ordereddict.g() returns None when key doesn't exist instead of KeyError @@ -345,75 +379,92 @@ def _is_inheritance(key, value): except KeyError: repo_config.s(section, {}) config_inheritance.s(section, {}) - sub_sections = config.g(f'groups.{repo_group}.{section}') + sub_sections = full_config.g(f'groups.{repo_group}.{section}') if sub_sections: for entries in sub_sections: # Do not overwrite repo values already present if not repo_config.g(f'{section}.{entries}'): - repo_config.s(f'{section}.{entries}', config.g(f'groups.{repo_group}.{section}.{entries}')) + repo_config.s(f'{section}.{entries}', full_config.g(f'groups.{repo_group}.{section}.{entries}')) config_inheritance.s(f'{section}.{entries}', True) else: config_inheritance.s(f'{section}.{entries}', False) + + repo_config = evaluate_variables(repo_config, full_config) return repo_config, config_inheritance -def load_config(config_file: Path) -> Optional[dict]: - logger.info(f"Loading configuration file {config_file}") + +def _load_config_file(config_file: Path) -> Union[bool, dict]: + """ + Checks whether config file is valid + """ try: with open(config_file, "r", encoding="utf-8") as file_handle: - # Roundtrip loader is default and preserves comments and ordering yaml = YAML(typ="rt") - config = yaml.load(file_handle) - config_file_is_updated = False + full_config = yaml.load(file_handle) - # Check if we need to encrypt some variables - if not is_encrypted(config): - logger.info("Encrypting non encrypted data in configuration file") - config_file_is_updated = True - - # Decrypt variables - config = crypt_config(config, AES_KEY, ENCRYPTED_OPTIONS, operation='decrypt') - if config == False: - if EARLIER_AES_KEY: - logger.warning("Trying to migrate encryption key") - config = crypt_config(config, EARLIER_AES_KEY, ENCRYPTED_OPTIONS, operation='decrypt') - if config == False: - logger.critical("Cannot decrypt config file with earlier key") - sys.exit(12) - else: - config_file_is_updated = True - logger.warning("Successfully migrated encryption key") - else: - logger.critical("Cannot decrypt config file") - sys.exit(11) + conf_version = full_config.g("conf_version") + if conf_version != CONF_VERSION: + logger.critical(f"Config file version {conf_version} is not required version {CONF_VERSION}") + return False + return full_config + except OSError: + logger.critical(f"Cannot load configuration file from {config_file}") + return False +def load_config(config_file: Path) -> Optional[dict]: + logger.info(f"Loading configuration file {config_file}") - # Check if we need to expand random vars - is_modified, config = has_random_variables(config) - if is_modified: + full_config = _load_config_file(config_file) + if not full_config: + return None + config_file_is_updated = False + print(full_config) + + # Check if we need to encrypt some variables + if not is_encrypted(full_config): + logger.info("Encrypting non encrypted data in configuration file") + config_file_is_updated = True + print(full_config) + # Decrypt variables + full_config = crypt_config(full_config, AES_KEY, ENCRYPTED_OPTIONS, operation='decrypt') + if full_config == False: + if EARLIER_AES_KEY: + logger.warning("Trying to migrate encryption key") + full_config = crypt_config(full_config, EARLIER_AES_KEY, ENCRYPTED_OPTIONS, operation='decrypt') + if full_config == False: + logger.critical("Cannot decrypt config file with earlier key") + sys.exit(12) + else: config_file_is_updated = True - logger.info("Handling random variables in configuration files") + logger.warning("Successfully migrated encryption key") + else: + logger.critical("Cannot decrypt config file") + sys.exit(11) - # save config file if needed - if config_file_is_updated: - logger.info("Updating config file") - save_config(config_file, config) - return config + # Check if we need to expand random vars + is_modified, full_config = has_random_variables(full_config) + if is_modified: + config_file_is_updated = True + logger.info("Handling random variables in configuration files") - except OSError: - logger.critical(f"Cannot load configuration file from {config_file}") - return None + # save config file if needed + if config_file_is_updated: + logger.info("Updating config file") + save_config(config_file, full_config) + + return full_config -def save_config(config_file: Path, config: dict) -> bool: +def save_config(config_file: Path, full_config: dict) -> bool: try: with open(config_file, "w", encoding="utf-8") as file_handle: - if not is_encrypted(config): - config = crypt_config(config, AES_KEY, ENCRYPTED_OPTIONS, operation='encrypt') + if not is_encrypted(full_config): + full_config = crypt_config(full_config, AES_KEY, ENCRYPTED_OPTIONS, operation='encrypt') yaml = YAML(typ="rt") - yaml.dump(config, file_handle) + yaml.dump(full_config, file_handle) # Since yaml is a "pointer object", we need to decrypt after saving - config = crypt_config(config, AES_KEY, ENCRYPTED_OPTIONS, operation='decrypt') + full_config = crypt_config(full_config, AES_KEY, ENCRYPTED_OPTIONS, operation='decrypt') return True except OSError: logger.critical(f"Cannot save configuration file to {config_file}") From 04b038d457475e92721544aa9fca58620544956e Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Mon, 11 Dec 2023 20:46:57 +0100 Subject: [PATCH 023/328] WIP: Add multi repo support --- npbackup/gui/__main__.py | 65 ++++++++++++++++++++------- npbackup/translations/generic.en.yml | 2 + npbackup/translations/generic.fr.yml | 4 +- npbackup/translations/main_gui.en.yml | 3 +- 4 files changed, 57 insertions(+), 17 deletions(-) diff --git a/npbackup/gui/__main__.py b/npbackup/gui/__main__.py index 2409a6c..c39bbbb 100644 --- a/npbackup/gui/__main__.py +++ b/npbackup/gui/__main__.py @@ -532,17 +532,46 @@ def _gui_backup(repo_config, stdout) -> Future: return result +def select_config_file(): + """ + Option to select a configuration file + """ + layout = [ + [sg.Text(_t("main_gui.select_config_file")), sg.Input(key="-config_file-"), sg.FileBrowse(_t("generic.select_file"))], + [sg.Button(_t("generic.cancel"), key="-CANCEL-"), sg.Button(_t("generic.accept"), key="-ACCEPT-")] + ] + window = sg.Window("Configuration File", layout=layout) + while True: + event, values = window.read() + if event in [sg.WIN_X_EVENT, sg.WIN_CLOSED, '-CANCEL-']: + break + if event == '-ACCEPT-': + config_file = Path(values["-config_file-"]) + if not config_file.exists(): + sg.PopupError(_t("generic.file_does_not_exist")) + continue + config = npbackup.configuration._load_config_file(config_file) + if not config: + sg.PopupError(_t("generic.bad_file")) + continue + return config_file + + def _main_gui(): config_file = Path(f'{CURRENT_DIR}/npbackup.conf') - if config_file.exists(): - full_config = npbackup.configuration.load_config(config_file) - # TODO add a repo selector - repo_config, inherit_config = npbackup.configuration.get_repo_config(full_config) - else: - # TODO: Add config load directory - sg.Popup("Cannot load configuration file") - sys.exit(3) + if not config_file.exists(): + while True: + config_file = select_config_file() + if config_file: + config_file = select_config_file() + else: + break + logger.info(f"Using configuration file {config_file}") + full_config = npbackup.configuration.load_config(config_file) + # TODO add a repo selector + repo_config, inherit_config = npbackup.configuration.get_repo_config(full_config) + repo_list = list(full_config.g("repos").keys()) backup_destination = _t("main_gui.local_folder") backend_type, repo_uri = get_anon_repo_uri(repo_config.g('repo_uri')) @@ -582,11 +611,9 @@ def _main_gui(): ), ], [ - sg.Text( - "{} {} {}".format( - _t("main_gui.backup_list_to"), backend_type, repo_uri - ) - ) + sg.Text(_t("main_gui.backup_list_to")), + sg.Combo(repo_list, key="-active_repo-", default_value=repo_list[0], enable_events=True), + sg.Text(f"Type {backend_type}", key="-backend_type-") ], [ sg.Table( @@ -605,7 +632,7 @@ def _main_gui(): sg.Button(_t("main_gui.operations"), key="operations"), sg.Button(_t("generic.configure"), key="configure"), sg.Button(_t("generic.about"), key="about"), - sg.Button(_t("generic.quit"), key="exit"), + sg.Button(_t("generic.quit"), key="-EXIT-"), ], ], element_justification="C", @@ -638,8 +665,16 @@ def _main_gui(): while True: event, values = window.read(timeout=60000) - if event in (sg.WIN_CLOSED, "exit"): + if event in [sg.WIN_X_EVENT, sg.WIN_CLOSED, '-EXIT-']: break + if event == "-active_repo-": + active_repo = values['-active_repo-'] + if full_config.g(f"repos.{active_repo}"): + repo_config = npbackup.configuration.get_repo_config(full_config, active_repo) + current_state, backup_tz, snapshot_list = get_gui_data(repo_config) + else: + sg.PopupError("Repo not existent in config") + continue if event == "launch-backup": progress_windows_layout = [ [ diff --git a/npbackup/translations/generic.en.yml b/npbackup/translations/generic.en.yml index 85013a1..e516ee3 100644 --- a/npbackup/translations/generic.en.yml +++ b/npbackup/translations/generic.en.yml @@ -50,3 +50,5 @@ en: no_snapshots: No snapshots are_you_sure: Are you sure ? + + select_file: Select file diff --git a/npbackup/translations/generic.fr.yml b/npbackup/translations/generic.fr.yml index 7db7682..f987019 100644 --- a/npbackup/translations/generic.fr.yml +++ b/npbackup/translations/generic.fr.yml @@ -49,4 +49,6 @@ fr: no_snapshots: Aucun instantané - are_you_sure: Etes-vous sûr ? \ No newline at end of file + are_you_sure: Etes-vous sûr ? + + select_file: Selection fichier \ No newline at end of file diff --git a/npbackup/translations/main_gui.en.yml b/npbackup/translations/main_gui.en.yml index 72cddca..60f7b46 100644 --- a/npbackup/translations/main_gui.en.yml +++ b/npbackup/translations/main_gui.en.yml @@ -32,4 +32,5 @@ en: repository_not_configured: Repository not configured execute_operation: Executing operation forget_failed: Failed to forget. Please check the logs - operations: Operations \ No newline at end of file + operations: Operations + select_config_file: Select config file \ No newline at end of file From 78a1e264927ab337f2a9189ce59fe70e242cf24c Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Mon, 11 Dec 2023 21:44:34 +0100 Subject: [PATCH 024/328] Require new ofunctions.misc with replace_in_iterable --- npbackup/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/npbackup/requirements.txt b/npbackup/requirements.txt index c84bd2b..3dc83cd 100644 --- a/npbackup/requirements.txt +++ b/npbackup/requirements.txt @@ -2,7 +2,7 @@ command_runner>=1.5.1 cryptidy>=1.2.2 python-dateutil ofunctions.logger_utils>=2.3.0 -ofunctions.misc>=1.5.2 +ofunctions.misc>=1.6.1 ofunctions.process>=1.4.0 ofunctions.threading>=2.0.0 ofunctions.platform>=1.4.1 From 9a237d86a6d6496f52de716b7c3d7dcc82faae57 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Wed, 13 Dec 2023 13:26:01 +0100 Subject: [PATCH 025/328] WIP Refactor config GUI --- npbackup/configuration.py | 20 +- npbackup/gui/config.py | 852 ++++++++++++++++++++------------------ 2 files changed, 471 insertions(+), 401 deletions(-) diff --git a/npbackup/configuration.py b/npbackup/configuration.py index 016f267..3383a25 100644 --- a/npbackup/configuration.py +++ b/npbackup/configuration.py @@ -393,6 +393,16 @@ def get_repo_config(full_config: dict, repo_name: str = 'default') -> Tuple[dict return repo_config, config_inheritance +def get_group_config(full_config: dict, group_name: str) -> dict: + try: + group_config = deepcopy(full_config.g(f"groups.{group_name}")) + except KeyError: + logger.error(f"No group with name {group_name} found in config") + return None + + return group_config + + def _load_config_file(config_file: Path) -> Union[bool, dict]: """ Checks whether config file is valid @@ -418,13 +428,11 @@ def load_config(config_file: Path) -> Optional[dict]: if not full_config: return None config_file_is_updated = False - print(full_config) # Check if we need to encrypt some variables if not is_encrypted(full_config): logger.info("Encrypting non encrypted data in configuration file") config_file_is_updated = True - print(full_config) # Decrypt variables full_config = crypt_config(full_config, AES_KEY, ENCRYPTED_OPTIONS, operation='decrypt') if full_config == False: @@ -469,3 +477,11 @@ def save_config(config_file: Path, full_config: dict) -> bool: except OSError: logger.critical(f"Cannot save configuration file to {config_file}") return False + + +def get_repo_list(full_config: dict) -> List[str]: + return list(full_config.g("repos").keys()) + + +def get_group_list(full_config: dict) -> List[str]: + return list(full_config.g("groups").keys()) \ No newline at end of file diff --git a/npbackup/gui/config.py b/npbackup/gui/config.py index 0e43726..768032f 100644 --- a/npbackup/gui/config.py +++ b/npbackup/gui/config.py @@ -10,6 +10,7 @@ __build__ = "2023121001" +from typing import List import os from logging import getLogger import PySimpleGUI as sg @@ -42,7 +43,7 @@ def ask_backup_admin_password(config_dict) -> bool: return False -def config_gui(config_dict: dict, config_file: str): +def config_gui(full_config: dict, config_file: str): logger.info("Launching configuration GUI") # Don't let PySimpleGUI handle key errros since we might have new keys in config file @@ -73,8 +74,38 @@ def config_gui(config_dict: dict, config_file: str): ENCRYPTED_DATA_PLACEHOLDER = "<{}>".format(_t("config_gui.encrypted_data")) - def update_gui(window, config_dict, unencrypted=False): - for section in config_dict.keys(): + def get_objects() -> List[str]: + """ + Adds repos and groups in a list for combobox + """ + object_list = [] + for repo in configuration.get_repo_list(full_config): + object_list.append(f"Repo: {repo}") + for group in configuration.get_group_list(full_config): + object_list.append(f"Group: {group}") + return object_list + + + def get_object_from_combo(combo_value: str) -> (str, str): + """ + Extracts selected object from combobox + Returns object type and name + """ + object_list = get_objects() + + if combo_value.startswith("Repo: "): + object_type = "repo" + object_name = combo_value[len("Repo: "):] + elif combo_value.startswith("Group: "): + object_type = "group" + object_name = combo_value[len("Group: "):] + return object_type, object_name + + + def update_gui(object_config, config_inheritance, object_type, unencrypted=False): + for section in object_config.keys(): + print(section) + continue if config_dict[section] is None: config_dict[section] = {} for entry in config_dict[section].keys(): @@ -122,6 +153,7 @@ def update_gui(window, config_dict, unencrypted=False): except TypeError as exc: logger.error("{} for {}.".format(exc, entry)) + def update_config_dict(values, config_dict): for key, value in values.items(): if value == ENCRYPTED_DATA_PLACEHOLDER: @@ -158,408 +190,418 @@ def update_config_dict(values, config_dict): config_dict[section][entry] = value return config_dict - right_click_menu = ["", [_t("config_gui.show_decrypted")]] - backup_col = [ - [ - sg.Text(_t("config_gui.compression"), size=(40, 1)), - sg.Combo( - list(combo_boxes["compression"].values()), - key="backup---compression", - size=(48, 1), - ), - ], - [ - sg.Text( - "{}\n({})".format( - _t("config_gui.backup_paths"), _t("config_gui.one_per_line") + def layout(): + backup_col = [ + [ + sg.Text(_t("config_gui.compression"), size=(40, 1)), + sg.Combo( + list(combo_boxes["compression"].values()), + key="backup---compression", + size=(48, 1), ), - size=(40, 2), - ), - sg.Multiline(key="backup---paths", size=(48, 4)), - ], - [ - sg.Text(_t("config_gui.source_type"), size=(40, 1)), - sg.Combo( - list(combo_boxes["source_type"].values()), - key="backup---source_type", - size=(48, 1), - ), - ], - [ - sg.Text( - "{}\n({})".format( - _t("config_gui.use_fs_snapshot"), _t("config_gui.windows_only") + ], + [ + sg.Text( + "{}\n({})".format( + _t("config_gui.backup_paths"), _t("config_gui.one_per_line") + ), + size=(40, 2), ), - size=(40, 2), - ), - sg.Checkbox("", key="backup---use_fs_snapshot", size=(41, 1)), - ], - [ - sg.Text( - "{}\n({})".format( - _t("config_gui.ignore_cloud_files"), _t("config_gui.windows_only") + sg.Multiline(key="backup---paths", size=(48, 4)), + ], + [ + sg.Text(_t("config_gui.source_type"), size=(40, 1)), + sg.Combo( + list(combo_boxes["source_type"].values()), + key="backup---source_type", + size=(48, 1), ), - size=(40, 2), - ), - sg.Checkbox("", key="backup---ignore_cloud_files", size=(41, 1)), - ], - [ - sg.Text( - "{}\n({})".format( - _t("config_gui.exclude_patterns"), _t("config_gui.one_per_line") + ], + [ + sg.Text( + "{}\n({})".format( + _t("config_gui.use_fs_snapshot"), _t("config_gui.windows_only") + ), + size=(40, 2), ), - size=(40, 2), - ), - sg.Multiline(key="backup---exclude_patterns", size=(48, 4)), - ], - [ - sg.Text( - "{}\n({})".format( - _t("config_gui.exclude_files"), _t("config_gui.one_per_line") + sg.Checkbox("", key="backup---use_fs_snapshot", size=(41, 1)), + ], + [ + sg.Text( + "{}\n({})".format( + _t("config_gui.ignore_cloud_files"), _t("config_gui.windows_only") + ), + size=(40, 2), ), - size=(40, 2), - ), - sg.Multiline(key="backup---exclude_files", size=(48, 4)), - ], - [ - sg.Text( - "{}\n({})".format( - _t("config_gui.exclude_case_ignore"), - _t("config_gui.windows_always"), + sg.Checkbox("", key="backup---ignore_cloud_files", size=(41, 1)), + ], + [ + sg.Text( + "{}\n({})".format( + _t("config_gui.exclude_patterns"), _t("config_gui.one_per_line") + ), + size=(40, 2), ), - size=(40, 2), - ), - sg.Checkbox("", key="backup---exclude_case_ignore", size=(41, 1)), - ], - [ - sg.Text(_t("config_gui.exclude_cache_dirs"), size=(40, 1)), - sg.Checkbox("", key="backup---exclude_caches", size=(41, 1)), - ], - [ - sg.Text(_t("config_gui.one_file_system"), size=(40, 1)), - sg.Checkbox("", key="backup---one_file_system", size=(41, 1)), - ], - [ - sg.Text(_t("config_gui.pre_exec_command"), size=(40, 1)), - sg.Input(key="backup---pre_exec_command", size=(50, 1)), - ], - [ - sg.Text(_t("config_gui.maximum_exec_time"), size=(40, 1)), - sg.Input(key="backup---pre_exec_timeout", size=(50, 1)), - ], - [ - sg.Text(_t("config_gui.exec_failure_is_fatal"), size=(40, 1)), - sg.Checkbox("", key="backup---pre_exec_failure_is_fatal", size=(41, 1)), - ], - [ - sg.Text(_t("config_gui.post_exec_command"), size=(40, 1)), - sg.Input(key="backup---post_exec_command", size=(50, 1)), - ], - [ - sg.Text(_t("config_gui.maximum_exec_time"), size=(40, 1)), - sg.Input(key="backup---post_exec_timeout", size=(50, 1)), - ], - [ - sg.Text(_t("config_gui.exec_failure_is_fatal"), size=(40, 1)), - sg.Checkbox("", key="backup---post_exec_failure_is_fatal", size=(41, 1)), - ], - [ - sg.Text( - "{}\n({})".format(_t("config_gui.tags"), _t("config_gui.one_per_line")), - size=(40, 2), - ), - sg.Multiline(key="backup---tags", size=(48, 2)), - ], - [ - sg.Text(_t("config_gui.backup_priority"), size=(40, 1)), - sg.Combo( - list(combo_boxes["priority"].values()), - key="backup---priority", - size=(48, 1), - ), - ], - [ - sg.Text(_t("config_gui.additional_parameters"), size=(40, 1)), - sg.Input(key="backup---additional_parameters", size=(50, 1)), - ], - [ - sg.Text(_t("config_gui.additional_backup_only_parameters"), size=(40, 1)), - sg.Input(key="backup---additional_backup_only_parameters", size=(50, 1)), - ], - ] - - repo_col = [ - [ - sg.Text( - "{}\n({})".format( - _t("config_gui.minimum_backup_age"), _t("generic.minutes") + sg.Multiline(key="backup---exclude_patterns", size=(48, 4)), + ], + [ + sg.Text( + "{}\n({})".format( + _t("config_gui.exclude_files"), _t("config_gui.one_per_line") + ), + size=(40, 2), ), - size=(40, 2), - ), - sg.Input(key="repo---minimum_backup_age", size=(50, 1)), - ], - [ - sg.Text(_t("config_gui.backup_repo_uri"), size=(40, 1)), - sg.Input(key="repo---repository", size=(50, 1)), - ], - [ - sg.Text(_t("config_gui.backup_repo_password"), size=(40, 1)), - sg.Input(key="repo---password", size=(50, 1)), - ], - [ - sg.Text(_t("config_gui.backup_repo_password_command"), size=(40, 1)), - sg.Input(key="repo---password_command", size=(50, 1)), - ], - [ - sg.Text(_t("config_gui.upload_speed"), size=(40, 1)), - sg.Input(key="repo---upload_speed", size=(50, 1)), - ], - [ - sg.Text(_t("config_gui.download_speed"), size=(40, 1)), - sg.Input(key="repo---download_speed", size=(50, 1)), - ], - [ - sg.Text(_t("config_gui.backend_connections"), size=(40, 1)), - sg.Input(key="repo---backend_connections", size=(50, 1)), - ], - ] - - retention_col = [ - [sg.Text(_t("config_gui.retention_policy"))], - [ - sg.Text(_t("config_gui.custom_time_server_url"), size=(40, 1)), - sg.Input(key="retentionpolicy---custom_time_server", size=(50, 1)), - ], - [ - sg.Text(_t("config_gui.keep"), size=(40, 1)), - sg.Text(_t("config_gui.hourly"), size=(10, 1)), - ], - ] - - identity_col = [ - [sg.Text(_t("config_gui.available_variables_id"))], - [ - sg.Text(_t("config_gui.machine_id"), size=(40, 1)), - sg.Input(key="identity---machine_id", size=(50, 1)), - ], - [ - sg.Text(_t("config_gui.machine_group"), size=(40, 1)), - sg.Input(key="identity---machine_group", size=(50, 1)), - ], - ] - - prometheus_col = [ - [sg.Text(_t("config_gui.available_variables"))], - [ - sg.Text(_t("config_gui.enable_prometheus"), size=(40, 1)), - sg.Checkbox("", key="prometheus---metrics", size=(41, 1)), - ], - [ - sg.Text(_t("config_gui.job_name"), size=(40, 1)), - sg.Input(key="prometheus---backup_job", size=(50, 1)), - ], - [ - sg.Text(_t("config_gui.metrics_destination"), size=(40, 1)), - sg.Input(key="prometheus---destination", size=(50, 1)), - ], - [ - sg.Text(_t("config_gui.no_cert_verify"), size=(40, 1)), - sg.Checkbox("", key="prometheus---no_cert_verify", size=(41, 1)), - ], - [ - sg.Text(_t("config_gui.metrics_username"), size=(40, 1)), - sg.Input(key="prometheus---http_username", size=(50, 1)), - ], - [ - sg.Text(_t("config_gui.metrics_password"), size=(40, 1)), - sg.Input(key="prometheus---http_password", size=(50, 1)), - ], - [ - sg.Text(_t("config_gui.instance"), size=(40, 1)), - sg.Input(key="prometheus---instance", size=(50, 1)), - ], - [ - sg.Text(_t("generic.group"), size=(40, 1)), - sg.Input(key="prometheus---group", size=(50, 1)), - ], - [ - sg.Text( - "{}\n({}\n{})".format( - _t("config_gui.additional_labels"), - _t("config_gui.one_per_line"), - _t("config_gui.format_equals"), + sg.Multiline(key="backup---exclude_files", size=(48, 4)), + ], + [ + sg.Text( + "{}\n({})".format( + _t("config_gui.exclude_case_ignore"), + _t("config_gui.windows_always"), + ), + size=(40, 2), ), - size=(40, 3), - ), - sg.Multiline(key="prometheus---additional_labels", size=(48, 3)), - ], - ] - - env_col = [ - [ - sg.Text( - "{}\n({}\n{})".format( - _t("config_gui.environment_variables"), - _t("config_gui.one_per_line"), - _t("config_gui.format_equals"), + sg.Checkbox("", key="backup---exclude_case_ignore", size=(41, 1)), + ], + [ + sg.Text(_t("config_gui.exclude_cache_dirs"), size=(40, 1)), + sg.Checkbox("", key="backup---exclude_caches", size=(41, 1)), + ], + [ + sg.Text(_t("config_gui.one_file_system"), size=(40, 1)), + sg.Checkbox("", key="backup---one_file_system", size=(41, 1)), + ], + [ + sg.Text(_t("config_gui.pre_exec_command"), size=(40, 1)), + sg.Input(key="backup---pre_exec_command", size=(50, 1)), + ], + [ + sg.Text(_t("config_gui.maximum_exec_time"), size=(40, 1)), + sg.Input(key="backup---pre_exec_timeout", size=(50, 1)), + ], + [ + sg.Text(_t("config_gui.exec_failure_is_fatal"), size=(40, 1)), + sg.Checkbox("", key="backup---pre_exec_failure_is_fatal", size=(41, 1)), + ], + [ + sg.Text(_t("config_gui.post_exec_command"), size=(40, 1)), + sg.Input(key="backup---post_exec_command", size=(50, 1)), + ], + [ + sg.Text(_t("config_gui.maximum_exec_time"), size=(40, 1)), + sg.Input(key="backup---post_exec_timeout", size=(50, 1)), + ], + [ + sg.Text(_t("config_gui.exec_failure_is_fatal"), size=(40, 1)), + sg.Checkbox("", key="backup---post_exec_failure_is_fatal", size=(41, 1)), + ], + [ + sg.Text( + "{}\n({})".format(_t("config_gui.tags"), _t("config_gui.one_per_line")), + size=(40, 2), ), - size=(40, 3), - ), - sg.Multiline(key="env---variables", size=(48, 5)), - ], - [ - sg.Text( - "{}\n({}\n{})".format( - _t("config_gui.encrypted_environment_variables"), - _t("config_gui.one_per_line"), - _t("config_gui.format_equals"), + sg.Multiline(key="backup---tags", size=(48, 2)), + ], + [ + sg.Text(_t("config_gui.backup_priority"), size=(40, 1)), + sg.Combo( + list(combo_boxes["priority"].values()), + key="backup---priority", + size=(48, 1), ), - size=(40, 3), - ), - sg.Multiline(key="env---encrypted_variables", size=(48, 5)), - ], - ] - - options_col = [ - [sg.Text(_t("config_gui.available_variables"))], - [ - sg.Text(_t("config_gui.auto_upgrade"), size=(40, 1)), - sg.Checkbox("", key="options---auto_upgrade", size=(41, 1)), - ], - [ - sg.Text(_t("config_gui.auto_upgrade_server_url"), size=(40, 1)), - sg.Input(key="options---auto_upgrade_server_url", size=(50, 1)), - ], - [ - sg.Text(_t("config_gui.auto_upgrade_server_username"), size=(40, 1)), - sg.Input(key="options---auto_upgrade_server_username", size=(50, 1)), - ], - [ - sg.Text(_t("config_gui.auto_upgrade_server_password"), size=(40, 1)), - sg.Input(key="options---auto_upgrade_server_password", size=(50, 1)), - ], - [ - sg.Text(_t("config_gui.auto_upgrade_interval"), size=(40, 1)), - sg.Input(key="options---auto_upgrade_interval", size=(50, 1)), - ], - [ - sg.Text(_t("generic.identity"), size=(40, 1)), - sg.Input(key="options---auto_upgrade_host_identity", size=(50, 1)), - ], - [ - sg.Text(_t("generic.group"), size=(40, 1)), - sg.Input(key="options---auto_upgrade_group", size=(50, 1)), - ], - [sg.HorizontalSeparator(key="sep")], - [ - sg.Text(_t("config_gui.enter_backup_admin_password"), size=(40, 1)), - sg.Input(key="backup_admin_password", size=(50, 1), password_char="*"), - ], - [sg.Button(_t("generic.change"), key="change_backup_admin_password")], - ] - - scheduled_task_col = [ - [ - sg.Text(_t("config_gui.create_scheduled_task_every")), - sg.Input(key="scheduled_task_interval", size=(4, 1)), - sg.Text(_t("generic.minutes")), - sg.Button(_t("generic.create"), key="create_task"), - ], - [sg.Text(_t("config_gui.scheduled_task_explanation"))], - ] - - buttons = [ - [ - sg.Text(" " * 135), - sg.Button(_t("generic.accept"), key="accept"), - sg.Button(_t("generic.cancel"), key="cancel"), + ], + [ + sg.Text(_t("config_gui.additional_parameters"), size=(40, 1)), + sg.Input(key="backup---additional_parameters", size=(50, 1)), + ], + [ + sg.Text(_t("config_gui.additional_backup_only_parameters"), size=(40, 1)), + sg.Input(key="backup---additional_backup_only_parameters", size=(50, 1)), + ], ] - ] - - tab_group_layout = [ - [ - sg.Tab( - _t("config_gui.backup"), - [[sg.Column(backup_col, scrollable=True, vertical_scroll_only=True)]], - font="helvetica 16", - key="--tab-backup--", - element_justification="C", - ) - ], - [ - sg.Tab( - _t("config_gui.backup_destination"), - repo_col, - font="helvetica 16", - key="--tab-repo--", - element_justification="C", - ) - ], - [ - sg.Tab( - _t("config_gui.retention_policy"), - retention_col, - font="helvetica 16", - key="--tab-retentino--", - element_justification="L", - ) - ], - [ - sg.Tab( - _t("config_gui.machine_identification"), - identity_col, - font="helvetica 16", - key="--tab-repo--", - element_justification="C", - ) - ], - [ - sg.Tab( - _t("config_gui.prometheus_config"), - prometheus_col, - font="helvetica 16", - key="--tab-prometheus--", - element_justification="C", - ) - ], - [ - sg.Tab( - _t("config_gui.environment_variables"), - env_col, - font="helvetica 16", - key="--tab-env--", - element_justification="C", - ) - ], - [ - sg.Tab( - _t("generic.options"), - options_col, - font="helvetica 16", - key="--tab-options--", - element_justification="C", - ) - ], - [ - sg.Tab( - _t("generic.scheduled_task"), - scheduled_task_col, - font="helvetica 16", - key="--tab-scheduled_task--", - element_justification="C", - ) - ], - ] - - layout = [ - [sg.TabGroup(tab_group_layout, enable_events=True, key="--tabgroup--")], - [sg.Column(buttons, element_justification="C")], - ] + repo_col = [ + [ + sg.Text( + "{}\n({})".format( + _t("config_gui.minimum_backup_age"), _t("generic.minutes") + ), + size=(40, 2), + ), + sg.Input(key="repo---minimum_backup_age", size=(50, 1)), + ], + [ + sg.Text(_t("config_gui.backup_repo_uri"), size=(40, 1)), + sg.Input(key="repo---repository", size=(50, 1)), + ], + [ + sg.Text(_t("config_gui.backup_repo_password"), size=(40, 1)), + sg.Input(key="repo---password", size=(50, 1)), + ], + [ + sg.Text(_t("config_gui.backup_repo_password_command"), size=(40, 1)), + sg.Input(key="repo---password_command", size=(50, 1)), + ], + [ + sg.Text(_t("config_gui.upload_speed"), size=(40, 1)), + sg.Input(key="repo---upload_speed", size=(50, 1)), + ], + [ + sg.Text(_t("config_gui.download_speed"), size=(40, 1)), + sg.Input(key="repo---download_speed", size=(50, 1)), + ], + [ + sg.Text(_t("config_gui.backend_connections"), size=(40, 1)), + sg.Input(key="repo---backend_connections", size=(50, 1)), + ], + ] + + retention_col = [ + [sg.Text(_t("config_gui.retention_policy"))], + [ + sg.Text(_t("config_gui.custom_time_server_url"), size=(40, 1)), + sg.Input(key="retentionpolicy---custom_time_server", size=(50, 1)), + ], + [ + sg.Text(_t("config_gui.keep"), size=(40, 1)), + sg.Text(_t("config_gui.hourly"), size=(10, 1)), + ], + ] + + identity_col = [ + [sg.Text(_t("config_gui.available_variables_id"))], + [ + sg.Text(_t("config_gui.machine_id"), size=(40, 1)), + sg.Input(key="identity---machine_id", size=(50, 1)), + ], + [ + sg.Text(_t("config_gui.machine_group"), size=(40, 1)), + sg.Input(key="identity---machine_group", size=(50, 1)), + ], + ] + + prometheus_col = [ + [sg.Text(_t("config_gui.available_variables"))], + [ + sg.Text(_t("config_gui.enable_prometheus"), size=(40, 1)), + sg.Checkbox("", key="prometheus---metrics", size=(41, 1)), + ], + [ + sg.Text(_t("config_gui.job_name"), size=(40, 1)), + sg.Input(key="prometheus---backup_job", size=(50, 1)), + ], + [ + sg.Text(_t("config_gui.metrics_destination"), size=(40, 1)), + sg.Input(key="prometheus---destination", size=(50, 1)), + ], + [ + sg.Text(_t("config_gui.no_cert_verify"), size=(40, 1)), + sg.Checkbox("", key="prometheus---no_cert_verify", size=(41, 1)), + ], + [ + sg.Text(_t("config_gui.metrics_username"), size=(40, 1)), + sg.Input(key="prometheus---http_username", size=(50, 1)), + ], + [ + sg.Text(_t("config_gui.metrics_password"), size=(40, 1)), + sg.Input(key="prometheus---http_password", size=(50, 1)), + ], + [ + sg.Text(_t("config_gui.instance"), size=(40, 1)), + sg.Input(key="prometheus---instance", size=(50, 1)), + ], + [ + sg.Text(_t("generic.group"), size=(40, 1)), + sg.Input(key="prometheus---group", size=(50, 1)), + ], + [ + sg.Text( + "{}\n({}\n{})".format( + _t("config_gui.additional_labels"), + _t("config_gui.one_per_line"), + _t("config_gui.format_equals"), + ), + size=(40, 3), + ), + sg.Multiline(key="prometheus---additional_labels", size=(48, 3)), + ], + ] + + env_col = [ + [ + sg.Text( + "{}\n({}\n{})".format( + _t("config_gui.environment_variables"), + _t("config_gui.one_per_line"), + _t("config_gui.format_equals"), + ), + size=(40, 3), + ), + sg.Multiline(key="env---variables", size=(48, 5)), + ], + [ + sg.Text( + "{}\n({}\n{})".format( + _t("config_gui.encrypted_environment_variables"), + _t("config_gui.one_per_line"), + _t("config_gui.format_equals"), + ), + size=(40, 3), + ), + sg.Multiline(key="env---encrypted_variables", size=(48, 5)), + ], + ] + + options_col = [ + [sg.Text(_t("config_gui.available_variables"))], + [ + sg.Text(_t("config_gui.auto_upgrade"), size=(40, 1)), + sg.Checkbox("", key="options---auto_upgrade", size=(41, 1)), + ], + [ + sg.Text(_t("config_gui.auto_upgrade_server_url"), size=(40, 1)), + sg.Input(key="options---auto_upgrade_server_url", size=(50, 1)), + ], + [ + sg.Text(_t("config_gui.auto_upgrade_server_username"), size=(40, 1)), + sg.Input(key="options---auto_upgrade_server_username", size=(50, 1)), + ], + [ + sg.Text(_t("config_gui.auto_upgrade_server_password"), size=(40, 1)), + sg.Input(key="options---auto_upgrade_server_password", size=(50, 1)), + ], + [ + sg.Text(_t("config_gui.auto_upgrade_interval"), size=(40, 1)), + sg.Input(key="options---auto_upgrade_interval", size=(50, 1)), + ], + [ + sg.Text(_t("generic.identity"), size=(40, 1)), + sg.Input(key="options---auto_upgrade_host_identity", size=(50, 1)), + ], + [ + sg.Text(_t("generic.group"), size=(40, 1)), + sg.Input(key="options---auto_upgrade_group", size=(50, 1)), + ], + [sg.HorizontalSeparator(key="sep")], + [ + sg.Text(_t("config_gui.enter_backup_admin_password"), size=(40, 1)), + sg.Input(key="backup_admin_password", size=(50, 1), password_char="*"), + ], + [sg.Button(_t("generic.change"), key="change_backup_admin_password")], + ] + + scheduled_task_col = [ + [ + sg.Text(_t("config_gui.create_scheduled_task_every")), + sg.Input(key="scheduled_task_interval", size=(4, 1)), + sg.Text(_t("generic.minutes")), + sg.Button(_t("generic.create"), key="create_task"), + ], + [sg.Text(_t("config_gui.scheduled_task_explanation"))], + ] + + + object_selector = [ + [ + sg.Text(_t("config_gui.select_object")), sg.Combo(get_objects(), key='-OBJECT-', enable_events=True) + ] + ] + + buttons = [ + [ + sg.Text(" " * 135), + sg.Button(_t("generic.accept"), key="accept"), + sg.Button(_t("generic.cancel"), key="cancel"), + ] + ] + + tab_group_layout = [ + [ + sg.Tab( + _t("config_gui.backup"), + [[sg.Column(backup_col, scrollable=True, vertical_scroll_only=True)]], + font="helvetica 16", + key="--tab-backup--", + element_justification="C", + ) + ], + [ + sg.Tab( + _t("config_gui.backup_destination"), + repo_col, + font="helvetica 16", + key="--tab-repo--", + element_justification="C", + ) + ], + [ + sg.Tab( + _t("config_gui.retention_policy"), + retention_col, + font="helvetica 16", + key="--tab-retentino--", + element_justification="L", + ) + ], + [ + sg.Tab( + _t("config_gui.machine_identification"), + identity_col, + font="helvetica 16", + key="--tab-repo--", + element_justification="C", + ) + ], + [ + sg.Tab( + _t("config_gui.prometheus_config"), + prometheus_col, + font="helvetica 16", + key="--tab-prometheus--", + element_justification="C", + ) + ], + [ + sg.Tab( + _t("config_gui.environment_variables"), + env_col, + font="helvetica 16", + key="--tab-env--", + element_justification="C", + ) + ], + [ + sg.Tab( + _t("generic.options"), + options_col, + font="helvetica 16", + key="--tab-options--", + element_justification="C", + ) + ], + [ + sg.Tab( + _t("generic.scheduled_task"), + scheduled_task_col, + font="helvetica 16", + key="--tab-scheduled_task--", + element_justification="C", + ) + ], + ] + + _layout = [ + [sg.Column(object_selector, element_justification='C')], + [sg.TabGroup(tab_group_layout, enable_events=True, key="--tabgroup--")], + [sg.Column(buttons, element_justification="C")], + ] + return _layout + + right_click_menu = ["", [_t("config_gui.show_decrypted")]] window = sg.Window( "Configuration", - layout, + layout(), size=(800, 600), text_justification="C", auto_size_text=True, @@ -573,18 +615,30 @@ def update_config_dict(values, config_dict): finalize=True, ) - update_gui(window, config_dict, unencrypted=False) + # TODO + #update_gui(window, config_dict, unencrypted=False) while True: event, values = window.read() - if event in (sg.WIN_CLOSED, "cancel"): + if event in (sg.WIN_CLOSED, sg.WIN_X_EVENT, "cancel"): break + if event == "-OBJECT-": + try: + object_type, object_name = get_object_from_combo(values["-OBJECT-"]) + if object_type == "repo": + repo_config, config_inheritance = configuration.get_repo_config(full_config, object_name) + update_gui(repo_config, config_inheritance, object_type, unencrypted=False) + elif object_type == "group": + group_config = configuration.get_group_config(full_config, object_name) + update_gui(group_config, None, object_type, unencrypted=False) + except AttributeError: + continue if event == "accept": if not values["repo---password"] and not values["repo---password_command"]: sg.PopupError(_t("config_gui.repo_password_cannot_be_empty")) continue - config_dict = update_config_dict(values, config_dict) - result = configuration.save_config(config_file, config_dict) + full_config = update_config_dict(values, full_config) + result = configuration.save_config(config_file, full_config) if result: sg.Popup(_t("config_gui.configuration_saved"), keep_on_top=True) logger.info("Configuration saved successfully.") @@ -595,8 +649,8 @@ def update_config_dict(values, config_dict): ) logger.info("Could not save configuration") if event == _t("config_gui.show_decrypted"): - if ask_backup_admin_password(config_dict): - update_gui(window, config_dict, unencrypted=True) + if ask_backup_admin_password(full_config): + update_gui(window, full_config, unencrypted=True) if event == "create_task": if os.name == "nt": result = create_scheduled_task( @@ -609,10 +663,10 @@ def update_config_dict(values, config_dict): else: sg.PopupError(_t("config_gui.scheduled_task_creation_failure")) if event == "change_backup_admin_password": - if ask_backup_admin_password(config_dict): - config_dict["options"]["backup_admin_password"] = values[ + if ask_backup_admin_password(full_config): + full_config["options"]["backup_admin_password"] = values[ "backup_admin_password" ] sg.Popup(_t("config_gui.password_updated_please_save")) window.close() - return config_dict + return full_config From 0dd340473cc4661ba94f1be6aab137371862bde2 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Wed, 13 Dec 2023 13:26:30 +0100 Subject: [PATCH 026/328] Translation updates --- npbackup/translations/config_gui.en.yml | 2 +- npbackup/translations/config_gui.fr.yml | 2 +- npbackup/translations/main_gui.fr.yml | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/npbackup/translations/config_gui.en.yml b/npbackup/translations/config_gui.en.yml index 644a1eb..4b59699 100644 --- a/npbackup/translations/config_gui.en.yml +++ b/npbackup/translations/config_gui.en.yml @@ -33,7 +33,7 @@ en: prometheus_config: Prometheus configuration available_variables: Available variables ${HOSTNAME}, ${BACKUP_JOB}, ${MACHINE_ID}, ${MACHINE_GROUP}, ${RANDOM}[n] - available_variables_id: Available variables ${HOSTNAME}, ${RANDOM}[n] where n is the number of chars + available_variables_id: Available variables ${HOSTNAME}, ${RANDOM}[n] where n is the number of random chars enable_prometheus: Enable prometheus metrics job_name: Job name (backup_job) metrics_destination: Metrics destination (URI / file) diff --git a/npbackup/translations/config_gui.fr.yml b/npbackup/translations/config_gui.fr.yml index d79f632..07cf456 100644 --- a/npbackup/translations/config_gui.fr.yml +++ b/npbackup/translations/config_gui.fr.yml @@ -33,7 +33,7 @@ fr: prometheus_config: Configuration prometheus available_variables: Variables disponibles ${HOSTNAME}, ${BACKUP_JOB}, ${MACHINE_ID}, ${MACHINE_GROUP}, ${RANDOM}[n] - available_variables_id: Variables disponibles ${HOSTNAME}, ${RANDOM}[n] ôu n est le nombre de caractères + available_variables_id: Variables disponibles ${HOSTNAME}, ${RANDOM}[n] où n est le nombre de caractères aléatoires enable_prometheus: Activer les métriques prometheus job_name: Nom du travail (backup_job) metrics_destination: Destination métriques (URI / fichier) diff --git a/npbackup/translations/main_gui.fr.yml b/npbackup/translations/main_gui.fr.yml index 5aaa919..f1ca8fe 100644 --- a/npbackup/translations/main_gui.fr.yml +++ b/npbackup/translations/main_gui.fr.yml @@ -32,4 +32,5 @@ fr: repository_not_configured: Dépot non configuré execute_operation: Opération en cours forget_failed: Oubli impossible. Veuillez vérifier les journaux - operations: Opérations \ No newline at end of file + operations: Opérations + select_config_file: Sélectionner fichier de configuration \ No newline at end of file From 6910fcf077a2772ae54c7cddadbb8b4be7fca55a Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Wed, 13 Dec 2023 13:26:58 +0100 Subject: [PATCH 027/328] Config GUI needs full config --- npbackup/gui/__main__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/npbackup/gui/__main__.py b/npbackup/gui/__main__.py index c39bbbb..9826969 100644 --- a/npbackup/gui/__main__.py +++ b/npbackup/gui/__main__.py @@ -571,7 +571,7 @@ def _main_gui(): full_config = npbackup.configuration.load_config(config_file) # TODO add a repo selector repo_config, inherit_config = npbackup.configuration.get_repo_config(full_config) - repo_list = list(full_config.g("repos").keys()) + repo_list = npbackup.configuration.get_repo_list(full_config) backup_destination = _t("main_gui.local_folder") backend_type, repo_uri = get_anon_repo_uri(repo_config.g('repo_uri')) @@ -750,10 +750,10 @@ def _main_gui(): # Make sure we trigger a GUI refresh after forgetting snapshots event = "state-button" if event == "operations": - repo_config = operations_gui(repo_config, config_file) + full_config = operations_gui(full_config, config_file) event = "state-button" if event == "configure": - repo_config = config_gui(repo_config, config_file) + repo_config = config_gui(full_config, config_file) # Make sure we trigger a GUI refresh when configuration is changed event = "state-button" if event == _t("generic.destination"): From aac5012328653a92e8f8282e6b8d78761a63639a Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Wed, 13 Dec 2023 18:26:25 +0100 Subject: [PATCH 028/328] WIP: Refactor config GUI for multi repo/group support --- npbackup/gui/config.py | 252 ++++++++++++++++++++++++++++------------- 1 file changed, 174 insertions(+), 78 deletions(-) diff --git a/npbackup/gui/config.py b/npbackup/gui/config.py index 768032f..5170195 100644 --- a/npbackup/gui/config.py +++ b/npbackup/gui/config.py @@ -191,7 +191,10 @@ def update_config_dict(values, config_dict): return config_dict - def layout(): + def object_layout(object_type: str = "repo") -> List[list]: + """ + Returns the GUI layout depending on the object type + """ backup_col = [ [ sg.Text(_t("config_gui.compression"), size=(40, 1)), @@ -333,7 +336,7 @@ def layout(): ], [ sg.Text(_t("config_gui.backup_repo_uri"), size=(40, 1)), - sg.Input(key="repo---repository", size=(50, 1)), + sg.Input(key="repo---repository", size=(50, 1), disabled=True if object_type == 'group' else False), ], [ sg.Text(_t("config_gui.backup_repo_password"), size=(40, 1)), @@ -355,29 +358,42 @@ def layout(): sg.Text(_t("config_gui.backend_connections"), size=(40, 1)), sg.Input(key="repo---backend_connections", size=(50, 1)), ], - ] - - retention_col = [ + [sg.HorizontalSeparator()], + [ + sg.Text(_t("config_gui.enter_backup_admin_password"), size=(40, 1)), + sg.Input(key="backup_admin_password", size=(50, 1), password_char="*"), + ], + [sg.Button(_t("generic.change"), key="change_backup_admin_password")], + [sg.HorizontalSeparator()], [sg.Text(_t("config_gui.retention_policy"))], [ sg.Text(_t("config_gui.custom_time_server_url"), size=(40, 1)), sg.Input(key="retentionpolicy---custom_time_server", size=(50, 1)), ], [ - sg.Text(_t("config_gui.keep"), size=(40, 1)), + sg.Text(_t("config_gui.keep"), size=(30, 1)), sg.Text(_t("config_gui.hourly"), size=(10, 1)), + sg.Input(key="repo---retention---hourly", size=(50, 1)) ], - ] - - identity_col = [ - [sg.Text(_t("config_gui.available_variables_id"))], [ - sg.Text(_t("config_gui.machine_id"), size=(40, 1)), - sg.Input(key="identity---machine_id", size=(50, 1)), + sg.Text(_t("config_gui.keep"), size=(30, 1)), + sg.Text(_t("config_gui.daily"), size=(10, 1)), + sg.Input(key="repo---retention---daily", size=(50, 1)) ], [ - sg.Text(_t("config_gui.machine_group"), size=(40, 1)), - sg.Input(key="identity---machine_group", size=(50, 1)), + sg.Text(_t("config_gui.keep"), size=(30, 1)), + sg.Text(_t("config_gui.weekly"), size=(10, 1)), + sg.Input(key="repo---retention---weekly", size=(50, 1)) + ], + [ + sg.Text(_t("config_gui.keep"), size=(30, 1)), + sg.Text(_t("config_gui.monthly"), size=(10, 1)), + sg.Input(key="repo---retention---monthly", size=(50, 1)) + ], + [ + sg.Text(_t("config_gui.keep"), size=(30, 1)), + sg.Text(_t("config_gui.yearly"), size=(10, 1)), + sg.Input(key="repo---retention---yearly", size=(50, 1)) ], ] @@ -453,7 +469,123 @@ def layout(): ], ] - options_col = [ + object_list = get_objects() + object_selector = [ + [ + sg.Text(_t("config_gui.select_object")), sg.Combo(object_list, default_value=object_list[0], key='-OBJECT-', enable_events=True) + ] + ] + + tab_group_layout = [ + [ + sg.Tab( + _t("config_gui.backup"), + [[sg.Column(backup_col, scrollable=True, vertical_scroll_only=True)]], + font="helvetica 16", + key="--tab-backup--", + element_justification="C", + ) + ], + [ + sg.Tab( + _t("config_gui.backup_destination"), + repo_col, + font="helvetica 16", + key="--tab-repo--", + element_justification="C", + ) + ], + [ + sg.Tab( + _t("config_gui.prometheus_config"), + prometheus_col, + font="helvetica 16", + key="--tab-prometheus--", + element_justification="C", + ) + ], + [ + sg.Tab( + _t("config_gui.environment_variables"), + env_col, + font="helvetica 16", + key="--tab-env--", + element_justification="C", + ) + ], + ] + + _layout = [ + [sg.Column(object_selector, element_justification='C')], + [sg.TabGroup(tab_group_layout, enable_events=True, key="--object-tabgroup--")], + ] + return _layout + + + def global_options_layout(): + """" + Returns layout for global options that can't be overrided by group / repo settings + """ + identity_col = [ + [sg.Text(_t("config_gui.available_variables_id"))], + [ + sg.Text(_t("config_gui.machine_id"), size=(40, 1)), + sg.Input(key="identity---machine_id", size=(50, 1)), + ], + [ + sg.Text(_t("config_gui.machine_group"), size=(40, 1)), + sg.Input(key="identity---machine_group", size=(50, 1)), + ], + ] + + prometheus_col = [ + [sg.Text(_t("config_gui.available_variables"))], + [ + sg.Text(_t("config_gui.enable_prometheus"), size=(40, 1)), + sg.Checkbox("", key="prometheus---metrics", size=(41, 1)), + ], + [ + sg.Text(_t("config_gui.job_name"), size=(40, 1)), + sg.Input(key="prometheus---backup_job", size=(50, 1)), + ], + [ + sg.Text(_t("config_gui.metrics_destination"), size=(40, 1)), + sg.Input(key="prometheus---destination", size=(50, 1)), + ], + [ + sg.Text(_t("config_gui.no_cert_verify"), size=(40, 1)), + sg.Checkbox("", key="prometheus---no_cert_verify", size=(41, 1)), + ], + [ + sg.Text(_t("config_gui.metrics_username"), size=(40, 1)), + sg.Input(key="prometheus---http_username", size=(50, 1)), + ], + [ + sg.Text(_t("config_gui.metrics_password"), size=(40, 1)), + sg.Input(key="prometheus---http_password", size=(50, 1)), + ], + [ + sg.Text(_t("config_gui.instance"), size=(40, 1)), + sg.Input(key="prometheus---instance", size=(50, 1)), + ], + [ + sg.Text(_t("generic.group"), size=(40, 1)), + sg.Input(key="prometheus---group", size=(50, 1)), + ], + [ + sg.Text( + "{}\n({}\n{})".format( + _t("config_gui.additional_labels"), + _t("config_gui.one_per_line"), + _t("config_gui.format_equals"), + ), + size=(40, 3), + ), + sg.Multiline(key="prometheus---additional_labels", size=(48, 3)), + ], + ] + + global_options_col = [ [sg.Text(_t("config_gui.available_variables"))], [ sg.Text(_t("config_gui.auto_upgrade"), size=(40, 1)), @@ -483,14 +615,9 @@ def layout(): sg.Text(_t("generic.group"), size=(40, 1)), sg.Input(key="options---auto_upgrade_group", size=(50, 1)), ], - [sg.HorizontalSeparator(key="sep")], - [ - sg.Text(_t("config_gui.enter_backup_admin_password"), size=(40, 1)), - sg.Input(key="backup_admin_password", size=(50, 1), password_char="*"), - ], - [sg.Button(_t("generic.change"), key="change_backup_admin_password")], + [sg.HorizontalSeparator()] ] - + scheduled_task_col = [ [ sg.Text(_t("config_gui.create_scheduled_task_every")), @@ -501,49 +628,7 @@ def layout(): [sg.Text(_t("config_gui.scheduled_task_explanation"))], ] - - object_selector = [ - [ - sg.Text(_t("config_gui.select_object")), sg.Combo(get_objects(), key='-OBJECT-', enable_events=True) - ] - ] - - buttons = [ - [ - sg.Text(" " * 135), - sg.Button(_t("generic.accept"), key="accept"), - sg.Button(_t("generic.cancel"), key="cancel"), - ] - ] - tab_group_layout = [ - [ - sg.Tab( - _t("config_gui.backup"), - [[sg.Column(backup_col, scrollable=True, vertical_scroll_only=True)]], - font="helvetica 16", - key="--tab-backup--", - element_justification="C", - ) - ], - [ - sg.Tab( - _t("config_gui.backup_destination"), - repo_col, - font="helvetica 16", - key="--tab-repo--", - element_justification="C", - ) - ], - [ - sg.Tab( - _t("config_gui.retention_policy"), - retention_col, - font="helvetica 16", - key="--tab-retentino--", - element_justification="L", - ) - ], [ sg.Tab( _t("config_gui.machine_identification"), @@ -562,19 +647,10 @@ def layout(): element_justification="C", ) ], - [ - sg.Tab( - _t("config_gui.environment_variables"), - env_col, - font="helvetica 16", - key="--tab-env--", - element_justification="C", - ) - ], [ sg.Tab( _t("generic.options"), - options_col, + global_options_col, font="helvetica 16", key="--tab-options--", element_justification="C", @@ -590,18 +666,38 @@ def layout(): ) ], ] - + _layout = [ - [sg.Column(object_selector, element_justification='C')], + [sg.TabGroup(tab_group_layout, enable_events=True, key="--global-tabgroup--")], + ] + return _layout + + + def config_layout(object_type: str = 'repo') -> List[list]: + + buttons = [ + [ + #sg.Text(" " * 135), + sg.Button(_t("generic.accept"), key="accept"), + sg.Button(_t("generic.cancel"), key="cancel"), + ] + ] + + tab_group_layout = [ + [sg.Tab(_t("config_gui.repo_group_config"), object_layout())], + [sg.Tab(_t("config_gui.global_config"), global_options_layout())] + ] + + _global_layout = [ [sg.TabGroup(tab_group_layout, enable_events=True, key="--tabgroup--")], [sg.Column(buttons, element_justification="C")], ] - return _layout + return _global_layout right_click_menu = ["", [_t("config_gui.show_decrypted")]] window = sg.Window( "Configuration", - layout(), + config_layout(), size=(800, 600), text_justification="C", auto_size_text=True, From d67bba5639f4d254d41ea2178ba63212f3476591 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Wed, 13 Dec 2023 18:26:44 +0100 Subject: [PATCH 029/328] Update translations --- npbackup/translations/config_gui.en.yml | 10 +++++++++- npbackup/translations/config_gui.fr.yml | 10 +++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/npbackup/translations/config_gui.en.yml b/npbackup/translations/config_gui.en.yml index 4b59699..1bbd09f 100644 --- a/npbackup/translations/config_gui.en.yml +++ b/npbackup/translations/config_gui.en.yml @@ -100,4 +100,12 @@ en: folder_list: Folder list files_from: Files from list files_from_verbatim: Files from verbatim list - files_from_raw: Files from raw list \ No newline at end of file + files_from_raw: Files from raw list + + keep: Keep + copies: copies + hourly: hourly + daily: daily + weekly: weekly + monthly: monthly + yearly: yearly \ No newline at end of file diff --git a/npbackup/translations/config_gui.fr.yml b/npbackup/translations/config_gui.fr.yml index 07cf456..8f1071f 100644 --- a/npbackup/translations/config_gui.fr.yml +++ b/npbackup/translations/config_gui.fr.yml @@ -100,4 +100,12 @@ fr: folder_list: Liste de dossiers files_from: Liste fichiers depuis un fichier files_from_verbatim: Liste fichiers depuis un fichier "exact" - files_from_raw: Liste fichiers depuis un fichier "raw" \ No newline at end of file + files_from_raw: Liste fichiers depuis un fichier "raw" + + keep: Garder + copies: copies + hourly: par heure + daily: journalières + weekly: hebdomadaires + monthly: mensuelles + yearly: annuelles \ No newline at end of file From bc230037fdf0926e3caf3dbbac543ecefcdf16c7 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Wed, 13 Dec 2023 18:27:18 +0100 Subject: [PATCH 030/328] Fix version string should be in a shared file --- npbackup/__version__.py | 15 +++++++++++++++ npbackup/gui/__main__.py | 9 ++++----- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/npbackup/__version__.py b/npbackup/__version__.py index 67b06eb..ce2233c 100644 --- a/npbackup/__version__.py +++ b/npbackup/__version__.py @@ -11,3 +11,18 @@ __license__ = "GPL-3.0-only" __build__ = "2023121001" __version__ = "2.3.0-dev" + + +import sys +from ofunctions.platform import python_arch +from npbackup.configuration import IS_PRIV_BUILD + +version_string = "{} v{}{}{}-{} {} - {}".format( + __intname__, + __version__, + "-PRIV" if IS_PRIV_BUILD else "", + "-P{}".format(sys.version_info[1]), + python_arch(), + __build__, + __copyright__, +) diff --git a/npbackup/gui/__main__.py b/npbackup/gui/__main__.py index 9826969..6e9958e 100644 --- a/npbackup/gui/__main__.py +++ b/npbackup/gui/__main__.py @@ -15,16 +15,15 @@ import os from pathlib import Path from logging import getLogger -import re from datetime import datetime import dateutil import queue from time import sleep -import PySimpleGUI as sg -import _tkinter from ofunctions.threading import threaded, Future from threading import Thread from ofunctions.misc import BytesConverter +import PySimpleGUI as sg +import _tkinter import npbackup.configuration from npbackup.customization import ( OEM_STRING, @@ -48,7 +47,7 @@ from npbackup.core.upgrade_runner import run_upgrade, check_new_version from npbackup.path_helper import CURRENT_DIR from npbackup.interface_entrypoint import entrypoint -from npbackup.__version__ import __intname__ as intname, __version__, __build__, __copyright__ +from npbackup.__version__ import version_string from npbackup.gui.config import config_gui from npbackup.gui.operations import operations_gui from npbackup.customization import ( @@ -753,7 +752,7 @@ def _main_gui(): full_config = operations_gui(full_config, config_file) event = "state-button" if event == "configure": - repo_config = config_gui(full_config, config_file) + full_config = config_gui(full_config, config_file) # Make sure we trigger a GUI refresh when configuration is changed event = "state-button" if event == _t("generic.destination"): From 3082e6311f49957761ded98ef3e26b73965edaa5 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Wed, 13 Dec 2023 18:43:42 +0100 Subject: [PATCH 031/328] Refactor upgrader to use new config format --- npbackup/core/upgrade_runner.py | 36 ++++++++++++++++----------------- npbackup/gui/__main__.py | 17 +++++++++------- 2 files changed, 27 insertions(+), 26 deletions(-) diff --git a/npbackup/core/upgrade_runner.py b/npbackup/core/upgrade_runner.py index c30f3fa..84ebc47 100644 --- a/npbackup/core/upgrade_runner.py +++ b/npbackup/core/upgrade_runner.py @@ -19,34 +19,32 @@ logger = getLogger() -def check_new_version(config_dict: dict) -> bool: - try: - upgrade_url = config_dict["options"]["auto_upgrade_server_url"] - username = config_dict["options"]["auto_upgrade_server_username"] - password = config_dict["options"]["auto_upgrade_server_password"] - except KeyError as exc: - logger.error("Missing auto upgrade info: %s, cannot launch auto upgrade", exc) +def check_new_version(full_config: dict) -> bool: + upgrade_url = full_config.g("global_options.auto_upgrade_server_url") + username = full_config.g("global_options.auto_upgrade_server_username") + password = full_config.g("global_options.auto_upgrade_server_password") + if not upgrade_url or not username or not password: + logger.error(f"Missing auto upgrade info, cannot launch auto upgrade") return None else: return _check_new_version(upgrade_url, username, password) -def run_upgrade(config_dict): - try: - auto_upgrade_upgrade_url = config_dict["options"]["auto_upgrade_server_url"] - auto_upgrade_username = config_dict["options"]["auto_upgrade_server_username"] - auto_upgrade_password = config_dict["options"]["auto_upgrade_server_password"] - except KeyError as exc: - logger.error("Missing auto upgrade info: %s, cannot launch auto upgrade", exc) +def run_upgrade(full_config: dict) -> bool: + upgrade_url = full_config.g("global_options.auto_upgrade_server_url") + username = full_config.g("global_options.auto_upgrade_server_username") + password = full_config.g("global_options.auto_upgrade_server_password") + if not upgrade_url or not username or not password: + logger.error(f"Missing auto upgrade info, cannot launch auto upgrade") return False - auto_upgrade_host_identity = config_dict.g("global_options.auto_upgrade_host_identity") - group = config_dict.g("global_options.auto_upgrade_group") + auto_upgrade_host_identity = full_config.g("global_options.auto_upgrade_host_identity") + group = full_config.g("global_options.auto_upgrade_group") result = auto_upgrader( - upgrade_url=auto_upgrade_upgrade_url, - username=auto_upgrade_username, - password=auto_upgrade_password, + upgrade_url=upgrade_url, + username=username, + password=password, auto_upgrade_host_identity=auto_upgrade_host_identity, installed_version=npbackup_version, group=group, diff --git a/npbackup/gui/__main__.py b/npbackup/gui/__main__.py index 6e9958e..f2d0777 100644 --- a/npbackup/gui/__main__.py +++ b/npbackup/gui/__main__.py @@ -65,11 +65,14 @@ THREAD_SHARED_DICT = {} -def _about_gui(version_string: str, repo_config: dict) -> None: +def _about_gui(version_string: str, full_config: dict) -> None: license_content = LICENSE_TEXT - result = check_new_version(repo_config) - if result: + if full_config.g("global_options.auto_upgrade_server_url"): + auto_upgrade_result = check_new_version(full_config) + else: + auto_upgrade_result = None + if auto_upgrade_result: new_version = [ sg.Button( _t("config_gui.auto_upgrade_launch"), @@ -77,9 +80,9 @@ def _about_gui(version_string: str, repo_config: dict) -> None: size=(12, 2), ) ] - elif result is False: + elif auto_upgrade_result is False: new_version = [sg.Text(_t("generic.is_uptodate"))] - elif result is None: + elif auto_upgrade_result is None: new_version = [sg.Text(_t("config_gui.auto_upgrade_disabled"))] try: with open(LICENSE_FILE, "r", encoding="utf-8") as file_handle: @@ -113,7 +116,7 @@ def _about_gui(version_string: str, repo_config: dict) -> None: ) if result == "OK": logger.info("Running GUI initiated upgrade") - sub_result = run_upgrade(repo_config) + sub_result = run_upgrade(full_config) if sub_result: sys.exit(0) else: @@ -768,7 +771,7 @@ def _main_gui(): except (TypeError, KeyError): sg.PopupNoFrame(_t("main_gui.unknown_repo")) if event == "about": - _about_gui(version_string, repo_config) + _about_gui(version_string, full_config) if event == "state-button": current_state, backup_tz, snapshot_list = get_gui_data(repo_config) _gui_update_state(window, current_state, backup_tz, snapshot_list) From 313ef9d7c72747a4865e718c07792fd46c54df3c Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Wed, 13 Dec 2023 21:49:59 +0100 Subject: [PATCH 032/328] WIP: Refactor config_gui --- npbackup/configuration.py | 18 +++- npbackup/gui/config.py | 193 ++++++++++++++++++++------------------ 2 files changed, 114 insertions(+), 97 deletions(-) diff --git a/npbackup/configuration.py b/npbackup/configuration.py index 3383a25..85c7a70 100644 --- a/npbackup/configuration.py +++ b/npbackup/configuration.py @@ -7,7 +7,7 @@ __author__ = "Orsiris de Jong" __copyright__ = "Copyright (C) 2022-2023 NetInvent" __license__ = "GPL-3.0-only" -__build__ = "2023121001" +__build__ = "2023121301" __version__ = "2.0.0 for npbackup 2.3.0+" CONF_VERSION = 2.3 @@ -140,6 +140,11 @@ def s(self, path, value, sep='.'): } }, "repo_opts": { + "permissions": { + "restore": True, + "verify": True, + "delete": False, + }, "repo_password": "", "repo_password_command": "", # Minimum time between two backups, in minutes @@ -345,7 +350,7 @@ def _evaluate_variables(value): return repo_config -def get_repo_config(full_config: dict, repo_name: str = 'default') -> Tuple[dict, dict]: +def get_repo_config(full_config: dict, repo_name: str = 'default', eval_variables: bool = True) -> Tuple[dict, dict]: """ Create inherited repo config Returns a dict containing the repo config, with expanded variables @@ -389,17 +394,20 @@ def get_repo_config(full_config: dict, repo_name: str = 'default') -> Tuple[dict else: config_inheritance.s(f'{section}.{entries}', False) - repo_config = evaluate_variables(repo_config, full_config) + if eval_variables: + repo_config = evaluate_variables(repo_config, full_config) return repo_config, config_inheritance -def get_group_config(full_config: dict, group_name: str) -> dict: +def get_group_config(full_config: dict, group_name: str, eval_variables: bool = True) -> dict: try: group_config = deepcopy(full_config.g(f"groups.{group_name}")) except KeyError: logger.error(f"No group with name {group_name} found in config") return None + if eval_variables: + group_config = evaluate_variables(group_config, full_config) return group_config @@ -484,4 +492,4 @@ def get_repo_list(full_config: dict) -> List[str]: def get_group_list(full_config: dict) -> List[str]: - return list(full_config.g("groups").keys()) \ No newline at end of file + return list(full_config.g("groups").keys()) diff --git a/npbackup/gui/config.py b/npbackup/gui/config.py index 5170195..35a3904 100644 --- a/npbackup/gui/config.py +++ b/npbackup/gui/config.py @@ -91,7 +91,6 @@ def get_object_from_combo(combo_value: str) -> (str, str): Extracts selected object from combobox Returns object type and name """ - object_list = get_objects() if combo_value.startswith("Repo: "): object_type = "repo" @@ -102,57 +101,70 @@ def get_object_from_combo(combo_value: str) -> (str, str): return object_type, object_name - def update_gui(object_config, config_inheritance, object_type, unencrypted=False): - for section in object_config.keys(): - print(section) - continue - if config_dict[section] is None: - config_dict[section] = {} - for entry in config_dict[section].keys(): - # Don't bother to update admin password since we won't show it - if entry == "backup_admin_password": - continue - try: - value = config_dict[section][entry] - # Don't show sensible info unless unencrypted requested - # TODO: Refactor this to use ENCRYPTED_OPTIONS from configuration - if not unencrypted: - if entry in [ - "http_username", - "http_password", - "repository", - "password", - "password_command", - "auto_upgrade_server_username", - "auto_upgrade_server_password", - "encrypted_variables", - ]: - try: - if ( - config_dict[section][entry] is None - or config_dict[section][entry] == "" - ): - continue - if not str(config_dict[section][entry]).startswith( - configuration.ID_STRING - ): - value = ENCRYPTED_DATA_PLACEHOLDER - except (KeyError, TypeError): - pass - - if isinstance(value, list): - value = "\n".join(value) - # window keys are section---entry - key = "{}---{}".format(section, entry) - if entry in combo_boxes: - window[key].Update(combo_boxes[entry][value]) - else: - window[key].Update(value) - except KeyError: - logger.error("No GUI equivalent for {}.".format(entry)) - except TypeError as exc: - logger.error("{} for {}.".format(exc, entry)) + def update_object_gui(object_name = None, unencrypted=False): + # Load fist available repo or group if none given + if not object_name: + object_name = get_objects()[0] + # First we need to clear the whole GUI to reload new values + for key in window.AllKeysDict: + # We only clear config keys, wihch have '---' separator + if isinstance(key, str) and "---" in key: + window[key].Update("") + + object_type, object_name = get_object_from_combo(object_name) + if object_type == 'repo': + object_config, config_inheritance = configuration.get_repo_config(full_config, object_name, eval_variables=False) + if object_type == 'group': + object_config = configuration.get_group_config(full_config, object_name, eval_variables=False) + + for section in object_config.keys(): + # TODO: add str and int and list support + if isinstance(object_config.g(section), dict): + for entry in object_config.g(section).keys(): + # Don't bother to update admin password since we won't show it + if entry == "backup_admin_password": + continue + try: + value = object_config.g(f"{section}.{entry}") + # Don't show sensible info unless unencrypted requested + # TODO: Refactor this to use ENCRYPTED_OPTIONS from configuration + if not unencrypted: + if entry in [ + "http_username", + "http_password", + "repository", + "password", + "password_command", + "auto_upgrade_server_username", + "auto_upgrade_server_password", + "encrypted_variables", + ]: + try: + if ( + value is None + or value == "" + ): + continue + if not str(value).startswith( + configuration.ID_STRING + ): + value = ENCRYPTED_DATA_PLACEHOLDER + except (KeyError, TypeError): + pass + + if isinstance(value, list): + value = "\n".join(value) + # window keys are section---entry + key = "{}---{}".format(section, entry) + if entry in combo_boxes: + window[key].Update(combo_boxes[entry][value]) + else: + window[key].Update(value) + except KeyError: + logger.error("No GUI equivalent for {}.".format(entry)) + except TypeError as exc: + logger.error("{} for {}.".format(exc, entry)) def update_config_dict(values, config_dict): for key, value in values.items(): @@ -200,7 +212,7 @@ def object_layout(object_type: str = "repo") -> List[list]: sg.Text(_t("config_gui.compression"), size=(40, 1)), sg.Combo( list(combo_boxes["compression"].values()), - key="backup---compression", + key="backup_opts---compression", size=(48, 1), ), ], @@ -211,13 +223,13 @@ def object_layout(object_type: str = "repo") -> List[list]: ), size=(40, 2), ), - sg.Multiline(key="backup---paths", size=(48, 4)), + sg.Multiline(key="backup_opts---paths", size=(48, 4)), ], [ sg.Text(_t("config_gui.source_type"), size=(40, 1)), sg.Combo( list(combo_boxes["source_type"].values()), - key="backup---source_type", + key="backup_opts---source_type", size=(48, 1), ), ], @@ -228,7 +240,7 @@ def object_layout(object_type: str = "repo") -> List[list]: ), size=(40, 2), ), - sg.Checkbox("", key="backup---use_fs_snapshot", size=(41, 1)), + sg.Checkbox("", key="backup_opts---use_fs_snapshot", size=(41, 1)), ], [ sg.Text( @@ -237,7 +249,7 @@ def object_layout(object_type: str = "repo") -> List[list]: ), size=(40, 2), ), - sg.Checkbox("", key="backup---ignore_cloud_files", size=(41, 1)), + sg.Checkbox("", key="backup_opts---ignore_cloud_files", size=(41, 1)), ], [ sg.Text( @@ -246,7 +258,7 @@ def object_layout(object_type: str = "repo") -> List[list]: ), size=(40, 2), ), - sg.Multiline(key="backup---exclude_patterns", size=(48, 4)), + sg.Multiline(key="backup_opts---exclude_patterns", size=(48, 4)), ], [ sg.Text( @@ -255,7 +267,7 @@ def object_layout(object_type: str = "repo") -> List[list]: ), size=(40, 2), ), - sg.Multiline(key="backup---exclude_files", size=(48, 4)), + sg.Multiline(key="backup_opts---exclude_files", size=(48, 4)), ], [ sg.Text( @@ -265,62 +277,62 @@ def object_layout(object_type: str = "repo") -> List[list]: ), size=(40, 2), ), - sg.Checkbox("", key="backup---exclude_case_ignore", size=(41, 1)), + sg.Checkbox("", key="backup_opts---exclude_case_ignore", size=(41, 1)), ], [ sg.Text(_t("config_gui.exclude_cache_dirs"), size=(40, 1)), - sg.Checkbox("", key="backup---exclude_caches", size=(41, 1)), + sg.Checkbox("", key="backup_opts---exclude_caches", size=(41, 1)), ], [ sg.Text(_t("config_gui.one_file_system"), size=(40, 1)), - sg.Checkbox("", key="backup---one_file_system", size=(41, 1)), + sg.Checkbox("", key="backup_opts---one_file_system", size=(41, 1)), ], [ sg.Text(_t("config_gui.pre_exec_command"), size=(40, 1)), - sg.Input(key="backup---pre_exec_command", size=(50, 1)), + sg.Input(key="backup_opts---pre_exec_command", size=(50, 1)), ], [ sg.Text(_t("config_gui.maximum_exec_time"), size=(40, 1)), - sg.Input(key="backup---pre_exec_timeout", size=(50, 1)), + sg.Input(key="backup_opts---pre_exec_timeout", size=(50, 1)), ], [ sg.Text(_t("config_gui.exec_failure_is_fatal"), size=(40, 1)), - sg.Checkbox("", key="backup---pre_exec_failure_is_fatal", size=(41, 1)), + sg.Checkbox("", key="backup_opts---pre_exec_failure_is_fatal", size=(41, 1)), ], [ sg.Text(_t("config_gui.post_exec_command"), size=(40, 1)), - sg.Input(key="backup---post_exec_command", size=(50, 1)), + sg.Input(key="backup_opts---post_exec_command", size=(50, 1)), ], [ sg.Text(_t("config_gui.maximum_exec_time"), size=(40, 1)), - sg.Input(key="backup---post_exec_timeout", size=(50, 1)), + sg.Input(key="backup_opts---post_exec_timeout", size=(50, 1)), ], [ sg.Text(_t("config_gui.exec_failure_is_fatal"), size=(40, 1)), - sg.Checkbox("", key="backup---post_exec_failure_is_fatal", size=(41, 1)), + sg.Checkbox("", key="backup_opts---post_exec_failure_is_fatal", size=(41, 1)), ], [ sg.Text( "{}\n({})".format(_t("config_gui.tags"), _t("config_gui.one_per_line")), size=(40, 2), ), - sg.Multiline(key="backup---tags", size=(48, 2)), + sg.Multiline(key="backup_opts---tags", size=(48, 2)), ], [ sg.Text(_t("config_gui.backup_priority"), size=(40, 1)), sg.Combo( list(combo_boxes["priority"].values()), - key="backup---priority", + key="backup_opts---priority", size=(48, 1), ), ], [ sg.Text(_t("config_gui.additional_parameters"), size=(40, 1)), - sg.Input(key="backup---additional_parameters", size=(50, 1)), + sg.Input(key="backup_opts---additional_parameters", size=(50, 1)), ], [ sg.Text(_t("config_gui.additional_backup_only_parameters"), size=(40, 1)), - sg.Input(key="backup---additional_backup_only_parameters", size=(50, 1)), + sg.Input(key="backup_opts---additional_backup_only_parameters", size=(50, 1)), ], ] @@ -332,31 +344,32 @@ def object_layout(object_type: str = "repo") -> List[list]: ), size=(40, 2), ), - sg.Input(key="repo---minimum_backup_age", size=(50, 1)), + sg.Input(key="repo_opts---minimum_backup_age", size=(50, 1)), ], [ sg.Text(_t("config_gui.backup_repo_uri"), size=(40, 1)), - sg.Input(key="repo---repository", size=(50, 1), disabled=True if object_type == 'group' else False), + # TODO: replace this + sg.Input(key="---repository", size=(50, 1), disabled=True if object_type == 'group' else False), ], [ sg.Text(_t("config_gui.backup_repo_password"), size=(40, 1)), - sg.Input(key="repo---password", size=(50, 1)), + sg.Input(key="repo_opts---repo_password", size=(50, 1)), ], [ sg.Text(_t("config_gui.backup_repo_password_command"), size=(40, 1)), - sg.Input(key="repo---password_command", size=(50, 1)), + sg.Input(key="repo_opts---repo_password_command", size=(50, 1)), ], [ sg.Text(_t("config_gui.upload_speed"), size=(40, 1)), - sg.Input(key="repo---upload_speed", size=(50, 1)), + sg.Input(key="repo_opts---upload_speed", size=(50, 1)), ], [ sg.Text(_t("config_gui.download_speed"), size=(40, 1)), - sg.Input(key="repo---download_speed", size=(50, 1)), + sg.Input(key="repo_opts---download_speed", size=(50, 1)), ], [ sg.Text(_t("config_gui.backend_connections"), size=(40, 1)), - sg.Input(key="repo---backend_connections", size=(50, 1)), + sg.Input(key="repo_opts---backend_connections", size=(50, 1)), ], [sg.HorizontalSeparator()], [ @@ -373,27 +386,27 @@ def object_layout(object_type: str = "repo") -> List[list]: [ sg.Text(_t("config_gui.keep"), size=(30, 1)), sg.Text(_t("config_gui.hourly"), size=(10, 1)), - sg.Input(key="repo---retention---hourly", size=(50, 1)) + sg.Input(key="repo_opts---retention---hourly", size=(50, 1)) ], [ sg.Text(_t("config_gui.keep"), size=(30, 1)), sg.Text(_t("config_gui.daily"), size=(10, 1)), - sg.Input(key="repo---retention---daily", size=(50, 1)) + sg.Input(key="repo_opts---retention---daily", size=(50, 1)) ], [ sg.Text(_t("config_gui.keep"), size=(30, 1)), sg.Text(_t("config_gui.weekly"), size=(10, 1)), - sg.Input(key="repo---retention---weekly", size=(50, 1)) + sg.Input(key="repo_opts---retention---weekly", size=(50, 1)) ], [ sg.Text(_t("config_gui.keep"), size=(30, 1)), sg.Text(_t("config_gui.monthly"), size=(10, 1)), - sg.Input(key="repo---retention---monthly", size=(50, 1)) + sg.Input(key="repo_opts---retention---monthly", size=(50, 1)) ], [ sg.Text(_t("config_gui.keep"), size=(30, 1)), sg.Text(_t("config_gui.yearly"), size=(10, 1)), - sg.Input(key="repo---retention---yearly", size=(50, 1)) + sg.Input(key="repo_opts---retention---yearly", size=(50, 1)) ], ] @@ -694,6 +707,7 @@ def config_layout(object_type: str = 'repo') -> List[list]: ] return _global_layout + right_click_menu = ["", [_t("config_gui.show_decrypted")]] window = sg.Window( "Configuration", @@ -711,8 +725,8 @@ def config_layout(object_type: str = 'repo') -> List[list]: finalize=True, ) - # TODO - #update_gui(window, config_dict, unencrypted=False) + # Update gui with first default object (repo or group) + update_object_gui(get_objects()[0], unencrypted=False) while True: event, values = window.read() @@ -721,16 +735,11 @@ def config_layout(object_type: str = 'repo') -> List[list]: if event == "-OBJECT-": try: object_type, object_name = get_object_from_combo(values["-OBJECT-"]) - if object_type == "repo": - repo_config, config_inheritance = configuration.get_repo_config(full_config, object_name) - update_gui(repo_config, config_inheritance, object_type, unencrypted=False) - elif object_type == "group": - group_config = configuration.get_group_config(full_config, object_name) - update_gui(group_config, None, object_type, unencrypted=False) + update_object_gui(values["-OBJECT-"], unencrypted=False) except AttributeError: continue if event == "accept": - if not values["repo---password"] and not values["repo---password_command"]: + if not values["repo_opts---password"] and not values["repo_opts---password_command"]: sg.PopupError(_t("config_gui.repo_password_cannot_be_empty")) continue full_config = update_config_dict(values, full_config) From b100ed9c9aeeeaa7a3d88e1bd0bce829e82c4239 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Thu, 14 Dec 2023 13:16:09 +0100 Subject: [PATCH 033/328] Remove unnecessary import --- npbackup/gui/__main__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/npbackup/gui/__main__.py b/npbackup/gui/__main__.py index f2d0777..ff7e299 100644 --- a/npbackup/gui/__main__.py +++ b/npbackup/gui/__main__.py @@ -20,7 +20,6 @@ import queue from time import sleep from ofunctions.threading import threaded, Future -from threading import Thread from ofunctions.misc import BytesConverter import PySimpleGUI as sg import _tkinter From 8b5e5a2132f959d1fbca75f2fdbec420d5ba7aeb Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Thu, 14 Dec 2023 15:05:05 +0100 Subject: [PATCH 034/328] WIP: Config GUI refactor --- npbackup/configuration.py | 7 +- npbackup/gui/config.py | 289 ++++++++++++++++++++------------------ 2 files changed, 157 insertions(+), 139 deletions(-) diff --git a/npbackup/configuration.py b/npbackup/configuration.py index 85c7a70..2f29713 100644 --- a/npbackup/configuration.py +++ b/npbackup/configuration.py @@ -153,12 +153,13 @@ def s(self, path, value, sep='.'): "upload_speed": 1000000, # in KiB, use 0 for unlimited upload speed "download_speed": 0, # in KiB, use 0 for unlimited download speed "backend_connections": 0, # Fine tune simultaneous connections to backend, use 0 for standard configuration - "retention_strategy": { + "retention": { "hourly": 72, "daily": 30, "weekly": 4, "monthly": 12, - "yearly": 3 + "yearly": 3, + "custom_time_server": None, } }, "prometheus": { @@ -174,7 +175,7 @@ def s(self, path, value, sep='.'): "machine_id": "${HOSTNAME}__${RANDOM}[4]", "machine_group": "", }, - "prometheus": { + "global_prometheus": { "metrics": False, "instance": "${MACHINE_ID}", "destination": "", diff --git a/npbackup/gui/config.py b/npbackup/gui/config.py index 35a3904..2a03a99 100644 --- a/npbackup/gui/config.py +++ b/npbackup/gui/config.py @@ -7,7 +7,7 @@ __author__ = "Orsiris de Jong" __copyright__ = "Copyright (C) 2022-2023 NetInvent" __license__ = "GPL-3.0-only" -__build__ = "2023121001" +__build__ = "2023121401" from typing import List @@ -100,6 +100,69 @@ def get_object_from_combo(combo_value: str) -> (str, str): object_name = combo_value[len("Group: "):] return object_type, object_name + + def update_gui_values(key, value, object_type, unencrypted): + """ + Update gui values depending on their type + """ + if key == "backup_admin_password": + return + if key == "repo_uri": + if object_type == "group": + window[key].Disabled = True + else: + window[key].Disabled = False + # special case for env + if key == "env": + if isinstance(value, dict): + for envkey, envvalue in value.items(): + value = f"{envkey}={envvalue}" + + try: + # Don't show sensible info unless unencrypted requested + if not unencrypted: + if key in configuration.ENCRYPTED_OPTIONS: + try: + if ( + value is None + or value == "" + ): + return + if not str(value).startswith( + configuration.ID_STRING + ): + value = ENCRYPTED_DATA_PLACEHOLDER + except (KeyError, TypeError): + pass + + if isinstance(value, list): + value = "\n".join(value) + if key in combo_boxes: + window[key].Update(combo_boxes[key][value]) + else: + window[key].Update(value) + except KeyError: + logger.error(f"No GUI equivalent for key {key}.") + except TypeError as exc: + logger.error(f"Error: {exc} for key {key}.") + + def iter_over_config(object_config: dict, object_type: str = None, unencrypted: bool = False, root_key: str =''): + """ + Iter over a dict while retaining the full key path to current object + """ + base_object = object_config + + def _iter_over_config(object_config: dict, root_key=''): + # Special case where env is a dict but should be transformed into a list + if isinstance(object_config, dict) and not root_key == 'env': + for key in object_config.keys(): + if root_key: + _iter_over_config(object_config[key], root_key=f'{root_key}.{key}') + else: + _iter_over_config(object_config[key], root_key=f'{key}') + else: + update_gui_values(root_key, base_object.g(root_key), object_type, unencrypted) + _iter_over_config(object_config, root_key) def update_object_gui(object_name = None, unencrypted=False): # Load fist available repo or group if none given @@ -108,9 +171,10 @@ def update_object_gui(object_name = None, unencrypted=False): # First we need to clear the whole GUI to reload new values for key in window.AllKeysDict: - # We only clear config keys, wihch have '---' separator - if isinstance(key, str) and "---" in key: - window[key].Update("") + # We only clear config keys, wihch have '.' separator + if "." in str(key): + print(key) + window[key]('') object_type, object_name = get_object_from_combo(object_name) if object_type == 'repo': @@ -118,66 +182,21 @@ def update_object_gui(object_name = None, unencrypted=False): if object_type == 'group': object_config = configuration.get_group_config(full_config, object_name, eval_variables=False) - for section in object_config.keys(): - # TODO: add str and int and list support - if isinstance(object_config.g(section), dict): - for entry in object_config.g(section).keys(): - # Don't bother to update admin password since we won't show it - if entry == "backup_admin_password": - continue - try: - value = object_config.g(f"{section}.{entry}") - # Don't show sensible info unless unencrypted requested - # TODO: Refactor this to use ENCRYPTED_OPTIONS from configuration - if not unencrypted: - if entry in [ - "http_username", - "http_password", - "repository", - "password", - "password_command", - "auto_upgrade_server_username", - "auto_upgrade_server_password", - "encrypted_variables", - ]: - try: - if ( - value is None - or value == "" - ): - continue - if not str(value).startswith( - configuration.ID_STRING - ): - value = ENCRYPTED_DATA_PLACEHOLDER - except (KeyError, TypeError): - pass - - if isinstance(value, list): - value = "\n".join(value) - # window keys are section---entry - key = "{}---{}".format(section, entry) - if entry in combo_boxes: - window[key].Update(combo_boxes[entry][value]) - else: - window[key].Update(value) - except KeyError: - logger.error("No GUI equivalent for {}.".format(entry)) - except TypeError as exc: - logger.error("{} for {}.".format(exc, entry)) - - def update_config_dict(values, config_dict): + + # Now let's iter over the whole config object and update keys accordingly + iter_over_config(object_config, object_type, unencrypted, None) + + + def update_config_dict(values, full_config): for key, value in values.items(): if value == ENCRYPTED_DATA_PLACEHOLDER: continue - try: - section, entry = key.split("---") - except ValueError: - # Don't bother with keys that don't begin with "---" + if not isinstance(key, str) or (isinstance(key, str) and not '.' in key): + # Don't bother with keys that don't contain with "." continue # Handle combo boxes first to transform translation into key - if entry in combo_boxes: - value = get_key_from_value(combo_boxes[entry], value) + if key in combo_boxes: + value = get_key_from_value(combo_boxes[key], value) # check whether we need to split into list elif not isinstance(value, bool): result = value.split("\n") @@ -196,11 +215,11 @@ def update_config_dict(values, config_dict): except ValueError: pass # Create section if not exists - if section not in config_dict.keys(): - config_dict[section] = {} + if key not in full_config.keys(): + full_config[key] = {} - config_dict[section][entry] = value - return config_dict + full_config.s(key, value) + return full_config def object_layout(object_type: str = "repo") -> List[list]: @@ -212,7 +231,7 @@ def object_layout(object_type: str = "repo") -> List[list]: sg.Text(_t("config_gui.compression"), size=(40, 1)), sg.Combo( list(combo_boxes["compression"].values()), - key="backup_opts---compression", + key="backup_opts.compression", size=(48, 1), ), ], @@ -223,13 +242,13 @@ def object_layout(object_type: str = "repo") -> List[list]: ), size=(40, 2), ), - sg.Multiline(key="backup_opts---paths", size=(48, 4)), + sg.Multiline(key="backup_opts.paths", size=(48, 4)), ], [ sg.Text(_t("config_gui.source_type"), size=(40, 1)), sg.Combo( list(combo_boxes["source_type"].values()), - key="backup_opts---source_type", + key="backup_opts.source_type", size=(48, 1), ), ], @@ -240,7 +259,7 @@ def object_layout(object_type: str = "repo") -> List[list]: ), size=(40, 2), ), - sg.Checkbox("", key="backup_opts---use_fs_snapshot", size=(41, 1)), + sg.Checkbox("", key="backup_opts.use_fs_snapshot", size=(41, 1)), ], [ sg.Text( @@ -249,7 +268,7 @@ def object_layout(object_type: str = "repo") -> List[list]: ), size=(40, 2), ), - sg.Checkbox("", key="backup_opts---ignore_cloud_files", size=(41, 1)), + sg.Checkbox("", key="backup_opts.ignore_cloud_files", size=(41, 1)), ], [ sg.Text( @@ -258,7 +277,7 @@ def object_layout(object_type: str = "repo") -> List[list]: ), size=(40, 2), ), - sg.Multiline(key="backup_opts---exclude_patterns", size=(48, 4)), + sg.Multiline(key="backup_opts.exclude_patterns", size=(48, 4)), ], [ sg.Text( @@ -267,7 +286,7 @@ def object_layout(object_type: str = "repo") -> List[list]: ), size=(40, 2), ), - sg.Multiline(key="backup_opts---exclude_files", size=(48, 4)), + sg.Multiline(key="backup_opts.exclude_files", size=(48, 4)), ], [ sg.Text( @@ -277,62 +296,62 @@ def object_layout(object_type: str = "repo") -> List[list]: ), size=(40, 2), ), - sg.Checkbox("", key="backup_opts---exclude_case_ignore", size=(41, 1)), + sg.Checkbox("", key="backup_opts.exclude_case_ignore", size=(41, 1)), ], [ sg.Text(_t("config_gui.exclude_cache_dirs"), size=(40, 1)), - sg.Checkbox("", key="backup_opts---exclude_caches", size=(41, 1)), + sg.Checkbox("", key="backup_opts.exclude_caches", size=(41, 1)), ], [ sg.Text(_t("config_gui.one_file_system"), size=(40, 1)), - sg.Checkbox("", key="backup_opts---one_file_system", size=(41, 1)), + sg.Checkbox("", key="backup_opts.one_file_system", size=(41, 1)), ], [ sg.Text(_t("config_gui.pre_exec_command"), size=(40, 1)), - sg.Input(key="backup_opts---pre_exec_command", size=(50, 1)), + sg.Input(key="backup_opts.pre_exec_command", size=(50, 1)), ], [ sg.Text(_t("config_gui.maximum_exec_time"), size=(40, 1)), - sg.Input(key="backup_opts---pre_exec_timeout", size=(50, 1)), + sg.Input(key="backup_opts.pre_exec_timeout", size=(50, 1)), ], [ sg.Text(_t("config_gui.exec_failure_is_fatal"), size=(40, 1)), - sg.Checkbox("", key="backup_opts---pre_exec_failure_is_fatal", size=(41, 1)), + sg.Checkbox("", key="backup_opts.pre_exec_failure_is_fatal", size=(41, 1)), ], [ sg.Text(_t("config_gui.post_exec_command"), size=(40, 1)), - sg.Input(key="backup_opts---post_exec_command", size=(50, 1)), + sg.Input(key="backup_opts.post_exec_command", size=(50, 1)), ], [ sg.Text(_t("config_gui.maximum_exec_time"), size=(40, 1)), - sg.Input(key="backup_opts---post_exec_timeout", size=(50, 1)), + sg.Input(key="backup_opts.post_exec_timeout", size=(50, 1)), ], [ sg.Text(_t("config_gui.exec_failure_is_fatal"), size=(40, 1)), - sg.Checkbox("", key="backup_opts---post_exec_failure_is_fatal", size=(41, 1)), + sg.Checkbox("", key="backup_opts.post_exec_failure_is_fatal", size=(41, 1)), ], [ sg.Text( "{}\n({})".format(_t("config_gui.tags"), _t("config_gui.one_per_line")), size=(40, 2), ), - sg.Multiline(key="backup_opts---tags", size=(48, 2)), + sg.Multiline(key="backup_opts.tags", size=(48, 2)), ], [ sg.Text(_t("config_gui.backup_priority"), size=(40, 1)), sg.Combo( list(combo_boxes["priority"].values()), - key="backup_opts---priority", + key="backup_opts.priority", size=(48, 1), ), ], [ sg.Text(_t("config_gui.additional_parameters"), size=(40, 1)), - sg.Input(key="backup_opts---additional_parameters", size=(50, 1)), + sg.Input(key="backup_opts.additional_parameters", size=(50, 1)), ], [ sg.Text(_t("config_gui.additional_backup_only_parameters"), size=(40, 1)), - sg.Input(key="backup_opts---additional_backup_only_parameters", size=(50, 1)), + sg.Input(key="backup_opts.additional_backup_only_parameters", size=(50, 1)), ], ] @@ -344,32 +363,31 @@ def object_layout(object_type: str = "repo") -> List[list]: ), size=(40, 2), ), - sg.Input(key="repo_opts---minimum_backup_age", size=(50, 1)), + sg.Input(key="repo_opts.minimum_backup_age", size=(50, 1)), ], [ sg.Text(_t("config_gui.backup_repo_uri"), size=(40, 1)), - # TODO: replace this - sg.Input(key="---repository", size=(50, 1), disabled=True if object_type == 'group' else False), + sg.Input(key="repo_uri", size=(50, 1)), ], [ sg.Text(_t("config_gui.backup_repo_password"), size=(40, 1)), - sg.Input(key="repo_opts---repo_password", size=(50, 1)), + sg.Input(key="repo_opts.repo_password", size=(50, 1)), ], [ sg.Text(_t("config_gui.backup_repo_password_command"), size=(40, 1)), - sg.Input(key="repo_opts---repo_password_command", size=(50, 1)), + sg.Input(key="repo_opts.repo_password_command", size=(50, 1)), ], [ sg.Text(_t("config_gui.upload_speed"), size=(40, 1)), - sg.Input(key="repo_opts---upload_speed", size=(50, 1)), + sg.Input(key="repo_opts.upload_speed", size=(50, 1)), ], [ sg.Text(_t("config_gui.download_speed"), size=(40, 1)), - sg.Input(key="repo_opts---download_speed", size=(50, 1)), + sg.Input(key="repo_opts.download_speed", size=(50, 1)), ], [ sg.Text(_t("config_gui.backend_connections"), size=(40, 1)), - sg.Input(key="repo_opts---backend_connections", size=(50, 1)), + sg.Input(key="repo_opts.backend_connections", size=(50, 1)), ], [sg.HorizontalSeparator()], [ @@ -381,32 +399,32 @@ def object_layout(object_type: str = "repo") -> List[list]: [sg.Text(_t("config_gui.retention_policy"))], [ sg.Text(_t("config_gui.custom_time_server_url"), size=(40, 1)), - sg.Input(key="retentionpolicy---custom_time_server", size=(50, 1)), + sg.Input(key="repo_opts.retention_strategy.custom_time_server", size=(50, 1)), ], [ sg.Text(_t("config_gui.keep"), size=(30, 1)), sg.Text(_t("config_gui.hourly"), size=(10, 1)), - sg.Input(key="repo_opts---retention---hourly", size=(50, 1)) + sg.Input(key="repo_opts.retention_strategy.hourly", size=(50, 1)) ], [ sg.Text(_t("config_gui.keep"), size=(30, 1)), sg.Text(_t("config_gui.daily"), size=(10, 1)), - sg.Input(key="repo_opts---retention---daily", size=(50, 1)) + sg.Input(key="repo_opts.retention_strategy.daily", size=(50, 1)) ], [ sg.Text(_t("config_gui.keep"), size=(30, 1)), sg.Text(_t("config_gui.weekly"), size=(10, 1)), - sg.Input(key="repo_opts---retention---weekly", size=(50, 1)) + sg.Input(key="repo_opts.retention_strategy.weekly", size=(50, 1)) ], [ sg.Text(_t("config_gui.keep"), size=(30, 1)), sg.Text(_t("config_gui.monthly"), size=(10, 1)), - sg.Input(key="repo_opts---retention---monthly", size=(50, 1)) + sg.Input(key="repo_opts.retention_strategy.monthly", size=(50, 1)) ], [ sg.Text(_t("config_gui.keep"), size=(30, 1)), sg.Text(_t("config_gui.yearly"), size=(10, 1)), - sg.Input(key="repo_opts---retention---yearly", size=(50, 1)) + sg.Input(key="repo_opts.retention_strategy.yearly", size=(50, 1)) ], ] @@ -414,35 +432,35 @@ def object_layout(object_type: str = "repo") -> List[list]: [sg.Text(_t("config_gui.available_variables"))], [ sg.Text(_t("config_gui.enable_prometheus"), size=(40, 1)), - sg.Checkbox("", key="prometheus---metrics", size=(41, 1)), + sg.Checkbox("", key="prometheus.metrics", size=(41, 1)), ], [ sg.Text(_t("config_gui.job_name"), size=(40, 1)), - sg.Input(key="prometheus---backup_job", size=(50, 1)), + sg.Input(key="prometheus.backup_job", size=(50, 1)), ], [ sg.Text(_t("config_gui.metrics_destination"), size=(40, 1)), - sg.Input(key="prometheus---destination", size=(50, 1)), + sg.Input(key="prometheus.destination", size=(50, 1)), ], [ sg.Text(_t("config_gui.no_cert_verify"), size=(40, 1)), - sg.Checkbox("", key="prometheus---no_cert_verify", size=(41, 1)), + sg.Checkbox("", key="prometheus.no_cert_verify", size=(41, 1)), ], [ sg.Text(_t("config_gui.metrics_username"), size=(40, 1)), - sg.Input(key="prometheus---http_username", size=(50, 1)), + sg.Input(key="prometheus.http_username", size=(50, 1)), ], [ sg.Text(_t("config_gui.metrics_password"), size=(40, 1)), - sg.Input(key="prometheus---http_password", size=(50, 1)), + sg.Input(key="prometheus.http_password", size=(50, 1)), ], [ sg.Text(_t("config_gui.instance"), size=(40, 1)), - sg.Input(key="prometheus---instance", size=(50, 1)), + sg.Input(key="prometheus.instance", size=(50, 1)), ], [ sg.Text(_t("generic.group"), size=(40, 1)), - sg.Input(key="prometheus---group", size=(50, 1)), + sg.Input(key="prometheus.group", size=(50, 1)), ], [ sg.Text( @@ -453,7 +471,7 @@ def object_layout(object_type: str = "repo") -> List[list]: ), size=(40, 3), ), - sg.Multiline(key="prometheus---additional_labels", size=(48, 3)), + sg.Multiline(key="prometheus.additional_labels", size=(48, 3)), ], ] @@ -467,7 +485,7 @@ def object_layout(object_type: str = "repo") -> List[list]: ), size=(40, 3), ), - sg.Multiline(key="env---variables", size=(48, 5)), + sg.Multiline(key="env.variables", size=(48, 5)), ], [ sg.Text( @@ -478,7 +496,7 @@ def object_layout(object_type: str = "repo") -> List[list]: ), size=(40, 3), ), - sg.Multiline(key="env---encrypted_variables", size=(48, 5)), + sg.Multiline(key="env.encrypted_variables", size=(48, 5)), ], ] @@ -543,11 +561,11 @@ def global_options_layout(): [sg.Text(_t("config_gui.available_variables_id"))], [ sg.Text(_t("config_gui.machine_id"), size=(40, 1)), - sg.Input(key="identity---machine_id", size=(50, 1)), + sg.Input(key="identity.machine_id", size=(50, 1)), ], [ sg.Text(_t("config_gui.machine_group"), size=(40, 1)), - sg.Input(key="identity---machine_group", size=(50, 1)), + sg.Input(key="identity.machine_group", size=(50, 1)), ], ] @@ -555,35 +573,35 @@ def global_options_layout(): [sg.Text(_t("config_gui.available_variables"))], [ sg.Text(_t("config_gui.enable_prometheus"), size=(40, 1)), - sg.Checkbox("", key="prometheus---metrics", size=(41, 1)), + sg.Checkbox("", key="global_prometheus.metrics", size=(41, 1)), ], [ sg.Text(_t("config_gui.job_name"), size=(40, 1)), - sg.Input(key="prometheus---backup_job", size=(50, 1)), + sg.Input(key="global_prometheus.backup_job", size=(50, 1)), ], [ sg.Text(_t("config_gui.metrics_destination"), size=(40, 1)), - sg.Input(key="prometheus---destination", size=(50, 1)), + sg.Input(key="global_prometheus.destination", size=(50, 1)), ], [ sg.Text(_t("config_gui.no_cert_verify"), size=(40, 1)), - sg.Checkbox("", key="prometheus---no_cert_verify", size=(41, 1)), + sg.Checkbox("", key="global_prometheus.no_cert_verify", size=(41, 1)), ], [ sg.Text(_t("config_gui.metrics_username"), size=(40, 1)), - sg.Input(key="prometheus---http_username", size=(50, 1)), + sg.Input(key="global_prometheus.http_username", size=(50, 1)), ], [ sg.Text(_t("config_gui.metrics_password"), size=(40, 1)), - sg.Input(key="prometheus---http_password", size=(50, 1)), + sg.Input(key="global_prometheus.http_password", size=(50, 1)), ], [ sg.Text(_t("config_gui.instance"), size=(40, 1)), - sg.Input(key="prometheus---instance", size=(50, 1)), + sg.Input(key="global_prometheus.instance", size=(50, 1)), ], [ sg.Text(_t("generic.group"), size=(40, 1)), - sg.Input(key="prometheus---group", size=(50, 1)), + sg.Input(key="global_prometheus.group", size=(50, 1)), ], [ sg.Text( @@ -594,7 +612,7 @@ def global_options_layout(): ), size=(40, 3), ), - sg.Multiline(key="prometheus---additional_labels", size=(48, 3)), + sg.Multiline(key="global_prometheus.additional_labels", size=(48, 3)), ], ] @@ -602,31 +620,31 @@ def global_options_layout(): [sg.Text(_t("config_gui.available_variables"))], [ sg.Text(_t("config_gui.auto_upgrade"), size=(40, 1)), - sg.Checkbox("", key="options---auto_upgrade", size=(41, 1)), + sg.Checkbox("", key="global_options.auto_upgrade", size=(41, 1)), ], [ sg.Text(_t("config_gui.auto_upgrade_server_url"), size=(40, 1)), - sg.Input(key="options---auto_upgrade_server_url", size=(50, 1)), + sg.Input(key="global_options.auto_upgrade_server_url", size=(50, 1)), ], [ sg.Text(_t("config_gui.auto_upgrade_server_username"), size=(40, 1)), - sg.Input(key="options---auto_upgrade_server_username", size=(50, 1)), + sg.Input(key="global_options.auto_upgrade_server_username", size=(50, 1)), ], [ sg.Text(_t("config_gui.auto_upgrade_server_password"), size=(40, 1)), - sg.Input(key="options---auto_upgrade_server_password", size=(50, 1)), + sg.Input(key="global_options.auto_upgrade_server_password", size=(50, 1)), ], [ sg.Text(_t("config_gui.auto_upgrade_interval"), size=(40, 1)), - sg.Input(key="options---auto_upgrade_interval", size=(50, 1)), + sg.Input(key="global_options.auto_upgrade_interval", size=(50, 1)), ], [ sg.Text(_t("generic.identity"), size=(40, 1)), - sg.Input(key="options---auto_upgrade_host_identity", size=(50, 1)), + sg.Input(key="global_options.auto_upgrade_host_identity", size=(50, 1)), ], [ sg.Text(_t("generic.group"), size=(40, 1)), - sg.Input(key="options---auto_upgrade_group", size=(50, 1)), + sg.Input(key="global_options.auto_upgrade_group", size=(50, 1)), ], [sg.HorizontalSeparator()] ] @@ -647,7 +665,7 @@ def global_options_layout(): _t("config_gui.machine_identification"), identity_col, font="helvetica 16", - key="--tab-repo--", + key="--tab-global-identification--", element_justification="C", ) ], @@ -656,7 +674,7 @@ def global_options_layout(): _t("config_gui.prometheus_config"), prometheus_col, font="helvetica 16", - key="--tab-prometheus--", + key="--tab-global-prometheus--", element_justification="C", ) ], @@ -665,7 +683,7 @@ def global_options_layout(): _t("generic.options"), global_options_col, font="helvetica 16", - key="--tab-options--", + key="--tab-global-options--", element_justification="C", ) ], @@ -674,7 +692,7 @@ def global_options_layout(): _t("generic.scheduled_task"), scheduled_task_col, font="helvetica 16", - key="--tab-scheduled_task--", + key="--tab-global-scheduled_task--", element_justification="C", ) ], @@ -697,12 +715,12 @@ def config_layout(object_type: str = 'repo') -> List[list]: ] tab_group_layout = [ - [sg.Tab(_t("config_gui.repo_group_config"), object_layout())], - [sg.Tab(_t("config_gui.global_config"), global_options_layout())] + [sg.Tab(_t("config_gui.repo_group_config"), object_layout(), key="--repo-group-config--")], + [sg.Tab(_t("config_gui.global_config"), global_options_layout(), key="--global-config--")] ] _global_layout = [ - [sg.TabGroup(tab_group_layout, enable_events=True, key="--tabgroup--")], + [sg.TabGroup(tab_group_layout, enable_events=True, key="--configtabgroup--")], [sg.Column(buttons, element_justification="C")], ] return _global_layout @@ -734,12 +752,11 @@ def config_layout(object_type: str = 'repo') -> List[list]: break if event == "-OBJECT-": try: - object_type, object_name = get_object_from_combo(values["-OBJECT-"]) update_object_gui(values["-OBJECT-"], unencrypted=False) except AttributeError: continue if event == "accept": - if not values["repo_opts---password"] and not values["repo_opts---password_command"]: + if not values["repo_opts.password"] and not values["repo_opts.password_command"]: sg.PopupError(_t("config_gui.repo_password_cannot_be_empty")) continue full_config = update_config_dict(values, full_config) @@ -755,7 +772,7 @@ def config_layout(object_type: str = 'repo') -> List[list]: logger.info("Could not save configuration") if event == _t("config_gui.show_decrypted"): if ask_backup_admin_password(full_config): - update_gui(window, full_config, unencrypted=True) + update_object_gui(values["-OBJECT-"], unencrypted=True) if event == "create_task": if os.name == "nt": result = create_scheduled_task( From ccd3339d1bf66f0453072c8fce1a03142a0b01ab Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Thu, 14 Dec 2023 18:15:37 +0100 Subject: [PATCH 035/328] WIP: Refactor config GUI --- npbackup/configuration.py | 41 +++++++-- npbackup/gui/config.py | 106 +++++++++++++++--------- npbackup/translations/config_gui.en.yml | 13 ++- npbackup/translations/config_gui.fr.yml | 13 ++- 4 files changed, 111 insertions(+), 62 deletions(-) diff --git a/npbackup/configuration.py b/npbackup/configuration.py index 2f29713..049aae2 100644 --- a/npbackup/configuration.py +++ b/npbackup/configuration.py @@ -96,13 +96,13 @@ def s(self, path, value, sep='.'): "repos": { "default": { "repo_uri": "", - "group": "default_group", + "repo_group": "default_group", "backup_opts": {}, "repo_opts": {}, "prometheus": {}, "env": { - "variables": {}, - "encrypted_variables": {} + "env_variables": [], + "encrypted_env_variables": [] }, }, }, @@ -167,8 +167,8 @@ def s(self, path, value, sep='.'): "group": "${MACHINE_GROUP}", }, "env": { - "variables": {}, - "encrypted_variables": {} + "env_variables": [], + "env_encrypted_variables": [] }, }, "identity": { @@ -361,6 +361,30 @@ def get_repo_config(full_config: dict, repo_name: str = 'default', eval_variable repo_config = ordereddict() config_inheritance = ordereddict() + def inherit_group_settings(repo_config: dict, group_config: dict) -> Tuple[dict, dict]: + """ + iter over group settings, update repo_config, and produce an identical version of repo_config + called config_inheritance, where every value is replaced with a boolean which states inheritance status + """ + + # WIP # TODO: cannot use local repo config, need to make a deepcopy first ? + + if isinstance(group_config, dict): + for key, value in group_config.items(): + if isinstance(value, dict): + repo_config[key] = inherit_group_settings(repo_config.g(key), group_config.g(key)) + elif isinstance(value, list) and isinstance(repo_config.g(key), list): + merged_lists = repo_config.g(key) + group_config.g(key) + repo_config.s(key, merged_lists) + config_inheritance.s(key, True) + else: + if not repo_config or not repo_config.g(key): + repo_config = group_config + config_inheritance.s(key, True) + else: + config_inheritance.s(key, False) + return repo_config, config_inheritance + try: repo_config = deepcopy(full_config.g(f'repos.{repo_name}')) # Let's make a copy of config since it's a "pointer object" @@ -369,10 +393,13 @@ def get_repo_config(full_config: dict, repo_name: str = 'default', eval_variable logger.error(f"No repo with name {repo_name} found in config") return None try: - repo_group = full_config.g(f'repos.{repo_name}.group') + repo_group = full_config.g(f'repos.{repo_name}.repo_group') + group_config = full_config.g(f'groups.{repo_group}') except KeyError: logger.warning(f"Repo {repo_name} has no group") else: + inherit_group_settings(repo_config, group_config) + """ sections = full_config.g(f'groups.{repo_group}') if sections: for section in sections: @@ -394,6 +421,7 @@ def get_repo_config(full_config: dict, repo_name: str = 'default', eval_variable config_inheritance.s(f'{section}.{entries}', True) else: config_inheritance.s(f'{section}.{entries}', False) + """ if eval_variables: repo_config = evaluate_variables(repo_config, full_config) @@ -469,7 +497,6 @@ def load_config(config_file: Path) -> Optional[dict]: if config_file_is_updated: logger.info("Updating config file") save_config(config_file, full_config) - return full_config diff --git a/npbackup/gui/config.py b/npbackup/gui/config.py index 2a03a99..8f17738 100644 --- a/npbackup/gui/config.py +++ b/npbackup/gui/config.py @@ -13,6 +13,7 @@ from typing import List import os from logging import getLogger +from copy import deepcopy import PySimpleGUI as sg import npbackup.configuration as configuration from ofunctions.misc import get_key_from_value @@ -101,7 +102,7 @@ def get_object_from_combo(combo_value: str) -> (str, str): return object_type, object_name - def update_gui_values(key, value, object_type, unencrypted): + def update_gui_values(key, value, inherited, object_type, unencrypted): """ Update gui values depending on their type """ @@ -112,12 +113,6 @@ def update_gui_values(key, value, object_type, unencrypted): window[key].Disabled = True else: window[key].Disabled = False - # special case for env - if key == "env": - if isinstance(value, dict): - for envkey, envvalue in value.items(): - value = f"{envkey}={envvalue}" - try: # Don't show sensible info unless unencrypted requested if not unencrypted: @@ -141,27 +136,37 @@ def update_gui_values(key, value, object_type, unencrypted): window[key].Update(combo_boxes[key][value]) else: window[key].Update(value) + + # Set inheritance on + inheritance_key = f'inherited.{key}' + if inheritance_key in window.AllKeysDict: + window[f'inherited.{key}'].update(visible=True if inherited else False) + except KeyError: logger.error(f"No GUI equivalent for key {key}.") except TypeError as exc: logger.error(f"Error: {exc} for key {key}.") - def iter_over_config(object_config: dict, object_type: str = None, unencrypted: bool = False, root_key: str =''): + def iter_over_config(object_config: dict, config_inheritance: dict = None, object_type: str = None, unencrypted: bool = False, root_key: str =''): """ Iter over a dict while retaining the full key path to current object """ base_object = object_config def _iter_over_config(object_config: dict, root_key=''): - # Special case where env is a dict but should be transformed into a list - if isinstance(object_config, dict) and not root_key == 'env': + # Special case where env is a dict but we should pass it directly as it to update_gui_values + if isinstance(object_config, dict): for key in object_config.keys(): if root_key: _iter_over_config(object_config[key], root_key=f'{root_key}.{key}') else: _iter_over_config(object_config[key], root_key=f'{key}') else: - update_gui_values(root_key, base_object.g(root_key), object_type, unencrypted) + if config_inheritance: + inherited = config_inheritance.g(root_key) + else: + inherited = False + update_gui_values(root_key, base_object.g(root_key), inherited, object_type, unencrypted) _iter_over_config(object_config, root_key) def update_object_gui(object_name = None, unencrypted=False): @@ -173,7 +178,6 @@ def update_object_gui(object_name = None, unencrypted=False): for key in window.AllKeysDict: # We only clear config keys, wihch have '.' separator if "." in str(key): - print(key) window[key]('') object_type, object_name = get_object_from_combo(object_name) @@ -184,7 +188,20 @@ def update_object_gui(object_name = None, unencrypted=False): # Now let's iter over the whole config object and update keys accordingly - iter_over_config(object_config, object_type, unencrypted, None) + iter_over_config(object_config, config_inheritance, object_type, unencrypted, None) + + def update_global_gui(full_config, unencrypted=False): + # TODO + return + global_config = deepcopy(full_config) + + # Only update global options gui with identified global keys + for key in global_config.keys(): + if key not in ('identity', 'global_prometheus', 'global_options'): + global_config.pop(key) + print(global_config) + + iter_over_config(global_config, None, None, unencrypted, None) def update_config_dict(values, full_config): @@ -259,6 +276,7 @@ def object_layout(object_type: str = "repo") -> List[list]: ), size=(40, 2), ), + sg.Text("inherited", key="inherited.backup_opts.use_fs_snapshot", visible=False), sg.Checkbox("", key="backup_opts.use_fs_snapshot", size=(41, 1)), ], [ @@ -356,6 +374,14 @@ def object_layout(object_type: str = "repo") -> List[list]: ] repo_col = [ + [ + sg.Text(_t("config_gui.backup_repo_uri"), size=(40, 1)), + sg.Input(key="repo_uri", size=(50, 1)), + ], + [ + sg.Text(_t("config_gui.repo_group"), size=(40, 1)), + sg.Input(key="repo_group", size=(50, 1)), + ], [ sg.Text( "{}\n({})".format( @@ -365,10 +391,6 @@ def object_layout(object_type: str = "repo") -> List[list]: ), sg.Input(key="repo_opts.minimum_backup_age", size=(50, 1)), ], - [ - sg.Text(_t("config_gui.backup_repo_uri"), size=(40, 1)), - sg.Input(key="repo_uri", size=(50, 1)), - ], [ sg.Text(_t("config_gui.backup_repo_password"), size=(40, 1)), sg.Input(key="repo_opts.repo_password", size=(50, 1)), @@ -403,28 +425,28 @@ def object_layout(object_type: str = "repo") -> List[list]: ], [ sg.Text(_t("config_gui.keep"), size=(30, 1)), - sg.Text(_t("config_gui.hourly"), size=(10, 1)), - sg.Input(key="repo_opts.retention_strategy.hourly", size=(50, 1)) + sg.Input(key="repo_opts.retention_strategy.hourly", size=(3, 1)), + sg.Text(_t("config_gui.hourly"), size=(20, 1)), ], [ sg.Text(_t("config_gui.keep"), size=(30, 1)), - sg.Text(_t("config_gui.daily"), size=(10, 1)), - sg.Input(key="repo_opts.retention_strategy.daily", size=(50, 1)) + sg.Input(key="repo_opts.retention_strategy.daily", size=(3, 1)), + sg.Text(_t("config_gui.daily"), size=(20, 1)), ], [ sg.Text(_t("config_gui.keep"), size=(30, 1)), - sg.Text(_t("config_gui.weekly"), size=(10, 1)), - sg.Input(key="repo_opts.retention_strategy.weekly", size=(50, 1)) + sg.Input(key="repo_opts.retention_strategy.weekly", size=(3, 1)), + sg.Text(_t("config_gui.weekly"), size=(20, 1)), ], [ sg.Text(_t("config_gui.keep"), size=(30, 1)), - sg.Text(_t("config_gui.monthly"), size=(10, 1)), - sg.Input(key="repo_opts.retention_strategy.monthly", size=(50, 1)) + sg.Input(key="repo_opts.retention_strategy.monthly", size=(3, 1)), + sg.Text(_t("config_gui.monthly"), size=(20, 1)), ], [ sg.Text(_t("config_gui.keep"), size=(30, 1)), - sg.Text(_t("config_gui.yearly"), size=(10, 1)), - sg.Input(key="repo_opts.retention_strategy.yearly", size=(50, 1)) + sg.Input(key="repo_opts.retention_strategy.yearly", size=(3, 1)), + sg.Text(_t("config_gui.yearly"), size=(20, 1)), ], ] @@ -485,7 +507,7 @@ def object_layout(object_type: str = "repo") -> List[list]: ), size=(40, 3), ), - sg.Multiline(key="env.variables", size=(48, 5)), + sg.Multiline(key="env.env_variables", size=(48, 5)), ], [ sg.Text( @@ -496,7 +518,7 @@ def object_layout(object_type: str = "repo") -> List[list]: ), size=(40, 3), ), - sg.Multiline(key="env.encrypted_variables", size=(48, 5)), + sg.Multiline(key="env.encrypted_env_variables", size=(48, 5)), ], ] @@ -514,7 +536,7 @@ def object_layout(object_type: str = "repo") -> List[list]: [[sg.Column(backup_col, scrollable=True, vertical_scroll_only=True)]], font="helvetica 16", key="--tab-backup--", - element_justification="C", + element_justification="L", ) ], [ @@ -523,7 +545,7 @@ def object_layout(object_type: str = "repo") -> List[list]: repo_col, font="helvetica 16", key="--tab-repo--", - element_justification="C", + element_justification="L", ) ], [ @@ -532,7 +554,7 @@ def object_layout(object_type: str = "repo") -> List[list]: prometheus_col, font="helvetica 16", key="--tab-prometheus--", - element_justification="C", + element_justification="L", ) ], [ @@ -541,13 +563,13 @@ def object_layout(object_type: str = "repo") -> List[list]: env_col, font="helvetica 16", key="--tab-env--", - element_justification="C", + element_justification="L", ) ], ] _layout = [ - [sg.Column(object_selector, element_justification='C')], + [sg.Column(object_selector, element_justification='L')], [sg.TabGroup(tab_group_layout, enable_events=True, key="--object-tabgroup--")], ] return _layout @@ -666,7 +688,7 @@ def global_options_layout(): identity_col, font="helvetica 16", key="--tab-global-identification--", - element_justification="C", + element_justification="L", ) ], [ @@ -675,7 +697,7 @@ def global_options_layout(): prometheus_col, font="helvetica 16", key="--tab-global-prometheus--", - element_justification="C", + element_justification="L", ) ], [ @@ -684,7 +706,7 @@ def global_options_layout(): global_options_col, font="helvetica 16", key="--tab-global-options--", - element_justification="C", + element_justification="L", ) ], [ @@ -693,7 +715,7 @@ def global_options_layout(): scheduled_task_col, font="helvetica 16", key="--tab-global-scheduled_task--", - element_justification="C", + element_justification="L", ) ], ] @@ -704,11 +726,11 @@ def global_options_layout(): return _layout - def config_layout(object_type: str = 'repo') -> List[list]: + def config_layout() -> List[list]: buttons = [ [ - #sg.Text(" " * 135), + sg.Push(), sg.Button(_t("generic.accept"), key="accept"), sg.Button(_t("generic.cancel"), key="cancel"), ] @@ -721,7 +743,8 @@ def config_layout(object_type: str = 'repo') -> List[list]: _global_layout = [ [sg.TabGroup(tab_group_layout, enable_events=True, key="--configtabgroup--")], - [sg.Column(buttons, element_justification="C")], + [sg.Column(buttons, element_justification="L")], + [sg.Button("trololo")] ] return _global_layout @@ -745,6 +768,7 @@ def config_layout(object_type: str = 'repo') -> List[list]: # Update gui with first default object (repo or group) update_object_gui(get_objects()[0], unencrypted=False) + update_global_gui(full_config, unencrypted=False) while True: event, values = window.read() diff --git a/npbackup/translations/config_gui.en.yml b/npbackup/translations/config_gui.en.yml index 1bbd09f..f25c209 100644 --- a/npbackup/translations/config_gui.en.yml +++ b/npbackup/translations/config_gui.en.yml @@ -46,7 +46,7 @@ en: no_config_available: No configuration file found. Please use --config-file "path" to specify one or copy a config file next to the NPBackup binary create_new_config: Would you like to create a new configuration ? saved_initial_config: If you saved your configuration, you may now reload this program - bogus_config_file: Bogus configuration file %{config_file} found + bogus_config_file: Bogus configuration file found encrypted_environment_variables: Encrypted envrionment variables (ie TOKENS etc) environment_variables: Environment variables @@ -103,9 +103,8 @@ en: files_from_raw: Files from raw list keep: Keep - copies: copies - hourly: hourly - daily: daily - weekly: weekly - monthly: monthly - yearly: yearly \ No newline at end of file + hourly: hourly copies + daily: daily copies + weekly: weekly copies + monthly: monthly copies + yearly: yearly copies \ No newline at end of file diff --git a/npbackup/translations/config_gui.fr.yml b/npbackup/translations/config_gui.fr.yml index 8f1071f..59d9357 100644 --- a/npbackup/translations/config_gui.fr.yml +++ b/npbackup/translations/config_gui.fr.yml @@ -46,7 +46,7 @@ fr: no_config_available: Aucun fichier de configuration trouvé. Merci d'utiliser --config-file "chemin" pour spécifier un fichier, ou copier un fichier de configuration a côté du binaire NPBackup. create_new_config: Souhaitez-vous créer une nouvelle configuration ? saved_initial_config: Si vous avez enregistré une configuration, vous pouvez à présent recharger le programme. - bogus_config_file: Fichier de configuration %{config_file} érroné + bogus_config_file: Fichier de configuration érroné encrypted_environment_variables: Variables d'envrionnement chiffrées (ie TOKENS etc) environment_variables: Variables d'environnement @@ -103,9 +103,8 @@ fr: files_from_raw: Liste fichiers depuis un fichier "raw" keep: Garder - copies: copies - hourly: par heure - daily: journalières - weekly: hebdomadaires - monthly: mensuelles - yearly: annuelles \ No newline at end of file + hourly: copies horaires + daily: copies journalières + weekly: copies hebdomadaires + monthly: copies mensuelles + yearly: copies annuelles \ No newline at end of file From 65d4b4fb603b3648ab4fb6065a8b56eab6b217af Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Fri, 15 Dec 2023 13:09:41 +0100 Subject: [PATCH 036/328] Refactored config to allow full group inheritance --- npbackup/configuration.py | 149 ++++++++++++-------------------------- 1 file changed, 48 insertions(+), 101 deletions(-) diff --git a/npbackup/configuration.py b/npbackup/configuration.py index 049aae2..74449bb 100644 --- a/npbackup/configuration.py +++ b/npbackup/configuration.py @@ -19,11 +19,13 @@ from pathlib import Path from ruamel.yaml import YAML from ruamel.yaml.compat import ordereddict +from ruamel.yaml.comments import CommentedMap from logging import getLogger import re import platform from cryptidy import symmetric_encryption as enc from ofunctions.random import random_string +from ofunctions.misc import replace_in_iterable from npbackup.customization import ID_STRING @@ -168,7 +170,7 @@ def s(self, path, value, sep='.'): }, "env": { "env_variables": [], - "env_encrypted_variables": [] + "encrypted_env_variables": [] }, }, "identity": { @@ -195,61 +197,6 @@ def s(self, path, value, sep='.'): } -def iter_over_keys(d: dict, fn: Callable) -> dict: - """ - Execute value=fn(value) on any key in a nested env - """ - if isinstance(d, dict): - for key, value in d.items(): - if isinstance(value, dict): - d[key] = iter_over_keys(value, fn) - else: - d[key] = fn(key, d[key]) - return d - - -# TODO: use ofunctions.misc -def replace_in_iterable( - src: Union[dict, list], original: Union[str, Callable], replacement: Any = None, callable_wants_key: bool = False -): - """ - Recursive replace data in a struct - - Replaces every instance of string original with string replacement in a list/dict - - If original is a callable function, it will replace every instance of original with callable(original) - If original is a callable function and callable_wants_key == True, - it will replace every instance of original with callable(key, original) for dicts - and with callable(original) for any other data type - """ - - def _replace_in_iterable(key, _src): - if isinstance(_src, dict) or isinstance(_src, list): - _src = replace_in_iterable(_src, original, replacement, callable_wants_key) - elif isinstance(original, Callable): - if callable_wants_key: - _src = original(key, _src) - else: - _src = original(_src) - elif isinstance(_src, str) and isinstance(replacement, str): - _src = _src.replace(original, replacement) - else: - _src = replacement - return _src - - if isinstance(src, dict): - for key, value in src.items(): - src[key] = _replace_in_iterable(key, value) - elif isinstance(src, list): - result = [] - for entry in src: - result.append(_replace_in_iterable(None, entry)) - src = result - else: - src = _replace_in_iterable(None, src) - return src - - def crypt_config(full_config: dict, aes_key: str, encrypted_options: List[str], operation: str): try: def _crypt_config(key: str, value: Any) -> Any: @@ -358,37 +305,60 @@ def get_repo_config(full_config: dict, repo_name: str = 'default', eval_variable and a dict containing the repo interitance status """ - repo_config = ordereddict() - config_inheritance = ordereddict() - def inherit_group_settings(repo_config: dict, group_config: dict) -> Tuple[dict, dict]: """ iter over group settings, update repo_config, and produce an identical version of repo_config called config_inheritance, where every value is replaced with a boolean which states inheritance status """ + _repo_config = deepcopy(repo_config) + _group_config = deepcopy(group_config) + _config_inheritance = deepcopy(repo_config) + + def _inherit_group_settings(_repo_config: dict, _group_config: dict, _config_inheritance: dict) -> Tuple[dict, dict]: + if isinstance(_group_config, dict): + if not _repo_config: + # Initialize blank if not set + _repo_config = CommentedMap() + _config_inheritance = CommentedMap() + for key, value in _group_config.items(): + if isinstance(value, dict): + __repo_config, __config_inheritance = _inherit_group_settings(_repo_config.g(key), _group_config.g(key), _config_inheritance.g(key)) + _repo_config.s(key, __repo_config) + _config_inheritance.s(key, __config_inheritance) + elif isinstance(value, list): + # TODO: Lists containing dicts won't be updated in repo_config here + # we need to have + # for elt in list: + # recurse into elt if elt is dict + if isinstance(_repo_config.g(key), list): + merged_lists = _repo_config.g(key) + _group_config.g(key) + else: + merged_lists = _group_config.g(key) + _repo_config.s(key, merged_lists) + _config_inheritance.s(key, True) + else: + # Tricky part + # repo_config may already contain a struct + if not _repo_config: + _repo_config = CommentedMap() + _config_inheritance = CommentedMap() + if not _repo_config.g(key): + _repo_config.s(key, value) + _config_inheritance.s(key, True) + else: + _config_inheritance.s(key, False) + + + - # WIP # TODO: cannot use local repo config, need to make a deepcopy first ? - if isinstance(group_config, dict): - for key, value in group_config.items(): - if isinstance(value, dict): - repo_config[key] = inherit_group_settings(repo_config.g(key), group_config.g(key)) - elif isinstance(value, list) and isinstance(repo_config.g(key), list): - merged_lists = repo_config.g(key) + group_config.g(key) - repo_config.s(key, merged_lists) - config_inheritance.s(key, True) - else: - if not repo_config or not repo_config.g(key): - repo_config = group_config - config_inheritance.s(key, True) - else: - config_inheritance.s(key, False) - return repo_config, config_inheritance + + return _repo_config, _config_inheritance + return _inherit_group_settings(_repo_config, _group_config, _config_inheritance) try: - repo_config = deepcopy(full_config.g(f'repos.{repo_name}')) # Let's make a copy of config since it's a "pointer object" - config_inheritance = replace_in_iterable(deepcopy(full_config.g(f'repos.{repo_name}')), False) + repo_config = deepcopy(full_config.g(f'repos.{repo_name}')) except KeyError: logger.error(f"No repo with name {repo_name} found in config") return None @@ -398,30 +368,7 @@ def inherit_group_settings(repo_config: dict, group_config: dict) -> Tuple[dict, except KeyError: logger.warning(f"Repo {repo_name} has no group") else: - inherit_group_settings(repo_config, group_config) - """ - sections = full_config.g(f'groups.{repo_group}') - if sections: - for section in sections: - # TODO: ordereddict.g() returns None when key doesn't exist instead of KeyError - # So we need this horrible hack - try: - if not repo_config.g(section): - repo_config.s(section, {}) - config_inheritance.s(section, {}) - except KeyError: - repo_config.s(section, {}) - config_inheritance.s(section, {}) - sub_sections = full_config.g(f'groups.{repo_group}.{section}') - if sub_sections: - for entries in sub_sections: - # Do not overwrite repo values already present - if not repo_config.g(f'{section}.{entries}'): - repo_config.s(f'{section}.{entries}', full_config.g(f'groups.{repo_group}.{section}.{entries}')) - config_inheritance.s(f'{section}.{entries}', True) - else: - config_inheritance.s(f'{section}.{entries}', False) - """ + repo_config, config_inheritance = inherit_group_settings(repo_config, group_config) if eval_variables: repo_config = evaluate_variables(repo_config, full_config) From 8874b94676cabc5c807ed1fad58d0a2494487c8a Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Fri, 15 Dec 2023 20:48:12 +0100 Subject: [PATCH 037/328] WIP: refactor config UI --- npbackup/configuration.py | 18 +++-- npbackup/customization.py | 1 + npbackup/gui/__main__.py | 20 +++-- npbackup/gui/config.py | 101 ++++++------------------ npbackup/translations/config_gui.en.yml | 7 +- npbackup/translations/config_gui.fr.yml | 8 +- resources/inheritance_icon.png | Bin 0 -> 442 bytes 7 files changed, 62 insertions(+), 93 deletions(-) create mode 100644 resources/inheritance_icon.png diff --git a/npbackup/configuration.py b/npbackup/configuration.py index 74449bb..89da1ff 100644 --- a/npbackup/configuration.py +++ b/npbackup/configuration.py @@ -7,12 +7,12 @@ __author__ = "Orsiris de Jong" __copyright__ = "Copyright (C) 2022-2023 NetInvent" __license__ = "GPL-3.0-only" -__build__ = "2023121301" +__build__ = "2023121501" __version__ = "2.0.0 for npbackup 2.3.0+" CONF_VERSION = 2.3 -from typing import Tuple, Optional, List, Callable, Any, Union +from typing import Tuple, Optional, List, Any, Union import sys import os from copy import deepcopy @@ -304,7 +304,6 @@ def get_repo_config(full_config: dict, repo_name: str = 'default', eval_variable Returns a dict containing the repo config, with expanded variables and a dict containing the repo interitance status """ - def inherit_group_settings(repo_config: dict, group_config: dict) -> Tuple[dict, dict]: """ iter over group settings, update repo_config, and produce an identical version of repo_config @@ -329,23 +328,30 @@ def _inherit_group_settings(_repo_config: dict, _group_config: dict, _config_inh # TODO: Lists containing dicts won't be updated in repo_config here # we need to have # for elt in list: - # recurse into elt if elt is dict + # recurse into elt if elt is dict if isinstance(_repo_config.g(key), list): merged_lists = _repo_config.g(key) + _group_config.g(key) + # Case where repo config already contains non list info but group config has list + elif _repo_config.g(key): + merged_lists = [_repo_config.g(key)] + _group_config.g(key) else: merged_lists = _group_config.g(key) _repo_config.s(key, merged_lists) _config_inheritance.s(key, True) else: - # Tricky part - # repo_config may already contain a struct + # repo_config may or may not already contain data if not _repo_config: _repo_config = CommentedMap() _config_inheritance = CommentedMap() if not _repo_config.g(key): _repo_config.s(key, value) _config_inheritance.s(key, True) + # Case where repo_config contains list but group info has single str + elif isinstance(_repo_config.g(key), list) and value: + merged_lists = _repo_config.g(key) + [value] + _repo_config.s(key, merged_lists) else: + # In other cases, just keep repo confg _config_inheritance.s(key, False) diff --git a/npbackup/customization.py b/npbackup/customization.py index 9722a31..e1199af 100644 --- a/npbackup/customization.py +++ b/npbackup/customization.py @@ -50,3 +50,4 @@ FOLDER_ICON = b"iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAAsSAAALEgHS3X78AAABnUlEQVQ4y8WSv2rUQRSFv7vZgJFFsQg2EkWb4AvEJ8hqKVilSmFn3iNvIAp21oIW9haihBRKiqwElMVsIJjNrprsOr/5dyzml3UhEQIWHhjmcpn7zblw4B9lJ8Xag9mlmQb3AJzX3tOX8Tngzg349q7t5xcfzpKGhOFHnjx+9qLTzW8wsmFTL2Gzk7Y2O/k9kCbtwUZbV+Zvo8Md3PALrjoiqsKSR9ljpAJpwOsNtlfXfRvoNU8Arr/NsVo0ry5z4dZN5hoGqEzYDChBOoKwS/vSq0XW3y5NAI/uN1cvLqzQur4MCpBGEEd1PQDfQ74HYR+LfeQOAOYAmgAmbly+dgfid5CHPIKqC74L8RDyGPIYy7+QQjFWa7ICsQ8SpB/IfcJSDVMAJUwJkYDMNOEPIBxA/gnuMyYPijXAI3lMse7FGnIKsIuqrxgRSeXOoYZUCI8pIKW/OHA7kD2YYcpAKgM5ABXk4qSsdJaDOMCsgTIYAlL5TQFTyUIZDmev0N/bnwqnylEBQS45UKnHx/lUlFvA3fo+jwR8ALb47/oNma38cuqiJ9AAAAAASUVORK5CYII=" FILE_ICON = b"iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAAsSAAALEgHS3X78AAABU0lEQVQ4y52TzStEURiHn/ecc6XG54JSdlMkNhYWsiILS0lsJaUsLW2Mv8CfIDtr2VtbY4GUEvmIZnKbZsY977Uwt2HcyW1+dTZvt6fn9557BGB+aaNQKBR2ifkbgWR+cX13ubO1svz++niVTA1ArDHDg91UahHFsMxbKWycYsjze4muTsP64vT43v7hSf/A0FgdjQPQWAmco68nB+T+SFSqNUQgcIbN1bn8Z3RwvL22MAvcu8TACFgrpMVZ4aUYcn77BMDkxGgemAGOHIBXxRjBWZMKoCPA2h6qEUSRR2MF6GxUUMUaIUgBCNTnAcm3H2G5YQfgvccYIXAtDH7FoKq/AaqKlbrBj2trFVXfBPAea4SOIIsBeN9kkCwxsNkAqRWy7+B7Z00G3xVc2wZeMSI4S7sVYkSk5Z/4PyBWROqvox3A28PN2cjUwinQC9QyckKALxj4kv2auK0xAAAAAElFTkSuQmCC" LOADER_ANIMATION = b"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" +INHERITANCE_ICON = b"iVBORw0KGgoAAAANSUhEUgAAABIAAAASCAYAAABWzo5XAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAACxMAAAsTAQCanBgAAAFPSURBVDhPrZS9LkRBFMevjxWVDa0obOGrl2g8gkdQoPIC3kKpofIEhFoUGq2CRMIWFDqyBfGR4Pe7e+/m7twZFP7Jb3fmnDPnzp055w5kaTWgBc18lmUdaMNHPvuDRmADjuEWngoca9NnzI+ahVP4+gVjjI1KxxXEFj7Ce2Azdgb65FZTOzmCSViF18JWcgKeZU++dzWgyi6oRXiG0L8OuczoIYYBJS9wDt5YzO/axiA/XvEChLqHbdiCO5iGmOahZSLrZFxLIH0+cQ824QJimoCmwSl5wCtg4CgMQVImsmItuJjcxQN4zXMaIrI0OibyEK2JmM6K/2UY7g5rcm3bRPbOoZZAb3DdHWZj4NV/5rN+HUCv/1IFuQNTsAT7YKKqv1aQKtUinmGsEC+h1iKldPiUcFGIMckkpdyqZW/F3kD5GXFs361B7XX+6cOWZd8L0Yd3yKkunwAAAABJRU5ErkJggg==" \ No newline at end of file diff --git a/npbackup/gui/__main__.py b/npbackup/gui/__main__.py index ff7e299..7fe1995 100644 --- a/npbackup/gui/__main__.py +++ b/npbackup/gui/__main__.py @@ -207,7 +207,7 @@ def _gui_update_state( ) elif current_state is False: window["state-button"].Update( - "{}: {}".format(_t("generic.too_old"), backup_tz), + "{}: {}".format(_t("generic.too_old"), backup_tz.replace(microsecond=0)), button_color=GUI_STATE_OLD_BUTTON, ) elif current_state is None: @@ -570,8 +570,7 @@ def _main_gui(): logger.info(f"Using configuration file {config_file}") full_config = npbackup.configuration.load_config(config_file) - # TODO add a repo selector - repo_config, inherit_config = npbackup.configuration.get_repo_config(full_config) + repo_config, config_inheritance = npbackup.configuration.get_repo_config(full_config) repo_list = npbackup.configuration.get_repo_list(full_config) backup_destination = _t("main_gui.local_folder") @@ -661,7 +660,12 @@ def _main_gui(): window["snapshot-list"].expand(True, True) window.read(timeout=1) - current_state, backup_tz, snapshot_list = get_gui_data(repo_config) + try: + current_state, backup_tz, snapshot_list = get_gui_data(repo_config) + except ValueError: + current_state = None + backup_tz = None + snapshot_list = [] _gui_update_state(window, current_state, backup_tz, snapshot_list) while True: event, values = window.read(timeout=60000) @@ -671,7 +675,7 @@ def _main_gui(): if event == "-active_repo-": active_repo = values['-active_repo-'] if full_config.g(f"repos.{active_repo}"): - repo_config = npbackup.configuration.get_repo_config(full_config, active_repo) + repo_config, config_inheriteance = npbackup.configuration.get_repo_config(full_config, active_repo) current_state, backup_tz, snapshot_list = get_gui_data(repo_config) else: sg.PopupError("Repo not existent in config") @@ -783,4 +787,8 @@ def main_gui(): _main_gui() except _tkinter.TclError as exc: logger.critical(f'Tkinter error: "{exc}". Is this a headless server ?') - sys.exit(250) \ No newline at end of file + sys.exit(250) + except Exception as exc: + sg.Popup(_t("config_gui.bogus_config_file") + f': {exc}') + raise #TODO replace with logger + sys.exit(251) \ No newline at end of file diff --git a/npbackup/gui/config.py b/npbackup/gui/config.py index 8f17738..3289f57 100644 --- a/npbackup/gui/config.py +++ b/npbackup/gui/config.py @@ -15,11 +15,12 @@ from logging import getLogger from copy import deepcopy import PySimpleGUI as sg +from ruamel.yaml.comments import CommentedMap import npbackup.configuration as configuration from ofunctions.misc import get_key_from_value from npbackup.core.i18n_helper import _t from npbackup.path_helper import CURRENT_EXECUTABLE -from npbackup.core.nuitka_helper import IS_COMPILED +from npbackup.customization import INHERITANCE_ICON if os.name == "nt": from npbackup.windows.task import create_scheduled_task @@ -108,7 +109,7 @@ def update_gui_values(key, value, inherited, object_type, unencrypted): """ if key == "backup_admin_password": return - if key == "repo_uri": + if key in ("repo_uri", "repo_group"): if object_type == "group": window[key].Disabled = True else: @@ -137,10 +138,10 @@ def update_gui_values(key, value, inherited, object_type, unencrypted): else: window[key].Update(value) - # Set inheritance on + # Enable inheritance icon when needed inheritance_key = f'inherited.{key}' if inheritance_key in window.AllKeysDict: - window[f'inherited.{key}'].update(visible=True if inherited else False) + window[inheritance_key].update(visible=True if inherited else False) except KeyError: logger.error(f"No GUI equivalent for key {key}.") @@ -177,31 +178,28 @@ def update_object_gui(object_name = None, unencrypted=False): # First we need to clear the whole GUI to reload new values for key in window.AllKeysDict: # We only clear config keys, wihch have '.' separator - if "." in str(key): + if "." in str(key) and not "inherited" in str(key): window[key]('') object_type, object_name = get_object_from_combo(object_name) + if object_type == 'repo': object_config, config_inheritance = configuration.get_repo_config(full_config, object_name, eval_variables=False) if object_type == 'group': object_config = configuration.get_group_config(full_config, object_name, eval_variables=False) - - + config_inheritance = None # Now let's iter over the whole config object and update keys accordingly iter_over_config(object_config, config_inheritance, object_type, unencrypted, None) + def update_global_gui(full_config, unencrypted=False): - # TODO - return - global_config = deepcopy(full_config) + global_config = CommentedMap() # Only update global options gui with identified global keys - for key in global_config.keys(): - if key not in ('identity', 'global_prometheus', 'global_options'): - global_config.pop(key) - print(global_config) - - iter_over_config(global_config, None, None, unencrypted, None) + for key in full_config.keys(): + if key in ('identity', 'global_options'): + global_config.s(key, full_config.g(key)) + iter_over_config(global_config, None, 'group', unencrypted, None) def update_config_dict(values, full_config): @@ -239,13 +237,14 @@ def update_config_dict(values, full_config): return full_config - def object_layout(object_type: str = "repo") -> List[list]: + def object_layout() -> List[list]: """ Returns the GUI layout depending on the object type """ backup_col = [ [ sg.Text(_t("config_gui.compression"), size=(40, 1)), + sg.pin(sg.Image(INHERITANCE_ICON, key="inherited.backup_opts.compression", tooltip=_t("config_gui.group_inherited"))), sg.Combo( list(combo_boxes["compression"].values()), key="backup_opts.compression", @@ -259,10 +258,12 @@ def object_layout(object_type: str = "repo") -> List[list]: ), size=(40, 2), ), + sg.pin(sg.Image(INHERITANCE_ICON, expand_x=True, expand_y=True, key="inherited.backup_opts.paths", tooltip=_t("config_gui.group_inherited"))), sg.Multiline(key="backup_opts.paths", size=(48, 4)), ], [ sg.Text(_t("config_gui.source_type"), size=(40, 1)), + sg.pin(sg.Image(INHERITANCE_ICON, expand_x=True, expand_y=True, key="inherited.backup_opts.source_type", tooltip=_t("config_gui.group_inherited"))), sg.Combo( list(combo_boxes["source_type"].values()), key="backup_opts.source_type", @@ -276,7 +277,7 @@ def object_layout(object_type: str = "repo") -> List[list]: ), size=(40, 2), ), - sg.Text("inherited", key="inherited.backup_opts.use_fs_snapshot", visible=False), + sg.pin(sg.Image(INHERITANCE_ICON, expand_x=True, expand_y=True, key="inherited.backup_opts.use_fs_snapshot", tooltip=_t("config_gui.group_inherited"))), sg.Checkbox("", key="backup_opts.use_fs_snapshot", size=(41, 1)), ], [ @@ -533,7 +534,7 @@ def object_layout(object_type: str = "repo") -> List[list]: [ sg.Tab( _t("config_gui.backup"), - [[sg.Column(backup_col, scrollable=True, vertical_scroll_only=True)]], + [[sg.Column(backup_col, scrollable=True, vertical_scroll_only=True, size=(700, 450))]], font="helvetica 16", key="--tab-backup--", element_justification="L", @@ -542,7 +543,7 @@ def object_layout(object_type: str = "repo") -> List[list]: [ sg.Tab( _t("config_gui.backup_destination"), - repo_col, + [[sg.Column(repo_col, scrollable=True, vertical_scroll_only=True, size=(700, 450))]], font="helvetica 16", key="--tab-repo--", element_justification="L", @@ -590,54 +591,6 @@ def global_options_layout(): sg.Input(key="identity.machine_group", size=(50, 1)), ], ] - - prometheus_col = [ - [sg.Text(_t("config_gui.available_variables"))], - [ - sg.Text(_t("config_gui.enable_prometheus"), size=(40, 1)), - sg.Checkbox("", key="global_prometheus.metrics", size=(41, 1)), - ], - [ - sg.Text(_t("config_gui.job_name"), size=(40, 1)), - sg.Input(key="global_prometheus.backup_job", size=(50, 1)), - ], - [ - sg.Text(_t("config_gui.metrics_destination"), size=(40, 1)), - sg.Input(key="global_prometheus.destination", size=(50, 1)), - ], - [ - sg.Text(_t("config_gui.no_cert_verify"), size=(40, 1)), - sg.Checkbox("", key="global_prometheus.no_cert_verify", size=(41, 1)), - ], - [ - sg.Text(_t("config_gui.metrics_username"), size=(40, 1)), - sg.Input(key="global_prometheus.http_username", size=(50, 1)), - ], - [ - sg.Text(_t("config_gui.metrics_password"), size=(40, 1)), - sg.Input(key="global_prometheus.http_password", size=(50, 1)), - ], - [ - sg.Text(_t("config_gui.instance"), size=(40, 1)), - sg.Input(key="global_prometheus.instance", size=(50, 1)), - ], - [ - sg.Text(_t("generic.group"), size=(40, 1)), - sg.Input(key="global_prometheus.group", size=(50, 1)), - ], - [ - sg.Text( - "{}\n({}\n{})".format( - _t("config_gui.additional_labels"), - _t("config_gui.one_per_line"), - _t("config_gui.format_equals"), - ), - size=(40, 3), - ), - sg.Multiline(key="global_prometheus.additional_labels", size=(48, 3)), - ], - ] - global_options_col = [ [sg.Text(_t("config_gui.available_variables"))], [ @@ -691,15 +644,6 @@ def global_options_layout(): element_justification="L", ) ], - [ - sg.Tab( - _t("config_gui.prometheus_config"), - prometheus_col, - font="helvetica 16", - key="--tab-global-prometheus--", - element_justification="L", - ) - ], [ sg.Tab( _t("generic.options"), @@ -743,8 +687,7 @@ def config_layout() -> List[list]: _global_layout = [ [sg.TabGroup(tab_group_layout, enable_events=True, key="--configtabgroup--")], - [sg.Column(buttons, element_justification="L")], - [sg.Button("trololo")] + [sg.Push(), sg.Column(buttons, element_justification="L")], ] return _global_layout diff --git a/npbackup/translations/config_gui.en.yml b/npbackup/translations/config_gui.en.yml index f25c209..fbb8757 100644 --- a/npbackup/translations/config_gui.en.yml +++ b/npbackup/translations/config_gui.en.yml @@ -107,4 +107,9 @@ en: daily: daily copies weekly: weekly copies monthly: monthly copies - yearly: yearly copies \ No newline at end of file + yearly: yearly copies + + group_inherited: Group inherited + repo_group_config: Repo group configuration + global_config: Global config + select_object: Select configuration object diff --git a/npbackup/translations/config_gui.fr.yml b/npbackup/translations/config_gui.fr.yml index 59d9357..3e5de1e 100644 --- a/npbackup/translations/config_gui.fr.yml +++ b/npbackup/translations/config_gui.fr.yml @@ -107,4 +107,10 @@ fr: daily: copies journalières weekly: copies hebdomadaires monthly: copies mensuelles - yearly: copies annuelles \ No newline at end of file + yearly: copies annuelles + + group_inherited: Hérité du groupe + repo_group_config: Configuration de groupe de dépots + global_config: Configuration globale + select_object: Selectionner l'object à configurer + \ No newline at end of file diff --git a/resources/inheritance_icon.png b/resources/inheritance_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..176cbabc7896b5620bcf319d6bcf740e569ad5f6 GIT binary patch literal 442 zcmV;r0Y(0aP)Px#1ZP1_K>z@;j|==^1poj532;bRa{vGf6951U69E94oEQKA0Z&OpK~y+Tt(3hk zL_rkCua6a#4Xr3_;j5QuJc371pz;FVLaCwh1cX`?8m)pv!WI-dvIX&x;P<Z+GULGw05oIp+>!X*Hk)&3u++9caTxKKp}4U;~cf7M=ttL@Abmb@4>sWfVw!-a!6|vbD`4O# z3_(&RvSKZ`nB2t!c+GPfBC<3(Ch`!iiI~oc|795N4r_94+eG%xp_V{zaNQkdW8l?) z=CghsK(GH&1-S!Lumt;HqN=}EkSf(8o?)yIFQL{VrF8g|a8Zac$t0!Rs%3A*-az>o kactkMLG5+^>BE*~-wV-)cgU$Op8x;=07*qoM6N<$f~EJmk^lez literal 0 HcmV?d00001 From e270f892e0ca7232e59ed28807c1cd93a11dbae1 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Fri, 15 Dec 2023 21:04:45 +0100 Subject: [PATCH 038/328] WIP: Gui improvements --- npbackup/gui/config.py | 63 +++++-------------------- npbackup/translations/config_gui.en.yml | 8 ++-- npbackup/translations/config_gui.fr.yml | 5 +- 3 files changed, 18 insertions(+), 58 deletions(-) diff --git a/npbackup/gui/config.py b/npbackup/gui/config.py index 3289f57..3ab2845 100644 --- a/npbackup/gui/config.py +++ b/npbackup/gui/config.py @@ -252,12 +252,7 @@ def object_layout() -> List[list]: ), ], [ - sg.Text( - "{}\n({})".format( - _t("config_gui.backup_paths"), _t("config_gui.one_per_line") - ), - size=(40, 2), - ), + sg.Text(f"{_t('config_gui.backup_paths')}\n({_t('config_gui.one_per_line')})", size=(40, 2)), sg.pin(sg.Image(INHERITANCE_ICON, expand_x=True, expand_y=True, key="inherited.backup_opts.paths", tooltip=_t("config_gui.group_inherited"))), sg.Multiline(key="backup_opts.paths", size=(48, 4)), ], @@ -290,21 +285,11 @@ def object_layout() -> List[list]: sg.Checkbox("", key="backup_opts.ignore_cloud_files", size=(41, 1)), ], [ - sg.Text( - "{}\n({})".format( - _t("config_gui.exclude_patterns"), _t("config_gui.one_per_line") - ), - size=(40, 2), - ), + sg.Text(f"{_t('config_gui.exclude_patterns')}\n({_t('config_gui.one_per_line')})", size=(40, 2)), sg.Multiline(key="backup_opts.exclude_patterns", size=(48, 4)), ], [ - sg.Text( - "{}\n({})".format( - _t("config_gui.exclude_files"), _t("config_gui.one_per_line") - ), - size=(40, 2), - ), + sg.Text(f"{_t('config_gui.exclude_files')}\n({_t('config_gui.one_per_line')})", size=(40, 2)), sg.Multiline(key="backup_opts.exclude_files", size=(48, 4)), ], [ @@ -326,8 +311,8 @@ def object_layout() -> List[list]: sg.Checkbox("", key="backup_opts.one_file_system", size=(41, 1)), ], [ - sg.Text(_t("config_gui.pre_exec_command"), size=(40, 1)), - sg.Input(key="backup_opts.pre_exec_command", size=(50, 1)), + sg.Text(f"{_t('config_gui.pre_exec_commands')}\n({_t('config_gui.one_per_line')})", size=(40, 2)), + sg.Multiline(key="backup_opts.pre_exec_commands", size=(48, 4)), ], [ sg.Text(_t("config_gui.maximum_exec_time"), size=(40, 1)), @@ -338,8 +323,8 @@ def object_layout() -> List[list]: sg.Checkbox("", key="backup_opts.pre_exec_failure_is_fatal", size=(41, 1)), ], [ - sg.Text(_t("config_gui.post_exec_command"), size=(40, 1)), - sg.Input(key="backup_opts.post_exec_command", size=(50, 1)), + sg.Text(f"{_t('config_gui.post_exec_commands')}\n({_t('config_gui.one_per_line')})", size=(40, 2)), + sg.Multiline(key="backup_opts.post_exec_commands", size=(48, 4)), ], [ sg.Text(_t("config_gui.maximum_exec_time"), size=(40, 1)), @@ -350,11 +335,8 @@ def object_layout() -> List[list]: sg.Checkbox("", key="backup_opts.post_exec_failure_is_fatal", size=(41, 1)), ], [ - sg.Text( - "{}\n({})".format(_t("config_gui.tags"), _t("config_gui.one_per_line")), - size=(40, 2), - ), - sg.Multiline(key="backup_opts.tags", size=(48, 2)), + sg.Text(f"{_t('config_gui.tags')}\n({_t('config_gui.one_per_line')})", size=(40, 2)), + sg.Multiline(key="backup_opts.tags", size=(48, 4)), ], [ sg.Text(_t("config_gui.backup_priority"), size=(40, 1)), @@ -486,39 +468,18 @@ def object_layout() -> List[list]: sg.Input(key="prometheus.group", size=(50, 1)), ], [ - sg.Text( - "{}\n({}\n{})".format( - _t("config_gui.additional_labels"), - _t("config_gui.one_per_line"), - _t("config_gui.format_equals"), - ), - size=(40, 3), - ), + sg.Text(f"{_t('config_gui.additional_labels')}\n({_t('config_gui.one_per_line')}\n{_t('config_gui.format_equals')})", size=(40, 3)), sg.Multiline(key="prometheus.additional_labels", size=(48, 3)), ], ] env_col = [ [ - sg.Text( - "{}\n({}\n{})".format( - _t("config_gui.environment_variables"), - _t("config_gui.one_per_line"), - _t("config_gui.format_equals"), - ), - size=(40, 3), - ), + sg.Text(f"{_t('config_gui.env_variables')}\n({_t('config_gui.one_per_line')}\n{_t('config_gui.format_equals')})", size=(40, 3)), sg.Multiline(key="env.env_variables", size=(48, 5)), ], [ - sg.Text( - "{}\n({}\n{})".format( - _t("config_gui.encrypted_environment_variables"), - _t("config_gui.one_per_line"), - _t("config_gui.format_equals"), - ), - size=(40, 3), - ), + sg.Text(f"{_t('config_gui.encrypted_env_variables')}\n({_t('config_gui.one_per_line')}\n{_t('config_gui.format_equals')})", size=(40, 3)), sg.Multiline(key="env.encrypted_env_variables", size=(48, 5)), ], ] diff --git a/npbackup/translations/config_gui.en.yml b/npbackup/translations/config_gui.en.yml index fbb8757..6a7e16c 100644 --- a/npbackup/translations/config_gui.en.yml +++ b/npbackup/translations/config_gui.en.yml @@ -12,10 +12,10 @@ en: windows_always: always enabled for Windows exclude_cache_dirs: Exclude cache dirs one_file_system: Do not follow mountpoints - pre_exec_command: Pre-exec command + pre_exec_commands: Pre-exec commands maximum_exec_time: Maximum exec time exec_failure_is_fatal: Execution failure is fatal - post_exec_command: Post-exec command + post_exec_commands: Post-exec commands tags: Tags one_per_line: one per line backup_priority: Backup priority @@ -48,8 +48,8 @@ en: saved_initial_config: If you saved your configuration, you may now reload this program bogus_config_file: Bogus configuration file found - encrypted_environment_variables: Encrypted envrionment variables (ie TOKENS etc) - environment_variables: Environment variables + encrypted_env_variables: Encrypted envrionment variables (ie TOKENS etc) + env_variables: Environment variables format_equals: Format variable=value no_runner: Cannot connect to backend. Please see logs diff --git a/npbackup/translations/config_gui.fr.yml b/npbackup/translations/config_gui.fr.yml index 3e5de1e..cf9db28 100644 --- a/npbackup/translations/config_gui.fr.yml +++ b/npbackup/translations/config_gui.fr.yml @@ -12,10 +12,10 @@ fr: windows_always: toujours actif pour Windows exclude_cache_dirs: Exclure dossiers cache one_file_system: Ne pas suivre les points de montage - pre_exec_command: Commande pré-sauvegarde + pre_exec_commands: Commandes pré-sauvegarde maximum_exec_time: Temps maximal d'execution exec_failure_is_fatal: L'échec d'execution est fatal - post_exec_command: Commande post-sauvegarde + post_exec_commands: Commandes post-sauvegarde tags: Tags one_per_line: un par ligne backup_priority: Priorité de sauvegarde @@ -113,4 +113,3 @@ fr: repo_group_config: Configuration de groupe de dépots global_config: Configuration globale select_object: Selectionner l'object à configurer - \ No newline at end of file From 276a539bbca61e4a088098f7d07a8d0140e5f1be Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Sat, 16 Dec 2023 13:46:36 +0100 Subject: [PATCH 039/328] Move debug logic to separate file --- bin/NPBackupInstaller.py | 2 +- npbackup/__debug__.py | 29 +++++++++++++++++++++++++++++ npbackup/gui/__main__.py | 7 +++++-- 3 files changed, 35 insertions(+), 3 deletions(-) create mode 100644 npbackup/__debug__.py diff --git a/bin/NPBackupInstaller.py b/bin/NPBackupInstaller.py index c29c9d7..e125880 100644 --- a/bin/NPBackupInstaller.py +++ b/bin/NPBackupInstaller.py @@ -23,10 +23,10 @@ from npbackup.customization import PROGRAM_NAME, PROGRAM_DIRECTORY from npbackup.path_helper import CURRENT_DIR +from npbackup.__debug__ import _DEBUG del sys.path[0] -_DEBUG = os.environ.get("_DEBUG", False) LOG_FILE = os.path.join(CURRENT_DIR, __intname__ + ".log") BASEDIR = os.path.abspath(os.path.dirname(os.path.realpath(__file__))) diff --git a/npbackup/__debug__.py b/npbackup/__debug__.py new file mode 100644 index 0000000..70fd97d --- /dev/null +++ b/npbackup/__debug__.py @@ -0,0 +1,29 @@ +#! /usr/bin/env python +# -*- coding: utf-8 -*- +# +# This file is part of npbackup + +__intname__ = "npbackup.__debug__" +__author__ = "Orsiris de Jong" +__site__ = "https://www.netperfect.fr/npbackup" +__description__ = "NetPerfect Backup Client" +__copyright__ = "Copyright (C) 2023 NetInvent" + + +import os + + +# If set, debugging will be enabled by setting envrionment variable to __SPECIAL_DEBUG_STRING content +# Else, a simple true or false will suffice +__SPECIAL_DEBUG_STRING = "" +__debug_os_env = os.environ.get("_DEBUG", "False").strip("'\"") + +try: + _DEBUG +except NameError: + _DEBUG = False + if __SPECIAL_DEBUG_STRING: + if __debug_os_env == __SPECIAL_DEBUG_STRING: + _DEBUG = True + elif __debug_os_env.capitalize() == "True": + _DEBUG = True diff --git a/npbackup/gui/__main__.py b/npbackup/gui/__main__.py index 7fe1995..cd89d48 100644 --- a/npbackup/gui/__main__.py +++ b/npbackup/gui/__main__.py @@ -47,6 +47,7 @@ from npbackup.path_helper import CURRENT_DIR from npbackup.interface_entrypoint import entrypoint from npbackup.__version__ import version_string +from npbackup.__debug__ import _DEBUG from npbackup.gui.config import config_gui from npbackup.gui.operations import operations_gui from npbackup.customization import ( @@ -789,6 +790,8 @@ def main_gui(): logger.critical(f'Tkinter error: "{exc}". Is this a headless server ?') sys.exit(250) except Exception as exc: - sg.Popup(_t("config_gui.bogus_config_file") + f': {exc}') - raise #TODO replace with logger + sg.Popup(_t("config_gui.unknown_error_see_logs") + f': {exc}') + logger.critical("GUI Execution error", exc) + if _DEBUG: + logger.critical("Trace:", exc_info=True) sys.exit(251) \ No newline at end of file From 16d0ca3e6d41fc55dee436b286e03a5c5f84778c Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Sat, 16 Dec 2023 13:47:28 +0100 Subject: [PATCH 040/328] Improve config GUI, add repo/group creation/deletion --- npbackup/gui/config.py | 73 ++++++++++++++++++++++--- npbackup/translations/config_gui.en.yml | 5 ++ npbackup/translations/config_gui.fr.yml | 5 ++ npbackup/translations/generic.en.yml | 2 + npbackup/translations/generic.fr.yml | 4 +- 5 files changed, 80 insertions(+), 9 deletions(-) diff --git a/npbackup/gui/config.py b/npbackup/gui/config.py index 3ab2845..cfbd8a9 100644 --- a/npbackup/gui/config.py +++ b/npbackup/gui/config.py @@ -87,7 +87,52 @@ def get_objects() -> List[str]: object_list.append(f"Group: {group}") return object_list + def create_object(full_config: dict) -> dict: + + layout = [ + [sg.Text(_t("generic.type")), sg.Combo(["repo", "group"], default_value="repo", key="-OBJECT-TYPE-"), sg.Text(_t("generic.name")), sg.Input(key="-OBJECT-NAME-")], + [sg.Push(), sg.Button(_t("generic.cancel"), key="--CANCEL--"), sg.Button(_t("generic.accept"), key="--ACCEPT--")] + ] + + window = sg.Window(_t("config_gui.create_object"), layout=layout, keep_on_top=True) + while True: + event, values = window.read() + if event in (sg.WIN_CLOSED, sg.WIN_X_EVENT, '--CANCEL--'): + break + if event == '--ACCEPT--': + object_type = values['-OBJECT-TYPE-'] + object_name = values['-OBJECT-NAME-'] + if object_type == "repo": + if full_config.g(f"repos.{object_name}"): + sg.PopupError(_t("config_gui.repo_already_exists"), keep_on_top=True) + continue + full_config.s(f"repos.{object_name}", CommentedMap()) + elif object_type == "group": + if full_config.g(f"groups.{object_name}"): + sg.PopupError(_t("config_gui.group_already_exists"), keep_on_top=True) + continue + full_config.s(f"groups.{object_name}", CommentedMap()) + else: + raise ValueError("Bogus object type given") + window.close() + update_object_gui(None, unencrypted=False) + return full_config + def delete_object(full_config: dict, object_name: str) -> dict: + object_type, object_name = get_object_from_combo(object_name) + result = sg.PopupYesNo(_t("config_gui.are_you_sure_to_delete") + f" {object_type} {object_name} ?") + if result: + full_config.d(f"{object_type}s.{object_name}") + update_object_gui(None, unencrypted=False) + return full_config + + + def update_object_selector() -> None: + objects = get_objects() + window["-OBJECT-SELECT-"].Update(objects) + window["-OBJECT-SELECT-"].Update(value=objects[0]) + + def get_object_from_combo(combo_value: str) -> (str, str): """ Extracts selected object from combobox @@ -487,7 +532,7 @@ def object_layout() -> List[list]: object_list = get_objects() object_selector = [ [ - sg.Text(_t("config_gui.select_object")), sg.Combo(object_list, default_value=object_list[0], key='-OBJECT-', enable_events=True) + sg.Text(_t("config_gui.select_object")), sg.Combo(object_list, default_value=object_list[0], key='-OBJECT-SELECT-', enable_events=True), ] ] @@ -636,8 +681,10 @@ def config_layout() -> List[list]: buttons = [ [ sg.Push(), - sg.Button(_t("generic.accept"), key="accept"), - sg.Button(_t("generic.cancel"), key="cancel"), + sg.Button(_t("config_gui.create_object"), key='-OBJECT-CREATE-'), + sg.Button(_t("config_gui.delete_object"), key='-OBJECT-DELETE-'), + sg.Button(_t("generic.cancel"), key="--CANCEL--"), + sg.Button(_t("generic.accept"), key="--ACCEPT--"), ] ] @@ -676,14 +723,23 @@ def config_layout() -> List[list]: while True: event, values = window.read() - if event in (sg.WIN_CLOSED, sg.WIN_X_EVENT, "cancel"): + if event in (sg.WIN_CLOSED, sg.WIN_X_EVENT, "--CANCEL--"): break - if event == "-OBJECT-": + if event == "-OBJECT-SELECT-": try: - update_object_gui(values["-OBJECT-"], unencrypted=False) + update_object_gui(values["-OBJECT-SELECT-"], unencrypted=False) + update_global_gui(full_config, unencrypted=False) except AttributeError: continue - if event == "accept": + if event == "-OBJECT-DELETE-": + full_config = delete_object(full_config, values["-OBJECT-SELECT-"]) + update_object_selector() + continue + if event == "-OBJECT-CREATE-": + full_config = create_object(full_config) + update_object_selector() + continue + if event == "--ACCEPT--": if not values["repo_opts.password"] and not values["repo_opts.password_command"]: sg.PopupError(_t("config_gui.repo_password_cannot_be_empty")) continue @@ -700,7 +756,8 @@ def config_layout() -> List[list]: logger.info("Could not save configuration") if event == _t("config_gui.show_decrypted"): if ask_backup_admin_password(full_config): - update_object_gui(values["-OBJECT-"], unencrypted=True) + update_object_gui(values["-OBJECT-SELECT-"], unencrypted=True) + update_global_gui(full_config, unencrypted=True) if event == "create_task": if os.name == "nt": result = create_scheduled_task( diff --git a/npbackup/translations/config_gui.en.yml b/npbackup/translations/config_gui.en.yml index 6a7e16c..5156dfd 100644 --- a/npbackup/translations/config_gui.en.yml +++ b/npbackup/translations/config_gui.en.yml @@ -113,3 +113,8 @@ en: repo_group_config: Repo group configuration global_config: Global config select_object: Select configuration object + create_object: Create new repo or group + delete_object: Delete selected repo or group + are_you_sure_to_delete: Are you sure you want to delete + repo_already_exists: Repo already exists + group_already_exists: Group already exists diff --git a/npbackup/translations/config_gui.fr.yml b/npbackup/translations/config_gui.fr.yml index cf9db28..cf3b4d7 100644 --- a/npbackup/translations/config_gui.fr.yml +++ b/npbackup/translations/config_gui.fr.yml @@ -113,3 +113,8 @@ fr: repo_group_config: Configuration de groupe de dépots global_config: Configuration globale select_object: Selectionner l'object à configurer + create_object: Créer un nouveau dépot ou groupe + delete_object: Supprimer le dépot ou groupe actuel + are_you_sure_to_delete: Êtes-vous sûr de vouloir supprimer le + repo_already_exists: Dépot déjà existant + group_already_exists: Groupe déjà existant \ No newline at end of file diff --git a/npbackup/translations/generic.en.yml b/npbackup/translations/generic.en.yml index e516ee3..2a69f5f 100644 --- a/npbackup/translations/generic.en.yml +++ b/npbackup/translations/generic.en.yml @@ -52,3 +52,5 @@ en: are_you_sure: Are you sure ? select_file: Select file + name: Name + type: Type \ No newline at end of file diff --git a/npbackup/translations/generic.fr.yml b/npbackup/translations/generic.fr.yml index f987019..cf27f86 100644 --- a/npbackup/translations/generic.fr.yml +++ b/npbackup/translations/generic.fr.yml @@ -51,4 +51,6 @@ fr: are_you_sure: Etes-vous sûr ? - select_file: Selection fichier \ No newline at end of file + select_file: Selection fichier + name: Nom + type: Type \ No newline at end of file From be5096d6527b2c6f5a9ed18be736744b4e25fd03 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Sat, 16 Dec 2023 13:47:48 +0100 Subject: [PATCH 041/328] Add deletion function to ruamel.yaml dicts --- npbackup/configuration.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/npbackup/configuration.py b/npbackup/configuration.py index 89da1ff..929e836 100644 --- a/npbackup/configuration.py +++ b/npbackup/configuration.py @@ -83,8 +83,22 @@ def s(self, path, value, sep='.'): data = data[key] data[lastkey] = value +def d(self, path, sep='.'): + """ + Deletion for dot notation in a dict/OrderedDict + d.d('my.array.keys') + """ + data = self + keys = path.split(sep) + lastkey = keys[-1] + for key in keys[:-1]: + data = data[key] + data.pop(lastkey) + + ordereddict.g = g ordereddict.s = s +ordereddict.d = d # NPF-SEC-00003: Avoid password command divulgation ENCRYPTED_OPTIONS = [ @@ -354,11 +368,6 @@ def _inherit_group_settings(_repo_config: dict, _group_config: dict, _config_inh # In other cases, just keep repo confg _config_inheritance.s(key, False) - - - - - return _repo_config, _config_inheritance return _inherit_group_settings(_repo_config, _group_config, _config_inheritance) From ba79fbe18e229eff8375de70c0ca4731ef542864 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Sat, 16 Dec 2023 15:09:59 +0100 Subject: [PATCH 042/328] Reformat files with black --- npbackup/configuration.py | 202 ++++++++++------- npbackup/core/runner.py | 55 +++-- npbackup/core/upgrade_runner.py | 4 +- npbackup/customization.py | 2 +- npbackup/gui/config.py | 323 ++++++++++++++++++++-------- npbackup/restic_wrapper/__init__.py | 6 +- 6 files changed, 404 insertions(+), 188 deletions(-) diff --git a/npbackup/configuration.py b/npbackup/configuration.py index 929e836..5f5c77e 100644 --- a/npbackup/configuration.py +++ b/npbackup/configuration.py @@ -64,14 +64,15 @@ # Monkeypatching ruamel.yaml ordreddict so we get to use pseudo dot notations # eg data.g('my.array.keys') == data['my']['array']['keys'] # and data.s('my.array.keys', 'new_value') -def g(self, path, sep='.', default=None, list_ok=False): +def g(self, path, sep=".", default=None, list_ok=False): """ Getter for dot notation in an a dict/OrderedDict print(d.g('my.array.keys')) """ return self.mlget(path.split(sep), default=default, list_ok=list_ok) -def s(self, path, value, sep='.'): + +def s(self, path, value, sep="."): """ Setter for dot notation in a dict/OrderedDict d.s('my.array.keys', 'new_value') @@ -80,10 +81,11 @@ def s(self, path, value, sep='.'): keys = path.split(sep) lastkey = keys[-1] for key in keys[:-1]: - data = data[key] + data = data[key] data[lastkey] = value -def d(self, path, sep='.'): + +def d(self, path, sep="."): """ Deletion for dot notation in a dict/OrderedDict d.d('my.array.keys') @@ -92,9 +94,9 @@ def d(self, path, sep='.'): keys = path.split(sep) lastkey = keys[-1] for key in keys[:-1]: - data = data[key] + data = data[key] data.pop(lastkey) - + ordereddict.g = g ordereddict.s = s @@ -102,25 +104,28 @@ def d(self, path, sep='.'): # NPF-SEC-00003: Avoid password command divulgation ENCRYPTED_OPTIONS = [ - "repo_uri", "repo_password", "repo_password_command", "http_username", "http_password", "encrypted_variables", - "auto_upgrade_server_username", "auto_upgrade_server_password" + "repo_uri", + "repo_password", + "repo_password_command", + "http_username", + "http_password", + "encrypted_variables", + "auto_upgrade_server_username", + "auto_upgrade_server_password", ] # This is what a config file looks like empty_config_dict = { "conf_version": CONF_VERSION, "repos": { - "default": { - "repo_uri": "", - "repo_group": "default_group", - "backup_opts": {}, - "repo_opts": {}, - "prometheus": {}, - "env": { - "env_variables": [], - "encrypted_env_variables": [] - }, - }, + "default": { + "repo_uri": "", + "repo_group": "default_group", + "backup_opts": {}, + "repo_opts": {}, + "prometheus": {}, + "env": {"env_variables": [], "encrypted_env_variables": []}, + }, }, "groups": { "default_group": { @@ -139,7 +144,7 @@ def d(self, path, sep='.'): "excludes/generic_excluded_extensions", "excludes/generic_excludes", "excludes/windows_excludes", - "excludes/linux_excludes" + "excludes/linux_excludes", ], "exclude_patterns": None, "exclude_patterns_source_type": "files_from_verbatim", @@ -153,39 +158,36 @@ def d(self, path, sep='.'): "post_exec_per_command_timeout": 3600, "post_exec_failure_is_fatal": False, "post_exec_execute_even_on_error": True, # TODO - } - }, - "repo_opts": { - "permissions": { - "restore": True, - "verify": True, - "delete": False, - }, - "repo_password": "", - "repo_password_command": "", - # Minimum time between two backups, in minutes - # Set to zero in order to disable time checks - "minimum_backup_age": 1440, - "upload_speed": 1000000, # in KiB, use 0 for unlimited upload speed - "download_speed": 0, # in KiB, use 0 for unlimited download speed - "backend_connections": 0, # Fine tune simultaneous connections to backend, use 0 for standard configuration - "retention": { - "hourly": 72, - "daily": 30, - "weekly": 4, - "monthly": 12, - "yearly": 3, - "custom_time_server": None, - } + } + }, + "repo_opts": { + "permissions": { + "restore": True, + "verify": True, + "delete": False, }, - "prometheus": { - "backup_job": "${MACHINE_ID}", - "group": "${MACHINE_GROUP}", + "repo_password": "", + "repo_password_command": "", + # Minimum time between two backups, in minutes + # Set to zero in order to disable time checks + "minimum_backup_age": 1440, + "upload_speed": 1000000, # in KiB, use 0 for unlimited upload speed + "download_speed": 0, # in KiB, use 0 for unlimited download speed + "backend_connections": 0, # Fine tune simultaneous connections to backend, use 0 for standard configuration + "retention": { + "hourly": 72, + "daily": 30, + "weekly": 4, + "monthly": 12, + "yearly": 3, + "custom_time_server": None, }, - "env": { - "env_variables": [], - "encrypted_env_variables": [] }, + "prometheus": { + "backup_job": "${MACHINE_ID}", + "group": "${MACHINE_GROUP}", + }, + "env": {"env_variables": [], "encrypted_env_variables": []}, }, "identity": { "machine_id": "${HOSTNAME}__${RANDOM}[4]", @@ -211,23 +213,30 @@ def d(self, path, sep='.'): } -def crypt_config(full_config: dict, aes_key: str, encrypted_options: List[str], operation: str): +def crypt_config( + full_config: dict, aes_key: str, encrypted_options: List[str], operation: str +): try: + def _crypt_config(key: str, value: Any) -> Any: if key in encrypted_options: - if operation == 'encrypt': - if isinstance(value, str) and not value.startswith("__NPBACKUP__") or not isinstance(value, str): + if operation == "encrypt": + if ( + isinstance(value, str) + and not value.startswith("__NPBACKUP__") + or not isinstance(value, str) + ): value = enc.encrypt_message_hf( value, aes_key, ID_STRING, ID_STRING ) - elif operation == 'decrypt': + elif operation == "decrypt": if isinstance(value, str) and value.startswith("__NPBACKUP__"): value = enc.decrypt_message_hf( - value, - aes_key, - ID_STRING, - ID_STRING, - ) + value, + aes_key, + ID_STRING, + ID_STRING, + ) else: raise ValueError(f"Bogus operation {operation} given") return value @@ -242,7 +251,7 @@ def is_encrypted(full_config: dict) -> bool: is_encrypted = True def _is_encrypted(key, value) -> Any: - nonlocal is_encrypted + nonlocal is_encrypted if key in ENCRYPTED_OPTIONS: if isinstance(value, str) and not value.startswith("__NPBACKUP__"): @@ -258,7 +267,7 @@ def has_random_variables(full_config: dict) -> Tuple[bool, dict]: Replaces ${RANDOM}[n] with n random alphanumeric chars, directly in config dict """ is_modified = False - + def _has_random_variables(value) -> Any: nonlocal is_modified @@ -272,7 +281,7 @@ def _has_random_variables(value) -> Any: value = re.sub(r"\${RANDOM}\[.*\]", random_string(char_quantity), value) is_modified = True return value - + full_config = replace_in_iterable(full_config, _has_random_variables) return is_modified, full_config @@ -281,15 +290,18 @@ def evaluate_variables(repo_config: dict, full_config: dict) -> dict: """ Replace runtime variables with their corresponding value """ + def _evaluate_variables(value): if isinstance(value, str): if "${MACHINE_ID}" in value: machine_id = full_config.g("identity.machine_id") value = value.replace("${MACHINE_ID}", machine_id if machine_id else "") - + if "${MACHINE_GROUP}" in value: machine_group = full_config.g("identity.machine_group") - value = value.replace("${MACHINE_GROUP}", machine_group if machine_group else "") + value = value.replace( + "${MACHINE_GROUP}", machine_group if machine_group else "" + ) if "${BACKUP_JOB}" in value: backup_job = repo_config.g("backup_opts.backup_job") @@ -298,7 +310,7 @@ def _evaluate_variables(value): if "${HOSTNAME}" in value: value = value.replace("${HOSTNAME}", platform.node()) return value - + # We need to make a loop to catch all nested variables (ie variable in a variable) # but we also need a max recursion limit # If each variable has two sub variables, we'd have max 4x2x2 loops @@ -312,13 +324,18 @@ def _evaluate_variables(value): return repo_config -def get_repo_config(full_config: dict, repo_name: str = 'default', eval_variables: bool = True) -> Tuple[dict, dict]: +def get_repo_config( + full_config: dict, repo_name: str = "default", eval_variables: bool = True +) -> Tuple[dict, dict]: """ Create inherited repo config Returns a dict containing the repo config, with expanded variables and a dict containing the repo interitance status """ - def inherit_group_settings(repo_config: dict, group_config: dict) -> Tuple[dict, dict]: + + def inherit_group_settings( + repo_config: dict, group_config: dict + ) -> Tuple[dict, dict]: """ iter over group settings, update repo_config, and produce an identical version of repo_config called config_inheritance, where every value is replaced with a boolean which states inheritance status @@ -326,8 +343,10 @@ def inherit_group_settings(repo_config: dict, group_config: dict) -> Tuple[dict, _repo_config = deepcopy(repo_config) _group_config = deepcopy(group_config) _config_inheritance = deepcopy(repo_config) - - def _inherit_group_settings(_repo_config: dict, _group_config: dict, _config_inheritance: dict) -> Tuple[dict, dict]: + + def _inherit_group_settings( + _repo_config: dict, _group_config: dict, _config_inheritance: dict + ) -> Tuple[dict, dict]: if isinstance(_group_config, dict): if not _repo_config: # Initialize blank if not set @@ -335,7 +354,11 @@ def _inherit_group_settings(_repo_config: dict, _group_config: dict, _config_inh _config_inheritance = CommentedMap() for key, value in _group_config.items(): if isinstance(value, dict): - __repo_config, __config_inheritance = _inherit_group_settings(_repo_config.g(key), _group_config.g(key), _config_inheritance.g(key)) + __repo_config, __config_inheritance = _inherit_group_settings( + _repo_config.g(key), + _group_config.g(key), + _config_inheritance.g(key), + ) _repo_config.s(key, __repo_config) _config_inheritance.s(key, __config_inheritance) elif isinstance(value, list): @@ -369,34 +392,39 @@ def _inherit_group_settings(_repo_config: dict, _group_config: dict, _config_inh _config_inheritance.s(key, False) return _repo_config, _config_inheritance + return _inherit_group_settings(_repo_config, _group_config, _config_inheritance) try: # Let's make a copy of config since it's a "pointer object" - repo_config = deepcopy(full_config.g(f'repos.{repo_name}')) + repo_config = deepcopy(full_config.g(f"repos.{repo_name}")) except KeyError: logger.error(f"No repo with name {repo_name} found in config") return None try: - repo_group = full_config.g(f'repos.{repo_name}.repo_group') - group_config = full_config.g(f'groups.{repo_group}') + repo_group = full_config.g(f"repos.{repo_name}.repo_group") + group_config = full_config.g(f"groups.{repo_group}") except KeyError: logger.warning(f"Repo {repo_name} has no group") else: - repo_config, config_inheritance = inherit_group_settings(repo_config, group_config) + repo_config, config_inheritance = inherit_group_settings( + repo_config, group_config + ) if eval_variables: repo_config = evaluate_variables(repo_config, full_config) return repo_config, config_inheritance -def get_group_config(full_config: dict, group_name: str, eval_variables: bool = True) -> dict: +def get_group_config( + full_config: dict, group_name: str, eval_variables: bool = True +) -> dict: try: group_config = deepcopy(full_config.g(f"groups.{group_name}")) except KeyError: logger.error(f"No group with name {group_name} found in config") return None - + if eval_variables: group_config = evaluate_variables(group_config, full_config) return group_config @@ -413,13 +441,16 @@ def _load_config_file(config_file: Path) -> Union[bool, dict]: conf_version = full_config.g("conf_version") if conf_version != CONF_VERSION: - logger.critical(f"Config file version {conf_version} is not required version {CONF_VERSION}") + logger.critical( + f"Config file version {conf_version} is not required version {CONF_VERSION}" + ) return False return full_config except OSError: logger.critical(f"Cannot load configuration file from {config_file}") return False + def load_config(config_file: Path) -> Optional[dict]: logger.info(f"Loading configuration file {config_file}") @@ -433,11 +464,15 @@ def load_config(config_file: Path) -> Optional[dict]: logger.info("Encrypting non encrypted data in configuration file") config_file_is_updated = True # Decrypt variables - full_config = crypt_config(full_config, AES_KEY, ENCRYPTED_OPTIONS, operation='decrypt') + full_config = crypt_config( + full_config, AES_KEY, ENCRYPTED_OPTIONS, operation="decrypt" + ) if full_config == False: if EARLIER_AES_KEY: logger.warning("Trying to migrate encryption key") - full_config = crypt_config(full_config, EARLIER_AES_KEY, ENCRYPTED_OPTIONS, operation='decrypt') + full_config = crypt_config( + full_config, EARLIER_AES_KEY, ENCRYPTED_OPTIONS, operation="decrypt" + ) if full_config == False: logger.critical("Cannot decrypt config file with earlier key") sys.exit(12) @@ -448,7 +483,6 @@ def load_config(config_file: Path) -> Optional[dict]: logger.critical("Cannot decrypt config file") sys.exit(11) - # Check if we need to expand random vars is_modified, full_config = has_random_variables(full_config) if is_modified: @@ -466,11 +500,15 @@ def save_config(config_file: Path, full_config: dict) -> bool: try: with open(config_file, "w", encoding="utf-8") as file_handle: if not is_encrypted(full_config): - full_config = crypt_config(full_config, AES_KEY, ENCRYPTED_OPTIONS, operation='encrypt') + full_config = crypt_config( + full_config, AES_KEY, ENCRYPTED_OPTIONS, operation="encrypt" + ) yaml = YAML(typ="rt") yaml.dump(full_config, file_handle) # Since yaml is a "pointer object", we need to decrypt after saving - full_config = crypt_config(full_config, AES_KEY, ENCRYPTED_OPTIONS, operation='decrypt') + full_config = crypt_config( + full_config, AES_KEY, ENCRYPTED_OPTIONS, operation="decrypt" + ) return True except OSError: logger.critical(f"Cannot save configuration file to {config_file}") diff --git a/npbackup/core/runner.py b/npbackup/core/runner.py index fc67a67..1bd1049 100644 --- a/npbackup/core/runner.py +++ b/npbackup/core/runner.py @@ -280,21 +280,27 @@ def apply_config_to_restic_runner(self) -> None: return None try: if self.repo_config.g("repo_opts.upload_speed"): - self.restic_runner.limit_upload = self.repo_config.g("repo_opts.upload_speed") + self.restic_runner.limit_upload = self.repo_config.g( + "repo_opts.upload_speed" + ) except KeyError: pass except ValueError: logger.error("Bogus upload limit given.") try: if self.repo_config.g("repo_opts.download_speed"): - self.restic_runner.limit_download = self.repo_config.g("repo_opts.download_speed") + self.restic_runner.limit_download = self.repo_config.g( + "repo_opts.download_speed" + ) except KeyError: pass except ValueError: logger.error("Bogus download limit given.") try: if self.repo_config.g("repo_opts.backend_connections"): - self.restic_runner.backend_connections = self.repo_config.g("repo_opts.backend_connections") + self.restic_runner.backend_connections = self.repo_config.g( + "repo_opts.backend_connections" + ) except KeyError: pass except ValueError: @@ -308,7 +314,9 @@ def apply_config_to_restic_runner(self) -> None: logger.warning("Bogus backup priority in config file.") try: if self.repo_config.g("backup_opts.ignore_cloud_files"): - self.restic_runner.ignore_cloud_files = self.repo_config.g("backup_opts.ignore_cloud_files") + self.restic_runner.ignore_cloud_files = self.repo_config.g( + "backup_opts.ignore_cloud_files" + ) except KeyError: pass except ValueError: @@ -316,7 +324,9 @@ def apply_config_to_restic_runner(self) -> None: try: if self.repo_config.g("backup_opts.additional_parameters"): - self.restic_runner.additional_parameters = self.repo_config.g("backup_opts.additional_parameters") + self.restic_runner.additional_parameters = self.repo_config.g( + "backup_opts.additional_parameters" + ) except KeyError: pass except ValueError: @@ -463,7 +473,9 @@ def backup(self, force: bool = False) -> bool: logger.error("No backup source given.") return False - exclude_patterns_source_type = self.repo_config.g("backup_opts.exclude_patterns_source_type") + exclude_patterns_source_type = self.repo_config.g( + "backup_opts.exclude_patterns_source_type" + ) # MSWindows does not support one-file-system option exclude_patterns = self.repo_config.g("backup_opts.exclude_patterns") @@ -474,18 +486,32 @@ def backup(self, force: bool = False) -> bool: if not isinstance(exclude_files, list): exclude_files = [exclude_files] - exclude_patterns_case_ignore = self.repo_config.g("backup_opts.exclude_patterns_case_ignore") + exclude_patterns_case_ignore = self.repo_config.g( + "backup_opts.exclude_patterns_case_ignore" + ) exclude_caches = self.repo_config.g("backup_opts.exclude_caches") - one_file_system = self.repo_config.g("backup_opts.one_file_system") if os.name != 'nt' else False + one_file_system = ( + self.repo_config.g("backup_opts.one_file_system") + if os.name != "nt" + else False + ) use_fs_snapshot = self.repo_config.g("backup_opts.use_fs_snapshot") pre_exec_commands = self.repo_config.g("backup_opts.pre_exec_commands") - pre_exec_per_command_timeout = self.repo_config.g("backup_opts.pre_exec_per_command_timeout") - pre_exec_failure_is_fatal = self.repo_config.g("backup_opts.pre_exec_failure_is_fatal") + pre_exec_per_command_timeout = self.repo_config.g( + "backup_opts.pre_exec_per_command_timeout" + ) + pre_exec_failure_is_fatal = self.repo_config.g( + "backup_opts.pre_exec_failure_is_fatal" + ) post_exec_commands = self.repo_config.g("backup_opts.post_exec_commands") - post_exec_per_command_timeout = self.repo_config.g("backup_opts.post_exec_per_command_timeout") - post_exec_failure_is_fatal = self.repo_config.g("backup_opts.post_exec_failure_is_fatal") + post_exec_per_command_timeout = self.repo_config.g( + "backup_opts.post_exec_per_command_timeout" + ) + post_exec_failure_is_fatal = self.repo_config.g( + "backup_opts.post_exec_failure_is_fatal" + ) # Make sure we convert tag to list if only one tag is given try: @@ -495,8 +521,9 @@ def backup(self, force: bool = False) -> bool: except KeyError: tags = None - additional_backup_only_parameters = self.repo_config.g("backup_opts.additional_backup_only_parameters") - + additional_backup_only_parameters = self.repo_config.g( + "backup_opts.additional_backup_only_parameters" + ) # Check if backup is required self.restic_runner.verbose = False diff --git a/npbackup/core/upgrade_runner.py b/npbackup/core/upgrade_runner.py index 84ebc47..c85db7c 100644 --- a/npbackup/core/upgrade_runner.py +++ b/npbackup/core/upgrade_runner.py @@ -38,7 +38,9 @@ def run_upgrade(full_config: dict) -> bool: logger.error(f"Missing auto upgrade info, cannot launch auto upgrade") return False - auto_upgrade_host_identity = full_config.g("global_options.auto_upgrade_host_identity") + auto_upgrade_host_identity = full_config.g( + "global_options.auto_upgrade_host_identity" + ) group = full_config.g("global_options.auto_upgrade_group") result = auto_upgrader( diff --git a/npbackup/customization.py b/npbackup/customization.py index e1199af..a44ec42 100644 --- a/npbackup/customization.py +++ b/npbackup/customization.py @@ -50,4 +50,4 @@ FOLDER_ICON = b"iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAAsSAAALEgHS3X78AAABnUlEQVQ4y8WSv2rUQRSFv7vZgJFFsQg2EkWb4AvEJ8hqKVilSmFn3iNvIAp21oIW9haihBRKiqwElMVsIJjNrprsOr/5dyzml3UhEQIWHhjmcpn7zblw4B9lJ8Xag9mlmQb3AJzX3tOX8Tngzg349q7t5xcfzpKGhOFHnjx+9qLTzW8wsmFTL2Gzk7Y2O/k9kCbtwUZbV+Zvo8Md3PALrjoiqsKSR9ljpAJpwOsNtlfXfRvoNU8Arr/NsVo0ry5z4dZN5hoGqEzYDChBOoKwS/vSq0XW3y5NAI/uN1cvLqzQur4MCpBGEEd1PQDfQ74HYR+LfeQOAOYAmgAmbly+dgfid5CHPIKqC74L8RDyGPIYy7+QQjFWa7ICsQ8SpB/IfcJSDVMAJUwJkYDMNOEPIBxA/gnuMyYPijXAI3lMse7FGnIKsIuqrxgRSeXOoYZUCI8pIKW/OHA7kD2YYcpAKgM5ABXk4qSsdJaDOMCsgTIYAlL5TQFTyUIZDmev0N/bnwqnylEBQS45UKnHx/lUlFvA3fo+jwR8ALb47/oNma38cuqiJ9AAAAAASUVORK5CYII=" FILE_ICON = b"iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAAsSAAALEgHS3X78AAABU0lEQVQ4y52TzStEURiHn/ecc6XG54JSdlMkNhYWsiILS0lsJaUsLW2Mv8CfIDtr2VtbY4GUEvmIZnKbZsY977Uwt2HcyW1+dTZvt6fn9557BGB+aaNQKBR2ifkbgWR+cX13ubO1svz++niVTA1ArDHDg91UahHFsMxbKWycYsjze4muTsP64vT43v7hSf/A0FgdjQPQWAmco68nB+T+SFSqNUQgcIbN1bn8Z3RwvL22MAvcu8TACFgrpMVZ4aUYcn77BMDkxGgemAGOHIBXxRjBWZMKoCPA2h6qEUSRR2MF6GxUUMUaIUgBCNTnAcm3H2G5YQfgvccYIXAtDH7FoKq/AaqKlbrBj2trFVXfBPAea4SOIIsBeN9kkCwxsNkAqRWy7+B7Z00G3xVc2wZeMSI4S7sVYkSk5Z/4PyBWROqvox3A28PN2cjUwinQC9QyckKALxj4kv2auK0xAAAAAElFTkSuQmCC" LOADER_ANIMATION = b"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" -INHERITANCE_ICON = b"iVBORw0KGgoAAAANSUhEUgAAABIAAAASCAYAAABWzo5XAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAACxMAAAsTAQCanBgAAAFPSURBVDhPrZS9LkRBFMevjxWVDa0obOGrl2g8gkdQoPIC3kKpofIEhFoUGq2CRMIWFDqyBfGR4Pe7e+/m7twZFP7Jb3fmnDPnzp055w5kaTWgBc18lmUdaMNHPvuDRmADjuEWngoca9NnzI+ahVP4+gVjjI1KxxXEFj7Ce2Azdgb65FZTOzmCSViF18JWcgKeZU++dzWgyi6oRXiG0L8OuczoIYYBJS9wDt5YzO/axiA/XvEChLqHbdiCO5iGmOahZSLrZFxLIH0+cQ824QJimoCmwSl5wCtg4CgMQVImsmItuJjcxQN4zXMaIrI0OibyEK2JmM6K/2UY7g5rcm3bRPbOoZZAb3DdHWZj4NV/5rN+HUCv/1IFuQNTsAT7YKKqv1aQKtUinmGsEC+h1iKldPiUcFGIMckkpdyqZW/F3kD5GXFs361B7XX+6cOWZd8L0Yd3yKkunwAAAABJRU5ErkJggg==" \ No newline at end of file +INHERITANCE_ICON = b"iVBORw0KGgoAAAANSUhEUgAAABIAAAASCAYAAABWzo5XAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAACxMAAAsTAQCanBgAAAFPSURBVDhPrZS9LkRBFMevjxWVDa0obOGrl2g8gkdQoPIC3kKpofIEhFoUGq2CRMIWFDqyBfGR4Pe7e+/m7twZFP7Jb3fmnDPnzp055w5kaTWgBc18lmUdaMNHPvuDRmADjuEWngoca9NnzI+ahVP4+gVjjI1KxxXEFj7Ce2Azdgb65FZTOzmCSViF18JWcgKeZU++dzWgyi6oRXiG0L8OuczoIYYBJS9wDt5YzO/axiA/XvEChLqHbdiCO5iGmOahZSLrZFxLIH0+cQ824QJimoCmwSl5wCtg4CgMQVImsmItuJjcxQN4zXMaIrI0OibyEK2JmM6K/2UY7g5rcm3bRPbOoZZAb3DdHWZj4NV/5rN+HUCv/1IFuQNTsAT7YKKqv1aQKtUinmGsEC+h1iKldPiUcFGIMckkpdyqZW/F3kD5GXFs361B7XX+6cOWZd8L0Yd3yKkunwAAAABJRU5ErkJggg==" diff --git a/npbackup/gui/config.py b/npbackup/gui/config.py index cfbd8a9..4f66320 100644 --- a/npbackup/gui/config.py +++ b/npbackup/gui/config.py @@ -86,30 +86,44 @@ def get_objects() -> List[str]: for group in configuration.get_group_list(full_config): object_list.append(f"Group: {group}") return object_list - + def create_object(full_config: dict) -> dict: - layout = [ - [sg.Text(_t("generic.type")), sg.Combo(["repo", "group"], default_value="repo", key="-OBJECT-TYPE-"), sg.Text(_t("generic.name")), sg.Input(key="-OBJECT-NAME-")], - [sg.Push(), sg.Button(_t("generic.cancel"), key="--CANCEL--"), sg.Button(_t("generic.accept"), key="--ACCEPT--")] + [ + sg.Text(_t("generic.type")), + sg.Combo(["repo", "group"], default_value="repo", key="-OBJECT-TYPE-"), + sg.Text(_t("generic.name")), + sg.Input(key="-OBJECT-NAME-"), + ], + [ + sg.Push(), + sg.Button(_t("generic.cancel"), key="--CANCEL--"), + sg.Button(_t("generic.accept"), key="--ACCEPT--"), + ], ] - window = sg.Window(_t("config_gui.create_object"), layout=layout, keep_on_top=True) + window = sg.Window( + _t("config_gui.create_object"), layout=layout, keep_on_top=True + ) while True: event, values = window.read() - if event in (sg.WIN_CLOSED, sg.WIN_X_EVENT, '--CANCEL--'): + if event in (sg.WIN_CLOSED, sg.WIN_X_EVENT, "--CANCEL--"): break - if event == '--ACCEPT--': - object_type = values['-OBJECT-TYPE-'] - object_name = values['-OBJECT-NAME-'] + if event == "--ACCEPT--": + object_type = values["-OBJECT-TYPE-"] + object_name = values["-OBJECT-NAME-"] if object_type == "repo": if full_config.g(f"repos.{object_name}"): - sg.PopupError(_t("config_gui.repo_already_exists"), keep_on_top=True) + sg.PopupError( + _t("config_gui.repo_already_exists"), keep_on_top=True + ) continue full_config.s(f"repos.{object_name}", CommentedMap()) elif object_type == "group": if full_config.g(f"groups.{object_name}"): - sg.PopupError(_t("config_gui.group_already_exists"), keep_on_top=True) + sg.PopupError( + _t("config_gui.group_already_exists"), keep_on_top=True + ) continue full_config.s(f"groups.{object_name}", CommentedMap()) else: @@ -117,22 +131,22 @@ def create_object(full_config: dict) -> dict: window.close() update_object_gui(None, unencrypted=False) return full_config - + def delete_object(full_config: dict, object_name: str) -> dict: object_type, object_name = get_object_from_combo(object_name) - result = sg.PopupYesNo(_t("config_gui.are_you_sure_to_delete") + f" {object_type} {object_name} ?") + result = sg.PopupYesNo( + _t("config_gui.are_you_sure_to_delete") + f" {object_type} {object_name} ?" + ) if result: full_config.d(f"{object_type}s.{object_name}") update_object_gui(None, unencrypted=False) return full_config - def update_object_selector() -> None: objects = get_objects() window["-OBJECT-SELECT-"].Update(objects) window["-OBJECT-SELECT-"].Update(value=objects[0]) - def get_object_from_combo(combo_value: str) -> (str, str): """ Extracts selected object from combobox @@ -141,13 +155,12 @@ def get_object_from_combo(combo_value: str) -> (str, str): if combo_value.startswith("Repo: "): object_type = "repo" - object_name = combo_value[len("Repo: "):] + object_name = combo_value[len("Repo: ") :] elif combo_value.startswith("Group: "): object_type = "group" - object_name = combo_value[len("Group: "):] + object_name = combo_value[len("Group: ") :] return object_type, object_name - def update_gui_values(key, value, inherited, object_type, unencrypted): """ Update gui values depending on their type @@ -164,14 +177,9 @@ def update_gui_values(key, value, inherited, object_type, unencrypted): if not unencrypted: if key in configuration.ENCRYPTED_OPTIONS: try: - if ( - value is None - or value == "" - ): + if value is None or value == "": return - if not str(value).startswith( - configuration.ID_STRING - ): + if not str(value).startswith(configuration.ID_STRING): value = ENCRYPTED_DATA_PLACEHOLDER except (KeyError, TypeError): pass @@ -184,7 +192,7 @@ def update_gui_values(key, value, inherited, object_type, unencrypted): window[key].Update(value) # Enable inheritance icon when needed - inheritance_key = f'inherited.{key}' + inheritance_key = f"inherited.{key}" if inheritance_key in window.AllKeysDict: window[inheritance_key].update(visible=True if inherited else False) @@ -193,29 +201,44 @@ def update_gui_values(key, value, inherited, object_type, unencrypted): except TypeError as exc: logger.error(f"Error: {exc} for key {key}.") - def iter_over_config(object_config: dict, config_inheritance: dict = None, object_type: str = None, unencrypted: bool = False, root_key: str =''): + def iter_over_config( + object_config: dict, + config_inheritance: dict = None, + object_type: str = None, + unencrypted: bool = False, + root_key: str = "", + ): """ Iter over a dict while retaining the full key path to current object """ base_object = object_config - def _iter_over_config(object_config: dict, root_key=''): + def _iter_over_config(object_config: dict, root_key=""): # Special case where env is a dict but we should pass it directly as it to update_gui_values if isinstance(object_config, dict): for key in object_config.keys(): if root_key: - _iter_over_config(object_config[key], root_key=f'{root_key}.{key}') + _iter_over_config( + object_config[key], root_key=f"{root_key}.{key}" + ) else: - _iter_over_config(object_config[key], root_key=f'{key}') + _iter_over_config(object_config[key], root_key=f"{key}") else: if config_inheritance: inherited = config_inheritance.g(root_key) else: inherited = False - update_gui_values(root_key, base_object.g(root_key), inherited, object_type, unencrypted) + update_gui_values( + root_key, + base_object.g(root_key), + inherited, + object_type, + unencrypted, + ) + _iter_over_config(object_config, root_key) - def update_object_gui(object_name = None, unencrypted=False): + def update_object_gui(object_name=None, unencrypted=False): # Load fist available repo or group if none given if not object_name: object_name = get_objects()[0] @@ -223,35 +246,39 @@ def update_object_gui(object_name = None, unencrypted=False): # First we need to clear the whole GUI to reload new values for key in window.AllKeysDict: # We only clear config keys, wihch have '.' separator - if "." in str(key) and not "inherited" in str(key): - window[key]('') - + if "." in str(key) and not "inherited" in str(key): + window[key]("") + object_type, object_name = get_object_from_combo(object_name) - if object_type == 'repo': - object_config, config_inheritance = configuration.get_repo_config(full_config, object_name, eval_variables=False) - if object_type == 'group': - object_config = configuration.get_group_config(full_config, object_name, eval_variables=False) + if object_type == "repo": + object_config, config_inheritance = configuration.get_repo_config( + full_config, object_name, eval_variables=False + ) + if object_type == "group": + object_config = configuration.get_group_config( + full_config, object_name, eval_variables=False + ) config_inheritance = None # Now let's iter over the whole config object and update keys accordingly - iter_over_config(object_config, config_inheritance, object_type, unencrypted, None) - + iter_over_config( + object_config, config_inheritance, object_type, unencrypted, None + ) def update_global_gui(full_config, unencrypted=False): global_config = CommentedMap() # Only update global options gui with identified global keys for key in full_config.keys(): - if key in ('identity', 'global_options'): - global_config.s(key, full_config.g(key)) - iter_over_config(global_config, None, 'group', unencrypted, None) - + if key in ("identity", "global_options"): + global_config.s(key, full_config.g(key)) + iter_over_config(global_config, None, "group", unencrypted, None) def update_config_dict(values, full_config): for key, value in values.items(): if value == ENCRYPTED_DATA_PLACEHOLDER: continue - if not isinstance(key, str) or (isinstance(key, str) and not '.' in key): + if not isinstance(key, str) or (isinstance(key, str) and not "." in key): # Don't bother with keys that don't contain with "." continue # Handle combo boxes first to transform translation into key @@ -281,7 +308,6 @@ def update_config_dict(values, full_config): full_config.s(key, value) return full_config - def object_layout() -> List[list]: """ Returns the GUI layout depending on the object type @@ -289,7 +315,13 @@ def object_layout() -> List[list]: backup_col = [ [ sg.Text(_t("config_gui.compression"), size=(40, 1)), - sg.pin(sg.Image(INHERITANCE_ICON, key="inherited.backup_opts.compression", tooltip=_t("config_gui.group_inherited"))), + sg.pin( + sg.Image( + INHERITANCE_ICON, + key="inherited.backup_opts.compression", + tooltip=_t("config_gui.group_inherited"), + ) + ), sg.Combo( list(combo_boxes["compression"].values()), key="backup_opts.compression", @@ -297,13 +329,32 @@ def object_layout() -> List[list]: ), ], [ - sg.Text(f"{_t('config_gui.backup_paths')}\n({_t('config_gui.one_per_line')})", size=(40, 2)), - sg.pin(sg.Image(INHERITANCE_ICON, expand_x=True, expand_y=True, key="inherited.backup_opts.paths", tooltip=_t("config_gui.group_inherited"))), + sg.Text( + f"{_t('config_gui.backup_paths')}\n({_t('config_gui.one_per_line')})", + size=(40, 2), + ), + sg.pin( + sg.Image( + INHERITANCE_ICON, + expand_x=True, + expand_y=True, + key="inherited.backup_opts.paths", + tooltip=_t("config_gui.group_inherited"), + ) + ), sg.Multiline(key="backup_opts.paths", size=(48, 4)), ], [ sg.Text(_t("config_gui.source_type"), size=(40, 1)), - sg.pin(sg.Image(INHERITANCE_ICON, expand_x=True, expand_y=True, key="inherited.backup_opts.source_type", tooltip=_t("config_gui.group_inherited"))), + sg.pin( + sg.Image( + INHERITANCE_ICON, + expand_x=True, + expand_y=True, + key="inherited.backup_opts.source_type", + tooltip=_t("config_gui.group_inherited"), + ) + ), sg.Combo( list(combo_boxes["source_type"].values()), key="backup_opts.source_type", @@ -317,24 +368,39 @@ def object_layout() -> List[list]: ), size=(40, 2), ), - sg.pin(sg.Image(INHERITANCE_ICON, expand_x=True, expand_y=True, key="inherited.backup_opts.use_fs_snapshot", tooltip=_t("config_gui.group_inherited"))), + sg.pin( + sg.Image( + INHERITANCE_ICON, + expand_x=True, + expand_y=True, + key="inherited.backup_opts.use_fs_snapshot", + tooltip=_t("config_gui.group_inherited"), + ) + ), sg.Checkbox("", key="backup_opts.use_fs_snapshot", size=(41, 1)), ], [ sg.Text( "{}\n({})".format( - _t("config_gui.ignore_cloud_files"), _t("config_gui.windows_only") + _t("config_gui.ignore_cloud_files"), + _t("config_gui.windows_only"), ), size=(40, 2), ), sg.Checkbox("", key="backup_opts.ignore_cloud_files", size=(41, 1)), ], [ - sg.Text(f"{_t('config_gui.exclude_patterns')}\n({_t('config_gui.one_per_line')})", size=(40, 2)), + sg.Text( + f"{_t('config_gui.exclude_patterns')}\n({_t('config_gui.one_per_line')})", + size=(40, 2), + ), sg.Multiline(key="backup_opts.exclude_patterns", size=(48, 4)), ], [ - sg.Text(f"{_t('config_gui.exclude_files')}\n({_t('config_gui.one_per_line')})", size=(40, 2)), + sg.Text( + f"{_t('config_gui.exclude_files')}\n({_t('config_gui.one_per_line')})", + size=(40, 2), + ), sg.Multiline(key="backup_opts.exclude_files", size=(48, 4)), ], [ @@ -356,7 +422,10 @@ def object_layout() -> List[list]: sg.Checkbox("", key="backup_opts.one_file_system", size=(41, 1)), ], [ - sg.Text(f"{_t('config_gui.pre_exec_commands')}\n({_t('config_gui.one_per_line')})", size=(40, 2)), + sg.Text( + f"{_t('config_gui.pre_exec_commands')}\n({_t('config_gui.one_per_line')})", + size=(40, 2), + ), sg.Multiline(key="backup_opts.pre_exec_commands", size=(48, 4)), ], [ @@ -365,10 +434,15 @@ def object_layout() -> List[list]: ], [ sg.Text(_t("config_gui.exec_failure_is_fatal"), size=(40, 1)), - sg.Checkbox("", key="backup_opts.pre_exec_failure_is_fatal", size=(41, 1)), + sg.Checkbox( + "", key="backup_opts.pre_exec_failure_is_fatal", size=(41, 1) + ), ], [ - sg.Text(f"{_t('config_gui.post_exec_commands')}\n({_t('config_gui.one_per_line')})", size=(40, 2)), + sg.Text( + f"{_t('config_gui.post_exec_commands')}\n({_t('config_gui.one_per_line')})", + size=(40, 2), + ), sg.Multiline(key="backup_opts.post_exec_commands", size=(48, 4)), ], [ @@ -377,10 +451,15 @@ def object_layout() -> List[list]: ], [ sg.Text(_t("config_gui.exec_failure_is_fatal"), size=(40, 1)), - sg.Checkbox("", key="backup_opts.post_exec_failure_is_fatal", size=(41, 1)), + sg.Checkbox( + "", key="backup_opts.post_exec_failure_is_fatal", size=(41, 1) + ), ], [ - sg.Text(f"{_t('config_gui.tags')}\n({_t('config_gui.one_per_line')})", size=(40, 2)), + sg.Text( + f"{_t('config_gui.tags')}\n({_t('config_gui.one_per_line')})", + size=(40, 2), + ), sg.Multiline(key="backup_opts.tags", size=(48, 4)), ], [ @@ -396,8 +475,12 @@ def object_layout() -> List[list]: sg.Input(key="backup_opts.additional_parameters", size=(50, 1)), ], [ - sg.Text(_t("config_gui.additional_backup_only_parameters"), size=(40, 1)), - sg.Input(key="backup_opts.additional_backup_only_parameters", size=(50, 1)), + sg.Text( + _t("config_gui.additional_backup_only_parameters"), size=(40, 1) + ), + sg.Input( + key="backup_opts.additional_backup_only_parameters", size=(50, 1) + ), ], ] @@ -449,7 +532,9 @@ def object_layout() -> List[list]: [sg.Text(_t("config_gui.retention_policy"))], [ sg.Text(_t("config_gui.custom_time_server_url"), size=(40, 1)), - sg.Input(key="repo_opts.retention_strategy.custom_time_server", size=(50, 1)), + sg.Input( + key="repo_opts.retention_strategy.custom_time_server", size=(50, 1) + ), ], [ sg.Text(_t("config_gui.keep"), size=(30, 1)), @@ -513,18 +598,27 @@ def object_layout() -> List[list]: sg.Input(key="prometheus.group", size=(50, 1)), ], [ - sg.Text(f"{_t('config_gui.additional_labels')}\n({_t('config_gui.one_per_line')}\n{_t('config_gui.format_equals')})", size=(40, 3)), + sg.Text( + f"{_t('config_gui.additional_labels')}\n({_t('config_gui.one_per_line')}\n{_t('config_gui.format_equals')})", + size=(40, 3), + ), sg.Multiline(key="prometheus.additional_labels", size=(48, 3)), ], ] env_col = [ [ - sg.Text(f"{_t('config_gui.env_variables')}\n({_t('config_gui.one_per_line')}\n{_t('config_gui.format_equals')})", size=(40, 3)), + sg.Text( + f"{_t('config_gui.env_variables')}\n({_t('config_gui.one_per_line')}\n{_t('config_gui.format_equals')})", + size=(40, 3), + ), sg.Multiline(key="env.env_variables", size=(48, 5)), ], [ - sg.Text(f"{_t('config_gui.encrypted_env_variables')}\n({_t('config_gui.one_per_line')}\n{_t('config_gui.format_equals')})", size=(40, 3)), + sg.Text( + f"{_t('config_gui.encrypted_env_variables')}\n({_t('config_gui.one_per_line')}\n{_t('config_gui.format_equals')})", + size=(40, 3), + ), sg.Multiline(key="env.encrypted_env_variables", size=(48, 5)), ], ] @@ -532,7 +626,13 @@ def object_layout() -> List[list]: object_list = get_objects() object_selector = [ [ - sg.Text(_t("config_gui.select_object")), sg.Combo(object_list, default_value=object_list[0], key='-OBJECT-SELECT-', enable_events=True), + sg.Text(_t("config_gui.select_object")), + sg.Combo( + object_list, + default_value=object_list[0], + key="-OBJECT-SELECT-", + enable_events=True, + ), ] ] @@ -540,7 +640,16 @@ def object_layout() -> List[list]: [ sg.Tab( _t("config_gui.backup"), - [[sg.Column(backup_col, scrollable=True, vertical_scroll_only=True, size=(700, 450))]], + [ + [ + sg.Column( + backup_col, + scrollable=True, + vertical_scroll_only=True, + size=(700, 450), + ) + ] + ], font="helvetica 16", key="--tab-backup--", element_justification="L", @@ -549,7 +658,16 @@ def object_layout() -> List[list]: [ sg.Tab( _t("config_gui.backup_destination"), - [[sg.Column(repo_col, scrollable=True, vertical_scroll_only=True, size=(700, 450))]], + [ + [ + sg.Column( + repo_col, + scrollable=True, + vertical_scroll_only=True, + size=(700, 450), + ) + ] + ], font="helvetica 16", key="--tab-repo--", element_justification="L", @@ -576,14 +694,17 @@ def object_layout() -> List[list]: ] _layout = [ - [sg.Column(object_selector, element_justification='L')], - [sg.TabGroup(tab_group_layout, enable_events=True, key="--object-tabgroup--")], + [sg.Column(object_selector, element_justification="L")], + [ + sg.TabGroup( + tab_group_layout, enable_events=True, key="--object-tabgroup--" + ) + ], ] return _layout - def global_options_layout(): - """" + """ " Returns layout for global options that can't be overrided by group / repo settings """ identity_col = [ @@ -609,11 +730,15 @@ def global_options_layout(): ], [ sg.Text(_t("config_gui.auto_upgrade_server_username"), size=(40, 1)), - sg.Input(key="global_options.auto_upgrade_server_username", size=(50, 1)), + sg.Input( + key="global_options.auto_upgrade_server_username", size=(50, 1) + ), ], [ sg.Text(_t("config_gui.auto_upgrade_server_password"), size=(40, 1)), - sg.Input(key="global_options.auto_upgrade_server_password", size=(50, 1)), + sg.Input( + key="global_options.auto_upgrade_server_password", size=(50, 1) + ), ], [ sg.Text(_t("config_gui.auto_upgrade_interval"), size=(40, 1)), @@ -627,9 +752,9 @@ def global_options_layout(): sg.Text(_t("generic.group"), size=(40, 1)), sg.Input(key="global_options.auto_upgrade_group", size=(50, 1)), ], - [sg.HorizontalSeparator()] + [sg.HorizontalSeparator()], ] - + scheduled_task_col = [ [ sg.Text(_t("config_gui.create_scheduled_task_every")), @@ -669,37 +794,54 @@ def global_options_layout(): ) ], ] - + _layout = [ - [sg.TabGroup(tab_group_layout, enable_events=True, key="--global-tabgroup--")], + [ + sg.TabGroup( + tab_group_layout, enable_events=True, key="--global-tabgroup--" + ) + ], ] return _layout - def config_layout() -> List[list]: - buttons = [ [ sg.Push(), - sg.Button(_t("config_gui.create_object"), key='-OBJECT-CREATE-'), - sg.Button(_t("config_gui.delete_object"), key='-OBJECT-DELETE-'), + sg.Button(_t("config_gui.create_object"), key="-OBJECT-CREATE-"), + sg.Button(_t("config_gui.delete_object"), key="-OBJECT-DELETE-"), sg.Button(_t("generic.cancel"), key="--CANCEL--"), sg.Button(_t("generic.accept"), key="--ACCEPT--"), ] ] - + tab_group_layout = [ - [sg.Tab(_t("config_gui.repo_group_config"), object_layout(), key="--repo-group-config--")], - [sg.Tab(_t("config_gui.global_config"), global_options_layout(), key="--global-config--")] + [ + sg.Tab( + _t("config_gui.repo_group_config"), + object_layout(), + key="--repo-group-config--", + ) + ], + [ + sg.Tab( + _t("config_gui.global_config"), + global_options_layout(), + key="--global-config--", + ) + ], ] _global_layout = [ - [sg.TabGroup(tab_group_layout, enable_events=True, key="--configtabgroup--")], + [ + sg.TabGroup( + tab_group_layout, enable_events=True, key="--configtabgroup--" + ) + ], [sg.Push(), sg.Column(buttons, element_justification="L")], ] return _global_layout - right_click_menu = ["", [_t("config_gui.show_decrypted")]] window = sg.Window( "Configuration", @@ -740,7 +882,10 @@ def config_layout() -> List[list]: update_object_selector() continue if event == "--ACCEPT--": - if not values["repo_opts.password"] and not values["repo_opts.password_command"]: + if ( + not values["repo_opts.password"] + and not values["repo_opts.password_command"] + ): sg.PopupError(_t("config_gui.repo_password_cannot_be_empty")) continue full_config = update_config_dict(values, full_config) diff --git a/npbackup/restic_wrapper/__init__.py b/npbackup/restic_wrapper/__init__.py index 97569aa..eef4b44 100644 --- a/npbackup/restic_wrapper/__init__.py +++ b/npbackup/restic_wrapper/__init__.py @@ -556,7 +556,11 @@ def backup( return None, None # Handle various source types - if exclude_patterns_source_type in ["files_from", "files_from_verbatim", "files_from_raw"]: + if exclude_patterns_source_type in [ + "files_from", + "files_from_verbatim", + "files_from_raw", + ]: cmd = "backup" if exclude_patterns_source_type == "files_from": source_parameter = "--files-from" From eb1304a357d16d5ed94e1ac1225e478b2f18cc71 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Sat, 16 Dec 2023 19:59:53 +0100 Subject: [PATCH 043/328] Reformat file with black --- npbackup/gui/__main__.py | 56 +++++++++++++++++++++++++--------------- 1 file changed, 35 insertions(+), 21 deletions(-) diff --git a/npbackup/gui/__main__.py b/npbackup/gui/__main__.py index cd89d48..1dad4ba 100644 --- a/npbackup/gui/__main__.py +++ b/npbackup/gui/__main__.py @@ -158,10 +158,9 @@ def _get_gui_data(repo_config: dict) -> Future: def get_gui_data(repo_config: dict) -> Tuple[bool, List[str]]: try: - if ( - not repo_config.g("repo_uri") - and (not repo_config.g("repo_opts.repo_password") - and not repo_config.g("repo_opts.repo_password_command")) + if not repo_config.g("repo_uri") and ( + not repo_config.g("repo_opts.repo_password") + and not repo_config.g("repo_opts.repo_password_command") ): sg.Popup(_t("main_gui.repository_not_configured")) return None, None @@ -539,15 +538,22 @@ def select_config_file(): Option to select a configuration file """ layout = [ - [sg.Text(_t("main_gui.select_config_file")), sg.Input(key="-config_file-"), sg.FileBrowse(_t("generic.select_file"))], - [sg.Button(_t("generic.cancel"), key="-CANCEL-"), sg.Button(_t("generic.accept"), key="-ACCEPT-")] + [ + sg.Text(_t("main_gui.select_config_file")), + sg.Input(key="-config_file-"), + sg.FileBrowse(_t("generic.select_file")), + ], + [ + sg.Button(_t("generic.cancel"), key="-CANCEL-"), + sg.Button(_t("generic.accept"), key="-ACCEPT-"), + ], ] window = sg.Window("Configuration File", layout=layout) while True: event, values = window.read() - if event in [sg.WIN_X_EVENT, sg.WIN_CLOSED, '-CANCEL-']: + if event in [sg.WIN_X_EVENT, sg.WIN_CLOSED, "-CANCEL-"]: break - if event == '-ACCEPT-': + if event == "-ACCEPT-": config_file = Path(values["-config_file-"]) if not config_file.exists(): sg.PopupError(_t("generic.file_does_not_exist")) @@ -560,7 +566,7 @@ def select_config_file(): def _main_gui(): - config_file = Path(f'{CURRENT_DIR}/npbackup.conf') + config_file = Path(f"{CURRENT_DIR}/npbackup.conf") if not config_file.exists(): while True: config_file = select_config_file() @@ -571,11 +577,13 @@ def _main_gui(): logger.info(f"Using configuration file {config_file}") full_config = npbackup.configuration.load_config(config_file) - repo_config, config_inheritance = npbackup.configuration.get_repo_config(full_config) + repo_config, config_inheritance = npbackup.configuration.get_repo_config( + full_config + ) repo_list = npbackup.configuration.get_repo_list(full_config) backup_destination = _t("main_gui.local_folder") - backend_type, repo_uri = get_anon_repo_uri(repo_config.g('repo_uri')) + backend_type, repo_uri = get_anon_repo_uri(repo_config.g("repo_uri")) right_click_menu = ["", [_t("generic.destination")]] headings = [ @@ -613,8 +621,13 @@ def _main_gui(): ], [ sg.Text(_t("main_gui.backup_list_to")), - sg.Combo(repo_list, key="-active_repo-", default_value=repo_list[0], enable_events=True), - sg.Text(f"Type {backend_type}", key="-backend_type-") + sg.Combo( + repo_list, + key="-active_repo-", + default_value=repo_list[0], + enable_events=True, + ), + sg.Text(f"Type {backend_type}", key="-backend_type-"), ], [ sg.Table( @@ -671,12 +684,15 @@ def _main_gui(): while True: event, values = window.read(timeout=60000) - if event in [sg.WIN_X_EVENT, sg.WIN_CLOSED, '-EXIT-']: + if event in [sg.WIN_X_EVENT, sg.WIN_CLOSED, "-EXIT-"]: break if event == "-active_repo-": - active_repo = values['-active_repo-'] + active_repo = values["-active_repo-"] if full_config.g(f"repos.{active_repo}"): - repo_config, config_inheriteance = npbackup.configuration.get_repo_config(full_config, active_repo) + ( + repo_config, + config_inheriteance, + ) = npbackup.configuration.get_repo_config(full_config, active_repo) current_state, backup_tz, snapshot_list = get_gui_data(repo_config) else: sg.PopupError("Repo not existent in config") @@ -766,9 +782,7 @@ def _main_gui(): try: if backend_type: if backend_type in ["REST", "SFTP"]: - destination_string = repo_config.g("repo_uri").split( - "@" - )[-1] + destination_string = repo_config.g("repo_uri").split("@")[-1] else: destination_string = repo_config.g("repo_uri") sg.PopupNoFrame(destination_string) @@ -790,8 +804,8 @@ def main_gui(): logger.critical(f'Tkinter error: "{exc}". Is this a headless server ?') sys.exit(250) except Exception as exc: - sg.Popup(_t("config_gui.unknown_error_see_logs") + f': {exc}') + sg.Popup(_t("config_gui.unknown_error_see_logs") + f": {exc}") logger.critical("GUI Execution error", exc) if _DEBUG: logger.critical("Trace:", exc_info=True) - sys.exit(251) \ No newline at end of file + sys.exit(251) From 509405df3d7ccbe678e4fe82460230a3f0f91f02 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Sat, 16 Dec 2023 20:00:20 +0100 Subject: [PATCH 044/328] Update gui after changing repo --- npbackup/gui/__main__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/npbackup/gui/__main__.py b/npbackup/gui/__main__.py index 1dad4ba..5cc0c15 100644 --- a/npbackup/gui/__main__.py +++ b/npbackup/gui/__main__.py @@ -684,7 +684,7 @@ def _main_gui(): while True: event, values = window.read(timeout=60000) - if event in [sg.WIN_X_EVENT, sg.WIN_CLOSED, "-EXIT-"]: + if event in (sg.WIN_X_EVENT, sg.WIN_CLOSED, "-EXIT-"): break if event == "-active_repo-": active_repo = values["-active_repo-"] @@ -694,6 +694,7 @@ def _main_gui(): config_inheriteance, ) = npbackup.configuration.get_repo_config(full_config, active_repo) current_state, backup_tz, snapshot_list = get_gui_data(repo_config) + _gui_update_state(window, current_state, backup_tz, snapshot_list) else: sg.PopupError("Repo not existent in config") continue From b8ed1c168eeab77a8fba3f9d553e11406b45f722 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Sun, 17 Dec 2023 21:38:27 +0100 Subject: [PATCH 045/328] WIP Refactor config gui --- npbackup/configuration.py | 53 +++++++-- npbackup/gui/config.py | 142 ++++++++++++++++++------ npbackup/translations/config_gui.en.yml | 23 +++- npbackup/translations/config_gui.fr.yml | 28 ++++- npbackup/translations/generic.en.yml | 4 +- npbackup/translations/generic.fr.yml | 4 +- 6 files changed, 197 insertions(+), 57 deletions(-) diff --git a/npbackup/configuration.py b/npbackup/configuration.py index 5f5c77e..bf519fe 100644 --- a/npbackup/configuration.py +++ b/npbackup/configuration.py @@ -120,11 +120,19 @@ def d(self, path, sep="."): "repos": { "default": { "repo_uri": "", + "permissions": "full", + "manager_password": None, "repo_group": "default_group", - "backup_opts": {}, + "backup_opts": { + "paths": [], + "tags": [], + }, "repo_opts": {}, "prometheus": {}, - "env": {"env_variables": [], "encrypted_env_variables": []}, + "env": { + "env_variables": [], + "encrypted_env_variables": [], + }, }, }, "groups": { @@ -161,11 +169,6 @@ def d(self, path, sep="."): } }, "repo_opts": { - "permissions": { - "restore": True, - "verify": True, - "delete": False, - }, "repo_password": "", "repo_password_command": "", # Minimum time between two backups, in minutes @@ -180,7 +183,7 @@ def d(self, path, sep="."): "weekly": 4, "monthly": 12, "yearly": 3, - "custom_time_server": None, + "ntp_time_server": None, }, }, "prometheus": { @@ -324,6 +327,39 @@ def _evaluate_variables(value): return repo_config +def extract_permissions_from_repo_config(repo_config: dict) -> dict: + """ + Extract permissions and manager password from repo_uri tuple + repo_config objects in memory are always "expanded" + This function is in order to expand when loading config + """ + repo_uri = repo_config.g("repo_uri") + if isinstance(repo_uri, tuple): + repo_uri, permissions, manager_password = repo_uri + repo_config.s("permissions", permissions) + repo_config.s("manager_password", manager_password) + return repo_config + + +def inject_permissions_into_repo_config(repo_config: dict) -> dict: + """ + Make sure repo_uri is a tuple containing permissions and manager password + This function is used before saving config + """ + repo_uri = repo_config.g("repo_uri") + permissions = repo_config.g("permissions") + manager_password = repo_config.g("manager_password") + repo_config.s(repo_uri, (repo_uri, permissions, manager_password)) + repo_config.d("repo_uri") + repo_config.d("permissions") + repo_config.d("manager_password") + return repo_config + + +def get_manager_password(full_config: dict, repo_name: str) -> str: + return full_config.g("repos.{repo_name}.manager_password") + + def get_repo_config( full_config: dict, repo_name: str = "default", eval_variables: bool = True ) -> Tuple[dict, dict]: @@ -413,6 +449,7 @@ def _inherit_group_settings( if eval_variables: repo_config = evaluate_variables(repo_config, full_config) + repo_config = extract_permissions_from_repo_config(repo_config) return repo_config, config_inheritance diff --git a/npbackup/gui/config.py b/npbackup/gui/config.py index 4f66320..25739bc 100644 --- a/npbackup/gui/config.py +++ b/npbackup/gui/config.py @@ -7,13 +7,12 @@ __author__ = "Orsiris de Jong" __copyright__ = "Copyright (C) 2022-2023 NetInvent" __license__ = "GPL-3.0-only" -__build__ = "2023121401" +__build__ = "2023121701" from typing import List import os from logging import getLogger -from copy import deepcopy import PySimpleGUI as sg from ruamel.yaml.comments import CommentedMap import npbackup.configuration as configuration @@ -28,21 +27,15 @@ logger = getLogger() -def ask_backup_admin_password(config_dict) -> bool: - try: - backup_admin_password = config_dict["options"]["backup_admin_password"] - except KeyError: - backup_admin_password = None - if backup_admin_password: +def ask_manager_password(manager_password: str) -> bool: + if manager_password: if sg.PopupGetText( - _t("config_gui.enter_backup_admin_password"), password_char="*" - ) == str(backup_admin_password): + _t("config_gui.set_manager_password"), password_char="*" + ) == str(manager_password): return True sg.PopupError(_t("config_gui.wrong_password")) return False - else: - sg.PopupError(_t("config_gui.no_backup_admin_password_set")) - return False + return True def config_gui(full_config: dict, config_file: str): @@ -72,6 +65,11 @@ def config_gui(full_config: dict, config_file: str): "normal": _t("config_gui.normal"), "high": _t("config_gui.high"), }, + "permissions": { + "backup": _t("config_gui.backup_perms"), + "restore": _t("config_gui.restore_perms"), + "full": _t("config_gui.full_perms"), + } } ENCRYPTED_DATA_PLACEHOLDER = "<{}>".format(_t("config_gui.encrypted_data")) @@ -255,11 +253,21 @@ def update_object_gui(object_name=None, unencrypted=False): object_config, config_inheritance = configuration.get_repo_config( full_config, object_name, eval_variables=False ) + + # Enable settings only valid for repos + window['repo_uri'].Update(visible=True) + window['--SET-PERMISSIONS--'].Update(visible=True) + if object_type == "group": object_config = configuration.get_group_config( full_config, object_name, eval_variables=False ) config_inheritance = None + + # Disable settings only valid for repos + window['repo_uri'].Update(visible=False) + window['--SET-PERMISSIONS--'].Update(visible=False) + # Now let's iter over the whole config object and update keys accordingly iter_over_config( object_config, config_inheritance, object_type, unencrypted, None @@ -274,12 +282,17 @@ def update_global_gui(full_config, unencrypted=False): global_config.s(key, full_config.g(key)) iter_over_config(global_config, None, "group", unencrypted, None) - def update_config_dict(values, full_config): + def update_config_dict(full_config, values): + """ + Update full_config with keys from + """ + object_type, object_name = get_object_from_combo(values['-OBJECT-SELECT-']) for key, value in values.items(): if value == ENCRYPTED_DATA_PLACEHOLDER: continue if not isinstance(key, str) or (isinstance(key, str) and not "." in key): - # Don't bother with keys that don't contain with "." + # Don't bother with keys that don't contain with "." since they're not in the YAML config file + # but are most probably for GUI events continue # Handle combo boxes first to transform translation into key if key in combo_boxes: @@ -302,10 +315,65 @@ def update_config_dict(values, full_config): except ValueError: pass # Create section if not exists - if key not in full_config.keys(): - full_config[key] = {} + active_object_key = f"{object_type}s.{object_name}.{key}" + print("ACTIVE KEY", active_object_key) + if not full_config.g(active_object_key): + full_config.s(active_object_key, CommentedMap()) + + full_config.s(active_object_key, value) + return full_config + # TODO: Do we actually save every modified object or just the last ? + # TDOO: also save global options + + def set_permissions(full_config: dict, object_name: str) -> dict: + """ + Sets repo wide repo_uri / password / permissions + """ + object_type, object_name = get_object_from_combo(object_name) + if object_type == "group": + sg.PopupError(_t("config_gui.permissions_only_for_repos")) + return full_config + repo_config, _ = configuration.get_repo_config(full_config, object_name, eval_variables=False) + permissions = list(combo_boxes["permissions"].values()) + default_perm = repo_config.g("permissions") + if not default_perm: + default_perm = permissions[-1] + manager_password = repo_config.g("manager_password") + + layout = [ + [ + sg.Text(_t("config_gui.permissions"), size=(40, 1)), + sg.Combo(permissions, default_value=default_perm, key="-PERMISSIONS-"), + ], + [sg.HorizontalSeparator()], + [ + sg.Text(_t("config_gui.set_manager_password"), size=(40, 1)), + sg.Input(manager_password, key="-MANAGER-PASSWORD-", size=(50, 1), password_char="*"), + #sg.Button(_t("generic.change"), key="--CHANGE-MANAGER-PASSWORD--") + ], + [sg.Push(), sg.Button(_t("generic.cancel"), key='--CANCEL--'), sg.Button(_t("generic.accept"), key='--ACCEPT--')], + ] - full_config.s(key, value) + window = sg.Window(_t("config_gui.permissions"), layout, keep_on_top=True) + while True: + event, values = window.read() + if event in (sg.WIN_CLOSED, sg.WIN_X_EVENT, '--CANCEL--'): + break + if event == '--ACCEPT--': + if not values['-MANAGER-PASSWORD-']: + sg.PopupError(_t("config_gui.setting_permissions_requires_manager_password"), keep_on_top=True) + continue + elif len(values['-MANAGER-PASSWORD-']) < 8: + sg.PopupError(_t("config_gui.manager_password_too_short"), keep_on_top=True) + continue + if not values['-PERMISSIONS-'] in permissions: + sg.PopupError(_t("generic.bogus_data_given"), keep_on_top=True) + continue + repo_config.s("permissions", values['-PERMISSIONS-']) + repo_config.s("manager_password", values['-MANAGER-PASSWORD-']) + break + window.close() + full_config.s(f"repos.{object_name}", repo_config) return full_config def object_layout() -> List[list]: @@ -489,6 +557,9 @@ def object_layout() -> List[list]: sg.Text(_t("config_gui.backup_repo_uri"), size=(40, 1)), sg.Input(key="repo_uri", size=(50, 1)), ], + [ + sg.Button(_t("config_gui.set_permissions"), key='--SET-PERMISSIONS--') + ], [ sg.Text(_t("config_gui.repo_group"), size=(40, 1)), sg.Input(key="repo_group", size=(50, 1)), @@ -523,17 +594,11 @@ def object_layout() -> List[list]: sg.Input(key="repo_opts.backend_connections", size=(50, 1)), ], [sg.HorizontalSeparator()], - [ - sg.Text(_t("config_gui.enter_backup_admin_password"), size=(40, 1)), - sg.Input(key="backup_admin_password", size=(50, 1), password_char="*"), - ], - [sg.Button(_t("generic.change"), key="change_backup_admin_password")], - [sg.HorizontalSeparator()], [sg.Text(_t("config_gui.retention_policy"))], [ - sg.Text(_t("config_gui.custom_time_server_url"), size=(40, 1)), + sg.Text(_t("config_gui.optional_ntp_server_uri"), size=(40, 1)), sg.Input( - key="repo_opts.retention_strategy.custom_time_server", size=(50, 1) + key="repo_opts.retention_strategy.ntp_time_server", size=(50, 1) ), ], [ @@ -854,7 +919,7 @@ def config_layout() -> List[list]: grab_anywhere=True, keep_on_top=False, alpha_channel=1.0, - default_button_element_size=(12, 1), + default_button_element_size=(16, 1), right_click_menu=right_click_menu, finalize=True, ) @@ -869,6 +934,7 @@ def config_layout() -> List[list]: break if event == "-OBJECT-SELECT-": try: + update_config_dict(full_config, values) update_object_gui(values["-OBJECT-SELECT-"], unencrypted=False) update_global_gui(full_config, unencrypted=False) except AttributeError: @@ -881,14 +947,20 @@ def config_layout() -> List[list]: full_config = create_object(full_config) update_object_selector() continue + if event == "--SET-PERMISSIONS--": + object_type, object_name = get_object_from_combo(values["-OBJECT-SELECT-"]) + manager_password = configuration.get_manager_password(full_config, object_name) + if ask_manager_password(manager_password): + full_config = set_permissions(full_config, values["-OBJECT-SELECT-"]) + continue if event == "--ACCEPT--": if ( - not values["repo_opts.password"] - and not values["repo_opts.password_command"] + not values["repo_opts.repo_password"] + and not values["repo_opts.repo_password_command"] ): sg.PopupError(_t("config_gui.repo_password_cannot_be_empty")) continue - full_config = update_config_dict(values, full_config) + full_config = update_config_dict(full_config, values) result = configuration.save_config(config_file, full_config) if result: sg.Popup(_t("config_gui.configuration_saved"), keep_on_top=True) @@ -900,7 +972,9 @@ def config_layout() -> List[list]: ) logger.info("Could not save configuration") if event == _t("config_gui.show_decrypted"): - if ask_backup_admin_password(full_config): + object_type, object_name = get_object_from_combo(values["-OBJECT-SELECT-"]) + manager_password = configuration.get_manager_password(full_config, object_name) + if ask_manager_password(manager_password): update_object_gui(values["-OBJECT-SELECT-"], unencrypted=True) update_global_gui(full_config, unencrypted=True) if event == "create_task": @@ -914,11 +988,5 @@ def config_layout() -> List[list]: sg.PopupError(_t("config_gui.scheduled_task_creation_failure")) else: sg.PopupError(_t("config_gui.scheduled_task_creation_failure")) - if event == "change_backup_admin_password": - if ask_backup_admin_password(full_config): - full_config["options"]["backup_admin_password"] = values[ - "backup_admin_password" - ] - sg.Popup(_t("config_gui.password_updated_please_save")) window.close() return full_config diff --git a/npbackup/translations/config_gui.en.yml b/npbackup/translations/config_gui.en.yml index 5156dfd..682e49e 100644 --- a/npbackup/translations/config_gui.en.yml +++ b/npbackup/translations/config_gui.en.yml @@ -25,8 +25,8 @@ en: backup_destination: Backup destination minimum_backup_age: Minimum delay between two backups backup_repo_uri: backup repo URI / path - backup_repo_password: Backup repo password - backup_repo_password_command: Command that returns backup repo password + backup_repo_password: Backup repo encryption password + backup_repo_password_command: Command that returns backup repo encryption password upload_speed: Upload speed limit (KB/s) download_speed: Download speed limit (KB/s) backend_connections: Simultaneous repo connections @@ -59,9 +59,8 @@ en: configuration_saved: Configuration saved cannot_save_configuration: Could not save configuration. See logs for further info repo_password_cannot_be_empty: Repo password or password command cannot be empty - enter_backup_admin_password: Backup admin password + set_manager_password: Manager password wrong_password: Wrong password - password_updated_please_save: Password updated. Please save configuration auto_upgrade: Auto upgrade auto_upgrade_server_url: Server URL @@ -102,13 +101,17 @@ en: files_from_verbatim: Files from verbatim list files_from_raw: Files from raw list + # retention policiy keep: Keep hourly: hourly copies daily: daily copies weekly: weekly copies monthly: monthly copies yearly: yearly copies + optional_ntp_server_uri: Optional NTP server URL + # repo / group managmeent + repo_group: Repo group group_inherited: Group inherited repo_group_config: Repo group configuration global_config: Global config @@ -118,3 +121,15 @@ en: are_you_sure_to_delete: Are you sure you want to delete repo_already_exists: Repo already exists group_already_exists: Group already exists + + # permissions + set_permissions: Set permissions + permissions_only_for_repos: Permissions can only be applied for repos + permissions: Permissions + backup_perms: Backup + restore_perms: Restore and Check repo + full_perms: Full permissions + setting_permissions_requires_manager_password: Setting permissions requires manager password + manager_password_too_short: Manager password is too short + + unknown_error_see_logs: Unknown error, please check logs \ No newline at end of file diff --git a/npbackup/translations/config_gui.fr.yml b/npbackup/translations/config_gui.fr.yml index cf3b4d7..b8139ea 100644 --- a/npbackup/translations/config_gui.fr.yml +++ b/npbackup/translations/config_gui.fr.yml @@ -25,8 +25,8 @@ fr: backup_destination: Destination de sauvegarde minimum_backup_age: Délai minimal entre deux sauvegardes backup_repo_uri: URL / chemin local dépot de sauvegarde - backup_repo_password: Mot de passe dépot de sauvegarde - backup_repo_password_command: Commande qui retourne le mot de passe dépot + backup_repo_password: Mot de passe (chiffrement) dépot de sauvegarde + backup_repo_password_command: Commande qui retourne le mot de passe chiffrement dépot upload_speed: Vitesse limite de téléversement (KB/s) download_speed: Vitesse limite de téléchargement (KB/s) backend_connections: Connexions simultanées au dépot @@ -58,10 +58,9 @@ fr: configuration_saved: Configuration sauvegardée cannot_save_configuration: Impossible d'enregistrer la configuration. Veuillez consulter les journaux pour plus de détails - repo_password_cannot_be_empty: Le mot de passe du dépot ou la commande de mot de passe ne peut être vide - enter_backup_admin_password: Mot de passe admin de sauvegarde + repo_password_cannot_be_empty: Le mot de passe du dépot ou la commande de mot de passe ne peuvent être vides + set_manager_password: Mot de passe gestionnaire wrong_password: Mot de passe érroné - password_updated_please_save: Mot de passe mis à jour. Veuillez enregistrer la configuraiton auto_upgrade: Mise à niveau auto_upgrade_server_url: Serveur de mise à niveau @@ -102,13 +101,18 @@ fr: files_from_verbatim: Liste fichiers depuis un fichier "exact" files_from_raw: Liste fichiers depuis un fichier "raw" + # retention policies + retention_policy: Politique de conservation keep: Garder hourly: copies horaires daily: copies journalières weekly: copies hebdomadaires monthly: copies mensuelles yearly: copies annuelles + optional_ntp_server_uri: URI optionnelle serveur NTP + # repo management + repo_group: Groupe de dépots group_inherited: Hérité du groupe repo_group_config: Configuration de groupe de dépots global_config: Configuration globale @@ -117,4 +121,16 @@ fr: delete_object: Supprimer le dépot ou groupe actuel are_you_sure_to_delete: Êtes-vous sûr de vouloir supprimer le repo_already_exists: Dépot déjà existant - group_already_exists: Groupe déjà existant \ No newline at end of file + group_already_exists: Groupe déjà existant + + # permissions + set_permissions: Régler les permissions + permissions_only_for_repos: Les permissions peuvent être appliquées uniquement à des dépots + permissions: Permissions + backup_perms: Sauvegarde + restore_perms: Restauration et vérification de dépot + full_perms: Accès total + setting_permissions_requires_manager_password: Un mot de passe gestionnaire est requis pour définir des permissions + manager_password_too_short: Le mot de passe gestionnaire est trop court + + unknown_error_see_logs: Erreur inconnue, merci de vérifier les journaux \ No newline at end of file diff --git a/npbackup/translations/generic.en.yml b/npbackup/translations/generic.en.yml index 2a69f5f..39555c4 100644 --- a/npbackup/translations/generic.en.yml +++ b/npbackup/translations/generic.en.yml @@ -53,4 +53,6 @@ en: select_file: Select file name: Name - type: Type \ No newline at end of file + type: Type + + bogus_data_given: Bogus data given \ No newline at end of file diff --git a/npbackup/translations/generic.fr.yml b/npbackup/translations/generic.fr.yml index cf27f86..8bb7a49 100644 --- a/npbackup/translations/generic.fr.yml +++ b/npbackup/translations/generic.fr.yml @@ -53,4 +53,6 @@ fr: select_file: Selection fichier name: Nom - type: Type \ No newline at end of file + type: Type + + bogus_data_given: Données invalides \ No newline at end of file From e0f85bacaee625dedd514268d931a5a5b8811b2a Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Sun, 17 Dec 2023 21:38:46 +0100 Subject: [PATCH 046/328] Add execution logs to gui --- npbackup/common.py | 49 ++++++++++++++++++++++++++++++++++++++++ npbackup/gui/__main__.py | 21 ++++++++++++++--- 2 files changed, 67 insertions(+), 3 deletions(-) create mode 100644 npbackup/common.py diff --git a/npbackup/common.py b/npbackup/common.py new file mode 100644 index 0000000..a8931c2 --- /dev/null +++ b/npbackup/common.py @@ -0,0 +1,49 @@ +from datetime import datetime +from logging import getLogger +import ofunctions.logger_utils + + +logger = getLogger() + + +EXIT_CODE = 0 + + +def execution_logs(start_time: datetime) -> None: + """ + Try to know if logger.warning or worse has been called + logger._cache contains a dict of values like {10: boolean, 20: boolean, 30: boolean, 40: boolean, 50: boolean} + where + 10 = debug, 20 = info, 30 = warning, 40 = error, 50 = critical + so "if 30 in logger._cache" checks if warning has been triggered + ATTENTION: logger._cache does only contain cache of current main, not modules, deprecated in favor of + ofunctions.logger_utils.ContextFilterWorstLevel + + ATTENTION: For ofunctions.logger_utils.ContextFilterWorstLevel will only check current logger instance + So using logger = getLogger("anotherinstance") will create a separate instance from the one we can inspect + Makes sense ;) + """ + global EXIT_CODE + + end_time = datetime.utcnow() + + logger_worst_level = 0 + for flt in logger.filters: + if isinstance(flt, ofunctions.logger_utils.ContextFilterWorstLevel): + logger_worst_level = flt.worst_level + + log_level_reached = "success" + EXIT_CODE = logger_worst_level + try: + if logger_worst_level >= 40: + log_level_reached = "errors" + elif logger_worst_level >= 30: + log_level_reached = "warnings" + except AttributeError as exc: + logger.error("Cannot get worst log level reached: {}".format(exc)) + logger.info( + "ExecTime = {}, finished, state is: {}.".format( + end_time - start_time, log_level_reached + ) + ) + # using sys.exit(code) in a atexit function will swallow the exitcode and render 0 \ No newline at end of file diff --git a/npbackup/gui/__main__.py b/npbackup/gui/__main__.py index 5cc0c15..0cf2c08 100644 --- a/npbackup/gui/__main__.py +++ b/npbackup/gui/__main__.py @@ -7,7 +7,7 @@ __author__ = "Orsiris de Jong" __copyright__ = "Copyright (C) 2022-2023 NetInvent" __license__ = "GPL-3.0-only" -__build__ = "2023121001" +__build__ = "2023121701" from typing import List, Optional, Tuple @@ -19,11 +19,13 @@ import dateutil import queue from time import sleep +import atexit from ofunctions.threading import threaded, Future from ofunctions.misc import BytesConverter import PySimpleGUI as sg import _tkinter import npbackup.configuration +import npbackup.common from npbackup.customization import ( OEM_STRING, OEM_LOGO, @@ -442,7 +444,15 @@ def ls_window(config: dict, snapshot_id: str) -> bool: # This is a little trichery lesson # Still we should open a case at PySimpleGUI to know why closing a sg.TreeData window is painfully slow # TODO window.hide() - Thread(target=window.close, args=()) + + @threaded + def _close_win(): + """ + Since closing a sg.Treedata takes alot of time, let's thread it into background + """ + window.close + + _close_win() return True @@ -665,7 +675,7 @@ def _main_gui(): grab_anywhere=False, keep_on_top=False, alpha_channel=0.9, - default_button_element_size=(12, 1), + default_button_element_size=(16, 1), right_click_menu=right_click_menu, finalize=True, ) @@ -799,8 +809,13 @@ def _main_gui(): def main_gui(): + atexit.register( + npbackup.common.execution_logs, + datetime.utcnow(), + ) try: _main_gui() + sys.exit(npbackup.common.EXIT_CODE) except _tkinter.TclError as exc: logger.critical(f'Tkinter error: "{exc}". Is this a headless server ?') sys.exit(250) From a0511ce2a5af8b83a00f58396a1579bae5f00f34 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Mon, 18 Dec 2023 21:54:03 +0100 Subject: [PATCH 047/328] WIP: Refactor operations GUI --- npbackup/common.py | 22 ++++-- npbackup/core/runner.py | 5 +- npbackup/gui/__main__.py | 60 +++++++++-------- npbackup/gui/operations.py | 96 +++++++++++---------------- npbackup/translations/main_gui.en.yml | 2 +- npbackup/translations/main_gui.fr.yml | 2 +- 6 files changed, 93 insertions(+), 94 deletions(-) diff --git a/npbackup/common.py b/npbackup/common.py index a8931c2..fd1041a 100644 --- a/npbackup/common.py +++ b/npbackup/common.py @@ -1,3 +1,17 @@ +#! /usr/bin/env python +# -*- coding: utf-8 -*- +# +# This file is part of npbackup + +__intname__ = "npbackup.common" +__author__ = "Orsiris de Jong" +__site__ = "https://www.netperfect.fr/npbackup" +__description__ = "NetPerfect Backup Client" +__copyright__ = "Copyright (C) 2023 NetInvent" +__license__ = "GPL-3.0-only" +__build__ = "2023121801" + + from datetime import datetime from logging import getLogger import ofunctions.logger_utils @@ -6,9 +20,6 @@ logger = getLogger() -EXIT_CODE = 0 - - def execution_logs(start_time: datetime) -> None: """ Try to know if logger.warning or worse has been called @@ -23,7 +34,6 @@ def execution_logs(start_time: datetime) -> None: So using logger = getLogger("anotherinstance") will create a separate instance from the one we can inspect Makes sense ;) """ - global EXIT_CODE end_time = datetime.utcnow() @@ -33,7 +43,6 @@ def execution_logs(start_time: datetime) -> None: logger_worst_level = flt.worst_level log_level_reached = "success" - EXIT_CODE = logger_worst_level try: if logger_worst_level >= 40: log_level_reached = "errors" @@ -46,4 +55,5 @@ def execution_logs(start_time: datetime) -> None: end_time - start_time, log_level_reached ) ) - # using sys.exit(code) in a atexit function will swallow the exitcode and render 0 \ No newline at end of file + # using sys.exit(code) in a atexit function will swallow the exitcode and render 0 + # Using sys.exit(logger.get_worst_logger_level()) is the way to go, when using ofunctions.logger_utils >= 2.4.1 \ No newline at end of file diff --git a/npbackup/core/runner.py b/npbackup/core/runner.py index 1bd1049..40cb388 100644 --- a/npbackup/core/runner.py +++ b/npbackup/core/runner.py @@ -652,7 +652,8 @@ def raw(self, command: str) -> bool: return result def group_runner( - self, operations_config: dict, result_queue: Optional[queue.Queue] + self, repo_list: list, operation: str, result_queue: Optional[queue.Queue] ) -> bool: - print(operations_config) + for repo in repo_list: + print(f"Running {operation} for repo {repo}") print("run to the hills") diff --git a/npbackup/gui/__main__.py b/npbackup/gui/__main__.py index 0cf2c08..ce4cfb5 100644 --- a/npbackup/gui/__main__.py +++ b/npbackup/gui/__main__.py @@ -14,7 +14,7 @@ import sys import os from pathlib import Path -from logging import getLogger +import ofunctions.logger_utils from datetime import datetime import dateutil import queue @@ -57,10 +57,15 @@ OEM_ICON, ) + + +LOG_FILE = os.path.join(CURRENT_DIR, "{}.log".format(__intname__)) +logger = ofunctions.logger_utils.logger_get_logger(LOG_FILE) + + sg.theme(PYSIMPLEGUI_THEME) sg.SetOptions(icon=OEM_ICON) -logger = getLogger() # Let's use mutable to get a cheap way of transfering data from thread to main program # There are no possible race conditions since we don't modifiy the data from anywhere outside the thread @@ -199,21 +204,21 @@ def _gui_update_state( window, current_state: bool, backup_tz: Optional[datetime], snapshot_list: List[str] ) -> None: if current_state: - window["state-button"].Update( + window["--STATE-BUTTON--"].Update( "{}: {}".format(_t("generic.up_to_date"), backup_tz), button_color=GUI_STATE_OK_BUTTON, ) elif current_state is False and backup_tz == datetime(1, 1, 1, 0, 0): - window["state-button"].Update( + window["--STATE-BUTTON--"].Update( _t("generic.no_snapshots"), button_color=GUI_STATE_OLD_BUTTON ) elif current_state is False: - window["state-button"].Update( + window["--STATE-BUTTON--"].Update( "{}: {}".format(_t("generic.too_old"), backup_tz.replace(microsecond=0)), button_color=GUI_STATE_OLD_BUTTON, ) elif current_state is None: - window["state-button"].Update( + window["--STATE-BUTTON--"].Update( _t("generic.not_connected_yet"), button_color=GUI_STATE_UNKNOWN_BUTTON ) @@ -619,7 +624,7 @@ def _main_gui(): [ sg.Button( _t("generic.unknown"), - key="state-button", + key="--STATE-BUTTON--", button_color=("white", "grey"), ) ], @@ -650,13 +655,13 @@ def _main_gui(): ) ], [ - sg.Button(_t("main_gui.launch_backup"), key="launch-backup"), - sg.Button(_t("main_gui.see_content"), key="see-content"), - sg.Button(_t("generic.forget"), key="forget"), - sg.Button(_t("main_gui.operations"), key="operations"), - sg.Button(_t("generic.configure"), key="configure"), - sg.Button(_t("generic.about"), key="about"), - sg.Button(_t("generic.quit"), key="-EXIT-"), + sg.Button(_t("main_gui.launch_backup"), key="--LAUNCH-BACKUP--"), + sg.Button(_t("main_gui.see_content"), key="--SEE-CONTENT--"), + sg.Button(_t("generic.forget"), key="--FORGET--"), + sg.Button(_t("main_gui.operations"), key="--OPERATIONS--"), + sg.Button(_t("generic.configure"), key="--CONFIGURE--"), + sg.Button(_t("generic.about"), key="--ABOUT--"), + sg.Button(_t("generic.quit"), key="--EXIT--"), ], ], element_justification="C", @@ -694,7 +699,7 @@ def _main_gui(): while True: event, values = window.read(timeout=60000) - if event in (sg.WIN_X_EVENT, sg.WIN_CLOSED, "-EXIT-"): + if event in (sg.WIN_X_EVENT, sg.WIN_CLOSED, "--EXIT--"): break if event == "-active_repo-": active_repo = values["-active_repo-"] @@ -708,7 +713,7 @@ def _main_gui(): else: sg.PopupError("Repo not existent in config") continue - if event == "launch-backup": + if event == "--LAUNCH-BACKUP--": progress_windows_layout = [ [ sg.Multiline( @@ -762,17 +767,16 @@ def _main_gui(): ) progress_window.close() continue - if event == "see-content": + if event == "--SEE-CONTENT--": if not values["snapshot-list"]: sg.Popup(_t("main_gui.select_backup"), keep_on_top=True) continue - print(values["snapshot-list"]) if len(values["snapshot-list"]) > 1: sg.Popup(_t("main_gui.select_only_one_snapshot")) continue snapshot_to_see = snapshot_list[values["snapshot-list"][0]][0] ls_window(repo_config, snapshot_to_see) - if event == "forget": + if event == "--FORGET--": if not values["snapshot-list"]: sg.Popup(_t("main_gui.select_backup"), keep_on_top=True) continue @@ -781,14 +785,14 @@ def _main_gui(): snapshots_to_forget.append(snapshot_list[row][0]) forget_snapshot(repo_config, snapshots_to_forget) # Make sure we trigger a GUI refresh after forgetting snapshots - event = "state-button" - if event == "operations": - full_config = operations_gui(full_config, config_file) - event = "state-button" - if event == "configure": + event = "--STATE-BUTTON--" + if event == "--OPERATIONS--": + full_config = operations_gui(full_config) + event = "--STATE-BUTTON--" + if event == "--CONFIGURE--": full_config = config_gui(full_config, config_file) # Make sure we trigger a GUI refresh when configuration is changed - event = "state-button" + event = "--STATE-BUTTON--" if event == _t("generic.destination"): try: if backend_type: @@ -799,9 +803,9 @@ def _main_gui(): sg.PopupNoFrame(destination_string) except (TypeError, KeyError): sg.PopupNoFrame(_t("main_gui.unknown_repo")) - if event == "about": + if event == "--ABOUT--": _about_gui(version_string, full_config) - if event == "state-button": + if event == "--STATE-BUTTON--": current_state, backup_tz, snapshot_list = get_gui_data(repo_config) _gui_update_state(window, current_state, backup_tz, snapshot_list) if current_state is None: @@ -815,7 +819,7 @@ def main_gui(): ) try: _main_gui() - sys.exit(npbackup.common.EXIT_CODE) + sys.exit(logger.get_worst_logger_level()) except _tkinter.TclError as exc: logger.critical(f'Tkinter error: "{exc}". Is this a headless server ?') sys.exit(250) diff --git a/npbackup/gui/operations.py b/npbackup/gui/operations.py index a6a254c..388123e 100644 --- a/npbackup/gui/operations.py +++ b/npbackup/gui/operations.py @@ -38,34 +38,28 @@ logger = getLogger(__intname__) -def add_repo(config_dict: dict) -> dict: - pass - - -def gui_update_state(window, config_dict: dict) -> list: +def gui_update_state(window, full_config: dict) -> list: repo_list = [] try: - for repo_name in config_dict["repos"]: - if ( - config_dict["repos"][repo_name]["repository"] - and config_dict["repos"][repo_name]["password"] + for repo_name in full_config.g("repos"): + repo_config, _ = configuration.get_repo_config(full_config, repo_name) + if repo_config.g(f"repo_uri") and ( + repo_config.g(f"repo_opts.repo_password") + or repo_config.g(f"repo_opts.repo_password_command") ): backend_type, repo_uri = get_anon_repo_uri( - config_dict["repos"][repo_name]["repository"] + repo_config.g(f"repo_uri") ) repo_list.append([backend_type, repo_uri]) else: logger.warning("Incomplete operations repo {}".format(repo_name)) except KeyError: logger.info("No operations repos configured") - if config_dict["repo"]["repository"] and config_dict["repo"]["password"]: - backend_type, repo_uri = get_anon_repo_uri(config_dict["repo"]["repository"]) - repo_list.append("[{}] {}".format(backend_type, repo_uri)) window["repo-list"].update(repo_list) return repo_list -def operations_gui(config_dict: dict, config_file: str) -> dict: +def operations_gui(full_config: dict) -> dict: """ Operate on one or multiple repositories """ @@ -102,13 +96,8 @@ def operations_gui(config_dict: dict, config_file: str) -> dict: ) ], [ - sg.Button(_t("operations_gui.add_repo"), key="add-repo"), - sg.Button(_t("operations_gui.edit_repo"), key="edit-repo"), - sg.Button(_t("operations_gui.remove_repo"), key="remove-repo"), - ], - [ - sg.Button(_t("operations_gui.quick_check"), key="quick-check"), - sg.Button(_t("operations_gui.full_check"), key="full-check"), + sg.Button(_t("operations_gui.quick_check"), key="--QUICK-CHECK--"), + sg.Button(_t("operations_gui.full_check"), key="--FULL-CHECK--"), ], [ sg.Button( @@ -118,11 +107,11 @@ def operations_gui(config_dict: dict, config_file: str) -> dict: ], [ sg.Button( - _t("operations_gui.standard_prune"), key="standard-prune" + _t("operations_gui.standard_prune"), key="--STANDARD-PRUNE--" ), - sg.Button(_t("operations_gui.max_prune"), key="max-prune"), + sg.Button(_t("operations_gui.max_prune"), key="--MAX-PRUNE--"), ], - [sg.Button(_t("generic.quit"), key="exit")], + [sg.Button(_t("generic.quit"), key="--EXIT--")], ], element_justification="C", ) @@ -144,7 +133,7 @@ def operations_gui(config_dict: dict, config_file: str) -> dict: finalize=True, ) - full_repo_list = gui_update_state(window, config_dict) + complete_repo_list = gui_update_state(window, full_config) # Auto reisze table to window size window["repo-list"].expand(True, True) @@ -152,34 +141,14 @@ def operations_gui(config_dict: dict, config_file: str) -> dict: while True: event, values = window.read(timeout=60000) - if event in (sg.WIN_CLOSED, "exit"): + if event in (sg.WIN_CLOSED, sg.WIN_X_EVENT, '--EXIT--'): break - if event == "add-repo": - pass - if event in ["add-repo", "remove-repo"]: - if not values["repo-list"]: - sg.Popup(_t("main_gui.select_backup"), keep_on_top=True) - continue - if event == "add-repo": - config_dict = add_repo(config_dict) - # Save to config here #TODO #WIP - event == "state-update" - elif event == "remove-repo": - result = sg.popup( - _t("generic.are_you_sure"), - custom_text=(_t("generic.yes"), _t("generic.no")), - ) - if result == _t("generic.yes"): - # Save to config here #TODO #WIP - event == "state-update" - if event == "forget": - pass if event in [ - "forget", - "quick-check", - "full-check", - "standard-prune", - "max-prune", + "--FORGET--", + "--QUICK-CHECK--", + "--FULL-CHECK--", + "--STANDARD-PRUNE--", + "--MAX-PRUNE--", ]: if not values["repo-list"]: result = sg.popup( @@ -188,14 +157,29 @@ def operations_gui(config_dict: dict, config_file: str) -> dict: ) if not result == _t("generic.yes"): continue - repos = full_repo_list + repos = complete_repo_list else: repos = values["repo-list"] + result_queue = queue.Queue() runner = NPBackupRunner() - runner.group_runner(repos, result_queue) - if event == "state-update": - full_repo_list = gui_update_state(window, config_dict) + print(repos) + group_runner_repo_list = [repo_name for backend_type, repo_name in repos] + + if event == '--FORGET--': + operation = 'forget' + if event == '--QUICK-CHECK--': + operation = 'quick_check' + if event == '--FULL-CHECK--': + operation = 'full_check' + if event == '--STANDARD-PRUNE--': + operation = 'standard_prune' + if event == '--MAX-PRUNE--': + operation = 'max_prune' + runner.group_runner(group_runner_repo_list, operation, result_queue) + event = '---STATE-UPDATE---' + if event == "---STATE-UPDATE---": + complete_repo_list = gui_update_state(window, full_config) window.close() - return config_dict + return full_config diff --git a/npbackup/translations/main_gui.en.yml b/npbackup/translations/main_gui.en.yml index 60f7b46..3983617 100644 --- a/npbackup/translations/main_gui.en.yml +++ b/npbackup/translations/main_gui.en.yml @@ -14,7 +14,7 @@ en: only_include: Only include destination_folder: Destination folder backup_state: Backup state - backup_list_to: List of backups to + backup_list_to: List of backups to repo local_folder: Local folder external_server: external server launch_backup: Launch backup diff --git a/npbackup/translations/main_gui.fr.yml b/npbackup/translations/main_gui.fr.yml index f1ca8fe..4e1025a 100644 --- a/npbackup/translations/main_gui.fr.yml +++ b/npbackup/translations/main_gui.fr.yml @@ -14,7 +14,7 @@ fr: only_include: Inclure seulement destination_folder: Dossier de destination backup_state: Etat de sauvegarde - backup_list_to: Liste des sauvegardes vers + backup_list_to: Liste des sauvegardes vers le dépot local_folder: Dossier local external_server: serveur externalisé launch_backup: Sauvegarder From e65e91c7a65fe5a30d31de1c10cfe7ce1e06e73f Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Mon, 18 Dec 2023 21:54:17 +0100 Subject: [PATCH 048/328] Require ofunctions.logger_utils >=2.4.1 --- npbackup/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/npbackup/requirements.txt b/npbackup/requirements.txt index 3dc83cd..31fae12 100644 --- a/npbackup/requirements.txt +++ b/npbackup/requirements.txt @@ -1,7 +1,7 @@ command_runner>=1.5.1 cryptidy>=1.2.2 python-dateutil -ofunctions.logger_utils>=2.3.0 +ofunctions.logger_utils>=2.4.1 ofunctions.misc>=1.6.1 ofunctions.process>=1.4.0 ofunctions.threading>=2.0.0 From afaa055806d059737fd7260e6b1cce9b0bf6de3e Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Tue, 19 Dec 2023 20:32:14 +0100 Subject: [PATCH 049/328] Refactor NPBackupRunner to return stdout/stderr to GUI --- npbackup/core/runner.py | 98 ++++++++++++++++++++++++++-- npbackup/gui/__main__.py | 7 +- npbackup/gui/operations.py | 66 ++++++++++++++++--- npbackup/restic_wrapper/__init__.py | 15 ++++- npbackup/translations/generic.en.yml | 5 +- npbackup/translations/generic.fr.yml | 5 +- 6 files changed, 173 insertions(+), 23 deletions(-) diff --git a/npbackup/core/runner.py b/npbackup/core/runner.py index 40cb388..0741daa 100644 --- a/npbackup/core/runner.py +++ b/npbackup/core/runner.py @@ -16,7 +16,9 @@ import queue from datetime import datetime, timedelta from functools import wraps +import queue from command_runner import command_runner +from ofunctions.threading import threaded from ofunctions.platform import os_arch from npbackup.restic_metrics import restic_output_2_metrics, upload_metrics from npbackup.restic_wrapper import ResticRunner @@ -114,6 +116,9 @@ class NPBackupRunner: # This can lead to a problem when the config file can be written by users other than npbackup def __init__(self, repo_config: Optional[dict] = None): + self._stdout = None + self._stderr = None + if repo_config: self.repo_config = repo_config @@ -176,6 +181,22 @@ def stdout(self, value): self._stdout = value self.apply_config_to_restic_runner() + @property + def stderr(self): + return self._stderr + + @stderr.setter + def stderr(self, value): + if ( + not isinstance(value, str) + and not isinstance(value, int) + and not isinstance(value, Callable) + and not isinstance(value, queue.Queue) + ): + raise ValueError("Bogus stdout parameter given: {}".format(value)) + self._stderr = value + self.apply_config_to_restic_runner() + @property def has_binary(self) -> bool: if self.is_ready: @@ -207,6 +228,30 @@ def wrapper(self, *args, **kwargs): return result return wrapper + + def write_logs(self, msg: str, error: bool=False): + logger.info(msg) + if error: + if self.stderr: + self.stderr.put(msg) + else: + if self.stdout: + self.stdout.put(msg) + + def close_queues(fn: Callable): + """ + Function that sends None to both stdout and stderr queues so GUI gets proper results + """ + def wrapper(self, *args, **kwargs): + close_queues = kwargs.pop("close_queues", True) + result = fn(self, *args, **kwargs) + if close_queues: + if self.stdout: + self.stdout.put(None) + if self.stderr: + self.stderr.put(None) + return result + return wrapper def create_restic_runner(self) -> None: can_run = True @@ -267,6 +312,9 @@ def create_restic_runner(self) -> None: binary_search_paths=[BASEDIR, CURRENT_DIR], ) + self.restic_runner.stdout = self.stdout + self.restic_runner.stderr = self.stderr + if self.restic_runner.binary is None: # Let's try to load our internal binary for dev purposes arch = os_arch() @@ -381,8 +429,16 @@ def apply_config_to_restic_runner(self) -> None: self.minimum_backup_age = 1440 self.restic_runner.verbose = self.verbose - self.restic_runner.stdout = self.stdout + # TODO + #self.restic_runner.stdout = self.stdout + #self.restic_runner.stderr = self.stderr + + ########################### + # ACTUAL RUNNER FUNCTIONS # + ########################### + + @close_queues @exec_timer def list(self) -> Optional[dict]: if not self.is_ready: @@ -391,6 +447,7 @@ def list(self) -> Optional[dict]: snapshots = self.restic_runner.snapshots() return snapshots + @close_queues @exec_timer def find(self, path: str) -> bool: if not self.is_ready: @@ -404,6 +461,7 @@ def find(self, path: str) -> bool: return True return False + @close_queues @exec_timer def ls(self, snapshot: str) -> Optional[dict]: if not self.is_ready: @@ -412,6 +470,7 @@ def ls(self, snapshot: str) -> Optional[dict]: result = self.restic_runner.ls(snapshot) return result + @close_queues @exec_timer def check_recent_backups(self) -> bool: """ @@ -444,6 +503,7 @@ def check_recent_backups(self) -> bool: logger.error("Cannot connect to repository or repository empty.") return result, backup_tz + @close_queues @exec_timer def backup(self, force: bool = False) -> bool: """ @@ -601,10 +661,16 @@ def backup(self, force: bool = False) -> bool: ) return result + @close_queues @exec_timer def restore(self, snapshot: str, target: str, restore_includes: List[str]) -> bool: if not self.is_ready: return False + if not self.repo_config.g("permissions") in ['restore', 'full']: + msg = "You don't have permissions to restore this repo" + self.output_queue.put(msg) + logger.critical(msg) + return False logger.info("Launching restore to {}".format(target)) result = self.restic_runner.restore( snapshot=snapshot, @@ -613,6 +679,7 @@ def restore(self, snapshot: str, target: str, restore_includes: List[str]) -> bo ) return result + @close_queues @exec_timer def forget(self, snapshot: str) -> bool: if not self.is_ready: @@ -621,14 +688,16 @@ def forget(self, snapshot: str) -> bool: result = self.restic_runner.forget(snapshot) return result + @close_queues @exec_timer def check(self, read_data: bool = True) -> bool: if not self.is_ready: return False - logger.info("Checking repository") + self.write_logs("Checking repository") result = self.restic_runner.check(read_data) return result + @close_queues @exec_timer def prune(self) -> bool: if not self.is_ready: @@ -637,6 +706,7 @@ def prune(self) -> bool: result = self.restic_runner.prune() return result + @close_queues @exec_timer def repair(self, order: str) -> bool: if not self.is_ready: @@ -645,15 +715,33 @@ def repair(self, order: str) -> bool: result = self.restic_runner.repair(order) return result + @close_queues @exec_timer def raw(self, command: str) -> bool: logger.info("Running raw command: {}".format(command)) result = self.restic_runner.raw(command=command) return result + @close_queues + @exec_timer def group_runner( - self, repo_list: list, operation: str, result_queue: Optional[queue.Queue] + self, repo_list: list, operation: str, **kwargs ) -> bool: + group_result = True + + # Make sure we don't close the stdout/stderr queues when running multiple operations + kwargs = { + **kwargs, + **{'close_queues': False} + } + for repo in repo_list: - print(f"Running {operation} for repo {repo}") - print("run to the hills") + self.write_logs(f"Running {operation} for repo {repo}") + result = self.__getattribute__(operation)(**kwargs) + if result: + self.write_logs(f"Finished {operation} for repo {repo}") + else: + self.write_logs(f"Operation {operation} failed for repo {repo}", error=True) + group_result = False + self.write_logs("Finished execution group operations") + return group_result \ No newline at end of file diff --git a/npbackup/gui/__main__.py b/npbackup/gui/__main__.py index ce4cfb5..0b7f279 100644 --- a/npbackup/gui/__main__.py +++ b/npbackup/gui/__main__.py @@ -47,7 +47,6 @@ from npbackup.core.i18n_helper import _t from npbackup.core.upgrade_runner import run_upgrade, check_new_version from npbackup.path_helper import CURRENT_DIR -from npbackup.interface_entrypoint import entrypoint from npbackup.__version__ import version_string from npbackup.__debug__ import _DEBUG from npbackup.gui.config import config_gui @@ -535,12 +534,13 @@ def restore_window( @threaded -def _gui_backup(repo_config, stdout) -> Future: +def _gui_backup(repo_config, stdout, stderr) -> Future: runner = NPBackupRunner(repo_config=repo_config) runner.verbose = ( True # We must use verbose so we get progress output from ResticRunner ) runner.stdout = stdout + runner.stderr = stderr result = runner.backup( force=True, ) # Since we run manually, force backup regardless of recent backup state @@ -729,11 +729,12 @@ def _main_gui(): # We need to read that window at least once fopr it to exist progress_window.read(timeout=1) stdout = queue.Queue() + stderr = queue.Queue() # let's use a mutable so the backup thread can modify it # We get a thread result, hence pylint will complain the thread isn't a tuple # pylint: disable=E1101 (no-member) - thread = _gui_backup(repo_config=repo_config, stdout=stdout) + thread = _gui_backup(repo_config=repo_config, stdout=stdout, stderr=stderr) while not thread.done() and not thread.cancelled(): try: stdout_line = stdout.get(timeout=0.01) diff --git a/npbackup/gui/operations.py b/npbackup/gui/operations.py index 388123e..7c152de 100644 --- a/npbackup/gui/operations.py +++ b/npbackup/gui/operations.py @@ -7,7 +7,7 @@ __author__ = "Orsiris de Jong" __copyright__ = "Copyright (C) 2023 NetInvent" __license__ = "GPL-3.0-only" -__build__ = "2023083101" +__build__ = "2023121901" from typing import Tuple @@ -138,6 +138,10 @@ def operations_gui(full_config: dict) -> dict: # Auto reisze table to window size window["repo-list"].expand(True, True) + # Create queues for getting runner data + stdout_queue = queue.Queue() + stderr_queue = queue.Queue() + while True: event, values = window.read(timeout=60000) @@ -160,23 +164,67 @@ def operations_gui(full_config: dict) -> dict: repos = complete_repo_list else: repos = values["repo-list"] - - result_queue = queue.Queue() + runner = NPBackupRunner() - print(repos) + runner.stdout = stdout_queue + runner.stderr = stderr_queue group_runner_repo_list = [repo_name for backend_type, repo_name in repos] if event == '--FORGET--': operation = 'forget' + op_args = {} if event == '--QUICK-CHECK--': - operation = 'quick_check' + operation = 'check' + op_args = {'read_data': False} if event == '--FULL-CHECK--': - operation = 'full_check' + operation = 'check' + op_args = {'read_data': True} if event == '--STANDARD-PRUNE--': - operation = 'standard_prune' + operation = 'prune' + op_args = {} if event == '--MAX-PRUNE--': - operation = 'max_prune' - runner.group_runner(group_runner_repo_list, operation, result_queue) + operation = 'prune' + op_args = {} + thread = runner.group_runner(group_runner_repo_list, operation, **op_args) + read_stdout_queue = True + read_sterr_queue = True + + progress_layout = [ + [sg.Text(_t("operations_gui.last_message"))], + [sg.Multiline(key='-OPERATIONS-PROGRESS-STDOUT-', size=(40, 10))], + [sg.Text(_t("operations_gui.error_messages"))], + [sg.Multiline(key='-OPERATIONS-PROGRESS-STDERR-', size=(40, 10))], + [sg.Button(_t("generic.close"), key="--EXIT--")] + ] + progress_window = sg.Window("Operation status", progress_layout) + event, values = progress_window.read(timeout=0.01) + + while read_stdout_queue or read_sterr_queue: + # Read stdout queue + try: + stdout_data = stdout_queue.get(timeout=0.01) + except queue.Empty: + pass + else: + if stdout_data is None: + read_stdout_queue = False + else: + progress_window['-OPERATIONS-PROGRESS-STDOUT-'].Update(stdout_data) + + # Read stderr queue + try: + stderr_data = stderr_queue.get(timeout=0.01) + except queue.Empty: + pass + else: + if stderr_data is None: + read_sterr_queue = False + else: + progress_window['-OPERATIONS-PROGRESS-STDERR-'].Update(f"{progress_window['-OPERATIONS-PROGRESS-STDERR-'].get()}\n{stderr_data}") + + _, _ = progress_window.read() + progress_window.close() + event = '---STATE-UPDATE---' if event == "---STATE-UPDATE---": complete_repo_list = gui_update_state(window, full_config) diff --git a/npbackup/restic_wrapper/__init__.py b/npbackup/restic_wrapper/__init__.py index eef4b44..c5108a0 100644 --- a/npbackup/restic_wrapper/__init__.py +++ b/npbackup/restic_wrapper/__init__.py @@ -35,6 +35,7 @@ def __init__( repository: str, password: str, binary_search_paths: List[str] = None, + ) -> None: self.repository = str(repository).strip() self.password = str(password).strip() @@ -77,7 +78,8 @@ def __init__( None # Function which will make executor abort if result is True ) self._executor_finished = False # Internal value to check whether executor is done, accessed via self.executor_finished property - self._stdout = None # Optional outputs when command is run as thread + self._stdout = None # Optional outputs when running GUI, to get interactive output + self._stderr = None def on_exit(self) -> bool: self._executor_finished = True @@ -145,6 +147,14 @@ def stdout(self) -> Optional[Union[int, str, Callable, queue.Queue]]: def stdout(self, value: Optional[Union[int, str, Callable, queue.Queue]]): self._stdout = value + @property + def stderr(self) -> Optional[Union[int, str, Callable, queue.Queue]]: + return self._stderr + + @stdout.setter + def stderr(self, value: Optional[Union[int, str, Callable, queue.Queue]]): + self._stderr = value + @property def verbose(self) -> bool: return self._verbose @@ -191,7 +201,7 @@ def executor( cmd: str, errors_allowed: bool = False, timeout: int = None, - live_stream=False, + live_stream=False, # TODO remove live stream since everything is live ) -> Tuple[bool, str]: """ Executes restic with given command @@ -220,6 +230,7 @@ def executor( live_output=self.verbose, valid_exit_codes=errors_allowed, stdout=self._stdout, + stderr=self._stderr, stop_on=self.stop_on, on_exit=self.on_exit, method="poller", diff --git a/npbackup/translations/generic.en.yml b/npbackup/translations/generic.en.yml index 39555c4..f5580c7 100644 --- a/npbackup/translations/generic.en.yml +++ b/npbackup/translations/generic.en.yml @@ -7,9 +7,10 @@ en: options: Options create: Create change: Change + close: Close - _yes: Yes - _no: No + yes: Yes + no: No seconds: seconds minutes: minutes diff --git a/npbackup/translations/generic.fr.yml b/npbackup/translations/generic.fr.yml index 8bb7a49..43b0968 100644 --- a/npbackup/translations/generic.fr.yml +++ b/npbackup/translations/generic.fr.yml @@ -7,9 +7,10 @@ fr: options: Options create: Créer change: Changer + close: Fermer - _yes: Oui - _no: Non + yes: Oui + no: Non seconds: secondes minutes: minutes From bee0c0c8402f99354e84a0663d7e79a488c38297 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Wed, 20 Dec 2023 10:33:42 +0100 Subject: [PATCH 050/328] WIP queue mgmt and thread mgmt --- npbackup/core/runner.py | 34 ++++++++++++++++++---------------- npbackup/gui/operations.py | 18 +++++++++++++++--- 2 files changed, 33 insertions(+), 19 deletions(-) diff --git a/npbackup/core/runner.py b/npbackup/core/runner.py index 0741daa..24f7399 100644 --- a/npbackup/core/runner.py +++ b/npbackup/core/runner.py @@ -438,7 +438,7 @@ def apply_config_to_restic_runner(self) -> None: # ACTUAL RUNNER FUNCTIONS # ########################### - @close_queues + #@close_queues @exec_timer def list(self) -> Optional[dict]: if not self.is_ready: @@ -447,7 +447,7 @@ def list(self) -> Optional[dict]: snapshots = self.restic_runner.snapshots() return snapshots - @close_queues + #@close_queues @exec_timer def find(self, path: str) -> bool: if not self.is_ready: @@ -461,7 +461,7 @@ def find(self, path: str) -> bool: return True return False - @close_queues + #@close_queues @exec_timer def ls(self, snapshot: str) -> Optional[dict]: if not self.is_ready: @@ -470,7 +470,7 @@ def ls(self, snapshot: str) -> Optional[dict]: result = self.restic_runner.ls(snapshot) return result - @close_queues + #@close_queues @exec_timer def check_recent_backups(self) -> bool: """ @@ -503,7 +503,7 @@ def check_recent_backups(self) -> bool: logger.error("Cannot connect to repository or repository empty.") return result, backup_tz - @close_queues + #@close_queues @exec_timer def backup(self, force: bool = False) -> bool: """ @@ -661,7 +661,7 @@ def backup(self, force: bool = False) -> bool: ) return result - @close_queues + #@close_queues @exec_timer def restore(self, snapshot: str, target: str, restore_includes: List[str]) -> bool: if not self.is_ready: @@ -679,7 +679,7 @@ def restore(self, snapshot: str, target: str, restore_includes: List[str]) -> bo ) return result - @close_queues + #@close_queues @exec_timer def forget(self, snapshot: str) -> bool: if not self.is_ready: @@ -688,7 +688,7 @@ def forget(self, snapshot: str) -> bool: result = self.restic_runner.forget(snapshot) return result - @close_queues + #@close_queues @exec_timer def check(self, read_data: bool = True) -> bool: if not self.is_ready: @@ -697,7 +697,7 @@ def check(self, read_data: bool = True) -> bool: result = self.restic_runner.check(read_data) return result - @close_queues + #@close_queues @exec_timer def prune(self) -> bool: if not self.is_ready: @@ -706,7 +706,7 @@ def prune(self) -> bool: result = self.restic_runner.prune() return result - @close_queues + #@close_queues @exec_timer def repair(self, order: str) -> bool: if not self.is_ready: @@ -715,14 +715,14 @@ def repair(self, order: str) -> bool: result = self.restic_runner.repair(order) return result - @close_queues + #@close_queues @exec_timer def raw(self, command: str) -> bool: logger.info("Running raw command: {}".format(command)) result = self.restic_runner.raw(command=command) return result - @close_queues + #@close_queues @exec_timer def group_runner( self, repo_list: list, operation: str, **kwargs @@ -730,10 +730,10 @@ def group_runner( group_result = True # Make sure we don't close the stdout/stderr queues when running multiple operations - kwargs = { - **kwargs, - **{'close_queues': False} - } + #kwargs = { + # **kwargs, + # **{'close_queues': False} + #} for repo in repo_list: self.write_logs(f"Running {operation} for repo {repo}") @@ -744,4 +744,6 @@ def group_runner( self.write_logs(f"Operation {operation} failed for repo {repo}", error=True) group_result = False self.write_logs("Finished execution group operations") + from time import sleep + sleep(2) return group_result \ No newline at end of file diff --git a/npbackup/gui/operations.py b/npbackup/gui/operations.py index 7c152de..f481998 100644 --- a/npbackup/gui/operations.py +++ b/npbackup/gui/operations.py @@ -185,7 +185,11 @@ def operations_gui(full_config: dict) -> dict: if event == '--MAX-PRUNE--': operation = 'prune' op_args = {} - thread = runner.group_runner(group_runner_repo_list, operation, **op_args) + @threaded + def _group_runner(group_runner_repo_list, operation, **op_args) -> Future: + return runner.group_runner(group_runner_repo_list, operation, **op_args) + thread = _group_runner(group_runner_repo_list, operation, **op_args) + read_stdout_queue = True read_sterr_queue = True @@ -199,7 +203,8 @@ def operations_gui(full_config: dict) -> dict: progress_window = sg.Window("Operation status", progress_layout) event, values = progress_window.read(timeout=0.01) - while read_stdout_queue or read_sterr_queue: + #while read_stdout_queue or read_sterr_queue: + while not thread.done() and not thread.cancelled(): # Read stdout queue try: stdout_data = stdout_queue.get(timeout=0.01) @@ -222,7 +227,14 @@ def operations_gui(full_config: dict) -> dict: else: progress_window['-OPERATIONS-PROGRESS-STDERR-'].Update(f"{progress_window['-OPERATIONS-PROGRESS-STDERR-'].get()}\n{stderr_data}") - _, _ = progress_window.read() + # So we actually need to read the progress window for it to refresh... + _, _ = progress_window.read(.01) + + # Keep the window open until user has done something + while True: + event, _ = progress_window.read() + if event in (sg.WIN_CLOSED, sg.WIN_X_EVENT, '--EXIT--'): + break progress_window.close() event = '---STATE-UPDATE---' From 771550be76c5fccf2c5a4fd89386cc1f371cbe4b Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Wed, 20 Dec 2023 14:25:30 +0100 Subject: [PATCH 051/328] Add permission and ready decorator to functions --- npbackup/core/runner.py | 165 +++++++++++++++++++++++++-------------- npbackup/gui/__main__.py | 2 +- 2 files changed, 107 insertions(+), 60 deletions(-) diff --git a/npbackup/core/runner.py b/npbackup/core/runner.py index 24f7399..234055d 100644 --- a/npbackup/core/runner.py +++ b/npbackup/core/runner.py @@ -25,8 +25,7 @@ from npbackup.core.restic_source_binary import get_restic_internal_binary from npbackup.path_helper import CURRENT_DIR, BASEDIR from npbackup.__version__ import __intname__ as NAME, __version__ as VERSION -from npbackup import configuration - +from time import sleep logger = logging.getLogger() @@ -119,6 +118,7 @@ def __init__(self, repo_config: Optional[dict] = None): self._stdout = None self._stderr = None + self._is_ready = False if repo_config: self.repo_config = repo_config @@ -128,18 +128,16 @@ def __init__(self, repo_config: Optional[dict] = None): self.restic_runner = None self.minimum_backup_age = None self._exec_time = None - - self.is_ready = False + # Create an instance of restic wrapper self.create_restic_runner() # Configure that instance self.apply_config_to_restic_runner() - else: - self.is_ready = False + @property def backend_version(self) -> bool: - if self.is_ready: + if self._is_ready: return self.restic_runner.binary_version return None @@ -199,7 +197,7 @@ def stderr(self, value): @property def has_binary(self) -> bool: - if self.is_ready: + if self._is_ready: return True if self.restic_runner.binary else False return False @@ -211,6 +209,19 @@ def exec_time(self): def exec_time(self, value: int): self._exec_time = value + def write_logs(self, msg: str, error: bool=False): + """ + Write logs to log file and stdout / stderr queues if exist for GUI usage + """ + if error: + logger.error(msg) + if self.stderr: + self.stderr.put(msg) + else: + logger.info(msg) + if self.stdout: + self.stdout.put(msg) + # pylint does not understand why this function does not take a self parameter # It's a decorator, and the inner function will have the self argument instead # pylint: disable=no-self-argument @@ -219,29 +230,22 @@ def exec_timer(fn: Callable): Decorator that calculates time of a function execution """ + @wraps(fn) def wrapper(self, *args, **kwargs): start_time = datetime.utcnow() # pylint: disable=E1102 (not-callable) result = fn(self, *args, **kwargs) self.exec_time = (datetime.utcnow() - start_time).total_seconds() - logger.info("Runner took {} seconds".format(self.exec_time)) + logger.info(f"Runner took {self.exec_time} seconds for {fn.__name__}") return result return wrapper - - def write_logs(self, msg: str, error: bool=False): - logger.info(msg) - if error: - if self.stderr: - self.stderr.put(msg) - else: - if self.stdout: - self.stdout.put(msg) - + def close_queues(fn: Callable): """ - Function that sends None to both stdout and stderr queues so GUI gets proper results + Decorator that sends None to both stdout and stderr queues so GUI gets proper results """ + @wraps(fn) def wrapper(self, *args, **kwargs): close_queues = kwargs.pop("close_queues", True) result = fn(self, *args, **kwargs) @@ -253,6 +257,46 @@ def wrapper(self, *args, **kwargs): return result return wrapper + def is_ready(fn: Callable): + """" + Decorator that checks if NPBackupRunner is ready to run, and logs accordingly + """ + @wraps(fn) + def wrapper(self, *args, **kwargs): + if not self._is_ready: + self.write_logs(f"Runner cannot execute {fn.__name__}. Backend not ready", error=True) + return False + return fn(self, *args, **kwargs) + return wrapper + + def has_permission(fn: Callable): + """ + Decorator that checks permissions before running functions + """ + @wraps(fn) + def wrapper(self, *args, **kwargs): + required_permissions = { + "backup": ["backup", "restore", "full"], + "check_recent_backups": ["backup", "restore", "full"], + "list": ["backup", "restore", "full"], + "ls": ["backup", "restore", "full"], + "find": ["backup", "restore", "full"], + "restore": ["restore", "full"], + "check": ["restore", "full"], + "forget": ["full"], + "prune": ["full"], + "raw": ["full"] + } + try: + operation = fn.__name__ + # TODO: enforce permissions + self.write_logs(f"Permissions required are {required_permissions[operation]}") + except (IndexError, KeyError): + self.write_logs("You don't have sufficient permissions") + return False + return fn(self, *args, **kwargs) + return wrapper + def create_restic_runner(self) -> None: can_run = True try: @@ -303,7 +347,7 @@ def create_restic_runner(self) -> None: "No password nor password command given. Repo password cannot be empty" ) can_run = False - self.is_ready = can_run + self._is_ready = can_run if not can_run: return None self.restic_runner = ResticRunner( @@ -324,7 +368,8 @@ def create_restic_runner(self) -> None: self.restic_runner.binary = binary def apply_config_to_restic_runner(self) -> None: - if not self.is_ready: + if not self._is_ready: + self.write_logs("Runner settings cannot be applied to backend", error=True) return None try: if self.repo_config.g("repo_opts.upload_speed"): @@ -438,20 +483,20 @@ def apply_config_to_restic_runner(self) -> None: # ACTUAL RUNNER FUNCTIONS # ########################### - #@close_queues @exec_timer + @has_permission + @is_ready + @close_queues def list(self) -> Optional[dict]: - if not self.is_ready: - return False logger.info("Listing snapshots") snapshots = self.restic_runner.snapshots() return snapshots - #@close_queues @exec_timer + @has_permission + @is_ready + @close_queues def find(self, path: str) -> bool: - if not self.is_ready: - return False logger.info("Searching for path {}".format(path)) result = self.restic_runner.find(path=path) if result: @@ -461,25 +506,25 @@ def find(self, path: str) -> bool: return True return False - #@close_queues @exec_timer + @has_permission + @is_ready + @close_queues def ls(self, snapshot: str) -> Optional[dict]: - if not self.is_ready: - return False logger.info("Showing content of snapshot {}".format(snapshot)) result = self.restic_runner.ls(snapshot) return result - #@close_queues @exec_timer + @has_permission + @is_ready + @close_queues def check_recent_backups(self) -> bool: """ Checks for backups in timespan Returns True or False if found or not Returns None if no information is available """ - if not self.is_ready: - return None if self.minimum_backup_age == 0: logger.info("No minimal backup age set. Set for backup") @@ -503,14 +548,14 @@ def check_recent_backups(self) -> bool: logger.error("Cannot connect to repository or repository empty.") return result, backup_tz - #@close_queues @exec_timer + @has_permission + @is_ready + @close_queues def backup(self, force: bool = False) -> bool: """ Run backup after checking if no recent backup exists, unless force == True """ - if not self.is_ready: - return False # Preflight checks paths = self.repo_config.g("backup_opts.paths") if not paths: @@ -661,11 +706,11 @@ def backup(self, force: bool = False) -> bool: ) return result - #@close_queues @exec_timer + @has_permission + @is_ready + @close_queues def restore(self, snapshot: str, target: str, restore_includes: List[str]) -> bool: - if not self.is_ready: - return False if not self.repo_config.g("permissions") in ['restore', 'full']: msg = "You don't have permissions to restore this repo" self.output_queue.put(msg) @@ -679,50 +724,52 @@ def restore(self, snapshot: str, target: str, restore_includes: List[str]) -> bo ) return result - #@close_queues @exec_timer + @has_permission + @is_ready + @close_queues def forget(self, snapshot: str) -> bool: - if not self.is_ready: - return False logger.info("Forgetting snapshot {}".format(snapshot)) result = self.restic_runner.forget(snapshot) return result - #@close_queues @exec_timer + @has_permission + @is_ready + @close_queues def check(self, read_data: bool = True) -> bool: - if not self.is_ready: - return False self.write_logs("Checking repository") + sleep(1) result = self.restic_runner.check(read_data) return result - #@close_queues @exec_timer + @has_permission + @is_ready + @close_queues def prune(self) -> bool: - if not self.is_ready: - return False logger.info("Pruning snapshots") result = self.restic_runner.prune() return result - #@close_queues @exec_timer + @has_permission + @is_ready + @close_queues def repair(self, order: str) -> bool: - if not self.is_ready: - return False logger.info("Repairing {} in repo".format(order)) result = self.restic_runner.repair(order) return result - #@close_queues @exec_timer + @has_permission + @is_ready + @close_queues def raw(self, command: str) -> bool: logger.info("Running raw command: {}".format(command)) result = self.restic_runner.raw(command=command) return result - #@close_queues @exec_timer def group_runner( self, repo_list: list, operation: str, **kwargs @@ -730,10 +777,10 @@ def group_runner( group_result = True # Make sure we don't close the stdout/stderr queues when running multiple operations - #kwargs = { - # **kwargs, - # **{'close_queues': False} - #} + kwargs = { + **kwargs, + **{'close_queues': False} + } for repo in repo_list: self.write_logs(f"Running {operation} for repo {repo}") @@ -744,6 +791,6 @@ def group_runner( self.write_logs(f"Operation {operation} failed for repo {repo}", error=True) group_result = False self.write_logs("Finished execution group operations") - from time import sleep sleep(2) + self.close_queues(lambda *args, **kwargs: None, **kwargs) return group_result \ No newline at end of file diff --git a/npbackup/gui/__main__.py b/npbackup/gui/__main__.py index 0b7f279..b557553 100644 --- a/npbackup/gui/__main__.py +++ b/npbackup/gui/__main__.py @@ -178,7 +178,7 @@ def get_gui_data(repo_config: dict) -> Tuple[bool, List[str]]: except ValueError: sg.Popup(_t("config_gui.no_runner")) return None, None - if not runner.is_ready: + if not runner._is_ready: sg.Popup(_t("config_gui.runner_not_configured")) return None, None if not runner.has_binary: From bde8df791ec4a813d62de02700213d25dc2e1fed Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Wed, 20 Dec 2023 14:25:40 +0100 Subject: [PATCH 052/328] WIP: add loader animation --- npbackup/gui/operations.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/npbackup/gui/operations.py b/npbackup/gui/operations.py index f481998..ae055f7 100644 --- a/npbackup/gui/operations.py +++ b/npbackup/gui/operations.py @@ -198,6 +198,7 @@ def _group_runner(group_runner_repo_list, operation, **op_args) -> Future: [sg.Multiline(key='-OPERATIONS-PROGRESS-STDOUT-', size=(40, 10))], [sg.Text(_t("operations_gui.error_messages"))], [sg.Multiline(key='-OPERATIONS-PROGRESS-STDERR-', size=(40, 10))], + [sg.Image(LOADER_ANIMATION, key="-LOADER-ANIMATION-")], [sg.Button(_t("generic.close"), key="--EXIT--")] ] progress_window = sg.Window("Operation status", progress_layout) @@ -227,6 +228,7 @@ def _group_runner(group_runner_repo_list, operation, **op_args) -> Future: else: progress_window['-OPERATIONS-PROGRESS-STDERR-'].Update(f"{progress_window['-OPERATIONS-PROGRESS-STDERR-'].get()}\n{stderr_data}") + progress_window['-LOADER-ANIMATION-'].UpdateAnimation(LOADER_ANIMATION, time_between_frames=100) # So we actually need to read the progress window for it to refresh... _, _ = progress_window.read(.01) From b3a03a8fbdfe531602d8df27605daa53e508603d Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Wed, 20 Dec 2023 14:27:18 +0100 Subject: [PATCH 053/328] Reformat files with black --- npbackup/common.py | 2 +- npbackup/core/runner.py | 56 +++++++++++---------- npbackup/gui/__main__.py | 7 +-- npbackup/gui/operations.py | 75 +++++++++++++++++------------ npbackup/restic_wrapper/__init__.py | 7 +-- 5 files changed, 83 insertions(+), 64 deletions(-) diff --git a/npbackup/common.py b/npbackup/common.py index fd1041a..f5c35a8 100644 --- a/npbackup/common.py +++ b/npbackup/common.py @@ -56,4 +56,4 @@ def execution_logs(start_time: datetime) -> None: ) ) # using sys.exit(code) in a atexit function will swallow the exitcode and render 0 - # Using sys.exit(logger.get_worst_logger_level()) is the way to go, when using ofunctions.logger_utils >= 2.4.1 \ No newline at end of file + # Using sys.exit(logger.get_worst_logger_level()) is the way to go, when using ofunctions.logger_utils >= 2.4.1 diff --git a/npbackup/core/runner.py b/npbackup/core/runner.py index 234055d..9f9420b 100644 --- a/npbackup/core/runner.py +++ b/npbackup/core/runner.py @@ -128,13 +128,12 @@ def __init__(self, repo_config: Optional[dict] = None): self.restic_runner = None self.minimum_backup_age = None self._exec_time = None - + # Create an instance of restic wrapper self.create_restic_runner() # Configure that instance self.apply_config_to_restic_runner() - @property def backend_version(self) -> bool: if self._is_ready: @@ -209,7 +208,7 @@ def exec_time(self): def exec_time(self, value: int): self._exec_time = value - def write_logs(self, msg: str, error: bool=False): + def write_logs(self, msg: str, error: bool = False): """ Write logs to log file and stdout / stderr queues if exist for GUI usage """ @@ -245,6 +244,7 @@ def close_queues(fn: Callable): """ Decorator that sends None to both stdout and stderr queues so GUI gets proper results """ + @wraps(fn) def wrapper(self, *args, **kwargs): close_queues = kwargs.pop("close_queues", True) @@ -255,24 +255,31 @@ def wrapper(self, *args, **kwargs): if self.stderr: self.stderr.put(None) return result + return wrapper def is_ready(fn: Callable): - """" + """ " Decorator that checks if NPBackupRunner is ready to run, and logs accordingly """ + @wraps(fn) def wrapper(self, *args, **kwargs): - if not self._is_ready: - self.write_logs(f"Runner cannot execute {fn.__name__}. Backend not ready", error=True) - return False - return fn(self, *args, **kwargs) + if not self._is_ready: + self.write_logs( + f"Runner cannot execute {fn.__name__}. Backend not ready", + error=True, + ) + return False + return fn(self, *args, **kwargs) + return wrapper def has_permission(fn: Callable): """ Decorator that checks permissions before running functions """ + @wraps(fn) def wrapper(self, *args, **kwargs): required_permissions = { @@ -285,18 +292,21 @@ def wrapper(self, *args, **kwargs): "check": ["restore", "full"], "forget": ["full"], "prune": ["full"], - "raw": ["full"] + "raw": ["full"], } try: operation = fn.__name__ # TODO: enforce permissions - self.write_logs(f"Permissions required are {required_permissions[operation]}") + self.write_logs( + f"Permissions required are {required_permissions[operation]}" + ) except (IndexError, KeyError): self.write_logs("You don't have sufficient permissions") return False return fn(self, *args, **kwargs) + return wrapper - + def create_restic_runner(self) -> None: can_run = True try: @@ -475,9 +485,8 @@ def apply_config_to_restic_runner(self) -> None: self.restic_runner.verbose = self.verbose # TODO - #self.restic_runner.stdout = self.stdout - #self.restic_runner.stderr = self.stderr - + # self.restic_runner.stdout = self.stdout + # self.restic_runner.stderr = self.stderr ########################### # ACTUAL RUNNER FUNCTIONS # @@ -711,7 +720,7 @@ def backup(self, force: bool = False) -> bool: @is_ready @close_queues def restore(self, snapshot: str, target: str, restore_includes: List[str]) -> bool: - if not self.repo_config.g("permissions") in ['restore', 'full']: + if not self.repo_config.g("permissions") in ["restore", "full"]: msg = "You don't have permissions to restore this repo" self.output_queue.put(msg) logger.critical(msg) @@ -771,16 +780,11 @@ def raw(self, command: str) -> bool: return result @exec_timer - def group_runner( - self, repo_list: list, operation: str, **kwargs - ) -> bool: + def group_runner(self, repo_list: list, operation: str, **kwargs) -> bool: group_result = True # Make sure we don't close the stdout/stderr queues when running multiple operations - kwargs = { - **kwargs, - **{'close_queues': False} - } + kwargs = {**kwargs, **{"close_queues": False}} for repo in repo_list: self.write_logs(f"Running {operation} for repo {repo}") @@ -788,9 +792,11 @@ def group_runner( if result: self.write_logs(f"Finished {operation} for repo {repo}") else: - self.write_logs(f"Operation {operation} failed for repo {repo}", error=True) + self.write_logs( + f"Operation {operation} failed for repo {repo}", error=True + ) group_result = False - self.write_logs("Finished execution group operations") + self.write_logs("Finished execution group operations") sleep(2) self.close_queues(lambda *args, **kwargs: None, **kwargs) - return group_result \ No newline at end of file + return group_result diff --git a/npbackup/gui/__main__.py b/npbackup/gui/__main__.py index b557553..5efe635 100644 --- a/npbackup/gui/__main__.py +++ b/npbackup/gui/__main__.py @@ -57,7 +57,6 @@ ) - LOG_FILE = os.path.join(CURRENT_DIR, "{}.log".format(__intname__)) logger = ofunctions.logger_utils.logger_get_logger(LOG_FILE) @@ -455,7 +454,7 @@ def _close_win(): Since closing a sg.Treedata takes alot of time, let's thread it into background """ window.close - + _close_win() return True @@ -655,7 +654,9 @@ def _main_gui(): ) ], [ - sg.Button(_t("main_gui.launch_backup"), key="--LAUNCH-BACKUP--"), + sg.Button( + _t("main_gui.launch_backup"), key="--LAUNCH-BACKUP--" + ), sg.Button(_t("main_gui.see_content"), key="--SEE-CONTENT--"), sg.Button(_t("generic.forget"), key="--FORGET--"), sg.Button(_t("main_gui.operations"), key="--OPERATIONS--"), diff --git a/npbackup/gui/operations.py b/npbackup/gui/operations.py index ae055f7..2e306ba 100644 --- a/npbackup/gui/operations.py +++ b/npbackup/gui/operations.py @@ -47,9 +47,7 @@ def gui_update_state(window, full_config: dict) -> list: repo_config.g(f"repo_opts.repo_password") or repo_config.g(f"repo_opts.repo_password_command") ): - backend_type, repo_uri = get_anon_repo_uri( - repo_config.g(f"repo_uri") - ) + backend_type, repo_uri = get_anon_repo_uri(repo_config.g(f"repo_uri")) repo_list.append([backend_type, repo_uri]) else: logger.warning("Incomplete operations repo {}".format(repo_name)) @@ -96,8 +94,12 @@ def operations_gui(full_config: dict) -> dict: ) ], [ - sg.Button(_t("operations_gui.quick_check"), key="--QUICK-CHECK--"), - sg.Button(_t("operations_gui.full_check"), key="--FULL-CHECK--"), + sg.Button( + _t("operations_gui.quick_check"), key="--QUICK-CHECK--" + ), + sg.Button( + _t("operations_gui.full_check"), key="--FULL-CHECK--" + ), ], [ sg.Button( @@ -107,7 +109,8 @@ def operations_gui(full_config: dict) -> dict: ], [ sg.Button( - _t("operations_gui.standard_prune"), key="--STANDARD-PRUNE--" + _t("operations_gui.standard_prune"), + key="--STANDARD-PRUNE--", ), sg.Button(_t("operations_gui.max_prune"), key="--MAX-PRUNE--"), ], @@ -145,7 +148,7 @@ def operations_gui(full_config: dict) -> dict: while True: event, values = window.read(timeout=60000) - if event in (sg.WIN_CLOSED, sg.WIN_X_EVENT, '--EXIT--'): + if event in (sg.WIN_CLOSED, sg.WIN_X_EVENT, "--EXIT--"): break if event in [ "--FORGET--", @@ -164,30 +167,32 @@ def operations_gui(full_config: dict) -> dict: repos = complete_repo_list else: repos = values["repo-list"] - + runner = NPBackupRunner() runner.stdout = stdout_queue runner.stderr = stderr_queue group_runner_repo_list = [repo_name for backend_type, repo_name in repos] - if event == '--FORGET--': - operation = 'forget' + if event == "--FORGET--": + operation = "forget" op_args = {} - if event == '--QUICK-CHECK--': - operation = 'check' - op_args = {'read_data': False} - if event == '--FULL-CHECK--': - operation = 'check' - op_args = {'read_data': True} - if event == '--STANDARD-PRUNE--': - operation = 'prune' + if event == "--QUICK-CHECK--": + operation = "check" + op_args = {"read_data": False} + if event == "--FULL-CHECK--": + operation = "check" + op_args = {"read_data": True} + if event == "--STANDARD-PRUNE--": + operation = "prune" op_args = {} - if event == '--MAX-PRUNE--': - operation = 'prune' + if event == "--MAX-PRUNE--": + operation = "prune" op_args = {} + @threaded def _group_runner(group_runner_repo_list, operation, **op_args) -> Future: return runner.group_runner(group_runner_repo_list, operation, **op_args) + thread = _group_runner(group_runner_repo_list, operation, **op_args) read_stdout_queue = True @@ -195,16 +200,16 @@ def _group_runner(group_runner_repo_list, operation, **op_args) -> Future: progress_layout = [ [sg.Text(_t("operations_gui.last_message"))], - [sg.Multiline(key='-OPERATIONS-PROGRESS-STDOUT-', size=(40, 10))], + [sg.Multiline(key="-OPERATIONS-PROGRESS-STDOUT-", size=(40, 10))], [sg.Text(_t("operations_gui.error_messages"))], - [sg.Multiline(key='-OPERATIONS-PROGRESS-STDERR-', size=(40, 10))], + [sg.Multiline(key="-OPERATIONS-PROGRESS-STDERR-", size=(40, 10))], [sg.Image(LOADER_ANIMATION, key="-LOADER-ANIMATION-")], - [sg.Button(_t("generic.close"), key="--EXIT--")] + [sg.Button(_t("generic.close"), key="--EXIT--")], ] progress_window = sg.Window("Operation status", progress_layout) event, values = progress_window.read(timeout=0.01) - #while read_stdout_queue or read_sterr_queue: + # while read_stdout_queue or read_sterr_queue: while not thread.done() and not thread.cancelled(): # Read stdout queue try: @@ -215,8 +220,10 @@ def _group_runner(group_runner_repo_list, operation, **op_args) -> Future: if stdout_data is None: read_stdout_queue = False else: - progress_window['-OPERATIONS-PROGRESS-STDOUT-'].Update(stdout_data) - + progress_window["-OPERATIONS-PROGRESS-STDOUT-"].Update( + stdout_data + ) + # Read stderr queue try: stderr_data = stderr_queue.get(timeout=0.01) @@ -226,20 +233,24 @@ def _group_runner(group_runner_repo_list, operation, **op_args) -> Future: if stderr_data is None: read_sterr_queue = False else: - progress_window['-OPERATIONS-PROGRESS-STDERR-'].Update(f"{progress_window['-OPERATIONS-PROGRESS-STDERR-'].get()}\n{stderr_data}") + progress_window["-OPERATIONS-PROGRESS-STDERR-"].Update( + f"{progress_window['-OPERATIONS-PROGRESS-STDERR-'].get()}\n{stderr_data}" + ) - progress_window['-LOADER-ANIMATION-'].UpdateAnimation(LOADER_ANIMATION, time_between_frames=100) + progress_window["-LOADER-ANIMATION-"].UpdateAnimation( + LOADER_ANIMATION, time_between_frames=100 + ) # So we actually need to read the progress window for it to refresh... - _, _ = progress_window.read(.01) - + _, _ = progress_window.read(0.01) + # Keep the window open until user has done something while True: event, _ = progress_window.read() - if event in (sg.WIN_CLOSED, sg.WIN_X_EVENT, '--EXIT--'): + if event in (sg.WIN_CLOSED, sg.WIN_X_EVENT, "--EXIT--"): break progress_window.close() - event = '---STATE-UPDATE---' + event = "---STATE-UPDATE---" if event == "---STATE-UPDATE---": complete_repo_list = gui_update_state(window, full_config) window.close() diff --git a/npbackup/restic_wrapper/__init__.py b/npbackup/restic_wrapper/__init__.py index c5108a0..85c7776 100644 --- a/npbackup/restic_wrapper/__init__.py +++ b/npbackup/restic_wrapper/__init__.py @@ -35,7 +35,6 @@ def __init__( repository: str, password: str, binary_search_paths: List[str] = None, - ) -> None: self.repository = str(repository).strip() self.password = str(password).strip() @@ -78,7 +77,9 @@ def __init__( None # Function which will make executor abort if result is True ) self._executor_finished = False # Internal value to check whether executor is done, accessed via self.executor_finished property - self._stdout = None # Optional outputs when running GUI, to get interactive output + self._stdout = ( + None # Optional outputs when running GUI, to get interactive output + ) self._stderr = None def on_exit(self) -> bool: @@ -201,7 +202,7 @@ def executor( cmd: str, errors_allowed: bool = False, timeout: int = None, - live_stream=False, # TODO remove live stream since everything is live + live_stream=False, # TODO remove live stream since everything is live ) -> Tuple[bool, str]: """ Executes restic with given command From 9733e4e26b17e642f690117357d915b0c65a260c Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Wed, 20 Dec 2023 19:28:21 +0100 Subject: [PATCH 054/328] WIP: Refactor runner into decorator oblivion --- npbackup/configuration.py | 3 +- npbackup/core/runner.py | 273 +++++++++++--------- npbackup/gui/__main__.py | 22 +- npbackup/gui/operations.py | 51 +++- npbackup/translations/operations_gui.en.yml | 2 + npbackup/translations/operations_gui.fr.yml | 2 + 6 files changed, 213 insertions(+), 140 deletions(-) diff --git a/npbackup/configuration.py b/npbackup/configuration.py index bf519fe..bfeb318 100644 --- a/npbackup/configuration.py +++ b/npbackup/configuration.py @@ -428,7 +428,7 @@ def _inherit_group_settings( _config_inheritance.s(key, False) return _repo_config, _config_inheritance - + return _inherit_group_settings(_repo_config, _group_config, _config_inheritance) try: @@ -443,6 +443,7 @@ def _inherit_group_settings( except KeyError: logger.warning(f"Repo {repo_name} has no group") else: + repo_config.s("name", repo_name) repo_config, config_inheritance = inherit_group_settings( repo_config, group_config ) diff --git a/npbackup/core/runner.py b/npbackup/core/runner.py index 9f9420b..f061fe0 100644 --- a/npbackup/core/runner.py +++ b/npbackup/core/runner.py @@ -17,6 +17,7 @@ from datetime import datetime, timedelta from functools import wraps import queue +from copy import deepcopy from command_runner import command_runner from ofunctions.threading import threaded from ofunctions.platform import os_arch @@ -29,7 +30,6 @@ logger = logging.getLogger() - def metric_writer( repo_config: dict, restic_result: bool, result_string: str, dry_run: bool ): @@ -114,25 +114,35 @@ class NPBackupRunner: # NPF-SEC-00002: password commands, pre_exec and post_exec commands will be executed with npbackup privileges # This can lead to a problem when the config file can be written by users other than npbackup - def __init__(self, repo_config: Optional[dict] = None): + def __init__(self): self._stdout = None self._stderr = None self._is_ready = False - if repo_config: - self.repo_config = repo_config - self._dry_run = False - self._verbose = False - self._stdout = None - self.restic_runner = None - self.minimum_backup_age = None - self._exec_time = None + self._repo_config = None + + self._dry_run = False + self._verbose = False + self._stdout = None + self.restic_runner = None + self.minimum_backup_age = None + self._exec_time = None + + self._using_dev_binary = False - # Create an instance of restic wrapper - self.create_restic_runner() - # Configure that instance - self.apply_config_to_restic_runner() + + @property + def repo_config(self) -> dict: + return self._repo_config + + @repo_config.setter + def repo_config(self, value: dict): + if not isinstance(value, dict): + raise ValueError(f"Bogus repo config given: {value}") + self._repo_config = deepcopy(value) + # Create an instance of restic wrapper + self.create_restic_runner() @property def backend_version(self) -> bool: @@ -149,7 +159,6 @@ def dry_run(self, value): if not isinstance(value, bool): raise ValueError("Bogus dry_run parameter given: {}".format(value)) self._dry_run = value - self.apply_config_to_restic_runner() @property def verbose(self): @@ -160,7 +169,6 @@ def verbose(self, value): if not isinstance(value, bool): raise ValueError("Bogus verbose parameter given: {}".format(value)) self._verbose = value - self.apply_config_to_restic_runner() @property def stdout(self): @@ -176,7 +184,6 @@ def stdout(self, value): ): raise ValueError("Bogus stdout parameter given: {}".format(value)) self._stdout = value - self.apply_config_to_restic_runner() @property def stderr(self): @@ -192,7 +199,6 @@ def stderr(self, value): ): raise ValueError("Bogus stdout parameter given: {}".format(value)) self._stderr = value - self.apply_config_to_restic_runner() @property def has_binary(self) -> bool: @@ -208,19 +214,28 @@ def exec_time(self): def exec_time(self, value: int): self._exec_time = value - def write_logs(self, msg: str, error: bool = False): + def write_logs(self, msg: str, level: str = None): """ Write logs to log file and stdout / stderr queues if exist for GUI usage """ - if error: + if level == 'warning': + logger.warning(msg) + if self.stderr: + self.stderr.put(msg) + elif level == 'error': logger.error(msg) if self.stderr: self.stderr.put(msg) + elif level == 'critical': + logger.critical(msg) + if self.stderr: + self.stderr.put(msg) else: logger.info(msg) if self.stdout: self.stdout.put(msg) + # pylint does not understand why this function does not take a self parameter # It's a decorator, and the inner function will have the self argument instead # pylint: disable=no-self-argument @@ -235,7 +250,7 @@ def wrapper(self, *args, **kwargs): # pylint: disable=E1102 (not-callable) result = fn(self, *args, **kwargs) self.exec_time = (datetime.utcnow() - start_time).total_seconds() - logger.info(f"Runner took {self.exec_time} seconds for {fn.__name__}") + self.write_logs(f"Runner took {self.exec_time} seconds for {fn.__name__}") return result return wrapper @@ -268,7 +283,7 @@ def wrapper(self, *args, **kwargs): if not self._is_ready: self.write_logs( f"Runner cannot execute {fn.__name__}. Backend not ready", - error=True, + level="error", ) return False return fn(self, *args, **kwargs) @@ -290,6 +305,7 @@ def wrapper(self, *args, **kwargs): "find": ["backup", "restore", "full"], "restore": ["restore", "full"], "check": ["restore", "full"], + "repair": ["full"], "forget": ["full"], "prune": ["full"], "raw": ["full"], @@ -301,11 +317,23 @@ def wrapper(self, *args, **kwargs): f"Permissions required are {required_permissions[operation]}" ) except (IndexError, KeyError): - self.write_logs("You don't have sufficient permissions") + self.write_logs("You don't have sufficient permissions", level="error") return False return fn(self, *args, **kwargs) return wrapper + + def apply_config_to_restic_runner(fn: Callable): + """ + Decorator to update backend before every run + """ + + @wraps(fn) + def wrapper(self, *args, **kwargs): + if not self._apply_config_to_restic_runner(): + return False + return fn(self, *args, **kwargs) + return wrapper def create_restic_runner(self) -> None: can_run = True @@ -314,12 +342,12 @@ def create_restic_runner(self) -> None: if not repository: raise KeyError except (KeyError, AttributeError): - logger.error("Repo cannot be empty") + self.write_logs("Repo cannot be empty", level="error") can_run = False try: password = self.repo_config.g("repo_opts.repo_password") except (KeyError, AttributeError): - logger.error("Repo password cannot be empty") + self.write_logs("Repo password cannot be empty", level="error") can_run = False if not password or password == "": try: @@ -334,27 +362,25 @@ def create_restic_runner(self) -> None: ) cr_logger.setLevel(cr_loglevel) if exit_code != 0 or output == "": - logger.error( - "Password command failed to produce output:\n{}".format( - output - ) + self.write_logs( + f"Password command failed to produce output:\n{output}", level="error" ) can_run = False elif "\n" in output.strip(): - logger.error( - "Password command returned multiline content instead of a string" + self.write_logs( + "Password command returned multiline content instead of a string", level="error" ) can_run = False else: password = output else: - logger.error( - "No password nor password command given. Repo password cannot be empty" + self.write_logs( + "No password nor password command given. Repo password cannot be empty", level="error" ) can_run = False except KeyError: - logger.error( - "No password nor password command given. Repo password cannot be empty" + self.write_logs( + "No password nor password command given. Repo password cannot be empty", level="error" ) can_run = False self._is_ready = can_run @@ -366,21 +392,20 @@ def create_restic_runner(self) -> None: binary_search_paths=[BASEDIR, CURRENT_DIR], ) - self.restic_runner.stdout = self.stdout - self.restic_runner.stderr = self.stderr - if self.restic_runner.binary is None: # Let's try to load our internal binary for dev purposes arch = os_arch() binary = get_restic_internal_binary(arch) if binary: - logger.info("Using dev binary !") + if not self._using_dev_binary: + self._using_dev_binary = True + self.write_logs("Using dev binary !", level='warning') self.restic_runner.binary = binary - def apply_config_to_restic_runner(self) -> None: - if not self._is_ready: - self.write_logs("Runner settings cannot be applied to backend", error=True) - return None + def _apply_config_to_restic_runner(self) -> bool: + if not isinstance(self.restic_runner, ResticRunner): + self.write_logs("Backend not ready", level="error") + return False try: if self.repo_config.g("repo_opts.upload_speed"): self.restic_runner.limit_upload = self.repo_config.g( @@ -389,7 +414,7 @@ def apply_config_to_restic_runner(self) -> None: except KeyError: pass except ValueError: - logger.error("Bogus upload limit given.") + self.write_logs("Bogus upload limit given.", level="error") try: if self.repo_config.g("repo_opts.download_speed"): self.restic_runner.limit_download = self.repo_config.g( @@ -398,7 +423,7 @@ def apply_config_to_restic_runner(self) -> None: except KeyError: pass except ValueError: - logger.error("Bogus download limit given.") + self.write_logs("Bogus download limit given.", level="error") try: if self.repo_config.g("repo_opts.backend_connections"): self.restic_runner.backend_connections = self.repo_config.g( @@ -407,14 +432,14 @@ def apply_config_to_restic_runner(self) -> None: except KeyError: pass except ValueError: - logger.error("Bogus backend connections value given.") + self.write_logs("Bogus backend connections value given.", level="erorr") try: if self.repo_config.g("backup_opts.priority"): self.restic_runner.priority = self.repo_config.g("backup_opts.priority") except KeyError: pass except ValueError: - logger.warning("Bogus backup priority in config file.") + self.write_logs("Bogus backup priority in config file.", level="warning") try: if self.repo_config.g("backup_opts.ignore_cloud_files"): self.restic_runner.ignore_cloud_files = self.repo_config.g( @@ -423,7 +448,7 @@ def apply_config_to_restic_runner(self) -> None: except KeyError: pass except ValueError: - logger.warning("Bogus ignore_cloud_files value given") + self.write_logs("Bogus ignore_cloud_files value given", level="warning") try: if self.repo_config.g("backup_opts.additional_parameters"): @@ -433,7 +458,7 @@ def apply_config_to_restic_runner(self) -> None: except KeyError: pass except ValueError: - logger.warning("Bogus additional parameters given") + self.write_logs("Bogus additional parameters given", level="warning") self.restic_runner.stdout = self.stdout try: @@ -462,19 +487,17 @@ def apply_config_to_restic_runner(self) -> None: value = os.path.expandvars(value) expanded_env_vars[key.strip()] = value.strip() except ValueError: - logger.error( - 'Bogus environment variable "{}" defined in configuration.'.format( - env_variable - ) + self.write_logs( + f'Bogus environment variable "{env_variable}" defined in configuration.', level="error" ) except (KeyError, AttributeError, TypeError): - logger.error("Bogus environment variables defined in configuration.") - logger.debug("Trace:", exc_info=True) + self.write_logs("Bogus environment variables defined in configuration.", level="error") + logger.error("Trace:", exc_info=True) try: self.restic_runner.environment_variables = expanded_env_vars except ValueError: - logger.error("Cannot initialize additional environment variables") + self.write_logs("Cannot initialize additional environment variables", level="error") try: self.minimum_backup_age = int( @@ -484,9 +507,10 @@ def apply_config_to_restic_runner(self) -> None: self.minimum_backup_age = 1440 self.restic_runner.verbose = self.verbose - # TODO - # self.restic_runner.stdout = self.stdout - # self.restic_runner.stderr = self.stderr + self.restic_runner.stdout = self.stdout + self.restic_runner.stderr = self.stderr + + return True ########################### # ACTUAL RUNNER FUNCTIONS # @@ -495,38 +519,42 @@ def apply_config_to_restic_runner(self) -> None: @exec_timer @has_permission @is_ready + @apply_config_to_restic_runner @close_queues def list(self) -> Optional[dict]: - logger.info("Listing snapshots") + self.write_logs(f"Listing snapshots of repo {self.repo_config.g('name')}", level="error") snapshots = self.restic_runner.snapshots() return snapshots @exec_timer @has_permission @is_ready + @apply_config_to_restic_runner @close_queues def find(self, path: str) -> bool: - logger.info("Searching for path {}".format(path)) + self.write_logs(f"Searching for path {path} in repo {self.repo_config.g('name')}", level="error") result = self.restic_runner.find(path=path) if result: - logger.info("Found path in:\n") + self.write_logs("Found path in:\n") for line in result: - logger.info(line) + self.write_logs(line) return True return False @exec_timer @has_permission @is_ready + @apply_config_to_restic_runner @close_queues def ls(self, snapshot: str) -> Optional[dict]: - logger.info("Showing content of snapshot {}".format(snapshot)) + self.write_logs(f"Showing content of snapshot {snapshot} in repo {self.repo_config.g('name')}") result = self.restic_runner.ls(snapshot) return result @exec_timer @has_permission @is_ready + @apply_config_to_restic_runner @close_queues def check_recent_backups(self) -> bool: """ @@ -535,12 +563,10 @@ def check_recent_backups(self) -> bool: Returns None if no information is available """ if self.minimum_backup_age == 0: - logger.info("No minimal backup age set. Set for backup") + self.write_logs("No minimal backup age set. Set for backup") - logger.info( - "Searching for a backup newer than {} ago".format( - str(timedelta(minutes=self.minimum_backup_age)) - ) + self.write_logs( + f"Searching for a backup newer than {str(timedelta(minutes=self.minimum_backup_age))} ago" ) self.restic_runner.verbose = False result, backup_tz = self.restic_runner.has_snapshot_timedelta( @@ -548,18 +574,19 @@ def check_recent_backups(self) -> bool: ) self.restic_runner.verbose = self.verbose if result: - logger.info("Most recent backup is from {}".format(backup_tz)) + self.write_logs(f"Most recent backup in repo {self.repo_config.g('name')} is from {backup_tz}") elif result is False and backup_tz == datetime(1, 1, 1, 0, 0): - logger.info("No snapshots found in repo.") + self.write_logs(f"No snapshots found in repo {self.repo_config.g('name')}.") elif result is False: - logger.info("No recent backup found. Newest is from {}".format(backup_tz)) + self.write_logs(f"No recent backup found in repo {self.repo_config.g('name')}. Newest is from {backup_tz}") elif result is None: - logger.error("Cannot connect to repository or repository empty.") + self.write_logs("Cannot connect to repository or repository empty.", level="error") return result, backup_tz @exec_timer @has_permission @is_ready + @apply_config_to_restic_runner @close_queues def backup(self, force: bool = False) -> bool: """ @@ -568,7 +595,7 @@ def backup(self, force: bool = False) -> bool: # Preflight checks paths = self.repo_config.g("backup_opts.paths") if not paths: - logger.error("No backup paths defined.") + self.write_logs(f"No paths to backup defined for repo {self.repo_config.g('name')}.", level="error") return False # Make sure we convert paths to list if only one path is give @@ -579,12 +606,12 @@ def backup(self, force: bool = False) -> bool: paths = [path.strip() for path in paths] for path in paths: if path == self.repo_config.g("repo_uri"): - logger.critical( - "You cannot backup source into it's own path. No inception allowed !" + self.write_logs( + f"You cannot backup source into it's own path in repo {self.repo_config.g('name')}. No inception allowed !", level='critical' ) return False except KeyError: - logger.error("No backup source given.") + self.write_logs(f"No backup source given for repo {self.repo_config.g('name')}.", level='error') return False exclude_patterns_source_type = self.repo_config.g( @@ -643,18 +670,18 @@ def backup(self, force: bool = False) -> bool: self.restic_runner.verbose = False if not self.restic_runner.is_init: if not self.restic_runner.init(): - logger.error("Cannot continue.") + self.write_logs(f"Cannot continue, repo {self.repo_config.g('name')} is not defined.", level="critical") return False if self.check_recent_backups() and not force: - logger.info("No backup necessary.") + self.write_logs("No backup necessary.") return True self.restic_runner.verbose = self.verbose # Run backup here if exclude_patterns_source_type not in ["folder_list", None]: - logger.info("Running backup of files in {} list".format(paths)) + self.write_logs(f"Running backup of files in {paths} list to repo {self.repo_config.g('name')}") else: - logger.info("Running backup of {}".format(paths)) + self.write_logs(f"Running backup of {paths} to repo {self.repo_config.g('name')}") if pre_exec_commands: for pre_exec_command in pre_exec_commands: @@ -662,18 +689,14 @@ def backup(self, force: bool = False) -> bool: pre_exec_command, shell=True, timeout=pre_exec_per_command_timeout ) if exit_code != 0: - logger.error( - "Pre-execution of command {} failed with:\n{}".format( - pre_exec_command, output - ) + self.write_logs( + f"Pre-execution of command {pre_exec_command} failed with:\n{output}", level="error" ) if pre_exec_failure_is_fatal: return False else: - logger.info( - "Pre-execution of command {} success with:\n{}.".format( - pre_exec_command, output - ) + self.write_logs( + "Pre-execution of command {pre_exec_command} success with:\n{output}." ) self.restic_runner.dry_run = self.dry_run @@ -689,7 +712,7 @@ def backup(self, force: bool = False) -> bool: tags=tags, additional_backup_only_parameters=additional_backup_only_parameters, ) - logger.debug("Restic output:\n{}".format(result_string)) + logger.debug(f"Restic output:\n{result_string}") metric_writer( self.repo_config, result, result_string, self.restic_runner.dry_run ) @@ -700,32 +723,27 @@ def backup(self, force: bool = False) -> bool: post_exec_command, shell=True, timeout=post_exec_per_command_timeout ) if exit_code != 0: - logger.error( - "Post-execution of command {} failed with:\n{}".format( - post_exec_command, output - ) + self.write_logs( + f"Post-execution of command {post_exec_command} failed with:\n{output}", level="error" ) if post_exec_failure_is_fatal: return False else: - logger.info( - "Post-execution of command {} success with:\n{}.".format( - post_exec_command, output - ) + self.write_logs( + "Post-execution of command {post_exec_command} success with:\n{output}.", level="error" ) return result @exec_timer @has_permission @is_ready + @apply_config_to_restic_runner @close_queues def restore(self, snapshot: str, target: str, restore_includes: List[str]) -> bool: if not self.repo_config.g("permissions") in ["restore", "full"]: - msg = "You don't have permissions to restore this repo" - self.output_queue.put(msg) - logger.critical(msg) + self.write_logs(f"You don't have permissions to restore repo {self.repo_config.g('name')}", level="error") return False - logger.info("Launching restore to {}".format(target)) + self.write_logs(f"Launching restore to {target}") result = self.restic_runner.restore( snapshot=snapshot, target=target, @@ -736,18 +754,24 @@ def restore(self, snapshot: str, target: str, restore_includes: List[str]) -> bo @exec_timer @has_permission @is_ready + @apply_config_to_restic_runner @close_queues def forget(self, snapshot: str) -> bool: - logger.info("Forgetting snapshot {}".format(snapshot)) + self.write_logs(f"Forgetting snapshot {snapshot}") result = self.restic_runner.forget(snapshot) return result @exec_timer @has_permission @is_ready + @apply_config_to_restic_runner + @threaded @close_queues def check(self, read_data: bool = True) -> bool: - self.write_logs("Checking repository") + if read_data: + self.write_logs(f"Running full data check of repository {self.repo_config.g('name')}") + else: + self.write_logs(f"Running metadata consistency check of repository {self.repo_config.g('name')}") sleep(1) result = self.restic_runner.check(read_data) return result @@ -755,48 +779,63 @@ def check(self, read_data: bool = True) -> bool: @exec_timer @has_permission @is_ready + @apply_config_to_restic_runner @close_queues def prune(self) -> bool: - logger.info("Pruning snapshots") + self.write_logs(f"Pruning snapshots for repo {self.repo_config.g('name')}") result = self.restic_runner.prune() return result @exec_timer @has_permission @is_ready + @apply_config_to_restic_runner @close_queues - def repair(self, order: str) -> bool: - logger.info("Repairing {} in repo".format(order)) - result = self.restic_runner.repair(order) + def repair(self, subject: str) -> bool: + self.write_logs(f"Repairing {subject} in repo {self.repo_config.g('name')}") + result = self.restic_runner.repair(subject) return result @exec_timer @has_permission @is_ready + @apply_config_to_restic_runner @close_queues def raw(self, command: str) -> bool: - logger.info("Running raw command: {}".format(command)) + self.write_logs(f"Running raw command: {command}") result = self.restic_runner.raw(command=command) return result @exec_timer - def group_runner(self, repo_list: list, operation: str, **kwargs) -> bool: + def group_runner(self, repo_config_list: list, operation: str, **kwargs) -> bool: group_result = True # Make sure we don't close the stdout/stderr queues when running multiple operations - kwargs = {**kwargs, **{"close_queues": False}} + # Also make sure we don't thread functions + kwargs = { + **kwargs, + **{ + "close_queues": False, + #"__no_threads": True, + } + } - for repo in repo_list: - self.write_logs(f"Running {operation} for repo {repo}") + for repo_name, repo_config in repo_config_list: + self.write_logs(f"Running {operation} for repo {repo_name}") + self.repo_config = repo_config result = self.__getattribute__(operation)(**kwargs) if result: - self.write_logs(f"Finished {operation} for repo {repo}") + self.write_logs(f"Finished {operation} for repo {repo_name}") else: self.write_logs( - f"Operation {operation} failed for repo {repo}", error=True + f"Operation {operation} failed for repo {repo_name}", level="error" ) group_result = False self.write_logs("Finished execution group operations") - sleep(2) - self.close_queues(lambda *args, **kwargs: None, **kwargs) + # Manually close the queues at the end + if self.stdout: + self.stdout.put(None) + if self.stderr: + self.stderr.put(None) + #sleep(1) # TODO this is arbitrary to allow queues to be read entirely return group_result diff --git a/npbackup/gui/__main__.py b/npbackup/gui/__main__.py index 5efe635..17c356e 100644 --- a/npbackup/gui/__main__.py +++ b/npbackup/gui/__main__.py @@ -131,7 +131,8 @@ def _about_gui(version_string: str, full_config: dict) -> None: @threaded def _get_gui_data(repo_config: dict) -> Future: - runner = NPBackupRunner(repo_config=repo_config) + runner = NPBackupRunner() + runner.repo_config = repo_config snapshots = runner.list() current_state, backup_tz = runner.check_recent_backups() snapshot_list = [] @@ -173,9 +174,10 @@ def get_gui_data(repo_config: dict) -> Tuple[bool, List[str]]: sg.Popup(_t("main_gui.repository_not_configured")) return None, None try: - runner = NPBackupRunner(repo_config=repo_config) - except ValueError: - sg.Popup(_t("config_gui.no_runner")) + runner = NPBackupRunner() + runner.repo_config = repo_config + except ValueError as exc: + sg.Popup(f'{_t("config_gui.no_runner")}: {exc}') return None, None if not runner._is_ready: sg.Popup(_t("config_gui.runner_not_configured")) @@ -283,14 +285,16 @@ def _make_treedata_from_json(ls_result: List[dict]) -> sg.TreeData: @threaded def _forget_snapshot(repo_config: dict, snapshot_id: str) -> Future: - runner = NPBackupRunner(repo_config=repo_config) + runner = NPBackupRunner() + runner.repo_config = repo_config result = runner.forget(snapshot=snapshot_id) return result @threaded def _ls_window(repo_config: dict, snapshot_id: str) -> Future: - runner = NPBackupRunner(repo_config=repo_config) + runner = NPBackupRunner() + runner.repo_config = repo_config result = runner.ls(snapshot=snapshot_id) if not result: return result, None @@ -464,7 +468,8 @@ def _close_win(): def _restore_window( repo_config: dict, snapshot: str, target: str, restore_includes: Optional[List] ) -> Future: - runner = NPBackupRunner(repo_config=repo_config) + runner = NPBackupRunner() + runner.repo_config = repo_config runner.verbose = True result = runner.restore(snapshot, target, restore_includes) THREAD_SHARED_DICT["exec_time"] = runner.exec_time @@ -534,7 +539,8 @@ def restore_window( @threaded def _gui_backup(repo_config, stdout, stderr) -> Future: - runner = NPBackupRunner(repo_config=repo_config) + runner = NPBackupRunner() + runner.rep_config = repo_config runner.verbose = ( True # We must use verbose so we get progress output from ResticRunner ) diff --git a/npbackup/gui/operations.py b/npbackup/gui/operations.py index 2e306ba..80535de 100644 --- a/npbackup/gui/operations.py +++ b/npbackup/gui/operations.py @@ -48,7 +48,7 @@ def gui_update_state(window, full_config: dict) -> list: or repo_config.g(f"repo_opts.repo_password_command") ): backend_type, repo_uri = get_anon_repo_uri(repo_config.g(f"repo_uri")) - repo_list.append([backend_type, repo_uri]) + repo_list.append([repo_name, backend_type, repo_uri]) else: logger.warning("Incomplete operations repo {}".format(repo_name)) except KeyError: @@ -63,7 +63,7 @@ def operations_gui(full_config: dict) -> dict: """ # This is a stupid hack to make sure uri column is large enough - headings = ["Backend", "URI "] + headings = ["Name ", "Backend", "URI "] layout = [ [ @@ -101,6 +101,19 @@ def operations_gui(full_config: dict) -> dict: _t("operations_gui.full_check"), key="--FULL-CHECK--" ), ], + [ + sg.Button( + _t("operations_gui.repair_index"), key="--REPAIR-INDEX--" + ), + sg.Button( + _t("operations_gui.repair_snapshots"), key="--REPAIR-SNAPSHOTS--" + ), + ], + [ + sg.Button( + _t("operations.gui.unlock"), key="--UNLOCK--" + ) + ], [ sg.Button( _t("operations_gui.forget_using_retention_policy"), @@ -150,13 +163,15 @@ def operations_gui(full_config: dict) -> dict: if event in (sg.WIN_CLOSED, sg.WIN_X_EVENT, "--EXIT--"): break - if event in [ + if event in ( "--FORGET--", "--QUICK-CHECK--", "--FULL-CHECK--", + "--REPAIR-INDEX--", + "--REPAIR-SNAPSHOTS--", "--STANDARD-PRUNE--", "--MAX-PRUNE--", - ]: + ): if not values["repo-list"]: result = sg.popup( _t("operations_gui.apply_to_all"), @@ -171,7 +186,10 @@ def operations_gui(full_config: dict) -> dict: runner = NPBackupRunner() runner.stdout = stdout_queue runner.stderr = stderr_queue - group_runner_repo_list = [repo_name for backend_type, repo_name in repos] + repo_config_list = [] + for repo_name, backend_type, repo_uri in repos: + repo_config, config_inheritance = configuration.get_repo_config(full_config, repo_name) + repo_config_list.append((repo_name, repo_config)) if event == "--FORGET--": operation = "forget" @@ -182,6 +200,12 @@ def operations_gui(full_config: dict) -> dict: if event == "--FULL-CHECK--": operation = "check" op_args = {"read_data": True} + if event == "--REPAIR-INDEX--": + operation = "repair" + op_args = {"subject": "index"} + if event == "--REPAIR-SNAPSHOTS--": + operation = "repair" + op_args = {"subject": "snapshots"} if event == "--STANDARD-PRUNE--": operation = "prune" op_args = {} @@ -190,13 +214,10 @@ def operations_gui(full_config: dict) -> dict: op_args = {} @threaded - def _group_runner(group_runner_repo_list, operation, **op_args) -> Future: - return runner.group_runner(group_runner_repo_list, operation, **op_args) + def _group_runner(repo_config_list, operation, **op_args) -> Future: + return runner.group_runner(repo_config_list, operation, **op_args) - thread = _group_runner(group_runner_repo_list, operation, **op_args) - - read_stdout_queue = True - read_sterr_queue = True + thread = _group_runner(repo_config_list, operation, **op_args) progress_layout = [ [sg.Text(_t("operations_gui.last_message"))], @@ -209,8 +230,10 @@ def _group_runner(group_runner_repo_list, operation, **op_args) -> Future: progress_window = sg.Window("Operation status", progress_layout) event, values = progress_window.read(timeout=0.01) - # while read_stdout_queue or read_sterr_queue: - while not thread.done() and not thread.cancelled(): + read_stdout_queue = True + read_stderr_queue = True + #while read_stdout_queue or read_stderr_queue: # TODO add queue read as while + while (not thread.done() and not thread.cancelled()) or (read_stdout_queue or read_stderr_queue): # Read stdout queue try: stdout_data = stdout_queue.get(timeout=0.01) @@ -231,7 +254,7 @@ def _group_runner(group_runner_repo_list, operation, **op_args) -> Future: pass else: if stderr_data is None: - read_sterr_queue = False + read_stderr_queue = False else: progress_window["-OPERATIONS-PROGRESS-STDERR-"].Update( f"{progress_window['-OPERATIONS-PROGRESS-STDERR-'].get()}\n{stderr_data}" diff --git a/npbackup/translations/operations_gui.en.yml b/npbackup/translations/operations_gui.en.yml index 5ee0bbb..151db28 100644 --- a/npbackup/translations/operations_gui.en.yml +++ b/npbackup/translations/operations_gui.en.yml @@ -2,6 +2,8 @@ en: configured_repositories: Configured repositories quick_check: Quick check full_check: Full check + repair_index: Repair index + repair_snapshots: Repair snapshots forget_using_retention_policy: Forget using retention polic standard_prune: Normal prune operation max_prune: Prune with maximum efficiency diff --git a/npbackup/translations/operations_gui.fr.yml b/npbackup/translations/operations_gui.fr.yml index ae5d99f..ab20937 100644 --- a/npbackup/translations/operations_gui.fr.yml +++ b/npbackup/translations/operations_gui.fr.yml @@ -2,6 +2,8 @@ fr: configured_repositories: Dépots configurés quick_check: Vérification rapide full_check: Vérification complète + repair_index: Réparer index + repair_snapshots: Réparer les sauvegardes forget_using_retention_policy: Oublier en utilisant la stratégie de rétention standard_prune: Opération de purge normale max_prune: Opération de purge la plus efficace From e35066043be778483128a79f0b60c4f2f75848ee Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Fri, 22 Dec 2023 15:53:41 +0100 Subject: [PATCH 055/328] WIP: Rewrite backup runner and backend runner stdout/stderr logging --- npbackup/core/runner.py | 101 +++++++++++++++++----------- npbackup/gui/operations.py | 7 +- npbackup/restic_wrapper/__init__.py | 60 ++++++++++++----- 3 files changed, 107 insertions(+), 61 deletions(-) diff --git a/npbackup/core/runner.py b/npbackup/core/runner.py index f061fe0..c2a9880 100644 --- a/npbackup/core/runner.py +++ b/npbackup/core/runner.py @@ -131,6 +131,15 @@ def __init__(self): self._using_dev_binary = False + @property + def repo_name(self) -> str: + return self._repo_name + + @repo_name.setter + def repo_name(self, value: str): + if not isinstance(value, str) or not value: + msg = f"Bogus repo name {value} found" + self.write_logs(msg, level="critical", raise_error="ValueError") @property def repo_config(self) -> dict: @@ -139,8 +148,10 @@ def repo_config(self) -> dict: @repo_config.setter def repo_config(self, value: dict): if not isinstance(value, dict): - raise ValueError(f"Bogus repo config given: {value}") + msg = f"Bogus repo config object given" + self.write_logs(msg, level="critical", raise_error="ValueError") self._repo_config = deepcopy(value) + self.repo_name = self.repo_config.g("name") # Create an instance of restic wrapper self.create_restic_runner() @@ -157,7 +168,8 @@ def dry_run(self): @dry_run.setter def dry_run(self, value): if not isinstance(value, bool): - raise ValueError("Bogus dry_run parameter given: {}".format(value)) + msg = f"Bogus dry_run parameter given: {value}" + self.write_logs(msg, level="critical", raise_error="ValueError") self._dry_run = value @property @@ -167,7 +179,8 @@ def verbose(self): @verbose.setter def verbose(self, value): if not isinstance(value, bool): - raise ValueError("Bogus verbose parameter given: {}".format(value)) + msg = f"Bogus verbose parameter given: {value}" + self.write_logs(msg, level="critical", raise_error="ValueError") self._verbose = value @property @@ -214,27 +227,32 @@ def exec_time(self): def exec_time(self, value: int): self._exec_time = value - def write_logs(self, msg: str, level: str = None): + def write_logs(self, msg: str, level: str, raise_error: str = None): """ Write logs to log file and stdout / stderr queues if exist for GUI usage """ if level == 'warning': logger.warning(msg) - if self.stderr: - self.stderr.put(msg) elif level == 'error': logger.error(msg) - if self.stderr: - self.stderr.put(msg) elif level == 'critical': logger.critical(msg) - if self.stderr: - self.stderr.put(msg) - else: + elif level == 'info': logger.info(msg) - if self.stdout: - self.stdout.put(msg) + elif level == 'debug': + logger.debug(msg) + else: + raise ValueError("Bogus log level given {level}") + if self.stdout and level in ('info', 'debug'): + self.stdout.put(msg) + if self.stderr and level in ('critical', 'error', 'warning'): + self.stderr.put(msg) + + if raise_error == "ValueError": + raise ValueError(msg) + elif raise_error: + raise Exception(msg) # pylint does not understand why this function does not take a self parameter # It's a decorator, and the inner function will have the self argument instead @@ -250,7 +268,7 @@ def wrapper(self, *args, **kwargs): # pylint: disable=E1102 (not-callable) result = fn(self, *args, **kwargs) self.exec_time = (datetime.utcnow() - start_time).total_seconds() - self.write_logs(f"Runner took {self.exec_time} seconds for {fn.__name__}") + self.write_logs(f"Runner took {self.exec_time} seconds for {fn.__name__}", level="info") return result return wrapper @@ -314,7 +332,7 @@ def wrapper(self, *args, **kwargs): operation = fn.__name__ # TODO: enforce permissions self.write_logs( - f"Permissions required are {required_permissions[operation]}" + f"Permissions required are {required_permissions[operation]}", level="info" ) except (IndexError, KeyError): self.write_logs("You don't have sufficient permissions", level="error") @@ -459,7 +477,6 @@ def _apply_config_to_restic_runner(self) -> bool: pass except ValueError: self.write_logs("Bogus additional parameters given", level="warning") - self.restic_runner.stdout = self.stdout try: env_variables = self.repo_config.g("env.variables") @@ -504,7 +521,8 @@ def _apply_config_to_restic_runner(self) -> bool: self.repo_config.g("repo_opts.minimum_backup_age") ) except (KeyError, ValueError, TypeError): - self.minimum_backup_age = 1440 + # In doubt, launch the backup regardless of last backup age + self.minimum_backup_age = 0 self.restic_runner.verbose = self.verbose self.restic_runner.stdout = self.stdout @@ -531,13 +549,14 @@ def list(self) -> Optional[dict]: @is_ready @apply_config_to_restic_runner @close_queues + # TODO: add json output def find(self, path: str) -> bool: self.write_logs(f"Searching for path {path} in repo {self.repo_config.g('name')}", level="error") result = self.restic_runner.find(path=path) if result: - self.write_logs("Found path in:\n") + self.write_logs("Found path in:\n", level="info") for line in result: - self.write_logs(line) + self.write_logs(line, level="info") return True return False @@ -547,7 +566,7 @@ def find(self, path: str) -> bool: @apply_config_to_restic_runner @close_queues def ls(self, snapshot: str) -> Optional[dict]: - self.write_logs(f"Showing content of snapshot {snapshot} in repo {self.repo_config.g('name')}") + self.write_logs(f"Showing content of snapshot {snapshot} in repo {self.repo_config.g('name')}", level="info") result = self.restic_runner.ls(snapshot) return result @@ -563,10 +582,10 @@ def check_recent_backups(self) -> bool: Returns None if no information is available """ if self.minimum_backup_age == 0: - self.write_logs("No minimal backup age set. Set for backup") + self.write_logs("No minimal backup age set set.", level="info") self.write_logs( - f"Searching for a backup newer than {str(timedelta(minutes=self.minimum_backup_age))} ago" + f"Searching for a backup newer than {str(timedelta(minutes=self.minimum_backup_age))} ago", level="info" ) self.restic_runner.verbose = False result, backup_tz = self.restic_runner.has_snapshot_timedelta( @@ -574,11 +593,11 @@ def check_recent_backups(self) -> bool: ) self.restic_runner.verbose = self.verbose if result: - self.write_logs(f"Most recent backup in repo {self.repo_config.g('name')} is from {backup_tz}") + self.write_logs(f"Most recent backup in repo {self.repo_config.g('name')} is from {backup_tz}", level="info") elif result is False and backup_tz == datetime(1, 1, 1, 0, 0): - self.write_logs(f"No snapshots found in repo {self.repo_config.g('name')}.") + self.write_logs(f"No snapshots found in repo {self.repo_config.g('name')}.", level="info") elif result is False: - self.write_logs(f"No recent backup found in repo {self.repo_config.g('name')}. Newest is from {backup_tz}") + self.write_logs(f"No recent backup found in repo {self.repo_config.g('name')}. Newest is from {backup_tz}", level="info") elif result is None: self.write_logs("Cannot connect to repository or repository empty.", level="error") return result, backup_tz @@ -673,15 +692,15 @@ def backup(self, force: bool = False) -> bool: self.write_logs(f"Cannot continue, repo {self.repo_config.g('name')} is not defined.", level="critical") return False if self.check_recent_backups() and not force: - self.write_logs("No backup necessary.") + self.write_logs("No backup necessary.", level="info") return True self.restic_runner.verbose = self.verbose # Run backup here if exclude_patterns_source_type not in ["folder_list", None]: - self.write_logs(f"Running backup of files in {paths} list to repo {self.repo_config.g('name')}") + self.write_logs(f"Running backup of files in {paths} list to repo {self.repo_config.g('name')}", level="info") else: - self.write_logs(f"Running backup of {paths} to repo {self.repo_config.g('name')}") + self.write_logs(f"Running backup of {paths} to repo {self.repo_config.g('name')}", level="info") if pre_exec_commands: for pre_exec_command in pre_exec_commands: @@ -696,7 +715,7 @@ def backup(self, force: bool = False) -> bool: return False else: self.write_logs( - "Pre-execution of command {pre_exec_command} success with:\n{output}." + "Pre-execution of command {pre_exec_command} success with:\n{output}.", level="info" ) self.restic_runner.dry_run = self.dry_run @@ -730,7 +749,7 @@ def backup(self, force: bool = False) -> bool: return False else: self.write_logs( - "Post-execution of command {post_exec_command} success with:\n{output}.", level="error" + "Post-execution of command {post_exec_command} success with:\n{output}.", level="info" ) return result @@ -743,7 +762,7 @@ def restore(self, snapshot: str, target: str, restore_includes: List[str]) -> bo if not self.repo_config.g("permissions") in ["restore", "full"]: self.write_logs(f"You don't have permissions to restore repo {self.repo_config.g('name')}", level="error") return False - self.write_logs(f"Launching restore to {target}") + self.write_logs(f"Launching restore to {target}", level="info") result = self.restic_runner.restore( snapshot=snapshot, target=target, @@ -757,7 +776,7 @@ def restore(self, snapshot: str, target: str, restore_includes: List[str]) -> bo @apply_config_to_restic_runner @close_queues def forget(self, snapshot: str) -> bool: - self.write_logs(f"Forgetting snapshot {snapshot}") + self.write_logs(f"Forgetting snapshot {snapshot}", level="info") result = self.restic_runner.forget(snapshot) return result @@ -769,9 +788,9 @@ def forget(self, snapshot: str) -> bool: @close_queues def check(self, read_data: bool = True) -> bool: if read_data: - self.write_logs(f"Running full data check of repository {self.repo_config.g('name')}") + self.write_logs(f"Running full data check of repository {self.repo_config.g('name')}", level="info") else: - self.write_logs(f"Running metadata consistency check of repository {self.repo_config.g('name')}") + self.write_logs(f"Running metadata consistency check of repository {self.repo_config.g('name')}", level="info") sleep(1) result = self.restic_runner.check(read_data) return result @@ -782,7 +801,7 @@ def check(self, read_data: bool = True) -> bool: @apply_config_to_restic_runner @close_queues def prune(self) -> bool: - self.write_logs(f"Pruning snapshots for repo {self.repo_config.g('name')}") + self.write_logs(f"Pruning snapshots for repo {self.repo_config.g('name')}", level="info") result = self.restic_runner.prune() return result @@ -792,7 +811,7 @@ def prune(self) -> bool: @apply_config_to_restic_runner @close_queues def repair(self, subject: str) -> bool: - self.write_logs(f"Repairing {subject} in repo {self.repo_config.g('name')}") + self.write_logs(f"Repairing {subject} in repo {self.repo_config.g('name')}", level="info") result = self.restic_runner.repair(subject) return result @@ -802,7 +821,7 @@ def repair(self, subject: str) -> bool: @apply_config_to_restic_runner @close_queues def raw(self, command: str) -> bool: - self.write_logs(f"Running raw command: {command}") + self.write_logs(f"Running raw command: {command}", level="info") result = self.restic_runner.raw(command=command) return result @@ -821,21 +840,21 @@ def group_runner(self, repo_config_list: list, operation: str, **kwargs) -> bool } for repo_name, repo_config in repo_config_list: - self.write_logs(f"Running {operation} for repo {repo_name}") + self.write_logs(f"Running {operation} for repo {repo_name}", level="info") self.repo_config = repo_config result = self.__getattribute__(operation)(**kwargs) if result: - self.write_logs(f"Finished {operation} for repo {repo_name}") + self.write_logs(f"Finished {operation} for repo {repo_name}", level="info") else: self.write_logs( f"Operation {operation} failed for repo {repo_name}", level="error" ) group_result = False - self.write_logs("Finished execution group operations") + self.write_logs("Finished execution group operations", level="info") # Manually close the queues at the end if self.stdout: self.stdout.put(None) if self.stderr: self.stderr.put(None) - #sleep(1) # TODO this is arbitrary to allow queues to be read entirely + sleep(3) # TODO this is arbitrary to allow queues to be read entirely return group_result diff --git a/npbackup/gui/operations.py b/npbackup/gui/operations.py index 80535de..09b0a42 100644 --- a/npbackup/gui/operations.py +++ b/npbackup/gui/operations.py @@ -225,6 +225,7 @@ def _group_runner(repo_config_list, operation, **op_args) -> Future: [sg.Text(_t("operations_gui.error_messages"))], [sg.Multiline(key="-OPERATIONS-PROGRESS-STDERR-", size=(40, 10))], [sg.Image(LOADER_ANIMATION, key="-LOADER-ANIMATION-")], + [sg.Text(_t("generic.finished"), key="-FINISHED-", visible=False)], [sg.Button(_t("generic.close"), key="--EXIT--")], ] progress_window = sg.Window("Operation status", progress_layout) @@ -233,7 +234,7 @@ def _group_runner(repo_config_list, operation, **op_args) -> Future: read_stdout_queue = True read_stderr_queue = True #while read_stdout_queue or read_stderr_queue: # TODO add queue read as while - while (not thread.done() and not thread.cancelled()) or (read_stdout_queue or read_stderr_queue): + while (not thread.done() and not thread.cancelled()) or read_stdout_queue or read_stderr_queue: # Read stdout queue try: stdout_data = stdout_queue.get(timeout=0.01) @@ -244,7 +245,7 @@ def _group_runner(repo_config_list, operation, **op_args) -> Future: read_stdout_queue = False else: progress_window["-OPERATIONS-PROGRESS-STDOUT-"].Update( - stdout_data + f"{progress_window['-OPERATIONS-PROGRESS-STDOUT-'].get()}\n{stdout_data}" ) # Read stderr queue @@ -267,6 +268,8 @@ def _group_runner(repo_config_list, operation, **op_args) -> Future: _, _ = progress_window.read(0.01) # Keep the window open until user has done something + progress_window["-LOADER-ANIMATION-"].Update(visible=False) + progress_window["-FINISHED-"].Update(visible=True) while True: event, _ = progress_window.read() if event in (sg.WIN_CLOSED, sg.WIN_X_EVENT, "--EXIT--"): diff --git a/npbackup/restic_wrapper/__init__.py b/npbackup/restic_wrapper/__init__.py index 85c7776..036ff5f 100644 --- a/npbackup/restic_wrapper/__init__.py +++ b/npbackup/restic_wrapper/__init__.py @@ -40,7 +40,6 @@ def __init__( self.password = str(password).strip() self._verbose = False self._dry_run = False - self._stdout = None self._binary = None self.binary_search_paths = binary_search_paths self._get_binary() @@ -77,9 +76,7 @@ def __init__( None # Function which will make executor abort if result is True ) self._executor_finished = False # Internal value to check whether executor is done, accessed via self.executor_finished property - self._stdout = ( - None # Optional outputs when running GUI, to get interactive output - ) + self._stdout = None self._stderr = None def on_exit(self) -> bool: @@ -152,7 +149,7 @@ def stdout(self, value: Optional[Union[int, str, Callable, queue.Queue]]): def stderr(self) -> Optional[Union[int, str, Callable, queue.Queue]]: return self._stderr - @stdout.setter + @stderr.setter def stderr(self, value: Optional[Union[int, str, Callable, queue.Queue]]): self._stderr = value @@ -197,6 +194,33 @@ def exec_time(self) -> Optional[int]: def exec_time(self, value: int): self._exec_time = value + def write_logs(self, msg: str, level: str, raise_error: str = None): + """ + Write logs to log file and stdout / stderr queues if exist for GUI usage + """ + if level == 'warning': + logger.warning(msg) + elif level == 'error': + logger.error(msg) + elif level == 'critical': + logger.critical(msg) + elif level == 'info': + logger.info(msg) + elif level == 'debug': + logger.debug(msg) + else: + raise ValueError("Bogus log level given {level}") + + if self.stdout and level in ('info', 'debug'): + self.stdout.put(msg) + if self.stderr and level in ('critical', 'error', 'warning'): + self.stderr.put(msg) + + if raise_error == "ValueError": + raise ValueError(msg) + elif raise_error: + raise Exception(msg) + def executor( self, cmd: str, @@ -220,7 +244,7 @@ def executor( _cmd = f'"{self._binary}" {additional_parameters}{cmd}{self.generic_arguments}' if self.dry_run: _cmd += " --dry-run" - logger.debug("Running command: [{}]".format(_cmd)) + self.write_logs(f"Running command: [{_cmd}]", level="debug") self._make_env() if live_stream: exit_code, output = command_runner( @@ -230,8 +254,8 @@ def executor( encoding="utf-8", live_output=self.verbose, valid_exit_codes=errors_allowed, - stdout=self._stdout, - stderr=self._stderr, + stdout=None, # TODO we need other local queues to get subprocess output into gui queues + stderr=None, stop_on=self.stop_on, on_exit=self.on_exit, method="poller", @@ -740,25 +764,25 @@ def check(self, read_data: bool = True) -> bool: cmd = "check{}".format(" --read-data" if read_data else "") result, output = self.executor(cmd) if result: - logger.info("Repo checked successfully.") + self.write_logs("Repo checked successfully.", level="info") return True - logger.critical("Repo check failed:\n {}".format(output)) + self.write_logs(f"Repo check failed:\n {output}", level="critical") return False - def repair(self, order: str) -> bool: + def repair(self, subject: str) -> bool: """ Check current repo status """ if not self.is_init: return None - if order not in ["index", "snapshots"]: - logger.error("Bogus repair order given: {}".format(order)) - cmd = "repair {}".format(order) + if subject not in ["index", "snapshots"]: + self.write_logs(f"Bogus repair order given: {subject}", level="error") + cmd = "repair {}".format(subject) result, output = self.executor(cmd) if result: - logger.info("Repo successfully repaired:\n{}".format(output)) + self.write_logs(f"Repo successfully repaired:\n{output}", level="info") return True - logger.critical("Repo repair failed:\n {}".format(output)) + self.write_logs(f"Repo repair failed:\n {output}", level="critical") return False def raw(self, command: str) -> Tuple[bool, str]: @@ -769,9 +793,9 @@ def raw(self, command: str) -> Tuple[bool, str]: return None result, output = self.executor(command) if result: - logger.info("successfully run raw command:\n{}".format(output)) + self.write_logs(f"successfully run raw command:\n{output}", level="info") return True, output - logger.critical("Raw command failed.") + self.write_logs("Raw command failed.", level="error") return False, output def has_snapshot_timedelta( From 1eff9411c65a0af768a91643850c3cd2afda9b51 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Fri, 22 Dec 2023 21:01:25 +0100 Subject: [PATCH 056/328] Add gui thread manager --- npbackup/gui/helpers.py | 140 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 138 insertions(+), 2 deletions(-) diff --git a/npbackup/gui/helpers.py b/npbackup/gui/helpers.py index 5a01906..678e077 100644 --- a/npbackup/gui/helpers.py +++ b/npbackup/gui/helpers.py @@ -7,11 +7,24 @@ __author__ = "Orsiris de Jong" __copyright__ = "Copyright (C) 2023 NetInvent" __license__ = "GPL-3.0-only" -__build__ = "2023083101" +__build__ = "2023122201" -from typing import Tuple +from typing import Tuple, Callable +from logging import getLogger import re +import queue +import PySimpleGUI as sg +from npbackup.core.i18n_helper import _t +from npbackup.customization import LOADER_ANIMATION, GUI_LOADER_COLOR, GUI_LOADER_TEXT_COLOR +from npbackup.core.runner import NPBackupRunner + +# For debugging purposes, we should be able to disable threading to see actual errors +# out of thread +USE_THREADING = True + + +logger = getLogger() def get_anon_repo_uri(repository: str) -> Tuple[str, str]: @@ -40,3 +53,126 @@ def get_anon_repo_uri(repository: str) -> Tuple[str, str]: backend_type = "LOCAL" backend_uri = repository return backend_type, backend_uri + + +# TODO: add compact popupanimation + error gui only +def gui_thread_runner(__repo_config: dict, __fn_name: str, __compact: bool = True, __autoclose: bool = False, __gui_msg: str = "", *args, **kwargs): + """ + Runs any NPBackupRunner functions in threads if needed + """ + runner = NPBackupRunner() + # So we don't always init repo_config, since runner.group_runner would do that itself + if __repo_config: + runner.repo_config = __repo_config + stdout_queue = queue.Queue() + stderr_queue = queue.Queue() + fn = getattr(runner, __fn_name) + logger.debug(f"gui_thread_runner runs {fn.__name__} {'with' if USE_THREADING else 'without'} threads") + + runner.stdout = stdout_queue + runner.stderr = stderr_queue + + stderr_has_messages = False + if USE_THREADING: + """ + thread = fn(*args, **kwargs) + while not thread.done() and not thread.cancelled(): + sg.PopupAnimated( + LOADER_ANIMATION, + message=_t("main_gui.loading_data_from_repo"), + time_between_frames=50, + background_color=GUI_LOADER_COLOR, + text_color=GUI_LOADER_TEXT_COLOR, + ) + sg.PopupAnimated(None) + return thread.result() + """ + progress_layout = [ + [sg.Text(__gui_msg, text_color=GUI_LOADER_TEXT_COLOR, visible=__compact, justification='C')], + [sg.Text(_t("operations_gui.last_message"), key="-OPERATIONS-PROGRESS-STDOUT-TITLE-", visible=not __compact)], + [sg.Multiline(key="-OPERATIONS-PROGRESS-STDOUT-", size=(40, 10), visible=not __compact)], + [sg.Text(_t("operations_gui.error_messages"), key="-OPERATIONS-PROGRESS-STDERR-TITLE-", visible=not __compact)], + [sg.Multiline(key="-OPERATIONS-PROGRESS-STDERR-", size=(40, 10), visible=not __compact)], + [sg.Image(LOADER_ANIMATION, key="-LOADER-ANIMATION-")], + [sg.Text(_t("generic.finished"), key="-FINISHED-", visible=False)], + [sg.Button(_t("generic.close"), key="--EXIT--")], + ] + progress_window = sg.Window(__gui_msg, progress_layout, no_titlebar=True, grab_anywhere=True, background_color=GUI_LOADER_COLOR) + event, values = progress_window.read(timeout=0.01) + + read_stdout_queue = True + read_stderr_queue = True + read_queues = True + thread_alive = True + grace_counter = 100 # 2s since we read 2x queues with 0.01 seconds + thread = fn(*args, **kwargs) + while True: + # Read stdout queue + try: + stdout_data = stdout_queue.get(timeout=0.01) + except queue.Empty: + pass + else: + if stdout_data is None: + read_stdout_queue = False + else: + progress_window["-OPERATIONS-PROGRESS-STDOUT-"].Update( + f"{progress_window['-OPERATIONS-PROGRESS-STDOUT-'].get()}\n{stdout_data}" + ) + + # Read stderr queue + try: + stderr_data = stderr_queue.get(timeout=0.01) + except queue.Empty: + pass + else: + if stderr_data is None: + read_stderr_queue = False + else: + stderr_has_messages = True + if __compact: + for key in progress_window.AllKeysDict: + progress_window[key].Update(visible=True) + progress_window["-OPERATIONS-PROGRESS-STDERR-"].Update( + f"{progress_window['-OPERATIONS-PROGRESS-STDERR-'].get()}\n{stderr_data}" + ) + + progress_window["-LOADER-ANIMATION-"].UpdateAnimation( + LOADER_ANIMATION, time_between_frames=100 + ) + # So we actually need to read the progress window for it to refresh... + _, _ = progress_window.read(0.01) + + if thread_alive: + thread_alive = not thread.done and not thread.cancelled() + read_queues = read_stdout_queue or read_stderr_queue + + if not thread_alive and not read_queues: + break + if not thread_alive and read_queues: + # Let's read the queue for a grace period if queues are not closed + grace_counter -= 1 + + if grace_counter < 1: + progress_window["-OPERATIONS-PROGRESS-STDERR-"].Update( + f"{progress_window['-OPERATIONS-PROGRESS-STDERR-'].get()}\nGRACE COUNTER FOR output queues encountered. Thread probably died." + ) + __autoclose = False + break + + # Keep the window open until user has done something + progress_window["-LOADER-ANIMATION-"].Update(visible=False) + progress_window["-FINISHED-"].Update(visible=True) + if not __autoclose or stderr_has_messages: + while True: + event, _ = progress_window.read() + if event in (sg.WIN_CLOSED, sg.WIN_X_EVENT, "--EXIT--"): + break + progress_window.close() + return thread.result() + else: + kwargs = { + **kwargs, + **{"__no_threads": True} + } + return runner.__getattr__(fn)(*args, **kwargs) From 1d726037df462ca3a2644fc3289bf41a463f2cac Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Fri, 22 Dec 2023 21:01:40 +0100 Subject: [PATCH 057/328] Migrate to new gui thread manager --- npbackup/gui/__main__.py | 420 ++++++++++--------------------------- npbackup/gui/operations.py | 73 +------ 2 files changed, 116 insertions(+), 377 deletions(-) diff --git a/npbackup/gui/__main__.py b/npbackup/gui/__main__.py index 17c356e..87d82a5 100644 --- a/npbackup/gui/__main__.py +++ b/npbackup/gui/__main__.py @@ -42,7 +42,7 @@ ) from npbackup.gui.config import config_gui from npbackup.gui.operations import operations_gui -from npbackup.gui.helpers import get_anon_repo_uri +from npbackup.gui.helpers import get_anon_repo_uri, gui_thread_runner from npbackup.core.runner import NPBackupRunner from npbackup.core.i18n_helper import _t from npbackup.core.upgrade_runner import run_upgrade, check_new_version @@ -65,12 +65,7 @@ sg.SetOptions(icon=OEM_ICON) -# Let's use mutable to get a cheap way of transfering data from thread to main program -# There are no possible race conditions since we don't modifiy the data from anywhere outside the thread -THREAD_SHARED_DICT = {} - - -def _about_gui(version_string: str, full_config: dict) -> None: +def about_gui(version_string: str, full_config: dict) -> None: license_content = LICENSE_TEXT if full_config.g("global_options.auto_upgrade_server_url"): @@ -129,12 +124,11 @@ def _about_gui(version_string: str, full_config: dict) -> None: window.close() -@threaded -def _get_gui_data(repo_config: dict) -> Future: - runner = NPBackupRunner() - runner.repo_config = repo_config - snapshots = runner.list() - current_state, backup_tz = runner.check_recent_backups() + + +def get_gui_data(repo_config: dict) -> Tuple[bool, List[str]]: + gui_msg = _t("main_gui.loading_snapshot_list_from_repo") + snapshots = gui_thread_runner(repo_config, "list", __autoclose=True, __compact=True) snapshot_list = [] if snapshots: snapshots.reverse() # Let's show newer snapshots first @@ -158,71 +152,10 @@ def _get_gui_data(repo_config: dict) -> Future: snapshot_tags, ] ) - + gui_msg = _t("main_gui.loading_last_snapshot_date") + current_state, backup_tz = gui_thread_runner(repo_config, "check_recent_backups", __gui_msg=gui_msg, __autoclose=True) return current_state, backup_tz, snapshot_list - - -def get_gui_data(repo_config: dict) -> Tuple[bool, List[str]]: - try: - if not repo_config.g("repo_uri") and ( - not repo_config.g("repo_opts.repo_password") - and not repo_config.g("repo_opts.repo_password_command") - ): - sg.Popup(_t("main_gui.repository_not_configured")) - return None, None - except KeyError: - sg.Popup(_t("main_gui.repository_not_configured")) - return None, None - try: - runner = NPBackupRunner() - runner.repo_config = repo_config - except ValueError as exc: - sg.Popup(f'{_t("config_gui.no_runner")}: {exc}') - return None, None - if not runner._is_ready: - sg.Popup(_t("config_gui.runner_not_configured")) - return None, None - if not runner.has_binary: - sg.Popup(_t("config_gui.no_binary")) - return None, None - # We get a thread result, hence pylint will complain the thread isn't a tuple - # pylint: disable=E1101 (no-member) - thread = _get_gui_data(repo_config) - while not thread.done() and not thread.cancelled(): - sg.PopupAnimated( - LOADER_ANIMATION, - message=_t("main_gui.loading_data_from_repo"), - time_between_frames=50, - background_color=GUI_LOADER_COLOR, - text_color=GUI_LOADER_TEXT_COLOR, - ) - sg.PopupAnimated(None) - return thread.result() - - -def _gui_update_state( - window, current_state: bool, backup_tz: Optional[datetime], snapshot_list: List[str] -) -> None: - if current_state: - window["--STATE-BUTTON--"].Update( - "{}: {}".format(_t("generic.up_to_date"), backup_tz), - button_color=GUI_STATE_OK_BUTTON, - ) - elif current_state is False and backup_tz == datetime(1, 1, 1, 0, 0): - window["--STATE-BUTTON--"].Update( - _t("generic.no_snapshots"), button_color=GUI_STATE_OLD_BUTTON - ) - elif current_state is False: - window["--STATE-BUTTON--"].Update( - "{}: {}".format(_t("generic.too_old"), backup_tz.replace(microsecond=0)), - button_color=GUI_STATE_OLD_BUTTON, - ) - elif current_state is None: - window["--STATE-BUTTON--"].Update( - _t("generic.not_connected_yet"), button_color=GUI_STATE_UNKNOWN_BUTTON - ) - - window["snapshot-list"].Update(snapshot_list) + @threaded @@ -283,25 +216,14 @@ def _make_treedata_from_json(ls_result: List[dict]) -> sg.TreeData: return treedata -@threaded -def _forget_snapshot(repo_config: dict, snapshot_id: str) -> Future: - runner = NPBackupRunner() - runner.repo_config = repo_config - result = runner.forget(snapshot=snapshot_id) - return result - - -@threaded -def _ls_window(repo_config: dict, snapshot_id: str) -> Future: - runner = NPBackupRunner() - runner.repo_config = repo_config - result = runner.ls(snapshot=snapshot_id) - if not result: - return result, None +def ls_window(repo_config: dict, snapshot_id: str) -> bool: + snapshot_content = gui_thread_runner(repo_config, 'ls', snapshot=snapshot_id, __autoclose=True, __compact=True) + if not snapshot_content: + return snapshot_content, None try: # Since ls returns an iter now, we need to use next - snapshot_id = next(result) + snapshot_id = next(snapshot_content) # Exception that happens when restic cannot successfully get snapshot content except StopIteration: return None, None @@ -322,71 +244,9 @@ def _ls_window(repo_config: dict, snapshot_id: str) -> Future: except (KeyError, IndexError): hostname = "[inconnu]" - backup_content = " {} {} {} {}@{} {} {}".format( - _t("main_gui.backup_content_from"), - snap_date, - _t("main_gui.run_as"), - username, - hostname, - _t("main_gui.identified_by"), - short_id, - ) - return backup_content, result - - -def forget_snapshot(config: dict, snapshot_ids: List[str]) -> bool: - batch_result = True - for snapshot_id in snapshot_ids: - # We get a thread result, hence pylint will complain the thread isn't a tuple - # pylint: disable=E1101 (no-member) - thread = _forget_snapshot(config, snapshot_id) - - while not thread.done() and not thread.cancelled(): - sg.PopupAnimated( - LOADER_ANIMATION, - message="{} {}. {}".format( - _t("generic.forgetting"), - snapshot_id, - _t("main_gui.this_will_take_a_while"), - ), - time_between_frames=50, - background_color=GUI_LOADER_COLOR, - text_color=GUI_LOADER_TEXT_COLOR, - ) - sg.PopupAnimated(None) - result = thread.result() - if not result: - batch_result = result - if not batch_result: - sg.PopupError(_t("main_gui.forget_failed"), keep_on_top=True) - return False - else: - sg.Popup( - "{} {} {}".format( - snapshot_ids, _t("generic.forgotten"), _t("generic.successfully") - ) - ) - - -def ls_window(config: dict, snapshot_id: str) -> bool: - # We get a thread result, hence pylint will complain the thread isn't a tuple - # pylint: disable=E1101 (no-member) - thread = _ls_window(config, snapshot_id) + backup_id = f" {_t('main_gui.backup_content_from')} {snap_date} {_t('main_gui.run_as')} {username}@{hostname} {_t('main_gui.identified_by')} {short_id}" - while not thread.done() and not thread.cancelled(): - sg.PopupAnimated( - LOADER_ANIMATION, - message="{}. {}".format( - _t("main_gui.loading_data_from_repo"), - _t("main_gui.this_will_take_a_while"), - ), - time_between_frames=50, - background_color=GUI_LOADER_COLOR, - text_color=GUI_LOADER_TEXT_COLOR, - ) - sg.PopupAnimated(None) - backup_content, ls_result = thread.result() - if not backup_content or not ls_result: + if not backup_id or not snapshot_content: sg.PopupError(_t("main_gui.cannot_get_content"), keep_on_top=True) return False @@ -396,8 +256,7 @@ def ls_window(config: dict, snapshot_id: str) -> bool: # We get a thread result, hence pylint will complain the thread isn't a tuple # pylint: disable=E1101 (no-member) - thread = _make_treedata_from_json(ls_result) - + thread = _make_treedata_from_json(snapshot_content) while not thread.done() and not thread.cancelled(): sg.PopupAnimated( LOADER_ANIMATION, @@ -410,7 +269,7 @@ def ls_window(config: dict, snapshot_id: str) -> bool: treedata = thread.result() left_col = [ - [sg.Text(backup_content)], + [sg.Text(backup_id)], [ sg.Tree( data=treedata, @@ -445,7 +304,7 @@ def ls_window(config: dict, snapshot_id: str) -> bool: if not values["-TREE-"]: sg.PopupError(_t("main_gui.select_folder")) continue - restore_window(config, snapshot_id, values["-TREE-"]) + restore_window(repo_config, snapshot_id, values["-TREE-"]) # Closing a big sg.TreeData is really slow # This is a little trichery lesson @@ -458,35 +317,24 @@ def _close_win(): Since closing a sg.Treedata takes alot of time, let's thread it into background """ window.close - _close_win() - return True -@threaded -def _restore_window( - repo_config: dict, snapshot: str, target: str, restore_includes: Optional[List] -) -> Future: - runner = NPBackupRunner() - runner.repo_config = repo_config - runner.verbose = True - result = runner.restore(snapshot, target, restore_includes) - THREAD_SHARED_DICT["exec_time"] = runner.exec_time - return result - - def restore_window( repo_config: dict, snapshot_id: str, restore_include: List[str] -) -> None: +) -> None: + def _restore_window( + repo_config: dict, snapshot: str, target: str, restore_includes: Optional[List] + ) -> bool: + result = gui_thread_runner(repo_config, "restore", snapshot=snapshot, target=target, restore_includes=restore_includes) + return result left_col = [ [ sg.Text(_t("main_gui.destination_folder")), sg.In(size=(25, 1), enable_events=True, key="-RESTORE-FOLDER-"), sg.FolderBrowse(), ], - # Do not show which folder gets to get restored since we already make that selection - # [sg.Text(_t("main_gui.only_include")), sg.Text(includes, size=(25, 1))], [ sg.Button(_t("main_gui.restore"), key="restore"), sg.Button(_t("generic.cancel"), key="cancel"), @@ -502,90 +350,102 @@ def restore_window( if event in (sg.WIN_CLOSED, sg.WIN_CLOSE_ATTEMPTED_EVENT, "cancel"): break if event == "restore": - # We get a thread result, hence pylint will complain the thread isn't a tuple - # pylint: disable=E1101 (no-member) - thread = _restore_window( - repo_config=repo_config, - snapshot=snapshot_id, - target=values["-RESTORE-FOLDER-"], - restore_includes=restore_include, - ) - while not thread.done() and not thread.cancelled(): - sg.PopupAnimated( - LOADER_ANIMATION, - message="{}...".format(_t("main_gui.restore_in_progress")), - time_between_frames=50, - background_color=GUI_LOADER_COLOR, - text_color=GUI_LOADER_TEXT_COLOR, - ) - sg.PopupAnimated(None) - - result = thread.result() - try: - exec_time = THREAD_SHARED_DICT["exec_time"] - except KeyError: - exec_time = "N/A" + result = _restore_window(repo_config, snapshot=snapshot_id, target=values["-RESTORE-FOLDER-"], restore_includes=restore_include) if result: sg.Popup( - _t("main_gui.restore_done", seconds=exec_time), keep_on_top=True + _t("main_gui.restore_done"), keep_on_top=True ) else: sg.PopupError( - _t("main_gui.restore_failed", seconds=exec_time), keep_on_top=True + _t("main_gui.restore_failed"), keep_on_top=True ) break window.close() -@threaded -def _gui_backup(repo_config, stdout, stderr) -> Future: - runner = NPBackupRunner() - runner.rep_config = repo_config - runner.verbose = ( - True # We must use verbose so we get progress output from ResticRunner - ) - runner.stdout = stdout - runner.stderr = stderr - result = runner.backup( - force=True, - ) # Since we run manually, force backup regardless of recent backup state - THREAD_SHARED_DICT["exec_time"] = runner.exec_time - return result +def backup(repo_config: dict) -> bool: + gui_msg = _t("main_gui.backup_activity") + result = gui_thread_runner(repo_config, 'backup', __gui_msg=gui_msg) + if not result: + sg.PopupError( + _t("main_gui.backup_failed"), keep_on_top=True + ) + return False + else: + sg.Popup( + _t("main_gui.backup_done"), keep_on_top=True + ) + return True -def select_config_file(): - """ - Option to select a configuration file - """ - layout = [ - [ - sg.Text(_t("main_gui.select_config_file")), - sg.Input(key="-config_file-"), - sg.FileBrowse(_t("generic.select_file")), - ], - [ - sg.Button(_t("generic.cancel"), key="-CANCEL-"), - sg.Button(_t("generic.accept"), key="-ACCEPT-"), - ], - ] - window = sg.Window("Configuration File", layout=layout) - while True: - event, values = window.read() - if event in [sg.WIN_X_EVENT, sg.WIN_CLOSED, "-CANCEL-"]: - break - if event == "-ACCEPT-": - config_file = Path(values["-config_file-"]) - if not config_file.exists(): - sg.PopupError(_t("generic.file_does_not_exist")) - continue - config = npbackup.configuration._load_config_file(config_file) - if not config: - sg.PopupError(_t("generic.bad_file")) - continue - return config_file +def forget_snapshot(repo_config: dict, snapshot_ids: List[str]) -> bool: + gui_msg = f"{_t('generic.forgetting')} {snapshot_ids} {_t('main_gui.this_will_take_a_while')}" + result = gui_thread_runner(repo_config, "forget", snapshots=snapshot_ids, __gui_msg=gui_msg, __autoclose=True) + if not result: + sg.PopupError(_t("main_gui.forget_failed"), keep_on_top=True) + return False + else: + sg.Popup( + f"{snapshot_ids} {_t('generic.forgotten')} {_t('generic.successfully')}" + ) + return True def _main_gui(): + def select_config_file(): + """ + Option to select a configuration file + """ + layout = [ + [ + sg.Text(_t("main_gui.select_config_file")), + sg.Input(key="-config_file-"), + sg.FileBrowse(_t("generic.select_file")), + ], + [ + sg.Button(_t("generic.cancel"), key="-CANCEL-"), + sg.Button(_t("generic.accept"), key="-ACCEPT-"), + ], + ] + window = sg.Window("Configuration File", layout=layout) + while True: + event, values = window.read() + if event in [sg.WIN_X_EVENT, sg.WIN_CLOSED, "-CANCEL-"]: + break + if event == "-ACCEPT-": + config_file = Path(values["-config_file-"]) + if not config_file.exists(): + sg.PopupError(_t("generic.file_does_not_exist")) + continue + config = npbackup.configuration._load_config_file(config_file) + if not config: + sg.PopupError(_t("generic.bad_file")) + continue + return config_file + + def gui_update_state() -> None: + if current_state: + window["--STATE-BUTTON--"].Update( + "{}: {}".format(_t("generic.up_to_date"), backup_tz), + button_color=GUI_STATE_OK_BUTTON, + ) + elif current_state is False and backup_tz == datetime(1, 1, 1, 0, 0): + window["--STATE-BUTTON--"].Update( + _t("generic.no_snapshots"), button_color=GUI_STATE_OLD_BUTTON + ) + elif current_state is False: + window["--STATE-BUTTON--"].Update( + "{}: {}".format(_t("generic.too_old"), backup_tz.replace(microsecond=0)), + button_color=GUI_STATE_OLD_BUTTON, + ) + elif current_state is None: + window["--STATE-BUTTON--"].Update( + _t("generic.not_connected_yet"), button_color=GUI_STATE_UNKNOWN_BUTTON + ) + + window["snapshot-list"].Update(snapshot_list) + + config_file = Path(f"{CURRENT_DIR}/npbackup.conf") if not config_file.exists(): while True: @@ -664,7 +524,7 @@ def _main_gui(): _t("main_gui.launch_backup"), key="--LAUNCH-BACKUP--" ), sg.Button(_t("main_gui.see_content"), key="--SEE-CONTENT--"), - sg.Button(_t("generic.forget"), key="--FORGET--"), + sg.Button(_t("generic.forget"), key="--FORGET--"), # TODO , visible=False if repo_config.g("permissions") != "full" else True), sg.Button(_t("main_gui.operations"), key="--OPERATIONS--"), sg.Button(_t("generic.configure"), key="--CONFIGURE--"), sg.Button(_t("generic.about"), key="--ABOUT--"), @@ -702,7 +562,7 @@ def _main_gui(): current_state = None backup_tz = None snapshot_list = [] - _gui_update_state(window, current_state, backup_tz, snapshot_list) + gui_update_state() while True: event, values = window.read(timeout=60000) @@ -716,65 +576,13 @@ def _main_gui(): config_inheriteance, ) = npbackup.configuration.get_repo_config(full_config, active_repo) current_state, backup_tz, snapshot_list = get_gui_data(repo_config) - _gui_update_state(window, current_state, backup_tz, snapshot_list) + gui_update_state() else: sg.PopupError("Repo not existent in config") continue if event == "--LAUNCH-BACKUP--": - progress_windows_layout = [ - [ - sg.Multiline( - size=(80, 10), key="progress", expand_x=True, expand_y=True - ) - ] - ] - progress_window = sg.Window( - _t("main_gui.backup_activity"), - layout=progress_windows_layout, - finalize=True, - ) - # We need to read that window at least once fopr it to exist - progress_window.read(timeout=1) - stdout = queue.Queue() - stderr = queue.Queue() - - # let's use a mutable so the backup thread can modify it - # We get a thread result, hence pylint will complain the thread isn't a tuple - # pylint: disable=E1101 (no-member) - thread = _gui_backup(repo_config=repo_config, stdout=stdout, stderr=stderr) - while not thread.done() and not thread.cancelled(): - try: - stdout_line = stdout.get(timeout=0.01) - except queue.Empty: - pass - else: - if stdout_line: - progress_window["progress"].Update(stdout_line) - sg.PopupAnimated( - LOADER_ANIMATION, - message="{}...".format(_t("main_gui.backup_in_progress")), - time_between_frames=50, - background_color=GUI_LOADER_COLOR, - text_color=GUI_LOADER_TEXT_COLOR, - ) - sg.PopupAnimated(None) - result = thread.result() - try: - exec_time = THREAD_SHARED_DICT["exec_time"] - except KeyError: - exec_time = "N/A" - current_state, backup_tz, snapshot_list = get_gui_data(repo_config) - _gui_update_state(window, current_state, backup_tz, snapshot_list) - if not result: - sg.PopupError( - _t("main_gui.backup_failed", seconds=exec_time), keep_on_top=True - ) - else: - sg.Popup( - _t("main_gui.backup_done", seconds=exec_time), keep_on_top=True - ) - progress_window.close() - continue + backup(repo_config) + event = "--STATE-BUTTON" if event == "--SEE-CONTENT--": if not values["snapshot-list"]: sg.Popup(_t("main_gui.select_backup"), keep_on_top=True) @@ -812,10 +620,10 @@ def _main_gui(): except (TypeError, KeyError): sg.PopupNoFrame(_t("main_gui.unknown_repo")) if event == "--ABOUT--": - _about_gui(version_string, full_config) + about_gui(version_string, full_config) if event == "--STATE-BUTTON--": current_state, backup_tz, snapshot_list = get_gui_data(repo_config) - _gui_update_state(window, current_state, backup_tz, snapshot_list) + gui_update_state() if current_state is None: sg.Popup(_t("main_gui.cannot_get_repo_status")) diff --git a/npbackup/gui/operations.py b/npbackup/gui/operations.py index 09b0a42..1a47e05 100644 --- a/npbackup/gui/operations.py +++ b/npbackup/gui/operations.py @@ -18,7 +18,7 @@ from ofunctions.threading import threaded, Future from npbackup.core.runner import NPBackupRunner from npbackup.core.i18n_helper import _t -from npbackup.gui.helpers import get_anon_repo_uri +from npbackup.gui.helpers import get_anon_repo_uri, gui_thread_runner from npbackup.customization import ( OEM_STRING, OEM_LOGO, @@ -154,10 +154,6 @@ def operations_gui(full_config: dict) -> dict: # Auto reisze table to window size window["repo-list"].expand(True, True) - # Create queues for getting runner data - stdout_queue = queue.Queue() - stderr_queue = queue.Queue() - while True: event, values = window.read(timeout=60000) @@ -183,14 +179,10 @@ def operations_gui(full_config: dict) -> dict: else: repos = values["repo-list"] - runner = NPBackupRunner() - runner.stdout = stdout_queue - runner.stderr = stderr_queue repo_config_list = [] for repo_name, backend_type, repo_uri in repos: repo_config, config_inheritance = configuration.get_repo_config(full_config, repo_name) repo_config_list.append((repo_name, repo_config)) - if event == "--FORGET--": operation = "forget" op_args = {} @@ -213,68 +205,7 @@ def operations_gui(full_config: dict) -> dict: operation = "prune" op_args = {} - @threaded - def _group_runner(repo_config_list, operation, **op_args) -> Future: - return runner.group_runner(repo_config_list, operation, **op_args) - - thread = _group_runner(repo_config_list, operation, **op_args) - - progress_layout = [ - [sg.Text(_t("operations_gui.last_message"))], - [sg.Multiline(key="-OPERATIONS-PROGRESS-STDOUT-", size=(40, 10))], - [sg.Text(_t("operations_gui.error_messages"))], - [sg.Multiline(key="-OPERATIONS-PROGRESS-STDERR-", size=(40, 10))], - [sg.Image(LOADER_ANIMATION, key="-LOADER-ANIMATION-")], - [sg.Text(_t("generic.finished"), key="-FINISHED-", visible=False)], - [sg.Button(_t("generic.close"), key="--EXIT--")], - ] - progress_window = sg.Window("Operation status", progress_layout) - event, values = progress_window.read(timeout=0.01) - - read_stdout_queue = True - read_stderr_queue = True - #while read_stdout_queue or read_stderr_queue: # TODO add queue read as while - while (not thread.done() and not thread.cancelled()) or read_stdout_queue or read_stderr_queue: - # Read stdout queue - try: - stdout_data = stdout_queue.get(timeout=0.01) - except queue.Empty: - pass - else: - if stdout_data is None: - read_stdout_queue = False - else: - progress_window["-OPERATIONS-PROGRESS-STDOUT-"].Update( - f"{progress_window['-OPERATIONS-PROGRESS-STDOUT-'].get()}\n{stdout_data}" - ) - - # Read stderr queue - try: - stderr_data = stderr_queue.get(timeout=0.01) - except queue.Empty: - pass - else: - if stderr_data is None: - read_stderr_queue = False - else: - progress_window["-OPERATIONS-PROGRESS-STDERR-"].Update( - f"{progress_window['-OPERATIONS-PROGRESS-STDERR-'].get()}\n{stderr_data}" - ) - - progress_window["-LOADER-ANIMATION-"].UpdateAnimation( - LOADER_ANIMATION, time_between_frames=100 - ) - # So we actually need to read the progress window for it to refresh... - _, _ = progress_window.read(0.01) - - # Keep the window open until user has done something - progress_window["-LOADER-ANIMATION-"].Update(visible=False) - progress_window["-FINISHED-"].Update(visible=True) - while True: - event, _ = progress_window.read() - if event in (sg.WIN_CLOSED, sg.WIN_X_EVENT, "--EXIT--"): - break - progress_window.close() + result = gui_thread_runner(None, 'group_runner', operation=operation, repo_config_list=repo_config_list, **op_args) event = "---STATE-UPDATE---" if event == "---STATE-UPDATE---": From f0a1e04804b3adff8e50f7ba8b3d9e98a419381e Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Fri, 22 Dec 2023 21:02:02 +0100 Subject: [PATCH 058/328] Improve restic_wrapper logging --- npbackup/restic_wrapper/__init__.py | 37 +++++++++++++++++++---------- 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/npbackup/restic_wrapper/__init__.py b/npbackup/restic_wrapper/__init__.py index 036ff5f..28ea6e0 100644 --- a/npbackup/restic_wrapper/__init__.py +++ b/npbackup/restic_wrapper/__init__.py @@ -20,6 +20,7 @@ import dateutil.parser import queue from command_runner import command_runner +from npbackup.__debug__ import _DEBUG logger = getLogger() @@ -211,7 +212,7 @@ def write_logs(self, msg: str, level: str, raise_error: str = None): else: raise ValueError("Bogus log level given {level}") - if self.stdout and level in ('info', 'debug'): + if self.stdout and (level == 'info' or (level == 'debug' and _DEBUG)): self.stdout.put(msg) if self.stderr and level in ('critical', 'error', 'warning'): self.stderr.put(msg) @@ -712,31 +713,41 @@ def restore(self, snapshot: str, target: str, includes: List[str] = None): return False def forget( - self, snapshot: Optional[str] = None, policy: Optional[dict] = None + self, snapshots: Union[List[str], Optional[str]] = None, policy: Optional[dict] = None ) -> bool: """ Execute forget command for given snapshot """ if not self.is_init: return None - if not snapshot and not policy: - logger.error("No valid snapshot or policy defined for pruning") + if not snapshots and not policy: + self.write_logs("No valid snapshot or policy defined for pruning", level="error") return False - if snapshot: - cmd = "forget {}".format(snapshot) + if snapshots: + if isinstance(snapshots, list): + cmds = [] + for snapshot in snapshots: + cmds.append(f"forget {snapshot}") + else: + cmds = f"forget {snapshots}" if policy: - cmd = "format {}".format(policy) # TODO # WIP + cmds = ["format {}".format(policy)] # TODO # WIP + # We need to be verbose here since server errors will not stop client from deletion attempts verbose = self.verbose self.verbose = True - result, output = self.executor(cmd) + batch_result = True + if cmds: + for cmd in cmds: + result, output = self.executor(cmd) + if result: + self.write_logs("successfully forgot snapshot", level='info') + else: + self.write_logs(f"Forget failed\n{output}", level="error") + batch_result = False self.verbose = verbose - if result: - logger.info("successfully forgot snapshots") - return True - logger.critical("Forget failed:\n{}".format(output)) - return False + return batch_result def prune(self) -> bool: """ From 9c470c3d16fad82dfb0c72386d9cc6670d907793 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Fri, 22 Dec 2023 21:02:25 +0100 Subject: [PATCH 059/328] Fix decorator order, also improve logging --- npbackup/core/runner.py | 68 ++++++++++++++++++++++++++--------------- 1 file changed, 44 insertions(+), 24 deletions(-) diff --git a/npbackup/core/runner.py b/npbackup/core/runner.py index c2a9880..50a8669 100644 --- a/npbackup/core/runner.py +++ b/npbackup/core/runner.py @@ -26,10 +26,12 @@ from npbackup.core.restic_source_binary import get_restic_internal_binary from npbackup.path_helper import CURRENT_DIR, BASEDIR from npbackup.__version__ import __intname__ as NAME, __version__ as VERSION -from time import sleep +from npbackup.__debug__ import _DEBUG + logger = logging.getLogger() + def metric_writer( repo_config: dict, restic_result: bool, result_string: str, dry_run: bool ): @@ -124,7 +126,6 @@ def __init__(self): self._dry_run = False self._verbose = False - self._stdout = None self.restic_runner = None self.minimum_backup_age = None self._exec_time = None @@ -244,7 +245,7 @@ def write_logs(self, msg: str, level: str, raise_error: str = None): else: raise ValueError("Bogus log level given {level}") - if self.stdout and level in ('info', 'debug'): + if self.stdout and (level == 'info' or (level == 'debug' and _DEBUG)): self.stdout.put(msg) if self.stderr and level in ('critical', 'error', 'warning'): self.stderr.put(msg) @@ -329,7 +330,12 @@ def wrapper(self, *args, **kwargs): "raw": ["full"], } try: - operation = fn.__name__ + # When running group_runner, we need to extract operation from kwargs + # else, operarion is just the wrapped function name + if fn.__name__ == "group_runner": + operation = kwargs.get("operation") + else: + operation = fn.__name__ # TODO: enforce permissions self.write_logs( f"Permissions required are {required_permissions[operation]}", level="info" @@ -534,24 +540,30 @@ def _apply_config_to_restic_runner(self) -> bool: # ACTUAL RUNNER FUNCTIONS # ########################### + # Decorator order is important + # Since we want a concurrent.futures.Future result, we need to put the @threaded decorator + # Before any other decorator that would change the results + @exec_timer + @close_queues + @threaded @has_permission @is_ready @apply_config_to_restic_runner - @close_queues def list(self) -> Optional[dict]: - self.write_logs(f"Listing snapshots of repo {self.repo_config.g('name')}", level="error") + self.write_logs(f"Listing snapshots of repo {self.repo_config.g('name')}", level="info") snapshots = self.restic_runner.snapshots() return snapshots @exec_timer + @close_queues + @threaded @has_permission @is_ready @apply_config_to_restic_runner - @close_queues # TODO: add json output def find(self, path: str) -> bool: - self.write_logs(f"Searching for path {path} in repo {self.repo_config.g('name')}", level="error") + self.write_logs(f"Searching for path {path} in repo {self.repo_config.g('name')}", level="info") result = self.restic_runner.find(path=path) if result: self.write_logs("Found path in:\n", level="info") @@ -561,20 +573,22 @@ def find(self, path: str) -> bool: return False @exec_timer + @close_queues + @threaded @has_permission @is_ready @apply_config_to_restic_runner - @close_queues def ls(self, snapshot: str) -> Optional[dict]: self.write_logs(f"Showing content of snapshot {snapshot} in repo {self.repo_config.g('name')}", level="info") result = self.restic_runner.ls(snapshot) return result @exec_timer + @close_queues + @threaded @has_permission @is_ready @apply_config_to_restic_runner - @close_queues def check_recent_backups(self) -> bool: """ Checks for backups in timespan @@ -603,10 +617,11 @@ def check_recent_backups(self) -> bool: return result, backup_tz @exec_timer + @close_queues + @threaded @has_permission @is_ready @apply_config_to_restic_runner - @close_queues def backup(self, force: bool = False) -> bool: """ Run backup after checking if no recent backup exists, unless force == True @@ -754,10 +769,11 @@ def backup(self, force: bool = False) -> bool: return result @exec_timer + @close_queues + @threaded @has_permission @is_ready @apply_config_to_restic_runner - @close_queues def restore(self, snapshot: str, target: str, restore_includes: List[str]) -> bool: if not self.repo_config.g("permissions") in ["restore", "full"]: self.write_logs(f"You don't have permissions to restore repo {self.repo_config.g('name')}", level="error") @@ -771,61 +787,66 @@ def restore(self, snapshot: str, target: str, restore_includes: List[str]) -> bo return result @exec_timer + @close_queues + @threaded @has_permission @is_ready @apply_config_to_restic_runner - @close_queues - def forget(self, snapshot: str) -> bool: - self.write_logs(f"Forgetting snapshot {snapshot}", level="info") - result = self.restic_runner.forget(snapshot) + def forget(self, snapshots: Union[List[str], str]) -> bool: + self.write_logs(f"Forgetting snapshots {snapshots}", level="info") + result = self.restic_runner.forget(snapshots) return result @exec_timer + @close_queues + @threaded @has_permission @is_ready @apply_config_to_restic_runner - @threaded - @close_queues def check(self, read_data: bool = True) -> bool: if read_data: self.write_logs(f"Running full data check of repository {self.repo_config.g('name')}", level="info") else: self.write_logs(f"Running metadata consistency check of repository {self.repo_config.g('name')}", level="info") - sleep(1) result = self.restic_runner.check(read_data) return result @exec_timer + @close_queues + @threaded @has_permission @is_ready @apply_config_to_restic_runner - @close_queues def prune(self) -> bool: self.write_logs(f"Pruning snapshots for repo {self.repo_config.g('name')}", level="info") result = self.restic_runner.prune() return result @exec_timer + @close_queues + @threaded @has_permission @is_ready @apply_config_to_restic_runner - @close_queues def repair(self, subject: str) -> bool: self.write_logs(f"Repairing {subject} in repo {self.repo_config.g('name')}", level="info") result = self.restic_runner.repair(subject) return result @exec_timer + @close_queues + @threaded @has_permission @is_ready @apply_config_to_restic_runner - @close_queues def raw(self, command: str) -> bool: self.write_logs(f"Running raw command: {command}", level="info") result = self.restic_runner.raw(command=command) return result @exec_timer + @threaded + @has_permission def group_runner(self, repo_config_list: list, operation: str, **kwargs) -> bool: group_result = True @@ -835,7 +856,7 @@ def group_runner(self, repo_config_list: list, operation: str, **kwargs) -> bool **kwargs, **{ "close_queues": False, - #"__no_threads": True, + "__no_threads": True, } } @@ -856,5 +877,4 @@ def group_runner(self, repo_config_list: list, operation: str, **kwargs) -> bool self.stdout.put(None) if self.stderr: self.stderr.put(None) - sleep(3) # TODO this is arbitrary to allow queues to be read entirely return group_result From 109d733f3af16507223a55c65a6833947efd6d4a Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Fri, 22 Dec 2023 21:03:00 +0100 Subject: [PATCH 060/328] Update sg keys to newer format --- npbackup/gui/config.py | 74 +++++++++++++++++++++++++++--------------- 1 file changed, 47 insertions(+), 27 deletions(-) diff --git a/npbackup/gui/config.py b/npbackup/gui/config.py index 25739bc..5a28972 100644 --- a/npbackup/gui/config.py +++ b/npbackup/gui/config.py @@ -69,7 +69,7 @@ def config_gui(full_config: dict, config_file: str): "backup": _t("config_gui.backup_perms"), "restore": _t("config_gui.restore_perms"), "full": _t("config_gui.full_perms"), - } + }, } ENCRYPTED_DATA_PLACEHOLDER = "<{}>".format(_t("config_gui.encrypted_data")) @@ -255,8 +255,8 @@ def update_object_gui(object_name=None, unencrypted=False): ) # Enable settings only valid for repos - window['repo_uri'].Update(visible=True) - window['--SET-PERMISSIONS--'].Update(visible=True) + window["repo_uri"].Update(visible=True) + window["--SET-PERMISSIONS--"].Update(visible=True) if object_type == "group": object_config = configuration.get_group_config( @@ -265,8 +265,8 @@ def update_object_gui(object_name=None, unencrypted=False): config_inheritance = None # Disable settings only valid for repos - window['repo_uri'].Update(visible=False) - window['--SET-PERMISSIONS--'].Update(visible=False) + window["repo_uri"].Update(visible=False) + window["--SET-PERMISSIONS--"].Update(visible=False) # Now let's iter over the whole config object and update keys accordingly iter_over_config( @@ -284,9 +284,11 @@ def update_global_gui(full_config, unencrypted=False): def update_config_dict(full_config, values): """ - Update full_config with keys from + Update full_config with keys from """ - object_type, object_name = get_object_from_combo(values['-OBJECT-SELECT-']) + # TODO + return + object_type, object_name = get_object_from_combo(values["-OBJECT-SELECT-"]) for key, value in values.items(): if value == ENCRYPTED_DATA_PLACEHOLDER: continue @@ -317,14 +319,14 @@ def update_config_dict(full_config, values): # Create section if not exists active_object_key = f"{object_type}s.{object_name}.{key}" print("ACTIVE KEY", active_object_key) - if not full_config.g(active_object_key): + if not full_config.g(active_object_key): full_config.s(active_object_key, CommentedMap()) full_config.s(active_object_key, value) return full_config # TODO: Do we actually save every modified object or just the last ? # TDOO: also save global options - + def set_permissions(full_config: dict, object_name: str) -> dict: """ Sets repo wide repo_uri / password / permissions @@ -333,7 +335,9 @@ def set_permissions(full_config: dict, object_name: str) -> dict: if object_type == "group": sg.PopupError(_t("config_gui.permissions_only_for_repos")) return full_config - repo_config, _ = configuration.get_repo_config(full_config, object_name, eval_variables=False) + repo_config, _ = configuration.get_repo_config( + full_config, object_name, eval_variables=False + ) permissions = list(combo_boxes["permissions"].values()) default_perm = repo_config.g("permissions") if not default_perm: @@ -348,29 +352,43 @@ def set_permissions(full_config: dict, object_name: str) -> dict: [sg.HorizontalSeparator()], [ sg.Text(_t("config_gui.set_manager_password"), size=(40, 1)), - sg.Input(manager_password, key="-MANAGER-PASSWORD-", size=(50, 1), password_char="*"), - #sg.Button(_t("generic.change"), key="--CHANGE-MANAGER-PASSWORD--") + sg.Input( + manager_password, + key="-MANAGER-PASSWORD-", + size=(50, 1), + password_char="*", + ), + # sg.Button(_t("generic.change"), key="--CHANGE-MANAGER-PASSWORD--") + ], + [ + sg.Push(), + sg.Button(_t("generic.cancel"), key="--CANCEL--"), + sg.Button(_t("generic.accept"), key="--ACCEPT--"), ], - [sg.Push(), sg.Button(_t("generic.cancel"), key='--CANCEL--'), sg.Button(_t("generic.accept"), key='--ACCEPT--')], ] window = sg.Window(_t("config_gui.permissions"), layout, keep_on_top=True) while True: event, values = window.read() - if event in (sg.WIN_CLOSED, sg.WIN_X_EVENT, '--CANCEL--'): + if event in (sg.WIN_CLOSED, sg.WIN_X_EVENT, "--CANCEL--"): break - if event == '--ACCEPT--': - if not values['-MANAGER-PASSWORD-']: - sg.PopupError(_t("config_gui.setting_permissions_requires_manager_password"), keep_on_top=True) + if event == "--ACCEPT--": + if not values["-MANAGER-PASSWORD-"]: + sg.PopupError( + _t("config_gui.setting_permissions_requires_manager_password"), + keep_on_top=True, + ) continue - elif len(values['-MANAGER-PASSWORD-']) < 8: - sg.PopupError(_t("config_gui.manager_password_too_short"), keep_on_top=True) + elif len(values["-MANAGER-PASSWORD-"]) < 8: + sg.PopupError( + _t("config_gui.manager_password_too_short"), keep_on_top=True + ) continue - if not values['-PERMISSIONS-'] in permissions: + if not values["-PERMISSIONS-"] in permissions: sg.PopupError(_t("generic.bogus_data_given"), keep_on_top=True) continue - repo_config.s("permissions", values['-PERMISSIONS-']) - repo_config.s("manager_password", values['-MANAGER-PASSWORD-']) + repo_config.s("permissions", values["-PERMISSIONS-"]) + repo_config.s("manager_password", values["-MANAGER-PASSWORD-"]) break window.close() full_config.s(f"repos.{object_name}", repo_config) @@ -557,9 +575,7 @@ def object_layout() -> List[list]: sg.Text(_t("config_gui.backup_repo_uri"), size=(40, 1)), sg.Input(key="repo_uri", size=(50, 1)), ], - [ - sg.Button(_t("config_gui.set_permissions"), key='--SET-PERMISSIONS--') - ], + [sg.Button(_t("config_gui.set_permissions"), key="--SET-PERMISSIONS--")], [ sg.Text(_t("config_gui.repo_group"), size=(40, 1)), sg.Input(key="repo_group", size=(50, 1)), @@ -949,7 +965,9 @@ def config_layout() -> List[list]: continue if event == "--SET-PERMISSIONS--": object_type, object_name = get_object_from_combo(values["-OBJECT-SELECT-"]) - manager_password = configuration.get_manager_password(full_config, object_name) + manager_password = configuration.get_manager_password( + full_config, object_name + ) if ask_manager_password(manager_password): full_config = set_permissions(full_config, values["-OBJECT-SELECT-"]) continue @@ -973,7 +991,9 @@ def config_layout() -> List[list]: logger.info("Could not save configuration") if event == _t("config_gui.show_decrypted"): object_type, object_name = get_object_from_combo(values["-OBJECT-SELECT-"]) - manager_password = configuration.get_manager_password(full_config, object_name) + manager_password = configuration.get_manager_password( + full_config, object_name + ) if ask_manager_password(manager_password): update_object_gui(values["-OBJECT-SELECT-"], unencrypted=True) update_global_gui(full_config, unencrypted=True) From 3b07a97c4b3041475659f825437bba1d14ae9ad6 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Fri, 22 Dec 2023 21:03:03 +0100 Subject: [PATCH 061/328] Update requirements.txt --- npbackup/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/npbackup/requirements.txt b/npbackup/requirements.txt index 31fae12..f639b8d 100644 --- a/npbackup/requirements.txt +++ b/npbackup/requirements.txt @@ -4,7 +4,7 @@ python-dateutil ofunctions.logger_utils>=2.4.1 ofunctions.misc>=1.6.1 ofunctions.process>=1.4.0 -ofunctions.threading>=2.0.0 +ofunctions.threading>=2.1.0 ofunctions.platform>=1.4.1 ofunctions.random python-pidfile>=3.0.0 From c29602305e3830deca7e5f4be99c1cf120bf5764 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Fri, 22 Dec 2023 21:03:12 +0100 Subject: [PATCH 062/328] Update translations --- npbackup/translations/main_gui.en.yml | 3 ++- npbackup/translations/main_gui.fr.yml | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/npbackup/translations/main_gui.en.yml b/npbackup/translations/main_gui.en.yml index 3983617..f0ecc07 100644 --- a/npbackup/translations/main_gui.en.yml +++ b/npbackup/translations/main_gui.en.yml @@ -1,5 +1,6 @@ en: - loading_data_from_repo: Loading data from repo + loading_snapshot_list_from_repo: Loading snapshot list from repo + loading_last_snapshot_date: Loading last snapshot date this_will_take_a_while: This will take a while cannot_get_content: Cannot get content. Pleasse check the logs cannot_get_repo_status: Cannot get repo status diff --git a/npbackup/translations/main_gui.fr.yml b/npbackup/translations/main_gui.fr.yml index 4e1025a..0871630 100644 --- a/npbackup/translations/main_gui.fr.yml +++ b/npbackup/translations/main_gui.fr.yml @@ -1,5 +1,6 @@ fr: - loading_data_from_repo: Chargement d'informations depuis le dépot + loading_snapshot_list_from_repo: Chargement liste des instantanés depuis le dépit + loading_last_snapshot_date: Chargement date du dernier instantané this_will_take_a_while: Cela prendra quelques instants cannot_get_content: Impossible d'obtenir le contenu. Veuillez vérifier les journaux cannot_get_repo_status: Impossible d'obtenir l'état du dépot From 819c29070c4cc13156a74a07c06b8aaa5fcf7174 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Sat, 23 Dec 2023 20:07:22 +0100 Subject: [PATCH 063/328] Cosmetic updates --- npbackup/gui/helpers.py | 37 +++++++-------- npbackup/gui/operations.py | 52 +++++++++++++-------- npbackup/translations/generic.en.yml | 1 + npbackup/translations/generic.fr.yml | 1 + npbackup/translations/main_gui.en.yml | 6 ++- npbackup/translations/main_gui.fr.yml | 6 ++- npbackup/translations/operations_gui.en.yml | 13 +++--- npbackup/translations/operations_gui.fr.yml | 11 +++-- 8 files changed, 74 insertions(+), 53 deletions(-) diff --git a/npbackup/gui/helpers.py b/npbackup/gui/helpers.py index 678e077..f97c298 100644 --- a/npbackup/gui/helpers.py +++ b/npbackup/gui/helpers.py @@ -73,31 +73,26 @@ def gui_thread_runner(__repo_config: dict, __fn_name: str, __compact: bool = Tru runner.stderr = stderr_queue stderr_has_messages = False + if not __gui_msg: + __gui_msg = "Operation" if USE_THREADING: - """ - thread = fn(*args, **kwargs) - while not thread.done() and not thread.cancelled(): - sg.PopupAnimated( - LOADER_ANIMATION, - message=_t("main_gui.loading_data_from_repo"), - time_between_frames=50, - background_color=GUI_LOADER_COLOR, - text_color=GUI_LOADER_TEXT_COLOR, - ) - sg.PopupAnimated(None) - return thread.result() - """ progress_layout = [ - [sg.Text(__gui_msg, text_color=GUI_LOADER_TEXT_COLOR, visible=__compact, justification='C')], - [sg.Text(_t("operations_gui.last_message"), key="-OPERATIONS-PROGRESS-STDOUT-TITLE-", visible=not __compact)], - [sg.Multiline(key="-OPERATIONS-PROGRESS-STDOUT-", size=(40, 10), visible=not __compact)], - [sg.Text(_t("operations_gui.error_messages"), key="-OPERATIONS-PROGRESS-STDERR-TITLE-", visible=not __compact)], - [sg.Multiline(key="-OPERATIONS-PROGRESS-STDERR-", size=(40, 10), visible=not __compact)], + [sg.Text(__gui_msg, text_color=GUI_LOADER_TEXT_COLOR, background_color=GUI_LOADER_COLOR, visible=__compact, justification='C')], + [sg.Text(_t("main_gui.last_messages"), key="-OPERATIONS-PROGRESS-STDOUT-TITLE-", text_color=GUI_LOADER_TEXT_COLOR, background_color=GUI_LOADER_COLOR, visible=not __compact)], + [sg.Multiline(key="-OPERATIONS-PROGRESS-STDOUT-", size=(70, 5), visible=not __compact)], + [sg.Text(_t("main_gui.error_messages"), key="-OPERATIONS-PROGRESS-STDERR-TITLE-", text_color=GUI_LOADER_TEXT_COLOR, background_color=GUI_LOADER_COLOR, visible=not __compact)], + [sg.Multiline(key="-OPERATIONS-PROGRESS-STDERR-", size=(70, 10), visible=not __compact)], [sg.Image(LOADER_ANIMATION, key="-LOADER-ANIMATION-")], - [sg.Text(_t("generic.finished"), key="-FINISHED-", visible=False)], - [sg.Button(_t("generic.close"), key="--EXIT--")], + [sg.Text(_t("generic.finished"), key="-FINISHED-", text_color=GUI_LOADER_TEXT_COLOR, visible=False)], + [sg.Button(_t("generic.close"), key="--EXIT--", button_color=('white', sg.COLOR_SYSTEM_DEFAULT))], ] - progress_window = sg.Window(__gui_msg, progress_layout, no_titlebar=True, grab_anywhere=True, background_color=GUI_LOADER_COLOR) + + full_layout = [ + [sg.Column(progress_layout, element_justification='C', background_color=GUI_LOADER_COLOR)] + ] + + progress_window = sg.Window(__gui_msg, full_layout, no_titlebar=True, grab_anywhere=True, + background_color=GUI_LOADER_COLOR) event, values = progress_window.read(timeout=0.01) read_stdout_queue = True diff --git a/npbackup/gui/operations.py b/npbackup/gui/operations.py index 1a47e05..998ca6b 100644 --- a/npbackup/gui/operations.py +++ b/npbackup/gui/operations.py @@ -10,13 +10,9 @@ __build__ = "2023121901" -from typing import Tuple from logging import getLogger -import queue import PySimpleGUI as sg import npbackup.configuration as configuration -from ofunctions.threading import threaded, Future -from npbackup.core.runner import NPBackupRunner from npbackup.core.i18n_helper import _t from npbackup.gui.helpers import get_anon_repo_uri, gui_thread_runner from npbackup.customization import ( @@ -71,7 +67,7 @@ def operations_gui(full_config: dict) -> dict: [ [ sg.Column( - [[sg.Image(data=OEM_LOGO, size=(64, 64))]], + [[sg.Image(data=OEM_LOGO)]], vertical_alignment="top", ), sg.Column( @@ -95,37 +91,44 @@ def operations_gui(full_config: dict) -> dict: ], [ sg.Button( - _t("operations_gui.quick_check"), key="--QUICK-CHECK--" + _t("operations_gui.quick_check"), key="--QUICK-CHECK--", + size=(45, 1) ), sg.Button( - _t("operations_gui.full_check"), key="--FULL-CHECK--" + _t("operations_gui.full_check"), key="--FULL-CHECK--", + size=(45, 1) ), ], [ sg.Button( - _t("operations_gui.repair_index"), key="--REPAIR-INDEX--" + _t("operations_gui.repair_index"), key="--REPAIR-INDEX--", + size=(45, 1) ), sg.Button( - _t("operations_gui.repair_snapshots"), key="--REPAIR-SNAPSHOTS--" + _t("operations_gui.repair_snapshots"), key="--REPAIR-SNAPSHOTS--", + size=(45, 1) ), ], [ sg.Button( - _t("operations.gui.unlock"), key="--UNLOCK--" - ) - ], - [ + _t("operations.gui.unlock"), key="--UNLOCK--", + size=(45, 1) + ), sg.Button( _t("operations_gui.forget_using_retention_policy"), key="forget", + size=(45, 1) ) ], [ sg.Button( _t("operations_gui.standard_prune"), key="--STANDARD-PRUNE--", + size=(45, 1) ), - sg.Button(_t("operations_gui.max_prune"), key="--MAX-PRUNE--"), + sg.Button(_t("operations_gui.max_prune"), key="--MAX-PRUNE--", + size=(45, 1) + ), ], [sg.Button(_t("generic.quit"), key="--EXIT--")], ], @@ -137,7 +140,7 @@ def operations_gui(full_config: dict) -> dict: window = sg.Window( "Configuration", layout, - size=(600, 600), + #size=(600, 600), text_justification="C", auto_size_text=True, auto_size_buttons=True, @@ -145,7 +148,7 @@ def operations_gui(full_config: dict) -> dict: grab_anywhere=True, keep_on_top=False, alpha_channel=1.0, - default_button_element_size=(12, 1), + default_button_element_size=(20, 1), finalize=True, ) @@ -163,6 +166,7 @@ def operations_gui(full_config: dict) -> dict: "--FORGET--", "--QUICK-CHECK--", "--FULL-CHECK--", + "--UNLOCK--" "--REPAIR-INDEX--", "--REPAIR-SNAPSHOTS--", "--STANDARD-PRUNE--", @@ -177,7 +181,7 @@ def operations_gui(full_config: dict) -> dict: continue repos = complete_repo_list else: - repos = values["repo-list"] + repos = complete_repo_list.index(values["repo-list"]) # TODO multi select repo_config_list = [] for repo_name, backend_type, repo_uri in repos: @@ -186,26 +190,36 @@ def operations_gui(full_config: dict) -> dict: if event == "--FORGET--": operation = "forget" op_args = {} + gui_msg = _t("operations_gui.forget_using_retention_policy") if event == "--QUICK-CHECK--": operation = "check" op_args = {"read_data": False} + gui_msg = _t("operations_gui.quick_check") if event == "--FULL-CHECK--": operation = "check" op_args = {"read_data": True} + gui_msg = _t("operations_gui.full_check") + if event == "--UNLOCK--": + operation = "unlock" + op_args = {} + gui_msg = _t("operations_gui.unlock") if event == "--REPAIR-INDEX--": operation = "repair" op_args = {"subject": "index"} + gui_msg = _t("operations_gui.repair_index") if event == "--REPAIR-SNAPSHOTS--": operation = "repair" op_args = {"subject": "snapshots"} + gui_msg = _t("operations_gui.repair_snapshots") if event == "--STANDARD-PRUNE--": operation = "prune" op_args = {} + gui_msg = _t("operations_gui.standard_prune") if event == "--MAX-PRUNE--": operation = "prune" op_args = {} - - result = gui_thread_runner(None, 'group_runner', operation=operation, repo_config_list=repo_config_list, **op_args) + gui_msg = _t("operations_gui.max_prune") + result = gui_thread_runner(None, 'group_runner', operation=operation, repo_config_list=repo_config_list, __gui_msg=gui_msg, **op_args) event = "---STATE-UPDATE---" if event == "---STATE-UPDATE---": diff --git a/npbackup/translations/generic.en.yml b/npbackup/translations/generic.en.yml index f5580c7..2baadbd 100644 --- a/npbackup/translations/generic.en.yml +++ b/npbackup/translations/generic.en.yml @@ -8,6 +8,7 @@ en: create: Create change: Change close: Close + finished: Finished yes: Yes no: No diff --git a/npbackup/translations/generic.fr.yml b/npbackup/translations/generic.fr.yml index 43b0968..69ec4fd 100644 --- a/npbackup/translations/generic.fr.yml +++ b/npbackup/translations/generic.fr.yml @@ -8,6 +8,7 @@ fr: create: Créer change: Changer close: Fermer + finished: Terminé yes: Oui no: Non diff --git a/npbackup/translations/main_gui.en.yml b/npbackup/translations/main_gui.en.yml index f0ecc07..b75c654 100644 --- a/npbackup/translations/main_gui.en.yml +++ b/npbackup/translations/main_gui.en.yml @@ -34,4 +34,8 @@ en: execute_operation: Executing operation forget_failed: Failed to forget. Please check the logs operations: Operations - select_config_file: Select config file \ No newline at end of file + select_config_file: Select config file + + # logs + last_messages: Last messages + error_messages: Error messages \ No newline at end of file diff --git a/npbackup/translations/main_gui.fr.yml b/npbackup/translations/main_gui.fr.yml index 0871630..719f9ec 100644 --- a/npbackup/translations/main_gui.fr.yml +++ b/npbackup/translations/main_gui.fr.yml @@ -34,4 +34,8 @@ fr: execute_operation: Opération en cours forget_failed: Oubli impossible. Veuillez vérifier les journaux operations: Opérations - select_config_file: Sélectionner fichier de configuration \ No newline at end of file + select_config_file: Sélectionner fichier de configuration + + # logs + last_messages: Last messages + error_messages: Error messages \ No newline at end of file diff --git a/npbackup/translations/operations_gui.en.yml b/npbackup/translations/operations_gui.en.yml index 151db28..dfdbb92 100644 --- a/npbackup/translations/operations_gui.en.yml +++ b/npbackup/translations/operations_gui.en.yml @@ -1,13 +1,14 @@ en: configured_repositories: Configured repositories - quick_check: Quick check - full_check: Full check - repair_index: Repair index - repair_snapshots: Repair snapshots + quick_check: Quick check of repo + full_check: Full check of repo + repair_index: Repair repo index + repair_snapshots: Repair repo snapshots + unlock: Unlock repo forget_using_retention_policy: Forget using retention polic - standard_prune: Normal prune operation + standard_prune: Normal prune data max_prune: Prune with maximum efficiency apply_to_all: Apply to all repos ? add_repo: Add repo edit_repo: Edit repo - remove_repo: Remove repo \ No newline at end of file + remove_repo: Remove repo diff --git a/npbackup/translations/operations_gui.fr.yml b/npbackup/translations/operations_gui.fr.yml index ab20937..651fa2b 100644 --- a/npbackup/translations/operations_gui.fr.yml +++ b/npbackup/translations/operations_gui.fr.yml @@ -1,10 +1,11 @@ fr: configured_repositories: Dépots configurés - quick_check: Vérification rapide - full_check: Vérification complète - repair_index: Réparer index - repair_snapshots: Réparer les sauvegardes - forget_using_retention_policy: Oublier en utilisant la stratégie de rétention + quick_check: Vérification rapide dépot + full_check: Vérification complète dépot + repair_index: Réparer les index du dépot + repair_snapshots: Réparer les instantanés du dépot + unlock: Déblocage de dépot + forget_using_retention_policy: Oublier les instantanés en utilisant la stratégie de rétention standard_prune: Opération de purge normale max_prune: Opération de purge la plus efficace appply_to_all: Appliquer à tous les dépots ? From f7865efe498a7b13113e75adb62a9f36b48379e2 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Tue, 26 Dec 2023 12:37:48 +0100 Subject: [PATCH 064/328] Cosmetic improvements --- npbackup/gui/helpers.py | 28 +++++++++++++++------------- npbackup/gui/operations.py | 2 +- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/npbackup/gui/helpers.py b/npbackup/gui/helpers.py index f97c298..d26989e 100644 --- a/npbackup/gui/helpers.py +++ b/npbackup/gui/helpers.py @@ -12,6 +12,7 @@ from typing import Tuple, Callable from logging import getLogger +from time import sleep import re import queue import PySimpleGUI as sg @@ -77,22 +78,22 @@ def gui_thread_runner(__repo_config: dict, __fn_name: str, __compact: bool = Tru __gui_msg = "Operation" if USE_THREADING: progress_layout = [ - [sg.Text(__gui_msg, text_color=GUI_LOADER_TEXT_COLOR, background_color=GUI_LOADER_COLOR, visible=__compact, justification='C')], + # Replaced by custom title bar + # [sg.Text(__gui_msg, text_color=GUI_LOADER_TEXT_COLOR, background_color=GUI_LOADER_COLOR, visible=__compact, justification='C')], [sg.Text(_t("main_gui.last_messages"), key="-OPERATIONS-PROGRESS-STDOUT-TITLE-", text_color=GUI_LOADER_TEXT_COLOR, background_color=GUI_LOADER_COLOR, visible=not __compact)], [sg.Multiline(key="-OPERATIONS-PROGRESS-STDOUT-", size=(70, 5), visible=not __compact)], [sg.Text(_t("main_gui.error_messages"), key="-OPERATIONS-PROGRESS-STDERR-TITLE-", text_color=GUI_LOADER_TEXT_COLOR, background_color=GUI_LOADER_COLOR, visible=not __compact)], [sg.Multiline(key="-OPERATIONS-PROGRESS-STDERR-", size=(70, 10), visible=not __compact)], [sg.Image(LOADER_ANIMATION, key="-LOADER-ANIMATION-")], - [sg.Text(_t("generic.finished"), key="-FINISHED-", text_color=GUI_LOADER_TEXT_COLOR, visible=False)], - [sg.Button(_t("generic.close"), key="--EXIT--", button_color=('white', sg.COLOR_SYSTEM_DEFAULT))], + [sg.Button(_t("generic.close"), key="--EXIT--", button_color=(GUI_LOADER_TEXT_COLOR, GUI_LOADER_COLOR))], ] full_layout = [ [sg.Column(progress_layout, element_justification='C', background_color=GUI_LOADER_COLOR)] ] - progress_window = sg.Window(__gui_msg, full_layout, no_titlebar=True, grab_anywhere=True, - background_color=GUI_LOADER_COLOR) + progress_window = sg.Window(__gui_msg, full_layout, use_custom_titlebar=True, grab_anywhere=True, + background_color=GUI_LOADER_COLOR, ) event, values = progress_window.read(timeout=0.01) read_stdout_queue = True @@ -102,6 +103,11 @@ def gui_thread_runner(__repo_config: dict, __fn_name: str, __compact: bool = Tru grace_counter = 100 # 2s since we read 2x queues with 0.01 seconds thread = fn(*args, **kwargs) while True: + progress_window["-LOADER-ANIMATION-"].UpdateAnimation( + LOADER_ANIMATION, time_between_frames=100 + ) + # So we actually need to read the progress window for it to refresh... + _, _ = progress_window.read(0.01) # Read stdout queue try: stdout_data = stdout_queue.get(timeout=0.01) @@ -132,17 +138,13 @@ def gui_thread_runner(__repo_config: dict, __fn_name: str, __compact: bool = Tru f"{progress_window['-OPERATIONS-PROGRESS-STDERR-'].get()}\n{stderr_data}" ) - progress_window["-LOADER-ANIMATION-"].UpdateAnimation( - LOADER_ANIMATION, time_between_frames=100 - ) - # So we actually need to read the progress window for it to refresh... - _, _ = progress_window.read(0.01) - if thread_alive: thread_alive = not thread.done and not thread.cancelled() read_queues = read_stdout_queue or read_stderr_queue if not thread_alive and not read_queues: + # Arbitrary wait time so window get's time to get fully drawn + sleep(.2) break if not thread_alive and read_queues: # Let's read the queue for a grace period if queues are not closed @@ -152,14 +154,14 @@ def gui_thread_runner(__repo_config: dict, __fn_name: str, __compact: bool = Tru progress_window["-OPERATIONS-PROGRESS-STDERR-"].Update( f"{progress_window['-OPERATIONS-PROGRESS-STDERR-'].get()}\nGRACE COUNTER FOR output queues encountered. Thread probably died." ) + # Make sure we will keep the window visible since we have errors __autoclose = False break # Keep the window open until user has done something progress_window["-LOADER-ANIMATION-"].Update(visible=False) - progress_window["-FINISHED-"].Update(visible=True) if not __autoclose or stderr_has_messages: - while True: + while True and progress_window: event, _ = progress_window.read() if event in (sg.WIN_CLOSED, sg.WIN_X_EVENT, "--EXIT--"): break diff --git a/npbackup/gui/operations.py b/npbackup/gui/operations.py index 998ca6b..838e659 100644 --- a/npbackup/gui/operations.py +++ b/npbackup/gui/operations.py @@ -111,7 +111,7 @@ def operations_gui(full_config: dict) -> dict: ], [ sg.Button( - _t("operations.gui.unlock"), key="--UNLOCK--", + _t("operations_gui.unlock"), key="--UNLOCK--", size=(45, 1) ), sg.Button( From e3a10a1def3d87152f5ed31f261d88998887466e Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Tue, 26 Dec 2023 13:43:00 +0100 Subject: [PATCH 065/328] Don't use GUI threads when debugging, also improve cosmetics --- npbackup/gui/helpers.py | 203 ++++++++++++++++++++++------------------ 1 file changed, 111 insertions(+), 92 deletions(-) diff --git a/npbackup/gui/helpers.py b/npbackup/gui/helpers.py index d26989e..c2a514b 100644 --- a/npbackup/gui/helpers.py +++ b/npbackup/gui/helpers.py @@ -19,14 +19,19 @@ from npbackup.core.i18n_helper import _t from npbackup.customization import LOADER_ANIMATION, GUI_LOADER_COLOR, GUI_LOADER_TEXT_COLOR from npbackup.core.runner import NPBackupRunner - -# For debugging purposes, we should be able to disable threading to see actual errors -# out of thread -USE_THREADING = True +from npbackup.__debug__ import _DEBUG logger = getLogger() +# For debugging purposes, we should be able to disable threading to see actual errors +# out of thread +if not _DEBUG: + USE_THREADING = True +else: + USE_THREADING = False + logger.info("Running without threads as per debug requirements") + def get_anon_repo_uri(repository: str) -> Tuple[str, str]: """ @@ -56,10 +61,11 @@ def get_anon_repo_uri(repository: str) -> Tuple[str, str]: return backend_type, backend_uri -# TODO: add compact popupanimation + error gui only def gui_thread_runner(__repo_config: dict, __fn_name: str, __compact: bool = True, __autoclose: bool = False, __gui_msg: str = "", *args, **kwargs): """ - Runs any NPBackupRunner functions in threads if needed + Runs any NPBackupRunner functions in threads for GUI + also gets stdout and stderr queues output into gui window + Has a grace period after thread end to get queue output, so we can see whenever a thread dies of mysterious causes """ runner = NPBackupRunner() # So we don't always init repo_config, since runner.group_runner would do that itself @@ -76,100 +82,113 @@ def gui_thread_runner(__repo_config: dict, __fn_name: str, __compact: bool = Tru stderr_has_messages = False if not __gui_msg: __gui_msg = "Operation" + + progress_layout = [ + # Replaced by custom title bar + # [sg.Text(__gui_msg, text_color=GUI_LOADER_TEXT_COLOR, background_color=GUI_LOADER_COLOR, visible=__compact, justification='C')], + [sg.Text(_t("main_gui.last_messages"), key="-OPERATIONS-PROGRESS-STDOUT-TITLE-", text_color=GUI_LOADER_TEXT_COLOR, background_color=GUI_LOADER_COLOR, visible=not __compact)], + [sg.Multiline(key="-OPERATIONS-PROGRESS-STDOUT-", size=(70, 5), visible=not __compact)], + [sg.Text(_t("main_gui.error_messages"), key="-OPERATIONS-PROGRESS-STDERR-TITLE-", text_color=GUI_LOADER_TEXT_COLOR, background_color=GUI_LOADER_COLOR, visible=not __compact)], + [sg.Multiline(key="-OPERATIONS-PROGRESS-STDERR-", size=(70, 10), visible=not __compact)], + [sg.Column( + [ + [ + sg.Image(LOADER_ANIMATION, key="-LOADER-ANIMATION-", visible=USE_THREADING) + ], + [ + sg.Text("Debugging active", visible=not USE_THREADING) + ] + ], expand_x=True, justification='C', element_justification='C', background_color=GUI_LOADER_COLOR)], + [sg.Button(_t("generic.close"), key="--EXIT--", button_color=(GUI_LOADER_TEXT_COLOR, GUI_LOADER_COLOR))], + ] + + full_layout = [ + [sg.Column(progress_layout, element_justification='C', expand_x=True, background_color=GUI_LOADER_COLOR)] + ] + + progress_window = sg.Window(__gui_msg, full_layout, use_custom_titlebar=True, grab_anywhere=True, keep_on_top=True, + background_color=GUI_LOADER_COLOR) + event, values = progress_window.read(timeout=0.01) + + read_stdout_queue = True + read_stderr_queue = True + read_queues = True if USE_THREADING: - progress_layout = [ - # Replaced by custom title bar - # [sg.Text(__gui_msg, text_color=GUI_LOADER_TEXT_COLOR, background_color=GUI_LOADER_COLOR, visible=__compact, justification='C')], - [sg.Text(_t("main_gui.last_messages"), key="-OPERATIONS-PROGRESS-STDOUT-TITLE-", text_color=GUI_LOADER_TEXT_COLOR, background_color=GUI_LOADER_COLOR, visible=not __compact)], - [sg.Multiline(key="-OPERATIONS-PROGRESS-STDOUT-", size=(70, 5), visible=not __compact)], - [sg.Text(_t("main_gui.error_messages"), key="-OPERATIONS-PROGRESS-STDERR-TITLE-", text_color=GUI_LOADER_TEXT_COLOR, background_color=GUI_LOADER_COLOR, visible=not __compact)], - [sg.Multiline(key="-OPERATIONS-PROGRESS-STDERR-", size=(70, 10), visible=not __compact)], - [sg.Image(LOADER_ANIMATION, key="-LOADER-ANIMATION-")], - [sg.Button(_t("generic.close"), key="--EXIT--", button_color=(GUI_LOADER_TEXT_COLOR, GUI_LOADER_COLOR))], - ] - - full_layout = [ - [sg.Column(progress_layout, element_justification='C', background_color=GUI_LOADER_COLOR)] - ] - - progress_window = sg.Window(__gui_msg, full_layout, use_custom_titlebar=True, grab_anywhere=True, - background_color=GUI_LOADER_COLOR, ) - event, values = progress_window.read(timeout=0.01) - - read_stdout_queue = True - read_stderr_queue = True - read_queues = True thread_alive = True grace_counter = 100 # 2s since we read 2x queues with 0.01 seconds thread = fn(*args, **kwargs) - while True: - progress_window["-LOADER-ANIMATION-"].UpdateAnimation( - LOADER_ANIMATION, time_between_frames=100 - ) - # So we actually need to read the progress window for it to refresh... - _, _ = progress_window.read(0.01) - # Read stdout queue - try: - stdout_data = stdout_queue.get(timeout=0.01) - except queue.Empty: - pass + else: + thread_alive = False + kwargs = { + **kwargs, + **{"__no_threads": True} + } + result = runner.__getattribute__(fn.__name__)(*args, **kwargs) + while True: + progress_window["-LOADER-ANIMATION-"].UpdateAnimation( + LOADER_ANIMATION, time_between_frames=100 + ) + # So we actually need to read the progress window for it to refresh... + _, _ = progress_window.read(0.01) + # Read stdout queue + try: + stdout_data = stdout_queue.get(timeout=0.01) + except queue.Empty: + pass + else: + if stdout_data is None: + read_stdout_queue = False else: - if stdout_data is None: - read_stdout_queue = False - else: - progress_window["-OPERATIONS-PROGRESS-STDOUT-"].Update( - f"{progress_window['-OPERATIONS-PROGRESS-STDOUT-'].get()}\n{stdout_data}" - ) - - # Read stderr queue - try: - stderr_data = stderr_queue.get(timeout=0.01) - except queue.Empty: - pass + progress_window["-OPERATIONS-PROGRESS-STDOUT-"].Update( + f"{progress_window['-OPERATIONS-PROGRESS-STDOUT-'].get()}\n{stdout_data}" + ) + + # Read stderr queue + try: + stderr_data = stderr_queue.get(timeout=0.01) + except queue.Empty: + pass + else: + if stderr_data is None: + read_stderr_queue = False else: - if stderr_data is None: - read_stderr_queue = False - else: - stderr_has_messages = True - if __compact: - for key in progress_window.AllKeysDict: - progress_window[key].Update(visible=True) - progress_window["-OPERATIONS-PROGRESS-STDERR-"].Update( - f"{progress_window['-OPERATIONS-PROGRESS-STDERR-'].get()}\n{stderr_data}" - ) - - if thread_alive: - thread_alive = not thread.done and not thread.cancelled() - read_queues = read_stdout_queue or read_stderr_queue - - if not thread_alive and not read_queues: - # Arbitrary wait time so window get's time to get fully drawn - sleep(.2) - break - if not thread_alive and read_queues: - # Let's read the queue for a grace period if queues are not closed - grace_counter -= 1 - - if grace_counter < 1: + stderr_has_messages = True + if __compact: + for key in progress_window.AllKeysDict: + progress_window[key].Update(visible=True) progress_window["-OPERATIONS-PROGRESS-STDERR-"].Update( - f"{progress_window['-OPERATIONS-PROGRESS-STDERR-'].get()}\nGRACE COUNTER FOR output queues encountered. Thread probably died." + f"{progress_window['-OPERATIONS-PROGRESS-STDERR-'].get()}\n{stderr_data}" ) - # Make sure we will keep the window visible since we have errors - __autoclose = False - break - # Keep the window open until user has done something - progress_window["-LOADER-ANIMATION-"].Update(visible=False) - if not __autoclose or stderr_has_messages: - while True and progress_window: - event, _ = progress_window.read() - if event in (sg.WIN_CLOSED, sg.WIN_X_EVENT, "--EXIT--"): - break - progress_window.close() + if thread_alive: + thread_alive = not thread.done and not thread.cancelled() + read_queues = read_stdout_queue or read_stderr_queue + + if not thread_alive and not read_queues: + # Arbitrary wait time so window get's time to get fully drawn + sleep(.2) + break + if USE_THREADING and not thread_alive and read_queues: + # Let's read the queue for a grace period if queues are not closed + grace_counter -= 1 + + if USE_THREADING and grace_counter < 1: + progress_window["-OPERATIONS-PROGRESS-STDERR-"].Update( + f"{progress_window['-OPERATIONS-PROGRESS-STDERR-'].get()}\nGRACE COUNTER FOR output queues encountered. Thread probably died." + ) + # Make sure we will keep the window visible since we have errors + __autoclose = False + break + + # Keep the window open until user has done something + progress_window["-LOADER-ANIMATION-"].Update(visible=False) + if not __autoclose or stderr_has_messages: + while True and not progress_window.is_closed(): + event, _ = progress_window.read() + if event in (sg.WIN_CLOSED, sg.WIN_X_EVENT, "--EXIT--"): + break + progress_window.close() + if USE_THREADING: return thread.result() else: - kwargs = { - **kwargs, - **{"__no_threads": True} - } - return runner.__getattr__(fn)(*args, **kwargs) + return result From 15bdc9b8037ee2eedc0115dc09acd4117e239f19 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Tue, 26 Dec 2023 14:01:15 +0100 Subject: [PATCH 066/328] Cosmetic changes --- npbackup/gui/__main__.py | 4 ++-- npbackup/gui/helpers.py | 2 +- npbackup/translations/main_gui.en.yml | 4 ++-- npbackup/translations/main_gui.fr.yml | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/npbackup/gui/__main__.py b/npbackup/gui/__main__.py index 87d82a5..008265c 100644 --- a/npbackup/gui/__main__.py +++ b/npbackup/gui/__main__.py @@ -365,7 +365,7 @@ def _restore_window( def backup(repo_config: dict) -> bool: gui_msg = _t("main_gui.backup_activity") - result = gui_thread_runner(repo_config, 'backup', __gui_msg=gui_msg) + result = gui_thread_runner(repo_config, 'backup', force=True, __autoclose=True, __compact=False, __gui_msg=gui_msg) if not result: sg.PopupError( _t("main_gui.backup_failed"), keep_on_top=True @@ -582,7 +582,7 @@ def gui_update_state() -> None: continue if event == "--LAUNCH-BACKUP--": backup(repo_config) - event = "--STATE-BUTTON" + event = "--STATE-BUTTON--" if event == "--SEE-CONTENT--": if not values["snapshot-list"]: sg.Popup(_t("main_gui.select_backup"), keep_on_top=True) diff --git a/npbackup/gui/helpers.py b/npbackup/gui/helpers.py index c2a514b..ee450aa 100644 --- a/npbackup/gui/helpers.py +++ b/npbackup/gui/helpers.py @@ -93,7 +93,7 @@ def gui_thread_runner(__repo_config: dict, __fn_name: str, __compact: bool = Tru [sg.Column( [ [ - sg.Image(LOADER_ANIMATION, key="-LOADER-ANIMATION-", visible=USE_THREADING) + sg.Image(LOADER_ANIMATION, key="-LOADER-ANIMATION-", background_color=GUI_LOADER_COLOR, visible=USE_THREADING) ], [ sg.Text("Debugging active", visible=not USE_THREADING) diff --git a/npbackup/translations/main_gui.en.yml b/npbackup/translations/main_gui.en.yml index b75c654..6cff404 100644 --- a/npbackup/translations/main_gui.en.yml +++ b/npbackup/translations/main_gui.en.yml @@ -24,8 +24,8 @@ en: backup_from: Backup from backup_activity: Backup activity backup_in_progress: Backup in progress - backup_done: Backup done in %{seconds} seconds - backup_failed: Backup failed in %{seconds} seconds. Please check the logs + backup_done: Backup done + backup_failed: Backup failed. Please check the logs select_backup: Please select a backup run_as: run as identified_by: identified by diff --git a/npbackup/translations/main_gui.fr.yml b/npbackup/translations/main_gui.fr.yml index 719f9ec..7c2b6a9 100644 --- a/npbackup/translations/main_gui.fr.yml +++ b/npbackup/translations/main_gui.fr.yml @@ -24,8 +24,8 @@ fr: backup_from: Sauvegarde du backup_activity: Activité de sauvegarde backup_in_progress: Sauvegarde en cours - backup_done: Sauvegarde terminée en %{seconds} secondes - backup_failed: Sauvegarde échouée en %{seconds} secondes. veuillez vérifier les journaux + backup_done: Sauvegarde terminée + backup_failed: Sauvegarde échouée. veuillez vérifier les journaux select_backup: Veuillez sélectionner une sauvegarde run_as: faite en tant que identified_by: identifiée en tant que From e1e307e1c02791b3c71299910b3c2c6ccc1b7ffe Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Tue, 26 Dec 2023 14:01:50 +0100 Subject: [PATCH 067/328] Implement retention strategy and unlock --- npbackup/configuration.py | 9 ++++-- npbackup/core/runner.py | 48 ++++++++++++++++++++++++++--- npbackup/gui/operations.py | 10 +++--- npbackup/restic_wrapper/__init__.py | 35 ++++++++++++++++++--- 4 files changed, 87 insertions(+), 15 deletions(-) diff --git a/npbackup/configuration.py b/npbackup/configuration.py index bfeb318..da3b2e3 100644 --- a/npbackup/configuration.py +++ b/npbackup/configuration.py @@ -177,14 +177,19 @@ def d(self, path, sep="."): "upload_speed": 1000000, # in KiB, use 0 for unlimited upload speed "download_speed": 0, # in KiB, use 0 for unlimited download speed "backend_connections": 0, # Fine tune simultaneous connections to backend, use 0 for standard configuration - "retention": { + "retention_strategy": { + "last": 0, "hourly": 72, "daily": 30, "weekly": 4, "monthly": 12, "yearly": 3, - "ntp_time_server": None, + "tags": [], + "within": True, + "ntp_time_server": None, # TODO }, + "prune_max_unused": None, + "prune_max_repack_size": None, }, "prometheus": { "backup_job": "${MACHINE_ID}", diff --git a/npbackup/core/runner.py b/npbackup/core/runner.py index 50a8669..3216edf 100644 --- a/npbackup/core/runner.py +++ b/npbackup/core/runner.py @@ -324,6 +324,7 @@ def wrapper(self, *args, **kwargs): "find": ["backup", "restore", "full"], "restore": ["restore", "full"], "check": ["restore", "full"], + "unlock": ["full"], "repair": ["full"], "forget": ["full"], "prune": ["full"], @@ -792,9 +793,34 @@ def restore(self, snapshot: str, target: str, restore_includes: List[str]) -> bo @has_permission @is_ready @apply_config_to_restic_runner - def forget(self, snapshots: Union[List[str], str]) -> bool: + def forget(self, snapshots: Optional[Union[List[str], str]] = None, use_policy: bool = None) -> bool: self.write_logs(f"Forgetting snapshots {snapshots}", level="info") - result = self.restic_runner.forget(snapshots) + if snapshots: + result = self.restic_runner.forget(snapshots) + elif use_policy: + # Build policiy + # policy = {'keep-within-hourly': 123} + policy = {} + for entry in ["last", "hourly", "daily", "weekly", "monthly", "yearly"]: + value = self.repo_config.g(f"repo_opts.retention_strategy.{entry}") + if value: + if not self.repo_config.g("repo_opts.retention_strategy.within"): + policy[f"keep-{entry}"] = value + else: + # We need to add a type value for keep-within + policy[f"keep-within-{entry}"] = value + keep_tags = self.repo_config.g("repo_opts.retention_strategy.tags") + if not isinstance(keep_tags, list) and keep_tags: + keep_tags = [keep_tags] + policy["keep-tags"] = keep_tags + # Fool proof, don't run without policy, or else we'll get + if not policy: + self.write_logs(f"Empty retention policy. Won't run", level="error") + return False + self.write_logs(f"Retention policy:\n{policy}", level="info") + result = self.restic_runner.forget(policy=policy) + else: + self.write_logs("Bogus options given to forget: snapshots={snapshots}, policy={policy}", level="critical", raise_error=True) return result @exec_timer @@ -817,9 +843,12 @@ def check(self, read_data: bool = True) -> bool: @has_permission @is_ready @apply_config_to_restic_runner - def prune(self) -> bool: + def prune(self, max: bool = False) -> bool: self.write_logs(f"Pruning snapshots for repo {self.repo_config.g('name')}", level="info") - result = self.restic_runner.prune() + if max: + max_unused = self.repo_config.g("prune_max_unused") + max_repack_size = self.repo_config.g("prune_max_repack_size") + result = self.restic_runner.prune(max_unused=max_unused, max_repack_size=max_repack_size) return result @exec_timer @@ -833,6 +862,17 @@ def repair(self, subject: str) -> bool: result = self.restic_runner.repair(subject) return result + @exec_timer + @close_queues + @threaded + @has_permission + @is_ready + @apply_config_to_restic_runner + def unlock(self) -> bool: + self.write_logs(f"Unlocking repo {self.repo_config.g('name')}", level="info") + result = self.restic_runner.unlock() + return result + @exec_timer @close_queues @threaded diff --git a/npbackup/gui/operations.py b/npbackup/gui/operations.py index 838e659..c003743 100644 --- a/npbackup/gui/operations.py +++ b/npbackup/gui/operations.py @@ -116,7 +116,7 @@ def operations_gui(full_config: dict) -> dict: ), sg.Button( _t("operations_gui.forget_using_retention_policy"), - key="forget", + key="--FORGET--", size=(45, 1) ) ], @@ -163,12 +163,12 @@ def operations_gui(full_config: dict) -> dict: if event in (sg.WIN_CLOSED, sg.WIN_X_EVENT, "--EXIT--"): break if event in ( - "--FORGET--", "--QUICK-CHECK--", "--FULL-CHECK--", - "--UNLOCK--" "--REPAIR-INDEX--", "--REPAIR-SNAPSHOTS--", + "--UNLOCK--", + "--FORGET--", "--STANDARD-PRUNE--", "--MAX-PRUNE--", ): @@ -189,7 +189,7 @@ def operations_gui(full_config: dict) -> dict: repo_config_list.append((repo_name, repo_config)) if event == "--FORGET--": operation = "forget" - op_args = {} + op_args = {"use_policy": True} gui_msg = _t("operations_gui.forget_using_retention_policy") if event == "--QUICK-CHECK--": operation = "check" @@ -217,7 +217,7 @@ def operations_gui(full_config: dict) -> dict: gui_msg = _t("operations_gui.standard_prune") if event == "--MAX-PRUNE--": operation = "prune" - op_args = {} + op_args = {"max": True} gui_msg = _t("operations_gui.max_prune") result = gui_thread_runner(None, 'group_runner', operation=operation, repo_config_list=repo_config_list, __gui_msg=gui_msg, **op_args) diff --git a/npbackup/restic_wrapper/__init__.py b/npbackup/restic_wrapper/__init__.py index 28ea6e0..3ba0434 100644 --- a/npbackup/restic_wrapper/__init__.py +++ b/npbackup/restic_wrapper/__init__.py @@ -713,7 +713,7 @@ def restore(self, snapshot: str, target: str, includes: List[str] = None): return False def forget( - self, snapshots: Union[List[str], Optional[str]] = None, policy: Optional[dict] = None + self, snapshots: Optional[Union[List[str], Optional[str]]] = None, policy: Optional[dict] = None ) -> bool: """ Execute forget command for given snapshot @@ -732,7 +732,16 @@ def forget( else: cmds = f"forget {snapshots}" if policy: - cmds = ["format {}".format(policy)] # TODO # WIP + cmd = "forget" + for key, value in policy.items(): + if key == 'keep-tags': + if isinstance(value, list): + for tag in value: + if tag: + cmd += f" --keep-tag {tag}" + else: + cmd += f" --{key.replace('_', '-')} {value}" + cmds = [cmd] # We need to be verbose here since server errors will not stop client from deletion attempts verbose = self.verbose @@ -749,13 +758,17 @@ def forget( self.verbose = verbose return batch_result - def prune(self) -> bool: + def prune(self, max_unused: Optional[str] = None, max_repack_size: Optional[int] = None) -> bool: """ Prune forgotten snapshots """ if not self.is_init: return None cmd = "prune" + if max_unused: + cmd += f"--max-unused {max_unused}" + if max_repack_size: + cmd += f"--max-repack-size {max_repack_size}" verbose = self.verbose self.verbose = True result, output = self.executor(cmd) @@ -788,13 +801,27 @@ def repair(self, subject: str) -> bool: return None if subject not in ["index", "snapshots"]: self.write_logs(f"Bogus repair order given: {subject}", level="error") - cmd = "repair {}".format(subject) + cmd = f"repair {subject}" result, output = self.executor(cmd) if result: self.write_logs(f"Repo successfully repaired:\n{output}", level="info") return True self.write_logs(f"Repo repair failed:\n {output}", level="critical") return False + + def unlock(self) -> bool: + """ + Remove stale locks from repos + """ + if not self.is_init: + return None + cmd = f"unlock" + result, output = self.executor(cmd) + if result: + self.write_logs(f"Repo successfully unlocked:\n{output}", level="info") + return True + self.write_logs(f"Repo unlock failed:\n {output}", level="critical") + return False def raw(self, command: str) -> Tuple[bool, str]: """ From c02a0c46389f639c120a290b5288b9a67e9fb2f3 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Wed, 27 Dec 2023 13:14:49 +0100 Subject: [PATCH 068/328] Cosmetic changes --- npbackup/customization.py | 3 ++- npbackup/gui/helpers.py | 15 +++++++++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/npbackup/customization.py b/npbackup/customization.py index a44ec42..47784bf 100644 --- a/npbackup/customization.py +++ b/npbackup/customization.py @@ -46,7 +46,8 @@ OEM_STRING = "NetPerfect Portable Network Backup Client" # OEM LOGO (base64 encoded png file) OEM_LOGO = b"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" -OEM_ICON = b"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" +OEM_ICON_OLD = b"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" +OEM_ICON = b"iVBORw0KGgoAAAANSUhEUgAAABkAAAAZCAYAAADE6YVjAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAALiAAAC4gAdUcHhsAAAW8SURBVEhLnVZrbFRFFD4z+2q7j25baNOCpQ8oSAUKtAgYRUjBqAlQRAViDcofoyb8MBqiifGBRCJGMFoVSipEiBKDoAalhVK08ujD1iYtFbTstkKxdIHt7u0+7r1zPHO3bFoKiH7J7Nw5M3O+c86cObMM7gBCYA5jUEifE6g5qAWpeam1M8Y81N8WtyRRNd1iNvEn6XMdtWIlpDouX1UgqgmwmDi4HDZw2W0Bi5k30/xOal8RoUr9KNyUBBHnU7ctHNWLbRYTkBfQ2x/E5e+fgIYuhUECx5npNnbfeAeUFmXA3GlZkJFqb6I964nohKFkGEaREIG0vIKadd/R3/UUh40tvjeHy7lz3VdE+3kfaDqyHbVeqO4PM9AQprmtuHHpRPbQvNwoGfU8EUnP4jA2X8cQQSUCWuU4EtVhyfY23tHVL+R4UnYqX75gEl+5qADuybIjcAb5djP4ozpbVtUOm3Y3W/3BSOWQnjjiJDJEArGirqsBVF0zZCZSIi19aVcru+QLoiEk6ILMwNgwza5DrjsADxSo8FZjD2zc3QxKWK2Q+owFBINECGGhbuspT4t1V/t3YGLDHLQw+NGrsLe/aAE6/DgRUqTTTREoUTrgjfA2KLfshTm207ClqQ/2HTkrI7GViKTeGAnFcFUgopS83liJSZzm5UkTpLEzUqzwybJcUdFyhVXsb0PyFmm93AV+bRCmuhrA6fbDtd5OUNNSYabLBM8e8sjzK6FFq6Se6yY/09TdBkevHme28DhyLSamoICZQrZ6yRRWuSJfvHLAy/fXnjVsMDFkLrMbalLLoN5cCIfdj4ELp4AiVYY0OFh/XqpYK384GZYjUBTXnD9Jwyxg/vFSHkdIN86XlT8ylW0ozcTHd3ey2qZuwTnDyzoDb48LPutfBr2hEjh+gcHZCMKKPCcW5qTI7XOkfjN9TPWHgs4jvt/oBuYBE3aKhAx9LGTyl248Wsmll1cX4Z/9J7G0qs00K8kM2XReLQMMkhPTcE2eA15bMAZnTk5nEzKTIdEmVRvV4W75NcEfHoDG6N8I0VkMbCY5ORIxPkh1JbLNa2dh5wen4FdFg3lOC368cqKYUTCWZY5xMKoQwzImDuOSOSJalMyliyWM63Fb5I5zsx1rpwtJnOu2itK5OeyuDBdFbyhbCDgYECLoR6EMyKFDkgRtZlLOEyj4RHYHKCnMZM9NSsa93YrphW31/OnNdbCnujOe3tGuduZ/swBUzxk5DEoSb3KCC0qsGQysPiLT5UQcRiaRmUNDAzK1p2Q59HV5To0mNFUTGgmNqiCh9/Ug9vUxU/IYOfTKM+lITnQEStNmOBsHfgC0KKQlppOSCoJ043v7Fd3t1ECnTHParcyeYOEvriySmRm3nqIV2yQ01Dp+Yjz/YeCp6QGSdMhQejjjTYtzqQrgRQgmX4gftCwfnkthKNj0C09/tZZnrq/h9a1/GRaTd5wO2nS9ybGUqz1/oNb6EbMUPwE8ydkk9Rt5RqianT194Yb0hVhmrWYsshQhMY1NznZT9uTRw0KXgmyWb8m4sTIrbw5UIxg6tJ0xugUJxaVSVGVMSMjaRZ43BC40o3oAMHr6HSHUkKwg/wGUUN/vFP2rKbnq9ktBAzWjdsVBgvnUIuqZrzH0DWBEEg36dLn63yBCigge/FT3lQMG9ryLQo1ESByvwiNAE+toC6qdREQehavLhOY5pouwX3p1o2dChBURaT+tX3uvXPjWEMGXW1BEBuXciPdkRGpKDC2o0HtbrPqZD1EMfM5Y0qPIUx6kipjPwEz3SWgMA14MHa9hatO3jGcXYWLZZpYwe1EUTOZRL+MoEgkiMt54VJVicakV9It1gNd+pqfysCzMsV2W+5k2MAf4+EVgmzYXuCv1lm/8LYFCl8nwFLVjFJkAhQz1a92o+86hftVD53CFxNpAbN5YN/KQh+GmntwIUpBD3f/83wXwD9xwKuX5NyKgAAAAAElFTkSuQmCC" FOLDER_ICON = b"iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAAsSAAALEgHS3X78AAABnUlEQVQ4y8WSv2rUQRSFv7vZgJFFsQg2EkWb4AvEJ8hqKVilSmFn3iNvIAp21oIW9haihBRKiqwElMVsIJjNrprsOr/5dyzml3UhEQIWHhjmcpn7zblw4B9lJ8Xag9mlmQb3AJzX3tOX8Tngzg349q7t5xcfzpKGhOFHnjx+9qLTzW8wsmFTL2Gzk7Y2O/k9kCbtwUZbV+Zvo8Md3PALrjoiqsKSR9ljpAJpwOsNtlfXfRvoNU8Arr/NsVo0ry5z4dZN5hoGqEzYDChBOoKwS/vSq0XW3y5NAI/uN1cvLqzQur4MCpBGEEd1PQDfQ74HYR+LfeQOAOYAmgAmbly+dgfid5CHPIKqC74L8RDyGPIYy7+QQjFWa7ICsQ8SpB/IfcJSDVMAJUwJkYDMNOEPIBxA/gnuMyYPijXAI3lMse7FGnIKsIuqrxgRSeXOoYZUCI8pIKW/OHA7kD2YYcpAKgM5ABXk4qSsdJaDOMCsgTIYAlL5TQFTyUIZDmev0N/bnwqnylEBQS45UKnHx/lUlFvA3fo+jwR8ALb47/oNma38cuqiJ9AAAAAASUVORK5CYII=" FILE_ICON = b"iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAAsSAAALEgHS3X78AAABU0lEQVQ4y52TzStEURiHn/ecc6XG54JSdlMkNhYWsiILS0lsJaUsLW2Mv8CfIDtr2VtbY4GUEvmIZnKbZsY977Uwt2HcyW1+dTZvt6fn9557BGB+aaNQKBR2ifkbgWR+cX13ubO1svz++niVTA1ArDHDg91UahHFsMxbKWycYsjze4muTsP64vT43v7hSf/A0FgdjQPQWAmco68nB+T+SFSqNUQgcIbN1bn8Z3RwvL22MAvcu8TACFgrpMVZ4aUYcn77BMDkxGgemAGOHIBXxRjBWZMKoCPA2h6qEUSRR2MF6GxUUMUaIUgBCNTnAcm3H2G5YQfgvccYIXAtDH7FoKq/AaqKlbrBj2trFVXfBPAea4SOIIsBeN9kkCwxsNkAqRWy7+B7Z00G3xVc2wZeMSI4S7sVYkSk5Z/4PyBWROqvox3A28PN2cjUwinQC9QyckKALxj4kv2auK0xAAAAAElFTkSuQmCC" LOADER_ANIMATION = b"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" diff --git a/npbackup/gui/helpers.py b/npbackup/gui/helpers.py index ee450aa..4883e4f 100644 --- a/npbackup/gui/helpers.py +++ b/npbackup/gui/helpers.py @@ -20,10 +20,19 @@ from npbackup.customization import LOADER_ANIMATION, GUI_LOADER_COLOR, GUI_LOADER_TEXT_COLOR from npbackup.core.runner import NPBackupRunner from npbackup.__debug__ import _DEBUG - +from npbackup.customization import ( + PYSIMPLEGUI_THEME, + OEM_ICON, + OEM_LOGO +) logger = getLogger() + +sg.theme(PYSIMPLEGUI_THEME) +sg.SetOptions(icon=OEM_ICON) + + # For debugging purposes, we should be able to disable threading to see actual errors # out of thread if not _DEBUG: @@ -107,7 +116,7 @@ def gui_thread_runner(__repo_config: dict, __fn_name: str, __compact: bool = Tru ] progress_window = sg.Window(__gui_msg, full_layout, use_custom_titlebar=True, grab_anywhere=True, keep_on_top=True, - background_color=GUI_LOADER_COLOR) + background_color=GUI_LOADER_COLOR, titlebar_icon=OEM_ICON) event, values = progress_window.read(timeout=0.01) read_stdout_queue = True @@ -137,6 +146,7 @@ def gui_thread_runner(__repo_config: dict, __fn_name: str, __compact: bool = Tru pass else: if stdout_data is None: + logger.debug("gui_thread_runner got stdout queue close signal") read_stdout_queue = False else: progress_window["-OPERATIONS-PROGRESS-STDOUT-"].Update( @@ -150,6 +160,7 @@ def gui_thread_runner(__repo_config: dict, __fn_name: str, __compact: bool = Tru pass else: if stderr_data is None: + logger.debug("gui_thread_runner got stderr queue close signal") read_stderr_queue = False else: stderr_has_messages = True From 28631184ccbb1c77476b44af67548cdda2b85fd0 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Wed, 27 Dec 2023 13:24:53 +0100 Subject: [PATCH 069/328] Convert restic_wrapper to write_logs --- npbackup/restic_wrapper/__init__.py | 138 +++++++++++++--------------- 1 file changed, 65 insertions(+), 73 deletions(-) diff --git a/npbackup/restic_wrapper/__init__.py b/npbackup/restic_wrapper/__init__.py index 3ba0434..b485b2f 100644 --- a/npbackup/restic_wrapper/__init__.py +++ b/npbackup/restic_wrapper/__init__.py @@ -25,6 +25,7 @@ logger = getLogger() + # Arbitrary timeout for init / init checks. # If init takes more than a minute, we really have a problem INIT_TIMEOUT = 60 @@ -37,10 +38,14 @@ def __init__( password: str, binary_search_paths: List[str] = None, ) -> None: + self._stdout = None + self._stderr = None + self.repository = str(repository).strip() self.password = str(password).strip() self._verbose = False self._dry_run = False + self._binary = None self.binary_search_paths = binary_search_paths self._get_binary() @@ -77,8 +82,6 @@ def __init__( None # Function which will make executor abort if result is True ) self._executor_finished = False # Internal value to check whether executor is done, accessed via self.executor_finished property - self._stdout = None - self._stderr = None def on_exit(self) -> bool: self._executor_finished = True @@ -92,7 +95,7 @@ def _make_env(self) -> None: try: os.environ["RESTIC_PASSWORD"] = str(self.password) except TypeError: - logger.error("Bogus restic password") + self.write_logs("Bogus restic password", level="critical") self.password = None if self.repository: try: @@ -101,11 +104,11 @@ def _make_env(self) -> None: self.repository = os.path.expandvars(self.repository) os.environ["RESTIC_REPOSITORY"] = str(self.repository) except TypeError: - logger.error("Bogus restic repository") + self.write_logs("Bogus restic repository", level="critical") self.repository = None for env_variable, value in self.environment_variables.items(): - logger.debug('Setting envrionment variable "{}"'.format(env_variable)) + self.write_logs(f'Setting envrionment variable "{env_variable}"', level="debug") os.environ[env_variable] = value # Configure default cpu usage when not specifically set @@ -117,6 +120,7 @@ def _make_env(self) -> None: gomaxprocs = nb_cores - 1 elif nb_cores > 4: gomaxprocs = nb_cores - 2 + # No need to use write_logs here logger.debug("Setting GOMAXPROCS to {}".format(gomaxprocs)) os.environ["GOMAXPROCS"] = str(gomaxprocs) @@ -211,7 +215,9 @@ def write_logs(self, msg: str, level: str, raise_error: str = None): logger.debug(msg) else: raise ValueError("Bogus log level given {level}") - + + if msg is None: + raise ValueError("None log message received") if self.stdout and (level == 'info' or (level == 'debug' and _DEBUG)): self.stdout.put(msg) if self.stderr and level in ('critical', 'error', 'warning'): @@ -226,14 +232,13 @@ def executor( self, cmd: str, errors_allowed: bool = False, + no_output_queues: bool = False, timeout: int = None, - live_stream=False, # TODO remove live stream since everything is live ) -> Tuple[bool, str]: """ Executes restic with given command - - When using live_stream, we'll have command_runner fill stdout queue, which is useful for interactive GUI programs, but slower, especially for ls operation - + errors_allowed is needed since we're testing if repo is already initialized + no_output_queues is needed since we don't want is_init output to be logged """ start_time = datetime.utcnow() self._executor_finished = False @@ -247,36 +252,22 @@ def executor( _cmd += " --dry-run" self.write_logs(f"Running command: [{_cmd}]", level="debug") self._make_env() - if live_stream: - exit_code, output = command_runner( - _cmd, - timeout=timeout, - split_streams=False, - encoding="utf-8", - live_output=self.verbose, - valid_exit_codes=errors_allowed, - stdout=None, # TODO we need other local queues to get subprocess output into gui queues - stderr=None, - stop_on=self.stop_on, - on_exit=self.on_exit, - method="poller", - priority=self.priority, - io_priority=self.priority, - ) - else: - exit_code, output = command_runner( - _cmd, - timeout=timeout, - split_streams=False, - encoding="utf-8", - live_output=self.verbose, - valid_exit_codes=errors_allowed, - stop_on=self.stop_on, - on_exit=self.on_exit, - method="monitor", - priority=self._priority, - io_priority=self._priority, - ) + + exit_code, output = command_runner( + _cmd, + timeout=timeout, + split_streams=False, + encoding="utf-8", + stdout=self.stdout if not no_output_queues else None, + stderr=self.stderr if not no_output_queues else None, + no_close_queues=True, + valid_exit_codes=errors_allowed, + stop_on=self.stop_on, + on_exit=self.on_exit, + method="poller", + priority=self._priority, + io_priority=self._priority, + ) # Don't keep protected environment variables in memory when not necessary self._remove_env() @@ -309,7 +300,7 @@ def executor( # From here, we assume that we have errors # We'll log them unless we tried to know if the repo is initialized if not errors_allowed and output: - logger.error(output) + self.write_logs(output, level="error") return False, output @property @@ -343,8 +334,8 @@ def _get_binary(self) -> None: if os.path.isfile(probed_path): self._binary = probed_path return - logger.error( - "No backup engine binary found. Please install latest binary from restic.net" + self.write_logs( + "No backup engine binary found. Please install latest binary from restic.net", level="error" ) @property @@ -390,7 +381,7 @@ def backend_connections(self, value: int): self._backend_connections = 8 except TypeError: - logger.warning("Bogus backend_connections value given.") + self.write_logs("Bogus backend_connections value given.", level="warning") @property def additional_parameters(self): @@ -445,9 +436,9 @@ def binary_version(self) -> Optional[str]: if exit_code == 0: return output.strip() else: - logger.error("Cannot get backend version: {}".format(output)) + self.write_logs("Cannot get backend version: {output}", level="warning") else: - logger.error("Cannot get backend version: No binary defined.") + self.write_logs("Cannot get backend version: No binary defined.", level="error") return None @property @@ -477,8 +468,9 @@ def init( cmd = "init --repository-version {} --compression {}".format( repository_version, compression ) + # We don't want output_queues here since we don't want is already inialized errors to show up result, output = self.executor( - cmd, errors_allowed=errors_allowed, timeout=INIT_TIMEOUT + cmd, errors_allowed=errors_allowed, no_output_queues=True, timeout=INIT_TIMEOUT, ) if result: if re.search( @@ -488,10 +480,10 @@ def init( return True else: if re.search(".*already exists", output, re.IGNORECASE): - logger.info("Repo already initialized.") + self.write_logs("Repo is initialized.", level="info") self.is_init = True return True - logger.error(f"Cannot contact repo: {output}") + self.write_logs(f"Cannot contact repo: {output}", level="error") self.is_init = False return False self.is_init = False @@ -527,7 +519,7 @@ def list(self, obj: str = "snapshots") -> Optional[list]: try: return json.loads(output) except json.decoder.JSONDecodeError: - logger.error("Returned data is not JSON:\n{}".format(output)) + self.write_logs(f"Returned data is not JSON:\n{output}", level="error") logger.debug("Trace:", exc_info=True) return None @@ -552,7 +544,7 @@ def ls(self, snapshot: str) -> Optional[list]: if line: yield json.loads(line) except json.decoder.JSONDecodeError: - logger.error("Returned data is not JSON:\n{}".format(output)) + self.write_logs(f"Returned data is not JSON:\n{output}", level="error") logger.debug("Trace:", exc_info=True) return result @@ -568,7 +560,7 @@ def snapshots(self) -> Optional[list]: try: return json.loads(output) except json.decoder.JSONDecodeError: - logger.error("Returned data is not JSON:\n{}".format(output)) + self.write_logs(f"Returned data is not JSON:\n{output}", level="error") logger.debug("Trace:", exc_info=True) return False return None @@ -591,7 +583,7 @@ def backup( """ if not self.is_init: return None, None - + # Handle various source types if exclude_patterns_source_type in [ "files_from", @@ -606,7 +598,7 @@ def backup( elif exclude_patterns_source_type == "files_from_raw": source_parameter = "--files-from-raw" else: - logger.error("Bogus source type given") + self.write_logs("Bogus source type given", level="error") return False, "" for path in paths: @@ -638,7 +630,7 @@ def backup( case_ignore_param, exclude_file ) else: - logger.error('Exclude file "{}" not found'.format(exclude_file)) + self.write_logs(f"Exclude file '{exclude_file}' not found", level="error") if exclude_caches: cmd += " --exclude-caches" if one_file_system: @@ -646,10 +638,10 @@ def backup( if use_fs_snapshot: if os.name == "nt": cmd += " --use-fs-snapshot" - logger.info("Using VSS snapshot to backup") + self.write_logs("Using VSS snapshot to backup", level="info") else: - logger.warning( - "Parameter --use-fs-snapshot was given, which is only compatible with Windows" + self.write_logs( + "Parameter --use-fs-snapshot was given, which is only compatible with Windows", level="warning" ) for tag in tags: if tag: @@ -657,19 +649,21 @@ def backup( cmd += " --tag {}".format(tag) if additional_backup_only_parameters: cmd += " {}".format(additional_backup_only_parameters) - result, output = self.executor(cmd, live_stream=True) + result, output = self.executor(cmd) if ( use_fs_snapshot and not result and re.search("VSS Error", output, re.IGNORECASE) ): - logger.warning("VSS cannot be used. Backup will be done without VSS.") + self.write_logs("VSS cannot be used. Backup will be done without VSS.", level="error") result, output = self.executor( - cmd.replace(" --use-fs-snapshot", ""), live_stream=True + cmd.replace(" --use-fs-snapshot", "") ) if result: + self.write_logs("Backend finished backup with success", level="info") return True, output + self.write_logs("Backup failed backup operation", level="error") return False, output def find(self, path: str) -> Optional[list]: @@ -681,13 +675,13 @@ def find(self, path: str) -> Optional[list]: cmd = 'find "{}" --json'.format(path) result, output = self.executor(cmd) if result: - logger.info("Successfuly found {}".format(path)) + self.write_logs(f"Successfuly found {path}", level="info") try: return json.loads(output) except json.decoder.JSONDecodeError: - logger.error("Returned data is not JSON:\n{}".format(output)) + self.write_logs(f"Returned data is not JSON:\n{output}", level="error") logger.debug("Trace:", exc_info=True) - logger.warning("Could not find path: {}".format(path)) + self.write_logs(f"Could not find path: {path}") return None def restore(self, snapshot: str, target: str, includes: List[str] = None): @@ -707,9 +701,9 @@ def restore(self, snapshot: str, target: str, includes: List[str] = None): cmd += ' --{}include "{}"'.format(case_ignore_param, include) result, output = self.executor(cmd) if result: - logger.info("successfully restored data.") + self.write_logs("successfully restored data.", level="info") return True - logger.critical("Data not restored: {}".format(output)) + self.write_logs(f"Data not restored: {output}", level="info") return False def forget( @@ -774,9 +768,9 @@ def prune(self, max_unused: Optional[str] = None, max_repack_size: Optional[int] result, output = self.executor(cmd) self.verbose = verbose if result: - logger.info("Successfully pruned repository:\n{}".format(output)) + self.write_logs(f"Successfully pruned repository:\n{output}", level="info") return True - logger.critical("Could not prune repository:\n{}".format(output)) + self.write_logs(f"Could not prune repository:\n{output}", level="error") return False def check(self, read_data: bool = True) -> bool: @@ -871,15 +865,13 @@ def has_snapshot_timedelta( tz_aware_timestamp - backup_ts ).total_seconds() / 60 if delta - snapshot_age_minutes > 0: - logger.debug( - "Recent snapshot {} of {} exists !".format( - snapshot["short_id"], snapshot["time"] - ) + self.write_logs( + f"Recent snapshot {snapshot['short_id']} of {snapshot['time']} exists !", level='info' ) return True, backup_ts return False, backup_ts except IndexError as exc: - logger.debug("snapshot information missing: {}".format(exc)) + self.write_logs(f"snapshot information missing: {exc}", level="error") logger.debug("Trace", exc_info=True) # No 'time' attribute in snapshot ? return None, None From 20dc7faffda48503525b588d0b96e3c7cf939ef0 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Wed, 27 Dec 2023 13:25:32 +0100 Subject: [PATCH 070/328] Remove unnecessary popups --- npbackup/gui/__main__.py | 55 +++++++++++++--------------------------- 1 file changed, 17 insertions(+), 38 deletions(-) diff --git a/npbackup/gui/__main__.py b/npbackup/gui/__main__.py index 008265c..3f39d45 100644 --- a/npbackup/gui/__main__.py +++ b/npbackup/gui/__main__.py @@ -17,10 +17,9 @@ import ofunctions.logger_utils from datetime import datetime import dateutil -import queue from time import sleep import atexit -from ofunctions.threading import threaded, Future +from ofunctions.threading import threaded from ofunctions.misc import BytesConverter import PySimpleGUI as sg import _tkinter @@ -39,11 +38,12 @@ FILE_ICON, LICENSE_TEXT, LICENSE_FILE, + PYSIMPLEGUI_THEME, + OEM_ICON ) from npbackup.gui.config import config_gui from npbackup.gui.operations import operations_gui from npbackup.gui.helpers import get_anon_repo_uri, gui_thread_runner -from npbackup.core.runner import NPBackupRunner from npbackup.core.i18n_helper import _t from npbackup.core.upgrade_runner import run_upgrade, check_new_version from npbackup.path_helper import CURRENT_DIR @@ -51,14 +51,10 @@ from npbackup.__debug__ import _DEBUG from npbackup.gui.config import config_gui from npbackup.gui.operations import operations_gui -from npbackup.customization import ( - PYSIMPLEGUI_THEME, - OEM_ICON, -) LOG_FILE = os.path.join(CURRENT_DIR, "{}.log".format(__intname__)) -logger = ofunctions.logger_utils.logger_get_logger(LOG_FILE) +logger = ofunctions.logger_utils.logger_get_logger(LOG_FILE, debug=_DEBUG) sg.theme(PYSIMPLEGUI_THEME) @@ -298,7 +294,7 @@ def ls_window(repo_config: dict, snapshot_id: str) -> bool: ) while True: event, values = window.read() - if event in (sg.WIN_CLOSED, sg.WIN_CLOSE_ATTEMPTED_EVENT, "quit"): + if event in (sg.WIN_CLOSED, sg.WIN_X_EVENT, "quit"): break if event == "restore_to": if not values["-TREE-"]: @@ -347,48 +343,31 @@ def _restore_window( ) while True: event, values = window.read() - if event in (sg.WIN_CLOSED, sg.WIN_CLOSE_ATTEMPTED_EVENT, "cancel"): + if event in (sg.WIN_CLOSED, sg.WIN_X_EVENT, "cancel"): break if event == "restore": + # on_success = _t("main_gui.restore_done") + # on_failure = _t("main_gui.restore_failed") result = _restore_window(repo_config, snapshot=snapshot_id, target=values["-RESTORE-FOLDER-"], restore_includes=restore_include) - if result: - sg.Popup( - _t("main_gui.restore_done"), keep_on_top=True - ) - else: - sg.PopupError( - _t("main_gui.restore_failed"), keep_on_top=True - ) break window.close() + return result def backup(repo_config: dict) -> bool: gui_msg = _t("main_gui.backup_activity") - result = gui_thread_runner(repo_config, 'backup', force=True, __autoclose=True, __compact=False, __gui_msg=gui_msg) - if not result: - sg.PopupError( - _t("main_gui.backup_failed"), keep_on_top=True - ) - return False - else: - sg.Popup( - _t("main_gui.backup_done"), keep_on_top=True - ) - return True + # on_success = _t("main_gui.backup_done") + # on_failure = _t("main_gui.backup_failed") + result = gui_thread_runner(repo_config, 'backup', force=True, __autoclose=False, __compact=False, __gui_msg=gui_msg) + return result def forget_snapshot(repo_config: dict, snapshot_ids: List[str]) -> bool: gui_msg = f"{_t('generic.forgetting')} {snapshot_ids} {_t('main_gui.this_will_take_a_while')}" + # on_success = f"{snapshot_ids} {_t('generic.forgotten')} {_t('generic.successfully')}" + # on_failure = _t("main_gui.forget_failed") result = gui_thread_runner(repo_config, "forget", snapshots=snapshot_ids, __gui_msg=gui_msg, __autoclose=True) - if not result: - sg.PopupError(_t("main_gui.forget_failed"), keep_on_top=True) - return False - else: - sg.Popup( - f"{snapshot_ids} {_t('generic.forgotten')} {_t('generic.successfully')}" - ) - return True + return result def _main_gui(): @@ -426,7 +405,7 @@ def select_config_file(): def gui_update_state() -> None: if current_state: window["--STATE-BUTTON--"].Update( - "{}: {}".format(_t("generic.up_to_date"), backup_tz), + "{}: {}".format(_t("generic.up_to_date"), backup_tz.replace(microsecond=0)), button_color=GUI_STATE_OK_BUTTON, ) elif current_state is False and backup_tz == datetime(1, 1, 1, 0, 0): From de3db9ec938750cfc5991c07df61792c2da60017 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Wed, 27 Dec 2023 13:26:34 +0100 Subject: [PATCH 071/328] WIP fix runner logs, also remove pre-permission decorator perm --- npbackup/core/runner.py | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/npbackup/core/runner.py b/npbackup/core/runner.py index 3216edf..0eeddbf 100644 --- a/npbackup/core/runner.py +++ b/npbackup/core/runner.py @@ -245,6 +245,8 @@ def write_logs(self, msg: str, level: str, raise_error: str = None): else: raise ValueError("Bogus log level given {level}") + if msg is None: + raise ValueError("None log message received") if self.stdout and (level == 'info' or (level == 'debug' and _DEBUG)): self.stdout.put(msg) if self.stderr and level in ('critical', 'error', 'warning'): @@ -718,6 +720,7 @@ def backup(self, force: bool = False) -> bool: else: self.write_logs(f"Running backup of {paths} to repo {self.repo_config.g('name')}", level="info") + pre_exec_commands_success = True if pre_exec_commands: for pre_exec_command in pre_exec_commands: exit_code, output = command_runner( @@ -729,6 +732,7 @@ def backup(self, force: bool = False) -> bool: ) if pre_exec_failure_is_fatal: return False + pre_exec_commands_success = False else: self.write_logs( "Pre-execution of command {pre_exec_command} success with:\n{output}.", level="info" @@ -747,11 +751,12 @@ def backup(self, force: bool = False) -> bool: tags=tags, additional_backup_only_parameters=additional_backup_only_parameters, ) - logger.debug(f"Restic output:\n{result_string}") + self.write_logs(f"Restic output:\n{result_string}", level="debug") metric_writer( self.repo_config, result, result_string, self.restic_runner.dry_run ) + post_exec_commands_success = True if post_exec_commands: for post_exec_command in post_exec_commands: exit_code, output = command_runner( @@ -761,13 +766,17 @@ def backup(self, force: bool = False) -> bool: self.write_logs( f"Post-execution of command {post_exec_command} failed with:\n{output}", level="error" ) + post_exec_commands_success = False if post_exec_failure_is_fatal: return False else: self.write_logs( - "Post-execution of command {post_exec_command} success with:\n{output}.", level="info" + f"Post-execution of command {post_exec_command} success with:\n{output}.", level="info" ) - return result + + operation_result = result and pre_exec_commands_success and post_exec_commands_success + self.write_logs(f"Operation finished with {'success' if operation_result else 'failure'}", level="info" if operation_result else "error") + return operation_result @exec_timer @close_queues @@ -776,9 +785,6 @@ def backup(self, force: bool = False) -> bool: @is_ready @apply_config_to_restic_runner def restore(self, snapshot: str, target: str, restore_includes: List[str]) -> bool: - if not self.repo_config.g("permissions") in ["restore", "full"]: - self.write_logs(f"You don't have permissions to restore repo {self.repo_config.g('name')}", level="error") - return False self.write_logs(f"Launching restore to {target}", level="info") result = self.restic_runner.restore( snapshot=snapshot, @@ -794,8 +800,8 @@ def restore(self, snapshot: str, target: str, restore_includes: List[str]) -> bo @is_ready @apply_config_to_restic_runner def forget(self, snapshots: Optional[Union[List[str], str]] = None, use_policy: bool = None) -> bool: - self.write_logs(f"Forgetting snapshots {snapshots}", level="info") if snapshots: + self.write_logs(f"Forgetting snapshots {snapshots}", level="info") result = self.restic_runner.forget(snapshots) elif use_policy: # Build policiy @@ -817,7 +823,7 @@ def forget(self, snapshots: Optional[Union[List[str], str]] = None, use_policy: if not policy: self.write_logs(f"Empty retention policy. Won't run", level="error") return False - self.write_logs(f"Retention policy:\n{policy}", level="info") + self.write_logs(f"Forgetting snapshots using retention policy: {policy}", level="info") result = self.restic_runner.forget(policy=policy) else: self.write_logs("Bogus options given to forget: snapshots={snapshots}, policy={policy}", level="critical", raise_error=True) From 4c4960ea4266bb0f96c08a215587a129404f9be1 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Wed, 27 Dec 2023 13:30:48 +0100 Subject: [PATCH 072/328] Fix fr translation --- npbackup/translations/main_gui.fr.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/npbackup/translations/main_gui.fr.yml b/npbackup/translations/main_gui.fr.yml index 7c2b6a9..ff8f6ab 100644 --- a/npbackup/translations/main_gui.fr.yml +++ b/npbackup/translations/main_gui.fr.yml @@ -1,5 +1,5 @@ fr: - loading_snapshot_list_from_repo: Chargement liste des instantanés depuis le dépit + loading_snapshot_list_from_repo: Chargement liste des instantanés depuis le dépot loading_last_snapshot_date: Chargement date du dernier instantané this_will_take_a_while: Cela prendra quelques instants cannot_get_content: Impossible d'obtenir le contenu. Veuillez vérifier les journaux From d288ad2a7cc53ff4d7600e524f24062bb6920464 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Wed, 27 Dec 2023 13:31:09 +0100 Subject: [PATCH 073/328] Fix missing title in loading window --- npbackup/gui/__main__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/npbackup/gui/__main__.py b/npbackup/gui/__main__.py index 3f39d45..89b6328 100644 --- a/npbackup/gui/__main__.py +++ b/npbackup/gui/__main__.py @@ -124,7 +124,7 @@ def about_gui(version_string: str, full_config: dict) -> None: def get_gui_data(repo_config: dict) -> Tuple[bool, List[str]]: gui_msg = _t("main_gui.loading_snapshot_list_from_repo") - snapshots = gui_thread_runner(repo_config, "list", __autoclose=True, __compact=True) + snapshots = gui_thread_runner(repo_config, "list", __gui_msg=gui_msg, __autoclose=True, __compact=True) snapshot_list = [] if snapshots: snapshots.reverse() # Let's show newer snapshots first From 756c3bebd87af1066bef41dcaccedc8c49499b20 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Wed, 27 Dec 2023 15:01:29 +0100 Subject: [PATCH 074/328] Refactor restic_wrapper and main gui for single transaction in status update --- npbackup/gui/__main__.py | 15 +++++--- npbackup/restic_wrapper/__init__.py | 56 +++++++++++++++++------------ 2 files changed, 44 insertions(+), 27 deletions(-) diff --git a/npbackup/gui/__main__.py b/npbackup/gui/__main__.py index 89b6328..2089770 100644 --- a/npbackup/gui/__main__.py +++ b/npbackup/gui/__main__.py @@ -13,6 +13,7 @@ from typing import List, Optional, Tuple import sys import os +import re from pathlib import Path import ofunctions.logger_utils from datetime import datetime @@ -51,6 +52,7 @@ from npbackup.__debug__ import _DEBUG from npbackup.gui.config import config_gui from npbackup.gui.operations import operations_gui +from npbackup.restic_wrapper import ResticRunner LOG_FILE = os.path.join(CURRENT_DIR, "{}.log".format(__intname__)) @@ -125,13 +127,18 @@ def about_gui(version_string: str, full_config: dict) -> None: def get_gui_data(repo_config: dict) -> Tuple[bool, List[str]]: gui_msg = _t("main_gui.loading_snapshot_list_from_repo") snapshots = gui_thread_runner(repo_config, "list", __gui_msg=gui_msg, __autoclose=True, __compact=True) + current_state, backup_tz = ResticRunner._has_snapshot_timedelta(snapshots, repo_config.g("repo_opts.minimum_backup_age")) snapshot_list = [] if snapshots: snapshots.reverse() # Let's show newer snapshots first for snapshot in snapshots: - snapshot_date = dateutil.parser.parse(snapshot["time"]).strftime( - "%Y-%m-%d %H:%M:%S" - ) + if re.match( + r"[0-9]{4}-[0-1][0-9]-[0-3][0-9]T[0-2][0-9]:[0-5][0-9]:[0-5][0-9]\..*\+[0-2][0-9]:[0-9]{2}", + snapshot["time"], + ): + snapshot_date = dateutil.parser.parse(snapshot["time"]).strftime("%Y-%m-%d %H:%M:%S") + else: + snapshot_date = "Unparsable" snapshot_username = snapshot["username"] snapshot_hostname = snapshot["hostname"] snapshot_id = snapshot["short_id"] @@ -148,8 +155,6 @@ def get_gui_data(repo_config: dict) -> Tuple[bool, List[str]]: snapshot_tags, ] ) - gui_msg = _t("main_gui.loading_last_snapshot_date") - current_state, backup_tz = gui_thread_runner(repo_config, "check_recent_backups", __gui_msg=gui_msg, __autoclose=True) return current_state, backup_tz, snapshot_list diff --git a/npbackup/restic_wrapper/__init__.py b/npbackup/restic_wrapper/__init__.py index b485b2f..80469ae 100644 --- a/npbackup/restic_wrapper/__init__.py +++ b/npbackup/restic_wrapper/__init__.py @@ -829,9 +829,38 @@ def raw(self, command: str) -> Tuple[bool, str]: return True, output self.write_logs("Raw command failed.", level="error") return False, output + + @staticmethod + def _has_snapshot_timedelta(snapshot_list: List, delta: int = None) -> Tuple[bool, Optional[datetime]]: + """ + Making the actual comparaison a static method so we can call it from GUI too + + Expects a restic snasphot_list (which is most recent at the end ordered) + Returns bool if delta (in minutes) is not reached since last successful backup, and returns the last backup timestamp + """ + # Don't bother to deal with mising delta or snapshot list + if not snapshot_list or not delta: + return False, datetime(1, 1, 1, 0, 0) + tz_aware_timestamp = datetime.now(timezone.utc).astimezone() + # Begin with most recent snapshot + snapshot_list.reverse() + for snapshot in snapshot_list: + if re.match( + r"[0-9]{4}-[0-1][0-9]-[0-3][0-9]T[0-2][0-9]:[0-5][0-9]:[0-5][0-9]\..*\+[0-2][0-9]:[0-9]{2}", + snapshot["time"], + ): + backup_ts = dateutil.parser.parse(snapshot["time"]) + snapshot_age_minutes = ( + tz_aware_timestamp - backup_ts + ).total_seconds() / 60 + if delta - snapshot_age_minutes > 0: + logger.info( + f"Recent snapshot {snapshot['short_id']} of {snapshot['time']} exists !" + ) + return True, backup_ts def has_snapshot_timedelta( - self, delta: int = 1441 + self, delta: int = None ) -> Tuple[bool, Optional[datetime]]: """ Checks if a snapshot exists that is newer that delta minutes @@ -845,31 +874,14 @@ def has_snapshot_timedelta( """ if not self.is_init: return None + # Don't bother to deal with mising delta + if not delta: + return False, None try: snapshots = self.snapshots() if self.last_command_status is False: return None, None - if not snapshots: - return False, datetime(1, 1, 1, 0, 0) - - tz_aware_timestamp = datetime.now(timezone.utc).astimezone() - # Begin with most recent snapshot - snapshots.reverse() - for snapshot in snapshots: - if re.match( - r"[0-9]{4}-[0-1][0-9]-[0-3][0-9]T[0-2][0-9]:[0-5][0-9]:[0-5][0-9]\..*\+[0-2][0-9]:[0-9]{2}", - snapshot["time"], - ): - backup_ts = dateutil.parser.parse(snapshot["time"]) - snapshot_age_minutes = ( - tz_aware_timestamp - backup_ts - ).total_seconds() / 60 - if delta - snapshot_age_minutes > 0: - self.write_logs( - f"Recent snapshot {snapshot['short_id']} of {snapshot['time']} exists !", level='info' - ) - return True, backup_ts - return False, backup_ts + return self.has_snapshot_timedelta(snapshots, delta) except IndexError as exc: self.write_logs(f"snapshot information missing: {exc}", level="error") logger.debug("Trace", exc_info=True) From 95c1c74606df4866b3e88f92897dbdc01c58749d Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Wed, 27 Dec 2023 18:08:32 +0100 Subject: [PATCH 075/328] Add repo viewer mode --- bin/npbackup-viewer | 16 ++++ npbackup/gui/__main__.py | 103 +++++++++++++++++--------- npbackup/translations/main_gui.en.yml | 2 + npbackup/translations/main_gui.fr.yml | 2 + 4 files changed, 89 insertions(+), 34 deletions(-) create mode 100644 bin/npbackup-viewer diff --git a/bin/npbackup-viewer b/bin/npbackup-viewer new file mode 100644 index 0000000..c0d6d62 --- /dev/null +++ b/bin/npbackup-viewer @@ -0,0 +1,16 @@ +#! /usr/bin/env python3 +# -*- coding: utf-8 -*- +# +# This file is part of npbackup, and is really just a binary shortcut to launch npbackup.gui.__main__ + +import os +import sys + +sys.path.insert(0, os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))) + +from npbackup.gui.__main__ import main_gui + +del sys.path[0] + +if __name__ == "__main__": + main_gui(viewer_mode=True) diff --git a/npbackup/gui/__main__.py b/npbackup/gui/__main__.py index 2089770..7d1290d 100644 --- a/npbackup/gui/__main__.py +++ b/npbackup/gui/__main__.py @@ -19,6 +19,7 @@ from datetime import datetime import dateutil from time import sleep +from ruamel.yaml.comments import CommentedMap import atexit from ofunctions.threading import threaded from ofunctions.misc import BytesConverter @@ -58,15 +59,14 @@ LOG_FILE = os.path.join(CURRENT_DIR, "{}.log".format(__intname__)) logger = ofunctions.logger_utils.logger_get_logger(LOG_FILE, debug=_DEBUG) - sg.theme(PYSIMPLEGUI_THEME) sg.SetOptions(icon=OEM_ICON) -def about_gui(version_string: str, full_config: dict) -> None: +def about_gui(version_string: str, full_config: dict = None) -> None: license_content = LICENSE_TEXT - if full_config.g("global_options.auto_upgrade_server_url"): + if full_config and full_config.g("global_options.auto_upgrade_server_url"): auto_upgrade_result = check_new_version(full_config) else: auto_upgrade_result = None @@ -122,7 +122,26 @@ def about_gui(version_string: str, full_config: dict) -> None: window.close() - +def viewer_repo_gui(viewer_repo_uri: str = None, viewer_repo_password: str = None) -> Tuple[str, str]: + """ + Ask for repo and password if not defined in env variables + """ + layout = [ + [sg.Text(_t("config_gui.backup_repo_uri"), size=(35, 1)), sg.Input(viewer_repo_uri, key="-REPO-URI-")], + [sg.Text(_t("config_gui.backup_repo_password"), size=(35, 1)), sg.Input(viewer_repo_password, key="-REPO-PASSWORD-", password_char='*')], + [sg.Push(), sg.Button(_t("generic.cancel"), key="--CANCEL--"), sg.Button(_t("generic.accept"), key="--ACCEPT--")] + ] + window = sg.Window("Viewer", layout, keep_on_top=True, grab_anywhere=True) + while True: + event, values = window.read() + if event in (sg.WIN_CLOSED, sg.WIN_X_EVENT, '--CANCEL--'): + break + if event == '--ACCEPT--': + if values['-REPO-URI-'] and values['-REPO-PASSWORD-']: + break + sg.Popup(_t("main_gui.repo_and_password_cannot_be_empty")) + window.close() + return values['-REPO-URI-'], values['-REPO-PASSWORD-'] def get_gui_data(repo_config: dict) -> Tuple[bool, List[str]]: gui_msg = _t("main_gui.loading_snapshot_list_from_repo") @@ -224,28 +243,28 @@ def ls_window(repo_config: dict, snapshot_id: str) -> bool: try: # Since ls returns an iter now, we need to use next - snapshot_id = next(snapshot_content) + snapshot = next(snapshot_content) # Exception that happens when restic cannot successfully get snapshot content except StopIteration: return None, None try: - snap_date = dateutil.parser.parse(snapshot_id["time"]) + snap_date = dateutil.parser.parse(snapshot["time"]) except (KeyError, IndexError): snap_date = "[inconnu]" try: - short_id = snapshot_id["short_id"] + short_id = snapshot["short_id"] except (KeyError, IndexError): short_id = "[inconnu]" try: - username = snapshot_id["username"] + username = snapshot["username"] except (KeyError, IndexError): username = "[inconnu]" try: - hostname = snapshot_id["hostname"] + hostname = snapshot["hostname"] except (KeyError, IndexError): hostname = "[inconnu]" - backup_id = f" {_t('main_gui.backup_content_from')} {snap_date} {_t('main_gui.run_as')} {username}@{hostname} {_t('main_gui.identified_by')} {short_id}" + backup_id = f"{_t('main_gui.backup_content_from')} {snap_date} {_t('main_gui.run_as')} {username}@{hostname} {_t('main_gui.identified_by')} {short_id}" if not backup_id or not snapshot_content: sg.PopupError(_t("main_gui.cannot_get_content"), keep_on_top=True) @@ -375,7 +394,8 @@ def forget_snapshot(repo_config: dict, snapshot_ids: List[str]) -> bool: return result -def _main_gui(): +def _main_gui(viewer_mode: bool): + def select_config_file(): """ Option to select a configuration file @@ -430,24 +450,38 @@ def gui_update_state() -> None: window["snapshot-list"].Update(snapshot_list) - config_file = Path(f"{CURRENT_DIR}/npbackup.conf") - if not config_file.exists(): - while True: - config_file = select_config_file() - if config_file: + if not viewer_mode: + config_file = Path(f"{CURRENT_DIR}/npbackup.conf") + if not config_file.exists(): + while True: config_file = select_config_file() - else: - break + if config_file: + config_file = select_config_file() + else: + break - logger.info(f"Using configuration file {config_file}") - full_config = npbackup.configuration.load_config(config_file) - repo_config, config_inheritance = npbackup.configuration.get_repo_config( - full_config - ) - repo_list = npbackup.configuration.get_repo_list(full_config) + logger.info(f"Using configuration file {config_file}") + full_config = npbackup.configuration.load_config(config_file) + repo_config, config_inheritance = npbackup.configuration.get_repo_config( + full_config + ) + repo_list = npbackup.configuration.get_repo_list(full_config) - backup_destination = _t("main_gui.local_folder") - backend_type, repo_uri = get_anon_repo_uri(repo_config.g("repo_uri")) + backup_destination = _t("main_gui.local_folder") + backend_type, repo_uri = get_anon_repo_uri(repo_config.g("repo_uri")) + else: + # Init empty REPO + repo_config = CommentedMap() + repo_config.s("name", "external") + viewer_repo_uri = os.environ.get("RESTIC_REPOSITORY", None) + viewer_repo_password = os.environ.get("RESTIC_PASSWORD", None) + if not viewer_repo_uri or not viewer_repo_password: + viewer_repo_uri, viewer_repo_password = viewer_repo_gui(viewer_repo_uri, viewer_repo_password) + repo_config.s("repo_uri", viewer_repo_uri) + repo_config.s("repo_opts", CommentedMap()) + repo_config.s("repo_opts.repo_password", viewer_repo_password) + # Let's set default backup age to 24h + repo_config.s("repo_opts.minimum_backup_age", 1440) right_click_menu = ["", [_t("generic.destination")]] headings = [ @@ -469,6 +503,7 @@ def gui_update_state() -> None: sg.Column( [ [sg.Text(OEM_STRING, font="Arial 14")], + [sg.Text(_t("main_gui.viewer_mode"))] if viewer_mode else [], [sg.Text("{}: ".format(_t("main_gui.backup_state")))], [ sg.Button( @@ -492,7 +527,7 @@ def gui_update_state() -> None: enable_events=True, ), sg.Text(f"Type {backend_type}", key="-backend_type-"), - ], + ] if not viewer_mode else [], [ sg.Table( values=[[]], @@ -505,12 +540,12 @@ def gui_update_state() -> None: ], [ sg.Button( - _t("main_gui.launch_backup"), key="--LAUNCH-BACKUP--" + _t("main_gui.launch_backup"), key="--LAUNCH-BACKUP--", disabled=viewer_mode ), sg.Button(_t("main_gui.see_content"), key="--SEE-CONTENT--"), - sg.Button(_t("generic.forget"), key="--FORGET--"), # TODO , visible=False if repo_config.g("permissions") != "full" else True), - sg.Button(_t("main_gui.operations"), key="--OPERATIONS--"), - sg.Button(_t("generic.configure"), key="--CONFIGURE--"), + sg.Button(_t("generic.forget"), key="--FORGET--", disabled=viewer_mode), # TODO , visible=False if repo_config.g("permissions") != "full" else True), + sg.Button(_t("main_gui.operations"), key="--OPERATIONS--", disabled=viewer_mode), + sg.Button(_t("generic.configure"), key="--CONFIGURE--", disabled=viewer_mode), sg.Button(_t("generic.about"), key="--ABOUT--"), sg.Button(_t("generic.quit"), key="--EXIT--"), ], @@ -604,7 +639,7 @@ def gui_update_state() -> None: except (TypeError, KeyError): sg.PopupNoFrame(_t("main_gui.unknown_repo")) if event == "--ABOUT--": - about_gui(version_string, full_config) + about_gui(version_string, full_config if not viewer_mode else None) if event == "--STATE-BUTTON--": current_state, backup_tz, snapshot_list = get_gui_data(repo_config) gui_update_state() @@ -612,13 +647,13 @@ def gui_update_state() -> None: sg.Popup(_t("main_gui.cannot_get_repo_status")) -def main_gui(): +def main_gui(viewer_mode=True): atexit.register( npbackup.common.execution_logs, datetime.utcnow(), ) try: - _main_gui() + _main_gui(viewer_mode=viewer_mode) sys.exit(logger.get_worst_logger_level()) except _tkinter.TclError as exc: logger.critical(f'Tkinter error: "{exc}". Is this a headless server ?') diff --git a/npbackup/translations/main_gui.en.yml b/npbackup/translations/main_gui.en.yml index 6cff404..81ad904 100644 --- a/npbackup/translations/main_gui.en.yml +++ b/npbackup/translations/main_gui.en.yml @@ -35,6 +35,8 @@ en: forget_failed: Failed to forget. Please check the logs operations: Operations select_config_file: Select config file + repo_and_password_cannot_be_empty: Repo and password cannot be empty + viewer_mode: Repository view-only mode # logs last_messages: Last messages diff --git a/npbackup/translations/main_gui.fr.yml b/npbackup/translations/main_gui.fr.yml index ff8f6ab..f69cf30 100644 --- a/npbackup/translations/main_gui.fr.yml +++ b/npbackup/translations/main_gui.fr.yml @@ -35,6 +35,8 @@ fr: forget_failed: Oubli impossible. Veuillez vérifier les journaux operations: Opérations select_config_file: Sélectionner fichier de configuration + repo_and_password_cannot_be_empty: Le dépot et le mot de passe ne peuvent être vides + viewer_mode: Mode visualisation de dépot uniquement # logs last_messages: Last messages From fa8a3d12210262e223aae0e63882486465a97797 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Wed, 27 Dec 2023 18:16:30 +0100 Subject: [PATCH 076/328] Disable default viewer mode --- npbackup/gui/__main__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/npbackup/gui/__main__.py b/npbackup/gui/__main__.py index 7d1290d..13b8aa5 100644 --- a/npbackup/gui/__main__.py +++ b/npbackup/gui/__main__.py @@ -647,7 +647,7 @@ def gui_update_state() -> None: sg.Popup(_t("main_gui.cannot_get_repo_status")) -def main_gui(viewer_mode=True): +def main_gui(viewer_mode=False): atexit.register( npbackup.common.execution_logs, datetime.utcnow(), From 08e9df53854f918c90f11aba5c7fc20f52b53a30 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Wed, 27 Dec 2023 18:16:41 +0100 Subject: [PATCH 077/328] Update icon and logo --- npbackup/customization.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/npbackup/customization.py b/npbackup/customization.py index 47784bf..eee7f3f 100644 --- a/npbackup/customization.py +++ b/npbackup/customization.py @@ -43,10 +43,9 @@ GUI_STATE_UNKNOWN_BUTTON = ("white", "darkgrey") # OEM STRING -OEM_STRING = "NetPerfect Portable Network Backup Client" +OEM_STRING = "NetPerfect Backup Client" # OEM LOGO (base64 encoded png file) -OEM_LOGO = b"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" -OEM_ICON_OLD = b"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" +OEM_LOGO = b"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" OEM_ICON = b"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" FOLDER_ICON = b"iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAAsSAAALEgHS3X78AAABnUlEQVQ4y8WSv2rUQRSFv7vZgJFFsQg2EkWb4AvEJ8hqKVilSmFn3iNvIAp21oIW9haihBRKiqwElMVsIJjNrprsOr/5dyzml3UhEQIWHhjmcpn7zblw4B9lJ8Xag9mlmQb3AJzX3tOX8Tngzg349q7t5xcfzpKGhOFHnjx+9qLTzW8wsmFTL2Gzk7Y2O/k9kCbtwUZbV+Zvo8Md3PALrjoiqsKSR9ljpAJpwOsNtlfXfRvoNU8Arr/NsVo0ry5z4dZN5hoGqEzYDChBOoKwS/vSq0XW3y5NAI/uN1cvLqzQur4MCpBGEEd1PQDfQ74HYR+LfeQOAOYAmgAmbly+dgfid5CHPIKqC74L8RDyGPIYy7+QQjFWa7ICsQ8SpB/IfcJSDVMAJUwJkYDMNOEPIBxA/gnuMyYPijXAI3lMse7FGnIKsIuqrxgRSeXOoYZUCI8pIKW/OHA7kD2YYcpAKgM5ABXk4qSsdJaDOMCsgTIYAlL5TQFTyUIZDmev0N/bnwqnylEBQS45UKnHx/lUlFvA3fo+jwR8ALb47/oNma38cuqiJ9AAAAAASUVORK5CYII=" FILE_ICON = b"iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAAsSAAALEgHS3X78AAABU0lEQVQ4y52TzStEURiHn/ecc6XG54JSdlMkNhYWsiILS0lsJaUsLW2Mv8CfIDtr2VtbY4GUEvmIZnKbZsY977Uwt2HcyW1+dTZvt6fn9557BGB+aaNQKBR2ifkbgWR+cX13ubO1svz++niVTA1ArDHDg91UahHFsMxbKWycYsjze4muTsP64vT43v7hSf/A0FgdjQPQWAmco68nB+T+SFSqNUQgcIbN1bn8Z3RwvL22MAvcu8TACFgrpMVZ4aUYcn77BMDkxGgemAGOHIBXxRjBWZMKoCPA2h6qEUSRR2MF6GxUUMUaIUgBCNTnAcm3H2G5YQfgvccYIXAtDH7FoKq/AaqKlbrBj2trFVXfBPAea4SOIIsBeN9kkCwxsNkAqRWy7+B7Z00G3xVc2wZeMSI4S7sVYkSk5Z/4PyBWROqvox3A28PN2cjUwinQC9QyckKALxj4kv2auK0xAAAAAElFTkSuQmCC" From b54c0c3a4956272c581f1bd02915f17929b20132 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Wed, 27 Dec 2023 21:34:09 +0100 Subject: [PATCH 078/328] Change state button while updating --- npbackup/gui/__main__.py | 69 ++++++++++++++++++++-------------------- 1 file changed, 35 insertions(+), 34 deletions(-) diff --git a/npbackup/gui/__main__.py b/npbackup/gui/__main__.py index 13b8aa5..68476bd 100644 --- a/npbackup/gui/__main__.py +++ b/npbackup/gui/__main__.py @@ -143,40 +143,6 @@ def viewer_repo_gui(viewer_repo_uri: str = None, viewer_repo_password: str = Non window.close() return values['-REPO-URI-'], values['-REPO-PASSWORD-'] -def get_gui_data(repo_config: dict) -> Tuple[bool, List[str]]: - gui_msg = _t("main_gui.loading_snapshot_list_from_repo") - snapshots = gui_thread_runner(repo_config, "list", __gui_msg=gui_msg, __autoclose=True, __compact=True) - current_state, backup_tz = ResticRunner._has_snapshot_timedelta(snapshots, repo_config.g("repo_opts.minimum_backup_age")) - snapshot_list = [] - if snapshots: - snapshots.reverse() # Let's show newer snapshots first - for snapshot in snapshots: - if re.match( - r"[0-9]{4}-[0-1][0-9]-[0-3][0-9]T[0-2][0-9]:[0-5][0-9]:[0-5][0-9]\..*\+[0-2][0-9]:[0-9]{2}", - snapshot["time"], - ): - snapshot_date = dateutil.parser.parse(snapshot["time"]).strftime("%Y-%m-%d %H:%M:%S") - else: - snapshot_date = "Unparsable" - snapshot_username = snapshot["username"] - snapshot_hostname = snapshot["hostname"] - snapshot_id = snapshot["short_id"] - try: - snapshot_tags = " [TAGS: {}]".format(snapshot["tags"]) - except KeyError: - snapshot_tags = "" - snapshot_list.append( - [ - snapshot_id, - snapshot_date, - snapshot_hostname, - snapshot_username, - snapshot_tags, - ] - ) - return current_state, backup_tz, snapshot_list - - @threaded def _make_treedata_from_json(ls_result: List[dict]) -> sg.TreeData: @@ -449,6 +415,41 @@ def gui_update_state() -> None: window["snapshot-list"].Update(snapshot_list) + def get_gui_data(repo_config: dict) -> Tuple[bool, List[str]]: + window['--STATE-BUTTON--'].Update( + _t("generic.please_wait"), button_color="orange" + ) + gui_msg = _t("main_gui.loading_snapshot_list_from_repo") + snapshots = gui_thread_runner(repo_config, "list", __gui_msg=gui_msg, __autoclose=True, __compact=True) + current_state, backup_tz = ResticRunner._has_snapshot_timedelta(snapshots, repo_config.g("repo_opts.minimum_backup_age")) + snapshot_list = [] + if snapshots: + snapshots.reverse() # Let's show newer snapshots first + for snapshot in snapshots: + if re.match( + r"[0-9]{4}-[0-1][0-9]-[0-3][0-9]T[0-2][0-9]:[0-5][0-9]:[0-5][0-9]\..*\+[0-2][0-9]:[0-9]{2}", + snapshot["time"], + ): + snapshot_date = dateutil.parser.parse(snapshot["time"]).strftime("%Y-%m-%d %H:%M:%S") + else: + snapshot_date = "Unparsable" + snapshot_username = snapshot["username"] + snapshot_hostname = snapshot["hostname"] + snapshot_id = snapshot["short_id"] + try: + snapshot_tags = " [TAGS: {}]".format(snapshot["tags"]) + except KeyError: + snapshot_tags = "" + snapshot_list.append( + [ + snapshot_id, + snapshot_date, + snapshot_hostname, + snapshot_username, + snapshot_tags, + ] + ) + return current_state, backup_tz, snapshot_list if not viewer_mode: config_file = Path(f"{CURRENT_DIR}/npbackup.conf") From 98e78cde73009d7bd39b7880f2ceef2d4d3de086 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Wed, 27 Dec 2023 21:34:25 +0100 Subject: [PATCH 079/328] Cosmetic changes --- npbackup/gui/operations.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/npbackup/gui/operations.py b/npbackup/gui/operations.py index c003743..3b10b7d 100644 --- a/npbackup/gui/operations.py +++ b/npbackup/gui/operations.py @@ -219,7 +219,7 @@ def operations_gui(full_config: dict) -> dict: operation = "prune" op_args = {"max": True} gui_msg = _t("operations_gui.max_prune") - result = gui_thread_runner(None, 'group_runner', operation=operation, repo_config_list=repo_config_list, __gui_msg=gui_msg, **op_args) + result = gui_thread_runner(None, 'group_runner', operation=operation, repo_config_list=repo_config_list, __autoclose=False, __compact=False, __gui_msg=gui_msg, **op_args) event = "---STATE-UPDATE---" if event == "---STATE-UPDATE---": From cff302cbec1e49165f9306334dfad76b5de9c683 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Wed, 27 Dec 2023 21:40:34 +0100 Subject: [PATCH 080/328] Remove thread test, also improve UX --- npbackup/gui/helpers.py | 33 ++++++++++++++------------------- 1 file changed, 14 insertions(+), 19 deletions(-) diff --git a/npbackup/gui/helpers.py b/npbackup/gui/helpers.py index 4883e4f..808f328 100644 --- a/npbackup/gui/helpers.py +++ b/npbackup/gui/helpers.py @@ -76,6 +76,12 @@ def gui_thread_runner(__repo_config: dict, __fn_name: str, __compact: bool = Tru also gets stdout and stderr queues output into gui window Has a grace period after thread end to get queue output, so we can see whenever a thread dies of mysterious causes """ + + def _upgrade_from_compact_view(): + for key in ('-OPERATIONS-PROGRESS-STDOUT-TITLE-', '-OPERATIONS-PROGRESS-STDOUT-', + '-OPERATIONS-PROGRESS-STDERR-TITLE-', '-OPERATIONS-PROGRESS-STDERR-'): + progress_window[key].Update(visible=True) + runner = NPBackupRunner() # So we don't always init repo_config, since runner.group_runner would do that itself if __repo_config: @@ -96,9 +102,9 @@ def gui_thread_runner(__repo_config: dict, __fn_name: str, __compact: bool = Tru # Replaced by custom title bar # [sg.Text(__gui_msg, text_color=GUI_LOADER_TEXT_COLOR, background_color=GUI_LOADER_COLOR, visible=__compact, justification='C')], [sg.Text(_t("main_gui.last_messages"), key="-OPERATIONS-PROGRESS-STDOUT-TITLE-", text_color=GUI_LOADER_TEXT_COLOR, background_color=GUI_LOADER_COLOR, visible=not __compact)], - [sg.Multiline(key="-OPERATIONS-PROGRESS-STDOUT-", size=(70, 5), visible=not __compact)], + [sg.Multiline(key="-OPERATIONS-PROGRESS-STDOUT-", size=(70, 5), visible=not __compact, autoscroll=True)], [sg.Text(_t("main_gui.error_messages"), key="-OPERATIONS-PROGRESS-STDERR-TITLE-", text_color=GUI_LOADER_TEXT_COLOR, background_color=GUI_LOADER_COLOR, visible=not __compact)], - [sg.Multiline(key="-OPERATIONS-PROGRESS-STDERR-", size=(70, 10), visible=not __compact)], + [sg.Multiline(key="-OPERATIONS-PROGRESS-STDERR-", size=(70, 10), visible=not __compact, autoscroll=True)], [sg.Column( [ [ @@ -123,11 +129,8 @@ def gui_thread_runner(__repo_config: dict, __fn_name: str, __compact: bool = Tru read_stderr_queue = True read_queues = True if USE_THREADING: - thread_alive = True - grace_counter = 100 # 2s since we read 2x queues with 0.01 seconds thread = fn(*args, **kwargs) else: - thread_alive = False kwargs = { **kwargs, **{"__no_threads": True} @@ -150,7 +153,7 @@ def gui_thread_runner(__repo_config: dict, __fn_name: str, __compact: bool = Tru read_stdout_queue = False else: progress_window["-OPERATIONS-PROGRESS-STDOUT-"].Update( - f"{progress_window['-OPERATIONS-PROGRESS-STDOUT-'].get()}\n{stdout_data}" + f"\n{stdout_data}", append=True ) # Read stderr queue @@ -168,28 +171,20 @@ def gui_thread_runner(__repo_config: dict, __fn_name: str, __compact: bool = Tru for key in progress_window.AllKeysDict: progress_window[key].Update(visible=True) progress_window["-OPERATIONS-PROGRESS-STDERR-"].Update( - f"{progress_window['-OPERATIONS-PROGRESS-STDERR-'].get()}\n{stderr_data}" + f"\n{stderr_data}", append=True ) - if thread_alive: - thread_alive = not thread.done and not thread.cancelled() read_queues = read_stdout_queue or read_stderr_queue - if not thread_alive and not read_queues: + if not read_queues: # Arbitrary wait time so window get's time to get fully drawn sleep(.2) break - if USE_THREADING and not thread_alive and read_queues: - # Let's read the queue for a grace period if queues are not closed - grace_counter -= 1 - - if USE_THREADING and grace_counter < 1: - progress_window["-OPERATIONS-PROGRESS-STDERR-"].Update( - f"{progress_window['-OPERATIONS-PROGRESS-STDERR-'].get()}\nGRACE COUNTER FOR output queues encountered. Thread probably died." - ) + + if stderr_has_messages: + _upgrade_from_compact_view() # Make sure we will keep the window visible since we have errors __autoclose = False - break # Keep the window open until user has done something progress_window["-LOADER-ANIMATION-"].Update(visible=False) From 9becb63e76f73c63d12ae61412313b6af4566767 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Wed, 27 Dec 2023 21:41:02 +0100 Subject: [PATCH 081/328] Reorder decorators so queues get to work well --- npbackup/core/runner.py | 93 +++++++++++++++++++++++++++-------------- 1 file changed, 62 insertions(+), 31 deletions(-) diff --git a/npbackup/core/runner.py b/npbackup/core/runner.py index 0eeddbf..4cefb19 100644 --- a/npbackup/core/runner.py +++ b/npbackup/core/runner.py @@ -273,7 +273,6 @@ def wrapper(self, *args, **kwargs): self.exec_time = (datetime.utcnow() - start_time).total_seconds() self.write_logs(f"Runner took {self.exec_time} seconds for {fn.__name__}", level="info") return result - return wrapper def close_queues(fn: Callable): @@ -361,6 +360,20 @@ def wrapper(self, *args, **kwargs): return False return fn(self, *args, **kwargs) return wrapper + + def catch_exceptions(fn: Callable): + """ + Catch any exception and log it + """ + @wraps(fn) + def wrapper(self, *args, **kwargs): + try: + return fn(self, *args, **kwargs) + except Exception as exc: + self.write_logs(f"Function {fn.__name__} failed with: {exc}") + logger.debug("Trace:", exc_info=True) + return False + return wrapper def create_restic_runner(self) -> None: can_run = True @@ -545,25 +558,31 @@ def _apply_config_to_restic_runner(self) -> bool: # Decorator order is important # Since we want a concurrent.futures.Future result, we need to put the @threaded decorator - # Before any other decorator that would change the results + # before any other decorator that would change the results + # @close_queues should come second, since we want to close queues only once the lower functions are finished + # @exec_timer is next, since we want to calc max exec time (except the close_queues and threaded overhead) + # All others are in no particular order + # but @catch_exceptions should come last, since we aren't supposed to have errors in decorators - @exec_timer - @close_queues @threaded + @close_queues + @exec_timer @has_permission @is_ready @apply_config_to_restic_runner + @catch_exceptions def list(self) -> Optional[dict]: self.write_logs(f"Listing snapshots of repo {self.repo_config.g('name')}", level="info") snapshots = self.restic_runner.snapshots() return snapshots - @exec_timer - @close_queues @threaded + @close_queues + @exec_timer @has_permission @is_ready @apply_config_to_restic_runner + @catch_exceptions # TODO: add json output def find(self, path: str) -> bool: self.write_logs(f"Searching for path {path} in repo {self.repo_config.g('name')}", level="info") @@ -575,23 +594,25 @@ def find(self, path: str) -> bool: return True return False - @exec_timer - @close_queues @threaded + @close_queues + @exec_timer @has_permission @is_ready @apply_config_to_restic_runner + @catch_exceptions def ls(self, snapshot: str) -> Optional[dict]: self.write_logs(f"Showing content of snapshot {snapshot} in repo {self.repo_config.g('name')}", level="info") result = self.restic_runner.ls(snapshot) return result - @exec_timer - @close_queues @threaded + @close_queues + @exec_timer @has_permission @is_ready @apply_config_to_restic_runner + @catch_exceptions def check_recent_backups(self) -> bool: """ Checks for backups in timespan @@ -619,12 +640,13 @@ def check_recent_backups(self) -> bool: self.write_logs("Cannot connect to repository or repository empty.", level="error") return result, backup_tz - @exec_timer - @close_queues @threaded + @close_queues + @exec_timer @has_permission @is_ready @apply_config_to_restic_runner + @catch_exceptions def backup(self, force: bool = False) -> bool: """ Run backup after checking if no recent backup exists, unless force == True @@ -778,12 +800,13 @@ def backup(self, force: bool = False) -> bool: self.write_logs(f"Operation finished with {'success' if operation_result else 'failure'}", level="info" if operation_result else "error") return operation_result - @exec_timer - @close_queues @threaded + @close_queues + @exec_timer @has_permission @is_ready @apply_config_to_restic_runner + @catch_exceptions def restore(self, snapshot: str, target: str, restore_includes: List[str]) -> bool: self.write_logs(f"Launching restore to {target}", level="info") result = self.restic_runner.restore( @@ -793,12 +816,13 @@ def restore(self, snapshot: str, target: str, restore_includes: List[str]) -> bo ) return result - @exec_timer - @close_queues @threaded + @close_queues + @exec_timer @has_permission @is_ready @apply_config_to_restic_runner + @catch_exceptions def forget(self, snapshots: Optional[Union[List[str], str]] = None, use_policy: bool = None) -> bool: if snapshots: self.write_logs(f"Forgetting snapshots {snapshots}", level="info") @@ -829,12 +853,13 @@ def forget(self, snapshots: Optional[Union[List[str], str]] = None, use_policy: self.write_logs("Bogus options given to forget: snapshots={snapshots}, policy={policy}", level="critical", raise_error=True) return result - @exec_timer - @close_queues @threaded + @close_queues + @exec_timer @has_permission @is_ready @apply_config_to_restic_runner + @catch_exceptions def check(self, read_data: bool = True) -> bool: if read_data: self.write_logs(f"Running full data check of repository {self.repo_config.g('name')}", level="info") @@ -843,12 +868,13 @@ def check(self, read_data: bool = True) -> bool: result = self.restic_runner.check(read_data) return result - @exec_timer - @close_queues @threaded + @close_queues + @exec_timer @has_permission @is_ready @apply_config_to_restic_runner + @catch_exceptions def prune(self, max: bool = False) -> bool: self.write_logs(f"Pruning snapshots for repo {self.repo_config.g('name')}", level="info") if max: @@ -857,42 +883,47 @@ def prune(self, max: bool = False) -> bool: result = self.restic_runner.prune(max_unused=max_unused, max_repack_size=max_repack_size) return result - @exec_timer - @close_queues @threaded + @close_queues + @exec_timer @has_permission @is_ready @apply_config_to_restic_runner + @catch_exceptions def repair(self, subject: str) -> bool: self.write_logs(f"Repairing {subject} in repo {self.repo_config.g('name')}", level="info") result = self.restic_runner.repair(subject) return result - @exec_timer - @close_queues @threaded + @close_queues + @exec_timer @has_permission @is_ready @apply_config_to_restic_runner + @catch_exceptions def unlock(self) -> bool: self.write_logs(f"Unlocking repo {self.repo_config.g('name')}", level="info") result = self.restic_runner.unlock() return result - @exec_timer - @close_queues @threaded + @close_queues + @exec_timer @has_permission @is_ready @apply_config_to_restic_runner + @catch_exceptions def raw(self, command: str) -> bool: self.write_logs(f"Running raw command: {command}", level="info") result = self.restic_runner.raw(command=command) return result - @exec_timer @threaded + @close_queues + @exec_timer @has_permission + @catch_exceptions def group_runner(self, repo_config_list: list, operation: str, **kwargs) -> bool: group_result = True @@ -919,8 +950,8 @@ def group_runner(self, repo_config_list: list, operation: str, **kwargs) -> bool group_result = False self.write_logs("Finished execution group operations", level="info") # Manually close the queues at the end - if self.stdout: - self.stdout.put(None) - if self.stderr: - self.stderr.put(None) + #if self.stdout: + # self.stdout.put(None) + #if self.stderr: + # self.stderr.put(None) return group_result From 12e0dbfb845ed96abb7675d56ff7530cee3afd83 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Wed, 27 Dec 2023 21:41:35 +0100 Subject: [PATCH 082/328] Update translationds --- npbackup/translations/generic.en.yml | 4 +++- npbackup/translations/generic.fr.yml | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/npbackup/translations/generic.en.yml b/npbackup/translations/generic.en.yml index 2baadbd..4f20cd2 100644 --- a/npbackup/translations/generic.en.yml +++ b/npbackup/translations/generic.en.yml @@ -57,4 +57,6 @@ en: name: Name type: Type - bogus_data_given: Bogus data given \ No newline at end of file + bogus_data_given: Bogus data given + + please_wait: Please wait \ No newline at end of file diff --git a/npbackup/translations/generic.fr.yml b/npbackup/translations/generic.fr.yml index 69ec4fd..ea4e31e 100644 --- a/npbackup/translations/generic.fr.yml +++ b/npbackup/translations/generic.fr.yml @@ -57,4 +57,6 @@ fr: name: Nom type: Type - bogus_data_given: Données invalides \ No newline at end of file + bogus_data_given: Données invalides + + please_wait: Merci de patienter \ No newline at end of file From b0377353c10380b9fb706e38c5db0ebea9795d88 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Wed, 27 Dec 2023 21:43:09 +0100 Subject: [PATCH 083/328] Always return a date from recent snapshot test --- npbackup/restic_wrapper/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/npbackup/restic_wrapper/__init__.py b/npbackup/restic_wrapper/__init__.py index 80469ae..6408cf5 100644 --- a/npbackup/restic_wrapper/__init__.py +++ b/npbackup/restic_wrapper/__init__.py @@ -838,9 +838,10 @@ def _has_snapshot_timedelta(snapshot_list: List, delta: int = None) -> Tuple[boo Expects a restic snasphot_list (which is most recent at the end ordered) Returns bool if delta (in minutes) is not reached since last successful backup, and returns the last backup timestamp """ + backup_ts = datetime(1, 1, 1, 0, 0) # Don't bother to deal with mising delta or snapshot list if not snapshot_list or not delta: - return False, datetime(1, 1, 1, 0, 0) + return False, backup_ts tz_aware_timestamp = datetime.now(timezone.utc).astimezone() # Begin with most recent snapshot snapshot_list.reverse() @@ -858,6 +859,7 @@ def _has_snapshot_timedelta(snapshot_list: List, delta: int = None) -> Tuple[boo f"Recent snapshot {snapshot['short_id']} of {snapshot['time']} exists !" ) return True, backup_ts + return None, backup_ts def has_snapshot_timedelta( self, delta: int = None From f87a38a0c1c3f604ff81b81e31f3724b71d86ed5 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Wed, 27 Dec 2023 21:44:07 +0100 Subject: [PATCH 084/328] Fix fr translation --- npbackup/translations/operations_gui.fr.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/npbackup/translations/operations_gui.fr.yml b/npbackup/translations/operations_gui.fr.yml index 651fa2b..997be4b 100644 --- a/npbackup/translations/operations_gui.fr.yml +++ b/npbackup/translations/operations_gui.fr.yml @@ -8,7 +8,7 @@ fr: forget_using_retention_policy: Oublier les instantanés en utilisant la stratégie de rétention standard_prune: Opération de purge normale max_prune: Opération de purge la plus efficace - appply_to_all: Appliquer à tous les dépots ? + apply_to_all: Appliquer à tous les dépots ? add_repo: Ajouter dépot edit_repo: Modifier dépot remove_repo: Supprimer dépot \ No newline at end of file From c66d174e355dd7d765a5201ec169e875a6a9111f Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Wed, 27 Dec 2023 21:48:29 +0100 Subject: [PATCH 085/328] Update requirements.txt --- npbackup/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/npbackup/requirements.txt b/npbackup/requirements.txt index f639b8d..1ca0211 100644 --- a/npbackup/requirements.txt +++ b/npbackup/requirements.txt @@ -1,4 +1,4 @@ -command_runner>=1.5.1 +command_runner>=1.5.2 cryptidy>=1.2.2 python-dateutil ofunctions.logger_utils>=2.4.1 From 266151ac3c0992eda8b84cf9794ecd5bd1a52f4d Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Wed, 27 Dec 2023 21:54:52 +0100 Subject: [PATCH 086/328] Avoid double writing restic output to queue --- npbackup/restic_wrapper/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/npbackup/restic_wrapper/__init__.py b/npbackup/restic_wrapper/__init__.py index 6408cf5..c716c25 100644 --- a/npbackup/restic_wrapper/__init__.py +++ b/npbackup/restic_wrapper/__init__.py @@ -300,7 +300,8 @@ def executor( # From here, we assume that we have errors # We'll log them unless we tried to know if the repo is initialized if not errors_allowed and output: - self.write_logs(output, level="error") + # We won't write to stdout/stderr queues since command_runner already did that for us + logger.error(output) return False, output @property From 43a35a2c707b25cad5d79b13f643f27eea16dc2f Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Wed, 27 Dec 2023 22:23:57 +0100 Subject: [PATCH 087/328] Reformat files with black --- npbackup/configuration.py | 4 +- npbackup/core/runner.py | 197 ++++++++++++++++++++-------- npbackup/gui/__main__.py | 133 ++++++++++++++----- npbackup/gui/helpers.py | 133 ++++++++++++++----- npbackup/gui/operations.py | 64 ++++++--- npbackup/restic_wrapper/__init__.py | 75 +++++++---- 6 files changed, 434 insertions(+), 172 deletions(-) diff --git a/npbackup/configuration.py b/npbackup/configuration.py index da3b2e3..2c429e4 100644 --- a/npbackup/configuration.py +++ b/npbackup/configuration.py @@ -186,7 +186,7 @@ def d(self, path, sep="."): "yearly": 3, "tags": [], "within": True, - "ntp_time_server": None, # TODO + "ntp_time_server": None, # TODO }, "prune_max_unused": None, "prune_max_repack_size": None, @@ -433,7 +433,7 @@ def _inherit_group_settings( _config_inheritance.s(key, False) return _repo_config, _config_inheritance - + return _inherit_group_settings(_repo_config, _group_config, _config_inheritance) try: diff --git a/npbackup/core/runner.py b/npbackup/core/runner.py index 4cefb19..91937dd 100644 --- a/npbackup/core/runner.py +++ b/npbackup/core/runner.py @@ -135,7 +135,7 @@ def __init__(self): @property def repo_name(self) -> str: return self._repo_name - + @repo_name.setter def repo_name(self, value: str): if not isinstance(value, str) or not value: @@ -145,7 +145,7 @@ def repo_name(self, value: str): @property def repo_config(self) -> dict: return self._repo_config - + @repo_config.setter def repo_config(self, value: dict): if not isinstance(value, dict): @@ -232,26 +232,26 @@ def write_logs(self, msg: str, level: str, raise_error: str = None): """ Write logs to log file and stdout / stderr queues if exist for GUI usage """ - if level == 'warning': + if level == "warning": logger.warning(msg) - elif level == 'error': + elif level == "error": logger.error(msg) - elif level == 'critical': + elif level == "critical": logger.critical(msg) - elif level == 'info': + elif level == "info": logger.info(msg) - elif level == 'debug': + elif level == "debug": logger.debug(msg) else: raise ValueError("Bogus log level given {level}") if msg is None: raise ValueError("None log message received") - if self.stdout and (level == 'info' or (level == 'debug' and _DEBUG)): + if self.stdout and (level == "info" or (level == "debug" and _DEBUG)): self.stdout.put(msg) - if self.stderr and level in ('critical', 'error', 'warning'): + if self.stderr and level in ("critical", "error", "warning"): self.stderr.put(msg) - + if raise_error == "ValueError": raise ValueError(msg) elif raise_error: @@ -271,8 +271,11 @@ def wrapper(self, *args, **kwargs): # pylint: disable=E1102 (not-callable) result = fn(self, *args, **kwargs) self.exec_time = (datetime.utcnow() - start_time).total_seconds() - self.write_logs(f"Runner took {self.exec_time} seconds for {fn.__name__}", level="info") + self.write_logs( + f"Runner took {self.exec_time} seconds for {fn.__name__}", level="info" + ) return result + return wrapper def close_queues(fn: Callable): @@ -340,7 +343,8 @@ def wrapper(self, *args, **kwargs): operation = fn.__name__ # TODO: enforce permissions self.write_logs( - f"Permissions required are {required_permissions[operation]}", level="info" + f"Permissions required are {required_permissions[operation]}", + level="info", ) except (IndexError, KeyError): self.write_logs("You don't have sufficient permissions", level="error") @@ -348,23 +352,25 @@ def wrapper(self, *args, **kwargs): return fn(self, *args, **kwargs) return wrapper - + def apply_config_to_restic_runner(fn: Callable): """ Decorator to update backend before every run """ - + @wraps(fn) def wrapper(self, *args, **kwargs): if not self._apply_config_to_restic_runner(): return False return fn(self, *args, **kwargs) + return wrapper - + def catch_exceptions(fn: Callable): """ Catch any exception and log it """ + @wraps(fn) def wrapper(self, *args, **kwargs): try: @@ -373,6 +379,7 @@ def wrapper(self, *args, **kwargs): self.write_logs(f"Function {fn.__name__} failed with: {exc}") logger.debug("Trace:", exc_info=True) return False + return wrapper def create_restic_runner(self) -> None: @@ -403,24 +410,28 @@ def create_restic_runner(self) -> None: cr_logger.setLevel(cr_loglevel) if exit_code != 0 or output == "": self.write_logs( - f"Password command failed to produce output:\n{output}", level="error" + f"Password command failed to produce output:\n{output}", + level="error", ) can_run = False elif "\n" in output.strip(): self.write_logs( - "Password command returned multiline content instead of a string", level="error" + "Password command returned multiline content instead of a string", + level="error", ) can_run = False else: password = output else: self.write_logs( - "No password nor password command given. Repo password cannot be empty", level="error" + "No password nor password command given. Repo password cannot be empty", + level="error", ) can_run = False except KeyError: self.write_logs( - "No password nor password command given. Repo password cannot be empty", level="error" + "No password nor password command given. Repo password cannot be empty", + level="error", ) can_run = False self._is_ready = can_run @@ -439,7 +450,7 @@ def create_restic_runner(self) -> None: if binary: if not self._using_dev_binary: self._using_dev_binary = True - self.write_logs("Using dev binary !", level='warning') + self.write_logs("Using dev binary !", level="warning") self.restic_runner.binary = binary def _apply_config_to_restic_runner(self) -> bool: @@ -527,16 +538,21 @@ def _apply_config_to_restic_runner(self) -> bool: expanded_env_vars[key.strip()] = value.strip() except ValueError: self.write_logs( - f'Bogus environment variable "{env_variable}" defined in configuration.', level="error" + f'Bogus environment variable "{env_variable}" defined in configuration.', + level="error", ) except (KeyError, AttributeError, TypeError): - self.write_logs("Bogus environment variables defined in configuration.", level="error") + self.write_logs( + "Bogus environment variables defined in configuration.", level="error" + ) logger.error("Trace:", exc_info=True) try: self.restic_runner.environment_variables = expanded_env_vars except ValueError: - self.write_logs("Cannot initialize additional environment variables", level="error") + self.write_logs( + "Cannot initialize additional environment variables", level="error" + ) try: self.minimum_backup_age = int( @@ -572,7 +588,9 @@ def _apply_config_to_restic_runner(self) -> bool: @apply_config_to_restic_runner @catch_exceptions def list(self) -> Optional[dict]: - self.write_logs(f"Listing snapshots of repo {self.repo_config.g('name')}", level="info") + self.write_logs( + f"Listing snapshots of repo {self.repo_config.g('name')}", level="info" + ) snapshots = self.restic_runner.snapshots() return snapshots @@ -585,7 +603,10 @@ def list(self) -> Optional[dict]: @catch_exceptions # TODO: add json output def find(self, path: str) -> bool: - self.write_logs(f"Searching for path {path} in repo {self.repo_config.g('name')}", level="info") + self.write_logs( + f"Searching for path {path} in repo {self.repo_config.g('name')}", + level="info", + ) result = self.restic_runner.find(path=path) if result: self.write_logs("Found path in:\n", level="info") @@ -602,7 +623,10 @@ def find(self, path: str) -> bool: @apply_config_to_restic_runner @catch_exceptions def ls(self, snapshot: str) -> Optional[dict]: - self.write_logs(f"Showing content of snapshot {snapshot} in repo {self.repo_config.g('name')}", level="info") + self.write_logs( + f"Showing content of snapshot {snapshot} in repo {self.repo_config.g('name')}", + level="info", + ) result = self.restic_runner.ls(snapshot) return result @@ -623,7 +647,8 @@ def check_recent_backups(self) -> bool: self.write_logs("No minimal backup age set set.", level="info") self.write_logs( - f"Searching for a backup newer than {str(timedelta(minutes=self.minimum_backup_age))} ago", level="info" + f"Searching for a backup newer than {str(timedelta(minutes=self.minimum_backup_age))} ago", + level="info", ) self.restic_runner.verbose = False result, backup_tz = self.restic_runner.has_snapshot_timedelta( @@ -631,13 +656,24 @@ def check_recent_backups(self) -> bool: ) self.restic_runner.verbose = self.verbose if result: - self.write_logs(f"Most recent backup in repo {self.repo_config.g('name')} is from {backup_tz}", level="info") + self.write_logs( + f"Most recent backup in repo {self.repo_config.g('name')} is from {backup_tz}", + level="info", + ) elif result is False and backup_tz == datetime(1, 1, 1, 0, 0): - self.write_logs(f"No snapshots found in repo {self.repo_config.g('name')}.", level="info") + self.write_logs( + f"No snapshots found in repo {self.repo_config.g('name')}.", + level="info", + ) elif result is False: - self.write_logs(f"No recent backup found in repo {self.repo_config.g('name')}. Newest is from {backup_tz}", level="info") + self.write_logs( + f"No recent backup found in repo {self.repo_config.g('name')}. Newest is from {backup_tz}", + level="info", + ) elif result is None: - self.write_logs("Cannot connect to repository or repository empty.", level="error") + self.write_logs( + "Cannot connect to repository or repository empty.", level="error" + ) return result, backup_tz @threaded @@ -654,7 +690,10 @@ def backup(self, force: bool = False) -> bool: # Preflight checks paths = self.repo_config.g("backup_opts.paths") if not paths: - self.write_logs(f"No paths to backup defined for repo {self.repo_config.g('name')}.", level="error") + self.write_logs( + f"No paths to backup defined for repo {self.repo_config.g('name')}.", + level="error", + ) return False # Make sure we convert paths to list if only one path is give @@ -666,11 +705,15 @@ def backup(self, force: bool = False) -> bool: for path in paths: if path == self.repo_config.g("repo_uri"): self.write_logs( - f"You cannot backup source into it's own path in repo {self.repo_config.g('name')}. No inception allowed !", level='critical' + f"You cannot backup source into it's own path in repo {self.repo_config.g('name')}. No inception allowed !", + level="critical", ) return False except KeyError: - self.write_logs(f"No backup source given for repo {self.repo_config.g('name')}.", level='error') + self.write_logs( + f"No backup source given for repo {self.repo_config.g('name')}.", + level="error", + ) return False exclude_patterns_source_type = self.repo_config.g( @@ -729,7 +772,10 @@ def backup(self, force: bool = False) -> bool: self.restic_runner.verbose = False if not self.restic_runner.is_init: if not self.restic_runner.init(): - self.write_logs(f"Cannot continue, repo {self.repo_config.g('name')} is not defined.", level="critical") + self.write_logs( + f"Cannot continue, repo {self.repo_config.g('name')} is not defined.", + level="critical", + ) return False if self.check_recent_backups() and not force: self.write_logs("No backup necessary.", level="info") @@ -738,9 +784,15 @@ def backup(self, force: bool = False) -> bool: # Run backup here if exclude_patterns_source_type not in ["folder_list", None]: - self.write_logs(f"Running backup of files in {paths} list to repo {self.repo_config.g('name')}", level="info") + self.write_logs( + f"Running backup of files in {paths} list to repo {self.repo_config.g('name')}", + level="info", + ) else: - self.write_logs(f"Running backup of {paths} to repo {self.repo_config.g('name')}", level="info") + self.write_logs( + f"Running backup of {paths} to repo {self.repo_config.g('name')}", + level="info", + ) pre_exec_commands_success = True if pre_exec_commands: @@ -750,14 +802,16 @@ def backup(self, force: bool = False) -> bool: ) if exit_code != 0: self.write_logs( - f"Pre-execution of command {pre_exec_command} failed with:\n{output}", level="error" + f"Pre-execution of command {pre_exec_command} failed with:\n{output}", + level="error", ) if pre_exec_failure_is_fatal: return False pre_exec_commands_success = False else: self.write_logs( - "Pre-execution of command {pre_exec_command} success with:\n{output}.", level="info" + "Pre-execution of command {pre_exec_command} success with:\n{output}.", + level="info", ) self.restic_runner.dry_run = self.dry_run @@ -786,18 +840,25 @@ def backup(self, force: bool = False) -> bool: ) if exit_code != 0: self.write_logs( - f"Post-execution of command {post_exec_command} failed with:\n{output}", level="error" + f"Post-execution of command {post_exec_command} failed with:\n{output}", + level="error", ) post_exec_commands_success = False if post_exec_failure_is_fatal: return False else: self.write_logs( - f"Post-execution of command {post_exec_command} success with:\n{output}.", level="info" + f"Post-execution of command {post_exec_command} success with:\n{output}.", + level="info", ) - operation_result = result and pre_exec_commands_success and post_exec_commands_success - self.write_logs(f"Operation finished with {'success' if operation_result else 'failure'}", level="info" if operation_result else "error") + operation_result = ( + result and pre_exec_commands_success and post_exec_commands_success + ) + self.write_logs( + f"Operation finished with {'success' if operation_result else 'failure'}", + level="info" if operation_result else "error", + ) return operation_result @threaded @@ -823,7 +884,9 @@ def restore(self, snapshot: str, target: str, restore_includes: List[str]) -> bo @is_ready @apply_config_to_restic_runner @catch_exceptions - def forget(self, snapshots: Optional[Union[List[str], str]] = None, use_policy: bool = None) -> bool: + def forget( + self, snapshots: Optional[Union[List[str], str]] = None, use_policy: bool = None + ) -> bool: if snapshots: self.write_logs(f"Forgetting snapshots {snapshots}", level="info") result = self.restic_runner.forget(snapshots) @@ -843,14 +906,20 @@ def forget(self, snapshots: Optional[Union[List[str], str]] = None, use_policy: if not isinstance(keep_tags, list) and keep_tags: keep_tags = [keep_tags] policy["keep-tags"] = keep_tags - # Fool proof, don't run without policy, or else we'll get + # Fool proof, don't run without policy, or else we'll get if not policy: self.write_logs(f"Empty retention policy. Won't run", level="error") return False - self.write_logs(f"Forgetting snapshots using retention policy: {policy}", level="info") + self.write_logs( + f"Forgetting snapshots using retention policy: {policy}", level="info" + ) result = self.restic_runner.forget(policy=policy) else: - self.write_logs("Bogus options given to forget: snapshots={snapshots}, policy={policy}", level="critical", raise_error=True) + self.write_logs( + "Bogus options given to forget: snapshots={snapshots}, policy={policy}", + level="critical", + raise_error=True, + ) return result @threaded @@ -862,9 +931,15 @@ def forget(self, snapshots: Optional[Union[List[str], str]] = None, use_policy: @catch_exceptions def check(self, read_data: bool = True) -> bool: if read_data: - self.write_logs(f"Running full data check of repository {self.repo_config.g('name')}", level="info") + self.write_logs( + f"Running full data check of repository {self.repo_config.g('name')}", + level="info", + ) else: - self.write_logs(f"Running metadata consistency check of repository {self.repo_config.g('name')}", level="info") + self.write_logs( + f"Running metadata consistency check of repository {self.repo_config.g('name')}", + level="info", + ) result = self.restic_runner.check(read_data) return result @@ -876,11 +951,15 @@ def check(self, read_data: bool = True) -> bool: @apply_config_to_restic_runner @catch_exceptions def prune(self, max: bool = False) -> bool: - self.write_logs(f"Pruning snapshots for repo {self.repo_config.g('name')}", level="info") + self.write_logs( + f"Pruning snapshots for repo {self.repo_config.g('name')}", level="info" + ) if max: max_unused = self.repo_config.g("prune_max_unused") max_repack_size = self.repo_config.g("prune_max_repack_size") - result = self.restic_runner.prune(max_unused=max_unused, max_repack_size=max_repack_size) + result = self.restic_runner.prune( + max_unused=max_unused, max_repack_size=max_repack_size + ) return result @threaded @@ -891,7 +970,9 @@ def prune(self, max: bool = False) -> bool: @apply_config_to_restic_runner @catch_exceptions def repair(self, subject: str) -> bool: - self.write_logs(f"Repairing {subject} in repo {self.repo_config.g('name')}", level="info") + self.write_logs( + f"Repairing {subject} in repo {self.repo_config.g('name')}", level="info" + ) result = self.restic_runner.repair(subject) return result @@ -934,15 +1015,17 @@ def group_runner(self, repo_config_list: list, operation: str, **kwargs) -> bool **{ "close_queues": False, "__no_threads": True, - } - } + }, + } for repo_name, repo_config in repo_config_list: self.write_logs(f"Running {operation} for repo {repo_name}", level="info") self.repo_config = repo_config result = self.__getattribute__(operation)(**kwargs) if result: - self.write_logs(f"Finished {operation} for repo {repo_name}", level="info") + self.write_logs( + f"Finished {operation} for repo {repo_name}", level="info" + ) else: self.write_logs( f"Operation {operation} failed for repo {repo_name}", level="error" @@ -950,8 +1033,8 @@ def group_runner(self, repo_config_list: list, operation: str, **kwargs) -> bool group_result = False self.write_logs("Finished execution group operations", level="info") # Manually close the queues at the end - #if self.stdout: + # if self.stdout: # self.stdout.put(None) - #if self.stderr: + # if self.stderr: # self.stderr.put(None) return group_result diff --git a/npbackup/gui/__main__.py b/npbackup/gui/__main__.py index 68476bd..d816ff7 100644 --- a/npbackup/gui/__main__.py +++ b/npbackup/gui/__main__.py @@ -41,7 +41,7 @@ LICENSE_TEXT, LICENSE_FILE, PYSIMPLEGUI_THEME, - OEM_ICON + OEM_ICON, ) from npbackup.gui.config import config_gui from npbackup.gui.operations import operations_gui @@ -122,26 +122,38 @@ def about_gui(version_string: str, full_config: dict = None) -> None: window.close() -def viewer_repo_gui(viewer_repo_uri: str = None, viewer_repo_password: str = None) -> Tuple[str, str]: +def viewer_repo_gui( + viewer_repo_uri: str = None, viewer_repo_password: str = None +) -> Tuple[str, str]: """ Ask for repo and password if not defined in env variables """ layout = [ - [sg.Text(_t("config_gui.backup_repo_uri"), size=(35, 1)), sg.Input(viewer_repo_uri, key="-REPO-URI-")], - [sg.Text(_t("config_gui.backup_repo_password"), size=(35, 1)), sg.Input(viewer_repo_password, key="-REPO-PASSWORD-", password_char='*')], - [sg.Push(), sg.Button(_t("generic.cancel"), key="--CANCEL--"), sg.Button(_t("generic.accept"), key="--ACCEPT--")] + [ + sg.Text(_t("config_gui.backup_repo_uri"), size=(35, 1)), + sg.Input(viewer_repo_uri, key="-REPO-URI-"), + ], + [ + sg.Text(_t("config_gui.backup_repo_password"), size=(35, 1)), + sg.Input(viewer_repo_password, key="-REPO-PASSWORD-", password_char="*"), + ], + [ + sg.Push(), + sg.Button(_t("generic.cancel"), key="--CANCEL--"), + sg.Button(_t("generic.accept"), key="--ACCEPT--"), + ], ] window = sg.Window("Viewer", layout, keep_on_top=True, grab_anywhere=True) while True: event, values = window.read() - if event in (sg.WIN_CLOSED, sg.WIN_X_EVENT, '--CANCEL--'): + if event in (sg.WIN_CLOSED, sg.WIN_X_EVENT, "--CANCEL--"): break - if event == '--ACCEPT--': - if values['-REPO-URI-'] and values['-REPO-PASSWORD-']: + if event == "--ACCEPT--": + if values["-REPO-URI-"] and values["-REPO-PASSWORD-"]: break sg.Popup(_t("main_gui.repo_and_password_cannot_be_empty")) window.close() - return values['-REPO-URI-'], values['-REPO-PASSWORD-'] + return values["-REPO-URI-"], values["-REPO-PASSWORD-"] @threaded @@ -203,7 +215,9 @@ def _make_treedata_from_json(ls_result: List[dict]) -> sg.TreeData: def ls_window(repo_config: dict, snapshot_id: str) -> bool: - snapshot_content = gui_thread_runner(repo_config, 'ls', snapshot=snapshot_id, __autoclose=True, __compact=True) + snapshot_content = gui_thread_runner( + repo_config, "ls", snapshot=snapshot_id, __autoclose=True, __compact=True + ) if not snapshot_content: return snapshot_content, None @@ -303,18 +317,26 @@ def _close_win(): Since closing a sg.Treedata takes alot of time, let's thread it into background """ window.close + _close_win() return True def restore_window( repo_config: dict, snapshot_id: str, restore_include: List[str] -) -> None: +) -> None: def _restore_window( - repo_config: dict, snapshot: str, target: str, restore_includes: Optional[List] + repo_config: dict, snapshot: str, target: str, restore_includes: Optional[List] ) -> bool: - result = gui_thread_runner(repo_config, "restore", snapshot=snapshot, target=target, restore_includes=restore_includes) + result = gui_thread_runner( + repo_config, + "restore", + snapshot=snapshot, + target=target, + restore_includes=restore_includes, + ) return result + left_col = [ [ sg.Text(_t("main_gui.destination_folder")), @@ -338,7 +360,12 @@ def _restore_window( if event == "restore": # on_success = _t("main_gui.restore_done") # on_failure = _t("main_gui.restore_failed") - result = _restore_window(repo_config, snapshot=snapshot_id, target=values["-RESTORE-FOLDER-"], restore_includes=restore_include) + result = _restore_window( + repo_config, + snapshot=snapshot_id, + target=values["-RESTORE-FOLDER-"], + restore_includes=restore_include, + ) break window.close() return result @@ -348,7 +375,14 @@ def backup(repo_config: dict) -> bool: gui_msg = _t("main_gui.backup_activity") # on_success = _t("main_gui.backup_done") # on_failure = _t("main_gui.backup_failed") - result = gui_thread_runner(repo_config, 'backup', force=True, __autoclose=False, __compact=False, __gui_msg=gui_msg) + result = gui_thread_runner( + repo_config, + "backup", + force=True, + __autoclose=False, + __compact=False, + __gui_msg=gui_msg, + ) return result @@ -356,12 +390,17 @@ def forget_snapshot(repo_config: dict, snapshot_ids: List[str]) -> bool: gui_msg = f"{_t('generic.forgetting')} {snapshot_ids} {_t('main_gui.this_will_take_a_while')}" # on_success = f"{snapshot_ids} {_t('generic.forgotten')} {_t('generic.successfully')}" # on_failure = _t("main_gui.forget_failed") - result = gui_thread_runner(repo_config, "forget", snapshots=snapshot_ids, __gui_msg=gui_msg, __autoclose=True) + result = gui_thread_runner( + repo_config, + "forget", + snapshots=snapshot_ids, + __gui_msg=gui_msg, + __autoclose=True, + ) return result def _main_gui(viewer_mode: bool): - def select_config_file(): """ Option to select a configuration file @@ -396,7 +435,9 @@ def select_config_file(): def gui_update_state() -> None: if current_state: window["--STATE-BUTTON--"].Update( - "{}: {}".format(_t("generic.up_to_date"), backup_tz.replace(microsecond=0)), + "{}: {}".format( + _t("generic.up_to_date"), backup_tz.replace(microsecond=0) + ), button_color=GUI_STATE_OK_BUTTON, ) elif current_state is False and backup_tz == datetime(1, 1, 1, 0, 0): @@ -405,7 +446,9 @@ def gui_update_state() -> None: ) elif current_state is False: window["--STATE-BUTTON--"].Update( - "{}: {}".format(_t("generic.too_old"), backup_tz.replace(microsecond=0)), + "{}: {}".format( + _t("generic.too_old"), backup_tz.replace(microsecond=0) + ), button_color=GUI_STATE_OLD_BUTTON, ) elif current_state is None: @@ -416,21 +459,27 @@ def gui_update_state() -> None: window["snapshot-list"].Update(snapshot_list) def get_gui_data(repo_config: dict) -> Tuple[bool, List[str]]: - window['--STATE-BUTTON--'].Update( + window["--STATE-BUTTON--"].Update( _t("generic.please_wait"), button_color="orange" ) gui_msg = _t("main_gui.loading_snapshot_list_from_repo") - snapshots = gui_thread_runner(repo_config, "list", __gui_msg=gui_msg, __autoclose=True, __compact=True) - current_state, backup_tz = ResticRunner._has_snapshot_timedelta(snapshots, repo_config.g("repo_opts.minimum_backup_age")) + snapshots = gui_thread_runner( + repo_config, "list", __gui_msg=gui_msg, __autoclose=True, __compact=True + ) + current_state, backup_tz = ResticRunner._has_snapshot_timedelta( + snapshots, repo_config.g("repo_opts.minimum_backup_age") + ) snapshot_list = [] if snapshots: snapshots.reverse() # Let's show newer snapshots first for snapshot in snapshots: if re.match( - r"[0-9]{4}-[0-1][0-9]-[0-3][0-9]T[0-2][0-9]:[0-5][0-9]:[0-5][0-9]\..*\+[0-2][0-9]:[0-9]{2}", - snapshot["time"], - ): - snapshot_date = dateutil.parser.parse(snapshot["time"]).strftime("%Y-%m-%d %H:%M:%S") + r"[0-9]{4}-[0-1][0-9]-[0-3][0-9]T[0-2][0-9]:[0-5][0-9]:[0-5][0-9]\..*\+[0-2][0-9]:[0-9]{2}", + snapshot["time"], + ): + snapshot_date = dateutil.parser.parse(snapshot["time"]).strftime( + "%Y-%m-%d %H:%M:%S" + ) else: snapshot_date = "Unparsable" snapshot_username = snapshot["username"] @@ -477,7 +526,9 @@ def get_gui_data(repo_config: dict) -> Tuple[bool, List[str]]: viewer_repo_uri = os.environ.get("RESTIC_REPOSITORY", None) viewer_repo_password = os.environ.get("RESTIC_PASSWORD", None) if not viewer_repo_uri or not viewer_repo_password: - viewer_repo_uri, viewer_repo_password = viewer_repo_gui(viewer_repo_uri, viewer_repo_password) + viewer_repo_uri, viewer_repo_password = viewer_repo_gui( + viewer_repo_uri, viewer_repo_password + ) repo_config.s("repo_uri", viewer_repo_uri) repo_config.s("repo_opts", CommentedMap()) repo_config.s("repo_opts.repo_password", viewer_repo_password) @@ -504,7 +555,9 @@ def get_gui_data(repo_config: dict) -> Tuple[bool, List[str]]: sg.Column( [ [sg.Text(OEM_STRING, font="Arial 14")], - [sg.Text(_t("main_gui.viewer_mode"))] if viewer_mode else [], + [sg.Text(_t("main_gui.viewer_mode"))] + if viewer_mode + else [], [sg.Text("{}: ".format(_t("main_gui.backup_state")))], [ sg.Button( @@ -528,7 +581,9 @@ def get_gui_data(repo_config: dict) -> Tuple[bool, List[str]]: enable_events=True, ), sg.Text(f"Type {backend_type}", key="-backend_type-"), - ] if not viewer_mode else [], + ] + if not viewer_mode + else [], [ sg.Table( values=[[]], @@ -541,12 +596,24 @@ def get_gui_data(repo_config: dict) -> Tuple[bool, List[str]]: ], [ sg.Button( - _t("main_gui.launch_backup"), key="--LAUNCH-BACKUP--", disabled=viewer_mode + _t("main_gui.launch_backup"), + key="--LAUNCH-BACKUP--", + disabled=viewer_mode, ), sg.Button(_t("main_gui.see_content"), key="--SEE-CONTENT--"), - sg.Button(_t("generic.forget"), key="--FORGET--", disabled=viewer_mode), # TODO , visible=False if repo_config.g("permissions") != "full" else True), - sg.Button(_t("main_gui.operations"), key="--OPERATIONS--", disabled=viewer_mode), - sg.Button(_t("generic.configure"), key="--CONFIGURE--", disabled=viewer_mode), + sg.Button( + _t("generic.forget"), key="--FORGET--", disabled=viewer_mode + ), # TODO , visible=False if repo_config.g("permissions") != "full" else True), + sg.Button( + _t("main_gui.operations"), + key="--OPERATIONS--", + disabled=viewer_mode, + ), + sg.Button( + _t("generic.configure"), + key="--CONFIGURE--", + disabled=viewer_mode, + ), sg.Button(_t("generic.about"), key="--ABOUT--"), sg.Button(_t("generic.quit"), key="--EXIT--"), ], diff --git a/npbackup/gui/helpers.py b/npbackup/gui/helpers.py index 808f328..7dec79e 100644 --- a/npbackup/gui/helpers.py +++ b/npbackup/gui/helpers.py @@ -17,14 +17,14 @@ import queue import PySimpleGUI as sg from npbackup.core.i18n_helper import _t -from npbackup.customization import LOADER_ANIMATION, GUI_LOADER_COLOR, GUI_LOADER_TEXT_COLOR -from npbackup.core.runner import NPBackupRunner -from npbackup.__debug__ import _DEBUG from npbackup.customization import ( - PYSIMPLEGUI_THEME, - OEM_ICON, - OEM_LOGO + LOADER_ANIMATION, + GUI_LOADER_COLOR, + GUI_LOADER_TEXT_COLOR, ) +from npbackup.core.runner import NPBackupRunner +from npbackup.__debug__ import _DEBUG +from npbackup.customization import PYSIMPLEGUI_THEME, OEM_ICON, OEM_LOGO logger = getLogger() @@ -70,7 +70,15 @@ def get_anon_repo_uri(repository: str) -> Tuple[str, str]: return backend_type, backend_uri -def gui_thread_runner(__repo_config: dict, __fn_name: str, __compact: bool = True, __autoclose: bool = False, __gui_msg: str = "", *args, **kwargs): +def gui_thread_runner( + __repo_config: dict, + __fn_name: str, + __compact: bool = True, + __autoclose: bool = False, + __gui_msg: str = "", + *args, + **kwargs, +): """ Runs any NPBackupRunner functions in threads for GUI also gets stdout and stderr queues output into gui window @@ -78,8 +86,12 @@ def gui_thread_runner(__repo_config: dict, __fn_name: str, __compact: bool = Tru """ def _upgrade_from_compact_view(): - for key in ('-OPERATIONS-PROGRESS-STDOUT-TITLE-', '-OPERATIONS-PROGRESS-STDOUT-', - '-OPERATIONS-PROGRESS-STDERR-TITLE-', '-OPERATIONS-PROGRESS-STDERR-'): + for key in ( + "-OPERATIONS-PROGRESS-STDOUT-TITLE-", + "-OPERATIONS-PROGRESS-STDOUT-", + "-OPERATIONS-PROGRESS-STDERR-TITLE-", + "-OPERATIONS-PROGRESS-STDERR-", + ): progress_window[key].Update(visible=True) runner = NPBackupRunner() @@ -89,7 +101,9 @@ def _upgrade_from_compact_view(): stdout_queue = queue.Queue() stderr_queue = queue.Queue() fn = getattr(runner, __fn_name) - logger.debug(f"gui_thread_runner runs {fn.__name__} {'with' if USE_THREADING else 'without'} threads") + logger.debug( + f"gui_thread_runner runs {fn.__name__} {'with' if USE_THREADING else 'without'} threads" + ) runner.stdout = stdout_queue runner.stderr = stderr_queue @@ -101,28 +115,88 @@ def _upgrade_from_compact_view(): progress_layout = [ # Replaced by custom title bar # [sg.Text(__gui_msg, text_color=GUI_LOADER_TEXT_COLOR, background_color=GUI_LOADER_COLOR, visible=__compact, justification='C')], - [sg.Text(_t("main_gui.last_messages"), key="-OPERATIONS-PROGRESS-STDOUT-TITLE-", text_color=GUI_LOADER_TEXT_COLOR, background_color=GUI_LOADER_COLOR, visible=not __compact)], - [sg.Multiline(key="-OPERATIONS-PROGRESS-STDOUT-", size=(70, 5), visible=not __compact, autoscroll=True)], - [sg.Text(_t("main_gui.error_messages"), key="-OPERATIONS-PROGRESS-STDERR-TITLE-", text_color=GUI_LOADER_TEXT_COLOR, background_color=GUI_LOADER_COLOR, visible=not __compact)], - [sg.Multiline(key="-OPERATIONS-PROGRESS-STDERR-", size=(70, 10), visible=not __compact, autoscroll=True)], - [sg.Column( - [ + [ + sg.Text( + _t("main_gui.last_messages"), + key="-OPERATIONS-PROGRESS-STDOUT-TITLE-", + text_color=GUI_LOADER_TEXT_COLOR, + background_color=GUI_LOADER_COLOR, + visible=not __compact, + ) + ], + [ + sg.Multiline( + key="-OPERATIONS-PROGRESS-STDOUT-", + size=(70, 5), + visible=not __compact, + autoscroll=True, + ) + ], + [ + sg.Text( + _t("main_gui.error_messages"), + key="-OPERATIONS-PROGRESS-STDERR-TITLE-", + text_color=GUI_LOADER_TEXT_COLOR, + background_color=GUI_LOADER_COLOR, + visible=not __compact, + ) + ], + [ + sg.Multiline( + key="-OPERATIONS-PROGRESS-STDERR-", + size=(70, 10), + visible=not __compact, + autoscroll=True, + ) + ], + [ + sg.Column( [ - sg.Image(LOADER_ANIMATION, key="-LOADER-ANIMATION-", background_color=GUI_LOADER_COLOR, visible=USE_THREADING) + [ + sg.Image( + LOADER_ANIMATION, + key="-LOADER-ANIMATION-", + background_color=GUI_LOADER_COLOR, + visible=USE_THREADING, + ) + ], + [sg.Text("Debugging active", visible=not USE_THREADING)], ], - [ - sg.Text("Debugging active", visible=not USE_THREADING) - ] - ], expand_x=True, justification='C', element_justification='C', background_color=GUI_LOADER_COLOR)], - [sg.Button(_t("generic.close"), key="--EXIT--", button_color=(GUI_LOADER_TEXT_COLOR, GUI_LOADER_COLOR))], + expand_x=True, + justification="C", + element_justification="C", + background_color=GUI_LOADER_COLOR, + ) + ], + [ + sg.Button( + _t("generic.close"), + key="--EXIT--", + button_color=(GUI_LOADER_TEXT_COLOR, GUI_LOADER_COLOR), + ) + ], ] full_layout = [ - [sg.Column(progress_layout, element_justification='C', expand_x=True, background_color=GUI_LOADER_COLOR)] + [ + sg.Column( + progress_layout, + element_justification="C", + expand_x=True, + background_color=GUI_LOADER_COLOR, + ) + ] ] - progress_window = sg.Window(__gui_msg, full_layout, use_custom_titlebar=True, grab_anywhere=True, keep_on_top=True, - background_color=GUI_LOADER_COLOR, titlebar_icon=OEM_ICON) + progress_window = sg.Window( + __gui_msg, + full_layout, + use_custom_titlebar=True, + grab_anywhere=True, + keep_on_top=True, + background_color=GUI_LOADER_COLOR, + titlebar_icon=OEM_ICON, + ) event, values = progress_window.read(timeout=0.01) read_stdout_queue = True @@ -131,10 +205,7 @@ def _upgrade_from_compact_view(): if USE_THREADING: thread = fn(*args, **kwargs) else: - kwargs = { - **kwargs, - **{"__no_threads": True} - } + kwargs = {**kwargs, **{"__no_threads": True}} result = runner.__getattribute__(fn.__name__)(*args, **kwargs) while True: progress_window["-LOADER-ANIMATION-"].UpdateAnimation( @@ -175,10 +246,10 @@ def _upgrade_from_compact_view(): ) read_queues = read_stdout_queue or read_stderr_queue - + if not read_queues: # Arbitrary wait time so window get's time to get fully drawn - sleep(.2) + sleep(0.2) break if stderr_has_messages: diff --git a/npbackup/gui/operations.py b/npbackup/gui/operations.py index 3b10b7d..e639d4e 100644 --- a/npbackup/gui/operations.py +++ b/npbackup/gui/operations.py @@ -59,7 +59,11 @@ def operations_gui(full_config: dict) -> dict: """ # This is a stupid hack to make sure uri column is large enough - headings = ["Name ", "Backend", "URI "] + headings = [ + "Name ", + "Backend", + "URI ", + ] layout = [ [ @@ -91,44 +95,49 @@ def operations_gui(full_config: dict) -> dict: ], [ sg.Button( - _t("operations_gui.quick_check"), key="--QUICK-CHECK--", - size=(45, 1) + _t("operations_gui.quick_check"), + key="--QUICK-CHECK--", + size=(45, 1), ), sg.Button( - _t("operations_gui.full_check"), key="--FULL-CHECK--", - size=(45, 1) + _t("operations_gui.full_check"), + key="--FULL-CHECK--", + size=(45, 1), ), ], [ sg.Button( - _t("operations_gui.repair_index"), key="--REPAIR-INDEX--", - size=(45, 1) + _t("operations_gui.repair_index"), + key="--REPAIR-INDEX--", + size=(45, 1), ), sg.Button( - _t("operations_gui.repair_snapshots"), key="--REPAIR-SNAPSHOTS--", - size=(45, 1) + _t("operations_gui.repair_snapshots"), + key="--REPAIR-SNAPSHOTS--", + size=(45, 1), ), ], [ sg.Button( - _t("operations_gui.unlock"), key="--UNLOCK--", - size=(45, 1) + _t("operations_gui.unlock"), key="--UNLOCK--", size=(45, 1) ), sg.Button( _t("operations_gui.forget_using_retention_policy"), key="--FORGET--", - size=(45, 1) - ) + size=(45, 1), + ), ], [ sg.Button( _t("operations_gui.standard_prune"), key="--STANDARD-PRUNE--", - size=(45, 1) + size=(45, 1), + ), + sg.Button( + _t("operations_gui.max_prune"), + key="--MAX-PRUNE--", + size=(45, 1), ), - sg.Button(_t("operations_gui.max_prune"), key="--MAX-PRUNE--", - size=(45, 1) - ), ], [sg.Button(_t("generic.quit"), key="--EXIT--")], ], @@ -140,7 +149,7 @@ def operations_gui(full_config: dict) -> dict: window = sg.Window( "Configuration", layout, - #size=(600, 600), + # size=(600, 600), text_justification="C", auto_size_text=True, auto_size_buttons=True, @@ -181,11 +190,15 @@ def operations_gui(full_config: dict) -> dict: continue repos = complete_repo_list else: - repos = complete_repo_list.index(values["repo-list"]) # TODO multi select + repos = complete_repo_list.index( + values["repo-list"] + ) # TODO multi select repo_config_list = [] for repo_name, backend_type, repo_uri in repos: - repo_config, config_inheritance = configuration.get_repo_config(full_config, repo_name) + repo_config, config_inheritance = configuration.get_repo_config( + full_config, repo_name + ) repo_config_list.append((repo_name, repo_config)) if event == "--FORGET--": operation = "forget" @@ -219,7 +232,16 @@ def operations_gui(full_config: dict) -> dict: operation = "prune" op_args = {"max": True} gui_msg = _t("operations_gui.max_prune") - result = gui_thread_runner(None, 'group_runner', operation=operation, repo_config_list=repo_config_list, __autoclose=False, __compact=False, __gui_msg=gui_msg, **op_args) + result = gui_thread_runner( + None, + "group_runner", + operation=operation, + repo_config_list=repo_config_list, + __autoclose=False, + __compact=False, + __gui_msg=gui_msg, + **op_args, + ) event = "---STATE-UPDATE---" if event == "---STATE-UPDATE---": diff --git a/npbackup/restic_wrapper/__init__.py b/npbackup/restic_wrapper/__init__.py index c716c25..1eb2010 100644 --- a/npbackup/restic_wrapper/__init__.py +++ b/npbackup/restic_wrapper/__init__.py @@ -40,7 +40,7 @@ def __init__( ) -> None: self._stdout = None self._stderr = None - + self.repository = str(repository).strip() self.password = str(password).strip() self._verbose = False @@ -108,7 +108,9 @@ def _make_env(self) -> None: self.repository = None for env_variable, value in self.environment_variables.items(): - self.write_logs(f'Setting envrionment variable "{env_variable}"', level="debug") + self.write_logs( + f'Setting envrionment variable "{env_variable}"', level="debug" + ) os.environ[env_variable] = value # Configure default cpu usage when not specifically set @@ -203,26 +205,26 @@ def write_logs(self, msg: str, level: str, raise_error: str = None): """ Write logs to log file and stdout / stderr queues if exist for GUI usage """ - if level == 'warning': + if level == "warning": logger.warning(msg) - elif level == 'error': + elif level == "error": logger.error(msg) - elif level == 'critical': + elif level == "critical": logger.critical(msg) - elif level == 'info': + elif level == "info": logger.info(msg) - elif level == 'debug': + elif level == "debug": logger.debug(msg) else: raise ValueError("Bogus log level given {level}") - + if msg is None: raise ValueError("None log message received") - if self.stdout and (level == 'info' or (level == 'debug' and _DEBUG)): + if self.stdout and (level == "info" or (level == "debug" and _DEBUG)): self.stdout.put(msg) - if self.stderr and level in ('critical', 'error', 'warning'): + if self.stderr and level in ("critical", "error", "warning"): self.stderr.put(msg) - + if raise_error == "ValueError": raise ValueError(msg) elif raise_error: @@ -336,7 +338,8 @@ def _get_binary(self) -> None: self._binary = probed_path return self.write_logs( - "No backup engine binary found. Please install latest binary from restic.net", level="error" + "No backup engine binary found. Please install latest binary from restic.net", + level="error", ) @property @@ -439,7 +442,9 @@ def binary_version(self) -> Optional[str]: else: self.write_logs("Cannot get backend version: {output}", level="warning") else: - self.write_logs("Cannot get backend version: No binary defined.", level="error") + self.write_logs( + "Cannot get backend version: No binary defined.", level="error" + ) return None @property @@ -471,7 +476,10 @@ def init( ) # We don't want output_queues here since we don't want is already inialized errors to show up result, output = self.executor( - cmd, errors_allowed=errors_allowed, no_output_queues=True, timeout=INIT_TIMEOUT, + cmd, + errors_allowed=errors_allowed, + no_output_queues=True, + timeout=INIT_TIMEOUT, ) if result: if re.search( @@ -584,7 +592,7 @@ def backup( """ if not self.is_init: return None, None - + # Handle various source types if exclude_patterns_source_type in [ "files_from", @@ -631,7 +639,9 @@ def backup( case_ignore_param, exclude_file ) else: - self.write_logs(f"Exclude file '{exclude_file}' not found", level="error") + self.write_logs( + f"Exclude file '{exclude_file}' not found", level="error" + ) if exclude_caches: cmd += " --exclude-caches" if one_file_system: @@ -642,7 +652,8 @@ def backup( self.write_logs("Using VSS snapshot to backup", level="info") else: self.write_logs( - "Parameter --use-fs-snapshot was given, which is only compatible with Windows", level="warning" + "Parameter --use-fs-snapshot was given, which is only compatible with Windows", + level="warning", ) for tag in tags: if tag: @@ -657,10 +668,10 @@ def backup( and not result and re.search("VSS Error", output, re.IGNORECASE) ): - self.write_logs("VSS cannot be used. Backup will be done without VSS.", level="error") - result, output = self.executor( - cmd.replace(" --use-fs-snapshot", "") + self.write_logs( + "VSS cannot be used. Backup will be done without VSS.", level="error" ) + result, output = self.executor(cmd.replace(" --use-fs-snapshot", "")) if result: self.write_logs("Backend finished backup with success", level="info") return True, output @@ -708,7 +719,9 @@ def restore(self, snapshot: str, target: str, includes: List[str] = None): return False def forget( - self, snapshots: Optional[Union[List[str], Optional[str]]] = None, policy: Optional[dict] = None + self, + snapshots: Optional[Union[List[str], Optional[str]]] = None, + policy: Optional[dict] = None, ) -> bool: """ Execute forget command for given snapshot @@ -716,7 +729,9 @@ def forget( if not self.is_init: return None if not snapshots and not policy: - self.write_logs("No valid snapshot or policy defined for pruning", level="error") + self.write_logs( + "No valid snapshot or policy defined for pruning", level="error" + ) return False if snapshots: @@ -729,7 +744,7 @@ def forget( if policy: cmd = "forget" for key, value in policy.items(): - if key == 'keep-tags': + if key == "keep-tags": if isinstance(value, list): for tag in value: if tag: @@ -746,14 +761,16 @@ def forget( for cmd in cmds: result, output = self.executor(cmd) if result: - self.write_logs("successfully forgot snapshot", level='info') + self.write_logs("successfully forgot snapshot", level="info") else: self.write_logs(f"Forget failed\n{output}", level="error") batch_result = False self.verbose = verbose return batch_result - def prune(self, max_unused: Optional[str] = None, max_repack_size: Optional[int] = None) -> bool: + def prune( + self, max_unused: Optional[str] = None, max_repack_size: Optional[int] = None + ) -> bool: """ Prune forgotten snapshots """ @@ -803,7 +820,7 @@ def repair(self, subject: str) -> bool: return True self.write_logs(f"Repo repair failed:\n {output}", level="critical") return False - + def unlock(self) -> bool: """ Remove stale locks from repos @@ -830,9 +847,11 @@ def raw(self, command: str) -> Tuple[bool, str]: return True, output self.write_logs("Raw command failed.", level="error") return False, output - + @staticmethod - def _has_snapshot_timedelta(snapshot_list: List, delta: int = None) -> Tuple[bool, Optional[datetime]]: + def _has_snapshot_timedelta( + snapshot_list: List, delta: int = None + ) -> Tuple[bool, Optional[datetime]]: """ Making the actual comparaison a static method so we can call it from GUI too From 2894e57ad5272a09fb679c7eaf26f868b4d15446 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Wed, 27 Dec 2023 22:40:47 +0100 Subject: [PATCH 088/328] Various pylint fixes --- npbackup/__debug__.py | 1 + npbackup/core/runner.py | 21 ++++++++++----------- npbackup/gui/__main__.py | 2 +- npbackup/restic_wrapper/__init__.py | 4 ++-- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/npbackup/__debug__.py b/npbackup/__debug__.py index 70fd97d..e07f3d1 100644 --- a/npbackup/__debug__.py +++ b/npbackup/__debug__.py @@ -19,6 +19,7 @@ __debug_os_env = os.environ.get("_DEBUG", "False").strip("'\"") try: + # pylint: disable=E0601 (used-before-assignment) _DEBUG except NameError: _DEBUG = False diff --git a/npbackup/core/runner.py b/npbackup/core/runner.py index 91937dd..5b8fa21 100644 --- a/npbackup/core/runner.py +++ b/npbackup/core/runner.py @@ -132,16 +132,6 @@ def __init__(self): self._using_dev_binary = False - @property - def repo_name(self) -> str: - return self._repo_name - - @repo_name.setter - def repo_name(self, value: str): - if not isinstance(value, str) or not value: - msg = f"Bogus repo name {value} found" - self.write_logs(msg, level="critical", raise_error="ValueError") - @property def repo_config(self) -> dict: return self._repo_config @@ -152,7 +142,6 @@ def repo_config(self, value: dict): msg = f"Bogus repo config object given" self.write_logs(msg, level="critical", raise_error="ValueError") self._repo_config = deepcopy(value) - self.repo_name = self.repo_config.g("name") # Create an instance of restic wrapper self.create_restic_runner() @@ -271,6 +260,7 @@ def wrapper(self, *args, **kwargs): # pylint: disable=E1102 (not-callable) result = fn(self, *args, **kwargs) self.exec_time = (datetime.utcnow() - start_time).total_seconds() + # pylint: disable=E1101 (no-member) self.write_logs( f"Runner took {self.exec_time} seconds for {fn.__name__}", level="info" ) @@ -286,6 +276,7 @@ def close_queues(fn: Callable): @wraps(fn) def wrapper(self, *args, **kwargs): close_queues = kwargs.pop("close_queues", True) + # pylint: disable=E1102 (not-callable) result = fn(self, *args, **kwargs) if close_queues: if self.stdout: @@ -304,11 +295,13 @@ def is_ready(fn: Callable): @wraps(fn) def wrapper(self, *args, **kwargs): if not self._is_ready: + # pylint: disable=E1101 (no-member) self.write_logs( f"Runner cannot execute {fn.__name__}. Backend not ready", level="error", ) return False + # pylint: disable=E1102 (not-callable) return fn(self, *args, **kwargs) return wrapper @@ -337,9 +330,11 @@ def wrapper(self, *args, **kwargs): try: # When running group_runner, we need to extract operation from kwargs # else, operarion is just the wrapped function name + # pylint: disable=E1101 (no-member) if fn.__name__ == "group_runner": operation = kwargs.get("operation") else: + # pylint: disable=E1101 (no-member) operation = fn.__name__ # TODO: enforce permissions self.write_logs( @@ -349,6 +344,7 @@ def wrapper(self, *args, **kwargs): except (IndexError, KeyError): self.write_logs("You don't have sufficient permissions", level="error") return False + # pylint: disable=E1102 (not-callable) return fn(self, *args, **kwargs) return wrapper @@ -362,6 +358,7 @@ def apply_config_to_restic_runner(fn: Callable): def wrapper(self, *args, **kwargs): if not self._apply_config_to_restic_runner(): return False + # pylint: disable=E1102 (not-callable) return fn(self, *args, **kwargs) return wrapper @@ -374,8 +371,10 @@ def catch_exceptions(fn: Callable): @wraps(fn) def wrapper(self, *args, **kwargs): try: + # pylint: disable=E1102 (not-callable) return fn(self, *args, **kwargs) except Exception as exc: + # pylint: disable=E1101 (no-member) self.write_logs(f"Function {fn.__name__} failed with: {exc}") logger.debug("Trace:", exc_info=True) return False diff --git a/npbackup/gui/__main__.py b/npbackup/gui/__main__.py index d816ff7..9fde6f5 100644 --- a/npbackup/gui/__main__.py +++ b/npbackup/gui/__main__.py @@ -728,7 +728,7 @@ def main_gui(viewer_mode=False): sys.exit(250) except Exception as exc: sg.Popup(_t("config_gui.unknown_error_see_logs") + f": {exc}") - logger.critical("GUI Execution error", exc) + logger.critical(f"GUI Execution error {exc}") if _DEBUG: logger.critical("Trace:", exc_info=True) sys.exit(251) diff --git a/npbackup/restic_wrapper/__init__.py b/npbackup/restic_wrapper/__init__.py index 1eb2010..50de5ce 100644 --- a/npbackup/restic_wrapper/__init__.py +++ b/npbackup/restic_wrapper/__init__.py @@ -693,7 +693,7 @@ def find(self, path: str) -> Optional[list]: except json.decoder.JSONDecodeError: self.write_logs(f"Returned data is not JSON:\n{output}", level="error") logger.debug("Trace:", exc_info=True) - self.write_logs(f"Could not find path: {path}") + self.write_logs(f"Could not find path: {path}", level="error") return None def restore(self, snapshot: str, target: str, includes: List[str] = None): @@ -903,7 +903,7 @@ def has_snapshot_timedelta( snapshots = self.snapshots() if self.last_command_status is False: return None, None - return self.has_snapshot_timedelta(snapshots, delta) + return self._has_snapshot_timedelta(snapshots, delta) except IndexError as exc: self.write_logs(f"snapshot information missing: {exc}", level="error") logger.debug("Trace", exc_info=True) From ca62614f8b6055c64f969b6a6221178d2e035817 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Thu, 28 Dec 2023 00:32:42 +0100 Subject: [PATCH 089/328] WIP Rework cli interface --- misc/{npbackup.cmd => npbackup-cli.cmd} | 0 npbackup/__main__.py | 296 ++++++++++++++----- npbackup/interface_entrypoint.py | 377 ------------------------ npbackup/runner_interface.py | 106 +++++++ 4 files changed, 327 insertions(+), 452 deletions(-) rename misc/{npbackup.cmd => npbackup-cli.cmd} (100%) delete mode 100644 npbackup/interface_entrypoint.py create mode 100644 npbackup/runner_interface.py diff --git a/misc/npbackup.cmd b/misc/npbackup-cli.cmd similarity index 100% rename from misc/npbackup.cmd rename to misc/npbackup-cli.cmd diff --git a/npbackup/__main__.py b/npbackup/__main__.py index 9ede4cc..ed785b9 100644 --- a/npbackup/__main__.py +++ b/npbackup/__main__.py @@ -8,100 +8,152 @@ import os import sys +from pathlib import Path +import atexit from argparse import ArgumentParser +from datetime import datetime +import tempfile +import pidfile import ofunctions.logger_utils -from ofunctions.platform import python_arch +from ofunctions.process import kill_childs from npbackup.path_helper import CURRENT_DIR -from npbackup.configuration import IS_PRIV_BUILD from npbackup.customization import ( LICENSE_TEXT, LICENSE_FILE, ) -from npbackup.interface_entrypoint import entrypoint -from npbackup.__version__ import __intname__ as intname, __version__, __build__, __copyright__, __description__ +import npbackup.configuration +from npbackup.runner_interface import entrypoint +from npbackup.__version__ import version_string +from npbackup.__debug__ import _DEBUG +from npbackup.common import execution_logs +from npbackup.core.i18n_helper import _t +if os.name == "nt": + from npbackup.windows.task import create_scheduled_task + +# Nuitka compat, see https://stackoverflow.com/a/74540217 +try: + # pylint: disable=W0611 (unused-import) + from charset_normalizer import md__mypyc # noqa +except ImportError: + pass -_DEBUG = False -_VERBOSE = False LOG_FILE = os.path.join(CURRENT_DIR, "{}.log".format(__intname__)) +PID_FILE = os.path.join(tempfile.gettempdir(), "{}.pid".format(__intname__)) -logger = ofunctions.logger_utils.logger_get_logger(LOG_FILE) +logger = ofunctions.logger_utils.logger_get_logger(LOG_FILE, debug=_DEBUG) -def cli_interface(): - global _DEBUG - global _VERBOSE - global CONFIG_FILE +def cli_interface(): parser = ArgumentParser( - prog=f"{__description__}", + prog=f"{__intname__}", description="""Portable Network Backup Client\n This program is distributed under the GNU General Public License and comes with ABSOLUTELY NO WARRANTY.\n This is free software, and you are welcome to redistribute it under certain conditions; Please type --license for more info.""", ) + parser.add_argument("-b", "--backup", action="store_true", help="Run a backup") parser.add_argument( - "--check", action="store_true", help="Check if a recent backup exists" + "-r", + "--restore", + type=str, + default=None, + required=False, + help="Restore to path given by --restore", ) - - parser.add_argument("-b", "--backup", action="store_true", help="Run a backup") - + parser.add_argument("-l", "--list", action="store_true", help="Show current snapshots") parser.add_argument( - "--force", - action="store_true", - default=False, - help="Force running a backup regardless of existing backups", + "--ls", + type=str, + default=None, + required=False, + help='Show content given snapshot. Use "latest" for most recent snapshot.', ) - parser.add_argument( - "-c", - "--config-file", - dest="config_file", + "--find", type=str, default=None, required=False, - help="Path to alternative configuration file", + help="Find full path of given file / directory", ) - parser.add_argument( - "--repo-name", - dest="repo_name", + "--forget", type=str, - default="default", + default=None, required=False, - help="Name of the repository to work with. Defaults to 'default'" + help='Forget given snapshot, or specify \"policy\" to apply retention policy', ) - parser.add_argument( - "-l", "--list", action="store_true", help="Show current snapshots" + "--quick-check", + action="store_true", + help="Quick check repository" ) - parser.add_argument( - "--ls", + "--full-check", + action="store_true", + help="Full check repository" + ) + parser.add_argument( + "--prune", + action="store_true", + help="Prune data in repository" + ) + parser.add_argument( + "--prune-max", + action="store_true", + help="Prune data in repository reclaiming maximum space" + ) + parser.add_argument( + "--unlock", + action="store_true", + help="Unlock repository" + ) + parser.add_argument( + "--repair-index", + action="store_true", + help="Repair repo index" + ) + parser.add_argument( + "--repair-snapshots", + action="store_true", + help="Repair repo snapshots" + ) + parser.add_argument( + "--raw", type=str, default=None, required=False, - help='Show content given snapshot. Use "latest" for most recent snapshot.', + help='Run raw command against backend.', ) + parser.add_argument( - "-f", - "--find", + "--has-recent-backup", action="store_true", help="Check if a recent backup exists" + ) + parser.add_argument( + "-f", "--force", + action="store_true", + default=False, + help="Force running a backup regardless of existing backups", + ) + parser.add_argument( + "-c", + "--config-file", + dest="config_file", type=str, default=None, required=False, - help="Find full path of given file / directory", + help="Path to alternative configuration file", ) - parser.add_argument( - "-r", - "--restore", + "--repo-name", + dest="repo_name", type=str, - default=None, + default="default", required=False, - help="Restore to path given by --restore", + help="Name of the repository to work with. Defaults to 'default'", ) - parser.add_argument( "--restore-include", type=str, @@ -109,7 +161,6 @@ def cli_interface(): required=False, help="Restore only paths within include path", ) - parser.add_argument( "--restore-from-snapshot", type=str, @@ -117,14 +168,6 @@ def cli_interface(): required=False, help="Choose which snapshot to restore from. Defaults to latest", ) - - parser.add_argument( - "--forget", type=str, default=None, required=False, help="Forget snapshot" - ) - parser.add_argument( - "--raw", type=str, default=None, required=False, help="Raw commands" - ) - parser.add_argument( "-v", "--verbose", action="store_true", help="Show verbose output" ) @@ -132,13 +175,11 @@ def cli_interface(): parser.add_argument( "-V", "--version", action="store_true", help="Show program version" ) - parser.add_argument( "--dry-run", action="store_true", help="Run operations in test mode (no actual modifications", ) - parser.add_argument( "--create-scheduled-task", type=str, @@ -146,27 +187,12 @@ def cli_interface(): required=False, help="Create task that runs every n minutes on Windows", ) - parser.add_argument("--license", action="store_true", help="Show license") parser.add_argument( "--auto-upgrade", action="store_true", help="Auto upgrade NPBackup" ) - parser.add_argument( - "--upgrade-conf", - action="store_true", - help="Add new configuration elements after upgrade", - ) - args = parser.parse_args() - version_string = "{} v{}{}{}-{} {} - {}".format( - intname, - __version__, - "-PRIV" if IS_PRIV_BUILD else "", - "-P{}".format(sys.version_info[1]), - python_arch(), - __build__, - __copyright__, - ) + if args.version: print(version_string) sys.exit(0) @@ -180,8 +206,7 @@ def cli_interface(): print(LICENSE_TEXT) sys.exit(0) - if args.debug or os.environ.get("_DEBUG", "False").capitalize() == "True": - _DEBUG = True + if args.debug or _DEBUG: logger.setLevel(ofunctions.logger_utils.logging.DEBUG) if args.verbose: @@ -189,15 +214,136 @@ def cli_interface(): if args.config_file: if not os.path.isfile(args.config_file): - logger.critical("Given file {} cannot be read.".format(args.config_file)) + logger.critical(f"Config file {args.config_file} cannot be read.") + sys.exit(70) CONFIG_FILE = args.config_file + else: + config_file = Path(f"{CURRENT_DIR}/npbackup.conf") + if config_file.exists: + CONFIG_FILE = config_file + else: + logger.critical("Cannot run without configuration file.") + sys.exit(70) + + full_config = npbackup.configuration.load_config(CONFIG_FILE) + if full_config: + repo_config, _ = npbackup.configuration.get_repo_config(full_config, args.repo_name) + else: + logger.critical("Cannot obtain repo config") + sys.exit(71) + + if not repo_config: + message = _t("config_gui.no_config_available") + logger.critical(message) + sys.exit(72) + + # Prepare program run + cli_args = { + "repo_config": repo_config, + "verbose": args.verbose, + "dry_run": args.dry_run, + "debug": args.debug, + "operation": None, + "op_args": {} + } + + if args.backup: + cli_args["operation"] = "backup" + cli_args["op_args"] = { + "force": args.force + } + elif args.restore: + cli_args["operation"] = "restore" + cli_args["op_args"] = { + "snapshot": args.snapshot, + "target": args.restore, + "restore_include": args.restore_include + } + elif args.list: + cli_args["operation"] = "list" + elif args.ls: + cli_args["operation"] = "ls" + cli_args["op_args"] = { + "snapshot": args.snapshot + } + elif args.find: + cli_args["operation"] = "find" + cli_args["op_args"] = { + "snapshot": args.snapshot, + "path": args.find + } + elif args.forget: + cli_args["operation"] = "forget" + if args.forget == "policy": + cli_args["op_args"] = { + "use_policy": True + } + else: + cli_args["op_args"] = { + "snapshots": args.forget + } + elif args.quick_check: + cli_args["operation"] = "check" + elif args.full_check: + cli_args["operation"] = "check" + cli_args["op_args"] = { + "read_data": True + } + elif args.prune: + cli_args["operation"] = "prune" + elif args.prune_max: + cli_args["operation"] = "prune" + cli_args["op_args"] = { + "max": True + } + elif args.unlock: + cli_args["operation"] = "unlock" + elif args.repair_index: + cli_args["operation"] = "repair" + cli_args["op_args"] = { + "subject": "index" + } + elif args.repair_snapshots: + cli_args["operation"] = "repair" + cli_args["op_args"] = { + "subject": "snapshots" + } + elif args.raw: + cli_args["operation"] = "raw" + cli_args["op_args"] = { + "command": args.raw + } + + locking_operations = ["backup", "repair", "forget", "prune", "raw", "unlock"] # Program entry - entrypoint() + if cli_args["operation"] in locking_operations: + try: + with pidfile.PIDFile(PID_FILE): + entrypoint(**cli_args) + except pidfile.AlreadyRunningError: + logger.critical("Backup process already running. Will not continue.") + # EXIT_CODE 21 = current backup process already running + sys.exit(21) + else: + entrypoint(**cli_args) + + def main(): + # Make sure we log execution time and error state at the end of the program + atexit.register( + execution_logs, + datetime.utcnow(), + ) + # kill_childs normally would not be necessary, but let's just be foolproof here (kills restic subprocess in all cases) + atexit.register( + kill_childs, + os.getpid(), + ) try: cli_interface() + sys.exit(logger.get_worst_logger_level()) except KeyboardInterrupt as exc: logger.error("Program interrupted by keyboard. {}".format(exc)) logger.info("Trace:", exc_info=True) diff --git a/npbackup/interface_entrypoint.py b/npbackup/interface_entrypoint.py deleted file mode 100644 index 245104d..0000000 --- a/npbackup/interface_entrypoint.py +++ /dev/null @@ -1,377 +0,0 @@ -#! /usr/bin/env python -# -*- coding: utf-8 -*- -# -# This file is part of npbackup - -__intname__ = "npbackup" -__author__ = "Orsiris de Jong" -__site__ = "https://www.netperfect.fr/npbackup" -__description__ = "NetPerfect Backup Client" -__copyright__ = "Copyright (C) 2022-2023 NetInvent" -__license__ = "GPL-3.0-only" -__build__ = "2023083101" -__version__ = "2.3.0-dev" - - -import os -import sys -import atexit -import dateutil.parser -from datetime import datetime -import tempfile -import pidfile -import ofunctions.logger_utils -from ofunctions.platform import python_arch -from ofunctions.process import kill_childs - - - -from npbackup.customization import ( - LICENSE_TEXT, - LICENSE_FILE, -) -from npbackup import configuration -from npbackup.core.runner import NPBackupRunner -from npbackup.core.i18n_helper import _t -from npbackup.path_helper import CURRENT_DIR, CURRENT_EXECUTABLE -from npbackup.core.nuitka_helper import IS_COMPILED -from npbackup.upgrade_client.upgrader import need_upgrade -from npbackup.core.upgrade_runner import run_upgrade - - - -if os.name == "nt": - from npbackup.windows.task import create_scheduled_task - - -# Nuitka compat, see https://stackoverflow.com/a/74540217 -try: - # pylint: disable=W0611 (unused-import) - from charset_normalizer import md__mypyc # noqa -except ImportError: - pass - - -_DEBUG = False -_VERBOSE = False -LOG_FILE = os.path.join(CURRENT_DIR, "{}.log".format(__intname__)) -CONFIG_FILE = os.path.join(CURRENT_DIR, "{}.conf".format(__intname__)) -PID_FILE = os.path.join(tempfile.gettempdir(), "{}.pid".format(__intname__)) - - -logger = ofunctions.logger_utils.logger_get_logger(LOG_FILE) - - -def execution_logs(start_time: datetime) -> None: - """ - Try to know if logger.warning or worse has been called - logger._cache contains a dict of values like {10: boolean, 20: boolean, 30: boolean, 40: boolean, 50: boolean} - where - 10 = debug, 20 = info, 30 = warning, 40 = error, 50 = critical - so "if 30 in logger._cache" checks if warning has been triggered - ATTENTION: logger._cache does only contain cache of current main, not modules, deprecated in favor of - ofunctions.logger_utils.ContextFilterWorstLevel - - ATTENTION: For ofunctions.logger_utils.ContextFilterWorstLevel will only check current logger instance - So using logger = getLogger("anotherinstance") will create a separate instance from the one we can inspect - Makes sense ;) - """ - end_time = datetime.utcnow() - - logger_worst_level = 0 - for flt in logger.filters: - if isinstance(flt, ofunctions.logger_utils.ContextFilterWorstLevel): - logger_worst_level = flt.worst_level - - log_level_reached = "success" - try: - if logger_worst_level >= 40: - log_level_reached = "errors" - elif logger_worst_level >= 30: - log_level_reached = "warnings" - except AttributeError as exc: - logger.error("Cannot get worst log level reached: {}".format(exc)) - logger.info( - "ExecTime = {}, finished, state is: {}.".format( - end_time - start_time, log_level_reached - ) - ) - # using sys.exit(code) in a atexit function will swallow the exitcode and render 0 - - - - - - -def interface(): - version_string = "{} v{}{}{}-{} {} - {}".format( - __intname__, - __version__, - "-PRIV" if configuration.IS_PRIV_BUILD else "", - "-P{}".format(sys.version_info[1]), - python_arch(), - __build__, - __copyright__, - ) - if args.version: - print(version_string) - sys.exit(0) - - logger.info(version_string) - if args.license: - try: - with open(LICENSE_FILE, "r", encoding="utf-8") as file_handle: - print(file_handle.read()) - except OSError: - print(LICENSE_TEXT) - sys.exit(0) - - if args.debug or os.environ.get("_DEBUG", "False").capitalize() == "True": - _DEBUG = True - logger.setLevel(ofunctions.logger_utils.logging.DEBUG) - - if args.verbose: - _VERBOSE = True - - # Make sure we log execution time and error state at the end of the program - if args.backup or args.restore or args.find or args.list or args.check: - atexit.register( - execution_logs, - datetime.utcnow(), - ) - - if args.config_file: - if not os.path.isfile(args.config_file): - logger.critical("Given file {} cannot be read.".format(args.config_file)) - CONFIG_FILE = args.config_file - - # Program entry - if Globvars.GUI and (args.config_gui or args.operations_gui): - try: - config = configuration.load_config(CONFIG_FILE) - if not config: - logger.error("Cannot load config file") - sys.exit(24) - except FileNotFoundError: - logger.warning( - 'No configuration file found. Please use --config-file "path" to specify one or put a config file into current directory. Will create fresh config file in current directory.' - ) - config = configuration.empty_config_dict - - if args.config_gui: - config = config_gui(config, CONFIG_FILE) - if args.operations_gui: - config = operations_gui(config, CONFIG_FILE) - sys.exit(0) - - if args.create_scheduled_task: - try: - result = create_scheduled_task( - executable_path=CURRENT_EXECUTABLE, - interval_minutes=int(args.create_scheduled_task), - ) - if result: - sys.exit(0) - else: - sys.exit(22) - except ValueError: - sys.exit(23) - - try: - config = configuration.load_config(CONFIG_FILE) - repo_config = configuration.get_repo_config(config_dict, args.repo_name) - except FileNotFoundError: - config = None - - if not config: - message = _t("config_gui.no_config_available") - logger.error(message) - - if config_dict is None and Globvars.GUI: - config_dict = configuration.empty_config_dict - # If no arguments are passed, assume we are launching the GUI - if len(sys.argv) == 1: - try: - result = sg.Popup( - "{}\n\n{}".format(message, _t("config_gui.create_new_config")), - custom_text=(_t("generic._yes"), _t("generic._no")), - keep_on_top=True, - ) - if result == _t("generic._yes"): - config_dict = config_gui(config_dict, CONFIG_FILE) - sg.Popup(_t("config_gui.saved_initial_config")) - else: - logger.error("No configuration created via GUI") - sys.exit(7) - except _tkinter.TclError as exc: - logger.info( - 'Tkinter error: "{}". Is this a headless server ?'.format(exc) - ) - parser.print_help(sys.stderr) - sys.exit(1) - sys.exit(7) - - elif not config_dict: - if len(sys.argv) == 1 and Globvars.GUI: - sg.Popup(_t("config_gui.bogus_config_file", config_file=CONFIG_FILE)) - sys.exit(7) - - if args.upgrade_conf: - # Whatever we need to add here for future releases - # Eg: - - logger.info("Upgrading configuration file to version %s", __version__) - try: - config_dict["identity"] - except KeyError: - # Create new section identity, as per upgrade 2.2.0rc2 - config_dict["identity"] = {"machine_id": "${HOSTNAME}"} - configuration.save_config(CONFIG_FILE, config_dict) - sys.exit(0) - - # Try to perform an auto upgrade if needed - try: - auto_upgrade = config_dict["options"]["auto_upgrade"] - except KeyError: - auto_upgrade = True - try: - auto_upgrade_interval = config_dict["options"]["interval"] - except KeyError: - auto_upgrade_interval = 10 - - if (auto_upgrade and need_upgrade(auto_upgrade_interval)) or args.auto_upgrade: - if args.auto_upgrade: - logger.info("Running user initiated auto upgrade") - else: - logger.info("Running program initiated auto upgrade") - result = run_upgrade(config_dict) - if result: - sys.exit(0) - elif args.auto_upgrade: - sys.exit(23) - - dry_run = False - if args.dry_run: - dry_run = True - - npbackup_runner = NPBackupRunner(config_dict=config_dict) - npbackup_runner.dry_run = dry_run - npbackup_runner.verbose = _VERBOSE - if not npbackup_runner.backend_version: - logger.critical("No backend available. Cannot continue") - sys.exit(25) - logger.info("Backend: {}".format(npbackup_runner.backend_version)) - - if args.check: - if npbackup_runner.check_recent_backups(): - sys.exit(0) - else: - sys.exit(2) - - if args.list: - result = npbackup_runner.list() - if result: - for snapshot in result: - try: - tags = snapshot["tags"] - except KeyError: - tags = None - logger.info( - "ID: {} Hostname: {}, Username: {}, Tags: {}, source: {}, time: {}".format( - snapshot["short_id"], - snapshot["hostname"], - snapshot["username"], - tags, - snapshot["paths"], - dateutil.parser.parse(snapshot["time"]), - ) - ) - sys.exit(0) - else: - sys.exit(2) - - if args.ls: - result = npbackup_runner.ls(snapshot=args.ls) - if result: - logger.info("Snapshot content:") - for entry in result: - logger.info(entry) - sys.exit(0) - else: - logger.error("Snapshot could not be listed.") - sys.exit(2) - - if args.find: - result = npbackup_runner.find(path=args.find) - if result: - sys.exit(0) - else: - sys.exit(2) - try: - with pidfile.PIDFile(PID_FILE): - if args.backup: - result = npbackup_runner.backup(force=args.force) - if result: - logger.info("Backup finished.") - sys.exit(0) - else: - logger.error("Backup operation failed.") - sys.exit(2) - if args.restore: - result = npbackup_runner.restore( - snapshot=args.restore_from_snapshot, - target=args.restore, - restore_includes=args.restore_include, - ) - if result: - sys.exit(0) - else: - sys.exit(2) - - if args.forget: - result = npbackup_runner.forget(snapshot=args.forget) - if result: - sys.exit(0) - else: - sys.exit(2) - - if args.raw: - result = npbackup_runner.raw(command=args.raw) - if result: - sys.exit(0) - else: - sys.exit(2) - - except pidfile.AlreadyRunningError: - logger.warning("Backup process already running. Will not continue.") - # EXIT_CODE 21 = current backup process already running - sys.exit(21) - - - -def main(): - try: - # kill_childs normally would not be necessary, but let's just be foolproof here (kills restic subprocess in all cases) - atexit.register( - kill_childs, - os.getpid(), - ) - interface() - except KeyboardInterrupt as exc: - logger.error("Program interrupted by keyboard. {}".format(exc)) - logger.info("Trace:", exc_info=True) - # EXIT_CODE 200 = keyboard interrupt - sys.exit(200) - except Exception as exc: - logger.error("Program interrupted by error. {}".format(exc)) - logger.info("Trace:", exc_info=True) - # EXIT_CODE 201 = Non handled exception - sys.exit(201) - - -if __name__ == "__main__": - main() - - -def entrypoint(): - pass diff --git a/npbackup/runner_interface.py b/npbackup/runner_interface.py new file mode 100644 index 0000000..e2629d8 --- /dev/null +++ b/npbackup/runner_interface.py @@ -0,0 +1,106 @@ +#! /usr/bin/env python +# -*- coding: utf-8 -*- +# +# This file is part of npbackup + +__intname__ = "npbackup.runner_interface" +__author__ = "Orsiris de Jong" +__site__ = "https://www.netperfect.fr/npbackup" +__description__ = "NetPerfect Backup Client" +__copyright__ = "Copyright (C) 2022-2023 NetInvent" +__license__ = "GPL-3.0-only" +__build__ = "2023122801" + + +import os +from logging import getLogger +from npbackup.core.runner import NPBackupRunner + + +logger = getLogger() + + +def entrypoint(*args, **kwargs): + npbackup_runner = NPBackupRunner() + npbackup_runner.repo_config = kwargs.pop("repo_config") + npbackup_runner.dry_run = kwargs.pop("dry_run") + npbackup_runner.verbose = kwargs.pop("verbose") + result = npbackup_runner.__getattribute__(kwargs.pop("operation"))(kwargs.pop("op_args"), __no_threads=True) + + +def auto_upgrade(full_config: dict): + pass + +""" +def interface(): + + # Program entry + if args.create_scheduled_task: + try: + result = create_scheduled_task( + executable_path=CURRENT_EXECUTABLE, + interval_minutes=int(args.create_scheduled_task), + ) + if result: + sys.exit(0) + else: + sys.exit(22) + except ValueError: + sys.exit(23) + + if args.upgrade_conf: + # Whatever we need to add here for future releases + # Eg: + + logger.info("Upgrading configuration file to version %s", __version__) + try: + config_dict["identity"] + except KeyError: + # Create new section identity, as per upgrade 2.2.0rc2 + config_dict["identity"] = {"machine_id": "${HOSTNAME}"} + configuration.save_config(CONFIG_FILE, config_dict) + sys.exit(0) + + # Try to perform an auto upgrade if needed + try: + auto_upgrade = config_dict["options"]["auto_upgrade"] + except KeyError: + auto_upgrade = True + try: + auto_upgrade_interval = config_dict["options"]["interval"] + except KeyError: + auto_upgrade_interval = 10 + + if (auto_upgrade and need_upgrade(auto_upgrade_interval)) or args.auto_upgrade: + if args.auto_upgrade: + logger.info("Running user initiated auto upgrade") + else: + logger.info("Running program initiated auto upgrade") + result = run_upgrade(full_config) + if result: + sys.exit(0) + elif args.auto_upgrade: + sys.exit(23) + + if args.list: + result = npbackup_runner.list() + if result: + for snapshot in result: + try: + tags = snapshot["tags"] + except KeyError: + tags = None + logger.info( + "ID: {} Hostname: {}, Username: {}, Tags: {}, source: {}, time: {}".format( + snapshot["short_id"], + snapshot["hostname"], + snapshot["username"], + tags, + snapshot["paths"], + dateutil.parser.parse(snapshot["time"]), + ) + ) + sys.exit(0) + else: + sys.exit(2) +""" \ No newline at end of file From 14d950427e3d0296edd4b851ffe770f0bde8b7e6 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Thu, 28 Dec 2023 00:32:56 +0100 Subject: [PATCH 090/328] Add kill_childs to GUI --- npbackup/gui/__main__.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/npbackup/gui/__main__.py b/npbackup/gui/__main__.py index 9fde6f5..b2c8142 100644 --- a/npbackup/gui/__main__.py +++ b/npbackup/gui/__main__.py @@ -21,6 +21,7 @@ from time import sleep from ruamel.yaml.comments import CommentedMap import atexit +from ofunctions.process import kill_childs from ofunctions.threading import threaded from ofunctions.misc import BytesConverter import PySimpleGUI as sg @@ -720,6 +721,11 @@ def main_gui(viewer_mode=False): npbackup.common.execution_logs, datetime.utcnow(), ) + # kill_childs normally would not be necessary, but let's just be foolproof here (kills restic subprocess in all cases) + atexit.register( + kill_childs, + os.getpid(), + ) try: _main_gui(viewer_mode=viewer_mode) sys.exit(logger.get_worst_logger_level()) From 42769607d1ca240baef4aae40a9f73580c48a8bd Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Thu, 28 Dec 2023 00:33:45 +0100 Subject: [PATCH 091/328] Add missing return --- npbackup/restic_wrapper/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/npbackup/restic_wrapper/__init__.py b/npbackup/restic_wrapper/__init__.py index 50de5ce..37374c9 100644 --- a/npbackup/restic_wrapper/__init__.py +++ b/npbackup/restic_wrapper/__init__.py @@ -813,6 +813,7 @@ def repair(self, subject: str) -> bool: return None if subject not in ["index", "snapshots"]: self.write_logs(f"Bogus repair order given: {subject}", level="error") + return False cmd = f"repair {subject}" result, output = self.executor(cmd) if result: From f56875bd3ce8404daee6cd7896238a1dceba2cb5 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Thu, 28 Dec 2023 13:49:15 +0100 Subject: [PATCH 092/328] Rename runner function --- npbackup/__main__.py | 80 +++++++++++++++-------------- npbackup/core/runner.py | 10 ++-- npbackup/gui/__main__.py | 2 +- npbackup/restic_wrapper/__init__.py | 6 +-- 4 files changed, 51 insertions(+), 47 deletions(-) diff --git a/npbackup/__main__.py b/npbackup/__main__.py index ed785b9..65e4070 100644 --- a/npbackup/__main__.py +++ b/npbackup/__main__.py @@ -53,7 +53,30 @@ def cli_interface(): This is free software, and you are welcome to redistribute it under certain conditions; Please type --license for more info.""", ) + parser.add_argument( + "-c", + "--config-file", + dest="config_file", + type=str, + default=None, + required=False, + help="Path to alternative configuration file (defaults to current dir/npbackup.conf)", + ) + parser.add_argument( + "--repo-name", + dest="repo_name", + type=str, + default="default", + required=False, + help="Name of the repository to work with. Defaults to 'default'", + ) parser.add_argument("-b", "--backup", action="store_true", help="Run a backup") + parser.add_argument( + "-f", "--force", + action="store_true", + default=False, + help="Force running a backup regardless of existing backups age", + ) parser.add_argument( "-r", "--restore", @@ -129,30 +152,7 @@ def cli_interface(): parser.add_argument( - "--has-recent-backup", action="store_true", help="Check if a recent backup exists" - ) - parser.add_argument( - "-f", "--force", - action="store_true", - default=False, - help="Force running a backup regardless of existing backups", - ) - parser.add_argument( - "-c", - "--config-file", - dest="config_file", - type=str, - default=None, - required=False, - help="Path to alternative configuration file", - ) - parser.add_argument( - "--repo-name", - dest="repo_name", - type=str, - default="default", - required=False, - help="Name of the repository to work with. Defaults to 'default'", + "--has-recent-snapshot", action="store_true", help="Check if a recent snapshot exists" ) parser.add_argument( "--restore-include", @@ -162,11 +162,11 @@ def cli_interface(): help="Restore only paths within include path", ) parser.add_argument( - "--restore-from-snapshot", + "--snapshot", type=str, default="latest", required=False, - help="Choose which snapshot to restore from. Defaults to latest", + help="Choose which snapshot to use. Defaults to latest", ) parser.add_argument( "-v", "--verbose", action="store_true", help="Show verbose output" @@ -313,20 +313,24 @@ def cli_interface(): cli_args["op_args"] = { "command": args.raw } + elif args.has_recent_snapshot: + cli_args["operation"] = "has_recent_snapshot" - locking_operations = ["backup", "repair", "forget", "prune", "raw", "unlock"] - - # Program entry - if cli_args["operation"] in locking_operations: - try: - with pidfile.PIDFile(PID_FILE): - entrypoint(**cli_args) - except pidfile.AlreadyRunningError: - logger.critical("Backup process already running. Will not continue.") - # EXIT_CODE 21 = current backup process already running - sys.exit(21) + if cli_args["operation"]: + locking_operations = ["backup", "repair", "forget", "prune", "raw", "unlock"] + # Program entry + if cli_args["operation"] in locking_operations: + try: + with pidfile.PIDFile(PID_FILE): + entrypoint(**cli_args) + except pidfile.AlreadyRunningError: + logger.critical("Backup process already running. Will not continue.") + # EXIT_CODE 21 = current backup process already running + sys.exit(21) + else: + entrypoint(**cli_args) else: - entrypoint(**cli_args) + logger.warning("No operation has been requested") diff --git a/npbackup/core/runner.py b/npbackup/core/runner.py index 5b8fa21..c03677c 100644 --- a/npbackup/core/runner.py +++ b/npbackup/core/runner.py @@ -315,7 +315,7 @@ def has_permission(fn: Callable): def wrapper(self, *args, **kwargs): required_permissions = { "backup": ["backup", "restore", "full"], - "check_recent_backups": ["backup", "restore", "full"], + "has_recent_snapshot": ["backup", "restore", "full"], "list": ["backup", "restore", "full"], "ls": ["backup", "restore", "full"], "find": ["backup", "restore", "full"], @@ -375,7 +375,7 @@ def wrapper(self, *args, **kwargs): return fn(self, *args, **kwargs) except Exception as exc: # pylint: disable=E1101 (no-member) - self.write_logs(f"Function {fn.__name__} failed with: {exc}") + self.write_logs(f"Function {fn.__name__} failed with: {exc}", level="error") logger.debug("Trace:", exc_info=True) return False @@ -636,7 +636,7 @@ def ls(self, snapshot: str) -> Optional[dict]: @is_ready @apply_config_to_restic_runner @catch_exceptions - def check_recent_backups(self) -> bool: + def has_recent_snapshot(self) -> bool: """ Checks for backups in timespan Returns True or False if found or not @@ -650,7 +650,7 @@ def check_recent_backups(self) -> bool: level="info", ) self.restic_runner.verbose = False - result, backup_tz = self.restic_runner.has_snapshot_timedelta( + result, backup_tz = self.restic_runner.has_recent_snapshot( self.minimum_backup_age ) self.restic_runner.verbose = self.verbose @@ -776,7 +776,7 @@ def backup(self, force: bool = False) -> bool: level="critical", ) return False - if self.check_recent_backups() and not force: + if self.has_recent_snapshot() and not force: self.write_logs("No backup necessary.", level="info") return True self.restic_runner.verbose = self.verbose diff --git a/npbackup/gui/__main__.py b/npbackup/gui/__main__.py index b2c8142..6b9e498 100644 --- a/npbackup/gui/__main__.py +++ b/npbackup/gui/__main__.py @@ -467,7 +467,7 @@ def get_gui_data(repo_config: dict) -> Tuple[bool, List[str]]: snapshots = gui_thread_runner( repo_config, "list", __gui_msg=gui_msg, __autoclose=True, __compact=True ) - current_state, backup_tz = ResticRunner._has_snapshot_timedelta( + current_state, backup_tz = ResticRunner._has_recent_snapshot( snapshots, repo_config.g("repo_opts.minimum_backup_age") ) snapshot_list = [] diff --git a/npbackup/restic_wrapper/__init__.py b/npbackup/restic_wrapper/__init__.py index 37374c9..06c4e29 100644 --- a/npbackup/restic_wrapper/__init__.py +++ b/npbackup/restic_wrapper/__init__.py @@ -850,7 +850,7 @@ def raw(self, command: str) -> Tuple[bool, str]: return False, output @staticmethod - def _has_snapshot_timedelta( + def _has_recent_snapshot( snapshot_list: List, delta: int = None ) -> Tuple[bool, Optional[datetime]]: """ @@ -882,7 +882,7 @@ def _has_snapshot_timedelta( return True, backup_ts return None, backup_ts - def has_snapshot_timedelta( + def has_recent_snapshot( self, delta: int = None ) -> Tuple[bool, Optional[datetime]]: """ @@ -904,7 +904,7 @@ def has_snapshot_timedelta( snapshots = self.snapshots() if self.last_command_status is False: return None, None - return self._has_snapshot_timedelta(snapshots, delta) + return self._has_recent_snapshot(snapshots, delta) except IndexError as exc: self.write_logs(f"snapshot information missing: {exc}", level="error") logger.debug("Trace", exc_info=True) From 0208eec5f164c9b6480924304d83a8bdf1e1e409 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Thu, 28 Dec 2023 13:49:29 +0100 Subject: [PATCH 093/328] Fix runner interface kwargs --- npbackup/runner_interface.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/npbackup/runner_interface.py b/npbackup/runner_interface.py index e2629d8..c243a56 100644 --- a/npbackup/runner_interface.py +++ b/npbackup/runner_interface.py @@ -25,7 +25,7 @@ def entrypoint(*args, **kwargs): npbackup_runner.repo_config = kwargs.pop("repo_config") npbackup_runner.dry_run = kwargs.pop("dry_run") npbackup_runner.verbose = kwargs.pop("verbose") - result = npbackup_runner.__getattribute__(kwargs.pop("operation"))(kwargs.pop("op_args"), __no_threads=True) + result = npbackup_runner.__getattribute__(kwargs.pop("operation"))(**kwargs.pop("op_args"), __no_threads=True) def auto_upgrade(full_config: dict): From 8b8c56872015c0854bca1488e645bf1d90a28593 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Thu, 28 Dec 2023 14:53:21 +0100 Subject: [PATCH 094/328] Create ROADMAP.md --- ROADMAP.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 ROADMAP.md diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..c21eb56 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,26 @@ +## What's planned ahead + +### Daemon mode + +Instead of relying on scheduled tasks, we could launch backup & housekeeping operations as deamon. +Caveats: + - We need a windows service (nuitka commercial implements one) + - We need to use apscheduler + - We need a resurrect service config for systemd and windows service + +### Web interface + +Since runner can discuss in JSON mode, we could simply wrap it all in FastAPI +Caveats: + - We'll need a web interface, with templates, whistles and belles + - We'll probably need an executor (Celery ?) in order to not block threads + +### KVM Backup plugin +Since we run cube backup, we could "bake in" full KVM support +Caveats: + - We'll need to re-implement libvirt controller class for linux + +### Hyper-V Backup plugin +That's another story. Creating snapshots and dumping VM is easy. +Shall we go that route since alot of good commercial products exist ? + From 68ace1820bc04c9e0d5818f73925bd6e70a9d54f Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Thu, 28 Dec 2023 18:01:54 +0100 Subject: [PATCH 095/328] Move concurrency checks to runner --- npbackup/__main__.py | 18 ++---------------- npbackup/core/runner.py | 40 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 41 insertions(+), 17 deletions(-) diff --git a/npbackup/__main__.py b/npbackup/__main__.py index 65e4070..8e2943d 100644 --- a/npbackup/__main__.py +++ b/npbackup/__main__.py @@ -12,8 +12,6 @@ import atexit from argparse import ArgumentParser from datetime import datetime -import tempfile -import pidfile import ofunctions.logger_utils from ofunctions.process import kill_childs from npbackup.path_helper import CURRENT_DIR @@ -39,7 +37,6 @@ LOG_FILE = os.path.join(CURRENT_DIR, "{}.log".format(__intname__)) -PID_FILE = os.path.join(tempfile.gettempdir(), "{}.pid".format(__intname__)) logger = ofunctions.logger_utils.logger_get_logger(LOG_FILE, debug=_DEBUG) @@ -316,22 +313,11 @@ def cli_interface(): elif args.has_recent_snapshot: cli_args["operation"] = "has_recent_snapshot" + if cli_args["operation"]: - locking_operations = ["backup", "repair", "forget", "prune", "raw", "unlock"] - # Program entry - if cli_args["operation"] in locking_operations: - try: - with pidfile.PIDFile(PID_FILE): - entrypoint(**cli_args) - except pidfile.AlreadyRunningError: - logger.critical("Backup process already running. Will not continue.") - # EXIT_CODE 21 = current backup process already running - sys.exit(21) - else: - entrypoint(**cli_args) + entrypoint(**cli_args) else: logger.warning("No operation has been requested") - def main(): diff --git a/npbackup/core/runner.py b/npbackup/core/runner.py index c03677c..45ae557 100644 --- a/npbackup/core/runner.py +++ b/npbackup/core/runner.py @@ -13,6 +13,8 @@ from typing import Optional, Callable, Union, List import os import logging +import tempfile +import pidfile import queue from datetime import datetime, timedelta from functools import wraps @@ -362,10 +364,34 @@ def wrapper(self, *args, **kwargs): return fn(self, *args, **kwargs) return wrapper + + def check_concurrency(fn: Callable): + """ + Make sure there we don't allow concurrent actions + """ + @wraps(fn) + def wrapper(self, *args, **kwargs): + locking_operations = ["backup", "repair", "forget", "prune", "raw", "unlock"] + if fn.__name__ in locking_operations: + pid_file = os.path.join(tempfile.gettempdir(), "{}.pid".format(__intname__)) + try: + with pidfile.PIDFile(pid_file): + # pylint: disable=E1102 (not-callable) + result = fn(self, *args, **kwargs) + except pidfile.AlreadyRunningError: + # pylint: disable=E1101 (no-member) + self.write_logs("There is already an operation {fn.__name__} running. Will not continue", level="critical") + return False + else: + # pylint: disable=E1102 (not-callable) + result = fn(self, *args, **kwargs) + return result + + return wrapper def catch_exceptions(fn: Callable): """ - Catch any exception and log it + Catch any exception and log it so we don't loose exceptions in thread """ @wraps(fn) @@ -582,6 +608,7 @@ def _apply_config_to_restic_runner(self) -> bool: @threaded @close_queues @exec_timer + @check_concurrency @has_permission @is_ready @apply_config_to_restic_runner @@ -596,6 +623,7 @@ def list(self) -> Optional[dict]: @threaded @close_queues @exec_timer + @check_concurrency @has_permission @is_ready @apply_config_to_restic_runner @@ -617,6 +645,7 @@ def find(self, path: str) -> bool: @threaded @close_queues @exec_timer + @check_concurrency @has_permission @is_ready @apply_config_to_restic_runner @@ -632,6 +661,7 @@ def ls(self, snapshot: str) -> Optional[dict]: @threaded @close_queues @exec_timer + @check_concurrency @has_permission @is_ready @apply_config_to_restic_runner @@ -678,6 +708,7 @@ def has_recent_snapshot(self) -> bool: @threaded @close_queues @exec_timer + @check_concurrency @has_permission @is_ready @apply_config_to_restic_runner @@ -863,6 +894,7 @@ def backup(self, force: bool = False) -> bool: @threaded @close_queues @exec_timer + @check_concurrency @has_permission @is_ready @apply_config_to_restic_runner @@ -879,6 +911,7 @@ def restore(self, snapshot: str, target: str, restore_includes: List[str]) -> bo @threaded @close_queues @exec_timer + @check_concurrency @has_permission @is_ready @apply_config_to_restic_runner @@ -924,6 +957,7 @@ def forget( @threaded @close_queues @exec_timer + @check_concurrency @has_permission @is_ready @apply_config_to_restic_runner @@ -945,6 +979,7 @@ def check(self, read_data: bool = True) -> bool: @threaded @close_queues @exec_timer + @check_concurrency @has_permission @is_ready @apply_config_to_restic_runner @@ -964,6 +999,7 @@ def prune(self, max: bool = False) -> bool: @threaded @close_queues @exec_timer + @check_concurrency @has_permission @is_ready @apply_config_to_restic_runner @@ -978,6 +1014,7 @@ def repair(self, subject: str) -> bool: @threaded @close_queues @exec_timer + @check_concurrency @has_permission @is_ready @apply_config_to_restic_runner @@ -990,6 +1027,7 @@ def unlock(self) -> bool: @threaded @close_queues @exec_timer + @check_concurrency @has_permission @is_ready @apply_config_to_restic_runner From a74a4dc9796a2156302fd216a0ca0ac13821e2b9 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Thu, 28 Dec 2023 18:02:30 +0100 Subject: [PATCH 096/328] Add grace period to kill_childs() call --- npbackup/__main__.py | 2 +- npbackup/gui/__main__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/npbackup/__main__.py b/npbackup/__main__.py index 8e2943d..c0fffea 100644 --- a/npbackup/__main__.py +++ b/npbackup/__main__.py @@ -329,7 +329,7 @@ def main(): # kill_childs normally would not be necessary, but let's just be foolproof here (kills restic subprocess in all cases) atexit.register( kill_childs, - os.getpid(), + os.getpid(), grace_period=30 ) try: cli_interface() diff --git a/npbackup/gui/__main__.py b/npbackup/gui/__main__.py index 6b9e498..ee45e06 100644 --- a/npbackup/gui/__main__.py +++ b/npbackup/gui/__main__.py @@ -724,7 +724,7 @@ def main_gui(viewer_mode=False): # kill_childs normally would not be necessary, but let's just be foolproof here (kills restic subprocess in all cases) atexit.register( kill_childs, - os.getpid(), + os.getpid(), grace_period = 30 ) try: _main_gui(viewer_mode=viewer_mode) From f47c3c8497d5471a881a2aa384d6a7afdf7b849a Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Thu, 28 Dec 2023 18:03:33 +0100 Subject: [PATCH 097/328] Make sure runner.backup() doesn't get it's queues closed by runner.has_recent_snapshot() --- npbackup/core/runner.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/npbackup/core/runner.py b/npbackup/core/runner.py index 45ae557..2e56a03 100644 --- a/npbackup/core/runner.py +++ b/npbackup/core/runner.py @@ -277,7 +277,7 @@ def close_queues(fn: Callable): @wraps(fn) def wrapper(self, *args, **kwargs): - close_queues = kwargs.pop("close_queues", True) + close_queues = kwargs.pop("__close_queues", True) # pylint: disable=E1102 (not-callable) result = fn(self, *args, **kwargs) if close_queues: @@ -807,7 +807,8 @@ def backup(self, force: bool = False) -> bool: level="critical", ) return False - if self.has_recent_snapshot() and not force: + # Since we don't want to close queues nor create a subthread, we need to change behavior here + if self.has_recent_snapshot(__no_threads=True, __close_queues=False) and not force: self.write_logs("No backup necessary.", level="info") return True self.restic_runner.verbose = self.verbose @@ -1050,7 +1051,7 @@ def group_runner(self, repo_config_list: list, operation: str, **kwargs) -> bool kwargs = { **kwargs, **{ - "close_queues": False, + "__close_queues": False, "__no_threads": True, }, } From a20ed911d9ee982f75a544e3c16f436426372534 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Thu, 28 Dec 2023 19:56:08 +0100 Subject: [PATCH 098/328] Linter fixes --- npbackup/core/runner.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/npbackup/core/runner.py b/npbackup/core/runner.py index 2e56a03..5c88966 100644 --- a/npbackup/core/runner.py +++ b/npbackup/core/runner.py @@ -369,9 +369,11 @@ def check_concurrency(fn: Callable): """ Make sure there we don't allow concurrent actions """ + @wraps(fn) def wrapper(self, *args, **kwargs): locking_operations = ["backup", "repair", "forget", "prune", "raw", "unlock"] + # pylint: disable=E1101 (no-member) if fn.__name__ in locking_operations: pid_file = os.path.join(tempfile.gettempdir(), "{}.pid".format(__intname__)) try: @@ -380,11 +382,10 @@ def wrapper(self, *args, **kwargs): result = fn(self, *args, **kwargs) except pidfile.AlreadyRunningError: # pylint: disable=E1101 (no-member) - self.write_logs("There is already an operation {fn.__name__} running. Will not continue", level="critical") + self.write_logs(f"There is already an {fn.__name__} operation running by NPBackup. Will not continue", level="critical") return False else: - # pylint: disable=E1102 (not-callable) - result = fn(self, *args, **kwargs) + result = fn(self, *args, **kwargs) # pylint: disable=E1102 (not-callable) return result return wrapper @@ -808,7 +809,8 @@ def backup(self, force: bool = False) -> bool: ) return False # Since we don't want to close queues nor create a subthread, we need to change behavior here - if self.has_recent_snapshot(__no_threads=True, __close_queues=False) and not force: + # pylint: disable=E1123 (unexpected-keyword-arg) + if self.has_recent_snapshot(__close_queues=False, __no_threads=True) and not force: self.write_logs("No backup necessary.", level="info") return True self.restic_runner.verbose = self.verbose From 4aa895044e78dc211909665a3b7abd2990132ee4 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Fri, 29 Dec 2023 00:52:03 +0100 Subject: [PATCH 099/328] Move to python 3.12 linters --- .github/workflows/pylint-linux.yaml | 2 +- .github/workflows/pylint-windows.yaml | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/pylint-linux.yaml b/.github/workflows/pylint-linux.yaml index cfcc678..3da82b3 100644 --- a/.github/workflows/pylint-linux.yaml +++ b/.github/workflows/pylint-linux.yaml @@ -10,7 +10,7 @@ jobs: matrix: os: [ubuntu-latest] # python-version: [3.3, 3.4, 3.5, 3.6, 3.7, 3.8, 3.9, "3.10", 'pypy-3.6', 'pypy-3.7'] - python-version: ["3.11"] + python-version: ["3.12"] steps: - uses: actions/checkout@v2 diff --git a/.github/workflows/pylint-windows.yaml b/.github/workflows/pylint-windows.yaml index cdeb26a..8ef51f1 100644 --- a/.github/workflows/pylint-windows.yaml +++ b/.github/workflows/pylint-windows.yaml @@ -11,7 +11,7 @@ jobs: os: [windows-latest] # Don't use pypy on windows since it does not have pywin32 module # python-version: [3.5, 3.6, 3.7, 3.8, 3.9, "3.10"] - python-version: ["3.11"] + python-version: ["3.12"] steps: - uses: actions/checkout@v2 @@ -26,14 +26,14 @@ jobs: if (Test-Path "npbackup/requirements.txt") { pip install -r npbackup/requirements.txt } if (Test-Path "upgrade_server/requirements.txt") { pip install -r upgrade_server/requirements.txt } - name: Lint with Pylint - if: ${{ matrix.python-version == '3.11' }} + if: ${{ matrix.python-version == '3.12' }} run: | python -m pip install pylint # Do not run pylint on python 3.3 because isort is not available for python 3.3, don't run on python 3.4 because pylint: disable=xxxx does not exist python -m pylint --disable=C,W,R --max-line-length=127 npbackup python -m pylint --disable=C,W,R --max-line-length=127 upgrade_server/upgrade_server - name: Lint with flake8 - if: ${{ matrix.python-version == '3.11' }} + if: ${{ matrix.python-version == '3.12' }} run: | python -m pip install flake8 # stop the build if there are Python syntax errors or undefined names @@ -44,7 +44,7 @@ jobs: python -m flake8 --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics upgrade_server/upgrade_server - name: Lint with Black # Don't run on python < 3.6 since black does not exist there, run only once - if: ${{ matrix.python-version == '3.11' }} + if: ${{ matrix.python-version == '3.12' }} run: | pip install black python -m black --check npbackup From c716036038e2d7bd5ab8e2e1e29c7655acb801a9 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Fri, 29 Dec 2023 00:52:16 +0100 Subject: [PATCH 100/328] Linter false positive fix --- npbackup/gui/helpers.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/npbackup/gui/helpers.py b/npbackup/gui/helpers.py index 7dec79e..f042894 100644 --- a/npbackup/gui/helpers.py +++ b/npbackup/gui/helpers.py @@ -208,6 +208,8 @@ def _upgrade_from_compact_view(): kwargs = {**kwargs, **{"__no_threads": True}} result = runner.__getattribute__(fn.__name__)(*args, **kwargs) while True: + # No idea why pylint thingks that UpdateAnimation does not exist in PySimpleGUI + # pylint: disable=no-member progress_window["-LOADER-ANIMATION-"].UpdateAnimation( LOADER_ANIMATION, time_between_frames=100 ) From 6125a265c46074fa7816f3fef4f2824d923e9121 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Fri, 29 Dec 2023 00:52:18 +0100 Subject: [PATCH 101/328] Update requirements.txt --- npbackup/requirements.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/npbackup/requirements.txt b/npbackup/requirements.txt index 1ca0211..cc7016b 100644 --- a/npbackup/requirements.txt +++ b/npbackup/requirements.txt @@ -3,8 +3,8 @@ cryptidy>=1.2.2 python-dateutil ofunctions.logger_utils>=2.4.1 ofunctions.misc>=1.6.1 -ofunctions.process>=1.4.0 -ofunctions.threading>=2.1.0 +ofunctions.process>=2.0.0 +ofunctions.threading>=2.2.0 ofunctions.platform>=1.4.1 ofunctions.random python-pidfile>=3.0.0 From c11edf634e6bdec0eae0d55fd9205ffeac08e460 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Fri, 29 Dec 2023 00:53:35 +0100 Subject: [PATCH 102/328] fixup linter fix --- npbackup/gui/helpers.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/npbackup/gui/helpers.py b/npbackup/gui/helpers.py index f042894..10090cb 100644 --- a/npbackup/gui/helpers.py +++ b/npbackup/gui/helpers.py @@ -209,10 +209,9 @@ def _upgrade_from_compact_view(): result = runner.__getattribute__(fn.__name__)(*args, **kwargs) while True: # No idea why pylint thingks that UpdateAnimation does not exist in PySimpleGUI - # pylint: disable=no-member progress_window["-LOADER-ANIMATION-"].UpdateAnimation( LOADER_ANIMATION, time_between_frames=100 - ) + ) # pylint: disable=E1101 (no-member) # So we actually need to read the progress window for it to refresh... _, _ = progress_window.read(0.01) # Read stdout queue From 4a9865b2a68e6641bf36ae2546e06fcedeeb10f5 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Fri, 29 Dec 2023 01:18:13 +0100 Subject: [PATCH 103/328] Linter fixes --- npbackup/core/i18n_helper.py | 1 + npbackup/core/restic_source_binary.py | 3 ++- npbackup/core/runner.py | 9 ++++++--- npbackup/gui/__main__.py | 6 ++++-- npbackup/gui/config.py | 13 ++++++------- npbackup/gui/helpers.py | 5 ++--- npbackup/restic_wrapper/__init__.py | 5 ++--- 7 files changed, 23 insertions(+), 19 deletions(-) diff --git a/npbackup/core/i18n_helper.py b/npbackup/core/i18n_helper.py index e56b3aa..40a48a0 100644 --- a/npbackup/core/i18n_helper.py +++ b/npbackup/core/i18n_helper.py @@ -51,3 +51,4 @@ def _t(*args, **kwargs): logger.error("Arguments: {}".format(*args)) if len(args) > 0: return args[0] + return args diff --git a/npbackup/core/restic_source_binary.py b/npbackup/core/restic_source_binary.py index 91ec917..29a727e 100644 --- a/npbackup/core/restic_source_binary.py +++ b/npbackup/core/restic_source_binary.py @@ -19,7 +19,7 @@ RESTIC_SOURCE_FILES_DIR = os.path.join(BASEDIR, os.pardir, "RESTIC_SOURCE_FILES") -def get_restic_internal_binary(arch): +def get_restic_internal_binary(arch: str) -> str: binary = None if os.path.isdir(RESTIC_SOURCE_FILES_DIR): if os.name == "nt": @@ -45,3 +45,4 @@ def get_restic_internal_binary(arch): guessed_path = glob.glob(os.path.join(RESTIC_SOURCE_FILES_DIR, binary)) if guessed_path: return guessed_path[0] + return None diff --git a/npbackup/core/runner.py b/npbackup/core/runner.py index 5c88966..c08bb7d 100644 --- a/npbackup/core/runner.py +++ b/npbackup/core/runner.py @@ -245,7 +245,7 @@ def write_logs(self, msg: str, level: str, raise_error: str = None): if raise_error == "ValueError": raise ValueError(msg) - elif raise_error: + if raise_error: raise Exception(msg) # pylint does not understand why this function does not take a self parameter @@ -408,7 +408,7 @@ def wrapper(self, *args, **kwargs): return wrapper - def create_restic_runner(self) -> None: + def create_restic_runner(self) -> bool: can_run = True try: repository = self.repo_config.g("repo_uri") @@ -462,7 +462,7 @@ def create_restic_runner(self) -> None: can_run = False self._is_ready = can_run if not can_run: - return None + return False self.restic_runner = ResticRunner( repository=repository, password=password, @@ -478,6 +478,9 @@ def create_restic_runner(self) -> None: self._using_dev_binary = True self.write_logs("Using dev binary !", level="warning") self.restic_runner.binary = binary + else: + return False + return True def _apply_config_to_restic_runner(self) -> bool: if not isinstance(self.restic_runner, ResticRunner): diff --git a/npbackup/gui/__main__.py b/npbackup/gui/__main__.py index ee45e06..bc30c20 100644 --- a/npbackup/gui/__main__.py +++ b/npbackup/gui/__main__.py @@ -109,7 +109,7 @@ def about_gui(version_string: str, full_config: dict = None) -> None: event, _ = window.read() if event in [sg.WIN_CLOSED, "exit"]: break - elif event == "autoupgrade": + if event == "autoupgrade": result = sg.PopupOKCancel( _t("config_gui.auto_ugprade_will_quit"), keep_on_top=True ) @@ -402,7 +402,7 @@ def forget_snapshot(repo_config: dict, snapshot_ids: List[str]) -> bool: def _main_gui(viewer_mode: bool): - def select_config_file(): + def select_config_file() -> None: """ Option to select a configuration file """ @@ -431,7 +431,9 @@ def select_config_file(): if not config: sg.PopupError(_t("generic.bad_file")) continue + window.close() return config_file + return None def gui_update_state() -> None: if current_state: diff --git a/npbackup/gui/config.py b/npbackup/gui/config.py index 5a28972..faaaf3b 100644 --- a/npbackup/gui/config.py +++ b/npbackup/gui/config.py @@ -192,7 +192,7 @@ def update_gui_values(key, value, inherited, object_type, unencrypted): # Enable inheritance icon when needed inheritance_key = f"inherited.{key}" if inheritance_key in window.AllKeysDict: - window[inheritance_key].update(visible=True if inherited else False) + window[inheritance_key].update(visible=inherited) except KeyError: logger.error(f"No GUI equivalent for key {key}.") @@ -379,7 +379,7 @@ def set_permissions(full_config: dict, object_name: str) -> dict: keep_on_top=True, ) continue - elif len(values["-MANAGER-PASSWORD-"]) < 8: + if len(values["-MANAGER-PASSWORD-"]) < 8: sg.PopupError( _t("config_gui.manager_password_too_short"), keep_on_top=True ) @@ -984,11 +984,10 @@ def config_layout() -> List[list]: sg.Popup(_t("config_gui.configuration_saved"), keep_on_top=True) logger.info("Configuration saved successfully.") break - else: - sg.PopupError( - _t("config_gui.cannot_save_configuration"), keep_on_top=True - ) - logger.info("Could not save configuration") + sg.PopupError( + _t("config_gui.cannot_save_configuration"), keep_on_top=True + ) + logger.info("Could not save configuration") if event == _t("config_gui.show_decrypted"): object_type, object_name = get_object_from_combo(values["-OBJECT-SELECT-"]) manager_password = configuration.get_manager_password( diff --git a/npbackup/gui/helpers.py b/npbackup/gui/helpers.py index 10090cb..7376da8 100644 --- a/npbackup/gui/helpers.py +++ b/npbackup/gui/helpers.py @@ -261,12 +261,11 @@ def _upgrade_from_compact_view(): # Keep the window open until user has done something progress_window["-LOADER-ANIMATION-"].Update(visible=False) if not __autoclose or stderr_has_messages: - while True and not progress_window.is_closed(): + while not progress_window.is_closed(): event, _ = progress_window.read() if event in (sg.WIN_CLOSED, sg.WIN_X_EVENT, "--EXIT--"): break progress_window.close() if USE_THREADING: return thread.result() - else: - return result + return result diff --git a/npbackup/restic_wrapper/__init__.py b/npbackup/restic_wrapper/__init__.py index 06c4e29..0c95e82 100644 --- a/npbackup/restic_wrapper/__init__.py +++ b/npbackup/restic_wrapper/__init__.py @@ -227,7 +227,7 @@ def write_logs(self, msg: str, level: str, raise_error: str = None): if raise_error == "ValueError": raise ValueError(msg) - elif raise_error: + if raise_error: raise Exception(msg) def executor( @@ -439,8 +439,7 @@ def binary_version(self) -> Optional[str]: ) if exit_code == 0: return output.strip() - else: - self.write_logs("Cannot get backend version: {output}", level="warning") + self.write_logs("Cannot get backend version: {output}", level="warning") else: self.write_logs( "Cannot get backend version: No binary defined.", level="error" From bbb93ab72424217826f95eabda9b0ae7e436f869 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Fri, 29 Dec 2023 01:20:14 +0100 Subject: [PATCH 104/328] Reformatted files with black --- npbackup/__main__.py | 106 +++++++++------------------- npbackup/core/runner.py | 35 ++++++--- npbackup/gui/__main__.py | 5 +- npbackup/gui/config.py | 4 +- npbackup/restic_wrapper/__init__.py | 4 +- npbackup/runner_interface.py | 7 +- 6 files changed, 70 insertions(+), 91 deletions(-) diff --git a/npbackup/__main__.py b/npbackup/__main__.py index c0fffea..68acd8f 100644 --- a/npbackup/__main__.py +++ b/npbackup/__main__.py @@ -25,6 +25,7 @@ from npbackup.__debug__ import _DEBUG from npbackup.common import execution_logs from npbackup.core.i18n_helper import _t + if os.name == "nt": from npbackup.windows.task import create_scheduled_task @@ -69,7 +70,8 @@ def cli_interface(): ) parser.add_argument("-b", "--backup", action="store_true", help="Run a backup") parser.add_argument( - "-f", "--force", + "-f", + "--force", action="store_true", default=False, help="Force running a backup regardless of existing backups age", @@ -82,7 +84,9 @@ def cli_interface(): required=False, help="Restore to path given by --restore", ) - parser.add_argument("-l", "--list", action="store_true", help="Show current snapshots") + parser.add_argument( + "-l", "--list", action="store_true", help="Show current snapshots" + ) parser.add_argument( "--ls", type=str, @@ -102,54 +106,37 @@ def cli_interface(): type=str, default=None, required=False, - help='Forget given snapshot, or specify \"policy\" to apply retention policy', - ) - parser.add_argument( - "--quick-check", - action="store_true", - help="Quick check repository" + help='Forget given snapshot, or specify "policy" to apply retention policy', ) parser.add_argument( - "--full-check", - action="store_true", - help="Full check repository" + "--quick-check", action="store_true", help="Quick check repository" ) parser.add_argument( - "--prune", - action="store_true", - help="Prune data in repository" + "--full-check", action="store_true", help="Full check repository" ) + parser.add_argument("--prune", action="store_true", help="Prune data in repository") parser.add_argument( "--prune-max", action="store_true", - help="Prune data in repository reclaiming maximum space" + help="Prune data in repository reclaiming maximum space", ) + parser.add_argument("--unlock", action="store_true", help="Unlock repository") + parser.add_argument("--repair-index", action="store_true", help="Repair repo index") parser.add_argument( - "--unlock", - action="store_true", - help="Unlock repository" - ) - parser.add_argument( - "--repair-index", - action="store_true", - help="Repair repo index" - ) - parser.add_argument( - "--repair-snapshots", - action="store_true", - help="Repair repo snapshots" + "--repair-snapshots", action="store_true", help="Repair repo snapshots" ) parser.add_argument( "--raw", type=str, default=None, required=False, - help='Run raw command against backend.', + help="Run raw command against backend.", ) - parser.add_argument( - "--has-recent-snapshot", action="store_true", help="Check if a recent snapshot exists" + "--has-recent-snapshot", + action="store_true", + help="Check if a recent snapshot exists", ) parser.add_argument( "--restore-include", @@ -224,7 +211,9 @@ def cli_interface(): full_config = npbackup.configuration.load_config(CONFIG_FILE) if full_config: - repo_config, _ = npbackup.configuration.get_repo_config(full_config, args.repo_name) + repo_config, _ = npbackup.configuration.get_repo_config( + full_config, args.repo_name + ) else: logger.critical("Cannot obtain repo config") sys.exit(71) @@ -241,78 +230,56 @@ def cli_interface(): "dry_run": args.dry_run, "debug": args.debug, "operation": None, - "op_args": {} + "op_args": {}, } if args.backup: cli_args["operation"] = "backup" - cli_args["op_args"] = { - "force": args.force - } + cli_args["op_args"] = {"force": args.force} elif args.restore: cli_args["operation"] = "restore" cli_args["op_args"] = { "snapshot": args.snapshot, "target": args.restore, - "restore_include": args.restore_include - } + "restore_include": args.restore_include, + } elif args.list: cli_args["operation"] = "list" elif args.ls: cli_args["operation"] = "ls" - cli_args["op_args"] = { - "snapshot": args.snapshot - } + cli_args["op_args"] = {"snapshot": args.snapshot} elif args.find: cli_args["operation"] = "find" - cli_args["op_args"] = { - "snapshot": args.snapshot, - "path": args.find - } + cli_args["op_args"] = {"snapshot": args.snapshot, "path": args.find} elif args.forget: cli_args["operation"] = "forget" if args.forget == "policy": - cli_args["op_args"] = { - "use_policy": True - } + cli_args["op_args"] = {"use_policy": True} else: - cli_args["op_args"] = { - "snapshots": args.forget - } + cli_args["op_args"] = {"snapshots": args.forget} elif args.quick_check: cli_args["operation"] = "check" elif args.full_check: cli_args["operation"] = "check" - cli_args["op_args"] = { - "read_data": True - } + cli_args["op_args"] = {"read_data": True} elif args.prune: cli_args["operation"] = "prune" elif args.prune_max: cli_args["operation"] = "prune" - cli_args["op_args"] = { - "max": True - } + cli_args["op_args"] = {"max": True} elif args.unlock: cli_args["operation"] = "unlock" elif args.repair_index: cli_args["operation"] = "repair" - cli_args["op_args"] = { - "subject": "index" - } + cli_args["op_args"] = {"subject": "index"} elif args.repair_snapshots: cli_args["operation"] = "repair" - cli_args["op_args"] = { - "subject": "snapshots" - } + cli_args["op_args"] = {"subject": "snapshots"} elif args.raw: cli_args["operation"] = "raw" - cli_args["op_args"] = { - "command": args.raw - } + cli_args["op_args"] = {"command": args.raw} elif args.has_recent_snapshot: cli_args["operation"] = "has_recent_snapshot" - if cli_args["operation"]: entrypoint(**cli_args) @@ -327,10 +294,7 @@ def main(): datetime.utcnow(), ) # kill_childs normally would not be necessary, but let's just be foolproof here (kills restic subprocess in all cases) - atexit.register( - kill_childs, - os.getpid(), grace_period=30 - ) + atexit.register(kill_childs, os.getpid(), grace_period=30) try: cli_interface() sys.exit(logger.get_worst_logger_level()) diff --git a/npbackup/core/runner.py b/npbackup/core/runner.py index c08bb7d..86af910 100644 --- a/npbackup/core/runner.py +++ b/npbackup/core/runner.py @@ -364,7 +364,7 @@ def wrapper(self, *args, **kwargs): return fn(self, *args, **kwargs) return wrapper - + def check_concurrency(fn: Callable): """ Make sure there we don't allow concurrent actions @@ -372,22 +372,36 @@ def check_concurrency(fn: Callable): @wraps(fn) def wrapper(self, *args, **kwargs): - locking_operations = ["backup", "repair", "forget", "prune", "raw", "unlock"] + locking_operations = [ + "backup", + "repair", + "forget", + "prune", + "raw", + "unlock", + ] # pylint: disable=E1101 (no-member) if fn.__name__ in locking_operations: - pid_file = os.path.join(tempfile.gettempdir(), "{}.pid".format(__intname__)) + pid_file = os.path.join( + tempfile.gettempdir(), "{}.pid".format(__intname__) + ) try: with pidfile.PIDFile(pid_file): # pylint: disable=E1102 (not-callable) result = fn(self, *args, **kwargs) except pidfile.AlreadyRunningError: # pylint: disable=E1101 (no-member) - self.write_logs(f"There is already an {fn.__name__} operation running by NPBackup. Will not continue", level="critical") + self.write_logs( + f"There is already an {fn.__name__} operation running by NPBackup. Will not continue", + level="critical", + ) return False else: - result = fn(self, *args, **kwargs) # pylint: disable=E1102 (not-callable) + result = fn( + self, *args, **kwargs + ) # pylint: disable=E1102 (not-callable) return result - + return wrapper def catch_exceptions(fn: Callable): @@ -402,7 +416,9 @@ def wrapper(self, *args, **kwargs): return fn(self, *args, **kwargs) except Exception as exc: # pylint: disable=E1101 (no-member) - self.write_logs(f"Function {fn.__name__} failed with: {exc}", level="error") + self.write_logs( + f"Function {fn.__name__} failed with: {exc}", level="error" + ) logger.debug("Trace:", exc_info=True) return False @@ -813,7 +829,10 @@ def backup(self, force: bool = False) -> bool: return False # Since we don't want to close queues nor create a subthread, we need to change behavior here # pylint: disable=E1123 (unexpected-keyword-arg) - if self.has_recent_snapshot(__close_queues=False, __no_threads=True) and not force: + if ( + self.has_recent_snapshot(__close_queues=False, __no_threads=True) + and not force + ): self.write_logs("No backup necessary.", level="info") return True self.restic_runner.verbose = self.verbose diff --git a/npbackup/gui/__main__.py b/npbackup/gui/__main__.py index bc30c20..4c8fdab 100644 --- a/npbackup/gui/__main__.py +++ b/npbackup/gui/__main__.py @@ -724,10 +724,7 @@ def main_gui(viewer_mode=False): datetime.utcnow(), ) # kill_childs normally would not be necessary, but let's just be foolproof here (kills restic subprocess in all cases) - atexit.register( - kill_childs, - os.getpid(), grace_period = 30 - ) + atexit.register(kill_childs, os.getpid(), grace_period=30) try: _main_gui(viewer_mode=viewer_mode) sys.exit(logger.get_worst_logger_level()) diff --git a/npbackup/gui/config.py b/npbackup/gui/config.py index faaaf3b..91feb63 100644 --- a/npbackup/gui/config.py +++ b/npbackup/gui/config.py @@ -984,9 +984,7 @@ def config_layout() -> List[list]: sg.Popup(_t("config_gui.configuration_saved"), keep_on_top=True) logger.info("Configuration saved successfully.") break - sg.PopupError( - _t("config_gui.cannot_save_configuration"), keep_on_top=True - ) + sg.PopupError(_t("config_gui.cannot_save_configuration"), keep_on_top=True) logger.info("Could not save configuration") if event == _t("config_gui.show_decrypted"): object_type, object_name = get_object_from_combo(values["-OBJECT-SELECT-"]) diff --git a/npbackup/restic_wrapper/__init__.py b/npbackup/restic_wrapper/__init__.py index 0c95e82..d519457 100644 --- a/npbackup/restic_wrapper/__init__.py +++ b/npbackup/restic_wrapper/__init__.py @@ -881,9 +881,7 @@ def _has_recent_snapshot( return True, backup_ts return None, backup_ts - def has_recent_snapshot( - self, delta: int = None - ) -> Tuple[bool, Optional[datetime]]: + def has_recent_snapshot(self, delta: int = None) -> Tuple[bool, Optional[datetime]]: """ Checks if a snapshot exists that is newer that delta minutes Eg: if delta = -60 we expect a snapshot newer than an hour ago, and return True if exists diff --git a/npbackup/runner_interface.py b/npbackup/runner_interface.py index c243a56..666614d 100644 --- a/npbackup/runner_interface.py +++ b/npbackup/runner_interface.py @@ -25,12 +25,15 @@ def entrypoint(*args, **kwargs): npbackup_runner.repo_config = kwargs.pop("repo_config") npbackup_runner.dry_run = kwargs.pop("dry_run") npbackup_runner.verbose = kwargs.pop("verbose") - result = npbackup_runner.__getattribute__(kwargs.pop("operation"))(**kwargs.pop("op_args"), __no_threads=True) + result = npbackup_runner.__getattribute__(kwargs.pop("operation"))( + **kwargs.pop("op_args"), __no_threads=True + ) def auto_upgrade(full_config: dict): pass + """ def interface(): @@ -103,4 +106,4 @@ def interface(): sys.exit(0) else: sys.exit(2) -""" \ No newline at end of file +""" From 9555d06fb6ac5c8cc6e2e51f30ac62bb00b344eb Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Fri, 29 Dec 2023 01:20:32 +0100 Subject: [PATCH 105/328] Remove unncecessary workflow conditions --- .github/workflows/pylint-linux.yaml | 6 +++--- .github/workflows/pylint-windows.yaml | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/pylint-linux.yaml b/.github/workflows/pylint-linux.yaml index 3da82b3..3d41e29 100644 --- a/.github/workflows/pylint-linux.yaml +++ b/.github/workflows/pylint-linux.yaml @@ -25,7 +25,7 @@ jobs: if [ -f npbackup/requirements.txt ]; then pip install -r npbackup/requirements.txt; fi if [ -f upgrade_server/requirements.txt ]; then pip install -r upgrade_server/requirements.txt; fi - name: Lint with Pylint - if: ${{ matrix.python-version == '3.11' }} + #if: ${{ matrix.python-version == '3.11' }} run: | python -m pip install pylint # Do not run pylint on python 3.3 because isort is not available for python 3.3, don't run on python 3.4 because pylint: disable=xxxx does not exist @@ -33,7 +33,7 @@ jobs: python -m pylint --disable=C,W,R --max-line-length=127 npbackup python -m pylint --disable=C,W,R --max-line-length=127 upgrade_server/upgrade_server - name: Lint with flake8 - if: ${{ matrix.python-version == '3.11' }} + #if: ${{ matrix.python-version == '3.11' }} run: | python -m pip install flake8 # stop the build if there are Python syntax errors or undefined names @@ -44,7 +44,7 @@ jobs: python -m flake8 --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics upgrade_server - name: Lint with Black # Don't run on python < 3.6 since black does not exist there, run only once - if: ${{ matrix.python-version == '3.11' }} + #if: ${{ matrix.python-version == '3.11' }} run: | pip install black python -m black --check npbackup diff --git a/.github/workflows/pylint-windows.yaml b/.github/workflows/pylint-windows.yaml index 8ef51f1..b02a00b 100644 --- a/.github/workflows/pylint-windows.yaml +++ b/.github/workflows/pylint-windows.yaml @@ -26,14 +26,14 @@ jobs: if (Test-Path "npbackup/requirements.txt") { pip install -r npbackup/requirements.txt } if (Test-Path "upgrade_server/requirements.txt") { pip install -r upgrade_server/requirements.txt } - name: Lint with Pylint - if: ${{ matrix.python-version == '3.12' }} + #if: ${{ matrix.python-version == '3.12' }} run: | python -m pip install pylint # Do not run pylint on python 3.3 because isort is not available for python 3.3, don't run on python 3.4 because pylint: disable=xxxx does not exist python -m pylint --disable=C,W,R --max-line-length=127 npbackup python -m pylint --disable=C,W,R --max-line-length=127 upgrade_server/upgrade_server - name: Lint with flake8 - if: ${{ matrix.python-version == '3.12' }} + #if: ${{ matrix.python-version == '3.12' }} run: | python -m pip install flake8 # stop the build if there are Python syntax errors or undefined names @@ -44,7 +44,7 @@ jobs: python -m flake8 --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics upgrade_server/upgrade_server - name: Lint with Black # Don't run on python < 3.6 since black does not exist there, run only once - if: ${{ matrix.python-version == '3.12' }} + #if: ${{ matrix.python-version == '3.12' }} run: | pip install black python -m black --check npbackup From c550e741a6614fd18dfaf533fb6475189edf1665 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Fri, 29 Dec 2023 01:36:48 +0100 Subject: [PATCH 106/328] fixup linter fix --- npbackup/core/runner.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/npbackup/core/runner.py b/npbackup/core/runner.py index 86af910..01c46b2 100644 --- a/npbackup/core/runner.py +++ b/npbackup/core/runner.py @@ -397,9 +397,10 @@ def wrapper(self, *args, **kwargs): ) return False else: + # pylint: disable=E1102 (not-callable) result = fn( self, *args, **kwargs - ) # pylint: disable=E1102 (not-callable) + ) return result return wrapper From 213e9bc4cf2134d4956e1c315ec3b1cceab8588f Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Fri, 29 Dec 2023 01:39:23 +0100 Subject: [PATCH 107/328] Fix config loader when empty / wrong file is given --- npbackup/configuration.py | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/npbackup/configuration.py b/npbackup/configuration.py index 2c429e4..d6ebb8c 100644 --- a/npbackup/configuration.py +++ b/npbackup/configuration.py @@ -7,10 +7,11 @@ __author__ = "Orsiris de Jong" __copyright__ = "Copyright (C) 2022-2023 NetInvent" __license__ = "GPL-3.0-only" -__build__ = "2023121501" +__build__ = "2023122901" __version__ = "2.0.0 for npbackup 2.3.0+" -CONF_VERSION = 2.3 +MIN_CONF_VERSION = 2.3 +MAX_CONF_VERSION = 2.3 from typing import Tuple, Optional, List, Any, Union import sys @@ -116,7 +117,7 @@ def d(self, path, sep="."): # This is what a config file looks like empty_config_dict = { - "conf_version": CONF_VERSION, + "conf_version": MAX_CONF_VERSION, "repos": { "default": { "repo_uri": "", @@ -481,12 +482,18 @@ def _load_config_file(config_file: Path) -> Union[bool, dict]: with open(config_file, "r", encoding="utf-8") as file_handle: yaml = YAML(typ="rt") full_config = yaml.load(file_handle) - - conf_version = full_config.g("conf_version") - if conf_version != CONF_VERSION: - logger.critical( - f"Config file version {conf_version} is not required version {CONF_VERSION}" - ) + if not full_config: + logger.critical("Config file seems empty !") + return False + try: + conf_version = float(full_config.g("conf_version")) + if conf_version < MIN_CONF_VERSION or conf_version > MAX_CONF_VERSION: + logger.critical( + f"Config file version {conf_version} is not required version min={MIN_CONF_VERSION}, max={MAX_CONF_VERSION}" + ) + return False + except AttributeError: + logger.critical("Cannot read conf version from config file, which seems bogus") return False return full_config except OSError: From 9c80875e9dd4783a244bf48a5e0fb99a4a0a9e59 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Fri, 29 Dec 2023 01:45:53 +0100 Subject: [PATCH 108/328] Reformat files with black --- npbackup/configuration.py | 4 +++- npbackup/core/runner.py | 4 +--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/npbackup/configuration.py b/npbackup/configuration.py index d6ebb8c..8372216 100644 --- a/npbackup/configuration.py +++ b/npbackup/configuration.py @@ -493,7 +493,9 @@ def _load_config_file(config_file: Path) -> Union[bool, dict]: ) return False except AttributeError: - logger.critical("Cannot read conf version from config file, which seems bogus") + logger.critical( + "Cannot read conf version from config file, which seems bogus" + ) return False return full_config except OSError: diff --git a/npbackup/core/runner.py b/npbackup/core/runner.py index 01c46b2..de39a5d 100644 --- a/npbackup/core/runner.py +++ b/npbackup/core/runner.py @@ -398,9 +398,7 @@ def wrapper(self, *args, **kwargs): return False else: # pylint: disable=E1102 (not-callable) - result = fn( - self, *args, **kwargs - ) + result = fn(self, *args, **kwargs) return result return wrapper From a2bc368a3282619c9280e5ad93129d7761413d28 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Fri, 29 Dec 2023 01:46:05 +0100 Subject: [PATCH 109/328] Undo bad linter fix --- npbackup/gui/helpers.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/npbackup/gui/helpers.py b/npbackup/gui/helpers.py index 7376da8..ea4a4d6 100644 --- a/npbackup/gui/helpers.py +++ b/npbackup/gui/helpers.py @@ -268,4 +268,6 @@ def _upgrade_from_compact_view(): progress_window.close() if USE_THREADING: return thread.result() - return result + # Do not change this because of linter, it's a false positive to say we can remove the else statement + else: + return result From f68cf52b0e9d3a31bd7e813859280ccd53cafd92 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Fri, 29 Dec 2023 01:58:06 +0100 Subject: [PATCH 110/328] Add expand button for compact view --- npbackup/gui/helpers.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/npbackup/gui/helpers.py b/npbackup/gui/helpers.py index ea4a4d6..6cd011f 100644 --- a/npbackup/gui/helpers.py +++ b/npbackup/gui/helpers.py @@ -93,6 +93,7 @@ def _upgrade_from_compact_view(): "-OPERATIONS-PROGRESS-STDERR-", ): progress_window[key].Update(visible=True) + progress_window['--EXPAND--'].Update(visible=False) runner = NPBackupRunner() # So we don't always init repo_config, since runner.group_runner would do that itself @@ -160,6 +161,7 @@ def _upgrade_from_compact_view(): visible=USE_THREADING, ) ], + [sg.Button(_t("generic.expand"), key="--EXPAND--", visible=__compact)], [sg.Text("Debugging active", visible=not USE_THREADING)], ], expand_x=True, @@ -197,6 +199,7 @@ def _upgrade_from_compact_view(): background_color=GUI_LOADER_COLOR, titlebar_icon=OEM_ICON, ) + # Finalize the window event, values = progress_window.read(timeout=0.01) read_stdout_queue = True @@ -213,7 +216,9 @@ def _upgrade_from_compact_view(): LOADER_ANIMATION, time_between_frames=100 ) # pylint: disable=E1101 (no-member) # So we actually need to read the progress window for it to refresh... - _, _ = progress_window.read(0.01) + event , _ = progress_window.read(0.01) + if event == '--EXPAND--': + _upgrade_from_compact_view() # Read stdout queue try: stdout_data = stdout_queue.get(timeout=0.01) @@ -239,9 +244,9 @@ def _upgrade_from_compact_view(): read_stderr_queue = False else: stderr_has_messages = True - if __compact: - for key in progress_window.AllKeysDict: - progress_window[key].Update(visible=True) + #if __compact: + #for key in progress_window.AllKeysDict: + # progress_window[key].Update(visible=True) progress_window["-OPERATIONS-PROGRESS-STDERR-"].Update( f"\n{stderr_data}", append=True ) From 1825e113da85c69e9f5f9fa31b955a78dd389e5c Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Fri, 29 Dec 2023 02:06:10 +0100 Subject: [PATCH 111/328] Replace is_init check with decorator --- npbackup/restic_wrapper/__init__.py | 53 +++++++++++++++-------------- 1 file changed, 27 insertions(+), 26 deletions(-) diff --git a/npbackup/restic_wrapper/__init__.py b/npbackup/restic_wrapper/__init__.py index d519457..f257004 100644 --- a/npbackup/restic_wrapper/__init__.py +++ b/npbackup/restic_wrapper/__init__.py @@ -7,8 +7,8 @@ __author__ = "Orsiris de Jong" __copyright__ = "Copyright (C) 2022-2023 NetInvent" __license__ = "GPL-3.0-only" -__build__ = "2023083101" -__version__ = "1.8.0" +__build__ = "2023122901" +__version__ = "1.9.0" from typing import Tuple, List, Optional, Callable, Union @@ -19,6 +19,7 @@ from datetime import datetime, timezone import dateutil.parser import queue +from functools import wraps from command_runner import command_runner from npbackup.__debug__ import _DEBUG @@ -507,6 +508,17 @@ def is_init(self): def is_init(self, value: bool): self._is_init = value + def check_if_init(fn): + """ + Decorator to check that we don't do anything unless repo is initialized + """ + @wraps(fn) + def wrapper(self, *args, **kwargs): + if not self.is_init: + return None + return fn(*args, **kwargs) + return wrapper + @property def last_command_status(self): return self._last_command_status @@ -515,12 +527,11 @@ def last_command_status(self): def last_command_status(self, value: bool): self._last_command_status = value + @check_if_init def list(self, obj: str = "snapshots") -> Optional[list]: """ Returns json list of snapshots """ - if not self.is_init: - return None cmd = "list {} --json".format(obj) result, output = self.executor(cmd) if result: @@ -531,12 +542,11 @@ def list(self, obj: str = "snapshots") -> Optional[list]: logger.debug("Trace:", exc_info=True) return None + @check_if_init def ls(self, snapshot: str) -> Optional[list]: """ Returns json list of objects """ - if not self.is_init: - return None cmd = "ls {} --json".format(snapshot) result, output = self.executor(cmd) if result and output: @@ -556,12 +566,11 @@ def ls(self, snapshot: str) -> Optional[list]: logger.debug("Trace:", exc_info=True) return result + @check_if_init def snapshots(self) -> Optional[list]: """ Returns json list of snapshots """ - if not self.is_init: - return None cmd = "snapshots --json" result, output = self.executor(cmd) if result: @@ -589,6 +598,7 @@ def backup( """ Executes restic backup after interpreting all arguments """ + # TODO: replace with @check_if_init decorator if not self.is_init: return None, None @@ -677,12 +687,11 @@ def backup( self.write_logs("Backup failed backup operation", level="error") return False, output + @check_if_init def find(self, path: str) -> Optional[list]: """ Returns find command """ - if not self.is_init: - return None cmd = 'find "{}" --json'.format(path) result, output = self.executor(cmd) if result: @@ -695,12 +704,11 @@ def find(self, path: str) -> Optional[list]: self.write_logs(f"Could not find path: {path}", level="error") return None + @check_if_init def restore(self, snapshot: str, target: str, includes: List[str] = None): """ Restore given snapshot to directory """ - if not self.is_init: - return None case_ignore_param = "" # Always use case ignore excludes under windows if os.name == "nt": @@ -717,6 +725,7 @@ def restore(self, snapshot: str, target: str, includes: List[str] = None): self.write_logs(f"Data not restored: {output}", level="info") return False + @check_if_init def forget( self, snapshots: Optional[Union[List[str], Optional[str]]] = None, @@ -725,8 +734,6 @@ def forget( """ Execute forget command for given snapshot """ - if not self.is_init: - return None if not snapshots and not policy: self.write_logs( "No valid snapshot or policy defined for pruning", level="error" @@ -767,14 +774,13 @@ def forget( self.verbose = verbose return batch_result + @check_if_init def prune( self, max_unused: Optional[str] = None, max_repack_size: Optional[int] = None ) -> bool: """ Prune forgotten snapshots """ - if not self.is_init: - return None cmd = "prune" if max_unused: cmd += f"--max-unused {max_unused}" @@ -790,12 +796,11 @@ def prune( self.write_logs(f"Could not prune repository:\n{output}", level="error") return False + @check_if_init def check(self, read_data: bool = True) -> bool: """ Check current repo status """ - if not self.is_init: - return None cmd = "check{}".format(" --read-data" if read_data else "") result, output = self.executor(cmd) if result: @@ -804,12 +809,11 @@ def check(self, read_data: bool = True) -> bool: self.write_logs(f"Repo check failed:\n {output}", level="critical") return False + @check_if_init def repair(self, subject: str) -> bool: """ Check current repo status """ - if not self.is_init: - return None if subject not in ["index", "snapshots"]: self.write_logs(f"Bogus repair order given: {subject}", level="error") return False @@ -821,12 +825,11 @@ def repair(self, subject: str) -> bool: self.write_logs(f"Repo repair failed:\n {output}", level="critical") return False + @check_if_init def unlock(self) -> bool: """ Remove stale locks from repos """ - if not self.is_init: - return None cmd = f"unlock" result, output = self.executor(cmd) if result: @@ -835,12 +838,11 @@ def unlock(self) -> bool: self.write_logs(f"Repo unlock failed:\n {output}", level="critical") return False + @check_if_init def raw(self, command: str) -> Tuple[bool, str]: """ Execute plain restic command without any interpretation" """ - if not self.is_init: - return None result, output = self.executor(command) if result: self.write_logs(f"successfully run raw command:\n{output}", level="info") @@ -881,6 +883,7 @@ def _has_recent_snapshot( return True, backup_ts return None, backup_ts + @check_if_init def has_recent_snapshot(self, delta: int = None) -> Tuple[bool, Optional[datetime]]: """ Checks if a snapshot exists that is newer that delta minutes @@ -892,8 +895,6 @@ def has_recent_snapshot(self, delta: int = None) -> Tuple[bool, Optional[datetim returns False, datetime = 0001-01-01T00:00:00 if no snapshots found Returns None, None on error """ - if not self.is_init: - return None # Don't bother to deal with mising delta if not delta: return False, None From f07c51df81b3e6de27e136090f104a3250363d7b Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Fri, 29 Dec 2023 12:44:07 +0100 Subject: [PATCH 112/328] Cosmetic changes --- npbackup/customization.py | 4 ++-- npbackup/gui/helpers.py | 24 ++++++++++++------------ 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/npbackup/customization.py b/npbackup/customization.py index eee7f3f..181af8d 100644 --- a/npbackup/customization.py +++ b/npbackup/customization.py @@ -36,8 +36,8 @@ # PySimpleGUI theme # Valid list: ['Black', 'BlueMono', 'BluePurple', 'BrightColors', 'BrownBlue', 'Dark', 'Dark2', 'DarkAmber', 'DarkBlack', 'DarkBlack1', 'DarkBlue', 'DarkBlue1', 'DarkBlue10', 'DarkBlue11', 'DarkBlue12', 'DarkBlue13', 'DarkBlue14', 'DarkBlue15', 'DarkBlue16', 'DarkBlue17', 'DarkBlue2', 'DarkBlue3', 'DarkBlue4', 'DarkBlue5', 'DarkBlue6', 'DarkBlue7', 'DarkBlue8', 'DarkBlue9', 'DarkBrown', 'DarkBrown1', 'DarkBrown2', 'DarkBrown3', 'DarkBrown4', 'DarkBrown5', 'DarkBrown6', 'DarkBrown7', 'DarkGreen', 'DarkGreen1', 'DarkGreen2', 'DarkGreen3', 'DarkGreen4', 'DarkGreen5', 'DarkGreen6', 'DarkGreen7', 'DarkGrey', 'DarkGrey1', 'DarkGrey10', 'DarkGrey11', 'DarkGrey12', 'DarkGrey13', 'DarkGrey14', 'DarkGrey15', 'DarkGrey2', 'DarkGrey3', 'DarkGrey4', 'DarkGrey5', 'DarkGrey6', 'DarkGrey7', 'DarkGrey8', 'DarkGrey9', 'DarkPurple', 'DarkPurple1', 'DarkPurple2', 'DarkPurple3', 'DarkPurple4', 'DarkPurple5', 'DarkPurple6', 'DarkPurple7', 'DarkRed', 'DarkRed1', 'DarkRed2', 'DarkTanBlue', 'DarkTeal', 'DarkTeal1', 'DarkTeal10', 'DarkTeal11', 'DarkTeal12', 'DarkTeal2', 'DarkTeal3', 'DarkTeal4', 'DarkTeal5', 'DarkTeal6', 'DarkTeal7', 'DarkTeal8', 'DarkTeal9', 'Default', 'Default1', 'DefaultNoMoreNagging', 'GrayGrayGray', 'Green', 'GreenMono', 'GreenTan', 'HotDogStand', 'Kayak', 'LightBlue', 'LightBlue1', 'LightBlue2', 'LightBlue3', 'LightBlue4', 'LightBlue5', 'LightBlue6', 'LightBlue7', 'LightBrown', 'LightBrown1', 'LightBrown10', 'LightBrown11', 'LightBrown12', 'LightBrown13', 'LightBrown2', 'LightBrown3', 'LightBrown4', 'LightBrown5', 'LightBrown6', 'LightBrown7', 'LightBrown8', 'LightBrown9', 'LightGray1', 'LightGreen', 'LightGreen1', 'LightGreen10', 'LightGreen2', 'LightGreen3', 'LightGreen4', 'LightGreen5', 'LightGreen6', 'LightGreen7', 'LightGreen8', 'LightGreen9', 'LightGrey', 'LightGrey1', 'LightGrey2', 'LightGrey3', 'LightGrey4', 'LightGrey5', 'LightGrey6', 'LightPurple', 'LightTeal', 'LightYellow', 'Material1', 'Material2', 'NeutralBlue', 'Purple', 'Python', 'PythonPlus', 'Reddit', 'Reds', 'SandyBeach', 'SystemDefault', 'SystemDefault1', 'SystemDefaultForReal', 'Tan', 'TanBlue', 'TealMono', 'Topanga'] PYSIMPLEGUI_THEME = "Reddit" -GUI_LOADER_COLOR = "#0079d3" -GUI_LOADER_TEXT_COLOR = "#FFFFFF" +BG_COLOR_LDR = "#0079d3" +TXT_COLOR_LDR = "#FFFFFF" GUI_STATE_OK_BUTTON = ("#FFFFFF", "#0079d3") GUI_STATE_OLD_BUTTON = ("white", "darkred") GUI_STATE_UNKNOWN_BUTTON = ("white", "darkgrey") diff --git a/npbackup/gui/helpers.py b/npbackup/gui/helpers.py index 6cd011f..054f229 100644 --- a/npbackup/gui/helpers.py +++ b/npbackup/gui/helpers.py @@ -19,8 +19,8 @@ from npbackup.core.i18n_helper import _t from npbackup.customization import ( LOADER_ANIMATION, - GUI_LOADER_COLOR, - GUI_LOADER_TEXT_COLOR, + BG_COLOR_LDR, + TXT_COLOR_LDR, ) from npbackup.core.runner import NPBackupRunner from npbackup.__debug__ import _DEBUG @@ -120,8 +120,8 @@ def _upgrade_from_compact_view(): sg.Text( _t("main_gui.last_messages"), key="-OPERATIONS-PROGRESS-STDOUT-TITLE-", - text_color=GUI_LOADER_TEXT_COLOR, - background_color=GUI_LOADER_COLOR, + text_color=TXT_COLOR_LDR, + background_color=BG_COLOR_LDR, visible=not __compact, ) ], @@ -137,8 +137,8 @@ def _upgrade_from_compact_view(): sg.Text( _t("main_gui.error_messages"), key="-OPERATIONS-PROGRESS-STDERR-TITLE-", - text_color=GUI_LOADER_TEXT_COLOR, - background_color=GUI_LOADER_COLOR, + text_color=TXT_COLOR_LDR, + background_color=BG_COLOR_LDR, visible=not __compact, ) ], @@ -153,28 +153,28 @@ def _upgrade_from_compact_view(): [ sg.Column( [ + [sg.Push(background_color=BG_COLOR_LDR), sg.Text("↓", key="--EXPAND--", enable_events=True, background_color=BG_COLOR_LDR, text_color=TXT_COLOR_LDR, visible=__compact)], [ sg.Image( LOADER_ANIMATION, key="-LOADER-ANIMATION-", - background_color=GUI_LOADER_COLOR, + background_color=BG_COLOR_LDR, visible=USE_THREADING, ) ], - [sg.Button(_t("generic.expand"), key="--EXPAND--", visible=__compact)], [sg.Text("Debugging active", visible=not USE_THREADING)], ], expand_x=True, justification="C", element_justification="C", - background_color=GUI_LOADER_COLOR, + background_color=BG_COLOR_LDR, ) ], [ sg.Button( _t("generic.close"), key="--EXIT--", - button_color=(GUI_LOADER_TEXT_COLOR, GUI_LOADER_COLOR), + button_color=(TXT_COLOR_LDR, BG_COLOR_LDR), ) ], ] @@ -185,7 +185,7 @@ def _upgrade_from_compact_view(): progress_layout, element_justification="C", expand_x=True, - background_color=GUI_LOADER_COLOR, + background_color=BG_COLOR_LDR, ) ] ] @@ -196,7 +196,7 @@ def _upgrade_from_compact_view(): use_custom_titlebar=True, grab_anywhere=True, keep_on_top=True, - background_color=GUI_LOADER_COLOR, + background_color=BG_COLOR_LDR, titlebar_icon=OEM_ICON, ) # Finalize the window From ff1dce44e17049d3d05cabefb8b5a757c0f04c59 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Fri, 29 Dec 2023 12:44:24 +0100 Subject: [PATCH 113/328] Fix missing self argument for decorator fn --- npbackup/restic_wrapper/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/npbackup/restic_wrapper/__init__.py b/npbackup/restic_wrapper/__init__.py index f257004..1cca92e 100644 --- a/npbackup/restic_wrapper/__init__.py +++ b/npbackup/restic_wrapper/__init__.py @@ -516,7 +516,7 @@ def check_if_init(fn): def wrapper(self, *args, **kwargs): if not self.is_init: return None - return fn(*args, **kwargs) + return fn(self, *args, **kwargs) return wrapper @property @@ -566,7 +566,7 @@ def ls(self, snapshot: str) -> Optional[list]: logger.debug("Trace:", exc_info=True) return result - @check_if_init + @check_if_init # TODO: remove function and keep list("snapshots") def snapshots(self) -> Optional[list]: """ Returns json list of snapshots From 5cfb6b73f1231204295fbedddb8891cfd7e0bd55 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Fri, 29 Dec 2023 12:45:19 +0100 Subject: [PATCH 114/328] Improve viewer to allow changing repos --- npbackup/gui/__main__.py | 95 +++++++++++++++++---------- npbackup/translations/main_gui.en.yml | 1 + npbackup/translations/main_gui.fr.yml | 3 +- 3 files changed, 64 insertions(+), 35 deletions(-) diff --git a/npbackup/gui/__main__.py b/npbackup/gui/__main__.py index 4c8fdab..6daa7d3 100644 --- a/npbackup/gui/__main__.py +++ b/npbackup/gui/__main__.py @@ -31,8 +31,8 @@ from npbackup.customization import ( OEM_STRING, OEM_LOGO, - GUI_LOADER_COLOR, - GUI_LOADER_TEXT_COLOR, + BG_COLOR_LDR, + TXT_COLOR_LDR, GUI_STATE_OK_BUTTON, GUI_STATE_OLD_BUTTON, GUI_STATE_UNKNOWN_BUTTON, @@ -123,6 +123,23 @@ def about_gui(version_string: str, full_config: dict = None) -> None: window.close() +def viewer_create_repo(viewer_repo_uri: str, viewer_repo_password: str) -> dict: + """ + Create a minimal repo config for viewing purposes + """ + repo_config = CommentedMap() + repo_config.s("name", "external") + repo_config.s("repo_uri", viewer_repo_uri) + repo_config.s("repo_opts", CommentedMap()) + repo_config.s("repo_opts.repo_password", viewer_repo_password) + # Let's set default backup age to 24h + repo_config.s("repo_opts.minimum_backup_age", 1440) + # NPF-SEC-00005 Add restore permission + repo_config.s("permissions", "restore") + + return repo_config + + def viewer_repo_gui( viewer_repo_uri: str = None, viewer_repo_password: str = None ) -> Tuple[str, str]: @@ -263,8 +280,8 @@ def ls_window(repo_config: dict, snapshot_id: str) -> bool: LOADER_ANIMATION, message="{}...".format(_t("main_gui.creating_tree")), time_between_frames=50, - background_color=GUI_LOADER_COLOR, - text_color=GUI_LOADER_TEXT_COLOR, + background_color=BG_COLOR_LDR, + text_color=TXT_COLOR_LDR, ) sg.PopupAnimated(None) treedata = thread.result() @@ -503,18 +520,26 @@ def get_gui_data(repo_config: dict) -> Tuple[bool, List[str]]: ) return current_state, backup_tz, snapshot_list - if not viewer_mode: + def get_config_file() -> str: + """ + Load config file until we got something + """ config_file = Path(f"{CURRENT_DIR}/npbackup.conf") - if not config_file.exists(): - while True: + + while True: + if not config_file or not config_file.exists(): config_file = select_config_file() - if config_file: - config_file = select_config_file() + if config_file: + logger.info(f"Using configuration file {config_file}") + full_config = npbackup.configuration.load_config(config_file) + if not full_config: + config_file = None + sg.PopupError("main_gui.config_error") else: - break + return full_config - logger.info(f"Using configuration file {config_file}") - full_config = npbackup.configuration.load_config(config_file) + if not viewer_mode: + full_config = get_config_file() repo_config, config_inheritance = npbackup.configuration.get_repo_config( full_config ) @@ -523,21 +548,14 @@ def get_gui_data(repo_config: dict) -> Tuple[bool, List[str]]: backup_destination = _t("main_gui.local_folder") backend_type, repo_uri = get_anon_repo_uri(repo_config.g("repo_uri")) else: - # Init empty REPO - repo_config = CommentedMap() - repo_config.s("name", "external") + # Let's try to read standard restic repository env variables viewer_repo_uri = os.environ.get("RESTIC_REPOSITORY", None) viewer_repo_password = os.environ.get("RESTIC_PASSWORD", None) - if not viewer_repo_uri or not viewer_repo_password: - viewer_repo_uri, viewer_repo_password = viewer_repo_gui( - viewer_repo_uri, viewer_repo_password - ) - repo_config.s("repo_uri", viewer_repo_uri) - repo_config.s("repo_opts", CommentedMap()) - repo_config.s("repo_opts.repo_password", viewer_repo_password) - # Let's set default backup age to 24h - repo_config.s("repo_opts.minimum_backup_age", 1440) - + if viewer_repo_uri and viewer_repo_password: + repo_config = viewer_create_repo(viewer_repo_uri, viewer_repo_password) + else: + repo_config = None + right_click_menu = ["", [_t("generic.destination")]] headings = [ "ID ", @@ -598,6 +616,11 @@ def get_gui_data(repo_config: dict) -> Tuple[bool, List[str]]: ) ], [ + sg.Button( + _t("main_gui.open_repo"), + key="--OPEN-REPO--", + visible=viewer_mode + ), sg.Button( _t("main_gui.launch_backup"), key="--LAUNCH-BACKUP--", @@ -646,13 +669,14 @@ def get_gui_data(repo_config: dict) -> Tuple[bool, List[str]]: window["snapshot-list"].expand(True, True) window.read(timeout=1) - try: - current_state, backup_tz, snapshot_list = get_gui_data(repo_config) - except ValueError: - current_state = None - backup_tz = None - snapshot_list = [] - gui_update_state() + if repo_config: + try: + current_state, backup_tz, snapshot_list = get_gui_data(repo_config) + except ValueError: + current_state = None + backup_tz = None + snapshot_list = [] + gui_update_state() while True: event, values = window.read(timeout=60000) @@ -699,6 +723,10 @@ def get_gui_data(repo_config: dict) -> Tuple[bool, List[str]]: full_config = config_gui(full_config, config_file) # Make sure we trigger a GUI refresh when configuration is changed event = "--STATE-BUTTON--" + if event == "--OPEN-REPO--": + viewer_repo_uri, viewer_repo_password = viewer_repo_gui() + repo_config = viewer_create_repo(viewer_repo_uri, viewer_repo_password) + event = "--STATE-BUTTON--" if event == _t("generic.destination"): try: if backend_type: @@ -734,6 +762,5 @@ def main_gui(viewer_mode=False): except Exception as exc: sg.Popup(_t("config_gui.unknown_error_see_logs") + f": {exc}") logger.critical(f"GUI Execution error {exc}") - if _DEBUG: - logger.critical("Trace:", exc_info=True) + logger.debug("Trace:", exc_info=True) sys.exit(251) diff --git a/npbackup/translations/main_gui.en.yml b/npbackup/translations/main_gui.en.yml index 81ad904..618c2b4 100644 --- a/npbackup/translations/main_gui.en.yml +++ b/npbackup/translations/main_gui.en.yml @@ -37,6 +37,7 @@ en: select_config_file: Select config file repo_and_password_cannot_be_empty: Repo and password cannot be empty viewer_mode: Repository view-only mode + open_repo: Open repo # logs last_messages: Last messages diff --git a/npbackup/translations/main_gui.fr.yml b/npbackup/translations/main_gui.fr.yml index f69cf30..36a7e08 100644 --- a/npbackup/translations/main_gui.fr.yml +++ b/npbackup/translations/main_gui.fr.yml @@ -37,7 +37,8 @@ fr: select_config_file: Sélectionner fichier de configuration repo_and_password_cannot_be_empty: Le dépot et le mot de passe ne peuvent être vides viewer_mode: Mode visualisation de dépot uniquement - + open_repo: Ouvrir dépot + # logs last_messages: Last messages error_messages: Error messages \ No newline at end of file From 5de27a7fe6b7a0b96c648a5f0f1160924b4a973c Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Fri, 29 Dec 2023 12:45:23 +0100 Subject: [PATCH 115/328] Update SECURITY.md --- SECURITY.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/SECURITY.md b/SECURITY.md index 2dc4357..5603855 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -17,4 +17,9 @@ Password command is also not logged. Partially covered with password_command feature. We should have a central password server that holds repo passwords, so password is never actually stored in config. -This will prevent local backups, so we need to think of a better zero knowledge strategy here. \ No newline at end of file +This will prevent local backups, so we need to think of a better zero knowledge strategy here. + +# NPF-SEC-00005: Viewer mode can bypass permissions + +Since viewer mode requires actual knowledge of repo URI and repo password, there's no need to manage local permissions. +Viewer mode permissions are set to "restore". \ No newline at end of file From 9bc37884f4d41ccb2615855cd3bf7e1f7b0ab9ff Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Fri, 29 Dec 2023 12:45:40 +0100 Subject: [PATCH 116/328] Update color placeholders --- npbackup/gui/operations.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/npbackup/gui/operations.py b/npbackup/gui/operations.py index e639d4e..c9870c2 100644 --- a/npbackup/gui/operations.py +++ b/npbackup/gui/operations.py @@ -18,8 +18,8 @@ from npbackup.customization import ( OEM_STRING, OEM_LOGO, - GUI_LOADER_COLOR, - GUI_LOADER_TEXT_COLOR, + BG_COLOR_LDR, + TXT_COLOR_LDR, GUI_STATE_OK_BUTTON, GUI_STATE_OLD_BUTTON, GUI_STATE_UNKNOWN_BUTTON, From 22b0d3ec2c4f903af56ebff89152cded8eb4f94a Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Fri, 29 Dec 2023 13:18:09 +0100 Subject: [PATCH 117/328] Add default_config retrieval --- npbackup/configuration.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/npbackup/configuration.py b/npbackup/configuration.py index 8372216..a847db6 100644 --- a/npbackup/configuration.py +++ b/npbackup/configuration.py @@ -222,6 +222,20 @@ def d(self, path, sep="."): } +def get_default_config() -> dict: + """ + Returns a config dict as nested CommentedMaps (used by ruamel.yaml to keep comments intact) + """ + full_config = deepcopy(empty_config_dict) + def convert_to(source_dict, ): + if isinstance(source_dict, dict): + return CommentedMap({k:convert_to(v) for k,v in source_dict.items()}) + else: + return source_dict + + return convert_to(full_config) + + def crypt_config( full_config: dict, aes_key: str, encrypted_options: List[str], operation: str ): From 3f3c75f41497aec6c38e62befa57e6bc2cabb82d Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Fri, 29 Dec 2023 13:29:05 +0100 Subject: [PATCH 118/328] Improve config file management --- npbackup/gui/__main__.py | 53 +++++++++++++++++++------ npbackup/translations/config_gui.en.yml | 4 +- npbackup/translations/config_gui.fr.yml | 4 +- npbackup/translations/generic.en.yml | 5 ++- npbackup/translations/generic.fr.yml | 5 ++- npbackup/translations/main_gui.en.yml | 2 + npbackup/translations/main_gui.fr.yml | 4 +- 7 files changed, 58 insertions(+), 19 deletions(-) diff --git a/npbackup/gui/__main__.py b/npbackup/gui/__main__.py index 6daa7d3..0982750 100644 --- a/npbackup/gui/__main__.py +++ b/npbackup/gui/__main__.py @@ -430,27 +430,35 @@ def select_config_file() -> None: sg.FileBrowse(_t("generic.select_file")), ], [ - sg.Button(_t("generic.cancel"), key="-CANCEL-"), - sg.Button(_t("generic.accept"), key="-ACCEPT-"), + sg.Push(), + sg.Button(_t("generic.quit"), key="--EXIT--"), + sg.Button(_t("main_gui.new_config"), key="--NEW-CONFIG--"), + sg.Button(_t("generic.accept"), key="--ACCEPT--"), ], ] window = sg.Window("Configuration File", layout=layout) + config_file = None while True: + action = None event, values = window.read() - if event in [sg.WIN_X_EVENT, sg.WIN_CLOSED, "-CANCEL-"]: + if event in [sg.WIN_X_EVENT, sg.WIN_CLOSED, "--EXIT--"]: + action = "--EXIT--" break - if event == "-ACCEPT-": + if event == "--NEW-CONFIG--": + action = event + break + if event == "--ACCEPT--": config_file = Path(values["-config_file-"]) - if not config_file.exists(): + if not values["-config_file-"] or not config_file.exists(): sg.PopupError(_t("generic.file_does_not_exist")) continue - config = npbackup.configuration._load_config_file(config_file) - if not config: + full_config = npbackup.configuration.load_config(config_file) + if not full_config: sg.PopupError(_t("generic.bad_file")) continue - window.close() - return config_file - return None + break + window.close() + return config_file, action def gui_update_state() -> None: if current_state: @@ -528,13 +536,18 @@ def get_config_file() -> str: while True: if not config_file or not config_file.exists(): - config_file = select_config_file() + config_file, action = select_config_file() + if action == "--EXIT--": + sys.exit(100) + if action == "--NEW-CONFIG--": + config_file = "npbackup.conf" + full_config = config_gui(npbackup.configuration.get_default_config(), config_file) if config_file: logger.info(f"Using configuration file {config_file}") full_config = npbackup.configuration.load_config(config_file) if not full_config: + sg.PopupError(f"{_t('main_gui.config_error')} {config_file}") config_file = None - sg.PopupError("main_gui.config_error") else: return full_config @@ -640,6 +653,11 @@ def get_config_file() -> str: key="--CONFIGURE--", disabled=viewer_mode, ), + sg.Button( + _t("main_gui.load_configuration"), + key="--LOAD-CONF--", + disabled=viewer_mode + ), sg.Button(_t("generic.about"), key="--ABOUT--"), sg.Button(_t("generic.quit"), key="--EXIT--"), ], @@ -727,6 +745,17 @@ def get_config_file() -> str: viewer_repo_uri, viewer_repo_password = viewer_repo_gui() repo_config = viewer_create_repo(viewer_repo_uri, viewer_repo_password) event = "--STATE-BUTTON--" + if event == "--LOAD-CONF--": + # TODO: duplicate code + full_config = get_config_file() + repo_config, config_inheritance = npbackup.configuration.get_repo_config( + full_config + ) + repo_list = npbackup.configuration.get_repo_list(full_config) + + backup_destination = _t("main_gui.local_folder") + backend_type, repo_uri = get_anon_repo_uri(repo_config.g("repo_uri")) + event = "--STATE-BUTTON--" if event == _t("generic.destination"): try: if backend_type: diff --git a/npbackup/translations/config_gui.en.yml b/npbackup/translations/config_gui.en.yml index 682e49e..646c0db 100644 --- a/npbackup/translations/config_gui.en.yml +++ b/npbackup/translations/config_gui.en.yml @@ -126,8 +126,8 @@ en: set_permissions: Set permissions permissions_only_for_repos: Permissions can only be applied for repos permissions: Permissions - backup_perms: Backup - restore_perms: Restore and Check repo + backup_perms: Backup only + restore_perms: Backup, verify and restore full_perms: Full permissions setting_permissions_requires_manager_password: Setting permissions requires manager password manager_password_too_short: Manager password is too short diff --git a/npbackup/translations/config_gui.fr.yml b/npbackup/translations/config_gui.fr.yml index b8139ea..44e37f8 100644 --- a/npbackup/translations/config_gui.fr.yml +++ b/npbackup/translations/config_gui.fr.yml @@ -127,8 +127,8 @@ fr: set_permissions: Régler les permissions permissions_only_for_repos: Les permissions peuvent être appliquées uniquement à des dépots permissions: Permissions - backup_perms: Sauvegarde - restore_perms: Restauration et vérification de dépot + backup_perms: Sauvegardes uniquement + restore_perms: Sauvegarde, vérification et restauration full_perms: Accès total setting_permissions_requires_manager_password: Un mot de passe gestionnaire est requis pour définir des permissions manager_password_too_short: Le mot de passe gestionnaire est trop court diff --git a/npbackup/translations/generic.en.yml b/npbackup/translations/generic.en.yml index 4f20cd2..da19e97 100644 --- a/npbackup/translations/generic.en.yml +++ b/npbackup/translations/generic.en.yml @@ -59,4 +59,7 @@ en: bogus_data_given: Bogus data given - please_wait: Please wait \ No newline at end of file + please_wait: Please wait + + bad_file: Bad file + file_does_not_exist: File does not exist \ No newline at end of file diff --git a/npbackup/translations/generic.fr.yml b/npbackup/translations/generic.fr.yml index ea4e31e..b14b95b 100644 --- a/npbackup/translations/generic.fr.yml +++ b/npbackup/translations/generic.fr.yml @@ -59,4 +59,7 @@ fr: bogus_data_given: Données invalides - please_wait: Merci de patienter \ No newline at end of file + please_wait: Merci de patienter + + bad_file: Fichier erroné + file_does_not_exist: Fichier inexistant \ No newline at end of file diff --git a/npbackup/translations/main_gui.en.yml b/npbackup/translations/main_gui.en.yml index 618c2b4..9ee08bf 100644 --- a/npbackup/translations/main_gui.en.yml +++ b/npbackup/translations/main_gui.en.yml @@ -38,6 +38,8 @@ en: repo_and_password_cannot_be_empty: Repo and password cannot be empty viewer_mode: Repository view-only mode open_repo: Open repo + new_config: New config + config_error: Configuration file error # logs last_messages: Last messages diff --git a/npbackup/translations/main_gui.fr.yml b/npbackup/translations/main_gui.fr.yml index 36a7e08..bca9c21 100644 --- a/npbackup/translations/main_gui.fr.yml +++ b/npbackup/translations/main_gui.fr.yml @@ -38,7 +38,9 @@ fr: repo_and_password_cannot_be_empty: Le dépot et le mot de passe ne peuvent être vides viewer_mode: Mode visualisation de dépot uniquement open_repo: Ouvrir dépot - + new_config: Nouvelle configuration + config_error: Erreur du fichier de configuration + # logs last_messages: Last messages error_messages: Error messages \ No newline at end of file From f5b6fe3a8be0c4862a79d04a16bbad4747078939 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Fri, 29 Dec 2023 20:22:13 +0100 Subject: [PATCH 119/328] Reformat file with black, linter fixes --- npbackup/configuration.py | 7 +++++-- npbackup/core/runner.py | 8 +++++--- npbackup/gui/__main__.py | 18 ++++++++--------- npbackup/gui/helpers.py | 31 ++++++++++++++++++++--------- npbackup/restic_wrapper/__init__.py | 27 ++++++++++++++++--------- 5 files changed, 59 insertions(+), 32 deletions(-) diff --git a/npbackup/configuration.py b/npbackup/configuration.py index a847db6..7ff1784 100644 --- a/npbackup/configuration.py +++ b/npbackup/configuration.py @@ -227,9 +227,12 @@ def get_default_config() -> dict: Returns a config dict as nested CommentedMaps (used by ruamel.yaml to keep comments intact) """ full_config = deepcopy(empty_config_dict) - def convert_to(source_dict, ): + + def convert_to( + source_dict, + ): if isinstance(source_dict, dict): - return CommentedMap({k:convert_to(v) for k,v in source_dict.items()}) + return CommentedMap({k: convert_to(v) for k, v in source_dict.items()}) else: return source_dict diff --git a/npbackup/core/runner.py b/npbackup/core/runner.py index de39a5d..6eaf80b 100644 --- a/npbackup/core/runner.py +++ b/npbackup/core/runner.py @@ -290,7 +290,7 @@ def wrapper(self, *args, **kwargs): return wrapper def is_ready(fn: Callable): - """ " + """ Decorator that checks if NPBackupRunner is ready to run, and logs accordingly """ @@ -397,8 +397,9 @@ def wrapper(self, *args, **kwargs): ) return False else: - # pylint: disable=E1102 (not-callable) - result = fn(self, *args, **kwargs) + result = fn( # pylint: disable=E1102 (not-callable) + self, *args, **kwargs + ) return result return wrapper @@ -636,6 +637,7 @@ def list(self) -> Optional[dict]: self.write_logs( f"Listing snapshots of repo {self.repo_config.g('name')}", level="info" ) + # TODO: replace with list("snapshots") snapshots = self.restic_runner.snapshots() return snapshots diff --git a/npbackup/gui/__main__.py b/npbackup/gui/__main__.py index 0982750..573c607 100644 --- a/npbackup/gui/__main__.py +++ b/npbackup/gui/__main__.py @@ -52,8 +52,6 @@ from npbackup.path_helper import CURRENT_DIR from npbackup.__version__ import version_string from npbackup.__debug__ import _DEBUG -from npbackup.gui.config import config_gui -from npbackup.gui.operations import operations_gui from npbackup.restic_wrapper import ResticRunner @@ -541,7 +539,9 @@ def get_config_file() -> str: sys.exit(100) if action == "--NEW-CONFIG--": config_file = "npbackup.conf" - full_config = config_gui(npbackup.configuration.get_default_config(), config_file) + full_config = config_gui( + npbackup.configuration.get_default_config(), config_file + ) if config_file: logger.info(f"Using configuration file {config_file}") full_config = npbackup.configuration.load_config(config_file) @@ -549,10 +549,10 @@ def get_config_file() -> str: sg.PopupError(f"{_t('main_gui.config_error')} {config_file}") config_file = None else: - return full_config + return full_config, config_file if not viewer_mode: - full_config = get_config_file() + full_config, config_file = get_config_file() repo_config, config_inheritance = npbackup.configuration.get_repo_config( full_config ) @@ -568,7 +568,7 @@ def get_config_file() -> str: repo_config = viewer_create_repo(viewer_repo_uri, viewer_repo_password) else: repo_config = None - + right_click_menu = ["", [_t("generic.destination")]] headings = [ "ID ", @@ -632,7 +632,7 @@ def get_config_file() -> str: sg.Button( _t("main_gui.open_repo"), key="--OPEN-REPO--", - visible=viewer_mode + visible=viewer_mode, ), sg.Button( _t("main_gui.launch_backup"), @@ -656,7 +656,7 @@ def get_config_file() -> str: sg.Button( _t("main_gui.load_configuration"), key="--LOAD-CONF--", - disabled=viewer_mode + disabled=viewer_mode, ), sg.Button(_t("generic.about"), key="--ABOUT--"), sg.Button(_t("generic.quit"), key="--EXIT--"), @@ -747,7 +747,7 @@ def get_config_file() -> str: event = "--STATE-BUTTON--" if event == "--LOAD-CONF--": # TODO: duplicate code - full_config = get_config_file() + full_config, config_file = get_config_file() repo_config, config_inheritance = npbackup.configuration.get_repo_config( full_config ) diff --git a/npbackup/gui/helpers.py b/npbackup/gui/helpers.py index 054f229..42e33f5 100644 --- a/npbackup/gui/helpers.py +++ b/npbackup/gui/helpers.py @@ -93,7 +93,7 @@ def _upgrade_from_compact_view(): "-OPERATIONS-PROGRESS-STDERR-", ): progress_window[key].Update(visible=True) - progress_window['--EXPAND--'].Update(visible=False) + progress_window["--EXPAND--"].Update(visible=False) runner = NPBackupRunner() # So we don't always init repo_config, since runner.group_runner would do that itself @@ -153,7 +153,17 @@ def _upgrade_from_compact_view(): [ sg.Column( [ - [sg.Push(background_color=BG_COLOR_LDR), sg.Text("↓", key="--EXPAND--", enable_events=True, background_color=BG_COLOR_LDR, text_color=TXT_COLOR_LDR, visible=__compact)], + [ + sg.Push(background_color=BG_COLOR_LDR), + sg.Text( + "↓", + key="--EXPAND--", + enable_events=True, + background_color=BG_COLOR_LDR, + text_color=TXT_COLOR_LDR, + visible=__compact, + ), + ], [ sg.Image( LOADER_ANIMATION, @@ -212,12 +222,15 @@ def _upgrade_from_compact_view(): result = runner.__getattribute__(fn.__name__)(*args, **kwargs) while True: # No idea why pylint thingks that UpdateAnimation does not exist in PySimpleGUI - progress_window["-LOADER-ANIMATION-"].UpdateAnimation( + # pylint: disable=E1101 (no-member) + progress_window[ + "-LOADER-ANIMATION-" + ].UpdateAnimation( LOADER_ANIMATION, time_between_frames=100 - ) # pylint: disable=E1101 (no-member) + ) # So we actually need to read the progress window for it to refresh... - event , _ = progress_window.read(0.01) - if event == '--EXPAND--': + event, _ = progress_window.read(0.01) + if event == "--EXPAND--": _upgrade_from_compact_view() # Read stdout queue try: @@ -244,9 +257,9 @@ def _upgrade_from_compact_view(): read_stderr_queue = False else: stderr_has_messages = True - #if __compact: - #for key in progress_window.AllKeysDict: - # progress_window[key].Update(visible=True) + # if __compact: + # for key in progress_window.AllKeysDict: + # progress_window[key].Update(visible=True) progress_window["-OPERATIONS-PROGRESS-STDERR-"].Update( f"\n{stderr_data}", append=True ) diff --git a/npbackup/restic_wrapper/__init__.py b/npbackup/restic_wrapper/__init__.py index 1cca92e..ec4da25 100644 --- a/npbackup/restic_wrapper/__init__.py +++ b/npbackup/restic_wrapper/__init__.py @@ -508,24 +508,33 @@ def is_init(self): def is_init(self, value: bool): self._is_init = value - def check_if_init(fn): + @property + def last_command_status(self): + return self._last_command_status + + @last_command_status.setter + def last_command_status(self, value: bool): + self._last_command_status = value + + # pylint: disable=E0213 (no-self-argument) + def check_if_init(fn: Callable): """ Decorator to check that we don't do anything unless repo is initialized """ + @wraps(fn) def wrapper(self, *args, **kwargs): if not self.is_init: + # pylint: disable=E1101 (no-member) + self.write_logs( + "Backend is not ready to perform operation {fn.__name}", + level="error", + ) return None + # pylint: disable=E1102 (not-callable) return fn(self, *args, **kwargs) - return wrapper - - @property - def last_command_status(self): - return self._last_command_status - @last_command_status.setter - def last_command_status(self, value: bool): - self._last_command_status = value + return wrapper @check_if_init def list(self, obj: str = "snapshots") -> Optional[list]: From 655eca698ad7c10b2441cdb83ee17794785d71f2 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Fri, 29 Dec 2023 20:55:52 +0100 Subject: [PATCH 120/328] Reformat file with black --- npbackup/gui/helpers.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/npbackup/gui/helpers.py b/npbackup/gui/helpers.py index 42e33f5..5215372 100644 --- a/npbackup/gui/helpers.py +++ b/npbackup/gui/helpers.py @@ -223,9 +223,7 @@ def _upgrade_from_compact_view(): while True: # No idea why pylint thingks that UpdateAnimation does not exist in PySimpleGUI # pylint: disable=E1101 (no-member) - progress_window[ - "-LOADER-ANIMATION-" - ].UpdateAnimation( + progress_window["-LOADER-ANIMATION-"].UpdateAnimation( LOADER_ANIMATION, time_between_frames=100 ) # So we actually need to read the progress window for it to refresh... From f1f01edb18d98694dbae438f6431203f4f1c453c Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Sun, 31 Dec 2023 00:44:30 +0100 Subject: [PATCH 121/328] Fix config encryption and update from gui --- npbackup/configuration.py | 50 ++++++++++++++++++++++++--------------- npbackup/requirements.txt | 2 +- 2 files changed, 32 insertions(+), 20 deletions(-) diff --git a/npbackup/configuration.py b/npbackup/configuration.py index 7ff1784..672ed1d 100644 --- a/npbackup/configuration.py +++ b/npbackup/configuration.py @@ -106,13 +106,13 @@ def d(self, path, sep="."): # NPF-SEC-00003: Avoid password command divulgation ENCRYPTED_OPTIONS = [ "repo_uri", - "repo_password", - "repo_password_command", - "http_username", - "http_password", - "encrypted_variables", - "auto_upgrade_server_username", - "auto_upgrade_server_password", + "repo_opts.repo_password", + "repo_opts.repo_password_command", + "prometheus.http_username", + "prometheus.http_username", + "env.encrypted_env_variables", + "global_options.auto_upgrade_server_username", + "global_options.auto_upgrade_server_password", ] # This is what a config file looks like @@ -239,25 +239,36 @@ def convert_to( return convert_to(full_config) +def key_should_be_encrypted(key, encrypted_options: List[str]): + """ + Checks whether key should be encrypted + """ + for option in encrypted_options: + if option in key: + return True + return False + def crypt_config( full_config: dict, aes_key: str, encrypted_options: List[str], operation: str ): try: - def _crypt_config(key: str, value: Any) -> Any: - if key in encrypted_options: + if key_should_be_encrypted(key, encrypted_options): + print("operation", operation) if operation == "encrypt": if ( - isinstance(value, str) - and not value.startswith("__NPBACKUP__") + (isinstance(value, str) + and (not value.startswith(ID_STRING) or not value.endswith(ID_STRING))) or not isinstance(value, str) ): value = enc.encrypt_message_hf( value, aes_key, ID_STRING, ID_STRING - ) + ).decode( + "utf-8" + ) elif operation == "decrypt": - if isinstance(value, str) and value.startswith("__NPBACKUP__"): - value = enc.decrypt_message_hf( + if isinstance(value, str) and value.startswith(ID_STRING) and value.endswith(ID_STRING): + _, value = enc.decrypt_message_hf( value, aes_key, ID_STRING, @@ -267,9 +278,10 @@ def _crypt_config(key: str, value: Any) -> Any: raise ValueError(f"Bogus operation {operation} given") return value - return replace_in_iterable(full_config, _crypt_config, callable_wants_key=True) + return replace_in_iterable(full_config, _crypt_config, callable_wants_key=True, callable_wants_root_key=True) except Exception as exc: logger.error(f"Cannot {operation} configuration: {exc}.") + logger.info("Trace:", exc_info=True) return False @@ -279,12 +291,12 @@ def is_encrypted(full_config: dict) -> bool: def _is_encrypted(key, value) -> Any: nonlocal is_encrypted - if key in ENCRYPTED_OPTIONS: - if isinstance(value, str) and not value.startswith("__NPBACKUP__"): - is_encrypted = True + if key_should_be_encrypted(key, ENCRYPTED_OPTIONS): + if isinstance(value, str) and (not value.startswith(ID_STRING) or not value.endswith(ID_STRING)): + is_encrypted = False return value - replace_in_iterable(full_config, _is_encrypted, callable_wants_key=True) + replace_in_iterable(full_config, _is_encrypted, callable_wants_key=True, callable_wants_root_key=True) return is_encrypted diff --git a/npbackup/requirements.txt b/npbackup/requirements.txt index cc7016b..5280a21 100644 --- a/npbackup/requirements.txt +++ b/npbackup/requirements.txt @@ -2,7 +2,7 @@ command_runner>=1.5.2 cryptidy>=1.2.2 python-dateutil ofunctions.logger_utils>=2.4.1 -ofunctions.misc>=1.6.1 +ofunctions.misc>=1.6.3 ofunctions.process>=2.0.0 ofunctions.threading>=2.2.0 ofunctions.platform>=1.4.1 From a535df57f2fc5a679c08d479131b0f90deabc686 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Sun, 31 Dec 2023 00:44:44 +0100 Subject: [PATCH 122/328] Improve config file select UX --- npbackup/gui/__main__.py | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/npbackup/gui/__main__.py b/npbackup/gui/__main__.py index 573c607..21cd581 100644 --- a/npbackup/gui/__main__.py +++ b/npbackup/gui/__main__.py @@ -417,18 +417,19 @@ def forget_snapshot(repo_config: dict, snapshot_ids: List[str]) -> bool: def _main_gui(viewer_mode: bool): - def select_config_file() -> None: + def select_config_file(config_file: str = None) -> None: """ Option to select a configuration file """ layout = [ [ sg.Text(_t("main_gui.select_config_file")), - sg.Input(key="-config_file-"), + sg.Input(config_file, key="-config_file-"), sg.FileBrowse(_t("generic.select_file")), ], [ sg.Push(), + sg.Button(_t("generic.cancel"), key="--CANCEL--"), sg.Button(_t("generic.quit"), key="--EXIT--"), sg.Button(_t("main_gui.new_config"), key="--NEW-CONFIG--"), sg.Button(_t("generic.accept"), key="--ACCEPT--"), @@ -439,7 +440,10 @@ def select_config_file() -> None: while True: action = None event, values = window.read() - if event in [sg.WIN_X_EVENT, sg.WIN_CLOSED, "--EXIT--"]: + if event in [sg.WIN_X_EVENT, sg.WIN_CLOSED, "--CANCEL--"]: + action = "--CANCEL--" + break + if event == "--EXIT--": action = "--EXIT--" break if event == "--NEW-CONFIG--": @@ -526,15 +530,18 @@ def get_gui_data(repo_config: dict) -> Tuple[bool, List[str]]: ) return current_state, backup_tz, snapshot_list - def get_config_file() -> str: + def get_config_file(default: bool = True) -> str: """ Load config file until we got something """ - config_file = Path(f"{CURRENT_DIR}/npbackup.conf") + if default: + config_file = Path(f"{CURRENT_DIR}/npbackup.conf") + else: + config_file = None while True: if not config_file or not config_file.exists(): - config_file, action = select_config_file() + config_file, action = select_config_file(config_file) if action == "--EXIT--": sys.exit(100) if action == "--NEW-CONFIG--": @@ -654,7 +661,7 @@ def get_config_file() -> str: disabled=viewer_mode, ), sg.Button( - _t("main_gui.load_configuration"), + _t("main_gui.load_config"), key="--LOAD-CONF--", disabled=viewer_mode, ), @@ -747,7 +754,7 @@ def get_config_file() -> str: event = "--STATE-BUTTON--" if event == "--LOAD-CONF--": # TODO: duplicate code - full_config, config_file = get_config_file() + full_config, config_file = get_config_file(default=False) repo_config, config_inheritance = npbackup.configuration.get_repo_config( full_config ) @@ -791,5 +798,5 @@ def main_gui(viewer_mode=False): except Exception as exc: sg.Popup(_t("config_gui.unknown_error_see_logs") + f": {exc}") logger.critical(f"GUI Execution error {exc}") - logger.debug("Trace:", exc_info=True) + logger.info("Trace:", exc_info=True) sys.exit(251) From 94918c7ffeee4eebfcc67329ea6437d66e772049 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Sun, 31 Dec 2023 00:45:17 +0100 Subject: [PATCH 123/328] Implement inheritance avoidance in config update from gui --- npbackup/gui/config.py | 57 +++++++++++++++++++++++++++++++++--------- 1 file changed, 45 insertions(+), 12 deletions(-) diff --git a/npbackup/gui/config.py b/npbackup/gui/config.py index 91feb63..eeaa739 100644 --- a/npbackup/gui/config.py +++ b/npbackup/gui/config.py @@ -163,16 +163,18 @@ def update_gui_values(key, value, inherited, object_type, unencrypted): """ Update gui values depending on their type """ - if key == "backup_admin_password": - return if key in ("repo_uri", "repo_group"): if object_type == "group": window[key].Disabled = True else: window[key].Disabled = False try: + # Don't bother to update repo name + if key == "name": + return # Don't show sensible info unless unencrypted requested if not unencrypted: + # Use last part of key only if key in configuration.ENCRYPTED_OPTIONS: try: if value is None or value == "": @@ -284,11 +286,14 @@ def update_global_gui(full_config, unencrypted=False): def update_config_dict(full_config, values): """ - Update full_config with keys from + Update full_config with keys from GUI + keys should always have form section.name or section.subsection.name """ - # TODO - return object_type, object_name = get_object_from_combo(values["-OBJECT-SELECT-"]) + if object_type == "repo": + object_group = full_config.g(f"repos.{object_name}.repo_group") + else: + object_group = None for key, value in values.items(): if value == ENCRYPTED_DATA_PLACEHOLDER: continue @@ -296,6 +301,7 @@ def update_config_dict(full_config, values): # Don't bother with keys that don't contain with "." since they're not in the YAML config file # but are most probably for GUI events continue + # Handle combo boxes first to transform translation into key if key in combo_boxes: value = get_key_from_value(combo_boxes[key], value) @@ -316,16 +322,43 @@ def update_config_dict(full_config, values): value = int(value) except ValueError: pass - # Create section if not exists - active_object_key = f"{object_type}s.{object_name}.{key}" - print("ACTIVE KEY", active_object_key) - if not full_config.g(active_object_key): - full_config.s(active_object_key, CommentedMap()) - full_config.s(active_object_key, value) + # Don't bother with inheritance on global options + if not key.startswith("global_options."): + + # Don't update items that have been inherited from groups + if object_group: + inheritance_key = f"groups.{object_group}.{key}" + # If object is a list, check which values are inherited from group and remove them + if isinstance(value, list): + for entry in full_config.g(inheritance_key): + if entry in value: + value.remove(entry) + # check if value is inherited from group + if full_config.g(inheritance_key) == value: + continue + + active_object_key = f"{object_type}s.{object_name}.{key}" + current_value = full_config.g(active_object_key) + if object_group: + inherited = full_config.g(inheritance_key) + else: + inherited = False + # WIP print(f"UPDATING {active_object_key} curr={current_value} inherited={inherited} new={value}") + #if not full_config.g(active_object_key): + # full_config.s(active_object_key, CommentedMap()) + + + # Don't bother to update empty strings, empty lists and None + if not current_value and not value: + continue + # Don't bother to update values which haven't changed + if current_value == value: + continue + + #full_config.s(active_object_key, value) return full_config # TODO: Do we actually save every modified object or just the last ? - # TDOO: also save global options def set_permissions(full_config: dict, object_name: str) -> dict: """ From 0543ec4fb574d96596d82b5dab4688d650c88b53 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Sun, 31 Dec 2023 00:45:27 +0100 Subject: [PATCH 124/328] Add translation --- npbackup/translations/main_gui.en.yml | 1 + npbackup/translations/main_gui.fr.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/npbackup/translations/main_gui.en.yml b/npbackup/translations/main_gui.en.yml index 9ee08bf..b87c300 100644 --- a/npbackup/translations/main_gui.en.yml +++ b/npbackup/translations/main_gui.en.yml @@ -39,6 +39,7 @@ en: viewer_mode: Repository view-only mode open_repo: Open repo new_config: New config + load_config: Load configuration config_error: Configuration file error # logs diff --git a/npbackup/translations/main_gui.fr.yml b/npbackup/translations/main_gui.fr.yml index bca9c21..f326156 100644 --- a/npbackup/translations/main_gui.fr.yml +++ b/npbackup/translations/main_gui.fr.yml @@ -39,6 +39,7 @@ fr: viewer_mode: Mode visualisation de dépot uniquement open_repo: Ouvrir dépot new_config: Nouvelle configuration + load_config: Charger configuration config_error: Erreur du fichier de configuration # logs From 370ad3a9ae2decf701c28a895e50d7a7ecbc12f1 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Sun, 31 Dec 2023 10:47:35 +0100 Subject: [PATCH 125/328] Show config file path in window title --- npbackup/customization.py | 1 + npbackup/gui/__main__.py | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/npbackup/customization.py b/npbackup/customization.py index 181af8d..5ce9b0e 100644 --- a/npbackup/customization.py +++ b/npbackup/customization.py @@ -29,6 +29,7 @@ FILE_DESCRIPTION = "Network Backup Client" TRADEMARKS = "NetInvent (C)" PRODUCT_NAME = "NPBackup Network Backup Client" +SHORT_PRODUCT_NAME = "NPBackup" # Arbitrary string ID_STRING = "__NPBACKUP__" diff --git a/npbackup/gui/__main__.py b/npbackup/gui/__main__.py index 21cd581..13280e1 100644 --- a/npbackup/gui/__main__.py +++ b/npbackup/gui/__main__.py @@ -43,6 +43,7 @@ LICENSE_FILE, PYSIMPLEGUI_THEME, OEM_ICON, + SHORT_PRODUCT_NAME ) from npbackup.gui.config import config_gui from npbackup.gui.operations import operations_gui @@ -675,7 +676,7 @@ def get_config_file(default: bool = True) -> str: ] window = sg.Window( - "npbackup", + SHORT_PRODUCT_NAME, layout, default_element_size=(12, 1), text_justification="r", @@ -702,6 +703,8 @@ def get_config_file(default: bool = True) -> str: backup_tz = None snapshot_list = [] gui_update_state() + # Show which config file is loaded + window.set_title(f"{SHORT_PRODUCT_NAME} - {config_file}") while True: event, values = window.read(timeout=60000) @@ -762,6 +765,7 @@ def get_config_file(default: bool = True) -> str: backup_destination = _t("main_gui.local_folder") backend_type, repo_uri = get_anon_repo_uri(repo_config.g("repo_uri")) + window.set_title(f"{SHORT_PRODUCT_NAME} - {config_file}") event = "--STATE-BUTTON--" if event == _t("generic.destination"): try: From c8d10e6a76b85b6233a970ab1b3759eb9ee85b88 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Sun, 31 Dec 2023 11:08:28 +0100 Subject: [PATCH 126/328] Change parameter name for list --- npbackup/restic_wrapper/__init__.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/npbackup/restic_wrapper/__init__.py b/npbackup/restic_wrapper/__init__.py index ec4da25..45d266b 100644 --- a/npbackup/restic_wrapper/__init__.py +++ b/npbackup/restic_wrapper/__init__.py @@ -537,11 +537,11 @@ def wrapper(self, *args, **kwargs): return wrapper @check_if_init - def list(self, obj: str = "snapshots") -> Optional[list]: + def list(self, subject: str) -> Optional[list]: """ Returns json list of snapshots """ - cmd = "list {} --json".format(obj) + cmd = "list {} --json".format(subject) result, output = self.executor(cmd) if result: try: @@ -575,7 +575,7 @@ def ls(self, snapshot: str) -> Optional[list]: logger.debug("Trace:", exc_info=True) return result - @check_if_init # TODO: remove function and keep list("snapshots") + @check_if_init def snapshots(self) -> Optional[list]: """ Returns json list of snapshots @@ -877,6 +877,7 @@ def _has_recent_snapshot( # Begin with most recent snapshot snapshot_list.reverse() for snapshot in snapshot_list: + print(snapshot) if re.match( r"[0-9]{4}-[0-1][0-9]-[0-3][0-9]T[0-2][0-9]:[0-5][0-9]:[0-5][0-9]\..*\+[0-2][0-9]:[0-9]{2}", snapshot["time"], From 933b290e2ad23bbb59fc139bc25e4b51f6bcaeea Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Sun, 31 Dec 2023 11:20:21 +0100 Subject: [PATCH 127/328] Add list function permissions, rename earlier list to snapshot --- npbackup/core/runner.py | 28 +++++++++++++++++++--------- npbackup/gui/__main__.py | 2 +- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/npbackup/core/runner.py b/npbackup/core/runner.py index 6eaf80b..a1107ab 100644 --- a/npbackup/core/runner.py +++ b/npbackup/core/runner.py @@ -318,11 +318,12 @@ def wrapper(self, *args, **kwargs): required_permissions = { "backup": ["backup", "restore", "full"], "has_recent_snapshot": ["backup", "restore", "full"], - "list": ["backup", "restore", "full"], + "snapshots": ["backup", "restore", "full"], "ls": ["backup", "restore", "full"], "find": ["backup", "restore", "full"], "restore": ["restore", "full"], "check": ["restore", "full"], + "list": ["full"], "unlock": ["full"], "repair": ["full"], "forget": ["full"], @@ -633,13 +634,27 @@ def _apply_config_to_restic_runner(self) -> bool: @is_ready @apply_config_to_restic_runner @catch_exceptions - def list(self) -> Optional[dict]: + def snapshots(self) -> Optional[dict]: self.write_logs( f"Listing snapshots of repo {self.repo_config.g('name')}", level="info" ) - # TODO: replace with list("snapshots") snapshots = self.restic_runner.snapshots() return snapshots + + @threaded + @close_queues + @exec_timer + @check_concurrency + @has_permission + @is_ready + @apply_config_to_restic_runner + @catch_exceptions + def list(self, subject: str) -> Optional[dict]: + self.write_logs( + f"Listing {subject} objects of repo {self.repo_config.g('name')}", level="info" + ) + snapshots = self.restic_runner.list(subject) + return snapshots @threaded @close_queues @@ -1068,7 +1083,7 @@ def raw(self, command: str) -> bool: @exec_timer @has_permission @catch_exceptions - def group_runner(self, repo_config_list: list, operation: str, **kwargs) -> bool: + def group_runner(self, repo_config_list: List, operation: str, **kwargs) -> bool: group_result = True # Make sure we don't close the stdout/stderr queues when running multiple operations @@ -1095,9 +1110,4 @@ def group_runner(self, repo_config_list: list, operation: str, **kwargs) -> bool ) group_result = False self.write_logs("Finished execution group operations", level="info") - # Manually close the queues at the end - # if self.stdout: - # self.stdout.put(None) - # if self.stderr: - # self.stderr.put(None) return group_result diff --git a/npbackup/gui/__main__.py b/npbackup/gui/__main__.py index 13280e1..2332ff8 100644 --- a/npbackup/gui/__main__.py +++ b/npbackup/gui/__main__.py @@ -495,7 +495,7 @@ def get_gui_data(repo_config: dict) -> Tuple[bool, List[str]]: ) gui_msg = _t("main_gui.loading_snapshot_list_from_repo") snapshots = gui_thread_runner( - repo_config, "list", __gui_msg=gui_msg, __autoclose=True, __compact=True + repo_config, "snapshots", __gui_msg=gui_msg, __autoclose=True, __compact=True ) current_state, backup_tz = ResticRunner._has_recent_snapshot( snapshots, repo_config.g("repo_opts.minimum_backup_age") From 94b569063f382dd8a086eb9b6948f5a25a95c6d6 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Sun, 31 Dec 2023 11:20:50 +0100 Subject: [PATCH 128/328] Simplify _has_recent_snapshot() code, improve date regex --- npbackup/restic_wrapper/__init__.py | 31 +++++++++++++---------------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/npbackup/restic_wrapper/__init__.py b/npbackup/restic_wrapper/__init__.py index 45d266b..dc9533f 100644 --- a/npbackup/restic_wrapper/__init__.py +++ b/npbackup/restic_wrapper/__init__.py @@ -874,24 +874,21 @@ def _has_recent_snapshot( if not snapshot_list or not delta: return False, backup_ts tz_aware_timestamp = datetime.now(timezone.utc).astimezone() - # Begin with most recent snapshot - snapshot_list.reverse() - for snapshot in snapshot_list: - print(snapshot) - if re.match( - r"[0-9]{4}-[0-1][0-9]-[0-3][0-9]T[0-2][0-9]:[0-5][0-9]:[0-5][0-9]\..*\+[0-2][0-9]:[0-9]{2}", - snapshot["time"], + + # Now just take the last snapshot in list (being the more recent), and check whether it's too old + last_snapshot = snapshot_list[-1] + if re.match( + r"[0-9]{4}-[0-1][0-9]-[0-3][0-9]T[0-2][0-9]:[0-5][0-9]:[0-5][0-9](\.\d*)?(\+[0-2][0-9]:[0-9]{2})?", + last_snapshot["time"], ): - backup_ts = dateutil.parser.parse(snapshot["time"]) - snapshot_age_minutes = ( - tz_aware_timestamp - backup_ts - ).total_seconds() / 60 - if delta - snapshot_age_minutes > 0: - logger.info( - f"Recent snapshot {snapshot['short_id']} of {snapshot['time']} exists !" - ) - return True, backup_ts - return None, backup_ts + backup_ts = dateutil.parser.parse(last_snapshot["time"]) + snapshot_age_minutes = (tz_aware_timestamp - backup_ts).total_seconds() / 60 + if delta - snapshot_age_minutes > 0: + logger.info( + f"Recent snapshot {last_snapshot['short_id']} of {last_snapshot['time']} exists !" + ) + return True, backup_ts + return False, backup_ts @check_if_init def has_recent_snapshot(self, delta: int = None) -> Tuple[bool, Optional[datetime]]: From 445f0219d523f7b63c7bc01c697654f45ddb4862 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Sun, 31 Dec 2023 11:40:05 +0100 Subject: [PATCH 129/328] Prep work for --api parameter --- npbackup/__main__.py | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/npbackup/__main__.py b/npbackup/__main__.py index 68acd8f..4256a9f 100644 --- a/npbackup/__main__.py +++ b/npbackup/__main__.py @@ -12,6 +12,7 @@ import atexit from argparse import ArgumentParser from datetime import datetime +import logging import ofunctions.logger_utils from ofunctions.process import kill_childs from npbackup.path_helper import CURRENT_DIR @@ -38,9 +39,7 @@ LOG_FILE = os.path.join(CURRENT_DIR, "{}.log".format(__intname__)) - - -logger = ofunctions.logger_utils.logger_get_logger(LOG_FILE, debug=_DEBUG) +logger = logging.getLogger() def cli_interface(): @@ -85,7 +84,7 @@ def cli_interface(): help="Restore to path given by --restore", ) parser.add_argument( - "-l", "--list", action="store_true", help="Show current snapshots" + "-s", "--snapshots", action="store_true", default=False, help="Show current snapshots" ) parser.add_argument( "--ls", @@ -125,6 +124,9 @@ def cli_interface(): parser.add_argument( "--repair-snapshots", action="store_true", help="Repair repo snapshots" ) + parser.add_argument( + "--list", type=str, default=None, required=False, help="Show [blobs|packs|index|snapshots|keys|locks] objects" + ) parser.add_argument( "--raw", type=str, @@ -146,12 +148,15 @@ def cli_interface(): help="Restore only paths within include path", ) parser.add_argument( - "--snapshot", + "--snapshot-id", type=str, default="latest", required=False, help="Choose which snapshot to use. Defaults to latest", ) + parser.add_argument( + "--api", action="store_true", help="Run in JSON API mode. Nothing else than JSON will be printed to stdout" + ) parser.add_argument( "-v", "--verbose", action="store_true", help="Show verbose output" ) @@ -177,6 +182,11 @@ def cli_interface(): ) args = parser.parse_args() + if args.api: + logger = ofunctions.logger_utils.logger_get_logger(LOG_FILE, console=False, debug=_DEBUG) + else: + logger = ofunctions.logger_utils.logger_get_logger(LOG_FILE, debug=_DEBUG) + if args.version: print(version_string) sys.exit(0) @@ -229,6 +239,7 @@ def cli_interface(): "verbose": args.verbose, "dry_run": args.dry_run, "debug": args.debug, + "api_mode": args.api, "operation": None, "op_args": {}, } @@ -239,18 +250,21 @@ def cli_interface(): elif args.restore: cli_args["operation"] = "restore" cli_args["op_args"] = { - "snapshot": args.snapshot, + "snapshot": args.snapshot_id, "target": args.restore, "restore_include": args.restore_include, } + elif args.snapshots: + cli_args["operation"] = "snapshots" elif args.list: cli_args["operation"] = "list" + cli_args["op_args"] = {"subject": args.list} elif args.ls: cli_args["operation"] = "ls" - cli_args["op_args"] = {"snapshot": args.snapshot} + cli_args["op_args"] = {"snapshot": args.snapshot_id} elif args.find: cli_args["operation"] = "find" - cli_args["op_args"] = {"snapshot": args.snapshot, "path": args.find} + cli_args["op_args"] = {"snapshot": args.snapshot_id, "path": args.find} elif args.forget: cli_args["operation"] = "forget" if args.forget == "policy": From 743063d541a390bbd3bccbd5c74b9b1222f02bcf Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Sun, 31 Dec 2023 11:44:42 +0100 Subject: [PATCH 130/328] Fix operations multi-repo selection --- npbackup/gui/operations.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/npbackup/gui/operations.py b/npbackup/gui/operations.py index c9870c2..a2f878d 100644 --- a/npbackup/gui/operations.py +++ b/npbackup/gui/operations.py @@ -190,9 +190,9 @@ def operations_gui(full_config: dict) -> dict: continue repos = complete_repo_list else: - repos = complete_repo_list.index( - values["repo-list"] - ) # TODO multi select + repos = [] + for value in values["repo-list"]: + repos.append(complete_repo_list[value]) repo_config_list = [] for repo_name, backend_type, repo_uri in repos: From 3de0a770021048192f519c552cf88a659e676a33 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Sun, 31 Dec 2023 12:38:00 +0100 Subject: [PATCH 131/328] Make queue intervals configurable --- npbackup/__env__.py | 28 ++++++++++++++++++++++++++++ npbackup/gui/helpers.py | 9 +++++---- npbackup/restic_wrapper/__init__.py | 8 +++----- 3 files changed, 36 insertions(+), 9 deletions(-) create mode 100644 npbackup/__env__.py diff --git a/npbackup/__env__.py b/npbackup/__env__.py new file mode 100644 index 0000000..303d62e --- /dev/null +++ b/npbackup/__env__.py @@ -0,0 +1,28 @@ +#! /usr/bin/env python +# -*- coding: utf-8 -*- +# +# This file is part of npbackup + +__intname__ = "npbackup.__env__" +__author__ = "Orsiris de Jong" +__site__ = "https://www.netperfect.fr/npbackup" +__description__ = "NetPerfect Backup Client" +__copyright__ = "Copyright (C) 2023 NetInvent" + + +################## +# CONSTANTS FILE # +################## + +# Interval for timeout in queue reads +# The lower, the faster we get backend results, but at the expense of cpu +CHECK_INTERVAL = 0.005 + +# The lower the snappier the GUI, but also more cpu hungry +# Should not be lower than CHECK_INTERVAL +GUI_CHECK_INTERVAL = 0.005 + + +# Arbitrary timeout for init / init checks. +# If init takes more than a minute, we really have a problem in our backend +INIT_TIMEOUT = 60 diff --git a/npbackup/gui/helpers.py b/npbackup/gui/helpers.py index 5215372..75cad04 100644 --- a/npbackup/gui/helpers.py +++ b/npbackup/gui/helpers.py @@ -24,7 +24,8 @@ ) from npbackup.core.runner import NPBackupRunner from npbackup.__debug__ import _DEBUG -from npbackup.customization import PYSIMPLEGUI_THEME, OEM_ICON, OEM_LOGO +from npbackup.__env__ import GUI_CHECK_INTERVAL +from npbackup.customization import PYSIMPLEGUI_THEME, OEM_ICON logger = getLogger() @@ -227,12 +228,12 @@ def _upgrade_from_compact_view(): LOADER_ANIMATION, time_between_frames=100 ) # So we actually need to read the progress window for it to refresh... - event, _ = progress_window.read(0.01) + event, _ = progress_window.read(0.000000001) if event == "--EXPAND--": _upgrade_from_compact_view() # Read stdout queue try: - stdout_data = stdout_queue.get(timeout=0.01) + stdout_data = stdout_queue.get(timeout=GUI_CHECK_INTERVAL) except queue.Empty: pass else: @@ -246,7 +247,7 @@ def _upgrade_from_compact_view(): # Read stderr queue try: - stderr_data = stderr_queue.get(timeout=0.01) + stderr_data = stderr_queue.get(timeout=GUI_CHECK_INTERVAL) except queue.Empty: pass else: diff --git a/npbackup/restic_wrapper/__init__.py b/npbackup/restic_wrapper/__init__.py index dc9533f..a05dbe1 100644 --- a/npbackup/restic_wrapper/__init__.py +++ b/npbackup/restic_wrapper/__init__.py @@ -22,14 +22,11 @@ from functools import wraps from command_runner import command_runner from npbackup.__debug__ import _DEBUG +from npbackup.__env__ import INIT_TIMEOUT, CHECK_INTERVAL -logger = getLogger() - -# Arbitrary timeout for init / init checks. -# If init takes more than a minute, we really have a problem -INIT_TIMEOUT = 60 +logger = getLogger() class ResticRunner: @@ -268,6 +265,7 @@ def executor( stop_on=self.stop_on, on_exit=self.on_exit, method="poller", + check_interval=CHECK_INTERVAL, priority=self._priority, io_priority=self._priority, ) From fbe562ab2e43ad8e97443440e1f8a47e3d53ccad Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Sun, 31 Dec 2023 15:12:51 +0100 Subject: [PATCH 132/328] Do not allow actions unless config is loaded --- npbackup/gui/__main__.py | 93 +++++++++++++++++++++++++++------------- 1 file changed, 64 insertions(+), 29 deletions(-) diff --git a/npbackup/gui/__main__.py b/npbackup/gui/__main__.py index 2332ff8..71372f3 100644 --- a/npbackup/gui/__main__.py +++ b/npbackup/gui/__main__.py @@ -431,7 +431,6 @@ def select_config_file(config_file: str = None) -> None: [ sg.Push(), sg.Button(_t("generic.cancel"), key="--CANCEL--"), - sg.Button(_t("generic.quit"), key="--EXIT--"), sg.Button(_t("main_gui.new_config"), key="--NEW-CONFIG--"), sg.Button(_t("generic.accept"), key="--ACCEPT--"), ], @@ -444,9 +443,6 @@ def select_config_file(config_file: str = None) -> None: if event in [sg.WIN_X_EVENT, sg.WIN_CLOSED, "--CANCEL--"]: action = "--CANCEL--" break - if event == "--EXIT--": - action = "--EXIT--" - break if event == "--NEW-CONFIG--": action = event break @@ -497,8 +493,13 @@ def get_gui_data(repo_config: dict) -> Tuple[bool, List[str]]: snapshots = gui_thread_runner( repo_config, "snapshots", __gui_msg=gui_msg, __autoclose=True, __compact=True ) + try: + min_backup_age = repo_config.g("repo_opts.minimum_backup_age") + except AttributeError: + min_backup_age = 0 + current_state, backup_tz = ResticRunner._has_recent_snapshot( - snapshots, repo_config.g("repo_opts.minimum_backup_age") + snapshots, min_backup_age ) snapshot_list = [] if snapshots: @@ -542,9 +543,9 @@ def get_config_file(default: bool = True) -> str: while True: if not config_file or not config_file.exists(): - config_file, action = select_config_file(config_file) - if action == "--EXIT--": - sys.exit(100) + config_file, action = select_config_file() + if action == "--CANCEL--": + break if action == "--NEW-CONFIG--": config_file = "npbackup.conf" full_config = config_gui( @@ -558,16 +559,43 @@ def get_config_file(default: bool = True) -> str: config_file = None else: return full_config, config_file + return None, None - if not viewer_mode: + def get_config(): full_config, config_file = get_config_file() - repo_config, config_inheritance = npbackup.configuration.get_repo_config( - full_config - ) + if full_config and config_file: + repo_config, config_inheritance = npbackup.configuration.get_repo_config( + full_config + ) + backup_destination = _t("main_gui.local_folder") + backend_type, repo_uri = get_anon_repo_uri(repo_config.g("repo_uri")) + window.set_title(f"{SHORT_PRODUCT_NAME} - {config_file}") + else: + repo_config = None + config_inheritance = None + backup_destination = "None" + backend_type = "None" + repo_uri = "None" repo_list = npbackup.configuration.get_repo_list(full_config) + return full_config, config_file, repo_config, backup_destination, backend_type, repo_uri, repo_list - backup_destination = _t("main_gui.local_folder") - backend_type, repo_uri = get_anon_repo_uri(repo_config.g("repo_uri")) + if not viewer_mode: + """ + full_config, config_file = get_config_file() + if full_config and config_file: + repo_config, config_inheritance = npbackup.configuration.get_repo_config( + full_config + ) + backup_destination = _t("main_gui.local_folder") + backend_type, repo_uri = get_anon_repo_uri(repo_config.g("repo_uri")) + else: + repo_config = None + config_inheritance = None + backup_destination = "None" + backend_type = "Bone" + repo_list = npbackup.configuration.get_repo_list(full_config) + """ + full_config, config_file, repo_config, backup_destination, backend_type, repo_uri, repo_list = get_config() else: # Let's try to read standard restic repository env variables viewer_repo_uri = os.environ.get("RESTIC_REPOSITORY", None) @@ -619,7 +647,7 @@ def get_config_file(default: bool = True) -> str: sg.Combo( repo_list, key="-active_repo-", - default_value=repo_list[0], + default_value=repo_list[0] if repo_list else None, enable_events=True, ), sg.Text(f"Type {backend_type}", key="-backend_type-"), @@ -723,9 +751,15 @@ def get_config_file(default: bool = True) -> str: sg.PopupError("Repo not existent in config") continue if event == "--LAUNCH-BACKUP--": + if not full_config: + sg.PopupError(_t("main_gui.no_config")) + continue backup(repo_config) event = "--STATE-BUTTON--" if event == "--SEE-CONTENT--": + if not full_config: + sg.PopupError(_t("main_gui.no_config")) + continue if not values["snapshot-list"]: sg.Popup(_t("main_gui.select_backup"), keep_on_top=True) continue @@ -735,6 +769,9 @@ def get_config_file(default: bool = True) -> str: snapshot_to_see = snapshot_list[values["snapshot-list"][0]][0] ls_window(repo_config, snapshot_to_see) if event == "--FORGET--": + if not full_config: + sg.PopupError(_t("main_gui.no_config")) + continue if not values["snapshot-list"]: sg.Popup(_t("main_gui.select_backup"), keep_on_top=True) continue @@ -745,9 +782,15 @@ def get_config_file(default: bool = True) -> str: # Make sure we trigger a GUI refresh after forgetting snapshots event = "--STATE-BUTTON--" if event == "--OPERATIONS--": + if not full_config: + sg.PopupError(_t("main_gui.no_config")) + continue full_config = operations_gui(full_config) event = "--STATE-BUTTON--" if event == "--CONFIGURE--": + if not full_config: + sg.PopupError(_t("main_gui.no_config")) + continue full_config = config_gui(full_config, config_file) # Make sure we trigger a GUI refresh when configuration is changed event = "--STATE-BUTTON--" @@ -756,16 +799,7 @@ def get_config_file(default: bool = True) -> str: repo_config = viewer_create_repo(viewer_repo_uri, viewer_repo_password) event = "--STATE-BUTTON--" if event == "--LOAD-CONF--": - # TODO: duplicate code - full_config, config_file = get_config_file(default=False) - repo_config, config_inheritance = npbackup.configuration.get_repo_config( - full_config - ) - repo_list = npbackup.configuration.get_repo_list(full_config) - - backup_destination = _t("main_gui.local_folder") - backend_type, repo_uri = get_anon_repo_uri(repo_config.g("repo_uri")) - window.set_title(f"{SHORT_PRODUCT_NAME} - {config_file}") + full_config, config_file, repo_config, backup_destination, backend_type, repo_uri, repo_list = get_config() event = "--STATE-BUTTON--" if event == _t("generic.destination"): try: @@ -780,10 +814,11 @@ def get_config_file(default: bool = True) -> str: if event == "--ABOUT--": about_gui(version_string, full_config if not viewer_mode else None) if event == "--STATE-BUTTON--": - current_state, backup_tz, snapshot_list = get_gui_data(repo_config) - gui_update_state() - if current_state is None: - sg.Popup(_t("main_gui.cannot_get_repo_status")) + if full_config: + current_state, backup_tz, snapshot_list = get_gui_data(repo_config) + gui_update_state() + if current_state is None: + sg.Popup(_t("main_gui.cannot_get_repo_status")) def main_gui(viewer_mode=False): From 5500386ed06860244fa7540d8760e82b891ced52 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Sun, 31 Dec 2023 15:13:11 +0100 Subject: [PATCH 133/328] Revert add exclude_patterns_source_type --- npbackup/restic_wrapper/__init__.py | 25 ++++++++++++++----------- npbackup/runner_interface.py | 2 ++ 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/npbackup/restic_wrapper/__init__.py b/npbackup/restic_wrapper/__init__.py index a05dbe1..40cfd0f 100644 --- a/npbackup/restic_wrapper/__init__.py +++ b/npbackup/restic_wrapper/__init__.py @@ -295,6 +295,8 @@ def executor( is_cloud_error = False if is_cloud_error is True: return True, output + else: + self.write_logs("Some files could not be backed up", level="error") # TEMP-FIX-4155-END self.last_command_status = False @@ -592,11 +594,12 @@ def snapshots(self) -> Optional[list]: def backup( self, paths: List[str], - exclude_patterns_source_type: str, + source_type: str, exclude_patterns: List[str] = [], exclude_files: List[str] = [], - exclude_patterns_case_ignore: bool = False, + excludes_case_ignore: bool = False, exclude_caches: bool = False, + exclude_files_larger_than: str = None, use_fs_snapshot: bool = False, tags: List[str] = [], one_file_system: bool = False, @@ -616,11 +619,11 @@ def backup( "files_from_raw", ]: cmd = "backup" - if exclude_patterns_source_type == "files_from": + if source_type == "files_from": source_parameter = "--files-from" - elif exclude_patterns_source_type == "files_from_verbatim": + elif source_type == "files_from_verbatim": source_parameter = "--files-from-verbatim" - elif exclude_patterns_source_type == "files_from_raw": + elif source_type == "files_from_raw": source_parameter = "--files-from-raw" else: self.write_logs("Bogus source type given", level="error") @@ -642,24 +645,24 @@ def backup( case_ignore_param = "" # Always use case ignore excludes under windows - if os.name == "nt" or exclude_patterns_case_ignore: + if os.name == "nt" or excludes_case_ignore: case_ignore_param = "i" for exclude_pattern in exclude_patterns: if exclude_pattern: - cmd += ' --{}exclude "{}"'.format(case_ignore_param, exclude_pattern) + cmd += f' --{case_ignore_param}exclude "{exclude_pattern}"' for exclude_file in exclude_files: if exclude_file: if os.path.isfile(exclude_file): - cmd += ' --{}exclude-file "{}"'.format( - case_ignore_param, exclude_file - ) - else: + cmd += f' --{case_ignore_param}exclude-file "{exclude_file}"' + else:g self.write_logs( f"Exclude file '{exclude_file}' not found", level="error" ) if exclude_caches: cmd += " --exclude-caches" + if exclude_files_larger_than: + cmd += f" --exclude-files-larger-than {exclude_files_larger_than}" if one_file_system: cmd += " --one-file-system" if use_fs_snapshot: diff --git a/npbackup/runner_interface.py b/npbackup/runner_interface.py index 666614d..ef09a66 100644 --- a/npbackup/runner_interface.py +++ b/npbackup/runner_interface.py @@ -28,6 +28,8 @@ def entrypoint(*args, **kwargs): result = npbackup_runner.__getattribute__(kwargs.pop("operation"))( **kwargs.pop("op_args"), __no_threads=True ) + print(result) + logger.debug(result) def auto_upgrade(full_config: dict): From 1516cee9b5bf5b66e7746441464f554aa8fbec3a Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Sun, 31 Dec 2023 15:13:20 +0100 Subject: [PATCH 134/328] Update translations --- npbackup/translations/config_gui.en.yml | 5 +++-- npbackup/translations/config_gui.fr.yml | 5 +++-- npbackup/translations/main_gui.en.yml | 5 +++-- npbackup/translations/main_gui.fr.yml | 5 +++-- 4 files changed, 12 insertions(+), 8 deletions(-) diff --git a/npbackup/translations/config_gui.en.yml b/npbackup/translations/config_gui.en.yml index 646c0db..f124934 100644 --- a/npbackup/translations/config_gui.en.yml +++ b/npbackup/translations/config_gui.en.yml @@ -8,7 +8,7 @@ en: windows_only: Windows only exclude_patterns: Exclude patterns exclude_files: Files containing exclusions - exclude_case_ignore: Ignore case for excludes + excludes_case_ignore: Ignore case for excludes patterns/files windows_always: always enabled for Windows exclude_cache_dirs: Exclude cache dirs one_file_system: Do not follow mountpoints @@ -16,6 +16,7 @@ en: maximum_exec_time: Maximum exec time exec_failure_is_fatal: Execution failure is fatal post_exec_commands: Post-exec commands + execute_even_on_backup_error: Execute even if backup failed tags: Tags one_per_line: one per line backup_priority: Backup priority @@ -82,7 +83,7 @@ en: machine_group: Machine group show_decrypted: Show decrypted - no_backup_admin_password_set: No backup admin password set, cannot show unencrypted + no_manager_password_set: No managert password set, cannot show unencrypted # compression auto: Automatic diff --git a/npbackup/translations/config_gui.fr.yml b/npbackup/translations/config_gui.fr.yml index 44e37f8..cc3a373 100644 --- a/npbackup/translations/config_gui.fr.yml +++ b/npbackup/translations/config_gui.fr.yml @@ -8,7 +8,7 @@ fr: windows_only: Windows seulement exclude_patterns: Patterns d'exclusion exclude_files: Fichiers contenant des exclusions - exclude_case_ignore: Ignorer la casse aux exclusions + excludes_case_ignore: Ignorer la casse des exclusions patterns/fichiers windows_always: toujours actif pour Windows exclude_cache_dirs: Exclure dossiers cache one_file_system: Ne pas suivre les points de montage @@ -16,6 +16,7 @@ fr: maximum_exec_time: Temps maximal d'execution exec_failure_is_fatal: L'échec d'execution est fatal post_exec_commands: Commandes post-sauvegarde + execute_even_on_backup_error: Executer même si la sauvegarde a échouée tags: Tags one_per_line: un par ligne backup_priority: Priorité de sauvegarde @@ -82,7 +83,7 @@ fr: machine_group: Groupe machine show_decrypted: Voir déchiffré - no_backup_admin_password_set: Mot de passe admin backup non initialisé, ne peut montrer la version déchiffrée + no_manager_password_set: Mot de passe gestionnaire non initialisé, ne peut montrer la version déchiffrée # compression auto: Automatique diff --git a/npbackup/translations/main_gui.en.yml b/npbackup/translations/main_gui.en.yml index b87c300..b129524 100644 --- a/npbackup/translations/main_gui.en.yml +++ b/npbackup/translations/main_gui.en.yml @@ -34,13 +34,14 @@ en: execute_operation: Executing operation forget_failed: Failed to forget. Please check the logs operations: Operations - select_config_file: Select config file + select_config_file: Load config file repo_and_password_cannot_be_empty: Repo and password cannot be empty viewer_mode: Repository view-only mode open_repo: Open repo new_config: New config load_config: Load configuration - config_error: Configuration file error + config_error: Configuration error + no_config: Please load a configuration before proceeding # logs last_messages: Last messages diff --git a/npbackup/translations/main_gui.fr.yml b/npbackup/translations/main_gui.fr.yml index f326156..7fc97a3 100644 --- a/npbackup/translations/main_gui.fr.yml +++ b/npbackup/translations/main_gui.fr.yml @@ -34,13 +34,14 @@ fr: execute_operation: Opération en cours forget_failed: Oubli impossible. Veuillez vérifier les journaux operations: Opérations - select_config_file: Sélectionner fichier de configuration + select_config_file: Charger fichier de configuration repo_and_password_cannot_be_empty: Le dépot et le mot de passe ne peuvent être vides viewer_mode: Mode visualisation de dépot uniquement open_repo: Ouvrir dépot new_config: Nouvelle configuration load_config: Charger configuration - config_error: Erreur du fichier de configuration + config_error: Erreur de configuration + no_config: Veuillez charger une configuration avant de procéder # logs last_messages: Last messages From c95a8bd53e5691a622bebd0ef363dcfcbd95e47b Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Sun, 31 Dec 2023 15:13:40 +0100 Subject: [PATCH 135/328] Revert add exclude_patterns_case_ignore --- npbackup/core/runner.py | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/npbackup/core/runner.py b/npbackup/core/runner.py index a1107ab..288dac3 100644 --- a/npbackup/core/runner.py +++ b/npbackup/core/runner.py @@ -782,8 +782,8 @@ def backup(self, force: bool = False) -> bool: ) return False - exclude_patterns_source_type = self.repo_config.g( - "backup_opts.exclude_patterns_source_type" + source_type = self.repo_config.g( + "backup_opts.source_type" ) # MSWindows does not support one-file-system option @@ -795,10 +795,20 @@ def backup(self, force: bool = False) -> bool: if not isinstance(exclude_files, list): exclude_files = [exclude_files] - exclude_patterns_case_ignore = self.repo_config.g( - "backup_opts.exclude_patterns_case_ignore" + excludes_case_ignore = self.repo_config.g( + "backup_opts.excludes_case_ignore" ) exclude_caches = self.repo_config.g("backup_opts.exclude_caches") + exclude_files_larger_than = self.repo_config.g("backup_opts.exclude_files_larger_than") + if not exclude_files_larger_than[-1] in ('k', 'K', 'm', 'M', 'g', 'G', 't', 'T'): + self.write_logs(f"Bogus exclude_files_larger_than value given: {exclude_files_larger_than}") + exclude_files_larger_than = None + try: + float(exclude_files_larger_than[:-1]) + except (ValueError, TypeError): + self.write_logs(f"Cannot check whether excludes_files_larger_than is a float: {exclude_files_larger_than}") + exclude_files_larger_than = None + one_file_system = ( self.repo_config.g("backup_opts.one_file_system") if os.name != "nt" @@ -854,7 +864,7 @@ def backup(self, force: bool = False) -> bool: self.restic_runner.verbose = self.verbose # Run backup here - if exclude_patterns_source_type not in ["folder_list", None]: + if source_type not in ["folder_list", None]: self.write_logs( f"Running backup of files in {paths} list to repo {self.repo_config.g('name')}", level="info", @@ -888,11 +898,12 @@ def backup(self, force: bool = False) -> bool: self.restic_runner.dry_run = self.dry_run result, result_string = self.restic_runner.backup( paths=paths, - exclude_patterns_source_type=exclude_patterns_source_type, + source_type=source_type, exclude_patterns=exclude_patterns, exclude_files=exclude_files, - exclude_patterns_case_ignore=exclude_patterns_case_ignore, + excludes_case_ignore=excludes_case_ignore, exclude_caches=exclude_caches, + exclude_files_largen_than=exclude_files_larger_than, one_file_system=one_file_system, use_fs_snapshot=use_fs_snapshot, tags=tags, From cebd4ed112994ec8f491a8e6a13acb81613258f1 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Sun, 31 Dec 2023 15:13:58 +0100 Subject: [PATCH 136/328] Revert add_exclude_patterns_case_ignore --- npbackup/configuration.py | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/npbackup/configuration.py b/npbackup/configuration.py index 672ed1d..14206ce 100644 --- a/npbackup/configuration.py +++ b/npbackup/configuration.py @@ -140,15 +140,16 @@ def d(self, path, sep="."): "default_group": { "backup_opts": { "paths": [], + "source_type": None, "tags": [], "compression": "auto", "use_fs_snapshot": True, "ignore_cloud_files": True, "exclude_caches": True, - "exclude_case_ignore": False, "one_file_system": True, "priority": "low", "exclude_caches": True, + "excludes_case_ignore": False, "exclude_files": [ "excludes/generic_excluded_extensions", "excludes/generic_excludes", @@ -156,8 +157,7 @@ def d(self, path, sep="."): "excludes/linux_excludes", ], "exclude_patterns": None, - "exclude_patterns_source_type": "files_from_verbatim", - "exclude_patterns_case_ignore": False, + "exclude_files_larger_than": None, "additional_parameters": None, "additional_backup_only_parameters": None, "pre_exec_commands": [], @@ -166,7 +166,8 @@ def d(self, path, sep="."): "post_exec_commands": [], "post_exec_per_command_timeout": 3600, "post_exec_failure_is_fatal": False, - "post_exec_execute_even_on_error": True, # TODO + "post_exec_execute_even_on_backup_error": True, + "minimum_backup_size_error": "1M" # TODO } }, "repo_opts": { @@ -254,7 +255,6 @@ def crypt_config( try: def _crypt_config(key: str, value: Any) -> Any: if key_should_be_encrypted(key, encrypted_options): - print("operation", operation) if operation == "encrypt": if ( (isinstance(value, str) @@ -466,6 +466,9 @@ def _inherit_group_settings( return _inherit_group_settings(_repo_config, _group_config, _config_inheritance) + if not full_config: + return None, None + try: # Let's make a copy of config since it's a "pointer object" repo_config = deepcopy(full_config.g(f"repos.{repo_name}")) @@ -521,7 +524,7 @@ def _load_config_file(config_file: Path) -> Union[bool, dict]: f"Config file version {conf_version} is not required version min={MIN_CONF_VERSION}, max={MAX_CONF_VERSION}" ) return False - except AttributeError: + except (AttributeError, TypeError): logger.critical( "Cannot read conf version from config file, which seems bogus" ) @@ -597,8 +600,12 @@ def save_config(config_file: Path, full_config: dict) -> bool: def get_repo_list(full_config: dict) -> List[str]: - return list(full_config.g("repos").keys()) + if full_config: + return list(full_config.g("repos").keys()) + return [] def get_group_list(full_config: dict) -> List[str]: - return list(full_config.g("groups").keys()) + if full_config: + return list(full_config.g("groups").keys()) + return [] From 1ff04570076fd0812196e146ecdfa6000d902a45 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Sun, 31 Dec 2023 15:15:11 +0100 Subject: [PATCH 137/328] Typo fix --- npbackup/restic_wrapper/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/npbackup/restic_wrapper/__init__.py b/npbackup/restic_wrapper/__init__.py index 40cfd0f..b2118ef 100644 --- a/npbackup/restic_wrapper/__init__.py +++ b/npbackup/restic_wrapper/__init__.py @@ -655,7 +655,7 @@ def backup( if exclude_file: if os.path.isfile(exclude_file): cmd += f' --{case_ignore_param}exclude-file "{exclude_file}"' - else:g + else: self.write_logs( f"Exclude file '{exclude_file}' not found", level="error" ) From d8233136bfa5ea8f637838c60109a9b896c97f0d Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Sun, 31 Dec 2023 17:33:51 +0100 Subject: [PATCH 138/328] Fix window title cannot be updated before window load --- npbackup/gui/__main__.py | 20 +++----------------- 1 file changed, 3 insertions(+), 17 deletions(-) diff --git a/npbackup/gui/__main__.py b/npbackup/gui/__main__.py index 71372f3..589a2b6 100644 --- a/npbackup/gui/__main__.py +++ b/npbackup/gui/__main__.py @@ -568,8 +568,7 @@ def get_config(): full_config ) backup_destination = _t("main_gui.local_folder") - backend_type, repo_uri = get_anon_repo_uri(repo_config.g("repo_uri")) - window.set_title(f"{SHORT_PRODUCT_NAME} - {config_file}") + backend_type, repo_uri = get_anon_repo_uri(repo_config.g("repo_uri")) else: repo_config = None config_inheritance = None @@ -580,21 +579,6 @@ def get_config(): return full_config, config_file, repo_config, backup_destination, backend_type, repo_uri, repo_list if not viewer_mode: - """ - full_config, config_file = get_config_file() - if full_config and config_file: - repo_config, config_inheritance = npbackup.configuration.get_repo_config( - full_config - ) - backup_destination = _t("main_gui.local_folder") - backend_type, repo_uri = get_anon_repo_uri(repo_config.g("repo_uri")) - else: - repo_config = None - config_inheritance = None - backup_destination = "None" - backend_type = "Bone" - repo_list = npbackup.configuration.get_repo_list(full_config) - """ full_config, config_file, repo_config, backup_destination, backend_type, repo_uri, repo_list = get_config() else: # Let's try to read standard restic repository env variables @@ -721,6 +705,7 @@ def get_config(): # Auto reisze table to window size window["snapshot-list"].expand(True, True) + window.set_title(f"{SHORT_PRODUCT_NAME} - {config_file}") window.read(timeout=1) if repo_config: @@ -800,6 +785,7 @@ def get_config(): event = "--STATE-BUTTON--" if event == "--LOAD-CONF--": full_config, config_file, repo_config, backup_destination, backend_type, repo_uri, repo_list = get_config() + window.set_title(f"{SHORT_PRODUCT_NAME} - {config_file}") event = "--STATE-BUTTON--" if event == _t("generic.destination"): try: From 663633b405f0a8e9141805118e7cdf65b44c397f Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Sun, 31 Dec 2023 17:34:17 +0100 Subject: [PATCH 139/328] WIP: full_config dict update from GUI with inheritance filter --- npbackup/gui/config.py | 44 ++++++++++++++++++++++++++++++------------ 1 file changed, 32 insertions(+), 12 deletions(-) diff --git a/npbackup/gui/config.py b/npbackup/gui/config.py index eeaa739..3c5a625 100644 --- a/npbackup/gui/config.py +++ b/npbackup/gui/config.py @@ -170,7 +170,8 @@ def update_gui_values(key, value, inherited, object_type, unencrypted): window[key].Disabled = False try: # Don't bother to update repo name - if key == "name": + # Also permissions / manager_password are in a separate gui + if key in ('name', 'permissions', 'manager_password'): return # Don't show sensible info unless unencrypted requested if not unencrypted: @@ -295,6 +296,7 @@ def update_config_dict(full_config, values): else: object_group = None for key, value in values.items(): + # Don't update placeholders ;) if value == ENCRYPTED_DATA_PLACEHOLDER: continue if not isinstance(key, str) or (isinstance(key, str) and not "." in key): @@ -323,6 +325,8 @@ def update_config_dict(full_config, values): except ValueError: pass + current_value = full_config.g(active_object_key) + # Don't bother with inheritance on global options if not key.startswith("global_options."): @@ -339,12 +343,11 @@ def update_config_dict(full_config, values): continue active_object_key = f"{object_type}s.{object_name}.{key}" - current_value = full_config.g(active_object_key) if object_group: inherited = full_config.g(inheritance_key) else: inherited = False - # WIP print(f"UPDATING {active_object_key} curr={current_value} inherited={inherited} new={value}") + # WIP print(f"UPDATING {active_object_key} curr={current_value} inherited={inherited} new={value}") #if not full_config.g(active_object_key): # full_config.s(active_object_key, CommentedMap()) @@ -515,6 +518,13 @@ def object_layout() -> List[list]: ), sg.Multiline(key="backup_opts.exclude_patterns", size=(48, 4)), ], + [ + sg.Text( + _t("config_gui.exclude_files_larger_than"), + size=(40, 2), + ), + sg.Input(key="backup_opts.exclude_files_larger_than", size=(50, 1)), + ], [ sg.Text( f"{_t('config_gui.exclude_files')}\n({_t('config_gui.one_per_line')})", @@ -525,12 +535,12 @@ def object_layout() -> List[list]: [ sg.Text( "{}\n({})".format( - _t("config_gui.exclude_case_ignore"), + _t("config_gui.excludes_case_ignore"), _t("config_gui.windows_always"), ), size=(40, 2), ), - sg.Checkbox("", key="backup_opts.exclude_case_ignore", size=(41, 1)), + sg.Checkbox("", key="backup_opts.excludes_case_ignore", size=(41, 1)), ], [ sg.Text(_t("config_gui.exclude_cache_dirs"), size=(40, 1)), @@ -540,6 +550,10 @@ def object_layout() -> List[list]: sg.Text(_t("config_gui.one_file_system"), size=(40, 1)), sg.Checkbox("", key="backup_opts.one_file_system", size=(41, 1)), ], + [ + sg.Text(_t("config_gui.minimum_backup_size_error"), size=(40, 2)), + sg.Input(key="backup_opts.minimum_backup_size_error", size=(50, 1)), + ], [ sg.Text( f"{_t('config_gui.pre_exec_commands')}\n({_t('config_gui.one_per_line')})", @@ -549,7 +563,7 @@ def object_layout() -> List[list]: ], [ sg.Text(_t("config_gui.maximum_exec_time"), size=(40, 1)), - sg.Input(key="backup_opts.pre_exec_timeout", size=(50, 1)), + sg.Input(key="backup_opts.pre_exec_per_command_timeout", size=(50, 1)), ], [ sg.Text(_t("config_gui.exec_failure_is_fatal"), size=(40, 1)), @@ -566,7 +580,7 @@ def object_layout() -> List[list]: ], [ sg.Text(_t("config_gui.maximum_exec_time"), size=(40, 1)), - sg.Input(key="backup_opts.post_exec_timeout", size=(50, 1)), + sg.Input(key="backup_opts.post_exec_per_command_timeout", size=(50, 1)), ], [ sg.Text(_t("config_gui.exec_failure_is_fatal"), size=(40, 1)), @@ -574,6 +588,12 @@ def object_layout() -> List[list]: "", key="backup_opts.post_exec_failure_is_fatal", size=(41, 1) ), ], + [ + sg.Text(_t("config_gui.execute_even_on_backup_error"), size=(40, 1)), + sg.Checkbox( + "", key="backup_opts.post_exec_execute_even_on_backup_error", size=(41, 1) + ), + ], [ sg.Text( f"{_t('config_gui.tags')}\n({_t('config_gui.one_per_line')})", @@ -743,7 +763,7 @@ def object_layout() -> List[list]: sg.Text(_t("config_gui.select_object")), sg.Combo( object_list, - default_value=object_list[0], + default_value=object_list[0] if object_list else None, key="-OBJECT-SELECT-", enable_events=True, ), @@ -922,10 +942,10 @@ def config_layout() -> List[list]: buttons = [ [ sg.Push(), - sg.Button(_t("config_gui.create_object"), key="-OBJECT-CREATE-"), - sg.Button(_t("config_gui.delete_object"), key="-OBJECT-DELETE-"), - sg.Button(_t("generic.cancel"), key="--CANCEL--"), - sg.Button(_t("generic.accept"), key="--ACCEPT--"), + sg.Button(_t("config_gui.create_object"), key="-OBJECT-CREATE-", size=(30, 1)), + sg.Button(_t("config_gui.delete_object"), key="-OBJECT-DELETE-", size=(30, 1)), + sg.Button(_t("generic.cancel"), key="--CANCEL--", size=(15, 1)), + sg.Button(_t("generic.accept"), key="--ACCEPT--", size=(15, 1)), ] ] From 35ec3dfa3d39b4a26dd3861ed5fab86f0e93fa63 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Sun, 31 Dec 2023 17:34:42 +0100 Subject: [PATCH 140/328] WIP: Implement minimum_backup_size_error --- npbackup/configuration.py | 2 +- npbackup/core/runner.py | 5 +++++ npbackup/translations/config_gui.en.yml | 1 + npbackup/translations/config_gui.fr.yml | 1 + 4 files changed, 8 insertions(+), 1 deletion(-) diff --git a/npbackup/configuration.py b/npbackup/configuration.py index 14206ce..73d57a3 100644 --- a/npbackup/configuration.py +++ b/npbackup/configuration.py @@ -160,6 +160,7 @@ def d(self, path, sep="."): "exclude_files_larger_than": None, "additional_parameters": None, "additional_backup_only_parameters": None, + "minimum_backup_size_error": "10M", # TODO "pre_exec_commands": [], "pre_exec_per_command_timeout": 3600, "pre_exec_failure_is_fatal": False, @@ -167,7 +168,6 @@ def d(self, path, sep="."): "post_exec_per_command_timeout": 3600, "post_exec_failure_is_fatal": False, "post_exec_execute_even_on_backup_error": True, - "minimum_backup_size_error": "1M" # TODO } }, "repo_opts": { diff --git a/npbackup/core/runner.py b/npbackup/core/runner.py index 288dac3..7873fd9 100644 --- a/npbackup/core/runner.py +++ b/npbackup/core/runner.py @@ -816,6 +816,8 @@ def backup(self, force: bool = False) -> bool: ) use_fs_snapshot = self.repo_config.g("backup_opts.use_fs_snapshot") + minimum_backup_size_error = self.repo_config.g("backup_opts.minimum_backup_size_error") + pre_exec_commands = self.repo_config.g("backup_opts.pre_exec_commands") pre_exec_per_command_timeout = self.repo_config.g( "backup_opts.pre_exec_per_command_timeout" @@ -910,6 +912,9 @@ def backup(self, force: bool = False) -> bool: additional_backup_only_parameters=additional_backup_only_parameters, ) self.write_logs(f"Restic output:\n{result_string}", level="debug") + # Extract backup size from result_string + + minimum_backup_size_error = 0 metric_writer( self.repo_config, result, result_string, self.restic_runner.dry_run ) diff --git a/npbackup/translations/config_gui.en.yml b/npbackup/translations/config_gui.en.yml index f124934..0c17be2 100644 --- a/npbackup/translations/config_gui.en.yml +++ b/npbackup/translations/config_gui.en.yml @@ -12,6 +12,7 @@ en: windows_always: always enabled for Windows exclude_cache_dirs: Exclude cache dirs one_file_system: Do not follow mountpoints + minimum_backup_size_error: Minimum size under which backup is considered failed pre_exec_commands: Pre-exec commands maximum_exec_time: Maximum exec time exec_failure_is_fatal: Execution failure is fatal diff --git a/npbackup/translations/config_gui.fr.yml b/npbackup/translations/config_gui.fr.yml index cc3a373..8588090 100644 --- a/npbackup/translations/config_gui.fr.yml +++ b/npbackup/translations/config_gui.fr.yml @@ -12,6 +12,7 @@ fr: windows_always: toujours actif pour Windows exclude_cache_dirs: Exclure dossiers cache one_file_system: Ne pas suivre les points de montage + minimum_backup_size_error: Taille minimale en dessous de laquelle la sauvegarde est considérée échouée pre_exec_commands: Commandes pré-sauvegarde maximum_exec_time: Temps maximal d'execution exec_failure_is_fatal: L'échec d'execution est fatal From e3d6db7ed182ac664a20b335cf7d5570e717450a Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Mon, 1 Jan 2024 16:54:55 +0100 Subject: [PATCH 141/328] Fix viewer mode missing vars --- npbackup/gui/__main__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/npbackup/gui/__main__.py b/npbackup/gui/__main__.py index 589a2b6..e7eaeca 100644 --- a/npbackup/gui/__main__.py +++ b/npbackup/gui/__main__.py @@ -588,6 +588,8 @@ def get_config(): repo_config = viewer_create_repo(viewer_repo_uri, viewer_repo_password) else: repo_config = None + config_file = None + full_config = None right_click_menu = ["", [_t("generic.destination")]] headings = [ @@ -800,7 +802,7 @@ def get_config(): if event == "--ABOUT--": about_gui(version_string, full_config if not viewer_mode else None) if event == "--STATE-BUTTON--": - if full_config: + if full_config or viewer_mode: current_state, backup_tz, snapshot_list = get_gui_data(repo_config) gui_update_state() if current_state is None: From 29581e69446604d84d868e89653d12ebf42a9fdf Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Mon, 1 Jan 2024 21:56:45 +0100 Subject: [PATCH 142/328] Refactor restic_metrics parser, and add tests --- npbackup/restic_metrics/__init__.py | 247 ++++++++++++++++++++++++---- tests/test_restic_metrics.py | 207 +++++++++++++++++++++++ 2 files changed, 424 insertions(+), 30 deletions(-) create mode 100644 tests/test_restic_metrics.py diff --git a/npbackup/restic_metrics/__init__.py b/npbackup/restic_metrics/__init__.py index ebf8b09..c7c678d 100644 --- a/npbackup/restic_metrics/__init__.py +++ b/npbackup/restic_metrics/__init__.py @@ -4,19 +4,20 @@ __intname__ = "restic_metrics" __author__ = "Orsiris de Jong" -__copyright__ = "Copyright (C) 2022-2023 Orsiris de Jong - NetInvent" +__copyright__ = "Copyright (C) 2022-2024 Orsiris de Jong - NetInvent" __licence__ = "BSD-3-Clause" -__version__ = "1.4.4" -__build__ = "2023052701" +__version__ = "2.0.0" +__build__ = "2024010101" __description__ = ( "Converts restic command line output to a text file node_exporter can scrape" ) -__compat__ = "python2.7+" +__compat__ = "python3.6+" import os import sys import re +import json from typing import Union, List, Tuple import logging import platform @@ -28,24 +29,193 @@ logger = logging.getLogger() logger.setLevel(logging.DEBUG) + +def restic_str_output_to_json(restic_exit_status: Union[bool, int], output: str) -> dict: + """ + Parsse restic output when used without `--json` parameter + """ + if restic_exit_status is False or (restic_exit_status is not True and restic_exit_status != 0): + errors = True + else: + errors = False + metrics = { + "files_new": None, + "files_changed": None, + "files_unmodified": None, + "dirs_new": None, + "dirs_changed": None, + "dirs_unmodified": None, + "data_blobs": None, # Not present in standard output + "tree_blobs": None, # Not present in standard output + "data_added": None, # Is "4.425" in Added to the repository: 4.425 MiB (1.431 MiB stored) + "data_stored": None, # Not present in json output, is "1.431" in Added to the repository: 4.425 MiB (1.431 MiB stored) + "total_files_processed": None, + "total_bytes_processed": None, + "total_duration": None, + + # type bool: + "errors": None + } + for line in output.splitlines(): + # for line in output: + matches = re.match( + r"Files:\s+(\d+)\snew,\s+(\d+)\schanged,\s+(\d+)\sunmodified", + line, + re.IGNORECASE, + ) + if matches: + try: + metrics["files_new"] = matches.group(1) + metrics["files_changed"] = matches.group(2) + metrics["files_unmodified"] = matches.group(3) + except IndexError: + logger.warning("Cannot parse restic log for files") + errors = True + + matches = re.match( + r"Dirs:\s+(\d+)\snew,\s+(\d+)\schanged,\s+(\d+)\sunmodified", + line, + re.IGNORECASE, + ) + if matches: + try: + metrics["dirs_new"] = matches.group(1) + metrics["dirs_changed"] = matches.group(2) + metrics["dirs_unmodified"] = matches.group(3) + except IndexError: + logger.warning("Cannot parse restic log for dirs") + errors = True + + matches = re.match( + r"Added to the repo.*:\s([-+]?(?:\d*\.\d+|\d+))\s(\w+)\s+\((.*)\sstored\)", + line, + re.IGNORECASE, + ) + if matches: + try: + size = matches.group(1) + unit = matches.group(2) + try: + value = int(BytesConverter("{} {}".format(size, unit))) + metrics["data_added"] = value + except TypeError: + logger.warning( + "Cannot parse restic values from added to repo size log line" + ) + errors = True + stored_size = matches.group(3) # TODO: add unit detection in regex + try: + stored_size = int(BytesConverter(stored_size)) + metrics["data_stored"] = stored_size + except TypeError: + logger.warning( + "Cannot parse restic values from added to repo stored_size log line" + ) + errors = True + except IndexError as exc: + logger.warning( + "Cannot parse restic log for added data: {}".format(exc) + ) + errors = True -if sys.version_info[0] < 3 or (sys.version_info[0] == 3 and sys.version_info[1] < 4): - import time + matches = re.match( + r"processed\s(\d+)\sfiles,\s([-+]?(?:\d*\.\d+|\d+))\s(\w+)\sin\s((\d+:\d+:\d+)|(\d+:\d+)|(\d+))", + line, + re.IGNORECASE, + ) + if matches: + try: + metrics["total_files_processed"] = matches.group(1) + size = matches.group(2) + unit = matches.group(3) + try: + value = int(BytesConverter("{} {}".format(size, unit))) + metrics["total_bytes_processed"] = value + except TypeError: + logger.warning("Cannot parse restic values for total repo size") + errors = True - def timestamp_get(): - """ - Get UTC timestamp - """ - return time.mktime(datetime.utcnow().timetuple()) + seconds_elapsed = convert_time_to_seconds(matches.group(4)) + try: + metrics["total_duration"] = int(seconds_elapsed) + except ValueError: + logger.warning("Cannot parse restic elapsed time") + errors = True + except IndexError as exc: + logger.error("Trace:", exc_info=True) + logger.warning( + "Cannot parse restic log for repo size: {}".format(exc) + ) + errors = True + matches = re.match( + r"Failure|Fatal|Unauthorized|no such host|s there a repository at the following location\?", + line, + re.IGNORECASE, + ) + if matches: + try: + logger.debug( + 'Matcher found error: "{}" in line "{}".'.format( + matches.group(), line + ) + ) + except IndexError as exc: + logger.error("Trace:", exc_info=True) + errors = True + + metrics["errors"] = errors + return metrics -else: - def timestamp_get(): - """ - Get UTC timestamp - """ - return datetime.utcnow().timestamp() +def restic_json_to_prometheus(restic_json, labels: dict = None) -> Tuple[bool, List[str]]: + """ + Transform a restic JSON result into prometheus metrics + """ + _labels = [] + for key, value in labels.items(): + _labels.append(f'{key}="{value}"') + labels = ",".join(_labels) + + # Take last line of restic output + if isinstance(restic_json, str): + found = False + for line in reversed(restic_json.split('\n')): + if '"message_type":"summary"' in line: + restic_json = line + found = True + break + if not found: + raise ValueError("Bogus data given. No message_type: summmary found") + + if not isinstance(restic_json, dict): + try: + restic_json = json.loads(restic_json) + except json.JSONDecodeError as exc: + logger.error("Cannot decode JSON") + raise ValueError("Bogus data given, except a json dict or string") + + prom_metrics = [] + for key, value in restic_json.items(): + skip = False + for starters in ("files", "dirs"): + if key.startswith(starters): + for enders in ("new", "changed", "unmodified"): + if key.endswith(enders): + prom_metrics.append(f'restic_{starters}{{{labels},state="{enders}",action="backup"}} {value}') + skip = True + if skip: + continue + if key == "total_files_processed": + prom_metrics.append(f'restic_files{{{labels},state="total",action="backup"}} {value}') + continue + if key == "total_bytes_processed": + prom_metrics.append(f'restic_snasphot_size_bytes{{{labels},action="backup",type="processed"}} {value}') + continue + if "duration" in key: + key += "_seconds" + prom_metrics.append(f'restic_{key}{{{labels},action="backup"}} {value}') + return prom_metrics def restic_output_2_metrics(restic_result, output, labels=None): @@ -59,6 +229,19 @@ def restic_output_2_metrics(restic_result, output, labels=None): Dirs: 258 new, 714 changed, 37066 unmodified Added to the repo: 493.649 MiB processed 237786 files, 85.487 GiB in 11:12 + + Logfile format with restic 0.16 (adds actual stored data size): + + repository 962d5924 opened (version 2, compression level auto) + using parent snapshot 8cb0c82d + [0:00] 100.00% 2 / 2 index files loaded + + Files: 0 new, 1 changed, 5856 unmodified + Dirs: 0 new, 5 changed, 859 unmodified + Added to the repository: 27.406 KiB (7.909 KiB stored) + + processed 5857 files, 113.659 MiB in 0:00 + snapshot 6881b995 saved """ metrics = [] @@ -223,23 +406,26 @@ def restic_output_2_metrics(restic_result, output, labels=None): metrics.append( 'restic_backup_failure{{{},timestamp="{}"}} {}'.format( - labels, int(timestamp_get()), 1 if errors else 0 + labels, int(datetime.utcnow().timestamp()), 1 if errors else 0 ) ) return errors, metrics -def upload_metrics(destination, authentication, no_cert_verify, metrics): +def upload_metrics(destination: str, authentication, no_cert_verify: bool, metrics): + """ + Optional upload of metrics to a pushgateway, when no node_exporter with text_collector is available + """ try: headers = { - "X-Requested-With": "{} {}".format(__intname__, __version__), + "X-Requested-With": f"{__intname__} {__version__}", "Content-type": "text/html", } data = "" for metric in metrics: - data += "{}\n".format(metric) - logger.debug("metrics:\n{}".format(data)) + data += f"{metric}\n" + logger.debug(f"metrics:\n{data}") result = requests.post( destination, headers=headers, @@ -251,18 +437,19 @@ def upload_metrics(destination, authentication, no_cert_verify, metrics): if result.status_code == 200: logger.info("Metrics pushed succesfully.") else: - logger.warning( - "Could not push metrics: {}: {}".format(result.reason, result.text) - ) + logger.warning(f"Could not push metrics: {result.reason}: {result.text}") except Exception as exc: - logger.error("Cannot upload metrics: {}".format(exc)) + logger.error(f"Cannot upload metrics: {exc}") logger.debug("Trace:", exc_info=True) -def write_metrics_file(metrics, filename): - with open(filename, "w", encoding="utf-8") as file_handle: - for metric in metrics: - file_handle.write(metric + "\n") +def write_metrics_file(metrics: List[str], filename: str): + try: + with open(filename, "w", encoding="utf-8") as file_handle: + for metric in metrics: + file_handle.write(metric + "\n") + except OSError as exc: + logger.error(f"Cannot write metrics file {filename}: {exc}") if __name__ == "__main__": diff --git a/tests/test_restic_metrics.py b/tests/test_restic_metrics.py new file mode 100644 index 0000000..fabe21d --- /dev/null +++ b/tests/test_restic_metrics.py @@ -0,0 +1,207 @@ +#! /usr/bin/env python3 +# -*- coding: utf-8 -*- + + +__intname__ = "restic_metrics_tests" +__author__ = "Orsiris de Jong" +__copyright__ = "Copyright (C) 2022-2024 Orsiris de Jong - NetInvent SASU" +__licence__ = "NetInvent CSE" +__build__ = "2024010101" +__description__ = "Converts restic command line output to a text file node_exporter can scrape" +__compat__ = "python3.6+" + +import sys +import os +from pathlib import Path +import shutil +import re +import json +import tempfile +from ofunctions.platform import os_arch +from command_runner import command_runner +try: + from npbackup.restic_metrics import * +except ImportError: # would be ModuleNotFoundError in Python 3+ + # In case we run tests without actually having installed command_runner + sys.path.insert(0, os.path.abspath(os.path.join(__file__, os.pardir, os.pardir))) + from npbackup.restic_metrics import * +from npbackup.core.restic_source_binary import get_restic_internal_binary + +restic_json_outputs = {} +restic_json_outputs["v0.16.2"] = \ +"""{"message_type":"summary","files_new":5,"files_changed":15,"files_unmodified":6058,"dirs_new":0,"dirs_changed":27,"dirs_unmodified":866,"data_blobs":17,"tree_blobs":28,"data_added":281097,"total_files_processed":6078,"total_bytes_processed":122342158,"total_duration":1.2836983,"snapshot_id":"360333437921660a5228a9c1b65a2d97381f0bc135499c6e851acb0ab84b0b0a"} +""" + +restic_str_outputs = {} +# log file from restic v0.16.2 +restic_str_outputs["v0.16.2"] = \ +"""repository 962d5924 opened (version 2, compression level auto) +using parent snapshot 325a2fa1 +[0:00] 100.00% 4 / 4 index files loaded + +Files: 216 new, 21 changed, 5836 unmodified +Dirs: 29 new, 47 changed, 817 unmodified +Added to the repository: 4.425 MiB (1.431 MiB stored) + +processed 6073 files, 116.657 MiB in 0:03 +snapshot b28b0901 save +d""" + + # log file from restic v0.14.0 +restic_str_outputs["v0.14.0"] = \ +"""using parent snapshot df60db01 + +Files: 1584 new, 269 changed, 235933 unmodified +Dirs: 258 new, 714 changed, 37066 unmodified +Added to the repo: 493.649 MiB + +processed 237786 files, 85.487 GiB in 11:12" +""" + + # log file form restic v0.9.4 +restic_str_outputs["v0.9.4"] = \ +""" +Files: 9 new, 32 changed, 110340 unmodified +Dirs: 0 new, 2 changed, 0 unmodified +Added to the repo: 196.568 MiB +processed 110381 files, 107.331 GiB in 0:36 +""" + +# restic_metrics_v1 prometheus output +expected_results_V1 = [ + r'restic_repo_files{instance="test",backup_job="some_nas",state="new"} (\d+)', + r'restic_repo_files{instance="test",backup_job="some_nas",state="changed"} (\d+)', + r'restic_repo_files{instance="test",backup_job="some_nas",state="unmodified"} (\d+)', + r'restic_repo_dirs{instance="test",backup_job="some_nas",state="new"} (\d+)', + r'restic_repo_dirs{instance="test",backup_job="some_nas",state="changed"} (\d+)', + r'restic_repo_dirs{instance="test",backup_job="some_nas",state="unmodified"} (\d+)', + r'restic_repo_files{instance="test",backup_job="some_nas",state="total"} (\d+)', + r'restic_repo_size_bytes{instance="test",backup_job="some_nas",state="total"} (\d+)', + r'restic_backup_duration_seconds{instance="test",backup_job="some_nas",action="backup"} (\d+)', +] + +# restic_metrics_v2 prometheus output +expected_results_V2 = [ + r'restic_files{instance="test",backup_job="some_nas",state="new",action="backup"} (\d+)', + r'restic_files{instance="test",backup_job="some_nas",state="changed",action="backup"} (\d+)', + r'restic_files{instance="test",backup_job="some_nas",state="unmodified",action="backup"} (\d+)', + r'restic_dirs{instance="test",backup_job="some_nas",state="new",action="backup"} (\d+)', + r'restic_dirs{instance="test",backup_job="some_nas",state="changed",action="backup"} (\d+)', + r'restic_dirs{instance="test",backup_job="some_nas",state="unmodified",action="backup"} (\d+)', + r'restic_files{instance="test",backup_job="some_nas",state="total",action="backup"} (\d+)', + r'restic_snasphot_size_bytes{instance="test",backup_job="some_nas",action="backup",type="processed"} (\d+)', + r'restic_total_duration_seconds{instance="test",backup_job="some_nas",action="backup"} (\d+)', +] + +def test_restic_str_output_2_metrics(): + instance = "test" + backup_job = "some_nas" + labels = "instance=\"{}\",backup_job=\"{}\"".format(instance, backup_job) + for version, output in restic_str_outputs.items(): + print(f"Testing V1 parser restic str output from version {version}") + errors, prom_metrics = restic_output_2_metrics(True, output, labels) + assert errors is False + #print(f"Parsed result:\n{prom_metrics}") + for expected_result in expected_results_V1: + match_found = False + #print("Searching for {}".format(expected_result)) + for metric in prom_metrics: + result = re.match(expected_result, metric) + if result: + match_found = True + break + assert match_found is True, 'No match found for {}'.format(expected_result) + + +def test_restic_str_output_to_json(): + labels = { + "instance": "test", + "backup_job": "some_nas" + } + for version, output in restic_str_outputs.items(): + print(f"Testing V2 parser restic str output from version {version}") + json_metrics = restic_str_output_to_json(True, output) + assert json_metrics["errors"] == False + #print(json_metrics) + prom_metrics = restic_json_to_prometheus(json_metrics, labels) + + #print(f"Parsed result:\n{prom_metrics}") + for expected_result in expected_results_V2: + match_found = False + #print("Searching for {}".format(expected_result)) + for metric in prom_metrics: + result = re.match(expected_result, metric) + if result: + match_found = True + break + assert match_found is True, 'No match found for {}'.format(expected_result) + + +def test_restic_json_output(): + labels = { + "instance": "test", + "backup_job": "some_nas" + } + for version, json_output in restic_json_outputs.items(): + print(f"Testing V2 direct restic --json output from version {version}") + restic_json = json.loads(json_output) + prom_metrics = restic_json_to_prometheus(restic_json, labels) + #print(f"Parsed result:\n{prom_metrics}") + for expected_result in expected_results_V2: + match_found = False + #print("Searching for {}".format(expected_result)) + for metric in prom_metrics: + result = re.match(expected_result, metric) + if result: + match_found = True + break + assert match_found is True, 'No match found for {}'.format(expected_result) + + +def test_real_restic_output(): + labels = { + "instance": "test", + "backup_job": "some_nas" + } + restic_binary = get_restic_internal_binary(os_arch()) + print(f"Testing real restic output, Running with restic {restic_binary}") + assert restic_binary is not None, "No restic binary found" + + for api_arg in ['', ' --json']: + + # Setup repo and run a quick backup + repo_path = Path(tempfile.gettempdir()) / "repo" + if repo_path.is_dir(): + shutil.rmtree(repo_path) + repo_path.mkdir() + + os.environ["RESTIC_REPOSITORY"] = str(repo_path) + os.environ["RESTIC_PASSWORD"] = "TEST" + + + exit_code, output = command_runner(f"{restic_binary} init --repository-version 2", live_output=True) + cmd = f"{restic_binary} backup {api_arg} ." + exit_code, output = command_runner(cmd, timeout=60, live_output=True) + assert exit_code == 0, "Failed to run restic" + if not api_arg: + restic_json = restic_str_output_to_json(True, output) + else: + restic_json = output + prom_metrics = restic_json_to_prometheus(restic_json, labels) + #print(f"Parsed result:\n{prom_metrics}") + for expected_result in expected_results_V2: + match_found = False + print("Searching for {}".format(expected_result)) + for metric in prom_metrics: + result = re.match(expected_result, metric) + if result: + match_found = True + break + assert match_found is True, 'No match found for {}'.format(expected_result) + + +if __name__ == "__main__": + test_restic_str_output_2_metrics() + test_restic_str_output_to_json() + test_restic_json_output() + test_real_restic_output() \ No newline at end of file From 6a8076fe6cadecfec336e1772482a650402b501f Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Mon, 1 Jan 2024 22:10:38 +0100 Subject: [PATCH 143/328] Add initial tests --- .github/workflows/linux.yaml | 35 +++++++++++++++++++++++++++++++ .github/workflows/windows.yaml | 38 ++++++++++++++++++++++++++++++++++ tests/test_restic_metrics.py | 15 ++++++++++++++ 3 files changed, 88 insertions(+) create mode 100644 .github/workflows/linux.yaml create mode 100644 .github/workflows/windows.yaml diff --git a/.github/workflows/linux.yaml b/.github/workflows/linux.yaml new file mode 100644 index 0000000..1db3b1e --- /dev/null +++ b/.github/workflows/linux.yaml @@ -0,0 +1,35 @@ +name: linux-tests + +on: [push, pull_request] + +jobs: + build: + + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest] + # Python 3.3 and 3.4 have been removed since github won't provide these anymore + # As of 2023/01/09, we have removed python 3.5 and 3.6 as they don't work anymore with linux on github + # As of 2023/08/30, we have removed python 2.7 since github actions won't provide it anymore + python-version: [3.7, 3.8, 3.9, "3.10", "3.11", "3.12", 'pypy-3.6', 'pypy-3.7', 'pypy-3.8', 'pypy-3.10'] + + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v3 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install --upgrade setuptools + if [ -f npbackup/requirements.txt ]; then pip install -r npbackup/requirements.txt; fi + - name: Generate Report + env: + RUNNING_ON_GITHUB_ACTIONS: true + run: | + pip install pytest coverage + python -m coverage run -m pytest -vvs tests + - name: Upload Coverage to Codecov + uses: codecov/codecov-action@v3 diff --git a/.github/workflows/windows.yaml b/.github/workflows/windows.yaml new file mode 100644 index 0000000..444ddd6 --- /dev/null +++ b/.github/workflows/windows.yaml @@ -0,0 +1,38 @@ +name: windows-tests + +# The default shell here is Powershell +# Don't run with python 3.3 as using python -m to run flake8 or pytest will fail. +# Hence, without python -m, pytest will not have it's PYTHONPATH set to current dir and imports will fail +# Don't run with python 3.4 as github cannot install it (pip install --upgrade pip fails) + +on: [push, pull_request] + +jobs: + build: + + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [windows-latest] + # As of 2023/08/30, we have removed python 2.7 since github actions won't provide it anymore + python-version: [3.5, 3.6, 3.7, 3.8, 3.9, "3.10", "3.11", "3.12", 'pypy-3.6', 'pypy-3.7', 'pypy-3.8', 'pypy-3.10'] + + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v3 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install --upgrade setuptools + if (Test-Path "npbackup/requirements.txt") { pip install -r npbackup/requirements.txt } + - name: Generate Report + env: + RUNNING_ON_GITHUB_ACTIONS: true + run: | + pip install pytest coverage + python -m coverage run -m pytest -vvs tests + - name: Upload Coverage to Codecov + uses: codecov/codecov-action@v3 diff --git a/tests/test_restic_metrics.py b/tests/test_restic_metrics.py index fabe21d..5a8ec58 100644 --- a/tests/test_restic_metrics.py +++ b/tests/test_restic_metrics.py @@ -93,6 +93,16 @@ r'restic_total_duration_seconds{instance="test",backup_job="some_nas",action="backup"} (\d+)', ] + +def running_on_github_actions(): + """ + This is set in github actions workflow with + env: + RUNNING_ON_GITHUB_ACTIONS: true + """ + return os.environ.get("RUNNING_ON_GITHUB_ACTIONS").lower() == "true" + + def test_restic_str_output_2_metrics(): instance = "test" backup_job = "some_nas" @@ -159,6 +169,11 @@ def test_restic_json_output(): def test_real_restic_output(): + # Don't do the real tests on github actions, since we don't have + # the binaries there. + # TODO: Add download/unzip restic binaries so we can run these tests + if running_on_github_actions(): + return labels = { "instance": "test", "backup_job": "some_nas" From 75b0b84f32ca046f9444ac4fa80ad4d8e6db7d71 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Mon, 1 Jan 2024 22:13:36 +0100 Subject: [PATCH 144/328] Reformat files with black --- npbackup/__main__.py | 20 ++- npbackup/configuration.py | 42 ++++-- npbackup/core/runner.py | 40 +++-- npbackup/gui/__main__.py | 40 ++++- npbackup/gui/config.py | 24 +-- npbackup/restic_metrics/__init__.py | 223 ++++++++++++++-------------- npbackup/restic_wrapper/__init__.py | 9 +- 7 files changed, 240 insertions(+), 158 deletions(-) diff --git a/npbackup/__main__.py b/npbackup/__main__.py index 4256a9f..70fc4b6 100644 --- a/npbackup/__main__.py +++ b/npbackup/__main__.py @@ -84,7 +84,11 @@ def cli_interface(): help="Restore to path given by --restore", ) parser.add_argument( - "-s", "--snapshots", action="store_true", default=False, help="Show current snapshots" + "-s", + "--snapshots", + action="store_true", + default=False, + help="Show current snapshots", ) parser.add_argument( "--ls", @@ -125,7 +129,11 @@ def cli_interface(): "--repair-snapshots", action="store_true", help="Repair repo snapshots" ) parser.add_argument( - "--list", type=str, default=None, required=False, help="Show [blobs|packs|index|snapshots|keys|locks] objects" + "--list", + type=str, + default=None, + required=False, + help="Show [blobs|packs|index|snapshots|keys|locks] objects", ) parser.add_argument( "--raw", @@ -155,7 +163,9 @@ def cli_interface(): help="Choose which snapshot to use. Defaults to latest", ) parser.add_argument( - "--api", action="store_true", help="Run in JSON API mode. Nothing else than JSON will be printed to stdout" + "--api", + action="store_true", + help="Run in JSON API mode. Nothing else than JSON will be printed to stdout", ) parser.add_argument( "-v", "--verbose", action="store_true", help="Show verbose output" @@ -183,7 +193,9 @@ def cli_interface(): args = parser.parse_args() if args.api: - logger = ofunctions.logger_utils.logger_get_logger(LOG_FILE, console=False, debug=_DEBUG) + logger = ofunctions.logger_utils.logger_get_logger( + LOG_FILE, console=False, debug=_DEBUG + ) else: logger = ofunctions.logger_utils.logger_get_logger(LOG_FILE, debug=_DEBUG) diff --git a/npbackup/configuration.py b/npbackup/configuration.py index 73d57a3..f55c822 100644 --- a/npbackup/configuration.py +++ b/npbackup/configuration.py @@ -160,7 +160,7 @@ def d(self, path, sep="."): "exclude_files_larger_than": None, "additional_parameters": None, "additional_backup_only_parameters": None, - "minimum_backup_size_error": "10M", # TODO + "minimum_backup_size_error": "10M", # TODO "pre_exec_commands": [], "pre_exec_per_command_timeout": 3600, "pre_exec_failure_is_fatal": False, @@ -249,25 +249,31 @@ def key_should_be_encrypted(key, encrypted_options: List[str]): return True return False + def crypt_config( full_config: dict, aes_key: str, encrypted_options: List[str], operation: str ): try: + def _crypt_config(key: str, value: Any) -> Any: if key_should_be_encrypted(key, encrypted_options): if operation == "encrypt": if ( - (isinstance(value, str) - and (not value.startswith(ID_STRING) or not value.endswith(ID_STRING))) - or not isinstance(value, str) - ): + isinstance(value, str) + and ( + not value.startswith(ID_STRING) + or not value.endswith(ID_STRING) + ) + ) or not isinstance(value, str): value = enc.encrypt_message_hf( value, aes_key, ID_STRING, ID_STRING - ).decode( - "utf-8" - ) + ).decode("utf-8") elif operation == "decrypt": - if isinstance(value, str) and value.startswith(ID_STRING) and value.endswith(ID_STRING): + if ( + isinstance(value, str) + and value.startswith(ID_STRING) + and value.endswith(ID_STRING) + ): _, value = enc.decrypt_message_hf( value, aes_key, @@ -278,7 +284,12 @@ def _crypt_config(key: str, value: Any) -> Any: raise ValueError(f"Bogus operation {operation} given") return value - return replace_in_iterable(full_config, _crypt_config, callable_wants_key=True, callable_wants_root_key=True) + return replace_in_iterable( + full_config, + _crypt_config, + callable_wants_key=True, + callable_wants_root_key=True, + ) except Exception as exc: logger.error(f"Cannot {operation} configuration: {exc}.") logger.info("Trace:", exc_info=True) @@ -292,11 +303,18 @@ def _is_encrypted(key, value) -> Any: nonlocal is_encrypted if key_should_be_encrypted(key, ENCRYPTED_OPTIONS): - if isinstance(value, str) and (not value.startswith(ID_STRING) or not value.endswith(ID_STRING)): + if isinstance(value, str) and ( + not value.startswith(ID_STRING) or not value.endswith(ID_STRING) + ): is_encrypted = False return value - replace_in_iterable(full_config, _is_encrypted, callable_wants_key=True, callable_wants_root_key=True) + replace_in_iterable( + full_config, + _is_encrypted, + callable_wants_key=True, + callable_wants_root_key=True, + ) return is_encrypted diff --git a/npbackup/core/runner.py b/npbackup/core/runner.py index 7873fd9..1664a1e 100644 --- a/npbackup/core/runner.py +++ b/npbackup/core/runner.py @@ -640,7 +640,7 @@ def snapshots(self) -> Optional[dict]: ) snapshots = self.restic_runner.snapshots() return snapshots - + @threaded @close_queues @exec_timer @@ -651,7 +651,8 @@ def snapshots(self) -> Optional[dict]: @catch_exceptions def list(self, subject: str) -> Optional[dict]: self.write_logs( - f"Listing {subject} objects of repo {self.repo_config.g('name')}", level="info" + f"Listing {subject} objects of repo {self.repo_config.g('name')}", + level="info", ) snapshots = self.restic_runner.list(subject) return snapshots @@ -782,9 +783,7 @@ def backup(self, force: bool = False) -> bool: ) return False - source_type = self.repo_config.g( - "backup_opts.source_type" - ) + source_type = self.repo_config.g("backup_opts.source_type") # MSWindows does not support one-file-system option exclude_patterns = self.repo_config.g("backup_opts.exclude_patterns") @@ -795,18 +794,31 @@ def backup(self, force: bool = False) -> bool: if not isinstance(exclude_files, list): exclude_files = [exclude_files] - excludes_case_ignore = self.repo_config.g( - "backup_opts.excludes_case_ignore" - ) + excludes_case_ignore = self.repo_config.g("backup_opts.excludes_case_ignore") exclude_caches = self.repo_config.g("backup_opts.exclude_caches") - exclude_files_larger_than = self.repo_config.g("backup_opts.exclude_files_larger_than") - if not exclude_files_larger_than[-1] in ('k', 'K', 'm', 'M', 'g', 'G', 't', 'T'): - self.write_logs(f"Bogus exclude_files_larger_than value given: {exclude_files_larger_than}") + exclude_files_larger_than = self.repo_config.g( + "backup_opts.exclude_files_larger_than" + ) + if not exclude_files_larger_than[-1] in ( + "k", + "K", + "m", + "M", + "g", + "G", + "t", + "T", + ): + self.write_logs( + f"Bogus exclude_files_larger_than value given: {exclude_files_larger_than}" + ) exclude_files_larger_than = None try: float(exclude_files_larger_than[:-1]) except (ValueError, TypeError): - self.write_logs(f"Cannot check whether excludes_files_larger_than is a float: {exclude_files_larger_than}") + self.write_logs( + f"Cannot check whether excludes_files_larger_than is a float: {exclude_files_larger_than}" + ) exclude_files_larger_than = None one_file_system = ( @@ -816,7 +828,9 @@ def backup(self, force: bool = False) -> bool: ) use_fs_snapshot = self.repo_config.g("backup_opts.use_fs_snapshot") - minimum_backup_size_error = self.repo_config.g("backup_opts.minimum_backup_size_error") + minimum_backup_size_error = self.repo_config.g( + "backup_opts.minimum_backup_size_error" + ) pre_exec_commands = self.repo_config.g("backup_opts.pre_exec_commands") pre_exec_per_command_timeout = self.repo_config.g( diff --git a/npbackup/gui/__main__.py b/npbackup/gui/__main__.py index e7eaeca..5169515 100644 --- a/npbackup/gui/__main__.py +++ b/npbackup/gui/__main__.py @@ -43,7 +43,7 @@ LICENSE_FILE, PYSIMPLEGUI_THEME, OEM_ICON, - SHORT_PRODUCT_NAME + SHORT_PRODUCT_NAME, ) from npbackup.gui.config import config_gui from npbackup.gui.operations import operations_gui @@ -491,7 +491,11 @@ def get_gui_data(repo_config: dict) -> Tuple[bool, List[str]]: ) gui_msg = _t("main_gui.loading_snapshot_list_from_repo") snapshots = gui_thread_runner( - repo_config, "snapshots", __gui_msg=gui_msg, __autoclose=True, __compact=True + repo_config, + "snapshots", + __gui_msg=gui_msg, + __autoclose=True, + __compact=True, ) try: min_backup_age = repo_config.g("repo_opts.minimum_backup_age") @@ -568,7 +572,7 @@ def get_config(): full_config ) backup_destination = _t("main_gui.local_folder") - backend_type, repo_uri = get_anon_repo_uri(repo_config.g("repo_uri")) + backend_type, repo_uri = get_anon_repo_uri(repo_config.g("repo_uri")) else: repo_config = None config_inheritance = None @@ -576,10 +580,26 @@ def get_config(): backend_type = "None" repo_uri = "None" repo_list = npbackup.configuration.get_repo_list(full_config) - return full_config, config_file, repo_config, backup_destination, backend_type, repo_uri, repo_list + return ( + full_config, + config_file, + repo_config, + backup_destination, + backend_type, + repo_uri, + repo_list, + ) if not viewer_mode: - full_config, config_file, repo_config, backup_destination, backend_type, repo_uri, repo_list = get_config() + ( + full_config, + config_file, + repo_config, + backup_destination, + backend_type, + repo_uri, + repo_list, + ) = get_config() else: # Let's try to read standard restic repository env variables viewer_repo_uri = os.environ.get("RESTIC_REPOSITORY", None) @@ -786,7 +806,15 @@ def get_config(): repo_config = viewer_create_repo(viewer_repo_uri, viewer_repo_password) event = "--STATE-BUTTON--" if event == "--LOAD-CONF--": - full_config, config_file, repo_config, backup_destination, backend_type, repo_uri, repo_list = get_config() + ( + full_config, + config_file, + repo_config, + backup_destination, + backend_type, + repo_uri, + repo_list, + ) = get_config() window.set_title(f"{SHORT_PRODUCT_NAME} - {config_file}") event = "--STATE-BUTTON--" if event == _t("generic.destination"): diff --git a/npbackup/gui/config.py b/npbackup/gui/config.py index 3c5a625..7673513 100644 --- a/npbackup/gui/config.py +++ b/npbackup/gui/config.py @@ -171,7 +171,7 @@ def update_gui_values(key, value, inherited, object_type, unencrypted): try: # Don't bother to update repo name # Also permissions / manager_password are in a separate gui - if key in ('name', 'permissions', 'manager_password'): + if key in ("name", "permissions", "manager_password"): return # Don't show sensible info unless unencrypted requested if not unencrypted: @@ -326,10 +326,9 @@ def update_config_dict(full_config, values): pass current_value = full_config.g(active_object_key) - + # Don't bother with inheritance on global options if not key.startswith("global_options."): - # Don't update items that have been inherited from groups if object_group: inheritance_key = f"groups.{object_group}.{key}" @@ -348,9 +347,8 @@ def update_config_dict(full_config, values): else: inherited = False # WIP print(f"UPDATING {active_object_key} curr={current_value} inherited={inherited} new={value}") - #if not full_config.g(active_object_key): - # full_config.s(active_object_key, CommentedMap()) - + # if not full_config.g(active_object_key): + # full_config.s(active_object_key, CommentedMap()) # Don't bother to update empty strings, empty lists and None if not current_value and not value: @@ -359,7 +357,7 @@ def update_config_dict(full_config, values): if current_value == value: continue - #full_config.s(active_object_key, value) + # full_config.s(active_object_key, value) return full_config # TODO: Do we actually save every modified object or just the last ? @@ -591,7 +589,9 @@ def object_layout() -> List[list]: [ sg.Text(_t("config_gui.execute_even_on_backup_error"), size=(40, 1)), sg.Checkbox( - "", key="backup_opts.post_exec_execute_even_on_backup_error", size=(41, 1) + "", + key="backup_opts.post_exec_execute_even_on_backup_error", + size=(41, 1), ), ], [ @@ -942,8 +942,12 @@ def config_layout() -> List[list]: buttons = [ [ sg.Push(), - sg.Button(_t("config_gui.create_object"), key="-OBJECT-CREATE-", size=(30, 1)), - sg.Button(_t("config_gui.delete_object"), key="-OBJECT-DELETE-", size=(30, 1)), + sg.Button( + _t("config_gui.create_object"), key="-OBJECT-CREATE-", size=(30, 1) + ), + sg.Button( + _t("config_gui.delete_object"), key="-OBJECT-DELETE-", size=(30, 1) + ), sg.Button(_t("generic.cancel"), key="--CANCEL--", size=(15, 1)), sg.Button(_t("generic.accept"), key="--ACCEPT--", size=(15, 1)), ] diff --git a/npbackup/restic_metrics/__init__.py b/npbackup/restic_metrics/__init__.py index c7c678d..f26c4aa 100644 --- a/npbackup/restic_metrics/__init__.py +++ b/npbackup/restic_metrics/__init__.py @@ -29,13 +29,17 @@ logger = logging.getLogger() logger.setLevel(logging.DEBUG) - -def restic_str_output_to_json(restic_exit_status: Union[bool, int], output: str) -> dict: + +def restic_str_output_to_json( + restic_exit_status: Union[bool, int], output: str +) -> dict: """ Parsse restic output when used without `--json` parameter """ - if restic_exit_status is False or (restic_exit_status is not True and restic_exit_status != 0): + if restic_exit_status is False or ( + restic_exit_status is not True and restic_exit_status != 0 + ): errors = True else: errors = False @@ -46,129 +50,126 @@ def restic_str_output_to_json(restic_exit_status: Union[bool, int], output: str) "dirs_new": None, "dirs_changed": None, "dirs_unmodified": None, - "data_blobs": None, # Not present in standard output - "tree_blobs": None, # Not present in standard output - "data_added": None, # Is "4.425" in Added to the repository: 4.425 MiB (1.431 MiB stored) - "data_stored": None, # Not present in json output, is "1.431" in Added to the repository: 4.425 MiB (1.431 MiB stored) + "data_blobs": None, # Not present in standard output + "tree_blobs": None, # Not present in standard output + "data_added": None, # Is "4.425" in Added to the repository: 4.425 MiB (1.431 MiB stored) + "data_stored": None, # Not present in json output, is "1.431" in Added to the repository: 4.425 MiB (1.431 MiB stored) "total_files_processed": None, "total_bytes_processed": None, "total_duration": None, - # type bool: - "errors": None + "errors": None, } for line in output.splitlines(): - # for line in output: - matches = re.match( - r"Files:\s+(\d+)\snew,\s+(\d+)\schanged,\s+(\d+)\sunmodified", - line, - re.IGNORECASE, - ) - if matches: - try: - metrics["files_new"] = matches.group(1) - metrics["files_changed"] = matches.group(2) - metrics["files_unmodified"] = matches.group(3) - except IndexError: - logger.warning("Cannot parse restic log for files") - errors = True + # for line in output: + matches = re.match( + r"Files:\s+(\d+)\snew,\s+(\d+)\schanged,\s+(\d+)\sunmodified", + line, + re.IGNORECASE, + ) + if matches: + try: + metrics["files_new"] = matches.group(1) + metrics["files_changed"] = matches.group(2) + metrics["files_unmodified"] = matches.group(3) + except IndexError: + logger.warning("Cannot parse restic log for files") + errors = True - matches = re.match( - r"Dirs:\s+(\d+)\snew,\s+(\d+)\schanged,\s+(\d+)\sunmodified", - line, - re.IGNORECASE, - ) - if matches: - try: - metrics["dirs_new"] = matches.group(1) - metrics["dirs_changed"] = matches.group(2) - metrics["dirs_unmodified"] = matches.group(3) - except IndexError: - logger.warning("Cannot parse restic log for dirs") - errors = True + matches = re.match( + r"Dirs:\s+(\d+)\snew,\s+(\d+)\schanged,\s+(\d+)\sunmodified", + line, + re.IGNORECASE, + ) + if matches: + try: + metrics["dirs_new"] = matches.group(1) + metrics["dirs_changed"] = matches.group(2) + metrics["dirs_unmodified"] = matches.group(3) + except IndexError: + logger.warning("Cannot parse restic log for dirs") + errors = True - matches = re.match( - r"Added to the repo.*:\s([-+]?(?:\d*\.\d+|\d+))\s(\w+)\s+\((.*)\sstored\)", - line, - re.IGNORECASE, - ) - if matches: + matches = re.match( + r"Added to the repo.*:\s([-+]?(?:\d*\.\d+|\d+))\s(\w+)\s+\((.*)\sstored\)", + line, + re.IGNORECASE, + ) + if matches: + try: + size = matches.group(1) + unit = matches.group(2) try: - size = matches.group(1) - unit = matches.group(2) - try: - value = int(BytesConverter("{} {}".format(size, unit))) - metrics["data_added"] = value - except TypeError: - logger.warning( - "Cannot parse restic values from added to repo size log line" - ) - errors = True - stored_size = matches.group(3) # TODO: add unit detection in regex - try: - stored_size = int(BytesConverter(stored_size)) - metrics["data_stored"] = stored_size - except TypeError: - logger.warning( - "Cannot parse restic values from added to repo stored_size log line" - ) - errors = True - except IndexError as exc: + value = int(BytesConverter("{} {}".format(size, unit))) + metrics["data_added"] = value + except TypeError: logger.warning( - "Cannot parse restic log for added data: {}".format(exc) + "Cannot parse restic values from added to repo size log line" ) errors = True - - matches = re.match( - r"processed\s(\d+)\sfiles,\s([-+]?(?:\d*\.\d+|\d+))\s(\w+)\sin\s((\d+:\d+:\d+)|(\d+:\d+)|(\d+))", - line, - re.IGNORECASE, - ) - if matches: + stored_size = matches.group(3) # TODO: add unit detection in regex try: - metrics["total_files_processed"] = matches.group(1) - size = matches.group(2) - unit = matches.group(3) - try: - value = int(BytesConverter("{} {}".format(size, unit))) - metrics["total_bytes_processed"] = value - except TypeError: - logger.warning("Cannot parse restic values for total repo size") - errors = True - - seconds_elapsed = convert_time_to_seconds(matches.group(4)) - try: - metrics["total_duration"] = int(seconds_elapsed) - except ValueError: - logger.warning("Cannot parse restic elapsed time") - errors = True - except IndexError as exc: - logger.error("Trace:", exc_info=True) + stored_size = int(BytesConverter(stored_size)) + metrics["data_stored"] = stored_size + except TypeError: logger.warning( - "Cannot parse restic log for repo size: {}".format(exc) + "Cannot parse restic values from added to repo stored_size log line" ) errors = True - matches = re.match( - r"Failure|Fatal|Unauthorized|no such host|s there a repository at the following location\?", - line, - re.IGNORECASE, - ) - if matches: + except IndexError as exc: + logger.warning("Cannot parse restic log for added data: {}".format(exc)) + errors = True + + matches = re.match( + r"processed\s(\d+)\sfiles,\s([-+]?(?:\d*\.\d+|\d+))\s(\w+)\sin\s((\d+:\d+:\d+)|(\d+:\d+)|(\d+))", + line, + re.IGNORECASE, + ) + if matches: + try: + metrics["total_files_processed"] = matches.group(1) + size = matches.group(2) + unit = matches.group(3) try: - logger.debug( - 'Matcher found error: "{}" in line "{}".'.format( - matches.group(), line - ) - ) - except IndexError as exc: - logger.error("Trace:", exc_info=True) + value = int(BytesConverter("{} {}".format(size, unit))) + metrics["total_bytes_processed"] = value + except TypeError: + logger.warning("Cannot parse restic values for total repo size") + errors = True + + seconds_elapsed = convert_time_to_seconds(matches.group(4)) + try: + metrics["total_duration"] = int(seconds_elapsed) + except ValueError: + logger.warning("Cannot parse restic elapsed time") + errors = True + except IndexError as exc: + logger.error("Trace:", exc_info=True) + logger.warning("Cannot parse restic log for repo size: {}".format(exc)) errors = True - + matches = re.match( + r"Failure|Fatal|Unauthorized|no such host|s there a repository at the following location\?", + line, + re.IGNORECASE, + ) + if matches: + try: + logger.debug( + 'Matcher found error: "{}" in line "{}".'.format( + matches.group(), line + ) + ) + except IndexError as exc: + logger.error("Trace:", exc_info=True) + errors = True + metrics["errors"] = errors return metrics -def restic_json_to_prometheus(restic_json, labels: dict = None) -> Tuple[bool, List[str]]: +def restic_json_to_prometheus( + restic_json, labels: dict = None +) -> Tuple[bool, List[str]]: """ Transform a restic JSON result into prometheus metrics """ @@ -180,7 +181,7 @@ def restic_json_to_prometheus(restic_json, labels: dict = None) -> Tuple[bool, L # Take last line of restic output if isinstance(restic_json, str): found = False - for line in reversed(restic_json.split('\n')): + for line in reversed(restic_json.split("\n")): if '"message_type":"summary"' in line: restic_json = line found = True @@ -202,15 +203,21 @@ def restic_json_to_prometheus(restic_json, labels: dict = None) -> Tuple[bool, L if key.startswith(starters): for enders in ("new", "changed", "unmodified"): if key.endswith(enders): - prom_metrics.append(f'restic_{starters}{{{labels},state="{enders}",action="backup"}} {value}') + prom_metrics.append( + f'restic_{starters}{{{labels},state="{enders}",action="backup"}} {value}' + ) skip = True if skip: continue if key == "total_files_processed": - prom_metrics.append(f'restic_files{{{labels},state="total",action="backup"}} {value}') + prom_metrics.append( + f'restic_files{{{labels},state="total",action="backup"}} {value}' + ) continue if key == "total_bytes_processed": - prom_metrics.append(f'restic_snasphot_size_bytes{{{labels},action="backup",type="processed"}} {value}') + prom_metrics.append( + f'restic_snasphot_size_bytes{{{labels},action="backup",type="processed"}} {value}' + ) continue if "duration" in key: key += "_seconds" diff --git a/npbackup/restic_wrapper/__init__.py b/npbackup/restic_wrapper/__init__.py index b2118ef..4b01e8c 100644 --- a/npbackup/restic_wrapper/__init__.py +++ b/npbackup/restic_wrapper/__init__.py @@ -25,7 +25,6 @@ from npbackup.__env__ import INIT_TIMEOUT, CHECK_INTERVAL - logger = getLogger() @@ -875,13 +874,13 @@ def _has_recent_snapshot( if not snapshot_list or not delta: return False, backup_ts tz_aware_timestamp = datetime.now(timezone.utc).astimezone() - + # Now just take the last snapshot in list (being the more recent), and check whether it's too old last_snapshot = snapshot_list[-1] if re.match( - r"[0-9]{4}-[0-1][0-9]-[0-3][0-9]T[0-2][0-9]:[0-5][0-9]:[0-5][0-9](\.\d*)?(\+[0-2][0-9]:[0-9]{2})?", - last_snapshot["time"], - ): + r"[0-9]{4}-[0-1][0-9]-[0-3][0-9]T[0-2][0-9]:[0-5][0-9]:[0-5][0-9](\.\d*)?(\+[0-2][0-9]:[0-9]{2})?", + last_snapshot["time"], + ): backup_ts = dateutil.parser.parse(last_snapshot["time"]) snapshot_age_minutes = (tz_aware_timestamp - backup_ts).total_seconds() / 60 if delta - snapshot_age_minutes > 0: From ec3d33ca9947b44260004032f85524a0458b302c Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Mon, 1 Jan 2024 22:17:09 +0100 Subject: [PATCH 145/328] Add missing log levels --- npbackup/core/runner.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/npbackup/core/runner.py b/npbackup/core/runner.py index 1664a1e..f1a7fd5 100644 --- a/npbackup/core/runner.py +++ b/npbackup/core/runner.py @@ -810,14 +810,14 @@ def backup(self, force: bool = False) -> bool: "T", ): self.write_logs( - f"Bogus exclude_files_larger_than value given: {exclude_files_larger_than}" + f"Bogus suffix for exclude_files_larger_than value given: {exclude_files_larger_than}", level="warning" ) exclude_files_larger_than = None try: float(exclude_files_larger_than[:-1]) except (ValueError, TypeError): self.write_logs( - f"Cannot check whether excludes_files_larger_than is a float: {exclude_files_larger_than}" + f"Cannot check whether excludes_files_larger_than is a float: {exclude_files_larger_than}", level="warning" ) exclude_files_larger_than = None From 43c3b82adf6055711636f379ee418f7be51ecf0e Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Mon, 1 Jan 2024 22:17:19 +0100 Subject: [PATCH 146/328] Fix var name --- npbackup/restic_wrapper/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/npbackup/restic_wrapper/__init__.py b/npbackup/restic_wrapper/__init__.py index 4b01e8c..b32c57d 100644 --- a/npbackup/restic_wrapper/__init__.py +++ b/npbackup/restic_wrapper/__init__.py @@ -612,7 +612,7 @@ def backup( return None, None # Handle various source types - if exclude_patterns_source_type in [ + if source_type in [ "files_from", "files_from_verbatim", "files_from_raw", From 095dd140793988a97c49de5455484580417582bd Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Mon, 1 Jan 2024 22:30:30 +0100 Subject: [PATCH 147/328] Fix uninitialized variable --- npbackup/gui/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/npbackup/gui/config.py b/npbackup/gui/config.py index 7673513..db763e9 100644 --- a/npbackup/gui/config.py +++ b/npbackup/gui/config.py @@ -325,6 +325,7 @@ def update_config_dict(full_config, values): except ValueError: pass + active_object_key = f"{object_type}s.{object_name}.{key}" current_value = full_config.g(active_object_key) # Don't bother with inheritance on global options @@ -341,7 +342,6 @@ def update_config_dict(full_config, values): if full_config.g(inheritance_key) == value: continue - active_object_key = f"{object_type}s.{object_name}.{key}" if object_group: inherited = full_config.g(inheritance_key) else: From 1b2936c15ce9bfce17287517e79d32ec132f44e3 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Mon, 1 Jan 2024 22:32:25 +0100 Subject: [PATCH 148/328] Don't run pypy tests on Windows --- .github/workflows/windows.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/windows.yaml b/.github/workflows/windows.yaml index 444ddd6..d5d1eee 100644 --- a/.github/workflows/windows.yaml +++ b/.github/workflows/windows.yaml @@ -15,7 +15,8 @@ jobs: matrix: os: [windows-latest] # As of 2023/08/30, we have removed python 2.7 since github actions won't provide it anymore - python-version: [3.5, 3.6, 3.7, 3.8, 3.9, "3.10", "3.11", "3.12", 'pypy-3.6', 'pypy-3.7', 'pypy-3.8', 'pypy-3.10'] + # Don't test on pypy since we don't have pywin32 + python-version: [3.5, 3.6, 3.7, 3.8, 3.9, "3.10", "3.11", "3.12"] steps: - uses: actions/checkout@v3 From bbfad1e64ad179e2b71b0762169aac04b0534089 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Mon, 1 Jan 2024 22:35:13 +0100 Subject: [PATCH 149/328] Reformat file with black --- npbackup/core/runner.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/npbackup/core/runner.py b/npbackup/core/runner.py index f1a7fd5..b7c79f8 100644 --- a/npbackup/core/runner.py +++ b/npbackup/core/runner.py @@ -810,14 +810,16 @@ def backup(self, force: bool = False) -> bool: "T", ): self.write_logs( - f"Bogus suffix for exclude_files_larger_than value given: {exclude_files_larger_than}", level="warning" + f"Bogus suffix for exclude_files_larger_than value given: {exclude_files_larger_than}", + level="warning", ) exclude_files_larger_than = None try: float(exclude_files_larger_than[:-1]) except (ValueError, TypeError): self.write_logs( - f"Cannot check whether excludes_files_larger_than is a float: {exclude_files_larger_than}", level="warning" + f"Cannot check whether excludes_files_larger_than is a float: {exclude_files_larger_than}", + level="warning", ) exclude_files_larger_than = None From 7d650412b5c0963fb391a2bf91cc12faf878e97a Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Mon, 1 Jan 2024 22:39:06 +0100 Subject: [PATCH 150/328] Don't try to run on Python 3.5 --- .github/workflows/linux.yaml | 2 +- .github/workflows/windows.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/linux.yaml b/.github/workflows/linux.yaml index 1db3b1e..0a790a5 100644 --- a/.github/workflows/linux.yaml +++ b/.github/workflows/linux.yaml @@ -12,7 +12,7 @@ jobs: # Python 3.3 and 3.4 have been removed since github won't provide these anymore # As of 2023/01/09, we have removed python 3.5 and 3.6 as they don't work anymore with linux on github # As of 2023/08/30, we have removed python 2.7 since github actions won't provide it anymore - python-version: [3.7, 3.8, 3.9, "3.10", "3.11", "3.12", 'pypy-3.6', 'pypy-3.7', 'pypy-3.8', 'pypy-3.10'] + python-version: [3.6, 3.7, 3.8, 3.9, "3.10", "3.11", "3.12", 'pypy-3.6', 'pypy-3.7', 'pypy-3.8', 'pypy-3.10'] steps: - uses: actions/checkout@v3 diff --git a/.github/workflows/windows.yaml b/.github/workflows/windows.yaml index d5d1eee..53355ec 100644 --- a/.github/workflows/windows.yaml +++ b/.github/workflows/windows.yaml @@ -16,7 +16,7 @@ jobs: os: [windows-latest] # As of 2023/08/30, we have removed python 2.7 since github actions won't provide it anymore # Don't test on pypy since we don't have pywin32 - python-version: [3.5, 3.6, 3.7, 3.8, 3.9, "3.10", "3.11", "3.12"] + python-version: [3.6, 3.7, 3.8, 3.9, "3.10", "3.11", "3.12"] steps: - uses: actions/checkout@v3 From d1a6288363cfe120e4a7e488d8a5ef6550f621ea Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Mon, 1 Jan 2024 22:41:07 +0100 Subject: [PATCH 151/328] Don't test python 3.6 on linux since GH doesn't provide anymore --- .github/workflows/linux.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/linux.yaml b/.github/workflows/linux.yaml index 0a790a5..1db3b1e 100644 --- a/.github/workflows/linux.yaml +++ b/.github/workflows/linux.yaml @@ -12,7 +12,7 @@ jobs: # Python 3.3 and 3.4 have been removed since github won't provide these anymore # As of 2023/01/09, we have removed python 3.5 and 3.6 as they don't work anymore with linux on github # As of 2023/08/30, we have removed python 2.7 since github actions won't provide it anymore - python-version: [3.6, 3.7, 3.8, 3.9, "3.10", "3.11", "3.12", 'pypy-3.6', 'pypy-3.7', 'pypy-3.8', 'pypy-3.10'] + python-version: [3.7, 3.8, 3.9, "3.10", "3.11", "3.12", 'pypy-3.6', 'pypy-3.7', 'pypy-3.8', 'pypy-3.10'] steps: - uses: actions/checkout@v3 From b5a960492575c3108e940b431e990fe300a35256 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Tue, 2 Jan 2024 13:43:11 +0100 Subject: [PATCH 152/328] Fixes exclude_files_largen_than --- npbackup/core/runner.py | 51 +++++++++++++++++++++-------------------- 1 file changed, 26 insertions(+), 25 deletions(-) diff --git a/npbackup/core/runner.py b/npbackup/core/runner.py index b7c79f8..1313118 100644 --- a/npbackup/core/runner.py +++ b/npbackup/core/runner.py @@ -654,8 +654,7 @@ def list(self, subject: str) -> Optional[dict]: f"Listing {subject} objects of repo {self.repo_config.g('name')}", level="info", ) - snapshots = self.restic_runner.list(subject) - return snapshots + return self.restic_runner.list(subject) @threaded @close_queues @@ -796,31 +795,33 @@ def backup(self, force: bool = False) -> bool: excludes_case_ignore = self.repo_config.g("backup_opts.excludes_case_ignore") exclude_caches = self.repo_config.g("backup_opts.exclude_caches") + exclude_files_larger_than = self.repo_config.g( "backup_opts.exclude_files_larger_than" ) - if not exclude_files_larger_than[-1] in ( - "k", - "K", - "m", - "M", - "g", - "G", - "t", - "T", - ): - self.write_logs( - f"Bogus suffix for exclude_files_larger_than value given: {exclude_files_larger_than}", - level="warning", - ) - exclude_files_larger_than = None - try: - float(exclude_files_larger_than[:-1]) - except (ValueError, TypeError): - self.write_logs( - f"Cannot check whether excludes_files_larger_than is a float: {exclude_files_larger_than}", - level="warning", - ) + if exclude_files_larger_than: + if not exclude_files_larger_than[-1] in ( + "k", + "K", + "m", + "M", + "g", + "G", + "t", + "T", + ): + self.write_logs( + f"Bogus suffix for exclude_files_larger_than value given: {exclude_files_larger_than}", + level="warning", + ) + exclude_files_larger_than = None + try: + float(exclude_files_larger_than[:-1]) + except (ValueError, TypeError): + self.write_logs( + f"Cannot check whether excludes_files_larger_than is a float: {exclude_files_larger_than}", + level="warning", + ) exclude_files_larger_than = None one_file_system = ( @@ -921,7 +922,7 @@ def backup(self, force: bool = False) -> bool: exclude_files=exclude_files, excludes_case_ignore=excludes_case_ignore, exclude_caches=exclude_caches, - exclude_files_largen_than=exclude_files_larger_than, + exclude_files_larger_than=exclude_files_larger_than, one_file_system=one_file_system, use_fs_snapshot=use_fs_snapshot, tags=tags, From 490744a829f73f8c083a72b499e581ad01bbfec4 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Tue, 2 Jan 2024 19:38:18 +0100 Subject: [PATCH 153/328] Rewrite runner/wrapper to be fully JSON compliant --- npbackup/__main__.py | 8 +- npbackup/core/runner.py | 82 ++++++++-- npbackup/gui/__main__.py | 19 ++- npbackup/gui/helpers.py | 5 + npbackup/restic_wrapper/__init__.py | 237 +++++++++++++++++++--------- npbackup/runner_interface.py | 17 +- 6 files changed, 261 insertions(+), 107 deletions(-) diff --git a/npbackup/__main__.py b/npbackup/__main__.py index 70fc4b6..d7d84a7 100644 --- a/npbackup/__main__.py +++ b/npbackup/__main__.py @@ -163,7 +163,7 @@ def cli_interface(): help="Choose which snapshot to use. Defaults to latest", ) parser.add_argument( - "--api", + "--json", action="store_true", help="Run in JSON API mode. Nothing else than JSON will be printed to stdout", ) @@ -192,7 +192,7 @@ def cli_interface(): ) args = parser.parse_args() - if args.api: + if args.json: logger = ofunctions.logger_utils.logger_get_logger( LOG_FILE, console=False, debug=_DEBUG ) @@ -251,7 +251,7 @@ def cli_interface(): "verbose": args.verbose, "dry_run": args.dry_run, "debug": args.debug, - "api_mode": args.api, + "json_output": args.json, "operation": None, "op_args": {}, } @@ -276,7 +276,7 @@ def cli_interface(): cli_args["op_args"] = {"snapshot": args.snapshot_id} elif args.find: cli_args["operation"] = "find" - cli_args["op_args"] = {"snapshot": args.snapshot_id, "path": args.find} + cli_args["op_args"] = {"path": args.find} elif args.forget: cli_args["operation"] = "forget" if args.forget == "policy": diff --git a/npbackup/core/runner.py b/npbackup/core/runner.py index 1313118..4d9c919 100644 --- a/npbackup/core/runner.py +++ b/npbackup/core/runner.py @@ -5,9 +5,9 @@ __intname__ = "npbackup.gui.core.runner" __author__ = "Orsiris de Jong" -__copyright__ = "Copyright (C) 2022-2023 NetInvent" +__copyright__ = "Copyright (C) 2022-2024 NetInvent" __license__ = "GPL-3.0-only" -__build__ = "2023083101" +__build__ = "2024010201" from typing import Optional, Callable, Union, List @@ -128,6 +128,7 @@ def __init__(self): self._dry_run = False self._verbose = False + self._json_output = False self.restic_runner = None self.minimum_backup_age = None self._exec_time = None @@ -175,6 +176,18 @@ def verbose(self, value): self.write_logs(msg, level="critical", raise_error="ValueError") self._verbose = value + + @property + def json_output(self): + return self._json_output + + @json_output.setter + def json_output(self, value): + if not isinstance(value, bool): + msg = f"Bogus json_output parameter given: {value}" + self.write_logs(msg, level="critical", raise_error="ValueError") + self._json_output = value + @property def stdout(self): return self._stdout @@ -262,6 +275,9 @@ def wrapper(self, *args, **kwargs): # pylint: disable=E1102 (not-callable) result = fn(self, *args, **kwargs) self.exec_time = (datetime.utcnow() - start_time).total_seconds() + # Optional patch result with exec time + if self.restic_runner.json_output and isinstance(result, dict): + result["exec_time"] = self.exec_time # pylint: disable=E1101 (no-member) self.write_logs( f"Runner took {self.exec_time} seconds for {fn.__name__}", level="info" @@ -298,8 +314,20 @@ def is_ready(fn: Callable): def wrapper(self, *args, **kwargs): if not self._is_ready: # pylint: disable=E1101 (no-member) + if fn.__name__ == "group_runner": + operation = kwargs.get("operation") + else: + # pylint: disable=E1101 (no-member) + operation = fn.__name__ + if self.json_output: + js = { + "result": False, + "operation": operation, + "reason": "backend not ready" + } + return js self.write_logs( - f"Runner cannot execute {fn.__name__}. Backend not ready", + f"Runner cannot execute {operation}. Backend not ready", level="error", ) return False @@ -339,13 +367,23 @@ def wrapper(self, *args, **kwargs): else: # pylint: disable=E1101 (no-member) operation = fn.__name__ - # TODO: enforce permissions + self.write_logs( f"Permissions required are {required_permissions[operation]}", level="info", ) - except (IndexError, KeyError): + has_permissions = True # TODO: enforce permissions + if not has_permissions: + raise PermissionError + except (IndexError, KeyError, PermissionError): self.write_logs("You don't have sufficient permissions", level="error") + if self.json_output: + js = { + "result": False, + "operation": operation, + "reason": "Not enough permissions" + } + return js return False # pylint: disable=E1102 (not-callable) return fn(self, *args, **kwargs) @@ -382,7 +420,12 @@ def wrapper(self, *args, **kwargs): "unlock", ] # pylint: disable=E1101 (no-member) - if fn.__name__ in locking_operations: + if fn.__name__ == "group_runner": + operation = kwargs.get("operation") + else: + # pylint: disable=E1101 (no-member) + operation = fn.__name__ + if operation in locking_operations: pid_file = os.path.join( tempfile.gettempdir(), "{}.pid".format(__intname__) ) @@ -391,9 +434,8 @@ def wrapper(self, *args, **kwargs): # pylint: disable=E1102 (not-callable) result = fn(self, *args, **kwargs) except pidfile.AlreadyRunningError: - # pylint: disable=E1101 (no-member) self.write_logs( - f"There is already an {fn.__name__} operation running by NPBackup. Will not continue", + f"There is already an {operation} operation running by NPBackup. Will not continue", level="critical", ) return False @@ -417,10 +459,22 @@ def wrapper(self, *args, **kwargs): return fn(self, *args, **kwargs) except Exception as exc: # pylint: disable=E1101 (no-member) + if fn.__name__ == "group_runner": + operation = kwargs.get("operation") + else: + # pylint: disable=E1101 (no-member) + operation = fn.__name__ self.write_logs( - f"Function {fn.__name__} failed with: {exc}", level="error" + f"Function {operation} failed with: {exc}", level="error" ) - logger.debug("Trace:", exc_info=True) + logger.info("Trace:", exc_info=True) + if self.json_output: + js = { + "result": False, + "operation": operation, + "reason": f"Exception: {exc}" + } + return js return False return wrapper @@ -609,6 +663,7 @@ def _apply_config_to_restic_runner(self) -> bool: self.minimum_backup_age = 0 self.restic_runner.verbose = self.verbose + self.restic_runner.json_output = self.json_output self.restic_runner.stdout = self.stdout self.restic_runner.stderr = self.stderr @@ -672,10 +727,8 @@ def find(self, path: str) -> bool: ) result = self.restic_runner.find(path=path) if result: - self.write_logs("Found path in:\n", level="info") - for line in result: - self.write_logs(line, level="info") - return True + self.write_logs(f"Found path in:\n{result}", level="info") + return result return False @threaded @@ -715,6 +768,7 @@ def has_recent_snapshot(self) -> bool: f"Searching for a backup newer than {str(timedelta(minutes=self.minimum_backup_age))} ago", level="info", ) + # Temporarily disable verbose and enable json result self.restic_runner.verbose = False result, backup_tz = self.restic_runner.has_recent_snapshot( self.minimum_backup_age diff --git a/npbackup/gui/__main__.py b/npbackup/gui/__main__.py index 5169515..064ee35 100644 --- a/npbackup/gui/__main__.py +++ b/npbackup/gui/__main__.py @@ -232,12 +232,13 @@ def _make_treedata_from_json(ls_result: List[dict]) -> sg.TreeData: def ls_window(repo_config: dict, snapshot_id: str) -> bool: - snapshot_content = gui_thread_runner( + result = gui_thread_runner( repo_config, "ls", snapshot=snapshot_id, __autoclose=True, __compact=True ) - if not snapshot_content: - return snapshot_content, None + if not result["result"]: + return None, None + snapshot_content = result["output"] try: # Since ls returns an iter now, we need to use next snapshot = next(snapshot_content) @@ -352,7 +353,7 @@ def _restore_window( target=target, restore_includes=restore_includes, ) - return result + return result["result"] left_col = [ [ @@ -400,7 +401,7 @@ def backup(repo_config: dict) -> bool: __compact=False, __gui_msg=gui_msg, ) - return result + return result["result"] def forget_snapshot(repo_config: dict, snapshot_ids: List[str]) -> bool: @@ -414,7 +415,7 @@ def forget_snapshot(repo_config: dict, snapshot_ids: List[str]) -> bool: __gui_msg=gui_msg, __autoclose=True, ) - return result + return result["result"] def _main_gui(viewer_mode: bool): @@ -490,13 +491,17 @@ def get_gui_data(repo_config: dict) -> Tuple[bool, List[str]]: _t("generic.please_wait"), button_color="orange" ) gui_msg = _t("main_gui.loading_snapshot_list_from_repo") - snapshots = gui_thread_runner( + result = gui_thread_runner( repo_config, "snapshots", __gui_msg=gui_msg, __autoclose=True, __compact=True, ) + if not result["result"]: + snapshots = None + else: + snapshots = result["output"] try: min_backup_age = repo_config.g("repo_opts.minimum_backup_age") except AttributeError: diff --git a/npbackup/gui/helpers.py b/npbackup/gui/helpers.py index 75cad04..35a66c9 100644 --- a/npbackup/gui/helpers.py +++ b/npbackup/gui/helpers.py @@ -97,9 +97,14 @@ def _upgrade_from_compact_view(): progress_window["--EXPAND--"].Update(visible=False) runner = NPBackupRunner() + + # We'll always use json output in GUI mode + + runner.json_output = True # So we don't always init repo_config, since runner.group_runner would do that itself if __repo_config: runner.repo_config = __repo_config + stdout_queue = queue.Queue() stderr_queue = queue.Queue() fn = getattr(runner, __fn_name) diff --git a/npbackup/restic_wrapper/__init__.py b/npbackup/restic_wrapper/__init__.py index b32c57d..7dd6f2b 100644 --- a/npbackup/restic_wrapper/__init__.py +++ b/npbackup/restic_wrapper/__init__.py @@ -5,14 +5,15 @@ __intname__ = "npbackup.restic_wrapper" __author__ = "Orsiris de Jong" -__copyright__ = "Copyright (C) 2022-2023 NetInvent" +__copyright__ = "Copyright (C) 2022-2024 NetInvent" __license__ = "GPL-3.0-only" -__build__ = "2023122901" -__version__ = "1.9.0" +__build__ = "2024010201" +__version__ = "2.0.0" from typing import Tuple, List, Optional, Callable, Union import os +import sys from logging import getLogger import re import json @@ -28,6 +29,8 @@ logger = getLogger() +fn_name = lambda n=0: sys._getframe(n + 1).f_code.co_name # TODO go to ofunctions.misc + class ResticRunner: def __init__( self, @@ -42,6 +45,7 @@ def __init__( self.password = str(password).strip() self._verbose = False self._dry_run = False + self._json_output = False self._binary = None self.binary_search_paths = binary_search_paths @@ -78,11 +82,12 @@ def __init__( self._stop_on = ( None # Function which will make executor abort if result is True ) - self._executor_finished = False # Internal value to check whether executor is done, accessed via self.executor_finished property + # Internal value to check whether executor is running, accessed via self.executor_running property + self._executor_running = False def on_exit(self) -> bool: - self._executor_finished = True - return self._executor_finished + self._executor_running = False + return self._executor_running def _make_env(self) -> None: """ @@ -178,6 +183,17 @@ def dry_run(self, value: bool): self._dry_run = value else: raise ValueError("Bogus dry run value givne") + + @property + def json_output(self) -> bool: + return self._json_output + + @json_output.setter + def json_output(self, value: bool): + if isinstance(value, bool): + self._json_output = value + else: + raise ValueError("Bogus json_output value givne") @property def ignore_cloud_files(self) -> bool: @@ -198,6 +214,10 @@ def exec_time(self) -> Optional[int]: def exec_time(self, value: int): self._exec_time = value + @property + def executor_running(self) -> bool: + return self._executor_running + def write_logs(self, msg: str, level: str, raise_error: str = None): """ Write logs to log file and stdout / stderr queues if exist for GUI usage @@ -240,15 +260,14 @@ def executor( no_output_queues is needed since we don't want is_init output to be logged """ start_time = datetime.utcnow() - self._executor_finished = False additional_parameters = ( f" {self.additional_parameters.strip()} " if self.additional_parameters else "" ) _cmd = f'"{self._binary}" {additional_parameters}{cmd}{self.generic_arguments}' - if self.dry_run: - _cmd += " --dry-run" + + self._executor_running = True self.write_logs(f"Running command: [{_cmd}]", level="debug") self._make_env() @@ -271,7 +290,8 @@ def executor( # Don't keep protected environment variables in memory when not necessary self._remove_env() - self._executor_finished = True + # _executor_running = False is also set via on_exit function call + self._executor_running = False self.exec_time = (datetime.utcnow() - start_time).total_seconds if exit_code == 0: @@ -306,10 +326,6 @@ def executor( logger.error(output) return False, output - @property - def executor_finished(self) -> bool: - return self._executor_finished - def _get_binary(self) -> None: """ Make sure we find restic binary depending on platform @@ -462,6 +478,10 @@ def generic_arguments(self): ) if self.verbose: args += " -vv" + if self.dry_run: + args += " --dry-run" + if self.json_output: + args += " --json" return args def init( @@ -534,62 +554,105 @@ def wrapper(self, *args, **kwargs): return fn(self, *args, **kwargs) return wrapper + + def convert_to_json_output(self, result, output, **kwargs): + """ + Converts restic --json output to parseable json + + as of restic 0.16.2: + restic --list snapshots|index... --json returns brute strings, one per line ! + """ + operation = fn_name(1) + js = { + "result": result, + "operation": operation, + "args": kwargs, + "output": None + } + if result: + if output: + if isinstance(output, str): + output = list(filter(None, output.split("\n"))) + else: + output = str(output) + if len(output) > 1: + output_is_list = True + js["output"] = [] + else: + output_is_list = False + for line in output: + try: + if output_is_list: + js["output"].append(line) + else: + js["output"] = json.loads(line) + except json.decoder.JSONDecodeError: + self.write_logs(f"Returned data is not JSON parseable:\n{line}", level="error") + """ + print("L", line) + try: + # Specia handling for list which returns "raw lists" + if operation == 'list': + js["output"].append(line) + else: + sub_js = json.loads(line) + if isinstance(sub_js) + js["output"].append() + """ + else: + js["reason"] = output + return js @check_if_init - def list(self, subject: str) -> Optional[list]: + def list(self, subject: str) -> Union[bool, str, dict]: """ - Returns json list of snapshots + Returns list of snapshots + + restic won't really return json content, but rather lines of object without any formatting """ - cmd = "list {} --json".format(subject) + cmd = "list {}".format(subject) result, output = self.executor(cmd) + if self.json_output: + return self.convert_to_json_output(result, output, subject=subject) if result: - try: - return json.loads(output) - except json.decoder.JSONDecodeError: - self.write_logs(f"Returned data is not JSON:\n{output}", level="error") - logger.debug("Trace:", exc_info=True) - return None + return output + return False @check_if_init - def ls(self, snapshot: str) -> Optional[list]: + def ls(self, snapshot: str) -> Union[bool, str, dict]: """ - Returns json list of objects + Returns list of objects in a snapshot + + # When not using --json, we must remove first line since it will contain a heading string like: + # snapshot db125b40 of [C:\\GIT\\npbackup] filtered by [] at 2023-01-03 09:41:30.9104257 +0100 CET): + return output.split("\n", 2)[2] + + Using --json here does not return actual json content, but lines with each file being a json... + """ - cmd = "ls {} --json".format(snapshot) + cmd = "ls {}".format(snapshot) result, output = self.executor(cmd) - if result and output: - """ - # When not using --json, we must remove first line since it will contain a heading string like: - # snapshot db125b40 of [C:\\GIT\\npbackup] filtered by [] at 2023-01-03 09:41:30.9104257 +0100 CET): - return output.split("\n", 2)[2] - - Using --json here does not return actual json content, but lines with each file being a json... ! - """ - try: - for line in output.split("\n"): - if line: - yield json.loads(line) - except json.decoder.JSONDecodeError: - self.write_logs(f"Returned data is not JSON:\n{output}", level="error") - logger.debug("Trace:", exc_info=True) - return result + if self.json_output: + return self.convert_to_json_output(result, output, snapshot=snapshot) + if result: + return output + return False @check_if_init - def snapshots(self) -> Optional[list]: + def snapshots(self) -> Union[bool, str, dict]: """ - Returns json list of snapshots + Returns a list of snapshots + --json is directly parseable """ - cmd = "snapshots --json" + cmd = "snapshots" result, output = self.executor(cmd) + if self.json_output: + return self.convert_to_json_output(result, output) if result: - try: - return json.loads(output) - except json.decoder.JSONDecodeError: - self.write_logs(f"Returned data is not JSON:\n{output}", level="error") - logger.debug("Trace:", exc_info=True) - return False - return None + return result + return False + @check_if_init def backup( self, paths: List[str], @@ -603,13 +666,10 @@ def backup( tags: List[str] = [], one_file_system: bool = False, additional_backup_only_parameters: str = None, - ) -> Tuple[bool, str]: + ) -> Union[bool, str, dict]: """ Executes restic backup after interpreting all arguments """ - # TODO: replace with @check_if_init decorator - if not self.is_init: - return None, None # Handle various source types if source_type in [ @@ -690,31 +750,30 @@ def backup( "VSS cannot be used. Backup will be done without VSS.", level="error" ) result, output = self.executor(cmd.replace(" --use-fs-snapshot", "")) + if self.json_output: + return self.convert_to_json_output(result, output, paths=paths) if result: self.write_logs("Backend finished backup with success", level="info") - return True, output + return output self.write_logs("Backup failed backup operation", level="error") - return False, output + return False @check_if_init - def find(self, path: str) -> Optional[list]: + def find(self, path: str) -> Union[bool, str, dict]: """ Returns find command + --json produces a directly parseable format """ - cmd = 'find "{}" --json'.format(path) + cmd = f'find "{path}"' result, output = self.executor(cmd) + if self.json_output: + return self.convert_to_json_output(result, output, path=path) if result: - self.write_logs(f"Successfuly found {path}", level="info") - try: - return json.loads(output) - except json.decoder.JSONDecodeError: - self.write_logs(f"Returned data is not JSON:\n{output}", level="error") - logger.debug("Trace:", exc_info=True) - self.write_logs(f"Could not find path: {path}", level="error") - return None - + return output + return False + @check_if_init - def restore(self, snapshot: str, target: str, includes: List[str] = None): + def restore(self, snapshot: str, target: str, includes: List[str] = None) -> Union[bool, str, dict]: """ Restore given snapshot to directory """ @@ -728,6 +787,8 @@ def restore(self, snapshot: str, target: str, includes: List[str] = None): if include: cmd += ' --{}include "{}"'.format(case_ignore_param, include) result, output = self.executor(cmd) + if self.json_output: + return self.convert_to_json_output(result, output, snapshot=snapshot, target=target, includes=includes) if result: self.write_logs("successfully restored data.", level="info") return True @@ -739,7 +800,7 @@ def forget( self, snapshots: Optional[Union[List[str], Optional[str]]] = None, policy: Optional[dict] = None, - ) -> bool: + ) -> Union[bool, str, dict]: """ Execute forget command for given snapshot """ @@ -786,7 +847,7 @@ def forget( @check_if_init def prune( self, max_unused: Optional[str] = None, max_repack_size: Optional[int] = None - ) -> bool: + ) -> Union[bool, str, dict]: """ Prune forgotten snapshots """ @@ -799,6 +860,8 @@ def prune( self.verbose = True result, output = self.executor(cmd) self.verbose = verbose + if self.json_output: + return self.convert_to_json_output(result, output, max_unused=max_unused, max_repack_size=max_repack_size) if result: self.write_logs(f"Successfully pruned repository:\n{output}", level="info") return True @@ -812,6 +875,8 @@ def check(self, read_data: bool = True) -> bool: """ cmd = "check{}".format(" --read-data" if read_data else "") result, output = self.executor(cmd) + if self.json_output: + return self.convert_to_json_output(result, output, read_data=read_data) if result: self.write_logs("Repo checked successfully.", level="info") return True @@ -828,6 +893,8 @@ def repair(self, subject: str) -> bool: return False cmd = f"repair {subject}" result, output = self.executor(cmd) + if self.json_output: + return self.convert_to_json_output(result, output, subject=subject) if result: self.write_logs(f"Repo successfully repaired:\n{output}", level="info") return True @@ -841,8 +908,10 @@ def unlock(self) -> bool: """ cmd = f"unlock" result, output = self.executor(cmd) + if self.json_output: + return self.convert_to_json_output(result, output) if result: - self.write_logs(f"Repo successfully unlocked:\n{output}", level="info") + self.write_logs(f"Repo successfully unlocked", level="info") return True self.write_logs(f"Repo unlock failed:\n {output}", level="critical") return False @@ -853,6 +922,8 @@ def raw(self, command: str) -> Tuple[bool, str]: Execute plain restic command without any interpretation" """ result, output = self.executor(command) + if self.json_output: + return self.convert_to_json_output(result, output, command=command) if result: self.write_logs(f"successfully run raw command:\n{output}", level="info") return True, output @@ -904,14 +975,28 @@ def has_recent_snapshot(self, delta: int = None) -> Tuple[bool, Optional[datetim """ # Don't bother to deal with mising delta if not delta: + if self.json_output: + self.convert_to_json_output(False, None, delta=delta) return False, None try: - snapshots = self.snapshots() + # Make sure we run with json support for this one + json_output = self.json_output + self.json_output = True + result = self.snapshots() + self.json_output = json_output if self.last_command_status is False: - return None, None - return self._has_recent_snapshot(snapshots, delta) + if self.json_output: + return self.convert_to_json_output(False, None, delta=delta) + return False, None + snapshots = result["output"] + result, timestamp = self._has_recent_snapshot(snapshots, delta) + if self.json_output: + return self.convert_to_json_output(result, timestamp, delta=delta) + return result, timestamp except IndexError as exc: self.write_logs(f"snapshot information missing: {exc}", level="error") logger.debug("Trace", exc_info=True) # No 'time' attribute in snapshot ? + if self.json_output: + return self.convert_to_json_output(None, None, delta=delta) return None, None diff --git a/npbackup/runner_interface.py b/npbackup/runner_interface.py index ef09a66..8763b44 100644 --- a/npbackup/runner_interface.py +++ b/npbackup/runner_interface.py @@ -7,13 +7,14 @@ __author__ = "Orsiris de Jong" __site__ = "https://www.netperfect.fr/npbackup" __description__ = "NetPerfect Backup Client" -__copyright__ = "Copyright (C) 2022-2023 NetInvent" +__copyright__ = "Copyright (C) 2022-2024 NetInvent" __license__ = "GPL-3.0-only" -__build__ = "2023122801" +__build__ = "2024010201" -import os +import sys from logging import getLogger +import json from npbackup.core.runner import NPBackupRunner @@ -25,12 +26,16 @@ def entrypoint(*args, **kwargs): npbackup_runner.repo_config = kwargs.pop("repo_config") npbackup_runner.dry_run = kwargs.pop("dry_run") npbackup_runner.verbose = kwargs.pop("verbose") + json_output = kwargs.pop("json_output") + npbackup_runner.json_output = json_output result = npbackup_runner.__getattribute__(kwargs.pop("operation"))( **kwargs.pop("op_args"), __no_threads=True ) - print(result) - logger.debug(result) - + if not json_output: + logger.info(f"Operation finished with {result}") + else: + print(json.dumps(result)) + sys.exit(0) def auto_upgrade(full_config: dict): pass From 39491e41e4d87d5c7dc9b5f1faf735a4dd727b18 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Tue, 2 Jan 2024 19:38:58 +0100 Subject: [PATCH 154/328] Add critical worst log --- npbackup/common.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/npbackup/common.py b/npbackup/common.py index f5c35a8..90c9ecc 100644 --- a/npbackup/common.py +++ b/npbackup/common.py @@ -44,7 +44,9 @@ def execution_logs(start_time: datetime) -> None: log_level_reached = "success" try: - if logger_worst_level >= 40: + if logger_worst_level >= 50: + log_level_reached = "critical" + elif logger_worst_level >= 40: log_level_reached = "errors" elif logger_worst_level >= 30: log_level_reached = "warnings" From 537402f65ef0a620ba19985530f11156d49e0c49 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Tue, 2 Jan 2024 22:42:07 +0100 Subject: [PATCH 155/328] Typo fix --- tests/test_restic_metrics.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_restic_metrics.py b/tests/test_restic_metrics.py index 5a8ec58..6039327 100644 --- a/tests/test_restic_metrics.py +++ b/tests/test_restic_metrics.py @@ -44,8 +44,8 @@ Added to the repository: 4.425 MiB (1.431 MiB stored) processed 6073 files, 116.657 MiB in 0:03 -snapshot b28b0901 save -d""" +snapshot b28b0901 saved +""" # log file from restic v0.14.0 restic_str_outputs["v0.14.0"] = \ From 4f7f09a36cf646849bfac520967af76b4a5b4333 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Tue, 2 Jan 2024 22:42:10 +0100 Subject: [PATCH 156/328] Create RESTIC_CMDLINE_CONSISTENCY.md --- RESTIC_CMDLINE_CONSISTENCY.md | 57 +++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 RESTIC_CMDLINE_CONSISTENCY.md diff --git a/RESTIC_CMDLINE_CONSISTENCY.md b/RESTIC_CMDLINE_CONSISTENCY.md new file mode 100644 index 0000000..46e1cf6 --- /dev/null +++ b/RESTIC_CMDLINE_CONSISTENCY.md @@ -0,0 +1,57 @@ +## List of various restic problems encountered while developping NPBackup + +As of 2024/01/02, version 0.16.2: + +### json inconsistencies + +- `restic check --json` does not produce json output, probably single str on error +- `restic unlock --json` does not produce any output, probably single str on error +- `restic repair index|snapshots` does not produce json output +``` +snapshot 00ecc4e3 of [c:\git\npbackup] at 2024-01-02 19:15:35.3779691 +0100 CET) + +snapshot 1066f045 of [c:\git\npbackup] at 2023-12-28 13:46:41.3639521 +0100 CET) + +no snapshots were modified +``` +- `restic forget ` does not produce any output, and produces str output on error. Example on error: +``` +Ignoring "ff20970b": no matching ID found for prefix "ff20970b" +``` +- `restic list index|blobs|snapshots --json` produce one result per line output, not json, example for blobs: +``` +tree 0d2eef6a1b06aa0650a08a82058d57a42bf515a4c84bf4f899e391a4b9906197 +tree 9e61b5966a936e2e8b4ef4198b86ad59000c5cba3fc6250ece97cb13621b3cd1 +tree 1fe90879bd35d90cd4fde440e64bfc16b331297cbddb776a43eb3fdf94875540 +``` +- `restic snapshots --json` is the only verb that produces valid json +- `restic backup --json` produces one per line valid json, makes sense + + +### backup results inconsistency + +When using `restic backup`, we get different results depending on if we're using `--json`or not: + +- "data_blobs": Not present in string output +- "tree_blobs": Not present in string output +- "data_added": Present in both outputs, is "4.425" in `Added to the repository: 4.425 MiB (1.431 MiB stored)` +- "data_stored": Not present in json output, is "1.431" in `Added to the repository: 4.425 MiB (1.431 MiB stored)` + +`restic backup` results +``` +repository 962d5924 opened (version 2, compression level auto) +using parent snapshot 325a2fa1 +[0:00] 100.00% 4 / 4 index files loaded + +Files: 216 new, 21 changed, 5836 unmodified +Dirs: 29 new, 47 changed, 817 unmodified +Added to the repository: 4.425 MiB (1.431 MiB stored) + +processed 6073 files, 116.657 MiB in 0:03 +snapshot b28b0901 saved +``` + +`restic backup --json` results +``` +{"message_type":"summary","files_new":5,"files_changed":15,"files_unmodified":6058,"dirs_new":0,"dirs_changed":27,"dirs_unmodified":866,"data_blobs":17,"tree_blobs":28,"data_added":281097,"total_files_processed":6078,"total_bytes_processed":122342158,"total_duration":1.2836983,"snapshot_id":"360333437921660a5228a9c1b65a2d97381f0bc135499c6e851acb0ab84b0b0a"} +``` \ No newline at end of file From 4ade66ee7f66f8c98ec0da0b6cefacbbc1654609 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Tue, 2 Jan 2024 22:42:27 +0100 Subject: [PATCH 157/328] No need for default timeout in operations GUI --- npbackup/gui/operations.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/npbackup/gui/operations.py b/npbackup/gui/operations.py index a2f878d..498fce2 100644 --- a/npbackup/gui/operations.py +++ b/npbackup/gui/operations.py @@ -167,7 +167,7 @@ def operations_gui(full_config: dict) -> dict: window["repo-list"].expand(True, True) while True: - event, values = window.read(timeout=60000) + event, values = window.read() if event in (sg.WIN_CLOSED, sg.WIN_X_EVENT, "--EXIT--"): break From 48efc135dbb10b3a0ac0d3ec99cd411688626fee Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Tue, 2 Jan 2024 22:45:09 +0100 Subject: [PATCH 158/328] WIP: Make both --json and str outputs work properly --- npbackup/__main__.py | 34 +++-- npbackup/core/runner.py | 35 +++-- npbackup/restic_wrapper/__init__.py | 203 ++++++++++++++++------------ 3 files changed, 169 insertions(+), 103 deletions(-) diff --git a/npbackup/__main__.py b/npbackup/__main__.py index d7d84a7..967ce00 100644 --- a/npbackup/__main__.py +++ b/npbackup/__main__.py @@ -13,6 +13,7 @@ from argparse import ArgumentParser from datetime import datetime import logging +import json import ofunctions.logger_utils from ofunctions.process import kill_childs from npbackup.path_helper import CURRENT_DIR @@ -38,11 +39,24 @@ pass +_JSON = False LOG_FILE = os.path.join(CURRENT_DIR, "{}.log".format(__intname__)) logger = logging.getLogger() +def json_error_logging(result: bool, msg: str, level: str): + if _JSON: + js = { + "result": result, + "reason": msg + } + print(json.dumps(js)) + logger.__getattribute__(level)(msg) + + def cli_interface(): + global _JSON + parser = ArgumentParser( prog=f"{__intname__}", description="""Portable Network Backup Client\n @@ -193,6 +207,7 @@ def cli_interface(): args = parser.parse_args() if args.json: + _JSON = True logger = ofunctions.logger_utils.logger_get_logger( LOG_FILE, console=False, debug=_DEBUG ) @@ -220,7 +235,8 @@ def cli_interface(): if args.config_file: if not os.path.isfile(args.config_file): - logger.critical(f"Config file {args.config_file} cannot be read.") + msg = f"Config file {args.config_file} cannot be read." + json_error_logging(False, msg, "critical") sys.exit(70) CONFIG_FILE = args.config_file else: @@ -228,7 +244,8 @@ def cli_interface(): if config_file.exists: CONFIG_FILE = config_file else: - logger.critical("Cannot run without configuration file.") + msg = "Cannot run without configuration file." + json_error_logging(False, msg, "critical") sys.exit(70) full_config = npbackup.configuration.load_config(CONFIG_FILE) @@ -237,12 +254,13 @@ def cli_interface(): full_config, args.repo_name ) else: - logger.critical("Cannot obtain repo config") + msg = "Cannot obtain repo config" + json_error_logging(False, msg, "critical") sys.exit(71) if not repo_config: - message = _t("config_gui.no_config_available") - logger.critical(message) + msg = "Cannot find repo config" + json_error_logging(False, msg, "critical") sys.exit(72) # Prepare program run @@ -310,7 +328,7 @@ def cli_interface(): if cli_args["operation"]: entrypoint(**cli_args) else: - logger.warning("No operation has been requested") + json_error_logging(False, "No operation has been requested", level="warning") def main(): @@ -325,12 +343,12 @@ def main(): cli_interface() sys.exit(logger.get_worst_logger_level()) except KeyboardInterrupt as exc: - logger.error("Program interrupted by keyboard. {}".format(exc)) + json_error_logging(False, f"Program interrupted by keyboard: {exc}", level="error") logger.info("Trace:", exc_info=True) # EXIT_CODE 200 = keyboard interrupt sys.exit(200) except Exception as exc: - logger.error("Program interrupted by error. {}".format(exc)) + json_error_logging(False, f"Program interrupted by error: {exc}", level="error") logger.info("Trace:", exc_info=True) # EXIT_CODE 201 = Non handled exception sys.exit(201) diff --git a/npbackup/core/runner.py b/npbackup/core/runner.py index 4d9c919..0bd5a68 100644 --- a/npbackup/core/runner.py +++ b/npbackup/core/runner.py @@ -668,7 +668,19 @@ def _apply_config_to_restic_runner(self) -> bool: self.restic_runner.stderr = self.stderr return True - + + def convert_to_json_output(self, result: bool, output: str = None): + if self.json_output: + js = { + "result": result, + } + if result: + js["output"] = output + else: + js["reason"] = output + return js + return result + ########################### # ACTUAL RUNNER FUNCTIONS # ########################### @@ -719,7 +731,6 @@ def list(self, subject: str) -> Optional[dict]: @is_ready @apply_config_to_restic_runner @catch_exceptions - # TODO: add json output def find(self, path: str) -> bool: self.write_logs( f"Searching for path {path} in repo {self.repo_config.g('name')}", @@ -728,8 +739,7 @@ def find(self, path: str) -> bool: result = self.restic_runner.find(path=path) if result: self.write_logs(f"Found path in:\n{result}", level="info") - return result - return False + return self.convert_to_json_output(result, None) @threaded @close_queues @@ -770,10 +780,18 @@ def has_recent_snapshot(self) -> bool: ) # Temporarily disable verbose and enable json result self.restic_runner.verbose = False - result, backup_tz = self.restic_runner.has_recent_snapshot( + json_output = self.restic_runner.json_output + self.restic_runner.json_output = True + data = self.restic_runner.has_recent_snapshot( self.minimum_backup_age ) self.restic_runner.verbose = self.verbose + self.restic_runner.json_output = json_output + if self.json_output: + return data + + result = data["result"] + backup_tz = data["output"] if result: self.write_logs( f"Most recent backup in repo {self.repo_config.g('name')} is from {backup_tz}", @@ -1068,8 +1086,9 @@ def forget( policy["keep-tags"] = keep_tags # Fool proof, don't run without policy, or else we'll get if not policy: - self.write_logs(f"Empty retention policy. Won't run", level="error") - return False + msg = f"Empty retention policy. Won't run" + self.write_logs(msg, level="error") + return self.convert_to_json_output(False, msg) self.write_logs( f"Forgetting snapshots using retention policy: {policy}", level="info" ) @@ -1080,7 +1099,7 @@ def forget( level="critical", raise_error=True, ) - return result + return self.convert_to_json_output(result) @threaded @close_queues diff --git a/npbackup/restic_wrapper/__init__.py b/npbackup/restic_wrapper/__init__.py index 7dd6f2b..5459da3 100644 --- a/npbackup/restic_wrapper/__init__.py +++ b/npbackup/restic_wrapper/__init__.py @@ -555,53 +555,59 @@ def wrapper(self, *args, **kwargs): return wrapper - def convert_to_json_output(self, result, output, **kwargs): + def convert_to_json_output(self, result, output, msg=None, **kwargs): """ + result, output = command_runner results + msg will be logged and used as reason on failure + Converts restic --json output to parseable json as of restic 0.16.2: restic --list snapshots|index... --json returns brute strings, one per line ! """ - operation = fn_name(1) - js = { - "result": result, - "operation": operation, - "args": kwargs, - "output": None - } - if result: - if output: - if isinstance(output, str): - output = list(filter(None, output.split("\n"))) - else: - output = str(output) - if len(output) > 1: - output_is_list = True - js["output"] = [] - else: - output_is_list = False - for line in output: - try: + if self.json_output: + operation = fn_name(1) + js = { + "result": result, + "operation": operation, + "args": kwargs, + "output": None + } + if result: + if output: + if isinstance(output, str): + output = list(filter(None, output.split("\n"))) + else: + output = [str(output)] + if len(output) > 1: + output_is_list = True + js["output"] = [] + else: + output_is_list = False + for line in output: if output_is_list: js["output"].append(line) else: - js["output"] = json.loads(line) - except json.decoder.JSONDecodeError: - self.write_logs(f"Returned data is not JSON parseable:\n{line}", level="error") - """ - print("L", line) - try: - # Specia handling for list which returns "raw lists" - if operation == 'list': - js["output"].append(line) - else: - sub_js = json.loads(line) - if isinstance(sub_js) - js["output"].append() - """ - else: - js["reason"] = output - return js + try: + js["output"] = json.loads(line) + except json.decoder.JSONDecodeError: + js["output"] = {'data': line} + if msg: + self.write_logs(msg, level="info") + else: + if msg: + js["reason"] = msg + self.write_logs(msg, level="error") + else: + js["reason"] = output + return js + + if result: + self.write_logs(msg, level="info") + return output + self.write_logs(msg, level="error") + return False + @check_if_init def list(self, subject: str) -> Union[bool, str, dict]: @@ -610,13 +616,13 @@ def list(self, subject: str) -> Union[bool, str, dict]: restic won't really return json content, but rather lines of object without any formatting """ + kwargs = locals() + kwargs.pop("self") + cmd = "list {}".format(subject) result, output = self.executor(cmd) - if self.json_output: - return self.convert_to_json_output(result, output, subject=subject) - if result: - return output - return False + return self.convert_to_json_output(result, output, **kwargs) + @check_if_init def ls(self, snapshot: str) -> Union[bool, str, dict]: @@ -630,13 +636,13 @@ def ls(self, snapshot: str) -> Union[bool, str, dict]: Using --json here does not return actual json content, but lines with each file being a json... """ + kwargs = locals() + kwargs.pop("self") + cmd = "ls {}".format(snapshot) result, output = self.executor(cmd) - if self.json_output: - return self.convert_to_json_output(result, output, snapshot=snapshot) - if result: - return output - return False + return self.convert_to_json_output(result, output, **kwargs) + @check_if_init def snapshots(self) -> Union[bool, str, dict]: @@ -644,13 +650,12 @@ def snapshots(self) -> Union[bool, str, dict]: Returns a list of snapshots --json is directly parseable """ + kwargs = locals() + kwargs.pop("self") + cmd = "snapshots" result, output = self.executor(cmd) - if self.json_output: - return self.convert_to_json_output(result, output) - if result: - return result - return False + return self.convert_to_json_output(result, output, **kwargs) @check_if_init def backup( @@ -670,6 +675,8 @@ def backup( """ Executes restic backup after interpreting all arguments """ + kwargs = locals() + kwargs.pop("self") # Handle various source types if source_type in [ @@ -751,7 +758,7 @@ def backup( ) result, output = self.executor(cmd.replace(" --use-fs-snapshot", "")) if self.json_output: - return self.convert_to_json_output(result, output, paths=paths) + return self.convert_to_json_output(result, output, **kwargs) if result: self.write_logs("Backend finished backup with success", level="info") return output @@ -764,19 +771,22 @@ def find(self, path: str) -> Union[bool, str, dict]: Returns find command --json produces a directly parseable format """ + kwargs = locals() + kwargs.pop("self") + cmd = f'find "{path}"' result, output = self.executor(cmd) - if self.json_output: - return self.convert_to_json_output(result, output, path=path) - if result: - return output - return False + return self.convert_to_json_output(result, output, **kwargs) + @check_if_init def restore(self, snapshot: str, target: str, includes: List[str] = None) -> Union[bool, str, dict]: """ Restore given snapshot to directory """ + kwargs = locals() + kwargs.pop("self") + case_ignore_param = "" # Always use case ignore excludes under windows if os.name == "nt": @@ -788,7 +798,7 @@ def restore(self, snapshot: str, target: str, includes: List[str] = None) -> Uni cmd += ' --{}include "{}"'.format(case_ignore_param, include) result, output = self.executor(cmd) if self.json_output: - return self.convert_to_json_output(result, output, snapshot=snapshot, target=target, includes=includes) + return self.convert_to_json_output(result, output, **kwargs) if result: self.write_logs("successfully restored data.", level="info") return True @@ -804,6 +814,9 @@ def forget( """ Execute forget command for given snapshot """ + kwargs = locals() + kwargs.pop("self") + if not snapshots and not policy: self.write_logs( "No valid snapshot or policy defined for pruning", level="error" @@ -833,6 +846,7 @@ def forget( verbose = self.verbose self.verbose = True batch_result = True + batch_output = "" if cmds: for cmd in cmds: result, output = self.executor(cmd) @@ -841,8 +855,9 @@ def forget( else: self.write_logs(f"Forget failed\n{output}", level="error") batch_result = False + batch_output += f"\n{output}" self.verbose = verbose - return batch_result + return self.convert_to_json_output(batch_result, batch_output, **kwargs) @check_if_init def prune( @@ -851,6 +866,9 @@ def prune( """ Prune forgotten snapshots """ + kwargs = locals() + kwargs.pop("self") + cmd = "prune" if max_unused: cmd += f"--max-unused {max_unused}" @@ -860,41 +878,45 @@ def prune( self.verbose = True result, output = self.executor(cmd) self.verbose = verbose - if self.json_output: - return self.convert_to_json_output(result, output, max_unused=max_unused, max_repack_size=max_repack_size) if result: - self.write_logs(f"Successfully pruned repository:\n{output}", level="info") - return True - self.write_logs(f"Could not prune repository:\n{output}", level="error") - return False + msg = "Successfully pruned repository" + else: + msg = "Could not prune repository" + return self.convert_to_json_output(result, output=output, msg=msg, **kwargs) + @check_if_init - def check(self, read_data: bool = True) -> bool: + def check(self, read_data: bool = True) -> Union[bool, str, dict]: """ Check current repo status """ + kwargs = locals() + kwargs.pop("self") + cmd = "check{}".format(" --read-data" if read_data else "") result, output = self.executor(cmd) - if self.json_output: - return self.convert_to_json_output(result, output, read_data=read_data) if result: - self.write_logs("Repo checked successfully.", level="info") - return True - self.write_logs(f"Repo check failed:\n {output}", level="critical") - return False + msg = "Repo checked successfully." + else: + msg = "Repo check failed" + return self.convert_to_json_output(result, output, msg=msg, **kwargs) + @check_if_init - def repair(self, subject: str) -> bool: + def repair(self, subject: str) -> Union[bool, str, dict]: """ Check current repo status """ + kwargs = locals() + kwargs.pop("self") + if subject not in ["index", "snapshots"]: self.write_logs(f"Bogus repair order given: {subject}", level="error") return False cmd = f"repair {subject}" result, output = self.executor(cmd) if self.json_output: - return self.convert_to_json_output(result, output, subject=subject) + return self.convert_to_json_output(result, output, **kwargs) if result: self.write_logs(f"Repo successfully repaired:\n{output}", level="info") return True @@ -902,14 +924,17 @@ def repair(self, subject: str) -> bool: return False @check_if_init - def unlock(self) -> bool: + def unlock(self) -> Union[bool, str, dict]: """ Remove stale locks from repos """ + kwargs = locals() + kwargs.pop("self") + cmd = f"unlock" result, output = self.executor(cmd) if self.json_output: - return self.convert_to_json_output(result, output) + return self.convert_to_json_output(result, output, **kwargs) if result: self.write_logs(f"Repo successfully unlocked", level="info") return True @@ -917,13 +942,16 @@ def unlock(self) -> bool: return False @check_if_init - def raw(self, command: str) -> Tuple[bool, str]: + def raw(self, command: str) -> Union[bool, str, dict]: """ Execute plain restic command without any interpretation" """ + kwargs = locals() + kwargs.pop("self") + result, output = self.executor(command) if self.json_output: - return self.convert_to_json_output(result, output, command=command) + return self.convert_to_json_output(result, output, **kwargs) if result: self.write_logs(f"successfully run raw command:\n{output}", level="info") return True, output @@ -973,30 +1001,31 @@ def has_recent_snapshot(self, delta: int = None) -> Tuple[bool, Optional[datetim returns False, datetime = 0001-01-01T00:00:00 if no snapshots found Returns None, None on error """ + kwargs = locals() + kwargs.pop("self") + # Don't bother to deal with mising delta if not delta: if self.json_output: - self.convert_to_json_output(False, None, delta=delta) + self.convert_to_json_output(False, None, **kwargs) return False, None try: # Make sure we run with json support for this one - json_output = self.json_output - self.json_output = True + result = self.snapshots() - self.json_output = json_output if self.last_command_status is False: if self.json_output: - return self.convert_to_json_output(False, None, delta=delta) + return self.convert_to_json_output(False, None, **kwargs) return False, None snapshots = result["output"] result, timestamp = self._has_recent_snapshot(snapshots, delta) if self.json_output: - return self.convert_to_json_output(result, timestamp, delta=delta) + return self.convert_to_json_output(result, timestamp, **kwargs) return result, timestamp except IndexError as exc: self.write_logs(f"snapshot information missing: {exc}", level="error") logger.debug("Trace", exc_info=True) # No 'time' attribute in snapshot ? if self.json_output: - return self.convert_to_json_output(None, None, delta=delta) + return self.convert_to_json_output(None, None, **kwargs) return None, None From 584f595c87e580ae4e1fae7850a9faee6758bc8f Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Wed, 3 Jan 2024 14:46:02 +0100 Subject: [PATCH 159/328] Implement dump and stats commands --- npbackup/__main__.py | 18 ++++++++++ npbackup/core/runner.py | 28 +++++++++++++++ npbackup/gui/operations.py | 12 +++++++ npbackup/restic_wrapper/__init__.py | 38 +++++++++++++++++++-- npbackup/translations/operations_gui.en.yml | 1 + npbackup/translations/operations_gui.fr.yml | 1 + 6 files changed, 96 insertions(+), 2 deletions(-) diff --git a/npbackup/__main__.py b/npbackup/__main__.py index 967ce00..22a5c1a 100644 --- a/npbackup/__main__.py +++ b/npbackup/__main__.py @@ -149,6 +149,19 @@ def cli_interface(): required=False, help="Show [blobs|packs|index|snapshots|keys|locks] objects", ) + parser.add_argument( + "--dump", + type=str, + default=None, + required=False, + help="Dump a specific file to stdout" + ) + parser.add_argument( + "--stats", + action="store_true", + help="Get repository statistics" + + ) parser.add_argument( "--raw", type=str, @@ -319,6 +332,11 @@ def cli_interface(): elif args.repair_snapshots: cli_args["operation"] = "repair" cli_args["op_args"] = {"subject": "snapshots"} + elif args.dump: + cli_args["operation"] = "dump" + cli_args["op_args"] = {"path": args.dump} + elif args.stats: + cli_args["operation"] = "stats" elif args.raw: cli_args["operation"] = "raw" cli_args["op_args"] = {"command": args.raw} diff --git a/npbackup/core/runner.py b/npbackup/core/runner.py index 0bd5a68..059259f 100644 --- a/npbackup/core/runner.py +++ b/npbackup/core/runner.py @@ -347,9 +347,11 @@ def wrapper(self, *args, **kwargs): "backup": ["backup", "restore", "full"], "has_recent_snapshot": ["backup", "restore", "full"], "snapshots": ["backup", "restore", "full"], + "stats": ["backup", "restore", "full"], "ls": ["backup", "restore", "full"], "find": ["backup", "restore", "full"], "restore": ["restore", "full"], + "dump": ["restore", "full"], "check": ["restore", "full"], "list": ["full"], "unlock": ["full"], @@ -1171,6 +1173,32 @@ def unlock(self) -> bool: result = self.restic_runner.unlock() return result + @threaded + @close_queues + @exec_timer + @check_concurrency + @has_permission + @is_ready + @apply_config_to_restic_runner + @catch_exceptions + def dump(self, path: str) -> bool: + self.write_logs(f"Dumping {path} from {self.repo_config.g('name')}", level="info") + result = self.restic_runner.dump(path) + return result + + @threaded + @close_queues + @exec_timer + @check_concurrency + @has_permission + @is_ready + @apply_config_to_restic_runner + @catch_exceptions + def stats(self) -> bool: + self.write_logs(f"Getting stats of repo {self.repo_config.g('name')}", level="info") + result = self.restic_runner.stats() + return result + @threaded @close_queues @exec_timer diff --git a/npbackup/gui/operations.py b/npbackup/gui/operations.py index 498fce2..9a05dc0 100644 --- a/npbackup/gui/operations.py +++ b/npbackup/gui/operations.py @@ -139,6 +139,13 @@ def operations_gui(full_config: dict) -> dict: size=(45, 1), ), ], + [ + sg.Button( + _t("operations_gui.stats"), + key="--STATS--", + size=(45, 1), + ) + ], [sg.Button(_t("generic.quit"), key="--EXIT--")], ], element_justification="C", @@ -180,6 +187,7 @@ def operations_gui(full_config: dict) -> dict: "--FORGET--", "--STANDARD-PRUNE--", "--MAX-PRUNE--", + "--STATS--" ): if not values["repo-list"]: result = sg.popup( @@ -232,6 +240,10 @@ def operations_gui(full_config: dict) -> dict: operation = "prune" op_args = {"max": True} gui_msg = _t("operations_gui.max_prune") + if event == "--STATS--": + operation = "stats" + op_args = {} + gui_msg = _t("operations_gui.stats") result = gui_thread_runner( None, "group_runner", diff --git a/npbackup/restic_wrapper/__init__.py b/npbackup/restic_wrapper/__init__.py index 5459da3..f88d24e 100644 --- a/npbackup/restic_wrapper/__init__.py +++ b/npbackup/restic_wrapper/__init__.py @@ -603,9 +603,11 @@ def convert_to_json_output(self, result, output, msg=None, **kwargs): return js if result: - self.write_logs(msg, level="info") + if msg: + self.write_logs(msg, level="info") return output - self.write_logs(msg, level="error") + if msg: + self.write_logs(msg, level="error") return False @@ -940,6 +942,38 @@ def unlock(self) -> Union[bool, str, dict]: return True self.write_logs(f"Repo unlock failed:\n {output}", level="critical") return False + + @check_if_init + def dump(self, path: str) -> Union[bool, str, dict]: + """ + Dump given file directly to stdout + """ + kwargs = locals() + kwargs.pop("self") + + cmd = f'dump "{path}"' + result, output = self.executor(cmd) + if result: + msg = f"File {path} successfully dumped" + else: + msg = f"Cannot dump file {path}:\n {output}" + return self.convert_to_json_output(result, output, msg=msg, **kwargs) + + @check_if_init + def stats(self) -> Union[bool, str, dict]: + """ + Gives various repository statistics + """ + kwargs = locals() + kwargs.pop("self") + + cmd = f"stats" + result, output = self.executor(cmd) + if result: + msg = f"Repo statistics command success" + else: + msg = f"Cannot get repo statistics:\n {output}" + return self.convert_to_json_output(result, output, msg=msg, **kwargs) @check_if_init def raw(self, command: str) -> Union[bool, str, dict]: diff --git a/npbackup/translations/operations_gui.en.yml b/npbackup/translations/operations_gui.en.yml index dfdbb92..efbb266 100644 --- a/npbackup/translations/operations_gui.en.yml +++ b/npbackup/translations/operations_gui.en.yml @@ -8,6 +8,7 @@ en: forget_using_retention_policy: Forget using retention polic standard_prune: Normal prune data max_prune: Prune with maximum efficiency + stats: Repo statistics apply_to_all: Apply to all repos ? add_repo: Add repo edit_repo: Edit repo diff --git a/npbackup/translations/operations_gui.fr.yml b/npbackup/translations/operations_gui.fr.yml index 997be4b..7b5e3db 100644 --- a/npbackup/translations/operations_gui.fr.yml +++ b/npbackup/translations/operations_gui.fr.yml @@ -8,6 +8,7 @@ fr: forget_using_retention_policy: Oublier les instantanés en utilisant la stratégie de rétention standard_prune: Opération de purge normale max_prune: Opération de purge la plus efficace + stats: Statistiques de dépot apply_to_all: Appliquer à tous les dépots ? add_repo: Ajouter dépot edit_repo: Modifier dépot From d01b5f7b5a312bf0d57861577382838e77c4bc22 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Wed, 3 Jan 2024 15:19:10 +0100 Subject: [PATCH 160/328] WIP: Implement operations center group selector --- npbackup/configuration.py | 9 ++++++ npbackup/core/runner.py | 3 +- npbackup/gui/operations.py | 57 ++++++++++++++++++++++++++++---------- 3 files changed, 53 insertions(+), 16 deletions(-) diff --git a/npbackup/configuration.py b/npbackup/configuration.py index f55c822..8f7fc8a 100644 --- a/npbackup/configuration.py +++ b/npbackup/configuration.py @@ -627,3 +627,12 @@ def get_group_list(full_config: dict) -> List[str]: if full_config: return list(full_config.g("groups").keys()) return [] + + +def get_repos_by_group(full_config: dict, group: str) -> List[str]: + repo_list = [] + if full_config: + for repo in list(full_config.g("repos").keys()): + if full_config.g(f"repos.{repo}.repo_group") == group: + repo_list.append(repo) + return repo_list \ No newline at end of file diff --git a/npbackup/core/runner.py b/npbackup/core/runner.py index 059259f..216a2dd 100644 --- a/npbackup/core/runner.py +++ b/npbackup/core/runner.py @@ -1230,7 +1230,8 @@ def group_runner(self, repo_config_list: List, operation: str, **kwargs) -> bool }, } - for repo_name, repo_config in repo_config_list: + for repo_config in repo_config_list: + repo_name = repo_config.g("name") self.write_logs(f"Running {operation} for repo {repo_name}", level="info") self.repo_config = repo_config result = self.__getattribute__(operation)(**kwargs) diff --git a/npbackup/gui/operations.py b/npbackup/gui/operations.py index 9a05dc0..489e267 100644 --- a/npbackup/gui/operations.py +++ b/npbackup/gui/operations.py @@ -5,9 +5,9 @@ __intname__ = "npbackup.gui.operations" __author__ = "Orsiris de Jong" -__copyright__ = "Copyright (C) 2023 NetInvent" +__copyright__ = "Copyright (C) 2023-2024 NetInvent" __license__ = "GPL-3.0-only" -__build__ = "2023121901" +__build__ = "2024010301" from logging import getLogger @@ -18,16 +18,6 @@ from npbackup.customization import ( OEM_STRING, OEM_LOGO, - BG_COLOR_LDR, - TXT_COLOR_LDR, - GUI_STATE_OK_BUTTON, - GUI_STATE_OLD_BUTTON, - GUI_STATE_UNKNOWN_BUTTON, - LOADER_ANIMATION, - FOLDER_ICON, - FILE_ICON, - LICENSE_TEXT, - LICENSE_FILE, ) @@ -44,7 +34,8 @@ def gui_update_state(window, full_config: dict) -> list: or repo_config.g(f"repo_opts.repo_password_command") ): backend_type, repo_uri = get_anon_repo_uri(repo_config.g(f"repo_uri")) - repo_list.append([repo_name, backend_type, repo_uri]) + repo_group = repo_config.g("repo_group") + repo_list.append([repo_name, repo_group, backend_type, repo_uri]) else: logger.warning("Incomplete operations repo {}".format(repo_name)) except KeyError: @@ -58,9 +49,42 @@ def operations_gui(full_config: dict) -> dict: Operate on one or multiple repositories """ + def _select_groups(): + selector_layout = [ + [ + sg.Table( + values=configuration.get_group_list(full_config), + headings=["Group Name"], + key="-GROUP_LIST-", + auto_size_columns=True, + justification="left", + ) + ], + [ + sg.Push(), + sg.Button(_t("generic.cancel"), key="--CANCEL--"), + sg.Button(_t("operations_gui.apply_to_selected_groups"), key="--SELECTED_GROUPS--"), + sg.Button(_t("operations_gui.apply_to_all"), key="--APPLY-TO-ALL--") + ] + ] + + select_group_window = sg.Window("Group", selector_layout) + while True: + event, values = select_group_window.read() + if event in (sg.WIN_CLOSED, sg.WIN_X_EVENT, "--CANCEL--"): + break + if event == "--SELECTED_GROUPS--": + if not values["-GROUP_LIST-"]: + sg.Popup("No groups selected") + continue + if event == "--APPLY-TO-ALL": + continue + select_group_window.close() + # This is a stupid hack to make sure uri column is large enough headings = [ "Name ", + "Group ", "Backend", "URI ", ] @@ -190,12 +214,15 @@ def operations_gui(full_config: dict) -> dict: "--STATS--" ): if not values["repo-list"]: + """ result = sg.popup( _t("operations_gui.apply_to_all"), custom_text=(_t("generic.yes"), _t("generic.no")), ) if not result == _t("generic.yes"): continue + """ + repos = _select_groups() # TODO #WIP repos = complete_repo_list else: repos = [] @@ -203,11 +230,11 @@ def operations_gui(full_config: dict) -> dict: repos.append(complete_repo_list[value]) repo_config_list = [] - for repo_name, backend_type, repo_uri in repos: + for repo_name, repo_group, backend_type, repo_uri in repos: repo_config, config_inheritance = configuration.get_repo_config( full_config, repo_name ) - repo_config_list.append((repo_name, repo_config)) + repo_config_list.append(repo_config) if event == "--FORGET--": operation = "forget" op_args = {"use_policy": True} From ae578d7ac2d1b085612acaea1775cc052d1828ea Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Wed, 3 Jan 2024 15:20:11 +0100 Subject: [PATCH 161/328] Typo fix --- npbackup/gui/__main__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/npbackup/gui/__main__.py b/npbackup/gui/__main__.py index 064ee35..5d1f3d9 100644 --- a/npbackup/gui/__main__.py +++ b/npbackup/gui/__main__.py @@ -522,7 +522,7 @@ def get_gui_data(repo_config: dict) -> Tuple[bool, List[str]]: "%Y-%m-%d %H:%M:%S" ) else: - snapshot_date = "Unparsable" + snapshot_date = "Unparseable" snapshot_username = snapshot["username"] snapshot_hostname = snapshot["hostname"] snapshot_id = snapshot["short_id"] From 270f10abc0e961470f94dbb912edc64d449aeb09 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Wed, 3 Jan 2024 15:20:23 +0100 Subject: [PATCH 162/328] Add more restic specific data --- RESTIC_CMDLINE_CONSISTENCY.md | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/RESTIC_CMDLINE_CONSISTENCY.md b/RESTIC_CMDLINE_CONSISTENCY.md index 46e1cf6..7136e49 100644 --- a/RESTIC_CMDLINE_CONSISTENCY.md +++ b/RESTIC_CMDLINE_CONSISTENCY.md @@ -6,7 +6,16 @@ As of 2024/01/02, version 0.16.2: - `restic check --json` does not produce json output, probably single str on error - `restic unlock --json` does not produce any output, probably single str on error -- `restic repair index|snapshots` does not produce json output +- `restic repair index --json` does not produce json output +``` +loading indexes... +getting pack files to read... +rebuilding index +[0:00] 100.00% 28 / 28 packs processed +deleting obsolete index files +done +``` +- `restic repair snapshots --json` does not produce json output ``` snapshot 00ecc4e3 of [c:\git\npbackup] at 2024-01-02 19:15:35.3779691 +0100 CET) @@ -14,7 +23,7 @@ snapshot 1066f045 of [c:\git\npbackup] at 2023-12-28 13:46:41.3639521 +0100 CET) no snapshots were modified ``` -- `restic forget ` does not produce any output, and produces str output on error. Example on error: +- `restic forget --json` does not produce any output, and produces str output on error. Example on error: ``` Ignoring "ff20970b": no matching ID found for prefix "ff20970b" ``` @@ -24,9 +33,13 @@ tree 0d2eef6a1b06aa0650a08a82058d57a42bf515a4c84bf4f899e391a4b9906197 tree 9e61b5966a936e2e8b4ef4198b86ad59000c5cba3fc6250ece97cb13621b3cd1 tree 1fe90879bd35d90cd4fde440e64bfc16b331297cbddb776a43eb3fdf94875540 ``` -- `restic snapshots --json` is the only verb that produces valid json -- `restic backup --json` produces one per line valid json, makes sense +- `restic key list --json` produces direct parseable json +- `restic stats --json` produces direct parseable json +- `restic find --json` produces direct parseable json +- `restic snapshots --json` produces direct parseable json +- `restic backup --json` produces multiple state lines, each one being valid json, which makes sense +- `restic restore --target --json` produces multiple state lines, each one being valid json, which makes sense ### backup results inconsistency From 2aa8956dba95d289dbb78b1d146722bc6668551c Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Wed, 3 Jan 2024 17:57:35 +0100 Subject: [PATCH 163/328] Minor --json CLI improvements --- npbackup/LICENSE.md | 674 ------------------------------------ npbackup/__main__.py | 22 +- npbackup/__version__.py | 29 +- npbackup/customization.py | 689 ++++++++++++++++++++++++++++++++++++- npbackup/gui/__main__.py | 11 +- npbackup/gui/operations.py | 1 + 6 files changed, 711 insertions(+), 715 deletions(-) diff --git a/npbackup/LICENSE.md b/npbackup/LICENSE.md index e72bfdd..e69de29 100644 --- a/npbackup/LICENSE.md +++ b/npbackup/LICENSE.md @@ -1,674 +0,0 @@ - GNU GENERAL PUBLIC LICENSE - Version 3, 29 June 2007 - - Copyright (C) 2007 Free Software Foundation, Inc. - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The GNU General Public License is a free, copyleft license for -software and other kinds of works. - - The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, -the GNU General Public License is intended to guarantee your freedom to -share and change all versions of a program--to make sure it remains free -software for all its users. We, the Free Software Foundation, use the -GNU General Public License for most of our software; it applies also to -any other work released this way by its authors. You can apply it to -your programs, too. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -them if you wish), that you receive source code or can get it if you -want it, that you can change the software or use pieces of it in new -free programs, and that you know you can do these things. - - To protect your rights, we need to prevent others from denying you -these rights or asking you to surrender the rights. Therefore, you have -certain responsibilities if you distribute copies of the software, or if -you modify it: responsibilities to respect the freedom of others. - - For example, if you distribute copies of such a program, whether -gratis or for a fee, you must pass on to the recipients the same -freedoms that you received. You must make sure that they, too, receive -or can get the source code. And you must show them these terms so they -know their rights. - - Developers that use the GNU GPL protect your rights with two steps: -(1) assert copyright on the software, and (2) offer you this License -giving you legal permission to copy, distribute and/or modify it. - - For the developers' and authors' protection, the GPL clearly explains -that there is no warranty for this free software. For both users' and -authors' sake, the GPL requires that modified versions be marked as -changed, so that their problems will not be attributed erroneously to -authors of previous versions. - - Some devices are designed to deny users access to install or run -modified versions of the software inside them, although the manufacturer -can do so. This is fundamentally incompatible with the aim of -protecting users' freedom to change the software. The systematic -pattern of such abuse occurs in the area of products for individuals to -use, which is precisely where it is most unacceptable. Therefore, we -have designed this version of the GPL to prohibit the practice for those -products. If such problems arise substantially in other domains, we -stand ready to extend this provision to those domains in future versions -of the GPL, as needed to protect the freedom of users. - - Finally, every program is threatened constantly by software patents. -States should not allow patents to restrict development and use of -software on general-purpose computers, but in those that do, we wish to -avoid the special danger that patents applied to a free program could -make it effectively proprietary. To prevent this, the GPL assures that -patents cannot be used to render the program non-free. - - The precise terms and conditions for copying, distribution and -modification follow. - - TERMS AND CONDITIONS - - 0. Definitions. - - "This License" refers to version 3 of the GNU General Public License. - - "Copyright" also means copyright-like laws that apply to other kinds of -works, such as semiconductor masks. - - "The Program" refers to any copyrightable work licensed under this -License. Each licensee is addressed as "you". "Licensees" and -"recipients" may be individuals or organizations. - - To "modify" a work means to copy from or adapt all or part of the work -in a fashion requiring copyright permission, other than the making of an -exact copy. The resulting work is called a "modified version" of the -earlier work or a work "based on" the earlier work. - - A "covered work" means either the unmodified Program or a work based -on the Program. - - To "propagate" a work means to do anything with it that, without -permission, would make you directly or secondarily liable for -infringement under applicable copyright law, except executing it on a -computer or modifying a private copy. Propagation includes copying, -distribution (with or without modification), making available to the -public, and in some countries other activities as well. - - To "convey" a work means any kind of propagation that enables other -parties to make or receive copies. Mere interaction with a user through -a computer network, with no transfer of a copy, is not conveying. - - An interactive user interface displays "Appropriate Legal Notices" -to the extent that it includes a convenient and prominently visible -feature that (1) displays an appropriate copyright notice, and (2) -tells the user that there is no warranty for the work (except to the -extent that warranties are provided), that licensees may convey the -work under this License, and how to view a copy of this License. If -the interface presents a list of user commands or options, such as a -menu, a prominent item in the list meets this criterion. - - 1. Source Code. - - The "source code" for a work means the preferred form of the work -for making modifications to it. "Object code" means any non-source -form of a work. - - A "Standard Interface" means an interface that either is an official -standard defined by a recognized standards body, or, in the case of -interfaces specified for a particular programming language, one that -is widely used among developers working in that language. - - The "System Libraries" of an executable work include anything, other -than the work as a whole, that (a) is included in the normal form of -packaging a Major Component, but which is not part of that Major -Component, and (b) serves only to enable use of the work with that -Major Component, or to implement a Standard Interface for which an -implementation is available to the public in source code form. A -"Major Component", in this context, means a major essential component -(kernel, window system, and so on) of the specific operating system -(if any) on which the executable work runs, or a compiler used to -produce the work, or an object code interpreter used to run it. - - The "Corresponding Source" for a work in object code form means all -the source code needed to generate, install, and (for an executable -work) run the object code and to modify the work, including scripts to -control those activities. However, it does not include the work's -System Libraries, or general-purpose tools or generally available free -programs which are used unmodified in performing those activities but -which are not part of the work. For example, Corresponding Source -includes interface definition files associated with source files for -the work, and the source code for shared libraries and dynamically -linked subprograms that the work is specifically designed to require, -such as by intimate data communication or control flow between those -subprograms and other parts of the work. - - The Corresponding Source need not include anything that users -can regenerate automatically from other parts of the Corresponding -Source. - - The Corresponding Source for a work in source code form is that -same work. - - 2. Basic Permissions. - - All rights granted under this License are granted for the term of -copyright on the Program, and are irrevocable provided the stated -conditions are met. This License explicitly affirms your unlimited -permission to run the unmodified Program. The output from running a -covered work is covered by this License only if the output, given its -content, constitutes a covered work. This License acknowledges your -rights of fair use or other equivalent, as provided by copyright law. - - You may make, run and propagate covered works that you do not -convey, without conditions so long as your license otherwise remains -in force. You may convey covered works to others for the sole purpose -of having them make modifications exclusively for you, or provide you -with facilities for running those works, provided that you comply with -the terms of this License in conveying all material for which you do -not control copyright. Those thus making or running the covered works -for you must do so exclusively on your behalf, under your direction -and control, on terms that prohibit them from making any copies of -your copyrighted material outside their relationship with you. - - Conveying under any other circumstances is permitted solely under -the conditions stated below. Sublicensing is not allowed; section 10 -makes it unnecessary. - - 3. Protecting Users' Legal Rights From Anti-Circumvention Law. - - No covered work shall be deemed part of an effective technological -measure under any applicable law fulfilling obligations under article -11 of the WIPO copyright treaty adopted on 20 December 1996, or -similar laws prohibiting or restricting circumvention of such -measures. - - When you convey a covered work, you waive any legal power to forbid -circumvention of technological measures to the extent such circumvention -is effected by exercising rights under this License with respect to -the covered work, and you disclaim any intention to limit operation or -modification of the work as a means of enforcing, against the work's -users, your or third parties' legal rights to forbid circumvention of -technological measures. - - 4. Conveying Verbatim Copies. - - You may convey verbatim copies of the Program's source code as you -receive it, in any medium, provided that you conspicuously and -appropriately publish on each copy an appropriate copyright notice; -keep intact all notices stating that this License and any -non-permissive terms added in accord with section 7 apply to the code; -keep intact all notices of the absence of any warranty; and give all -recipients a copy of this License along with the Program. - - You may charge any price or no price for each copy that you convey, -and you may offer support or warranty protection for a fee. - - 5. Conveying Modified Source Versions. - - You may convey a work based on the Program, or the modifications to -produce it from the Program, in the form of source code under the -terms of section 4, provided that you also meet all of these conditions: - - a) The work must carry prominent notices stating that you modified - it, and giving a relevant date. - - b) The work must carry prominent notices stating that it is - released under this License and any conditions added under section - 7. This requirement modifies the requirement in section 4 to - "keep intact all notices". - - c) You must license the entire work, as a whole, under this - License to anyone who comes into possession of a copy. This - License will therefore apply, along with any applicable section 7 - additional terms, to the whole of the work, and all its parts, - regardless of how they are packaged. This License gives no - permission to license the work in any other way, but it does not - invalidate such permission if you have separately received it. - - d) If the work has interactive user interfaces, each must display - Appropriate Legal Notices; however, if the Program has interactive - interfaces that do not display Appropriate Legal Notices, your - work need not make them do so. - - A compilation of a covered work with other separate and independent -works, which are not by their nature extensions of the covered work, -and which are not combined with it such as to form a larger program, -in or on a volume of a storage or distribution medium, is called an -"aggregate" if the compilation and its resulting copyright are not -used to limit the access or legal rights of the compilation's users -beyond what the individual works permit. Inclusion of a covered work -in an aggregate does not cause this License to apply to the other -parts of the aggregate. - - 6. Conveying Non-Source Forms. - - You may convey a covered work in object code form under the terms -of sections 4 and 5, provided that you also convey the -machine-readable Corresponding Source under the terms of this License, -in one of these ways: - - a) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by the - Corresponding Source fixed on a durable physical medium - customarily used for software interchange. - - b) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by a - written offer, valid for at least three years and valid for as - long as you offer spare parts or customer support for that product - model, to give anyone who possesses the object code either (1) a - copy of the Corresponding Source for all the software in the - product that is covered by this License, on a durable physical - medium customarily used for software interchange, for a price no - more than your reasonable cost of physically performing this - conveying of source, or (2) access to copy the - Corresponding Source from a network server at no charge. - - c) Convey individual copies of the object code with a copy of the - written offer to provide the Corresponding Source. This - alternative is allowed only occasionally and noncommercially, and - only if you received the object code with such an offer, in accord - with subsection 6b. - - d) Convey the object code by offering access from a designated - place (gratis or for a charge), and offer equivalent access to the - Corresponding Source in the same way through the same place at no - further charge. You need not require recipients to copy the - Corresponding Source along with the object code. If the place to - copy the object code is a network server, the Corresponding Source - may be on a different server (operated by you or a third party) - that supports equivalent copying facilities, provided you maintain - clear directions next to the object code saying where to find the - Corresponding Source. Regardless of what server hosts the - Corresponding Source, you remain obligated to ensure that it is - available for as long as needed to satisfy these requirements. - - e) Convey the object code using peer-to-peer transmission, provided - you inform other peers where the object code and Corresponding - Source of the work are being offered to the general public at no - charge under subsection 6d. - - A separable portion of the object code, whose source code is excluded -from the Corresponding Source as a System Library, need not be -included in conveying the object code work. - - A "User Product" is either (1) a "consumer product", which means any -tangible personal property which is normally used for personal, family, -or household purposes, or (2) anything designed or sold for incorporation -into a dwelling. In determining whether a product is a consumer product, -doubtful cases shall be resolved in favor of coverage. For a particular -product received by a particular user, "normally used" refers to a -typical or common use of that class of product, regardless of the status -of the particular user or of the way in which the particular user -actually uses, or expects or is expected to use, the product. A product -is a consumer product regardless of whether the product has substantial -commercial, industrial or non-consumer uses, unless such uses represent -the only significant mode of use of the product. - - "Installation Information" for a User Product means any methods, -procedures, authorization keys, or other information required to install -and execute modified versions of a covered work in that User Product from -a modified version of its Corresponding Source. The information must -suffice to ensure that the continued functioning of the modified object -code is in no case prevented or interfered with solely because -modification has been made. - - If you convey an object code work under this section in, or with, or -specifically for use in, a User Product, and the conveying occurs as -part of a transaction in which the right of possession and use of the -User Product is transferred to the recipient in perpetuity or for a -fixed term (regardless of how the transaction is characterized), the -Corresponding Source conveyed under this section must be accompanied -by the Installation Information. But this requirement does not apply -if neither you nor any third party retains the ability to install -modified object code on the User Product (for example, the work has -been installed in ROM). - - The requirement to provide Installation Information does not include a -requirement to continue to provide support service, warranty, or updates -for a work that has been modified or installed by the recipient, or for -the User Product in which it has been modified or installed. Access to a -network may be denied when the modification itself materially and -adversely affects the operation of the network or violates the rules and -protocols for communication across the network. - - Corresponding Source conveyed, and Installation Information provided, -in accord with this section must be in a format that is publicly -documented (and with an implementation available to the public in -source code form), and must require no special password or key for -unpacking, reading or copying. - - 7. Additional Terms. - - "Additional permissions" are terms that supplement the terms of this -License by making exceptions from one or more of its conditions. -Additional permissions that are applicable to the entire Program shall -be treated as though they were included in this License, to the extent -that they are valid under applicable law. If additional permissions -apply only to part of the Program, that part may be used separately -under those permissions, but the entire Program remains governed by -this License without regard to the additional permissions. - - When you convey a copy of a covered work, you may at your option -remove any additional permissions from that copy, or from any part of -it. (Additional permissions may be written to require their own -removal in certain cases when you modify the work.) You may place -additional permissions on material, added by you to a covered work, -for which you have or can give appropriate copyright permission. - - Notwithstanding any other provision of this License, for material you -add to a covered work, you may (if authorized by the copyright holders of -that material) supplement the terms of this License with terms: - - a) Disclaiming warranty or limiting liability differently from the - terms of sections 15 and 16 of this License; or - - b) Requiring preservation of specified reasonable legal notices or - author attributions in that material or in the Appropriate Legal - Notices displayed by works containing it; or - - c) Prohibiting misrepresentation of the origin of that material, or - requiring that modified versions of such material be marked in - reasonable ways as different from the original version; or - - d) Limiting the use for publicity purposes of names of licensors or - authors of the material; or - - e) Declining to grant rights under trademark law for use of some - trade names, trademarks, or service marks; or - - f) Requiring indemnification of licensors and authors of that - material by anyone who conveys the material (or modified versions of - it) with contractual assumptions of liability to the recipient, for - any liability that these contractual assumptions directly impose on - those licensors and authors. - - All other non-permissive additional terms are considered "further -restrictions" within the meaning of section 10. If the Program as you -received it, or any part of it, contains a notice stating that it is -governed by this License along with a term that is a further -restriction, you may remove that term. If a license document contains -a further restriction but permits relicensing or conveying under this -License, you may add to a covered work material governed by the terms -of that license document, provided that the further restriction does -not survive such relicensing or conveying. - - If you add terms to a covered work in accord with this section, you -must place, in the relevant source files, a statement of the -additional terms that apply to those files, or a notice indicating -where to find the applicable terms. - - Additional terms, permissive or non-permissive, may be stated in the -form of a separately written license, or stated as exceptions; -the above requirements apply either way. - - 8. Termination. - - You may not propagate or modify a covered work except as expressly -provided under this License. Any attempt otherwise to propagate or -modify it is void, and will automatically terminate your rights under -this License (including any patent licenses granted under the third -paragraph of section 11). - - However, if you cease all violation of this License, then your -license from a particular copyright holder is reinstated (a) -provisionally, unless and until the copyright holder explicitly and -finally terminates your license, and (b) permanently, if the copyright -holder fails to notify you of the violation by some reasonable means -prior to 60 days after the cessation. - - Moreover, your license from a particular copyright holder is -reinstated permanently if the copyright holder notifies you of the -violation by some reasonable means, this is the first time you have -received notice of violation of this License (for any work) from that -copyright holder, and you cure the violation prior to 30 days after -your receipt of the notice. - - Termination of your rights under this section does not terminate the -licenses of parties who have received copies or rights from you under -this License. If your rights have been terminated and not permanently -reinstated, you do not qualify to receive new licenses for the same -material under section 10. - - 9. Acceptance Not Required for Having Copies. - - You are not required to accept this License in order to receive or -run a copy of the Program. Ancillary propagation of a covered work -occurring solely as a consequence of using peer-to-peer transmission -to receive a copy likewise does not require acceptance. However, -nothing other than this License grants you permission to propagate or -modify any covered work. These actions infringe copyright if you do -not accept this License. Therefore, by modifying or propagating a -covered work, you indicate your acceptance of this License to do so. - - 10. Automatic Licensing of Downstream Recipients. - - Each time you convey a covered work, the recipient automatically -receives a license from the original licensors, to run, modify and -propagate that work, subject to this License. You are not responsible -for enforcing compliance by third parties with this License. - - An "entity transaction" is a transaction transferring control of an -organization, or substantially all assets of one, or subdividing an -organization, or merging organizations. If propagation of a covered -work results from an entity transaction, each party to that -transaction who receives a copy of the work also receives whatever -licenses to the work the party's predecessor in interest had or could -give under the previous paragraph, plus a right to possession of the -Corresponding Source of the work from the predecessor in interest, if -the predecessor has it or can get it with reasonable efforts. - - You may not impose any further restrictions on the exercise of the -rights granted or affirmed under this License. For example, you may -not impose a license fee, royalty, or other charge for exercise of -rights granted under this License, and you may not initiate litigation -(including a cross-claim or counterclaim in a lawsuit) alleging that -any patent claim is infringed by making, using, selling, offering for -sale, or importing the Program or any portion of it. - - 11. Patents. - - A "contributor" is a copyright holder who authorizes use under this -License of the Program or a work on which the Program is based. The -work thus licensed is called the contributor's "contributor version". - - A contributor's "essential patent claims" are all patent claims -owned or controlled by the contributor, whether already acquired or -hereafter acquired, that would be infringed by some manner, permitted -by this License, of making, using, or selling its contributor version, -but do not include claims that would be infringed only as a -consequence of further modification of the contributor version. For -purposes of this definition, "control" includes the right to grant -patent sublicenses in a manner consistent with the requirements of -this License. - - Each contributor grants you a non-exclusive, worldwide, royalty-free -patent license under the contributor's essential patent claims, to -make, use, sell, offer for sale, import and otherwise run, modify and -propagate the contents of its contributor version. - - In the following three paragraphs, a "patent license" is any express -agreement or commitment, however denominated, not to enforce a patent -(such as an express permission to practice a patent or covenant not to -sue for patent infringement). To "grant" such a patent license to a -party means to make such an agreement or commitment not to enforce a -patent against the party. - - If you convey a covered work, knowingly relying on a patent license, -and the Corresponding Source of the work is not available for anyone -to copy, free of charge and under the terms of this License, through a -publicly available network server or other readily accessible means, -then you must either (1) cause the Corresponding Source to be so -available, or (2) arrange to deprive yourself of the benefit of the -patent license for this particular work, or (3) arrange, in a manner -consistent with the requirements of this License, to extend the patent -license to downstream recipients. "Knowingly relying" means you have -actual knowledge that, but for the patent license, your conveying the -covered work in a country, or your recipient's use of the covered work -in a country, would infringe one or more identifiable patents in that -country that you have reason to believe are valid. - - If, pursuant to or in connection with a single transaction or -arrangement, you convey, or propagate by procuring conveyance of, a -covered work, and grant a patent license to some of the parties -receiving the covered work authorizing them to use, propagate, modify -or convey a specific copy of the covered work, then the patent license -you grant is automatically extended to all recipients of the covered -work and works based on it. - - A patent license is "discriminatory" if it does not include within -the scope of its coverage, prohibits the exercise of, or is -conditioned on the non-exercise of one or more of the rights that are -specifically granted under this License. You may not convey a covered -work if you are a party to an arrangement with a third party that is -in the business of distributing software, under which you make payment -to the third party based on the extent of your activity of conveying -the work, and under which the third party grants, to any of the -parties who would receive the covered work from you, a discriminatory -patent license (a) in connection with copies of the covered work -conveyed by you (or copies made from those copies), or (b) primarily -for and in connection with specific products or compilations that -contain the covered work, unless you entered into that arrangement, -or that patent license was granted, prior to 28 March 2007. - - Nothing in this License shall be construed as excluding or limiting -any implied license or other defenses to infringement that may -otherwise be available to you under applicable patent law. - - 12. No Surrender of Others' Freedom. - - If conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot convey a -covered work so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you may -not convey it at all. For example, if you agree to terms that obligate you -to collect a royalty for further conveying from those to whom you convey -the Program, the only way you could satisfy both those terms and this -License would be to refrain entirely from conveying the Program. - - 13. Use with the GNU Affero General Public License. - - Notwithstanding any other provision of this License, you have -permission to link or combine any covered work with a work licensed -under version 3 of the GNU Affero General Public License into a single -combined work, and to convey the resulting work. The terms of this -License will continue to apply to the part which is the covered work, -but the special requirements of the GNU Affero General Public License, -section 13, concerning interaction through a network will apply to the -combination as such. - - 14. Revised Versions of this License. - - The Free Software Foundation may publish revised and/or new versions of -the GNU General Public License from time to time. Such new versions will -be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - - Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU General -Public License "or any later version" applies to it, you have the -option of following the terms and conditions either of that numbered -version or of any later version published by the Free Software -Foundation. If the Program does not specify a version number of the -GNU General Public License, you may choose any version ever published -by the Free Software Foundation. - - If the Program specifies that a proxy can decide which future -versions of the GNU General Public License can be used, that proxy's -public statement of acceptance of a version permanently authorizes you -to choose that version for the Program. - - Later license versions may give you additional or different -permissions. However, no additional obligations are imposed on any -author or copyright holder as a result of your choosing to follow a -later version. - - 15. Disclaimer of Warranty. - - THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY -APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT -HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY -OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, -THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM -IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF -ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - - 16. Limitation of Liability. - - IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS -THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY -GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE -USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF -DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD -PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), -EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF -SUCH DAMAGES. - - 17. Interpretation of Sections 15 and 16. - - If the disclaimer of warranty and limitation of liability provided -above cannot be given local legal effect according to their terms, -reviewing courts shall apply local law that most closely approximates -an absolute waiver of all civil liability in connection with the -Program, unless a warranty or assumption of liability accompanies a -copy of the Program in return for a fee. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - - If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - - To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -state the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - - Copyright (C) - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . - -Also add information on how to contact you by electronic and paper mail. - - If the program does terminal interaction, make it output a short -notice like this when it starts in an interactive mode: - - Copyright (C) - This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. - This is free software, and you are welcome to redistribute it - under certain conditions; type `show c' for details. - -The hypothetical commands `show w' and `show c' should show the appropriate -parts of the General Public License. Of course, your program's commands -might be different; for a GUI interface, you would use an "about box". - - You should also get your employer (if you work as a programmer) or school, -if any, to sign a "copyright disclaimer" for the program, if necessary. -For more information on this, and how to apply and follow the GNU GPL, see -. - - The GNU General Public License does not permit incorporating your program -into proprietary programs. If your program is a subroutine library, you -may consider it more useful to permit linking proprietary applications with -the library. If this is what you want to do, use the GNU Lesser General -Public License instead of this License. But first, please read -. \ No newline at end of file diff --git a/npbackup/__main__.py b/npbackup/__main__.py index 22a5c1a..ac4efa5 100644 --- a/npbackup/__main__.py +++ b/npbackup/__main__.py @@ -17,13 +17,10 @@ import ofunctions.logger_utils from ofunctions.process import kill_childs from npbackup.path_helper import CURRENT_DIR -from npbackup.customization import ( - LICENSE_TEXT, - LICENSE_FILE, -) +from npbackup.customization import LICENSE_TEXT import npbackup.configuration from npbackup.runner_interface import entrypoint -from npbackup.__version__ import version_string +from npbackup.__version__ import version_string, version_dict from npbackup.__debug__ import _DEBUG from npbackup.common import execution_logs from npbackup.core.i18n_helper import _t @@ -228,15 +225,20 @@ def cli_interface(): logger = ofunctions.logger_utils.logger_get_logger(LOG_FILE, debug=_DEBUG) if args.version: - print(version_string) + if _JSON: + print(json.dumps({ + "result": True, + "version": version_dict + })) + else: + print(version_string) sys.exit(0) logger.info(version_string) if args.license: - try: - with open(LICENSE_FILE, "r", encoding="utf-8") as file_handle: - print(file_handle.read()) - except OSError: + if _JSON: + print(json.dumps({"result": True, "output": LICENSE_TEXT})) + else: print(LICENSE_TEXT) sys.exit(0) diff --git a/npbackup/__version__.py b/npbackup/__version__.py index ce2233c..23b2887 100644 --- a/npbackup/__version__.py +++ b/npbackup/__version__.py @@ -7,22 +7,25 @@ __author__ = "Orsiris de Jong" __site__ = "https://www.netperfect.fr/npbackup" __description__ = "NetPerfect Backup Client" -__copyright__ = "Copyright (C) 2022-2023 NetInvent" +__copyright__ = "Copyright (C) 2022-2024 NetInvent" __license__ = "GPL-3.0-only" -__build__ = "2023121001" -__version__ = "2.3.0-dev" +__build__ = "2023010301" +__version__ = "3.0.0-dev" import sys -from ofunctions.platform import python_arch +from ofunctions.platform import python_arch, get_os_identifier from npbackup.configuration import IS_PRIV_BUILD -version_string = "{} v{}{}{}-{} {} - {}".format( - __intname__, - __version__, - "-PRIV" if IS_PRIV_BUILD else "", - "-P{}".format(sys.version_info[1]), - python_arch(), - __build__, - __copyright__, -) +version_string = f"{__intname__} v{__version__}-{'priv' if IS_PRIV_BUILD else 'pub'}-{sys.version_info[0]}.{sys.version_info[1]}-{python_arch()} {__build__} - {__copyright__}" +version_dict = { + 'name': __intname__, + 'version': __version__, + 'buildtype': "priv" if IS_PRIV_BUILD else "pub", + 'os': get_os_identifier(), + 'arch': python_arch(), + 'pv': sys.version_info, + 'comp': "__compiled__" in globals(), + 'build': __build__, + 'copyright': __copyright__ +} diff --git a/npbackup/customization.py b/npbackup/customization.py index 5ce9b0e..27cc979 100644 --- a/npbackup/customization.py +++ b/npbackup/customization.py @@ -5,19 +5,15 @@ __intname__ = "npbackup.customization" __author__ = "Orsiris de Jong" -__copyright__ = "Copyright (C) 2022-2023 NetInvent" +__copyright__ = "Copyright (C) 2022-2024 NetInvent" __license__ = "GPL-3.0-only" -__build__ = "2023012401" -__version__ = "1.3.0" +__build__ = "2024010301" +__version__ = "1.3.1" import os -LICENSE_TEXT = "This program is distributed under the GNU GPLv3 license. See https://www.gnu.org/licenses/gpl-3.0.txt" -LICENSE_FILE_DIR = os.path.abspath(os.path.dirname(os.path.realpath(__file__))) -LICENSE_FILE = os.path.join(LICENSE_FILE_DIR, "LICENSE.md") - # Windows Program name for scheduled task PROGRAM_NAME = "Client Sauvegarde NetPerfect" # Windows target directory @@ -25,7 +21,7 @@ # Windows executable file info COMPANY_NAME = "NetInvent" -COPYRIGHT = "NetInvent 2022-2023" +COPYRIGHT = "NetInvent 2022-2024" FILE_DESCRIPTION = "Network Backup Client" TRADEMARKS = "NetInvent (C)" PRODUCT_NAME = "NPBackup Network Backup Client" @@ -52,3 +48,680 @@ FILE_ICON = b"iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAAsSAAALEgHS3X78AAABU0lEQVQ4y52TzStEURiHn/ecc6XG54JSdlMkNhYWsiILS0lsJaUsLW2Mv8CfIDtr2VtbY4GUEvmIZnKbZsY977Uwt2HcyW1+dTZvt6fn9557BGB+aaNQKBR2ifkbgWR+cX13ubO1svz++niVTA1ArDHDg91UahHFsMxbKWycYsjze4muTsP64vT43v7hSf/A0FgdjQPQWAmco68nB+T+SFSqNUQgcIbN1bn8Z3RwvL22MAvcu8TACFgrpMVZ4aUYcn77BMDkxGgemAGOHIBXxRjBWZMKoCPA2h6qEUSRR2MF6GxUUMUaIUgBCNTnAcm3H2G5YQfgvccYIXAtDH7FoKq/AaqKlbrBj2trFVXfBPAea4SOIIsBeN9kkCwxsNkAqRWy7+B7Z00G3xVc2wZeMSI4S7sVYkSk5Z/4PyBWROqvox3A28PN2cjUwinQC9QyckKALxj4kv2auK0xAAAAAElFTkSuQmCC" LOADER_ANIMATION = b"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" INHERITANCE_ICON = b"iVBORw0KGgoAAAANSUhEUgAAABIAAAASCAYAAABWzo5XAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAACxMAAAsTAQCanBgAAAFPSURBVDhPrZS9LkRBFMevjxWVDa0obOGrl2g8gkdQoPIC3kKpofIEhFoUGq2CRMIWFDqyBfGR4Pe7e+/m7twZFP7Jb3fmnDPnzp055w5kaTWgBc18lmUdaMNHPvuDRmADjuEWngoca9NnzI+ahVP4+gVjjI1KxxXEFj7Ce2Azdgb65FZTOzmCSViF18JWcgKeZU++dzWgyi6oRXiG0L8OuczoIYYBJS9wDt5YzO/axiA/XvEChLqHbdiCO5iGmOahZSLrZFxLIH0+cQ824QJimoCmwSl5wCtg4CgMQVImsmItuJjcxQN4zXMaIrI0OibyEK2JmM6K/2UY7g5rcm3bRPbOoZZAb3DdHWZj4NV/5rN+HUCv/1IFuQNTsAT7YKKqv1aQKtUinmGsEC+h1iKldPiUcFGIMckkpdyqZW/F3kD5GXFs361B7XX+6cOWZd8L0Yd3yKkunwAAAABJRU5ErkJggg==" + +LICENSE_TEXT = """ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. +""" \ No newline at end of file diff --git a/npbackup/gui/__main__.py b/npbackup/gui/__main__.py index 5d1f3d9..fc8214a 100644 --- a/npbackup/gui/__main__.py +++ b/npbackup/gui/__main__.py @@ -40,7 +40,6 @@ FOLDER_ICON, FILE_ICON, LICENSE_TEXT, - LICENSE_FILE, PYSIMPLEGUI_THEME, OEM_ICON, SHORT_PRODUCT_NAME, @@ -64,8 +63,6 @@ def about_gui(version_string: str, full_config: dict = None) -> None: - license_content = LICENSE_TEXT - if full_config and full_config.g("global_options.auto_upgrade_server_url"): auto_upgrade_result = check_new_version(full_config) else: @@ -82,17 +79,11 @@ def about_gui(version_string: str, full_config: dict = None) -> None: new_version = [sg.Text(_t("generic.is_uptodate"))] elif auto_upgrade_result is None: new_version = [sg.Text(_t("config_gui.auto_upgrade_disabled"))] - try: - with open(LICENSE_FILE, "r", encoding="utf-8") as file_handle: - license_content = file_handle.read() - except OSError: - logger.info("Could not read license file.") - layout = [ [sg.Text(version_string)], new_version, [sg.Text("License: GNU GPLv3")], - [sg.Multiline(license_content, size=(65, 20), disabled=True)], + [sg.Multiline(LICENSE_TEXT, size=(65, 20), disabled=True)], [sg.Button(_t("generic.accept"), key="exit")], ] diff --git a/npbackup/gui/operations.py b/npbackup/gui/operations.py index 489e267..dad2e41 100644 --- a/npbackup/gui/operations.py +++ b/npbackup/gui/operations.py @@ -58,6 +58,7 @@ def _select_groups(): key="-GROUP_LIST-", auto_size_columns=True, justification="left", + size=(60, 5) ) ], [ From bb6b29313804b409ea75ce2e4131f12f26cb9ab6 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Wed, 3 Jan 2024 17:57:42 +0100 Subject: [PATCH 164/328] Fix typo --- npbackup/configuration.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/npbackup/configuration.py b/npbackup/configuration.py index 8f7fc8a..617cd51 100644 --- a/npbackup/configuration.py +++ b/npbackup/configuration.py @@ -410,7 +410,7 @@ def inject_permissions_into_repo_config(repo_config: dict) -> dict: def get_manager_password(full_config: dict, repo_name: str) -> str: - return full_config.g("repos.{repo_name}.manager_password") + return full_config.g(f"repos.{repo_name}.manager_password") def get_repo_config( From adcba0bc5f030c526fafd8c41489b97021f460f5 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Wed, 3 Jan 2024 19:55:24 +0100 Subject: [PATCH 165/328] UX improvements --- npbackup/gui/__main__.py | 25 +++++++++++++++++++++---- npbackup/translations/main_gui.en.yml | 2 +- npbackup/translations/main_gui.fr.yml | 2 +- 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/npbackup/gui/__main__.py b/npbackup/gui/__main__.py index fc8214a..ae3887b 100644 --- a/npbackup/gui/__main__.py +++ b/npbackup/gui/__main__.py @@ -538,6 +538,12 @@ def get_config_file(default: bool = True) -> str: """ if default: config_file = Path(f"{CURRENT_DIR}/npbackup.conf") + if default: + full_config = npbackup.configuration.load_config(config_file) + if not config_file.exists(): + config_file = None + if not full_config: + return full_config, config_file else: config_file = None @@ -561,8 +567,8 @@ def get_config_file(default: bool = True) -> str: return full_config, config_file return None, None - def get_config(): - full_config, config_file = get_config_file() + def get_config(default: bool = False): + full_config, config_file = get_config_file(default=default) if full_config and config_file: repo_config, config_inheritance = npbackup.configuration.get_repo_config( full_config @@ -595,7 +601,7 @@ def get_config(): backend_type, repo_uri, repo_list, - ) = get_config() + ) = get_config(default=True) else: # Let's try to read standard restic repository env variables viewer_repo_uri = os.environ.get("RESTIC_REPOSITORY", None) @@ -644,6 +650,10 @@ def get_config(): vertical_alignment="top", ), ], + [ + sg.Text(_t("main_gui.no_config"), font=("Arial", 14), text_color="red", key="-NO-CONFIG-", visible=False) + ] if not viewer_mode + else [], [ sg.Text(_t("main_gui.backup_list_to")), sg.Combo( @@ -664,6 +674,7 @@ def get_config(): justification="left", key="snapshot-list", select_mode="extended", + size=(None, 10) ) ], [ @@ -725,7 +736,9 @@ def get_config(): window["snapshot-list"].expand(True, True) window.set_title(f"{SHORT_PRODUCT_NAME} - {config_file}") - window.read(timeout=1) + window.read(timeout=0.01) + if not config_file and not full_config and not viewer_mode: + window["-NO-CONFIG-"].Update(visible=True) if repo_config: try: current_state, backup_tz, snapshot_list = get_gui_data(repo_config) @@ -812,6 +825,10 @@ def get_config(): repo_list, ) = get_config() window.set_title(f"{SHORT_PRODUCT_NAME} - {config_file}") + if not viewer_mode and not config_file and not full_config: + window["-NO-CONFIG-"].Update(visible=True) + elif not viewer_mode: + window["-NO-CONFIG-"].Update(visible=False) event = "--STATE-BUTTON--" if event == _t("generic.destination"): try: diff --git a/npbackup/translations/main_gui.en.yml b/npbackup/translations/main_gui.en.yml index b129524..9b1399e 100644 --- a/npbackup/translations/main_gui.en.yml +++ b/npbackup/translations/main_gui.en.yml @@ -41,7 +41,7 @@ en: new_config: New config load_config: Load configuration config_error: Configuration error - no_config: Please load a configuration before proceeding + no_config: Please load / create a configuration before proceeding # logs last_messages: Last messages diff --git a/npbackup/translations/main_gui.fr.yml b/npbackup/translations/main_gui.fr.yml index 7fc97a3..38e1911 100644 --- a/npbackup/translations/main_gui.fr.yml +++ b/npbackup/translations/main_gui.fr.yml @@ -41,7 +41,7 @@ fr: new_config: Nouvelle configuration load_config: Charger configuration config_error: Erreur de configuration - no_config: Veuillez charger une configuration avant de procéder + no_config: Veuillez charger / créer une configuration avant de procéder # logs last_messages: Last messages From 0ac3f0a4c9cd9c3d45f466a772b2d323e9a536a2 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Wed, 3 Jan 2024 20:01:03 +0100 Subject: [PATCH 166/328] Update (C) year --- bin/NPBackupInstaller.py | 2 +- bin/compile.py | 2 +- bin/sign_windows.py | 2 +- npbackup/__debug__.py | 2 +- npbackup/__env__.py | 2 +- npbackup/common.py | 2 +- npbackup/configuration.py | 2 +- npbackup/core/i18n_helper.py | 2 +- npbackup/core/nuitka_helper.py | 2 +- npbackup/core/restic_source_binary.py | 2 +- npbackup/core/upgrade_runner.py | 2 +- npbackup/gui/__main__.py | 2 +- npbackup/gui/config.py | 2 +- npbackup/gui/helpers.py | 2 +- npbackup/gui/minimize_window.py | 2 +- npbackup/secret_keys.py | 2 +- npbackup/upgrade_client/requestor.py | 2 +- npbackup/upgrade_client/upgrader.py | 2 +- npbackup/windows/task.py | 2 +- setup.py | 2 +- upgrade_server/upgrade_server.py | 2 +- upgrade_server/upgrade_server/api.py | 2 +- upgrade_server/upgrade_server/configuration.py | 2 +- upgrade_server/upgrade_server/crud.py | 2 +- upgrade_server/upgrade_server/models/files.py | 2 +- upgrade_server/upgrade_server/models/oper.py | 2 +- 26 files changed, 26 insertions(+), 26 deletions(-) diff --git a/bin/NPBackupInstaller.py b/bin/NPBackupInstaller.py index e125880..bce950b 100644 --- a/bin/NPBackupInstaller.py +++ b/bin/NPBackupInstaller.py @@ -5,7 +5,7 @@ __intname__ = "npbackup.installer" __author__ = "Orsiris de Jong" -__copyright__ = "Copyright (C) 2023 NetInvent" +__copyright__ = "Copyright (C) 2023-2024 NetInvent" __license__ = "GPL-3.0-only" __build__ = "2023012901" __version__ = "1.1.7" diff --git a/bin/compile.py b/bin/compile.py index bed246c..4e88fe2 100644 --- a/bin/compile.py +++ b/bin/compile.py @@ -5,7 +5,7 @@ __intname__ = "npbackup.compile" __author__ = "Orsiris de Jong" -__copyright__ = "Copyright (C) 2023 NetInvent" +__copyright__ = "Copyright (C) 2023-2024 NetInvent" __license__ = "GPL-3.0-only" __build__ = "2023090101" __version__ = "1.9.0" diff --git a/bin/sign_windows.py b/bin/sign_windows.py index f1c09e9..43bdb81 100644 --- a/bin/sign_windows.py +++ b/bin/sign_windows.py @@ -5,7 +5,7 @@ __intname__ = "npbackup.sign_windows" __author__ = "Orsiris de Jong" -__copyright__ = "Copyright (C) 2023 NetInvent" +__copyright__ = "Copyright (C) 2023-2024 NetInvent" __license__ = "GPL-3.0-only" __build__ = "2023050301" __version__ = "1.0.0" diff --git a/npbackup/__debug__.py b/npbackup/__debug__.py index e07f3d1..c734dfa 100644 --- a/npbackup/__debug__.py +++ b/npbackup/__debug__.py @@ -7,7 +7,7 @@ __author__ = "Orsiris de Jong" __site__ = "https://www.netperfect.fr/npbackup" __description__ = "NetPerfect Backup Client" -__copyright__ = "Copyright (C) 2023 NetInvent" +__copyright__ = "Copyright (C) 2023-2024 NetInvent" import os diff --git a/npbackup/__env__.py b/npbackup/__env__.py index 303d62e..ae0eb42 100644 --- a/npbackup/__env__.py +++ b/npbackup/__env__.py @@ -7,7 +7,7 @@ __author__ = "Orsiris de Jong" __site__ = "https://www.netperfect.fr/npbackup" __description__ = "NetPerfect Backup Client" -__copyright__ = "Copyright (C) 2023 NetInvent" +__copyright__ = "Copyright (C) 2023-2024 NetInvent" ################## diff --git a/npbackup/common.py b/npbackup/common.py index 90c9ecc..be4edce 100644 --- a/npbackup/common.py +++ b/npbackup/common.py @@ -7,7 +7,7 @@ __author__ = "Orsiris de Jong" __site__ = "https://www.netperfect.fr/npbackup" __description__ = "NetPerfect Backup Client" -__copyright__ = "Copyright (C) 2023 NetInvent" +__copyright__ = "Copyright (C) 2023-2024 NetInvent" __license__ = "GPL-3.0-only" __build__ = "2023121801" diff --git a/npbackup/configuration.py b/npbackup/configuration.py index 617cd51..e04dbdb 100644 --- a/npbackup/configuration.py +++ b/npbackup/configuration.py @@ -5,7 +5,7 @@ __intname__ = "npbackup.configuration" __author__ = "Orsiris de Jong" -__copyright__ = "Copyright (C) 2022-2023 NetInvent" +__copyright__ = "Copyright (C) 2022-2024 NetInvent" __license__ = "GPL-3.0-only" __build__ = "2023122901" __version__ = "2.0.0 for npbackup 2.3.0+" diff --git a/npbackup/core/i18n_helper.py b/npbackup/core/i18n_helper.py index 40a48a0..c9e8934 100644 --- a/npbackup/core/i18n_helper.py +++ b/npbackup/core/i18n_helper.py @@ -5,7 +5,7 @@ __intname__ = "npbackup.core.i18n_helper" __author__ = "Orsiris de Jong" -__copyright__ = "Copyright (C) 2022-2023 NetInvent" +__copyright__ = "Copyright (C) 2022-2024 NetInvent" __license__ = "BSD-3-Clause" __build__ = "2023032101" diff --git a/npbackup/core/nuitka_helper.py b/npbackup/core/nuitka_helper.py index ae4176d..88be8b7 100644 --- a/npbackup/core/nuitka_helper.py +++ b/npbackup/core/nuitka_helper.py @@ -5,7 +5,7 @@ __intname__ = "npbackup.core.nuitka_helper" __author__ = "Orsiris de Jong" -__copyright__ = "Copyright (C) 2022-2023 NetInvent" +__copyright__ = "Copyright (C) 2022-2024 NetInvent" __license__ = "BSD-3-Clause" __build__ = "2023040401" diff --git a/npbackup/core/restic_source_binary.py b/npbackup/core/restic_source_binary.py index 29a727e..89968af 100644 --- a/npbackup/core/restic_source_binary.py +++ b/npbackup/core/restic_source_binary.py @@ -5,7 +5,7 @@ __intname__ = "npbackup.gui.core.restic_source_binary" __author__ = "Orsiris de Jong" -__copyright__ = "Copyright (C) 2022-2023 NetInvent" +__copyright__ = "Copyright (C) 2022-2024 NetInvent" __license__ = "GPL-3.0-only" __build__ = "2023061102" diff --git a/npbackup/core/upgrade_runner.py b/npbackup/core/upgrade_runner.py index c85db7c..0093189 100644 --- a/npbackup/core/upgrade_runner.py +++ b/npbackup/core/upgrade_runner.py @@ -5,7 +5,7 @@ __intname__ = "npbackup.gui.core.upgrade_runner" __author__ = "Orsiris de Jong" -__copyright__ = "Copyright (C) 2022-2023 NetInvent" +__copyright__ = "Copyright (C) 2022-2024 NetInvent" __license__ = "GPL-3.0-only" __build__ = "2023040401" diff --git a/npbackup/gui/__main__.py b/npbackup/gui/__main__.py index ae3887b..d428571 100644 --- a/npbackup/gui/__main__.py +++ b/npbackup/gui/__main__.py @@ -5,7 +5,7 @@ __intname__ = "npbackup.gui.main" __author__ = "Orsiris de Jong" -__copyright__ = "Copyright (C) 2022-2023 NetInvent" +__copyright__ = "Copyright (C) 2022-2024 NetInvent" __license__ = "GPL-3.0-only" __build__ = "2023121701" diff --git a/npbackup/gui/config.py b/npbackup/gui/config.py index db763e9..54b010b 100644 --- a/npbackup/gui/config.py +++ b/npbackup/gui/config.py @@ -5,7 +5,7 @@ __intname__ = "npbackup.gui.config" __author__ = "Orsiris de Jong" -__copyright__ = "Copyright (C) 2022-2023 NetInvent" +__copyright__ = "Copyright (C) 2022-2024 NetInvent" __license__ = "GPL-3.0-only" __build__ = "2023121701" diff --git a/npbackup/gui/helpers.py b/npbackup/gui/helpers.py index 35a66c9..66418eb 100644 --- a/npbackup/gui/helpers.py +++ b/npbackup/gui/helpers.py @@ -5,7 +5,7 @@ __intname__ = "npbackup.gui.helpers" __author__ = "Orsiris de Jong" -__copyright__ = "Copyright (C) 2023 NetInvent" +__copyright__ = "Copyright (C) 2023-2024 NetInvent" __license__ = "GPL-3.0-only" __build__ = "2023122201" diff --git a/npbackup/gui/minimize_window.py b/npbackup/gui/minimize_window.py index 8582e90..372bdff 100644 --- a/npbackup/gui/minimize_window.py +++ b/npbackup/gui/minimize_window.py @@ -5,7 +5,7 @@ __intname__ = "npbackup.gui.window_reducer" __author__ = "Orsiris de Jong" -__copyright__ = "Copyright (C) 2022-2023 NetInvent" +__copyright__ = "Copyright (C) 2022-2024 NetInvent" __license__ = "GPL-3.0-only" __build__ = "2023020601" diff --git a/npbackup/secret_keys.py b/npbackup/secret_keys.py index 045cf36..40ea9f2 100644 --- a/npbackup/secret_keys.py +++ b/npbackup/secret_keys.py @@ -5,7 +5,7 @@ __intname__ = "npbackup.secret_keys" __author__ = "Orsiris de Jong" -__copyright__ = "Copyright (C) 2023 NetInvent" +__copyright__ = "Copyright (C) 2023-2024 NetInvent" __license__ = "GPL-3.0-only" __build__ = "2023120601" diff --git a/npbackup/upgrade_client/requestor.py b/npbackup/upgrade_client/requestor.py index dcdfb6a..1cbd3f9 100644 --- a/npbackup/upgrade_client/requestor.py +++ b/npbackup/upgrade_client/requestor.py @@ -5,7 +5,7 @@ __intname__ = "ofunctions.requestor" __author__ = "Orsiris de Jong" -__copyright__ = "Copyright (C) 2014-2023 NetInvent" +__copyright__ = "Copyright (C) 2014-2024 NetInvent" __license__ = "BSD-3-Clause" __build__ = "2022072201" diff --git a/npbackup/upgrade_client/upgrader.py b/npbackup/upgrade_client/upgrader.py index c178e7d..9e7de79 100644 --- a/npbackup/upgrade_client/upgrader.py +++ b/npbackup/upgrade_client/upgrader.py @@ -5,7 +5,7 @@ __intname__ = "npbackup.upgrade_client.upgrader" __author__ = "Orsiris de Jong" -__copyright__ = "Copyright (C) 2023 NetInvent" +__copyright__ = "Copyright (C) 2023-2024 NetInvent" __license__ = "BSD-3-Clause" __build__ = "20263040401" diff --git a/npbackup/windows/task.py b/npbackup/windows/task.py index 758ae3c..b057375 100644 --- a/npbackup/windows/task.py +++ b/npbackup/windows/task.py @@ -5,7 +5,7 @@ __intname__ = "npbackup.windows.task" __author__ = "Orsiris de Jong" -__copyright__ = "Copyright (C) 2022-2023 NetInvent" +__copyright__ = "Copyright (C) 2022-2024 NetInvent" __license__ = "GPL-3.0-only" __build__ = "2023020201" diff --git a/setup.py b/setup.py index 8e5d2f3..bca2bba 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ __intname__ = "npbackup.setup" __author__ = "Orsiris de Jong" -__copyright__ = "Copyright (C) 2022-2023 NetInvent" +__copyright__ = "Copyright (C) 2022-2024 NetInvent" __license__ = "GPL-3.0-only" __build__ = "2023012901" __setup_ver__ = "1.1.0" diff --git a/upgrade_server/upgrade_server.py b/upgrade_server/upgrade_server.py index ccd7deb..4e5318e 100644 --- a/upgrade_server/upgrade_server.py +++ b/upgrade_server/upgrade_server.py @@ -5,7 +5,7 @@ __intname__ = "npbackup.upgrade_server.upgrade_server" __author__ = "Orsiris de Jong" -__copyright__ = "Copyright (C) 2023 NetInvent" +__copyright__ = "Copyright (C) 2023-2024 NetInvent" __license__ = "GPL-3.0-only" __build__ = "2023020601" __version__ = "1.4.0" diff --git a/upgrade_server/upgrade_server/api.py b/upgrade_server/upgrade_server/api.py index 817bf3d..a25a707 100644 --- a/upgrade_server/upgrade_server/api.py +++ b/upgrade_server/upgrade_server/api.py @@ -5,7 +5,7 @@ __intname__ = "npbackup.upgrade_server.api" __author__ = "Orsiris de Jong" -__copyright__ = "Copyright (C) 2023 NetInvent" +__copyright__ = "Copyright (C) 2023-2024 NetInvent" __license__ = "GPL-3.0-only" __build__ = "2023030301" __appname__ = "npbackup.upgrader" diff --git a/upgrade_server/upgrade_server/configuration.py b/upgrade_server/upgrade_server/configuration.py index a2bb7fc..c313675 100644 --- a/upgrade_server/upgrade_server/configuration.py +++ b/upgrade_server/upgrade_server/configuration.py @@ -5,7 +5,7 @@ __intname__ = "npbackup.upgrade_server.configuration" __author__ = "Orsiris de Jong" -__copyright__ = "Copyright (C) 2023 NetInvent" +__copyright__ = "Copyright (C) 2023-2024 NetInvent" __license__ = "GPL-3.0-only" __build__ = "2023020601" diff --git a/upgrade_server/upgrade_server/crud.py b/upgrade_server/upgrade_server/crud.py index 14ab95d..da113f3 100644 --- a/upgrade_server/upgrade_server/crud.py +++ b/upgrade_server/upgrade_server/crud.py @@ -5,7 +5,7 @@ __intname__ = "npbackup.upgrade_server.crud" __author__ = "Orsiris de Jong" -__copyright__ = "Copyright (C) 2023 NetInvent" +__copyright__ = "Copyright (C) 2023-2024 NetInvent" __license__ = "GPL-3.0-only" __build__ = "2023020601" diff --git a/upgrade_server/upgrade_server/models/files.py b/upgrade_server/upgrade_server/models/files.py index 46229fb..cb6fe52 100644 --- a/upgrade_server/upgrade_server/models/files.py +++ b/upgrade_server/upgrade_server/models/files.py @@ -5,7 +5,7 @@ __intname__ = "npbackup.upgrade_server.models.files" __author__ = "Orsiris de Jong" -__copyright__ = "Copyright (C) 2023 NetInvent" +__copyright__ = "Copyright (C) 2023-2024 NetInvent" __license__ = "GPL-3.0-only" __build__ = "202303101" diff --git a/upgrade_server/upgrade_server/models/oper.py b/upgrade_server/upgrade_server/models/oper.py index 751ae27..32dc3e4 100644 --- a/upgrade_server/upgrade_server/models/oper.py +++ b/upgrade_server/upgrade_server/models/oper.py @@ -5,7 +5,7 @@ __intname__ = "npbackup.upgrade_server.models.oper" __author__ = "Orsiris de Jong" -__copyright__ = "Copyright (C) 2023 NetInvent" +__copyright__ = "Copyright (C) 2023-2024 NetInvent" __license__ = "GPL-3.0-only" __build__ = "202303101" From 5441c828080548a959a74b60e691bde615b4b8fe Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Wed, 3 Jan 2024 20:05:10 +0100 Subject: [PATCH 167/328] Updated copyright year for BSD licensed files --- npbackup/path_helper.py | 4 ++-- npbackup/restic_metrics/__init__.py | 2 +- tests/test_restic_metrics.py | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/npbackup/path_helper.py b/npbackup/path_helper.py index 2ef0ec1..237cb8f 100644 --- a/npbackup/path_helper.py +++ b/npbackup/path_helper.py @@ -5,8 +5,8 @@ __intname__ = "npbackup.path_helper" __author__ = "Orsiris de Jong" -__copyright__ = "Copyright (C) 2023 NetInvent" -__license__ = "GPL-3.0-only" +__copyright__ = "Copyright (C) 2023-2024 NetInvent" +__license__ = "BSD-3-Clause" __build__ = "2023012201" diff --git a/npbackup/restic_metrics/__init__.py b/npbackup/restic_metrics/__init__.py index f26c4aa..7814c31 100644 --- a/npbackup/restic_metrics/__init__.py +++ b/npbackup/restic_metrics/__init__.py @@ -4,7 +4,7 @@ __intname__ = "restic_metrics" __author__ = "Orsiris de Jong" -__copyright__ = "Copyright (C) 2022-2024 Orsiris de Jong - NetInvent" +__copyright__ = "Copyright (C) 2022-2024 NetInvent" __licence__ = "BSD-3-Clause" __version__ = "2.0.0" __build__ = "2024010101" diff --git a/tests/test_restic_metrics.py b/tests/test_restic_metrics.py index 6039327..c6ad7ca 100644 --- a/tests/test_restic_metrics.py +++ b/tests/test_restic_metrics.py @@ -4,8 +4,8 @@ __intname__ = "restic_metrics_tests" __author__ = "Orsiris de Jong" -__copyright__ = "Copyright (C) 2022-2024 Orsiris de Jong - NetInvent SASU" -__licence__ = "NetInvent CSE" +__copyright__ = "Copyright (C) 2022-2024 NetInvent" +__licence__ = "BSD-3-Clause" __build__ = "2024010101" __description__ = "Converts restic command line output to a text file node_exporter can scrape" __compat__ = "python3.6+" From 2229cec1d792e6d466b6f29204845fcce6cac616 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Thu, 4 Jan 2024 00:19:54 +0100 Subject: [PATCH 168/328] Update ls_window since we changed ls output to use live stdout, which is too slow --- npbackup/gui/__main__.py | 35 ++++++-------- npbackup/gui/helpers.py | 69 +++++++++++++++------------ npbackup/restic_wrapper/__init__.py | 5 +- npbackup/translations/main_gui.en.yml | 2 + npbackup/translations/main_gui.fr.yml | 1 + 5 files changed, 60 insertions(+), 52 deletions(-) diff --git a/npbackup/gui/__main__.py b/npbackup/gui/__main__.py index d428571..df8c72f 100644 --- a/npbackup/gui/__main__.py +++ b/npbackup/gui/__main__.py @@ -47,6 +47,7 @@ from npbackup.gui.config import config_gui from npbackup.gui.operations import operations_gui from npbackup.gui.helpers import get_anon_repo_uri, gui_thread_runner +from npbackup.gui.notification import display_notification from npbackup.core.i18n_helper import _t from npbackup.core.upgrade_runner import run_upgrade, check_new_version from npbackup.path_helper import CURRENT_DIR @@ -180,9 +181,6 @@ def _make_treedata_from_json(ls_result: List[dict]) -> sg.TreeData: """ treedata = sg.TreeData() count = 0 - # First entry of list of list should be the snapshot description and can be discarded - # Since we use an iter now, first result was discarded by ls_window function already - # ls_result.pop(0) for entry in ls_result: # Make sure we drop the prefix '/' so sg.TreeData does not get an empty root entry["path"] = entry["path"].lstrip("/") @@ -224,38 +222,35 @@ def _make_treedata_from_json(ls_result: List[dict]) -> sg.TreeData: def ls_window(repo_config: dict, snapshot_id: str) -> bool: result = gui_thread_runner( - repo_config, "ls", snapshot=snapshot_id, __autoclose=True, __compact=True + repo_config, "ls", snapshot=snapshot_id, __stdout=False, __autoclose=True, __compact=True ) if not result["result"]: + sg.Popup("main_gui.snapshot_is_empty") return None, None - - snapshot_content = result["output"] - try: - # Since ls returns an iter now, we need to use next - snapshot = next(snapshot_content) - # Exception that happens when restic cannot successfully get snapshot content - except StopIteration: - return None, None + # result is {"result": True, "output": [{snapshot_description}, {entry}, {entry}]} + content = result["output"] + # First entry of snapshot list is the snapshot description + snapshot = content.pop(0) try: snap_date = dateutil.parser.parse(snapshot["time"]) - except (KeyError, IndexError): + except (KeyError, IndexError, TypeError): snap_date = "[inconnu]" try: short_id = snapshot["short_id"] - except (KeyError, IndexError): - short_id = "[inconnu]" + except (KeyError, IndexError, TypeError): + short_id = None try: username = snapshot["username"] - except (KeyError, IndexError): + except (KeyError, IndexError, TypeError): username = "[inconnu]" try: hostname = snapshot["hostname"] - except (KeyError, IndexError): + except (KeyError, IndexError, TypeError): hostname = "[inconnu]" backup_id = f"{_t('main_gui.backup_content_from')} {snap_date} {_t('main_gui.run_as')} {username}@{hostname} {_t('main_gui.identified_by')} {short_id}" - if not backup_id or not snapshot_content: + if not backup_id or not snapshot or not short_id: sg.PopupError(_t("main_gui.cannot_get_content"), keep_on_top=True) return False @@ -265,7 +260,7 @@ def ls_window(repo_config: dict, snapshot_id: str) -> bool: # We get a thread result, hence pylint will complain the thread isn't a tuple # pylint: disable=E1101 (no-member) - thread = _make_treedata_from_json(snapshot_content) + thread = _make_treedata_from_json(content) while not thread.done() and not thread.cancelled(): sg.PopupAnimated( LOADER_ANIMATION, @@ -773,7 +768,7 @@ def get_config(default: bool = False): backup(repo_config) event = "--STATE-BUTTON--" if event == "--SEE-CONTENT--": - if not full_config: + if not repo_config: sg.PopupError(_t("main_gui.no_config")) continue if not values["snapshot-list"]: diff --git a/npbackup/gui/helpers.py b/npbackup/gui/helpers.py index 66418eb..3c209c7 100644 --- a/npbackup/gui/helpers.py +++ b/npbackup/gui/helpers.py @@ -77,6 +77,7 @@ def gui_thread_runner( __compact: bool = True, __autoclose: bool = False, __gui_msg: str = "", + __stdout: bool = True, *args, **kwargs, ): @@ -105,16 +106,17 @@ def _upgrade_from_compact_view(): if __repo_config: runner.repo_config = __repo_config - stdout_queue = queue.Queue() + if __stdout: + stdout_queue = queue.Queue() + runner.stdout = stdout_queue + stderr_queue = queue.Queue() + runner.stderr = stderr_queue fn = getattr(runner, __fn_name) logger.debug( f"gui_thread_runner runs {fn.__name__} {'with' if USE_THREADING else 'without'} threads" ) - runner.stdout = stdout_queue - runner.stderr = stderr_queue - stderr_has_messages = False if not __gui_msg: __gui_msg = "Operation" @@ -191,6 +193,7 @@ def _upgrade_from_compact_view(): _t("generic.close"), key="--EXIT--", button_color=(TXT_COLOR_LDR, BG_COLOR_LDR), + disabled=True ) ], ] @@ -212,13 +215,14 @@ def _upgrade_from_compact_view(): use_custom_titlebar=True, grab_anywhere=True, keep_on_top=True, + disable_close=True, # Don't allow closing this window via "X" since we still need to update it background_color=BG_COLOR_LDR, titlebar_icon=OEM_ICON, ) # Finalize the window event, values = progress_window.read(timeout=0.01) - read_stdout_queue = True + read_stdout_queue = __stdout read_stderr_queue = True read_queues = True if USE_THREADING: @@ -237,36 +241,38 @@ def _upgrade_from_compact_view(): if event == "--EXPAND--": _upgrade_from_compact_view() # Read stdout queue - try: - stdout_data = stdout_queue.get(timeout=GUI_CHECK_INTERVAL) - except queue.Empty: - pass - else: - if stdout_data is None: - logger.debug("gui_thread_runner got stdout queue close signal") - read_stdout_queue = False + if read_stdout_queue: + try: + stdout_data = stdout_queue.get(timeout=GUI_CHECK_INTERVAL) + except queue.Empty: + pass else: - progress_window["-OPERATIONS-PROGRESS-STDOUT-"].Update( - f"\n{stdout_data}", append=True - ) + if stdout_data is None: + logger.debug("gui_thread_runner got stdout queue close signal") + read_stdout_queue = False + else: + progress_window["-OPERATIONS-PROGRESS-STDOUT-"].Update( + f"\n{stdout_data}", append=True + ) # Read stderr queue - try: - stderr_data = stderr_queue.get(timeout=GUI_CHECK_INTERVAL) - except queue.Empty: - pass - else: - if stderr_data is None: - logger.debug("gui_thread_runner got stderr queue close signal") - read_stderr_queue = False + if read_stderr_queue: + try: + stderr_data = stderr_queue.get(timeout=GUI_CHECK_INTERVAL) + except queue.Empty: + pass else: - stderr_has_messages = True - # if __compact: - # for key in progress_window.AllKeysDict: - # progress_window[key].Update(visible=True) - progress_window["-OPERATIONS-PROGRESS-STDERR-"].Update( - f"\n{stderr_data}", append=True - ) + if stderr_data is None: + logger.debug("gui_thread_runner got stderr queue close signal") + read_stderr_queue = False + else: + stderr_has_messages = True + # if __compact: + # for key in progress_window.AllKeysDict: + # progress_window[key].Update(visible=True) + progress_window["-OPERATIONS-PROGRESS-STDERR-"].Update( + f"\n{stderr_data}", append=True + ) read_queues = read_stdout_queue or read_stderr_queue @@ -280,6 +286,7 @@ def _upgrade_from_compact_view(): # Make sure we will keep the window visible since we have errors __autoclose = False + progress_window["--EXIT--"].Update(disabled=False) # Keep the window open until user has done something progress_window["-LOADER-ANIMATION-"].Update(visible=False) if not __autoclose or stderr_has_messages: diff --git a/npbackup/restic_wrapper/__init__.py b/npbackup/restic_wrapper/__init__.py index f88d24e..28702a2 100644 --- a/npbackup/restic_wrapper/__init__.py +++ b/npbackup/restic_wrapper/__init__.py @@ -586,7 +586,10 @@ def convert_to_json_output(self, result, output, msg=None, **kwargs): output_is_list = False for line in output: if output_is_list: - js["output"].append(line) + try: + js["output"].append(json.loads(line)) + except json.decoder.JSONDecodeError: + js["output"].append(line) else: try: js["output"] = json.loads(line) diff --git a/npbackup/translations/main_gui.en.yml b/npbackup/translations/main_gui.en.yml index 9b1399e..f2f638b 100644 --- a/npbackup/translations/main_gui.en.yml +++ b/npbackup/translations/main_gui.en.yml @@ -43,6 +43,8 @@ en: config_error: Configuration error no_config: Please load / create a configuration before proceeding + snapshot_is_empty: Snapshot is empty + # logs last_messages: Last messages error_messages: Error messages \ No newline at end of file diff --git a/npbackup/translations/main_gui.fr.yml b/npbackup/translations/main_gui.fr.yml index 38e1911..39dc1ec 100644 --- a/npbackup/translations/main_gui.fr.yml +++ b/npbackup/translations/main_gui.fr.yml @@ -43,6 +43,7 @@ fr: config_error: Erreur de configuration no_config: Veuillez charger / créer une configuration avant de procéder + snapshot_is_empty: L'instantané est vide # logs last_messages: Last messages error_messages: Error messages \ No newline at end of file From e5941b3d5de2e1d7e5a2686e31055b0fe2894463 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Thu, 4 Jan 2024 00:21:11 +0100 Subject: [PATCH 169/328] Remove threaded window.close() since we're not slow anymore --- npbackup/gui/__main__.py | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/npbackup/gui/__main__.py b/npbackup/gui/__main__.py index df8c72f..1fa0f03 100644 --- a/npbackup/gui/__main__.py +++ b/npbackup/gui/__main__.py @@ -310,19 +310,7 @@ def ls_window(repo_config: dict, snapshot_id: str) -> bool: continue restore_window(repo_config, snapshot_id, values["-TREE-"]) - # Closing a big sg.TreeData is really slow - # This is a little trichery lesson - # Still we should open a case at PySimpleGUI to know why closing a sg.TreeData window is painfully slow # TODO - window.hide() - - @threaded - def _close_win(): - """ - Since closing a sg.Treedata takes alot of time, let's thread it into background - """ - window.close - - _close_win() + window.close() return True From 9fed9e31a9014e321a5841adc61e4a42fe86c223 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Thu, 4 Jan 2024 00:32:59 +0100 Subject: [PATCH 170/328] Make sure we never run viewer without uri/password --- npbackup/gui/__main__.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/npbackup/gui/__main__.py b/npbackup/gui/__main__.py index 1fa0f03..891d675 100644 --- a/npbackup/gui/__main__.py +++ b/npbackup/gui/__main__.py @@ -794,7 +794,10 @@ def get_config(default: bool = False): # Make sure we trigger a GUI refresh when configuration is changed event = "--STATE-BUTTON--" if event == "--OPEN-REPO--": - viewer_repo_uri, viewer_repo_password = viewer_repo_gui() + viewer_repo_uri, viewer_repo_password = viewer_repo_gui(viewer_repo_uri, viewer_repo_password) + if not viewer_repo_uri or not viewer_repo_password: + sg.Popup(_t("main_gui.repo_and_password_cannot_be_empty"), keep_on_top=True) + continue repo_config = viewer_create_repo(viewer_repo_uri, viewer_repo_password) event = "--STATE-BUTTON--" if event == "--LOAD-CONF--": From 9e9bda9807b71c60ed7bd1c8b5baa29eed2bf2b9 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Thu, 4 Jan 2024 01:27:21 +0100 Subject: [PATCH 171/328] Implemented --stdin backup source --- npbackup/__main__.py | 20 ++- npbackup/core/runner.py | 196 +++++++++++++++------------- npbackup/restic_wrapper/__init__.py | 136 ++++++++++--------- 3 files changed, 199 insertions(+), 153 deletions(-) diff --git a/npbackup/__main__.py b/npbackup/__main__.py index ac4efa5..d271f4a 100644 --- a/npbackup/__main__.py +++ b/npbackup/__main__.py @@ -191,6 +191,17 @@ def cli_interface(): action="store_true", help="Run in JSON API mode. Nothing else than JSON will be printed to stdout", ) + parser.add_argument( + "--stdin", + action="store_true", + help="Backup using data from stdin input" + ) + parser.add_argument( + "--stdin-filename", + type=str, + default=None, + help="Alternate filename for stdin, defaults to 'stdin.data'" + ) parser.add_argument( "-v", "--verbose", action="store_true", help="Show verbose output" ) @@ -289,7 +300,14 @@ def cli_interface(): "op_args": {}, } - if args.backup: + if args.stdin: + cli_args["operation"] = "backup" + cli_args["op_args"] = { + "force": True, + "read_from_stdin": True, + "stdin_filename": args.stdin_filename if args.stdin_filename else None + } + elif args.backup: cli_args["operation"] = "backup" cli_args["op_args"] = {"force": args.force} elif args.restore: diff --git a/npbackup/core/runner.py b/npbackup/core/runner.py index 216a2dd..b8e5a61 100644 --- a/npbackup/core/runner.py +++ b/npbackup/core/runner.py @@ -823,87 +823,88 @@ def has_recent_snapshot(self) -> bool: @is_ready @apply_config_to_restic_runner @catch_exceptions - def backup(self, force: bool = False) -> bool: + def backup(self, force: bool = False, read_from_stdin: bool = False, stdin_filename: str = "stdin.data") -> bool: """ Run backup after checking if no recent backup exists, unless force == True """ # Preflight checks - paths = self.repo_config.g("backup_opts.paths") - if not paths: - self.write_logs( - f"No paths to backup defined for repo {self.repo_config.g('name')}.", - level="error", - ) - return False + if not read_from_stdin: + paths = self.repo_config.g("backup_opts.paths") + if not paths: + self.write_logs( + f"No paths to backup defined for repo {self.repo_config.g('name')}.", + level="error", + ) + return False - # Make sure we convert paths to list if only one path is give - # Also make sure we remove trailing and ending spaces - try: - if not isinstance(paths, list): - paths = [paths] - paths = [path.strip() for path in paths] - for path in paths: - if path == self.repo_config.g("repo_uri"): - self.write_logs( - f"You cannot backup source into it's own path in repo {self.repo_config.g('name')}. No inception allowed !", - level="critical", - ) - return False - except KeyError: - self.write_logs( - f"No backup source given for repo {self.repo_config.g('name')}.", - level="error", - ) - return False + # Make sure we convert paths to list if only one path is give + # Also make sure we remove trailing and ending spaces + try: + if not isinstance(paths, list): + paths = [paths] + paths = [path.strip() for path in paths] + for path in paths: + if path == self.repo_config.g("repo_uri"): + self.write_logs( + f"You cannot backup source into it's own path in repo {self.repo_config.g('name')}. No inception allowed !", + level="critical", + ) + return False + except KeyError: + self.write_logs( + f"No backup source given for repo {self.repo_config.g('name')}.", + level="error", + ) + return False - source_type = self.repo_config.g("backup_opts.source_type") + source_type = self.repo_config.g("backup_opts.source_type") - # MSWindows does not support one-file-system option - exclude_patterns = self.repo_config.g("backup_opts.exclude_patterns") - if not isinstance(exclude_patterns, list): - exclude_patterns = [exclude_patterns] + # MSWindows does not support one-file-system option + exclude_patterns = self.repo_config.g("backup_opts.exclude_patterns") + if not isinstance(exclude_patterns, list): + exclude_patterns = [exclude_patterns] - exclude_files = self.repo_config.g("backup_opts.exclude_files") - if not isinstance(exclude_files, list): - exclude_files = [exclude_files] + exclude_files = self.repo_config.g("backup_opts.exclude_files") + if not isinstance(exclude_files, list): + exclude_files = [exclude_files] - excludes_case_ignore = self.repo_config.g("backup_opts.excludes_case_ignore") - exclude_caches = self.repo_config.g("backup_opts.exclude_caches") - - exclude_files_larger_than = self.repo_config.g( - "backup_opts.exclude_files_larger_than" - ) - if exclude_files_larger_than: - if not exclude_files_larger_than[-1] in ( - "k", - "K", - "m", - "M", - "g", - "G", - "t", - "T", - ): - self.write_logs( - f"Bogus suffix for exclude_files_larger_than value given: {exclude_files_larger_than}", - level="warning", - ) + excludes_case_ignore = self.repo_config.g("backup_opts.excludes_case_ignore") + exclude_caches = self.repo_config.g("backup_opts.exclude_caches") + + exclude_files_larger_than = self.repo_config.g( + "backup_opts.exclude_files_larger_than" + ) + if exclude_files_larger_than: + if not exclude_files_larger_than[-1] in ( + "k", + "K", + "m", + "M", + "g", + "G", + "t", + "T", + ): + self.write_logs( + f"Bogus suffix for exclude_files_larger_than value given: {exclude_files_larger_than}", + level="warning", + ) + exclude_files_larger_than = None + try: + float(exclude_files_larger_than[:-1]) + except (ValueError, TypeError): + self.write_logs( + f"Cannot check whether excludes_files_larger_than is a float: {exclude_files_larger_than}", + level="warning", + ) exclude_files_larger_than = None - try: - float(exclude_files_larger_than[:-1]) - except (ValueError, TypeError): - self.write_logs( - f"Cannot check whether excludes_files_larger_than is a float: {exclude_files_larger_than}", - level="warning", - ) - exclude_files_larger_than = None - one_file_system = ( - self.repo_config.g("backup_opts.one_file_system") - if os.name != "nt" - else False - ) - use_fs_snapshot = self.repo_config.g("backup_opts.use_fs_snapshot") + one_file_system = ( + self.repo_config.g("backup_opts.one_file_system") + if os.name != "nt" + else False + ) + use_fs_snapshot = self.repo_config.g("backup_opts.use_fs_snapshot") minimum_backup_size_error = self.repo_config.g( "backup_opts.minimum_backup_size_error" @@ -957,16 +958,19 @@ def backup(self, force: bool = False) -> bool: self.restic_runner.verbose = self.verbose # Run backup here - if source_type not in ["folder_list", None]: - self.write_logs( - f"Running backup of files in {paths} list to repo {self.repo_config.g('name')}", - level="info", - ) + if not read_from_stdin: + if source_type not in ["folder_list", None]: + self.write_logs( + f"Running backup of files in {paths} list to repo {self.repo_config.g('name')}", + level="info", + ) + else: + self.write_logs( + f"Running backup of {paths} to repo {self.repo_config.g('name')}", + level="info", + ) else: - self.write_logs( - f"Running backup of {paths} to repo {self.repo_config.g('name')}", - level="info", - ) + self.write_logs(f"Running backup of piped stdin data as name {stdin_filename} to repo {self.repo_config.g('name')}", level="info") pre_exec_commands_success = True if pre_exec_commands: @@ -989,19 +993,27 @@ def backup(self, force: bool = False) -> bool: ) self.restic_runner.dry_run = self.dry_run - result, result_string = self.restic_runner.backup( - paths=paths, - source_type=source_type, - exclude_patterns=exclude_patterns, - exclude_files=exclude_files, - excludes_case_ignore=excludes_case_ignore, - exclude_caches=exclude_caches, - exclude_files_larger_than=exclude_files_larger_than, - one_file_system=one_file_system, - use_fs_snapshot=use_fs_snapshot, - tags=tags, - additional_backup_only_parameters=additional_backup_only_parameters, - ) + if not read_from_stdin: + result, result_string = self.restic_runner.backup( + paths=paths, + source_type=source_type, + exclude_patterns=exclude_patterns, + exclude_files=exclude_files, + excludes_case_ignore=excludes_case_ignore, + exclude_caches=exclude_caches, + exclude_files_larger_than=exclude_files_larger_than, + one_file_system=one_file_system, + use_fs_snapshot=use_fs_snapshot, + tags=tags, + additional_backup_only_parameters=additional_backup_only_parameters, + ) + else: + result, result_string = self.restic_runner.backup( + read_from_stdin=read_from_stdin, + stdin_filename=stdin_filename, + tags=tags, + additional_backup_only_parameters=additional_backup_only_parameters + ) self.write_logs(f"Restic output:\n{result_string}", level="debug") # Extract backup size from result_string diff --git a/npbackup/restic_wrapper/__init__.py b/npbackup/restic_wrapper/__init__.py index 28702a2..1468cdd 100644 --- a/npbackup/restic_wrapper/__init__.py +++ b/npbackup/restic_wrapper/__init__.py @@ -253,6 +253,8 @@ def executor( errors_allowed: bool = False, no_output_queues: bool = False, timeout: int = None, + stdin: sys.stdin = None + ) -> Tuple[bool, str]: """ Executes restic with given command @@ -276,6 +278,7 @@ def executor( timeout=timeout, split_streams=False, encoding="utf-8", + stdin=stdin, stdout=self.stdout if not no_output_queues else None, stderr=self.stderr if not no_output_queues else None, no_close_queues=True, @@ -665,8 +668,8 @@ def snapshots(self) -> Union[bool, str, dict]: @check_if_init def backup( self, - paths: List[str], - source_type: str, + paths: List[str] = None, + source_type: str = None, exclude_patterns: List[str] = [], exclude_files: List[str] = [], excludes_case_ignore: bool = False, @@ -675,6 +678,8 @@ def backup( use_fs_snapshot: bool = False, tags: List[str] = [], one_file_system: bool = False, + read_from_stdin: bool = False, + stdin_filename: str = "stdin.data", additional_backup_only_parameters: str = None, ) -> Union[bool, str, dict]: """ @@ -683,78 +688,88 @@ def backup( kwargs = locals() kwargs.pop("self") - # Handle various source types - if source_type in [ - "files_from", - "files_from_verbatim", - "files_from_raw", - ]: - cmd = "backup" - if source_type == "files_from": - source_parameter = "--files-from" - elif source_type == "files_from_verbatim": - source_parameter = "--files-from-verbatim" - elif source_type == "files_from_raw": - source_parameter = "--files-from-raw" - else: - self.write_logs("Bogus source type given", level="error") - return False, "" - - for path in paths: - cmd += ' {} "{}"'.format(source_parameter, path) + if read_from_stdin: + cmd = "backup --stdin" + if stdin_filename: + cmd += f' --stdin-filename "{stdin_filename}"' else: - # make sure path is a list and does not have trailing slashes, unless we're backing up root - # We don't need to scan files for ETA, so let's add --no-scan - cmd = "backup --no-scan {}".format( - " ".join( - [ - '"{}"'.format(path.rstrip("/\\")) if path != "/" else path - for path in paths - ] - ) - ) + # Handle various source types + if source_type in [ + "files_from", + "files_from_verbatim", + "files_from_raw", + ]: + cmd = "backup" + if source_type == "files_from": + source_parameter = "--files-from" + elif source_type == "files_from_verbatim": + source_parameter = "--files-from-verbatim" + elif source_type == "files_from_raw": + source_parameter = "--files-from-raw" + else: + self.write_logs("Bogus source type given", level="error") + return False, "" - case_ignore_param = "" - # Always use case ignore excludes under windows - if os.name == "nt" or excludes_case_ignore: - case_ignore_param = "i" + for path in paths: + cmd += ' {} "{}"'.format(source_parameter, path) + else: + # make sure path is a list and does not have trailing slashes, unless we're backing up root + # We don't need to scan files for ETA, so let's add --no-scan + cmd = "backup --no-scan {}".format( + " ".join( + [ + '"{}"'.format(path.rstrip("/\\")) if path != "/" else path + for path in paths + ] + ) + ) - for exclude_pattern in exclude_patterns: - if exclude_pattern: - cmd += f' --{case_ignore_param}exclude "{exclude_pattern}"' - for exclude_file in exclude_files: - if exclude_file: - if os.path.isfile(exclude_file): - cmd += f' --{case_ignore_param}exclude-file "{exclude_file}"' + case_ignore_param = "" + # Always use case ignore excludes under windows + if os.name == "nt" or excludes_case_ignore: + case_ignore_param = "i" + + for exclude_pattern in exclude_patterns: + if exclude_pattern: + cmd += f' --{case_ignore_param}exclude "{exclude_pattern}"' + for exclude_file in exclude_files: + if exclude_file: + if os.path.isfile(exclude_file): + cmd += f' --{case_ignore_param}exclude-file "{exclude_file}"' + else: + self.write_logs( + f"Exclude file '{exclude_file}' not found", level="error" + ) + if exclude_caches: + cmd += " --exclude-caches" + if exclude_files_larger_than: + cmd += f" --exclude-files-larger-than {exclude_files_larger_than}" + if one_file_system: + cmd += " --one-file-system" + if use_fs_snapshot: + if os.name == "nt": + cmd += " --use-fs-snapshot" + self.write_logs("Using VSS snapshot to backup", level="info") else: self.write_logs( - f"Exclude file '{exclude_file}' not found", level="error" + "Parameter --use-fs-snapshot was given, which is only compatible with Windows", + level="warning", ) - if exclude_caches: - cmd += " --exclude-caches" - if exclude_files_larger_than: - cmd += f" --exclude-files-larger-than {exclude_files_larger_than}" - if one_file_system: - cmd += " --one-file-system" - if use_fs_snapshot: - if os.name == "nt": - cmd += " --use-fs-snapshot" - self.write_logs("Using VSS snapshot to backup", level="info") - else: - self.write_logs( - "Parameter --use-fs-snapshot was given, which is only compatible with Windows", - level="warning", - ) for tag in tags: if tag: tag = tag.strip() cmd += " --tag {}".format(tag) if additional_backup_only_parameters: cmd += " {}".format(additional_backup_only_parameters) - result, output = self.executor(cmd) + + # Run backup + if read_from_stdin: + result, output = self.executor(cmd, stdin=sys.stdin.buffer) + else: + result, output = self.executor(cmd) if ( - use_fs_snapshot + not read_from_stdin and use_fs_snapshot and not result and re.search("VSS Error", output, re.IGNORECASE) ): @@ -762,6 +777,7 @@ def backup( "VSS cannot be used. Backup will be done without VSS.", level="error" ) result, output = self.executor(cmd.replace(" --use-fs-snapshot", "")) + if self.json_output: return self.convert_to_json_output(result, output, **kwargs) if result: From 06aa5a07cb364e160eee581febbf4bede1810cb7 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Thu, 4 Jan 2024 02:03:19 +0100 Subject: [PATCH 172/328] Patch --json backup result --- npbackup/core/runner.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/npbackup/core/runner.py b/npbackup/core/runner.py index b8e5a61..7b4c710 100644 --- a/npbackup/core/runner.py +++ b/npbackup/core/runner.py @@ -1017,7 +1017,7 @@ def backup(self, force: bool = False, read_from_stdin: bool = False, stdin_filen self.write_logs(f"Restic output:\n{result_string}", level="debug") # Extract backup size from result_string - minimum_backup_size_error = 0 + minimum_backup_size_error = 0 # TODO metric_writer( self.repo_config, result, result_string, self.restic_runner.dry_run ) @@ -1049,7 +1049,10 @@ def backup(self, force: bool = False, read_from_stdin: bool = False, stdin_filen f"Operation finished with {'success' if operation_result else 'failure'}", level="info" if operation_result else "error", ) - return operation_result + if not operation_result: + if isinstance(result, dict): + result["result"] = False + return result @threaded @close_queues From 950b86a52e56ca3981af78b9ff61899de7d25ce8 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Thu, 4 Jan 2024 02:03:36 +0100 Subject: [PATCH 173/328] Use convert_to_json on every function --- npbackup/restic_wrapper/__init__.py | 77 ++++++++++++++++------------- 1 file changed, 43 insertions(+), 34 deletions(-) diff --git a/npbackup/restic_wrapper/__init__.py b/npbackup/restic_wrapper/__init__.py index 1468cdd..7219c3e 100644 --- a/npbackup/restic_wrapper/__init__.py +++ b/npbackup/restic_wrapper/__init__.py @@ -629,7 +629,11 @@ def list(self, subject: str) -> Union[bool, str, dict]: cmd = "list {}".format(subject) result, output = self.executor(cmd) - return self.convert_to_json_output(result, output, **kwargs) + if result: + msg = f"Successfully listed {subject} objects" + else: + msg = f"Failed to list {subject} objects:\n{output}" + return self.convert_to_json_output(result, output, msg=msg, **kwargs) @check_if_init @@ -649,7 +653,11 @@ def ls(self, snapshot: str) -> Union[bool, str, dict]: cmd = "ls {}".format(snapshot) result, output = self.executor(cmd) - return self.convert_to_json_output(result, output, **kwargs) + if result: + msg = f"Successfuly listed snapshot {snapshot} content" + else: + msg = f"Could not list snapshot {snapshot} content:\n{output}" + return self.convert_to_json_output(result, output, msg=msg, **kwargs) @check_if_init @@ -663,7 +671,11 @@ def snapshots(self) -> Union[bool, str, dict]: cmd = "snapshots" result, output = self.executor(cmd) - return self.convert_to_json_output(result, output, **kwargs) + if result: + msg = "Snapshots listed successfully" + else: + msg = f"Could not list snapshots:n{output}" + return self.convert_to_json_output(result, output, msg=msg, **kwargs) @check_if_init def backup( @@ -778,13 +790,13 @@ def backup( ) result, output = self.executor(cmd.replace(" --use-fs-snapshot", "")) - if self.json_output: - return self.convert_to_json_output(result, output, **kwargs) if result: - self.write_logs("Backend finished backup with success", level="info") - return output - self.write_logs("Backup failed backup operation", level="error") - return False + msg = "Backend finished backup with success" + else: + msg = f"Backup failed backup operation:\n{output}" + # For backups, we need to return the result string restic too, for metrics analysis + result = self.convert_to_json_output(result, output, msg=msg, **kwargs) + return result, output @check_if_init def find(self, path: str) -> Union[bool, str, dict]: @@ -797,7 +809,11 @@ def find(self, path: str) -> Union[bool, str, dict]: cmd = f'find "{path}"' result, output = self.executor(cmd) - return self.convert_to_json_output(result, output, **kwargs) + if result: + msg = "Find command succeed" + else: + msg = f"Could not find path {path}:\n{output}" + return self.convert_to_json_output(result, output, msg=msg, **kwargs) @check_if_init @@ -818,13 +834,12 @@ def restore(self, snapshot: str, target: str, includes: List[str] = None) -> Uni if include: cmd += ' --{}include "{}"'.format(case_ignore_param, include) result, output = self.executor(cmd) - if self.json_output: - return self.convert_to_json_output(result, output, **kwargs) if result: - self.write_logs("successfully restored data.", level="info") - return True - self.write_logs(f"Data not restored: {output}", level="info") - return False + msg = "successfully restored data" + else: + msg = f"Data not restored:\n{output}" + return self.convert_to_json_output(result, output, msg=msg, **kwargs) + @check_if_init def forget( @@ -936,13 +951,11 @@ def repair(self, subject: str) -> Union[bool, str, dict]: return False cmd = f"repair {subject}" result, output = self.executor(cmd) - if self.json_output: - return self.convert_to_json_output(result, output, **kwargs) if result: - self.write_logs(f"Repo successfully repaired:\n{output}", level="info") - return True - self.write_logs(f"Repo repair failed:\n {output}", level="critical") - return False + msg = f"Repo successfully repaired:\n{output}" + else: + msg = f"Repo repair failed:\n{output}" + return self.convert_to_json_output(result, output, msg=msg, **kwargs) @check_if_init def unlock(self) -> Union[bool, str, dict]: @@ -954,13 +967,11 @@ def unlock(self) -> Union[bool, str, dict]: cmd = f"unlock" result, output = self.executor(cmd) - if self.json_output: - return self.convert_to_json_output(result, output, **kwargs) if result: - self.write_logs(f"Repo successfully unlocked", level="info") - return True - self.write_logs(f"Repo unlock failed:\n {output}", level="critical") - return False + msg = f"Repo successfully unlocked" + else: + msg = f"Repo unlock failed:\n{output}" + return self.convert_to_json_output(result, output, msg=msg, **kwargs) @check_if_init def dump(self, path: str) -> Union[bool, str, dict]: @@ -1003,13 +1014,11 @@ def raw(self, command: str) -> Union[bool, str, dict]: kwargs.pop("self") result, output = self.executor(command) - if self.json_output: - return self.convert_to_json_output(result, output, **kwargs) if result: - self.write_logs(f"successfully run raw command:\n{output}", level="info") - return True, output - self.write_logs("Raw command failed.", level="error") - return False, output + msg = f"successfully run raw command:\n{output}" + else: + msg = "Raw command failed:\n{output}" + return self.convert_to_json_output(result, output, msg=msg, **kwargs) @staticmethod def _has_recent_snapshot( From f107ff11bf216232478282cb1fd83bcbcf1ff9cc Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Tue, 9 Jan 2024 20:55:31 +0100 Subject: [PATCH 174/328] Update requirements.txt --- npbackup/requirements.txt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/npbackup/requirements.txt b/npbackup/requirements.txt index 5280a21..8738e98 100644 --- a/npbackup/requirements.txt +++ b/npbackup/requirements.txt @@ -1,11 +1,11 @@ -command_runner>=1.5.2 +command_runner>=1.6.0 cryptidy>=1.2.2 python-dateutil ofunctions.logger_utils>=2.4.1 -ofunctions.misc>=1.6.3 +ofunctions.misc>=1.6.4 ofunctions.process>=2.0.0 ofunctions.threading>=2.2.0 -ofunctions.platform>=1.4.1 +ofunctions.platform>=1.5.0 ofunctions.random python-pidfile>=3.0.0 pysimplegui>=4.6.0 From e803eebb81d5350277421848a6fb76d4f195616a Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Tue, 9 Jan 2024 21:26:57 +0100 Subject: [PATCH 175/328] Fix config file check --- npbackup/__main__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/npbackup/__main__.py b/npbackup/__main__.py index d271f4a..817fae5 100644 --- a/npbackup/__main__.py +++ b/npbackup/__main__.py @@ -267,8 +267,9 @@ def cli_interface(): CONFIG_FILE = args.config_file else: config_file = Path(f"{CURRENT_DIR}/npbackup.conf") - if config_file.exists: + if config_file.exists(): CONFIG_FILE = config_file + logger.info(f"Loading default configuration file {config_file}") else: msg = "Cannot run without configuration file." json_error_logging(False, msg, "critical") From de234e810a4af5df849a8bb00bff6e29cf6fe8fe Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Tue, 9 Jan 2024 21:27:09 +0100 Subject: [PATCH 176/328] Improve logs --- npbackup/restic_wrapper/__init__.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/npbackup/restic_wrapper/__init__.py b/npbackup/restic_wrapper/__init__.py index 7219c3e..2595de7 100644 --- a/npbackup/restic_wrapper/__init__.py +++ b/npbackup/restic_wrapper/__init__.py @@ -1069,7 +1069,8 @@ def has_recent_snapshot(self, delta: int = None) -> Tuple[bool, Optional[datetim # Don't bother to deal with mising delta if not delta: if self.json_output: - self.convert_to_json_output(False, None, **kwargs) + msg = "No delta given" + self.convert_to_json_output(False, None, msg=msg **kwargs) return False, None try: # Make sure we run with json support for this one @@ -1077,7 +1078,8 @@ def has_recent_snapshot(self, delta: int = None) -> Tuple[bool, Optional[datetim result = self.snapshots() if self.last_command_status is False: if self.json_output: - return self.convert_to_json_output(False, None, **kwargs) + msg = "Could not check for snapshots" + return self.convert_to_json_output(False, None, msg=msg, **kwargs) return False, None snapshots = result["output"] result, timestamp = self._has_recent_snapshot(snapshots, delta) From 2ad353b394c57c78881dd875ef454da2c321cc54 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Tue, 9 Jan 2024 21:27:28 +0100 Subject: [PATCH 177/328] Fix datetime objects serialization --- npbackup/runner_interface.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/npbackup/runner_interface.py b/npbackup/runner_interface.py index 8763b44..4eec566 100644 --- a/npbackup/runner_interface.py +++ b/npbackup/runner_interface.py @@ -15,12 +15,23 @@ import sys from logging import getLogger import json +import datetime from npbackup.core.runner import NPBackupRunner logger = getLogger() +def serialize_datetime(obj): + """ + By default, datetime objects aren't serialisable to json directly + Here's a quick converter from https://www.geeksforgeeks.org/how-to-fix-datetime-datetime-not-json-serializable-in-python/ + """ + if isinstance(obj, datetime.datetime): + return obj.isoformat() + raise TypeError("Type not serializable") + + def entrypoint(*args, **kwargs): npbackup_runner = NPBackupRunner() npbackup_runner.repo_config = kwargs.pop("repo_config") @@ -34,7 +45,7 @@ def entrypoint(*args, **kwargs): if not json_output: logger.info(f"Operation finished with {result}") else: - print(json.dumps(result)) + print(json.dumps(result, default=serialize_datetime)) sys.exit(0) def auto_upgrade(full_config: dict): From b8c75b35d350efd6f0f97d5e8d5c952ada741c3e Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Tue, 9 Jan 2024 21:30:01 +0100 Subject: [PATCH 178/328] Fix forget when only one snapshot is given --- npbackup/restic_wrapper/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/npbackup/restic_wrapper/__init__.py b/npbackup/restic_wrapper/__init__.py index 2595de7..f8aa3ba 100644 --- a/npbackup/restic_wrapper/__init__.py +++ b/npbackup/restic_wrapper/__init__.py @@ -865,7 +865,7 @@ def forget( for snapshot in snapshots: cmds.append(f"forget {snapshot}") else: - cmds = f"forget {snapshots}" + cmds = [f"forget {snapshots}"] if policy: cmd = "forget" for key, value in policy.items(): From fca5343f5288311ddb7857c80e6f63e8cab275cf Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Tue, 9 Jan 2024 21:40:24 +0100 Subject: [PATCH 179/328] Fix has_recent_snapshot output without json --- npbackup/core/runner.py | 10 ++++------ npbackup/restic_wrapper/__init__.py | 4 +++- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/npbackup/core/runner.py b/npbackup/core/runner.py index 7b4c710..6abf76c 100644 --- a/npbackup/core/runner.py +++ b/npbackup/core/runner.py @@ -782,18 +782,16 @@ def has_recent_snapshot(self) -> bool: ) # Temporarily disable verbose and enable json result self.restic_runner.verbose = False - json_output = self.restic_runner.json_output - self.restic_runner.json_output = True data = self.restic_runner.has_recent_snapshot( self.minimum_backup_age ) self.restic_runner.verbose = self.verbose - self.restic_runner.json_output = json_output if self.json_output: return data - - result = data["result"] - backup_tz = data["output"] + + # has_recent_snapshot returns a tuple when not self.json_output + result = data[0] + backup_tz = data[1] if result: self.write_logs( f"Most recent backup in repo {self.repo_config.g('name')} is from {backup_tz}", diff --git a/npbackup/restic_wrapper/__init__.py b/npbackup/restic_wrapper/__init__.py index f8aa3ba..7045128 100644 --- a/npbackup/restic_wrapper/__init__.py +++ b/npbackup/restic_wrapper/__init__.py @@ -1074,8 +1074,10 @@ def has_recent_snapshot(self, delta: int = None) -> Tuple[bool, Optional[datetim return False, None try: # Make sure we run with json support for this one - + json_output = self.json_output + self.json_output = True result = self.snapshots() + self.json_output = json_output if self.last_command_status is False: if self.json_output: msg = "Could not check for snapshots" From e53e92ad0c482dd51afef6322221723b8057ea0a Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Tue, 9 Jan 2024 21:50:42 +0100 Subject: [PATCH 180/328] WIP: Change restic backup output to json compat format --- npbackup/core/runner.py | 22 +++++++--------------- npbackup/restic_wrapper/__init__.py | 5 ++--- 2 files changed, 9 insertions(+), 18 deletions(-) diff --git a/npbackup/core/runner.py b/npbackup/core/runner.py index 6abf76c..fd1da2c 100644 --- a/npbackup/core/runner.py +++ b/npbackup/core/runner.py @@ -829,11 +829,8 @@ def backup(self, force: bool = False, read_from_stdin: bool = False, stdin_filen if not read_from_stdin: paths = self.repo_config.g("backup_opts.paths") if not paths: - self.write_logs( - f"No paths to backup defined for repo {self.repo_config.g('name')}.", - level="error", - ) - return False + output = f"No paths to backup defined for repo {self.repo_config.g('name')}" + return self.convert_to_json_output(False, output) # Make sure we convert paths to list if only one path is give # Also make sure we remove trailing and ending spaces @@ -843,17 +840,11 @@ def backup(self, force: bool = False, read_from_stdin: bool = False, stdin_filen paths = [path.strip() for path in paths] for path in paths: if path == self.repo_config.g("repo_uri"): - self.write_logs( - f"You cannot backup source into it's own path in repo {self.repo_config.g('name')}. No inception allowed !", - level="critical", - ) - return False + output = f"You cannot backup source into it's own path in repo {self.repo_config.g('name')}. No inception allowed !" + return self.convert_to_json_output(False, output) except KeyError: - self.write_logs( - f"No backup source given for repo {self.repo_config.g('name')}.", - level="error", - ) - return False + output = f"No backup source given for repo {self.repo_config.g('name')}" + return self.convert_to_json_output(False, output) source_type = self.repo_config.g("backup_opts.source_type") @@ -883,6 +874,7 @@ def backup(self, force: bool = False, read_from_stdin: bool = False, stdin_filen "t", "T", ): + # TODO: we need to bring this message to json self.write_logs( f"Bogus suffix for exclude_files_larger_than value given: {exclude_files_larger_than}", level="warning", diff --git a/npbackup/restic_wrapper/__init__.py b/npbackup/restic_wrapper/__init__.py index 7045128..8e23459 100644 --- a/npbackup/restic_wrapper/__init__.py +++ b/npbackup/restic_wrapper/__init__.py @@ -794,9 +794,8 @@ def backup( msg = "Backend finished backup with success" else: msg = f"Backup failed backup operation:\n{output}" - # For backups, we need to return the result string restic too, for metrics analysis - result = self.convert_to_json_output(result, output, msg=msg, **kwargs) - return result, output + + return self.convert_to_json_output(result, output, msg=msg, **kwargs) @check_if_init def find(self, path: str) -> Union[bool, str, dict]: From 9516606c5ffadee4bdf912e7b30661b07f9f817b Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Wed, 10 Jan 2024 21:23:57 +0100 Subject: [PATCH 181/328] Replace init with snapshots to test repo existence --- npbackup/__env__.py | 2 +- npbackup/restic_wrapper/__init__.py | 43 +++++++++++++++++------------ 2 files changed, 27 insertions(+), 18 deletions(-) diff --git a/npbackup/__env__.py b/npbackup/__env__.py index ae0eb42..f6067cb 100644 --- a/npbackup/__env__.py +++ b/npbackup/__env__.py @@ -25,4 +25,4 @@ # Arbitrary timeout for init / init checks. # If init takes more than a minute, we really have a problem in our backend -INIT_TIMEOUT = 60 +FAST_COMMANDS_TIMEOUT = 60 diff --git a/npbackup/restic_wrapper/__init__.py b/npbackup/restic_wrapper/__init__.py index 8e23459..1502df2 100644 --- a/npbackup/restic_wrapper/__init__.py +++ b/npbackup/restic_wrapper/__init__.py @@ -23,7 +23,7 @@ from functools import wraps from command_runner import command_runner from npbackup.__debug__ import _DEBUG -from npbackup.__env__ import INIT_TIMEOUT, CHECK_INTERVAL +from npbackup.__env__ import FAST_COMMANDS_TIMEOUT, CHECK_INTERVAL logger = getLogger() @@ -491,17 +491,13 @@ def init( self, repository_version: int = 2, compression: str = "auto", - errors_allowed: bool = False, ) -> bool: cmd = "init --repository-version {} --compression {}".format( repository_version, compression ) - # We don't want output_queues here since we don't want is already inialized errors to show up result, output = self.executor( cmd, - errors_allowed=errors_allowed, - no_output_queues=True, - timeout=INIT_TIMEOUT, + timeout=FAST_COMMANDS_TIMEOUT, ) if result: if re.search( @@ -510,8 +506,8 @@ def init( self.is_init = True return True else: - if re.search(".*already exists", output, re.IGNORECASE): - self.write_logs("Repo is initialized.", level="info") + if re.search(".*already exists|.*already initialized", output, re.IGNORECASE): + self.write_logs("Repo is already initialized.", level="info") self.is_init = True return True self.write_logs(f"Cannot contact repo: {output}", level="error") @@ -522,8 +518,13 @@ def init( @property def is_init(self): - if self._is_init is None: - self.init(errors_allowed=True) + """ + We'll just check if snapshots can be read + """ + cmd = "snapshots" + self._is_init, output = self.executor(cmd, timeout=FAST_COMMANDS_TIMEOUT, errors_allowed=True) + if not self._is_init: + self.write_logs(output, level="error") return self._is_init @is_init.setter @@ -542,17 +543,25 @@ def last_command_status(self, value: bool): def check_if_init(fn: Callable): """ Decorator to check that we don't do anything unless repo is initialized + Also auto init repo when backing up """ @wraps(fn) def wrapper(self, *args, **kwargs): if not self.is_init: - # pylint: disable=E1101 (no-member) - self.write_logs( - "Backend is not ready to perform operation {fn.__name}", - level="error", - ) - return None + if fn.__name__ == "backup": + if not self.init(): + self.write_logs( + f"Could not initialize repo for backup operation", level="critical" + ) + return None + else: + # pylint: disable=E1101 (no-member) + self.write_logs( + f"Backend is not ready to perform operation {fn.__name__}", + level="error", + ) + return None # pylint: disable=E1102 (not-callable) return fn(self, *args, **kwargs) @@ -670,7 +679,7 @@ def snapshots(self) -> Union[bool, str, dict]: kwargs.pop("self") cmd = "snapshots" - result, output = self.executor(cmd) + result, output = self.executor(cmd, timeout=FAST_COMMANDS_TIMEOUT) if result: msg = "Snapshots listed successfully" else: From 734fecf6082c265fa5cc4a2b3ff0a7fb54f4a933 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Thu, 11 Jan 2024 03:08:34 +0100 Subject: [PATCH 182/328] Make result message more clear --- npbackup/runner_interface.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/npbackup/runner_interface.py b/npbackup/runner_interface.py index 4eec566..6d5e9ca 100644 --- a/npbackup/runner_interface.py +++ b/npbackup/runner_interface.py @@ -43,7 +43,7 @@ def entrypoint(*args, **kwargs): **kwargs.pop("op_args"), __no_threads=True ) if not json_output: - logger.info(f"Operation finished with {result}") + logger.info(f"Operation finished with {'success' if result else 'failure'}") else: print(json.dumps(result, default=serialize_datetime)) sys.exit(0) From 41322bc744d078418fe4a6f24d3330210d4c0d05 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Thu, 11 Jan 2024 03:08:58 +0100 Subject: [PATCH 183/328] WIP: Refactor both json and str restic output --- npbackup/core/runner.py | 122 +++++++++++++++------------- npbackup/restic_wrapper/__init__.py | 9 +- 2 files changed, 71 insertions(+), 60 deletions(-) diff --git a/npbackup/core/runner.py b/npbackup/core/runner.py index fd1da2c..6d0e57f 100644 --- a/npbackup/core/runner.py +++ b/npbackup/core/runner.py @@ -23,7 +23,7 @@ from command_runner import command_runner from ofunctions.threading import threaded from ofunctions.platform import os_arch -from npbackup.restic_metrics import restic_output_2_metrics, upload_metrics +from npbackup.restic_metrics import restic_str_output_to_json, restic_json_to_prometheus, upload_metrics from npbackup.restic_wrapper import ResticRunner from npbackup.core.restic_source_binary import get_restic_internal_binary from npbackup.path_helper import CURRENT_DIR, BASEDIR @@ -46,6 +46,7 @@ def metric_writer( no_cert_verify = repo_config.g("prometheus.no_cert_verify") destination = repo_config.g("prometheus.destination") prometheus_additional_labels = repo_config.g("prometheus.additional_labels") + minimum_backup_size_error = repo_config.g("backup_opts.minimum_backup_size_error") # TODO if not isinstance(prometheus_additional_labels, list): prometheus_additional_labels = [prometheus_additional_labels] @@ -73,8 +74,13 @@ def metric_writer( logger.debug("Trace:", exc_info=True) label_string += ',npversion="{}{}"'.format(NAME, VERSION) - errors, metrics = restic_output_2_metrics( - restic_result=restic_result, output=result_string, labels=label_string + + # If result was a str, we need to transform it into json first + if isinstance(result_string, str): + restic_result = restic_str_output_to_json(restic_result, result_string) + + errors, metrics = restic_json_to_prometheus( + restic_result=restic_result, output=restic_result, labels=label_string ) if errors or not restic_result: logger.error("Restic finished with errors.") @@ -671,11 +677,15 @@ def _apply_config_to_restic_runner(self) -> bool: return True - def convert_to_json_output(self, result: bool, output: str = None): + def convert_to_json_output(self, result: bool, output: str = None, backend_js: dict = None, warnings: str = None): if self.json_output: + if backend_js: + js = backend_js js = { "result": result, } + if warnings: + js["warnings"] = warnings if result: js["output"] = output else: @@ -825,12 +835,16 @@ def backup(self, force: bool = False, read_from_stdin: bool = False, stdin_filen """ Run backup after checking if no recent backup exists, unless force == True """ + # Possible warnings to add to json output + warnings = [] + # Preflight checks if not read_from_stdin: paths = self.repo_config.g("backup_opts.paths") if not paths: - output = f"No paths to backup defined for repo {self.repo_config.g('name')}" - return self.convert_to_json_output(False, output) + msg = f"No paths to backup defined for repo {self.repo_config.g('name')}" + self.write_logs(msg, level="critical") + return self.convert_to_json_output(False, msg) # Make sure we convert paths to list if only one path is give # Also make sure we remove trailing and ending spaces @@ -840,11 +854,13 @@ def backup(self, force: bool = False, read_from_stdin: bool = False, stdin_filen paths = [path.strip() for path in paths] for path in paths: if path == self.repo_config.g("repo_uri"): - output = f"You cannot backup source into it's own path in repo {self.repo_config.g('name')}. No inception allowed !" - return self.convert_to_json_output(False, output) + msg = f"You cannot backup source into it's own path in repo {self.repo_config.g('name')}. No inception allowed !" + self.write_logs(msg, level="critical") + return self.convert_to_json_output(False, msg) except KeyError: - output = f"No backup source given for repo {self.repo_config.g('name')}" - return self.convert_to_json_output(False, output) + msg = f"No backup source given for repo {self.repo_config.g('name')}" + self.write_logs(msg, level="critical") + return self.convert_to_json_output(False, msg) source_type = self.repo_config.g("backup_opts.source_type") @@ -874,19 +890,16 @@ def backup(self, force: bool = False, read_from_stdin: bool = False, stdin_filen "t", "T", ): - # TODO: we need to bring this message to json - self.write_logs( - f"Bogus suffix for exclude_files_larger_than value given: {exclude_files_larger_than}", - level="warning", - ) + warning = f"Bogus suffix for exclude_files_larger_than value given: {exclude_files_larger_than}" + self.write_logs( warning, level="warning") + warnings.append(warning) exclude_files_larger_than = None try: float(exclude_files_larger_than[:-1]) except (ValueError, TypeError): - self.write_logs( - f"Cannot check whether excludes_files_larger_than is a float: {exclude_files_larger_than}", - level="warning", - ) + warning = f"Cannot check whether excludes_files_larger_than is a float: {exclude_files_larger_than}" + self.write_logs(warning, level="warning") + warnings.append(warning) exclude_files_larger_than = None one_file_system = ( @@ -896,10 +909,6 @@ def backup(self, force: bool = False, read_from_stdin: bool = False, stdin_filen ) use_fs_snapshot = self.repo_config.g("backup_opts.use_fs_snapshot") - minimum_backup_size_error = self.repo_config.g( - "backup_opts.minimum_backup_size_error" - ) - pre_exec_commands = self.repo_config.g("backup_opts.pre_exec_commands") pre_exec_per_command_timeout = self.repo_config.g( "backup_opts.pre_exec_per_command_timeout" @@ -928,23 +937,20 @@ def backup(self, force: bool = False, read_from_stdin: bool = False, stdin_filen "backup_opts.additional_backup_only_parameters" ) - # Check if backup is required + # Check if backup is required, no need to be verbose, but we'll make sure we don't get a json result here self.restic_runner.verbose = False - if not self.restic_runner.is_init: - if not self.restic_runner.init(): - self.write_logs( - f"Cannot continue, repo {self.repo_config.g('name')} is not defined.", - level="critical", - ) - return False + json_output = self.json_output + self.json_output = False # Since we don't want to close queues nor create a subthread, we need to change behavior here # pylint: disable=E1123 (unexpected-keyword-arg) - if ( - self.has_recent_snapshot(__close_queues=False, __no_threads=True) - and not force - ): - self.write_logs("No backup necessary.", level="info") - return True + has_recent_snapshots, backup_tz = self.has_recent_snapshot(__close_queues=False, __no_threads=True) + self.json_output = json_output + # We also need to "reapply" the json setting to backend + self.restic_runner.json_output = json_output + if has_recent_snapshots and not force: + msg = "No backup necessary" + self.write_logs(msg, level="info") + return self.convert_to_json_output(True, msg) self.restic_runner.verbose = self.verbose # Run backup here @@ -969,12 +975,12 @@ def backup(self, force: bool = False, read_from_stdin: bool = False, stdin_filen pre_exec_command, shell=True, timeout=pre_exec_per_command_timeout ) if exit_code != 0: - self.write_logs( - f"Pre-execution of command {pre_exec_command} failed with:\n{output}", - level="error", - ) + msg = f"Pre-execution of command {pre_exec_command} failed with:\n{output}" + self.write_logs(msg, level="error") if pre_exec_failure_is_fatal: - return False + return self.convert_to_json_output(False, msg) + else: + warnings.append(msg) pre_exec_commands_success = False else: self.write_logs( @@ -984,7 +990,7 @@ def backup(self, force: bool = False, read_from_stdin: bool = False, stdin_filen self.restic_runner.dry_run = self.dry_run if not read_from_stdin: - result, result_string = self.restic_runner.backup( + result = self.restic_runner.backup( paths=paths, source_type=source_type, exclude_patterns=exclude_patterns, @@ -998,18 +1004,18 @@ def backup(self, force: bool = False, read_from_stdin: bool = False, stdin_filen additional_backup_only_parameters=additional_backup_only_parameters, ) else: - result, result_string = self.restic_runner.backup( + result = self.restic_runner.backup( read_from_stdin=read_from_stdin, stdin_filename=stdin_filename, tags=tags, additional_backup_only_parameters=additional_backup_only_parameters ) - self.write_logs(f"Restic output:\n{result_string}", level="debug") + + self.write_logs(f"Restic output:\n{self.restic_runner.backup_result_content}", level="debug") # Extract backup size from result_string - minimum_backup_size_error = 0 # TODO - metric_writer( - self.repo_config, result, result_string, self.restic_runner.dry_run + metrics_analyzer_result = metric_writer( + self.repo_config, result, self.restic_runner.backup_result_content, self.restic_runner.dry_run ) post_exec_commands_success = True @@ -1019,13 +1025,13 @@ def backup(self, force: bool = False, read_from_stdin: bool = False, stdin_filen post_exec_command, shell=True, timeout=post_exec_per_command_timeout ) if exit_code != 0: - self.write_logs( - f"Post-execution of command {post_exec_command} failed with:\n{output}", - level="error", - ) + msg = f"Post-execution of command {post_exec_command} failed with:\n{output}" + self.write_logs(msg, level="error") post_exec_commands_success = False if post_exec_failure_is_fatal: - return False + return self.convert_to_json_output(False, msg) + else: + warnings.append(msg) else: self.write_logs( f"Post-execution of command {post_exec_command} success with:\n{output}.", @@ -1035,14 +1041,16 @@ def backup(self, force: bool = False, read_from_stdin: bool = False, stdin_filen operation_result = ( result and pre_exec_commands_success and post_exec_commands_success ) - self.write_logs( - f"Operation finished with {'success' if operation_result else 'failure'}", - level="info" if operation_result else "error", + msg = f"Operation finished with {'success' if operation_result else 'failure'}" + self.write_logs(msg, level="info" if operation_result else "error", ) if not operation_result: + # patch result if json if isinstance(result, dict): result["result"] = False - return result + # Don't overwrite backend output in case of failure + return self.convert_to_json_output(result) + return self.convert_to_json_output(result, msg) @threaded @close_queues diff --git a/npbackup/restic_wrapper/__init__.py b/npbackup/restic_wrapper/__init__.py index 1502df2..202eb60 100644 --- a/npbackup/restic_wrapper/__init__.py +++ b/npbackup/restic_wrapper/__init__.py @@ -47,6 +47,8 @@ def __init__( self._dry_run = False self._json_output = False + self.backup_result_content = None + self._binary = None self.binary_search_paths = binary_search_paths self._get_binary() @@ -669,7 +671,7 @@ def ls(self, snapshot: str) -> Union[bool, str, dict]: return self.convert_to_json_output(result, output, msg=msg, **kwargs) - @check_if_init + # @check_if_init # We don't need to run if init before checking snapshots since if init searches for snapshots def snapshots(self) -> Union[bool, str, dict]: """ Returns a list of snapshots @@ -804,6 +806,7 @@ def backup( else: msg = f"Backup failed backup operation:\n{output}" + self.backup_result_content = output return self.convert_to_json_output(result, output, msg=msg, **kwargs) @check_if_init @@ -1059,7 +1062,7 @@ def _has_recent_snapshot( return True, backup_ts return False, backup_ts - @check_if_init + # @check_if_init # We don't need to run if init before checking snapshots since if init searches for snapshots def has_recent_snapshot(self, delta: int = None) -> Tuple[bool, Optional[datetime]]: """ Checks if a snapshot exists that is newer that delta minutes @@ -1078,7 +1081,7 @@ def has_recent_snapshot(self, delta: int = None) -> Tuple[bool, Optional[datetim if not delta: if self.json_output: msg = "No delta given" - self.convert_to_json_output(False, None, msg=msg **kwargs) + self.convert_to_json_outpugt(False, None, msg=msg **kwargs) return False, None try: # Make sure we run with json support for this one From 648b757d396d114fa889c8599f55be8717432326 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Thu, 11 Jan 2024 03:09:05 +0100 Subject: [PATCH 184/328] Update ROADMAP.md --- ROADMAP.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index c21eb56..c64b265 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -24,3 +24,11 @@ Caveats: That's another story. Creating snapshots and dumping VM is easy. Shall we go that route since alot of good commercial products exist ? +### Key management + +Possibility to add new keys to current repo, and delete old keys if more than one key present + +### Provision server + +Possibility to auto load repo settings for new instances from central server +We actually could improve upgrade_server to do so \ No newline at end of file From 084e1328bb52a3f7336ea89ee3e167a2f9b217ad Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Thu, 11 Jan 2024 03:47:13 +0100 Subject: [PATCH 185/328] Make GUI loader more snappier --- npbackup/gui/helpers.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/npbackup/gui/helpers.py b/npbackup/gui/helpers.py index 3c209c7..915d55b 100644 --- a/npbackup/gui/helpers.py +++ b/npbackup/gui/helpers.py @@ -96,6 +96,7 @@ def _upgrade_from_compact_view(): ): progress_window[key].Update(visible=True) progress_window["--EXPAND--"].Update(visible=False) + progress_window["-OPERATIONS-PROGRESS-STDOUT-"].update(autoscroll=True) runner = NPBackupRunner() @@ -138,7 +139,7 @@ def _upgrade_from_compact_view(): key="-OPERATIONS-PROGRESS-STDOUT-", size=(70, 5), visible=not __compact, - autoscroll=True, + autoscroll=False, # Setting autoscroll=True on not visible Multiline takes seconds on updates ) ], [ @@ -271,7 +272,7 @@ def _upgrade_from_compact_view(): # for key in progress_window.AllKeysDict: # progress_window[key].Update(visible=True) progress_window["-OPERATIONS-PROGRESS-STDERR-"].Update( - f"\n{stderr_data}", append=True + f"{stderr_data}", append=True ) read_queues = read_stdout_queue or read_stderr_queue From 0ab21abef2fdf253366b27fc0962855067ae3329 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Thu, 11 Jan 2024 03:52:53 +0100 Subject: [PATCH 186/328] Fix duplicate code --- npbackup/gui/__main__.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/npbackup/gui/__main__.py b/npbackup/gui/__main__.py index 891d675..7d5553c 100644 --- a/npbackup/gui/__main__.py +++ b/npbackup/gui/__main__.py @@ -717,7 +717,9 @@ def get_config(default: bool = False): # Auto reisze table to window size window["snapshot-list"].expand(True, True) - window.set_title(f"{SHORT_PRODUCT_NAME} - {config_file}") + # Show which config file is loaded + if config_file: + window.set_title(f"{SHORT_PRODUCT_NAME} - {config_file}") window.read(timeout=0.01) if not config_file and not full_config and not viewer_mode: @@ -730,8 +732,7 @@ def get_config(default: bool = False): backup_tz = None snapshot_list = [] gui_update_state() - # Show which config file is loaded - window.set_title(f"{SHORT_PRODUCT_NAME} - {config_file}") + while True: event, values = window.read(timeout=60000) From c990c4893f7c1ca8e2932e44529d6b7951eafeeb Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Fri, 12 Jan 2024 14:07:18 +0100 Subject: [PATCH 187/328] Fix prune operation without max parameter --- npbackup/core/runner.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/npbackup/core/runner.py b/npbackup/core/runner.py index 6d0e57f..8fa3a14 100644 --- a/npbackup/core/runner.py +++ b/npbackup/core/runner.py @@ -1153,9 +1153,11 @@ def prune(self, max: bool = False) -> bool: if max: max_unused = self.repo_config.g("prune_max_unused") max_repack_size = self.repo_config.g("prune_max_repack_size") - result = self.restic_runner.prune( - max_unused=max_unused, max_repack_size=max_repack_size - ) + result = self.restic_runner.prune( + max_unused=max_unused, max_repack_size=max_repack_size + ) + else: + result = self.restic_runner.prune() return result @threaded From b85db5a9280d9c79f58b4d1596580bf64a3698b4 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Fri, 12 Jan 2024 14:54:57 +0100 Subject: [PATCH 188/328] Minor UX improvement --- npbackup/gui/__main__.py | 37 +++++++++++++++++++++++-------------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/npbackup/gui/__main__.py b/npbackup/gui/__main__.py index 7d5553c..09ad27d 100644 --- a/npbackup/gui/__main__.py +++ b/npbackup/gui/__main__.py @@ -457,7 +457,7 @@ def gui_update_state() -> None: window["--STATE-BUTTON--"].Update( _t("generic.not_connected_yet"), button_color=GUI_STATE_UNKNOWN_BUTTON ) - + window["-backend_type-"].Update(backend_type) window["snapshot-list"].Update(snapshot_list) def get_gui_data(repo_config: dict) -> Tuple[bool, List[str]]: @@ -550,7 +550,7 @@ def get_config_file(default: bool = True) -> str: return full_config, config_file return None, None - def get_config(default: bool = False): + def get_config(default: bool = False, window: sg.Window = None): full_config, config_file = get_config_file(default=default) if full_config and config_file: repo_config, config_inheritance = npbackup.configuration.get_repo_config( @@ -565,6 +565,17 @@ def get_config(default: bool = False): backend_type = "None" repo_uri = "None" repo_list = npbackup.configuration.get_repo_list(full_config) + + if window: + if config_file: + window.set_title(f"{SHORT_PRODUCT_NAME} - {config_file}") + if not viewer_mode and config_file: + window['--LAUNCH-BACKUP--'].Update(disabled=False) + window['--OPERATIONS--'].Update(disabled=False) + window['--FORGET--'].Update(disabled=False) + window['--CONFIGURE--'].Update(disabled=False) + if repo_list: + window['-active_repo-'].Update(values=repo_list, value=repo_list[0]) return ( full_config, config_file, @@ -619,7 +630,7 @@ def get_config(default: bool = False): [sg.Text(_t("main_gui.viewer_mode"))] if viewer_mode else [], - [sg.Text("{}: ".format(_t("main_gui.backup_state")))], + [sg.Text("{} ".format(_t("main_gui.backup_state"))), sg.Text("", key="-backend_type-")], [ sg.Button( _t("generic.unknown"), @@ -644,8 +655,8 @@ def get_config(default: bool = False): key="-active_repo-", default_value=repo_list[0] if repo_list else None, enable_events=True, + size=(20, 1) ), - sg.Text(f"Type {backend_type}", key="-backend_type-"), ] if not viewer_mode else [], @@ -669,21 +680,23 @@ def get_config(default: bool = False): sg.Button( _t("main_gui.launch_backup"), key="--LAUNCH-BACKUP--", - disabled=viewer_mode, + disabled=viewer_mode or (not viewer_mode and not config_file), + ), + sg.Button(_t("main_gui.see_content"), key="--SEE-CONTENT--", + disabled=not viewer_mode and not config_file ), - sg.Button(_t("main_gui.see_content"), key="--SEE-CONTENT--"), sg.Button( - _t("generic.forget"), key="--FORGET--", disabled=viewer_mode + _t("generic.forget"), key="--FORGET--", disabled=viewer_mode or (not viewer_mode and not config_file) ), # TODO , visible=False if repo_config.g("permissions") != "full" else True), sg.Button( _t("main_gui.operations"), key="--OPERATIONS--", - disabled=viewer_mode, + disabled=viewer_mode or (not viewer_mode and not config_file), ), sg.Button( _t("generic.configure"), key="--CONFIGURE--", - disabled=viewer_mode, + disabled=viewer_mode or (not viewer_mode and not config_file), ), sg.Button( _t("main_gui.load_config"), @@ -717,9 +730,6 @@ def get_config(default: bool = False): # Auto reisze table to window size window["snapshot-list"].expand(True, True) - # Show which config file is loaded - if config_file: - window.set_title(f"{SHORT_PRODUCT_NAME} - {config_file}") window.read(timeout=0.01) if not config_file and not full_config and not viewer_mode: @@ -810,8 +820,7 @@ def get_config(default: bool = False): backend_type, repo_uri, repo_list, - ) = get_config() - window.set_title(f"{SHORT_PRODUCT_NAME} - {config_file}") + ) = get_config(window=window) if not viewer_mode and not config_file and not full_config: window["-NO-CONFIG-"].Update(visible=True) elif not viewer_mode: From 2b5ebb8ba32e11be898527921a00a5d12b4eb0e1 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Fri, 12 Jan 2024 21:53:45 +0100 Subject: [PATCH 189/328] Add debugging decorator --- npbackup/__debug__.py | 35 +++++++++++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/npbackup/__debug__.py b/npbackup/__debug__.py index c734dfa..86a32dc 100644 --- a/npbackup/__debug__.py +++ b/npbackup/__debug__.py @@ -8,9 +8,16 @@ __site__ = "https://www.netperfect.fr/npbackup" __description__ = "NetPerfect Backup Client" __copyright__ = "Copyright (C) 2023-2024 NetInvent" +__build__ = "2024011201" import os +from typing import Callable +from functools import wraps +from logging import getLogger + + +logger = getLogger() # If set, debugging will be enabled by setting envrionment variable to __SPECIAL_DEBUG_STRING content @@ -18,13 +25,33 @@ __SPECIAL_DEBUG_STRING = "" __debug_os_env = os.environ.get("_DEBUG", "False").strip("'\"") -try: - # pylint: disable=E0601 (used-before-assignment) - _DEBUG -except NameError: + +if not "_DEBUG" in globals(): _DEBUG = False if __SPECIAL_DEBUG_STRING: if __debug_os_env == __SPECIAL_DEBUG_STRING: _DEBUG = True elif __debug_os_env.capitalize() == "True": _DEBUG = True + + +def catch_exceptions(fn: Callable): + """ + Catch any exception and log it so we don't loose exceptions in thread + """ + + @wraps(fn) + def wrapper(self, *args, **kwargs): + try: + # pylint: disable=E1102 (not-callable) + return fn(self, *args, **kwargs) + except Exception as exc: + # pylint: disable=E1101 (no-member) + operation = fn.__name__ + logger.error( + f"Function {operation} failed with: {exc}", level="error" + ) + logger.error("Trace:", exc_info=True) + return None + + return wrapper \ No newline at end of file From 6cec3c7dc50b5aa8869971b2e7b6f7a431653332 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Sun, 14 Jan 2024 22:48:09 +0100 Subject: [PATCH 190/328] Allow --log-file parameter --- npbackup/__main__.py | 10 ++++++ npbackup/gui/__main__.py | 78 ++++++++++++++++++++++++++++++++-------- 2 files changed, 73 insertions(+), 15 deletions(-) diff --git a/npbackup/__main__.py b/npbackup/__main__.py index 817fae5..781a4c3 100644 --- a/npbackup/__main__.py +++ b/npbackup/__main__.py @@ -225,8 +225,18 @@ def cli_interface(): parser.add_argument( "--auto-upgrade", action="store_true", help="Auto upgrade NPBackup" ) + parser.add_argument( + "--log-file", + type=str, + default=None, + required=False, + help="Optional path for logfile" + ) args = parser.parse_args() + if args.log_file: + LOG_FILE = args.log_file + if args.json: _JSON = True logger = ofunctions.logger_utils.logger_get_logger( diff --git a/npbackup/gui/__main__.py b/npbackup/gui/__main__.py index 09ad27d..9608fb9 100644 --- a/npbackup/gui/__main__.py +++ b/npbackup/gui/__main__.py @@ -14,7 +14,9 @@ import sys import os import re +from argparse import ArgumentParser from pathlib import Path +from logging import getLogger import ofunctions.logger_utils from datetime import datetime import dateutil @@ -47,7 +49,6 @@ from npbackup.gui.config import config_gui from npbackup.gui.operations import operations_gui from npbackup.gui.helpers import get_anon_repo_uri, gui_thread_runner -from npbackup.gui.notification import display_notification from npbackup.core.i18n_helper import _t from npbackup.core.upgrade_runner import run_upgrade, check_new_version from npbackup.path_helper import CURRENT_DIR @@ -56,8 +57,8 @@ from npbackup.restic_wrapper import ResticRunner -LOG_FILE = os.path.join(CURRENT_DIR, "{}.log".format(__intname__)) -logger = ofunctions.logger_utils.logger_get_logger(LOG_FILE, debug=_DEBUG) +logger = getLogger() + sg.theme(PYSIMPLEGUI_THEME) sg.SetOptions(icon=OEM_ICON) @@ -393,6 +394,55 @@ def forget_snapshot(repo_config: dict, snapshot_ids: List[str]) -> bool: def _main_gui(viewer_mode: bool): + global logger + + parser = ArgumentParser( + prog=f"{__intname__}", + description="""Portable Network Backup Client\n + This program is distributed under the GNU General Public License and comes with ABSOLUTELY NO WARRANTY.\n + This is free software, and you are welcome to redistribute it under certain conditions; Please type --license for more info.""", + ) + + parser.add_argument( + "-c", + "--config-file", + dest="config_file", + type=str, + default=None, + required=False, + help="Path to alternative configuration file (defaults to current dir/npbackup.conf)", + ) + parser.add_argument( + "--repo-name", + dest="repo_name", + type=str, + default="default", + required=False, + help="Name of the repository to work with. Defaults to 'default'", + ) + parser.add_argument( + "--log-file", + type=str, + default=None, + required=False, + help="Optional path for logfile" + ) + args = parser.parse_args() + if args.log_file: + log_file = args.log_file + else: + log_file = os.path.join(CURRENT_DIR, "{}.log".format(__intname__)) + logger = ofunctions.logger_utils.logger_get_logger(log_file, debug=_DEBUG) + + if args.config_file: + config_file = args.config_file + else: + config_file = Path(f"{CURRENT_DIR}/npbackup.conf") + + # TODO + if args.repo_name: + repo_name = args.repo_name + def select_config_file(config_file: str = None) -> None: """ Option to select a configuration file @@ -515,18 +565,16 @@ def get_gui_data(repo_config: dict) -> Tuple[bool, List[str]]: ) return current_state, backup_tz, snapshot_list - def get_config_file(default: bool = True) -> str: + def get_config_file(config_file: str = None) -> str: """ Load config file until we got something """ - if default: - config_file = Path(f"{CURRENT_DIR}/npbackup.conf") - if default: - full_config = npbackup.configuration.load_config(config_file) - if not config_file.exists(): - config_file = None - if not full_config: - return full_config, config_file + if config_file: + full_config = npbackup.configuration.load_config(config_file) + if not config_file.exists(): + config_file = None + if not full_config: + return full_config, config_file else: config_file = None @@ -550,8 +598,8 @@ def get_config_file(default: bool = True) -> str: return full_config, config_file return None, None - def get_config(default: bool = False, window: sg.Window = None): - full_config, config_file = get_config_file(default=default) + def get_config(config_file: str = None, window: sg.Window = None): + full_config, config_file = get_config_file(config_file = config_file) if full_config and config_file: repo_config, config_inheritance = npbackup.configuration.get_repo_config( full_config @@ -595,7 +643,7 @@ def get_config(default: bool = False, window: sg.Window = None): backend_type, repo_uri, repo_list, - ) = get_config(default=True) + ) = get_config(config_file = config_file) else: # Let's try to read standard restic repository env variables viewer_repo_uri = os.environ.get("RESTIC_REPOSITORY", None) From be6b8b268ebffde5576b86d03b336baadd7067c0 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Sun, 14 Jan 2024 22:54:53 +0100 Subject: [PATCH 191/328] Improve UX --- npbackup/gui/helpers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/npbackup/gui/helpers.py b/npbackup/gui/helpers.py index 915d55b..c41f067 100644 --- a/npbackup/gui/helpers.py +++ b/npbackup/gui/helpers.py @@ -215,13 +215,13 @@ def _upgrade_from_compact_view(): full_layout, use_custom_titlebar=True, grab_anywhere=True, - keep_on_top=True, disable_close=True, # Don't allow closing this window via "X" since we still need to update it background_color=BG_COLOR_LDR, titlebar_icon=OEM_ICON, ) # Finalize the window event, values = progress_window.read(timeout=0.01) + progress_window.bring_to_front() read_stdout_queue = __stdout read_stderr_queue = True From 3c06a80eadd801a6848a8bbbe96a2a51120e5df7 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Sun, 14 Jan 2024 23:12:01 +0100 Subject: [PATCH 192/328] Init output queues before anything else --- npbackup/gui/helpers.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/npbackup/gui/helpers.py b/npbackup/gui/helpers.py index c41f067..06f1fee 100644 --- a/npbackup/gui/helpers.py +++ b/npbackup/gui/helpers.py @@ -99,20 +99,20 @@ def _upgrade_from_compact_view(): progress_window["-OPERATIONS-PROGRESS-STDOUT-"].update(autoscroll=True) runner = NPBackupRunner() - - # We'll always use json output in GUI mode - - runner.json_output = True - # So we don't always init repo_config, since runner.group_runner would do that itself - if __repo_config: - runner.repo_config = __repo_config - + if __stdout: stdout_queue = queue.Queue() runner.stdout = stdout_queue stderr_queue = queue.Queue() runner.stderr = stderr_queue + + # We'll always use json output in GUI mode + runner.json_output = True + # So we don't always init repo_config, since runner.group_runner would do that itself + if __repo_config: + runner.repo_config = __repo_config + fn = getattr(runner, __fn_name) logger.debug( f"gui_thread_runner runs {fn.__name__} {'with' if USE_THREADING else 'without'} threads" From 32735ba924f48a3ac39e9b8680ac898d0bd3d65e Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Sun, 14 Jan 2024 23:12:20 +0100 Subject: [PATCH 193/328] Make sure we log when backend is not ready for any reason --- npbackup/core/runner.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/npbackup/core/runner.py b/npbackup/core/runner.py index 8fa3a14..3ba1381 100644 --- a/npbackup/core/runner.py +++ b/npbackup/core/runner.py @@ -282,7 +282,7 @@ def wrapper(self, *args, **kwargs): result = fn(self, *args, **kwargs) self.exec_time = (datetime.utcnow() - start_time).total_seconds() # Optional patch result with exec time - if self.restic_runner.json_output and isinstance(result, dict): + if self.restic_runner and self.restic_runner.json_output and isinstance(result, dict): result["exec_time"] = self.exec_time # pylint: disable=E1101 (no-member) self.write_logs( @@ -325,6 +325,9 @@ def wrapper(self, *args, **kwargs): else: # pylint: disable=E1101 (no-member) operation = fn.__name__ + msg = f"Runner cannot execute {operation}. Backend not ready" + if self.stderr: + self.stderr.put(msg) if self.json_output: js = { "result": False, @@ -333,7 +336,7 @@ def wrapper(self, *args, **kwargs): } return js self.write_logs( - f"Runner cannot execute {operation}. Backend not ready", + msg, level="error", ) return False From eb3ae93baa998b0ee4f62c164c4c928c914af471 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Sun, 14 Jan 2024 23:37:39 +0100 Subject: [PATCH 194/328] More UX --- npbackup/core/runner.py | 2 +- npbackup/gui/helpers.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/npbackup/core/runner.py b/npbackup/core/runner.py index 3ba1381..11f4a50 100644 --- a/npbackup/core/runner.py +++ b/npbackup/core/runner.py @@ -558,7 +558,7 @@ def create_restic_runner(self) -> bool: if binary: if not self._using_dev_binary: self._using_dev_binary = True - self.write_logs("Using dev binary !", level="warning") + self.write_logs("Using dev binary !", level="info") self.restic_runner.binary = binary else: return False diff --git a/npbackup/gui/helpers.py b/npbackup/gui/helpers.py index 06f1fee..1422bef 100644 --- a/npbackup/gui/helpers.py +++ b/npbackup/gui/helpers.py @@ -213,7 +213,7 @@ def _upgrade_from_compact_view(): progress_window = sg.Window( __gui_msg, full_layout, - use_custom_titlebar=True, + use_custom_titlebar=False, # Will not show an icon in task bar if custom titlebar is set unless window is minimized, basically it can be hidden behind others with this option grab_anywhere=True, disable_close=True, # Don't allow closing this window via "X" since we still need to update it background_color=BG_COLOR_LDR, From e41ccc3e528f5331c8733dc787ac22ed71761514 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Mon, 15 Jan 2024 00:04:01 +0100 Subject: [PATCH 195/328] Update configuration.py --- npbackup/configuration.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/npbackup/configuration.py b/npbackup/configuration.py index e04dbdb..1577d24 100644 --- a/npbackup/configuration.py +++ b/npbackup/configuration.py @@ -398,6 +398,8 @@ def inject_permissions_into_repo_config(repo_config: dict) -> dict: """ Make sure repo_uri is a tuple containing permissions and manager password This function is used before saving config + + NPF-SEC-00006: Never inject permissions if some are already present """ repo_uri = repo_config.g("repo_uri") permissions = repo_config.g("permissions") From 9c341bdb3c8106ace9bf6dc9b54e492492c314e1 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Mon, 15 Jan 2024 00:12:47 +0100 Subject: [PATCH 196/328] Don't allow duplicates in get_repos_by_group --- npbackup/configuration.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/npbackup/configuration.py b/npbackup/configuration.py index 1577d24..ca6e8c8 100644 --- a/npbackup/configuration.py +++ b/npbackup/configuration.py @@ -635,6 +635,6 @@ def get_repos_by_group(full_config: dict, group: str) -> List[str]: repo_list = [] if full_config: for repo in list(full_config.g("repos").keys()): - if full_config.g(f"repos.{repo}.repo_group") == group: + if full_config.g(f"repos.{repo}.repo_group") == group and group not in repo_list: repo_list.append(repo) return repo_list \ No newline at end of file From dd1aaae082dd0e09382028d7c3aeebfc5c7ef5da Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Mon, 15 Jan 2024 01:19:37 +0100 Subject: [PATCH 197/328] Add initial tests --- tests/test_npbackup-cli.py | 73 ++++++++++++++++++++++++++++++++++++ tests/test_restic_metrics.py | 2 +- 2 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 tests/test_npbackup-cli.py diff --git a/tests/test_npbackup-cli.py b/tests/test_npbackup-cli.py new file mode 100644 index 0000000..3625c2a --- /dev/null +++ b/tests/test_npbackup-cli.py @@ -0,0 +1,73 @@ +#! /usr/bin/env python3 +# -*- coding: utf-8 -*- + + +__intname__ = "npbackup_cli_tests" +__author__ = "Orsiris de Jong" +__copyright__ = "Copyright (C) 2022-2024 NetInvent" +__licence__ = "BSD-3-Clause" +__build__ = "2024011501" +__compat__ = "python3.6+" + + +""" +Simple test where we launch the GUI and hope it doesn't die +""" + +import sys +from io import StringIO +from npbackup import __main__ + + +class RedirectedStdout: + """ + Balantly copied from https://stackoverflow.com/a/45899925/2635443 + """ + def __init__(self): + self._stdout = None + self._string_io = None + + def __enter__(self): + self._stdout = sys.stdout + sys.stdout = self._string_io = StringIO() + return self + + def __exit__(self, type, value, traceback): + sys.stdout = self._stdout + + def __str__(self): + return self._string_io.getvalue() + + +def test_npbackup_cli_no_config(): + sys.argv = [''] # Make sure we don't get any pytest args + try: + with RedirectedStdout() as logs: + __main__.main() + except SystemExit: + assert 'CRITICAL :: Cannot run without configuration file' in str(logs), "There should be a critical error when config file is not given" + + +def test_npbackup_cli_wrong_config_path(): + sys.argv = ['', '-c', 'npbackup-non-existent.conf'] + try: + with RedirectedStdout() as logs: + __main__.main() + except SystemExit: + assert 'Config file npbackup-non-existent.conf cannot be read' in str(logs), "There should be a critical error when config file is not given" + + +def test_npbackup_cli_snapshots(): + sys.argv = ['', '-c', 'npbackup-test.conf', '--snapshots'] + try: + with RedirectedStdout() as logs: + __main__.main() + except SystemExit: + print(logs) + + + +if __name__ == "__main__": + test_npbackup_cli_no_config() + test_npbackup_cli_wrong_config_path() + test_npbackup_cli_snapshots() \ No newline at end of file diff --git a/tests/test_restic_metrics.py b/tests/test_restic_metrics.py index c6ad7ca..274d727 100644 --- a/tests/test_restic_metrics.py +++ b/tests/test_restic_metrics.py @@ -100,7 +100,7 @@ def running_on_github_actions(): env: RUNNING_ON_GITHUB_ACTIONS: true """ - return os.environ.get("RUNNING_ON_GITHUB_ACTIONS").lower() == "true" + return os.environ.get("RUNNING_ON_GITHUB_ACTIONS", "False").lower() == "true" def test_restic_str_output_2_metrics(): From 47b3cd9390d5c3b65e09f3616bd8ee0f3f4d8525 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Mon, 15 Jan 2024 01:19:53 +0100 Subject: [PATCH 198/328] Fix log file should be set globally --- npbackup/__main__.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/npbackup/__main__.py b/npbackup/__main__.py index 781a4c3..b1b6eaa 100644 --- a/npbackup/__main__.py +++ b/npbackup/__main__.py @@ -37,7 +37,6 @@ _JSON = False -LOG_FILE = os.path.join(CURRENT_DIR, "{}.log".format(__intname__)) logger = logging.getLogger() @@ -53,6 +52,7 @@ def json_error_logging(result: bool, msg: str, level: str): def cli_interface(): global _JSON + global logger parser = ArgumentParser( prog=f"{__intname__}", @@ -235,15 +235,17 @@ def cli_interface(): args = parser.parse_args() if args.log_file: - LOG_FILE = args.log_file + log_file = args.log_file + else: + log_file = os.path.join(CURRENT_DIR, "{}.log".format(__intname__)) if args.json: _JSON = True logger = ofunctions.logger_utils.logger_get_logger( - LOG_FILE, console=False, debug=_DEBUG + log_file, console=False, debug=_DEBUG ) else: - logger = ofunctions.logger_utils.logger_get_logger(LOG_FILE, debug=_DEBUG) + logger = ofunctions.logger_utils.logger_get_logger(log_file, debug=_DEBUG) if args.version: if _JSON: From 302c3910dbfe48e09ad3931e61661a2638cbc6a8 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Mon, 15 Jan 2024 01:22:48 +0100 Subject: [PATCH 199/328] Fix --config-file parameter should be a Path --- npbackup/__main__.py | 2 +- npbackup/gui/__main__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/npbackup/__main__.py b/npbackup/__main__.py index b1b6eaa..590e54c 100644 --- a/npbackup/__main__.py +++ b/npbackup/__main__.py @@ -276,7 +276,7 @@ def cli_interface(): msg = f"Config file {args.config_file} cannot be read." json_error_logging(False, msg, "critical") sys.exit(70) - CONFIG_FILE = args.config_file + CONFIG_FILE = Path(args.config_file) else: config_file = Path(f"{CURRENT_DIR}/npbackup.conf") if config_file.exists(): diff --git a/npbackup/gui/__main__.py b/npbackup/gui/__main__.py index 9608fb9..8fdca6e 100644 --- a/npbackup/gui/__main__.py +++ b/npbackup/gui/__main__.py @@ -435,7 +435,7 @@ def _main_gui(viewer_mode: bool): logger = ofunctions.logger_utils.logger_get_logger(log_file, debug=_DEBUG) if args.config_file: - config_file = args.config_file + config_file = Path(args.config_file) else: config_file = Path(f"{CURRENT_DIR}/npbackup.conf") From 4f7ecc267b0a30a2f2b0d795f2ba2d8c137dbaee Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Mon, 15 Jan 2024 01:23:04 +0100 Subject: [PATCH 200/328] WIP operations gui needs group selector --- npbackup/gui/operations.py | 37 +++++++++++++++++++++---------------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/npbackup/gui/operations.py b/npbackup/gui/operations.py index dad2e41..d1a8b8e 100644 --- a/npbackup/gui/operations.py +++ b/npbackup/gui/operations.py @@ -50,22 +50,24 @@ def operations_gui(full_config: dict) -> dict: """ def _select_groups(): + group_list = configuration.get_group_list(full_config) selector_layout = [ [ sg.Table( - values=configuration.get_group_list(full_config), + values=group_list, headings=["Group Name"], key="-GROUP_LIST-", auto_size_columns=True, justification="left", - size=(60, 5) + expand_x=True, + expand_y=True ) ], [ sg.Push(), sg.Button(_t("generic.cancel"), key="--CANCEL--"), sg.Button(_t("operations_gui.apply_to_selected_groups"), key="--SELECTED_GROUPS--"), - sg.Button(_t("operations_gui.apply_to_all"), key="--APPLY-TO-ALL--") + sg.Button(_t("operations_gui.apply_to_all"), key="--APPLY_TO_ALL--") ] ] @@ -73,14 +75,24 @@ def _select_groups(): while True: event, values = select_group_window.read() if event in (sg.WIN_CLOSED, sg.WIN_X_EVENT, "--CANCEL--"): + result = [] break if event == "--SELECTED_GROUPS--": if not values["-GROUP_LIST-"]: sg.Popup("No groups selected") continue - if event == "--APPLY-TO-ALL": - continue + repo_list = [] + for group_index in values["-GROUP_LIST-"]: + group_name = group_list[group_index] + repo_list += configuration.get_repos_by_group(full_config, group_name) + result = repo_list + break + if event == "--APPLY_TO_ALL--": + result = complete_repo_list + break select_group_window.close() + print(result) + return result # This is a stupid hack to make sure uri column is large enough headings = [ @@ -116,7 +128,7 @@ def _select_groups(): key="repo-list", auto_size_columns=True, justification="left", - ) + ), ], [ sg.Button( @@ -215,22 +227,15 @@ def _select_groups(): "--STATS--" ): if not values["repo-list"]: - """ - result = sg.popup( - _t("operations_gui.apply_to_all"), - custom_text=(_t("generic.yes"), _t("generic.no")), - ) - if not result == _t("generic.yes"): - continue - """ - repos = _select_groups() # TODO #WIP - repos = complete_repo_list + repos = _select_groups() else: repos = [] for value in values["repo-list"]: repos.append(complete_repo_list[value]) repo_config_list = [] + if not repos: + continue for repo_name, repo_group, backend_type, repo_uri in repos: repo_config, config_inheritance = configuration.get_repo_config( full_config, repo_name From ef9a26aa08b090734196c668b09a6f4e21485b8e Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Mon, 15 Jan 2024 01:23:13 +0100 Subject: [PATCH 201/328] Update ROADMAP.md --- ROADMAP.md | 29 +++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index c64b265..5917e20 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,11 +1,11 @@ -## What's planned ahead +## What's planned / considered ### Daemon mode Instead of relying on scheduled tasks, we could launch backup & housekeeping operations as deamon. Caveats: - We need a windows service (nuitka commercial implements one) - - We need to use apscheduler + - We need to use apscheduler (wait for v4) - We need a resurrect service config for systemd and windows service ### Web interface @@ -20,9 +20,12 @@ Since we run cube backup, we could "bake in" full KVM support Caveats: - We'll need to re-implement libvirt controller class for linux -### Hyper-V Backup plugin -That's another story. Creating snapshots and dumping VM is easy. -Shall we go that route since alot of good commercial products exist ? +### SQL Backups +That's a pre-script job ;) +Perhaps, provide pre-scripts for major SQL engines +Perhaps, provide an alternative dump | npbackup-cli syntax. +In the latter case, shell (bash, zsh, ksh) would need `shopt -o pipefail`, and minimum backup size set. +The pipefail will not be given to npbackup-cli, so we'd need to wrap everything into a script, which defeats the prometheus metrics. ### Key management @@ -31,4 +34,18 @@ Possibility to add new keys to current repo, and delete old keys if more than on ### Provision server Possibility to auto load repo settings for new instances from central server -We actually could improve upgrade_server to do so \ No newline at end of file +We actually could improve upgrade_server to do so + +### Hyper-V Backup plugin +That's another story. Creating snapshots and dumping VM is easy +Shall we go that route since alot of good commercial products exist ? Probably not + +### Full disk cloning +Out of scope of NPBackup. There are plenty of good tools out there, designed for that job + +### Rust rewrite +That would be my "dream" project in order to learn a new language in an existing usecase. +But this would need massive sponsoring as I couldn't get the non-paid time to do so. + +### More backends support +Rustic is a current alternative backend candidate I tested. Might happen if enough traction. \ No newline at end of file From f114544b4683d09e892720c3f4b438d816d88e59 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Mon, 15 Jan 2024 01:23:16 +0100 Subject: [PATCH 202/328] Update SECURITY.md --- SECURITY.md | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/SECURITY.md b/SECURITY.md index 5603855..2c61479 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,3 +1,4 @@ +# Retired since v2.3.0, replaced by NPF-SEC-00007 # NPF-SEC-00001: SECURITY-ADMIN-BACKUP-PASSWORD ONLY AVAILABLE ON PRIVATE COMPILED BUILDS In gui.config we have a function that allows to show unencrypted values of the yaml config file @@ -7,6 +8,7 @@ While this is practical, it should never be allowed on non compiled builds or wi All these commands are run with npbackup held privileges. In order to avoid a potential attack, the config file has to be world readable only. +We need to document this, and perhaps add a line in installer script # NPF-SEC-00003: Avoid password command divulgation @@ -22,4 +24,18 @@ This will prevent local backups, so we need to think of a better zero knowledge # NPF-SEC-00005: Viewer mode can bypass permissions Since viewer mode requires actual knowledge of repo URI and repo password, there's no need to manage local permissions. -Viewer mode permissions are set to "restore". \ No newline at end of file +Viewer mode permissions are set to "restore". + +# NPF-SEC-00006: Never inject permissions if some are already present + +Since v2.3.0, we insert permissions directly into the encrypted repo URI. +Hence, update permissions should only happen in two cases: +- CLI: Recreate repo_uri entry and add permission field from YAML file +- GUI: Enter permission password to update permissions + +# NPF-SEC-00007: Encrypted data needs to be protected + +Since encryption is symmetric, we need to protect our sensible data. +Best ways: +- Compile with alternative aes-key +- Use --aes-key with alternative aes-key which is protected by system \ No newline at end of file From 139901efe46796f8bf8e81e4c94cad48bbf0762a Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Mon, 15 Jan 2024 01:26:21 +0100 Subject: [PATCH 203/328] Fix typo --- npbackup/restic_wrapper/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/npbackup/restic_wrapper/__init__.py b/npbackup/restic_wrapper/__init__.py index 202eb60..fb7b186 100644 --- a/npbackup/restic_wrapper/__init__.py +++ b/npbackup/restic_wrapper/__init__.py @@ -1081,7 +1081,7 @@ def has_recent_snapshot(self, delta: int = None) -> Tuple[bool, Optional[datetim if not delta: if self.json_output: msg = "No delta given" - self.convert_to_json_outpugt(False, None, msg=msg **kwargs) + self.convert_to_json_output(False, None, msg=msg **kwargs) return False, None try: # Make sure we run with json support for this one From 4877cd4e865a3929f28966777cff3b84c40a9719 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Mon, 15 Jan 2024 13:58:38 +0100 Subject: [PATCH 204/328] Operations GUI: finish group selector --- npbackup/gui/operations.py | 7 ++++--- npbackup/translations/operations_gui.en.yml | 4 +++- npbackup/translations/operations_gui.fr.yml | 4 +++- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/npbackup/gui/operations.py b/npbackup/gui/operations.py index d1a8b8e..da5cce3 100644 --- a/npbackup/gui/operations.py +++ b/npbackup/gui/operations.py @@ -79,11 +79,12 @@ def _select_groups(): break if event == "--SELECTED_GROUPS--": if not values["-GROUP_LIST-"]: - sg.Popup("No groups selected") + sg.Popup(_t("operations_gui.no_groups_selected")) continue repo_list = [] for group_index in values["-GROUP_LIST-"]: group_name = group_list[group_index] + print(group_name, configuration.get_repos_by_group(full_config, group_name)) repo_list += configuration.get_repos_by_group(full_config, group_name) result = repo_list break @@ -231,12 +232,12 @@ def _select_groups(): else: repos = [] for value in values["repo-list"]: - repos.append(complete_repo_list[value]) + repos.append(complete_repo_list[value][0]) repo_config_list = [] if not repos: continue - for repo_name, repo_group, backend_type, repo_uri in repos: + for repo_name in repos: repo_config, config_inheritance = configuration.get_repo_config( full_config, repo_name ) diff --git a/npbackup/translations/operations_gui.en.yml b/npbackup/translations/operations_gui.en.yml index efbb266..d27aaf3 100644 --- a/npbackup/translations/operations_gui.en.yml +++ b/npbackup/translations/operations_gui.en.yml @@ -9,7 +9,9 @@ en: standard_prune: Normal prune data max_prune: Prune with maximum efficiency stats: Repo statistics - apply_to_all: Apply to all repos ? + apply_to_all: Apply to all repos + apply_to_selected_groups: Apply to selected groups + no_groups_selected: No groups selected add_repo: Add repo edit_repo: Edit repo remove_repo: Remove repo diff --git a/npbackup/translations/operations_gui.fr.yml b/npbackup/translations/operations_gui.fr.yml index 7b5e3db..ace1089 100644 --- a/npbackup/translations/operations_gui.fr.yml +++ b/npbackup/translations/operations_gui.fr.yml @@ -9,7 +9,9 @@ fr: standard_prune: Opération de purge normale max_prune: Opération de purge la plus efficace stats: Statistiques de dépot - apply_to_all: Appliquer à tous les dépots ? + apply_to_all: Appliquer à tous les dépots + apply_to_selected_groups: Appliquer aux groupes sélectionnés + no_groups_selected: Aucun groupe sélectionné add_repo: Ajouter dépot edit_repo: Modifier dépot remove_repo: Supprimer dépot \ No newline at end of file From 07f297252063c2a6d049b0f1a3d959b897e16d86 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Mon, 15 Jan 2024 16:37:34 +0100 Subject: [PATCH 205/328] Return most recent restic dev binary --- npbackup/core/restic_source_binary.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/npbackup/core/restic_source_binary.py b/npbackup/core/restic_source_binary.py index 89968af..d69c925 100644 --- a/npbackup/core/restic_source_binary.py +++ b/npbackup/core/restic_source_binary.py @@ -44,5 +44,7 @@ def get_restic_internal_binary(arch: str) -> str: if binary: guessed_path = glob.glob(os.path.join(RESTIC_SOURCE_FILES_DIR, binary)) if guessed_path: - return guessed_path[0] + # Take glob results reversed so we get newer version + # Does not always compute, but is g00denough(TM) for our dev + return guessed_path[-1] return None From ad0785f3eb1760c1b4331af5c63df1d3a745a988 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Mon, 15 Jan 2024 16:38:10 +0100 Subject: [PATCH 206/328] Various JS / backend binary fixes --- npbackup/core/runner.py | 18 ++++++++++-------- npbackup/restic_wrapper/__init__.py | 7 +++++-- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/npbackup/core/runner.py b/npbackup/core/runner.py index 11f4a50..d5b1879 100644 --- a/npbackup/core/runner.py +++ b/npbackup/core/runner.py @@ -139,8 +139,6 @@ def __init__(self): self.minimum_backup_age = None self._exec_time = None - self._using_dev_binary = False - @property def repo_config(self) -> dict: return self._repo_config @@ -556,11 +554,11 @@ def create_restic_runner(self) -> bool: arch = os_arch() binary = get_restic_internal_binary(arch) if binary: - if not self._using_dev_binary: - self._using_dev_binary = True - self.write_logs("Using dev binary !", level="info") self.restic_runner.binary = binary + version = self.restic_runner.binary_version + self.write_logs(f"Using dev binary {version}", level="info") else: + self._is_ready = False return False return True @@ -684,9 +682,12 @@ def convert_to_json_output(self, result: bool, output: str = None, backend_js: d if self.json_output: if backend_js: js = backend_js - js = { - "result": result, - } + if isinstance(result, dict): + js = result + else: + js = { + "result": result, + } if warnings: js["warnings"] = warnings if result: @@ -1017,6 +1018,7 @@ def backup(self, force: bool = False, read_from_stdin: bool = False, stdin_filen self.write_logs(f"Restic output:\n{self.restic_runner.backup_result_content}", level="debug") # Extract backup size from result_string + # Metrics will not be in json format, since we need to diag cloud issues until metrics_analyzer_result = metric_writer( self.repo_config, result, self.restic_runner.backup_result_content, self.restic_runner.dry_run ) diff --git a/npbackup/restic_wrapper/__init__.py b/npbackup/restic_wrapper/__init__.py index fb7b186..2ba0e6f 100644 --- a/npbackup/restic_wrapper/__init__.py +++ b/npbackup/restic_wrapper/__init__.py @@ -318,6 +318,7 @@ def executor( ): is_cloud_error = False if is_cloud_error is True: + self.last_command_status = True return True, output else: self.write_logs("Some files could not be backed up", level="error") @@ -785,7 +786,9 @@ def backup( if additional_backup_only_parameters: cmd += " {}".format(additional_backup_only_parameters) - # Run backup + # Run backup without json output, as we could not compute the cloud errors in json output via regexes + json_output = self.json_output + self.json_output = False if read_from_stdin: result, output = self.executor(cmd, stdin=sys.stdin.buffer) else: @@ -800,7 +803,7 @@ def backup( "VSS cannot be used. Backup will be done without VSS.", level="error" ) result, output = self.executor(cmd.replace(" --use-fs-snapshot", "")) - + self.json_output = json_output if result: msg = "Backend finished backup with success" else: From 13593ba19929106f750023a42d39f7ab06cab515 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Mon, 15 Jan 2024 18:00:05 +0100 Subject: [PATCH 207/328] Implement minimum_backup_size_error alert --- npbackup/configuration.py | 2 +- npbackup/core/runner.py | 96 +++++++++++++++-------------- npbackup/restic_metrics/__init__.py | 21 +++++-- tests/test_restic_metrics.py | 9 +-- 4 files changed, 72 insertions(+), 56 deletions(-) diff --git a/npbackup/configuration.py b/npbackup/configuration.py index ca6e8c8..78bcab2 100644 --- a/npbackup/configuration.py +++ b/npbackup/configuration.py @@ -160,7 +160,7 @@ def d(self, path, sep="."): "exclude_files_larger_than": None, "additional_parameters": None, "additional_backup_only_parameters": None, - "minimum_backup_size_error": "10M", # TODO + "minimum_backup_size_error": "10", # In megabytes "pre_exec_commands": [], "pre_exec_per_command_timeout": 3600, "pre_exec_failure_is_fatal": False, diff --git a/npbackup/core/runner.py b/npbackup/core/runner.py index d5b1879..e1ad510 100644 --- a/npbackup/core/runner.py +++ b/npbackup/core/runner.py @@ -36,9 +36,13 @@ def metric_writer( repo_config: dict, restic_result: bool, result_string: str, dry_run: bool -): +) -> bool: + backup_too_small = False + minimum_backup_size_error = repo_config.g("backup_opts.minimum_backup_size_error") try: - labels = {} + labels = { + "npversion": f"{NAME}{VERSION}" + } if repo_config.g("prometheus.metrics"): labels["instance"] = repo_config.g("prometheus.instance") labels["backup_job"] = repo_config.g("prometheus.backup_job") @@ -46,23 +50,18 @@ def metric_writer( no_cert_verify = repo_config.g("prometheus.no_cert_verify") destination = repo_config.g("prometheus.destination") prometheus_additional_labels = repo_config.g("prometheus.additional_labels") - minimum_backup_size_error = repo_config.g("backup_opts.minimum_backup_size_error") # TODO + if not isinstance(prometheus_additional_labels, list): prometheus_additional_labels = [prometheus_additional_labels] # Configure lables - label_string = ",".join( - [f'{key}="{value}"' for key, value in labels.items() if value] - ) try: if prometheus_additional_labels: for additional_label in prometheus_additional_labels: if additional_label: try: label, value = additional_label.split("=") - label_string += ',{}="{}"'.format( - label.strip(), value.strip() - ) + labels[label.strip()] = value.strip() except ValueError: logger.error( 'Bogus additional label "{}" defined in configuration.'.format( @@ -73,47 +72,46 @@ def metric_writer( logger.error("Bogus additional labels defined in configuration.") logger.debug("Trace:", exc_info=True) - label_string += ',npversion="{}{}"'.format(NAME, VERSION) - - # If result was a str, we need to transform it into json first - if isinstance(result_string, str): - restic_result = restic_str_output_to_json(restic_result, result_string) + # If result was a str, we need to transform it into json first + if isinstance(result_string, str): + restic_result = restic_str_output_to_json(restic_result, result_string) - errors, metrics = restic_json_to_prometheus( - restic_result=restic_result, output=restic_result, labels=label_string - ) - if errors or not restic_result: - logger.error("Restic finished with errors.") - if destination: - logger.debug("Uploading metrics to {}".format(destination)) - if destination.lower().startswith("http"): - try: - authentication = ( - repo_config.g("prometheus.http_username"), - repo_config.g("prometheus.http_password"), - ) - except KeyError: - logger.info("No metrics authentication present.") - authentication = None - if not dry_run: - upload_metrics( - destination, authentication, no_cert_verify, metrics - ) - else: - logger.info("Not uploading metrics in dry run mode") + errors, metrics, backup_too_small = restic_json_to_prometheus( + restic_result=restic_result, restic_json=restic_result, labels=labels, minimum_backup_size_error=minimum_backup_size_error + ) + if errors or not restic_result: + logger.error("Restic finished with errors.") + if repo_config.g("prometheus.metrics") and destination: + logger.debug("Uploading metrics to {}".format(destination)) + if destination.lower().startswith("http"): + try: + authentication = ( + repo_config.g("prometheus.http_username"), + repo_config.g("prometheus.http_password"), + ) + except KeyError: + logger.info("No metrics authentication present.") + authentication = None + if not dry_run: + upload_metrics( + destination, authentication, no_cert_verify, metrics + ) else: - try: - with open(destination, "w") as file_handle: - for metric in metrics: - file_handle.write(metric + "\n") - except OSError as exc: - logger.error( - "Cannot write metrics file {}: {}".format(destination, exc) - ) + logger.info("Not uploading metrics in dry run mode") + else: + try: + with open(destination, "w") as file_handle: + for metric in metrics: + file_handle.write(metric + "\n") + except OSError as exc: + logger.error( + "Cannot write metrics file {}: {}".format(destination, exc) + ) except KeyError as exc: logger.info("Metrics not configured: {}".format(exc)) except OSError as exc: logger.error("Cannot write metric file: ".format(exc)) + return backup_too_small class NPBackupRunner: @@ -1016,12 +1014,16 @@ def backup(self, force: bool = False, read_from_stdin: bool = False, stdin_filen ) self.write_logs(f"Restic output:\n{self.restic_runner.backup_result_content}", level="debug") + # Extract backup size from result_string - # Metrics will not be in json format, since we need to diag cloud issues until - metrics_analyzer_result = metric_writer( + # there is a fix for https://github.com/restic/restic/issues/4155 + backup_too_small = metric_writer( self.repo_config, result, self.restic_runner.backup_result_content, self.restic_runner.dry_run ) + print(backup_too_small) + if backup_too_small: + self.write_logs("Backup is smaller than expected", level="error") post_exec_commands_success = True if post_exec_commands: @@ -1044,7 +1046,7 @@ def backup(self, force: bool = False, read_from_stdin: bool = False, stdin_filen ) operation_result = ( - result and pre_exec_commands_success and post_exec_commands_success + result and pre_exec_commands_success and post_exec_commands_success and not backup_too_small ) msg = f"Operation finished with {'success' if operation_result else 'failure'}" self.write_logs(msg, level="info" if operation_result else "error", diff --git a/npbackup/restic_metrics/__init__.py b/npbackup/restic_metrics/__init__.py index 7814c31..3721195 100644 --- a/npbackup/restic_metrics/__init__.py +++ b/npbackup/restic_metrics/__init__.py @@ -168,14 +168,14 @@ def restic_str_output_to_json( def restic_json_to_prometheus( - restic_json, labels: dict = None -) -> Tuple[bool, List[str]]: + restic_result: bool, restic_json: dict, labels: dict = None, minimum_backup_size_error: float = None, +) -> Tuple[bool, List[str], bool]: """ Transform a restic JSON result into prometheus metrics """ _labels = [] for key, value in labels.items(): - _labels.append(f'{key}="{value}"') + _labels.append(f'{key.strip()}="{value.strip()}"') labels = ",".join(_labels) # Take last line of restic output @@ -222,7 +222,20 @@ def restic_json_to_prometheus( if "duration" in key: key += "_seconds" prom_metrics.append(f'restic_{key}{{{labels},action="backup"}} {value}') - return prom_metrics + + backup_too_small = False + if minimum_backup_size_error: + if restic_json["data_added"] < minimum_backup_size_error: + backup_too_small = True + good_backup = restic_result and not backup_too_small + + prom_metrics.append( + 'restic_backup_failure{{{},timestamp="{}"}} {}'.format( + labels, int(datetime.utcnow().timestamp()), 1 if not good_backup else 0 + ) + ) + + return good_backup, prom_metrics, backup_too_small def restic_output_2_metrics(restic_result, output, labels=None): diff --git a/tests/test_restic_metrics.py b/tests/test_restic_metrics.py index 274d727..7d07aeb 100644 --- a/tests/test_restic_metrics.py +++ b/tests/test_restic_metrics.py @@ -133,7 +133,7 @@ def test_restic_str_output_to_json(): json_metrics = restic_str_output_to_json(True, output) assert json_metrics["errors"] == False #print(json_metrics) - prom_metrics = restic_json_to_prometheus(json_metrics, labels) + _, prom_metrics, _ = restic_json_to_prometheus(True, json_metrics, labels) #print(f"Parsed result:\n{prom_metrics}") for expected_result in expected_results_V2: @@ -155,7 +155,7 @@ def test_restic_json_output(): for version, json_output in restic_json_outputs.items(): print(f"Testing V2 direct restic --json output from version {version}") restic_json = json.loads(json_output) - prom_metrics = restic_json_to_prometheus(restic_json, labels) + _, prom_metrics, _ = restic_json_to_prometheus(True, restic_json, labels) #print(f"Parsed result:\n{prom_metrics}") for expected_result in expected_results_V2: match_found = False @@ -195,14 +195,15 @@ def test_real_restic_output(): exit_code, output = command_runner(f"{restic_binary} init --repository-version 2", live_output=True) + # Just backend current directory cmd = f"{restic_binary} backup {api_arg} ." - exit_code, output = command_runner(cmd, timeout=60, live_output=True) + exit_code, output = command_runner(cmd, timeout=120, live_output=True) assert exit_code == 0, "Failed to run restic" if not api_arg: restic_json = restic_str_output_to_json(True, output) else: restic_json = output - prom_metrics = restic_json_to_prometheus(restic_json, labels) + _, prom_metrics, _ = restic_json_to_prometheus(True, restic_json, labels) #print(f"Parsed result:\n{prom_metrics}") for expected_result in expected_results_V2: match_found = False From 9ddd676af37de9251f97da7963a614bcf76a821e Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Mon, 15 Jan 2024 18:03:25 +0100 Subject: [PATCH 208/328] Remove WIP print statement --- npbackup/core/runner.py | 1 - 1 file changed, 1 deletion(-) diff --git a/npbackup/core/runner.py b/npbackup/core/runner.py index e1ad510..4694336 100644 --- a/npbackup/core/runner.py +++ b/npbackup/core/runner.py @@ -1021,7 +1021,6 @@ def backup(self, force: bool = False, read_from_stdin: bool = False, stdin_filen backup_too_small = metric_writer( self.repo_config, result, self.restic_runner.backup_result_content, self.restic_runner.dry_run ) - print(backup_too_small) if backup_too_small: self.write_logs("Backup is smaller than expected", level="error") From 1ef8887b8b99bbe87a11f1ad55c9dec8c41d65a1 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Mon, 22 Jan 2024 09:38:52 +0100 Subject: [PATCH 209/328] WIP: Refactor UI --- npbackup/gui/config.py | 286 +++++++++++++----------- npbackup/translations/config_gui.en.yml | 16 +- npbackup/translations/config_gui.fr.yml | 17 +- npbackup/translations/generic.en.yml | 6 +- npbackup/translations/generic.fr.yml | 6 +- 5 files changed, 186 insertions(+), 145 deletions(-) diff --git a/npbackup/gui/config.py b/npbackup/gui/config.py index 54b010b..5b76fa5 100644 --- a/npbackup/gui/config.py +++ b/npbackup/gui/config.py @@ -14,12 +14,13 @@ import os from logging import getLogger import PySimpleGUI as sg +import textwrap from ruamel.yaml.comments import CommentedMap import npbackup.configuration as configuration from ofunctions.misc import get_key_from_value from npbackup.core.i18n_helper import _t from npbackup.path_helper import CURRENT_EXECUTABLE -from npbackup.customization import INHERITANCE_ICON +from npbackup.customization import INHERITANCE_ICON, FILE_ICON, FOLDER_ICON if os.name == "nt": from npbackup.windows.task import create_scheduled_task @@ -27,6 +28,29 @@ logger = getLogger() +# Monkeypatching PySimpleGUI +def delete(self, key): + if key == '': + return False + try: + node = self.tree_dict[key] + key_list = [key, ] + parent_node = self.tree_dict[node.parent] + parent_node.children.remove(node) + while key_list != []: + temp = [] + for item in key_list: + temp += self.tree_dict[item].children + del self.tree_dict[item] + key_list = temp + return True + except KeyError: + pass + + +sg.TreeData.delete = delete + + def ask_manager_password(manager_password: str) -> bool: if manager_password: if sg.PopupGetText( @@ -434,80 +458,82 @@ def object_layout() -> List[list]: """ backup_col = [ [ - sg.Text(_t("config_gui.compression"), size=(40, 1)), - sg.pin( - sg.Image( - INHERITANCE_ICON, - key="inherited.backup_opts.compression", - tooltip=_t("config_gui.group_inherited"), - ) + sg.Text( + textwrap.fill(f"{_t('config_gui.backup_paths')}"), + size=(None, None), expand_x=True, + ), + sg.Text( + textwrap.fill(f"{_t('config_gui.source_type')}"), + size=(None, None), expand_x=True, justification='R' ), sg.Combo( - list(combo_boxes["compression"].values()), - key="backup_opts.compression", + list(combo_boxes["source_type"].values()), + key="backup_opts.source_type", size=(48, 1), ), ], [ - sg.Text( - f"{_t('config_gui.backup_paths')}\n({_t('config_gui.one_per_line')})", - size=(40, 2), - ), - sg.pin( - sg.Image( - INHERITANCE_ICON, - expand_x=True, - expand_y=True, - key="inherited.backup_opts.paths", - tooltip=_t("config_gui.group_inherited"), - ) - ), - sg.Multiline(key="backup_opts.paths", size=(48, 4)), + sg.Tree(sg.TreeData(), key="inherited.backup_opts.path", headings=[], expand_x=True, expand_y=True) ], [ - sg.Text(_t("config_gui.source_type"), size=(40, 1)), - sg.pin( - sg.Image( - INHERITANCE_ICON, - expand_x=True, - expand_y=True, - key="inherited.backup_opts.source_type", - tooltip=_t("config_gui.group_inherited"), - ) - ), + sg.Input(visible=False, key="--PATHS-ADD-FILE--", enable_events=True), + sg.FilesBrowse(_t("generic.add_files"), target="--PATHS-ADD-FILE--"), + sg.Input(visible=False, key="--PATHS-ADD-FOLDER--", enable_events=True), + sg.FolderBrowse(_t("generic.add_folder"), target="--PATHS-ADD-FOLDER--"), + sg.Button(_t("generic.remove_selected"), key="--REMOVE-SELECTED-BACKUP-PATHS--") + ], + [ + sg.Text(_t("config_gui.compression"), size=(20, None)), + sg.Combo(list(combo_boxes["compression"].values()), key="backup_opts.compression", size=(20, 1)), + sg.pin(sg.Image(INHERITANCE_ICON, key="inherited.backup_opts.compression", tooltip=_t("config_gui.group_inherited"))), + sg.Text(_t("config_gui.backup_priority"), size=(20, 1)), sg.Combo( - list(combo_boxes["source_type"].values()), - key="backup_opts.source_type", - size=(48, 1), + list(combo_boxes["priority"].values()), + key="backup_opts.priority", + size=(20, 1), ), + sg.pin(sg.Image(INHERITANCE_ICON, key="inherited.backup_opts.backup_priority", tooltip=_t("config_gui.group_inherited"))) ], [ + sg.Checkbox("", key="backup_opts.use_fs_snapshot", size=(1, 1)), sg.Text( - "{}\n({})".format( - _t("config_gui.use_fs_snapshot"), _t("config_gui.windows_only") - ), + textwrap.fill(f'{_t("config_gui.use_fs_snapshot")} ({_t("config_gui.windows_only")})', width=34), + size=(34, 2), + ), + ], + [ + sg.Text(_t("config_gui.minimum_backup_size_error"), size=(40, 2)), + sg.Input(key="backup_opts.minimum_backup_size_error", size=(50, 1)), + ], + [ + sg.Text( + f"{_t('config_gui.tags')}\n({_t('config_gui.one_per_line')})", size=(40, 2), ), - sg.pin( - sg.Image( - INHERITANCE_ICON, - expand_x=True, - expand_y=True, - key="inherited.backup_opts.use_fs_snapshot", - tooltip=_t("config_gui.group_inherited"), - ) + sg.Multiline(key="backup_opts.tags", size=(48, 4)), + ], + [ + sg.Text(_t("config_gui.additional_parameters"), size=(40, 1)), + sg.Input(key="backup_opts.additional_parameters", size=(50, 1)), + ], + [ + sg.Text( + _t("config_gui.additional_backup_only_parameters"), size=(40, 1) + ), + sg.Input( + key="backup_opts.additional_backup_only_parameters", size=(50, 1) ), - sg.Checkbox("", key="backup_opts.use_fs_snapshot", size=(41, 1)), ], + + ] + + exclusions_col = [ [ + sg.Checkbox("", key="backup_opts.ignore_cloud_files", size=(1, 1)), sg.Text( - "{}\n({})".format( - _t("config_gui.ignore_cloud_files"), - _t("config_gui.windows_only"), - ), - size=(40, 2), + textwrap.fill(f'{_t("config_gui.ignore_cloud_files")} ({_t("config_gui.windows_only")})', width=34), + size=(34, 2), ), - sg.Checkbox("", key="backup_opts.ignore_cloud_files", size=(41, 1)), ], [ sg.Text( @@ -548,11 +574,10 @@ def object_layout() -> List[list]: sg.Text(_t("config_gui.one_file_system"), size=(40, 1)), sg.Checkbox("", key="backup_opts.one_file_system", size=(41, 1)), ], - [ - sg.Text(_t("config_gui.minimum_backup_size_error"), size=(40, 2)), - sg.Input(key="backup_opts.minimum_backup_size_error", size=(50, 1)), - ], - [ + ] + + pre_post_col = [ + [ sg.Text( f"{_t('config_gui.pre_exec_commands')}\n({_t('config_gui.one_per_line')})", size=(40, 2), @@ -594,33 +619,6 @@ def object_layout() -> List[list]: size=(41, 1), ), ], - [ - sg.Text( - f"{_t('config_gui.tags')}\n({_t('config_gui.one_per_line')})", - size=(40, 2), - ), - sg.Multiline(key="backup_opts.tags", size=(48, 4)), - ], - [ - sg.Text(_t("config_gui.backup_priority"), size=(40, 1)), - sg.Combo( - list(combo_boxes["priority"].values()), - key="backup_opts.priority", - size=(48, 1), - ), - ], - [ - sg.Text(_t("config_gui.additional_parameters"), size=(40, 1)), - sg.Input(key="backup_opts.additional_parameters", size=(50, 1)), - ], - [ - sg.Text( - _t("config_gui.additional_backup_only_parameters"), size=(40, 1) - ), - sg.Input( - key="backup_opts.additional_backup_only_parameters", size=(50, 1) - ), - ], ] repo_col = [ @@ -628,6 +626,14 @@ def object_layout() -> List[list]: sg.Text(_t("config_gui.backup_repo_uri"), size=(40, 1)), sg.Input(key="repo_uri", size=(50, 1)), ], + [ + sg.Text(_t("config_gui.backup_repo_password"), size=(40, 1)), + sg.Input(key="repo_opts.repo_password", size=(50, 1)), + ], + [ + sg.Text(_t("config_gui.backup_repo_password_command"), size=(40, 1)), + sg.Input(key="repo_opts.repo_password_command", size=(50, 1)), + ], [sg.Button(_t("config_gui.set_permissions"), key="--SET-PERMISSIONS--")], [ sg.Text(_t("config_gui.repo_group"), size=(40, 1)), @@ -642,14 +648,6 @@ def object_layout() -> List[list]: ), sg.Input(key="repo_opts.minimum_backup_age", size=(50, 1)), ], - [ - sg.Text(_t("config_gui.backup_repo_password"), size=(40, 1)), - sg.Input(key="repo_opts.repo_password", size=(50, 1)), - ], - [ - sg.Text(_t("config_gui.backup_repo_password_command"), size=(40, 1)), - sg.Input(key="repo_opts.repo_password_command", size=(50, 1)), - ], [ sg.Text(_t("config_gui.upload_speed"), size=(40, 1)), sg.Input(key="repo_opts.upload_speed", size=(50, 1)), @@ -774,37 +772,41 @@ def object_layout() -> List[list]: [ sg.Tab( _t("config_gui.backup"), - [ - [ - sg.Column( - backup_col, - scrollable=True, - vertical_scroll_only=True, - size=(700, 450), - ) - ] - ], + backup_col, font="helvetica 16", key="--tab-backup--", - element_justification="L", + #element_justification="L", + expand_x=True, + expand_y=True ) ], [ sg.Tab( _t("config_gui.backup_destination"), - [ - [ - sg.Column( - repo_col, - scrollable=True, - vertical_scroll_only=True, - size=(700, 450), - ) - ] - ], + repo_col, font="helvetica 16", key="--tab-repo--", - element_justification="L", + expand_x=True, expand_y=True, + ) + ], + [ + sg.Tab( + _t("config_gui.exclusions"), + exclusions_col, + font="helvetica 16", + key="--tab-exclusions--", + expand_x=True, + expand_y=True + ) + ], + [ + sg.Tab( + _t("config_gui.pre_post"), + pre_post_col, + font="helvetica 16", + key="--tab-hooks--", + expand_x=True, + expand_y=True ) ], [ @@ -813,7 +815,7 @@ def object_layout() -> List[list]: prometheus_col, font="helvetica 16", key="--tab-prometheus--", - element_justification="L", + expand_x=True, expand_y=True, ) ], [ @@ -822,13 +824,15 @@ def object_layout() -> List[list]: env_col, font="helvetica 16", key="--tab-env--", - element_justification="L", + expand_x=True, expand_y=True, ) ], ] _layout = [ - [sg.Column(object_selector, element_justification="L")], + [sg.Column(object_selector, + #element_justification="L" + )], [ sg.TabGroup( tab_group_layout, enable_events=True, key="--object-tabgroup--" @@ -906,7 +910,7 @@ def global_options_layout(): identity_col, font="helvetica 16", key="--tab-global-identification--", - element_justification="L", + #element_justification="L", ) ], [ @@ -915,7 +919,7 @@ def global_options_layout(): global_options_col, font="helvetica 16", key="--tab-global-options--", - element_justification="L", + #element_justification="L", ) ], [ @@ -924,7 +928,7 @@ def global_options_layout(): scheduled_task_col, font="helvetica 16", key="--tab-global-scheduled_task--", - element_justification="L", + #element_justification="L", ) ], ] @@ -943,13 +947,13 @@ def config_layout() -> List[list]: [ sg.Push(), sg.Button( - _t("config_gui.create_object"), key="-OBJECT-CREATE-", size=(30, 1) + _t("config_gui.create_object"), key="-OBJECT-CREATE-", size=(28, 1) ), sg.Button( - _t("config_gui.delete_object"), key="-OBJECT-DELETE-", size=(30, 1) + _t("config_gui.delete_object"), key="-OBJECT-DELETE-", size=(28, 1) ), - sg.Button(_t("generic.cancel"), key="--CANCEL--", size=(15, 1)), - sg.Button(_t("generic.accept"), key="--ACCEPT--", size=(15, 1)), + sg.Button(_t("generic.cancel"), key="--CANCEL--", size=(13, 1)), + sg.Button(_t("generic.accept"), key="--ACCEPT--", size=(13, 1)), ] ] @@ -959,6 +963,8 @@ def config_layout() -> List[list]: _t("config_gui.repo_group_config"), object_layout(), key="--repo-group-config--", + expand_x=True, + expand_y=True ) ], [ @@ -966,6 +972,8 @@ def config_layout() -> List[list]: _t("config_gui.global_config"), global_options_layout(), key="--global-config--", + expand_x=True, + expand_y=True ) ], ] @@ -973,10 +981,11 @@ def config_layout() -> List[list]: _global_layout = [ [ sg.TabGroup( - tab_group_layout, enable_events=True, key="--configtabgroup--" + tab_group_layout, enable_events=True, key="--configtabgroup--", expand_x=True, expand_y=True, ) ], - [sg.Push(), sg.Column(buttons, element_justification="L")], + [sg.Push(), sg.Column(buttons, + )], ] return _global_layout @@ -984,8 +993,7 @@ def config_layout() -> List[list]: window = sg.Window( "Configuration", config_layout(), - size=(800, 600), - text_justification="C", + #size=(800, 650), auto_size_text=True, auto_size_buttons=False, no_titlebar=False, @@ -997,6 +1005,8 @@ def config_layout() -> List[list]: finalize=True, ) + backup_paths_tree = sg.TreeData() + # Update gui with first default object (repo or group) update_object_gui(get_objects()[0], unencrypted=False) update_global_gui(full_config, unencrypted=False) @@ -1028,6 +1038,20 @@ def config_layout() -> List[list]: if ask_manager_password(manager_password): full_config = set_permissions(full_config, values["-OBJECT-SELECT-"]) continue + if event in ("--PATHS-ADD-FILE--", '--PATHS-ADD-FOLDER--'): + if event == "--PATHS-ADD-FILE--": + node = values["--PATHS-ADD-FILE--"] + icon = FILE_ICON + elif event == '--PATHS-ADD-FOLDER--': + node = values['--PATHS-ADD-FOLDER--'] + icon = FOLDER_ICON + backup_paths_tree.insert('', node, node, node, icon=icon) + window['inherited.backup_opts.path'].update(values=backup_paths_tree) + if event == "--REMOVE-SELECTED-BACKUP-PATHS--": + # TODO: prevent removing inherited values + for key in values['inherited.backup_opts.path']: + backup_paths_tree.delete(key) + window['inherited.backup_opts.path'].update(values=backup_paths_tree) if event == "--ACCEPT--": if ( not values["repo_opts.repo_password"] diff --git a/npbackup/translations/config_gui.en.yml b/npbackup/translations/config_gui.en.yml index 0c17be2..2a693b5 100644 --- a/npbackup/translations/config_gui.en.yml +++ b/npbackup/translations/config_gui.en.yml @@ -1,6 +1,11 @@ en: - encrypted_data: Encrypted Data + # tabs backup: Backup + backup_destination: Destination + exclusions: Exclusions + pre_post: Pre/Post exec + + encrypted_data: Encrypted Data compression: Compression backup_paths: Backup paths use_fs_snapshot: Use VSS snapshots @@ -24,7 +29,6 @@ en: additional_parameters: Additional parameters additional_backup_only_parameters: Additional backup only parmas - backup_destination: Backup destination minimum_backup_age: Minimum delay between two backups backup_repo_uri: backup repo URI / path backup_repo_password: Backup repo encryption password @@ -98,10 +102,10 @@ en: # source types source_type: Sources type - folder_list: Folder list - files_from: Files from list - files_from_verbatim: Files from verbatim list - files_from_raw: Files from raw list + folder_list: Folder / file list + files_from: From file + files_from_verbatim: From verbatim + files_from_raw: From raw # retention policiy keep: Keep diff --git a/npbackup/translations/config_gui.fr.yml b/npbackup/translations/config_gui.fr.yml index 8588090..459443b 100644 --- a/npbackup/translations/config_gui.fr.yml +++ b/npbackup/translations/config_gui.fr.yml @@ -1,6 +1,12 @@ fr: - encrypted_data: Donnée Chiffrée + # tabs backup: Sauvegarde + backup_destination: Destination + exclusions: Exclusions + pre_post: Pré/Post exec + + + encrypted_data: Donnée Chiffrée compression: Compression backup_paths: Chemins à sauvegarder use_fs_snapshot: Utiliser les instantanés VSS @@ -24,7 +30,6 @@ fr: additional_parameters: Paramètres supplémentaires additional_backup_only_parameters: Paramètres supp. sauvegarde - backup_destination: Destination de sauvegarde minimum_backup_age: Délai minimal entre deux sauvegardes backup_repo_uri: URL / chemin local dépot de sauvegarde backup_repo_password: Mot de passe (chiffrement) dépot de sauvegarde @@ -98,10 +103,10 @@ fr: # source types source_type: Type de sources - folder_list: Liste de dossiers - files_from: Liste fichiers depuis un fichier - files_from_verbatim: Liste fichiers depuis un fichier "exact" - files_from_raw: Liste fichiers depuis un fichier "raw" + folder_list: Liste de dossiers / fichiers + files_from: Liste depuis un fichier + files_from_verbatim: Liste depuis un fichier "exact" + files_from_raw: Liste depuis un fichier "raw" # retention policies retention_policy: Politique de conservation diff --git a/npbackup/translations/generic.en.yml b/npbackup/translations/generic.en.yml index da19e97..7b933ba 100644 --- a/npbackup/translations/generic.en.yml +++ b/npbackup/translations/generic.en.yml @@ -62,4 +62,8 @@ en: please_wait: Please wait bad_file: Bad file - file_does_not_exist: File does not exist \ No newline at end of file + file_does_not_exist: File does not exist + + add_files: Add files + add_folder: Add folder + remove_selected: Remove selected \ No newline at end of file diff --git a/npbackup/translations/generic.fr.yml b/npbackup/translations/generic.fr.yml index b14b95b..4563651 100644 --- a/npbackup/translations/generic.fr.yml +++ b/npbackup/translations/generic.fr.yml @@ -62,4 +62,8 @@ fr: please_wait: Merci de patienter bad_file: Fichier erroné - file_does_not_exist: Fichier inexistant \ No newline at end of file + file_does_not_exist: Fichier inexistant + + add_files: Ajouter fichiers + add_folder: Ajouter dossier + remove_selected: Enlever la sélection \ No newline at end of file From d63b93ea6de8aca29a54e323237f843ebdebabd4 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Mon, 22 Jan 2024 09:39:14 +0100 Subject: [PATCH 210/328] WIP: permissions --- npbackup/configuration.py | 78 ++++++++++++++++++++++++++------------- 1 file changed, 53 insertions(+), 25 deletions(-) diff --git a/npbackup/configuration.py b/npbackup/configuration.py index 78bcab2..548e49f 100644 --- a/npbackup/configuration.py +++ b/npbackup/configuration.py @@ -7,7 +7,7 @@ __author__ = "Orsiris de Jong" __copyright__ = "Copyright (C) 2022-2024 NetInvent" __license__ = "GPL-3.0-only" -__build__ = "2023122901" +__build__ = "2024010501" __version__ = "2.0.0 for npbackup 2.3.0+" MIN_CONF_VERSION = 2.3 @@ -91,12 +91,16 @@ def d(self, path, sep="."): Deletion for dot notation in a dict/OrderedDict d.d('my.array.keys') """ - data = self - keys = path.split(sep) - lastkey = keys[-1] - for key in keys[:-1]: - data = data[key] - data.pop(lastkey) + try: + data = self + keys = path.split(sep) + lastkey = keys[-1] + for key in keys[:-1]: + data = data[key] + data.pop(lastkey) + except KeyError: + # We don't care deleting non existent keys ^^ + pass ordereddict.g = g @@ -380,35 +384,49 @@ def _evaluate_variables(value): return repo_config -def extract_permissions_from_repo_config(repo_config: dict) -> dict: +def extract_permissions_from_full_config(full_config: dict) -> dict: """ Extract permissions and manager password from repo_uri tuple repo_config objects in memory are always "expanded" This function is in order to expand when loading config """ - repo_uri = repo_config.g("repo_uri") - if isinstance(repo_uri, tuple): - repo_uri, permissions, manager_password = repo_uri - repo_config.s("permissions", permissions) - repo_config.s("manager_password", manager_password) - return repo_config + for repo in full_config.g("repos").keys(): + repo_uri = full_config.g(f"repos.{repo}.repo_uri") + if isinstance(repo_uri, tuple): + repo_uri, permissions, manager_password = repo_uri + # Overwrite existing permissions / password if it was set in repo_uri + full_config.s(f"repos.{repo}.repo_uri", repo_uri) + full_config.s(f"repos.{repo}.permissions", permissions) + full_config.s(f"repos.{repo}.manager_password", manager_password) + full_config.s(f"repos.{repo}.__saved_manager_password", manager_password) + return full_config -def inject_permissions_into_repo_config(repo_config: dict) -> dict: +def inject_permissions_into_full_config(full_config: dict) -> Tuple[bool, dict]: """ Make sure repo_uri is a tuple containing permissions and manager password This function is used before saving config - NPF-SEC-00006: Never inject permissions if some are already present + NPF-SEC-00006: Never inject permissions if some are already present unless current manager password equals initial one """ - repo_uri = repo_config.g("repo_uri") - permissions = repo_config.g("permissions") - manager_password = repo_config.g("manager_password") - repo_config.s(repo_uri, (repo_uri, permissions, manager_password)) - repo_config.d("repo_uri") - repo_config.d("permissions") - repo_config.d("manager_password") - return repo_config + updated_full_config = False + for repo in full_config.g("repos").keys(): + repo_uri = full_config.g(f"repos.{repo}.repo_uri") + manager_password = full_config.g(f"repos.{repo}.manager_password") + permissions = full_config.g(f"repos.{repo}.permissions") + __saved_manager_password = full_config.g(f"repos.{repo}.__saved_manager_password") + + if __saved_manager_password and manager_password and __saved_manager_password == manager_password: + updated_full_config = True + full_config.s(f"repos.{repo}.repo_uri", (repo_uri, permissions, manager_password)) + full_config.s(f"repos.{repo}.is_protected", True) + else: + logger.info(f"Permissions are already set for repo {repo}. Will not update them unless manager password is given") + + full_config.d(f"repos.{repo}.__saved_manager_password") # Don't keep decrypted manager password + full_config.d(f"repos.{repo}.permissions") + full_config.d(f"repos.{repo}.manager_password") + return updated_full_config, full_config def get_manager_password(full_config: dict, repo_name: str) -> str: @@ -508,7 +526,6 @@ def _inherit_group_settings( if eval_variables: repo_config = evaluate_variables(repo_config, full_config) - repo_config = extract_permissions_from_repo_config(repo_config) return repo_config, config_inheritance @@ -593,6 +610,15 @@ def load_config(config_file: Path) -> Optional[dict]: config_file_is_updated = True logger.info("Handling random variables in configuration files") + # Inject permissions into conf file if needed + is_modified, full_config = inject_permissions_into_full_config(full_config) + if is_modified: + config_file_is_updated = True + logger.info("Handling permissions in configuration file") + + # Extract permissions / password from repo + full_config = extract_permissions_from_full_config(full_config) + # save config file if needed if config_file_is_updated: logger.info("Updating config file") @@ -603,6 +629,8 @@ def load_config(config_file: Path) -> Optional[dict]: def save_config(config_file: Path, full_config: dict) -> bool: try: with open(config_file, "w", encoding="utf-8") as file_handle: + _, full_config = inject_permissions_into_full_config(full_config) + if not is_encrypted(full_config): full_config = crypt_config( full_config, AES_KEY, ENCRYPTED_OPTIONS, operation="encrypt" From fa7fc8f69bd708569f953dba0b0f32c105c110b9 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Mon, 22 Jan 2024 09:39:21 +0100 Subject: [PATCH 211/328] WIP permissions --- npbackup/core/runner.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/npbackup/core/runner.py b/npbackup/core/runner.py index 4694336..43e125f 100644 --- a/npbackup/core/runner.py +++ b/npbackup/core/runner.py @@ -375,8 +375,9 @@ def wrapper(self, *args, **kwargs): # pylint: disable=E1101 (no-member) operation = fn.__name__ + current_permissions = self.repo_config.g("permissions") self.write_logs( - f"Permissions required are {required_permissions[operation]}", + f"Permissions required are {required_permissions[operation]}, current permissions are {current_permissions}", level="info", ) has_permissions = True # TODO: enforce permissions From 8387ebc35471c328280551f8d76abf6117c2b1e3 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Mon, 22 Jan 2024 12:39:32 +0100 Subject: [PATCH 212/328] Make minimum_backup_size_error unit aware --- npbackup/configuration.py | 2 +- npbackup/restic_metrics/__init__.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/npbackup/configuration.py b/npbackup/configuration.py index 548e49f..af203c5 100644 --- a/npbackup/configuration.py +++ b/npbackup/configuration.py @@ -164,7 +164,7 @@ def d(self, path, sep="."): "exclude_files_larger_than": None, "additional_parameters": None, "additional_backup_only_parameters": None, - "minimum_backup_size_error": "10", # In megabytes + "minimum_backup_size_error": "10MiB", # allows BytesConverter units "pre_exec_commands": [], "pre_exec_per_command_timeout": 3600, "pre_exec_failure_is_fatal": False, diff --git a/npbackup/restic_metrics/__init__.py b/npbackup/restic_metrics/__init__.py index 3721195..e7fc6cc 100644 --- a/npbackup/restic_metrics/__init__.py +++ b/npbackup/restic_metrics/__init__.py @@ -168,7 +168,7 @@ def restic_str_output_to_json( def restic_json_to_prometheus( - restic_result: bool, restic_json: dict, labels: dict = None, minimum_backup_size_error: float = None, + restic_result: bool, restic_json: dict, labels: dict = None, minimum_backup_size_error: str = None, ) -> Tuple[bool, List[str], bool]: """ Transform a restic JSON result into prometheus metrics @@ -225,7 +225,7 @@ def restic_json_to_prometheus( backup_too_small = False if minimum_backup_size_error: - if restic_json["data_added"] < minimum_backup_size_error: + if restic_json["data_added"] < int(BytesConverter(str(minimum_backup_size_error).replace(" ", ""))): backup_too_small = True good_backup = restic_result and not backup_too_small From caf8b27bc605977d7d174e1634c25c3a880e5452 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Mon, 22 Jan 2024 21:26:22 +0100 Subject: [PATCH 213/328] WIP GUI refactor --- npbackup/gui/config.py | 77 ++++++++++++++++++------- npbackup/translations/config_gui.en.yml | 2 +- npbackup/translations/config_gui.fr.yml | 2 +- npbackup/translations/generic.en.yml | 1 + npbackup/translations/generic.fr.yml | 1 + 5 files changed, 60 insertions(+), 23 deletions(-) diff --git a/npbackup/gui/config.py b/npbackup/gui/config.py index 5b76fa5..ede8586 100644 --- a/npbackup/gui/config.py +++ b/npbackup/gui/config.py @@ -96,6 +96,9 @@ def config_gui(full_config: dict, config_file: str): }, } + byte_units = ["B", "KB", "KiB", "MB", "MiB", "GB", "GiB", "TB", "TiB", "PB", "PiB"] + + ENCRYPTED_DATA_PLACEHOLDER = "<{}>".format(_t("config_gui.encrypted_data")) def get_objects() -> List[str]: @@ -272,7 +275,10 @@ def update_object_gui(object_name=None, unencrypted=False): for key in window.AllKeysDict: # We only clear config keys, wihch have '.' separator if "." in str(key) and not "inherited" in str(key): - window[key]("") + if isinstance(window[key], sg.Tree): + window[key].Update(sg.TreeData()) + else: + window[key]("") object_type, object_name = get_object_from_combo(object_name) @@ -473,7 +479,9 @@ def object_layout() -> List[list]: ), ], [ - sg.Tree(sg.TreeData(), key="inherited.backup_opts.path", headings=[], expand_x=True, expand_y=True) + sg.Tree(sg.TreeData(), key="inherited.backup_opts.path", headings=[], + col0_heading=_t("generic.paths"), + expand_x=True, expand_y=True) ], [ sg.Input(visible=False, key="--PATHS-ADD-FILE--", enable_events=True), @@ -494,32 +502,43 @@ def object_layout() -> List[list]: ), sg.pin(sg.Image(INHERITANCE_ICON, key="inherited.backup_opts.backup_priority", tooltip=_t("config_gui.group_inherited"))) ], + [ + sg.Column([ + [ + sg.Tree(sg.TreeData(), key="inherited.backup_opts.tags", headings=[], + col0_heading="Tags", col0_width=30, num_rows=3, expand_x=True, expand_y=True) + ] + ], pad=0, size=(295, 80)), + sg.Column([ + [ + sg.Button("+", key="--ADD-TAG--", size=(3, 1)) + ], + [ + sg.Button("-", key="--REMOVE-TAG--", size=(3, 1)) + ] + ], pad=0, size=(50, 80)), + sg.Column([ + [ + sg.Text(_t("config_gui.minimum_backup_size_error"), size=(40, 2)), + ], + [ + sg.Input(key="backup_opts.minimum_backup_size_error", size=(20, 1)), + sg.Combo(byte_units, default_value=byte_units[3], key="bakcup_opts.minimum_backup_size_error_unit") + ] + ], pad=0, size=(300, 80)) + ], [ sg.Checkbox("", key="backup_opts.use_fs_snapshot", size=(1, 1)), sg.Text( textwrap.fill(f'{_t("config_gui.use_fs_snapshot")} ({_t("config_gui.windows_only")})', width=34), size=(34, 2), ), - ], - [ - sg.Text(_t("config_gui.minimum_backup_size_error"), size=(40, 2)), - sg.Input(key="backup_opts.minimum_backup_size_error", size=(50, 1)), - ], - [ sg.Text( - f"{_t('config_gui.tags')}\n({_t('config_gui.one_per_line')})", - size=(40, 2), + _t("config_gui.additional_backup_only_parameters"), size=(40, 1) ), - sg.Multiline(key="backup_opts.tags", size=(48, 4)), - ], - [ - sg.Text(_t("config_gui.additional_parameters"), size=(40, 1)), - sg.Input(key="backup_opts.additional_parameters", size=(50, 1)), ], [ - sg.Text( - _t("config_gui.additional_backup_only_parameters"), size=(40, 1) - ), + sg.Input( key="backup_opts.additional_backup_only_parameters", size=(50, 1) ), @@ -753,6 +772,10 @@ def object_layout() -> List[list]: ), sg.Multiline(key="env.encrypted_env_variables", size=(48, 5)), ], + [ + sg.Text(_t("config_gui.additional_parameters"), size=(40, 1)), + sg.Input(key="backup_opts.additional_parameters", size=(50, 1)), + ], ] object_list = get_objects() @@ -964,7 +987,8 @@ def config_layout() -> List[list]: object_layout(), key="--repo-group-config--", expand_x=True, - expand_y=True + expand_y=True, + pad=0 ) ], [ @@ -973,7 +997,8 @@ def config_layout() -> List[list]: global_options_layout(), key="--global-config--", expand_x=True, - expand_y=True + expand_y=True, + pad=0 ) ], ] @@ -981,7 +1006,7 @@ def config_layout() -> List[list]: _global_layout = [ [ sg.TabGroup( - tab_group_layout, enable_events=True, key="--configtabgroup--", expand_x=True, expand_y=True, + tab_group_layout, enable_events=True, key="--configtabgroup--", expand_x=True, expand_y=True, pad=0, ) ], [sg.Push(), sg.Column(buttons, @@ -1006,6 +1031,7 @@ def config_layout() -> List[list]: ) backup_paths_tree = sg.TreeData() + tags_tree = sg.TreeData() # Update gui with first default object (repo or group) update_object_gui(get_objects()[0], unencrypted=False) @@ -1052,6 +1078,15 @@ def config_layout() -> List[list]: for key in values['inherited.backup_opts.path']: backup_paths_tree.delete(key) window['inherited.backup_opts.path'].update(values=backup_paths_tree) + if event == "--ADD-TAG--": + node = sg.PopupGetText(_t("config_gui.enter_tag")) + if node: + tags_tree.insert('', node, node, node) + window["inherited.backup_opts.tags"].Update(values=tags_tree) + if event == "--REMOVE-TAG--": + for key in values["inherited.backup_opts.tags"]: + tags_tree.delete(key) + window["inherited.backup_opts.tags"].Update(values=tags_tree) if event == "--ACCEPT--": if ( not values["repo_opts.repo_password"] diff --git a/npbackup/translations/config_gui.en.yml b/npbackup/translations/config_gui.en.yml index 2a693b5..425749a 100644 --- a/npbackup/translations/config_gui.en.yml +++ b/npbackup/translations/config_gui.en.yml @@ -19,7 +19,7 @@ en: one_file_system: Do not follow mountpoints minimum_backup_size_error: Minimum size under which backup is considered failed pre_exec_commands: Pre-exec commands - maximum_exec_time: Maximum exec time + maximum_exec_time: Maximum exec time (seconds) exec_failure_is_fatal: Execution failure is fatal post_exec_commands: Post-exec commands execute_even_on_backup_error: Execute even if backup failed diff --git a/npbackup/translations/config_gui.fr.yml b/npbackup/translations/config_gui.fr.yml index 459443b..6f11b55 100644 --- a/npbackup/translations/config_gui.fr.yml +++ b/npbackup/translations/config_gui.fr.yml @@ -20,7 +20,7 @@ fr: one_file_system: Ne pas suivre les points de montage minimum_backup_size_error: Taille minimale en dessous de laquelle la sauvegarde est considérée échouée pre_exec_commands: Commandes pré-sauvegarde - maximum_exec_time: Temps maximal d'execution + maximum_exec_time: Temps maximal d'execution (secondes) exec_failure_is_fatal: L'échec d'execution est fatal post_exec_commands: Commandes post-sauvegarde execute_even_on_backup_error: Executer même si la sauvegarde a échouée diff --git a/npbackup/translations/generic.en.yml b/npbackup/translations/generic.en.yml index 7b933ba..5b2d54d 100644 --- a/npbackup/translations/generic.en.yml +++ b/npbackup/translations/generic.en.yml @@ -28,6 +28,7 @@ en: size: Size path: Path + paths: Paths modification_date: Modification date content: Content diff --git a/npbackup/translations/generic.fr.yml b/npbackup/translations/generic.fr.yml index 4563651..c0bbf4f 100644 --- a/npbackup/translations/generic.fr.yml +++ b/npbackup/translations/generic.fr.yml @@ -28,6 +28,7 @@ fr: size: Taille path: Chemin + path: Chemins modification_date: Date de modification content: Contenu From 3cfb20a96c82bb4b560579686d1586c6fa95fcb8 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Tue, 30 Jan 2024 00:28:07 +0100 Subject: [PATCH 214/328] WIP: refactor UI --- npbackup/gui/config.py | 167 ++++++++++++++---------- npbackup/translations/config_gui.en.yml | 2 +- npbackup/translations/config_gui.fr.yml | 2 +- 3 files changed, 103 insertions(+), 68 deletions(-) diff --git a/npbackup/gui/config.py b/npbackup/gui/config.py index ede8586..f440fec 100644 --- a/npbackup/gui/config.py +++ b/npbackup/gui/config.py @@ -491,48 +491,57 @@ def object_layout() -> List[list]: sg.Button(_t("generic.remove_selected"), key="--REMOVE-SELECTED-BACKUP-PATHS--") ], [ - sg.Text(_t("config_gui.compression"), size=(20, None)), - sg.Combo(list(combo_boxes["compression"].values()), key="backup_opts.compression", size=(20, 1)), - sg.pin(sg.Image(INHERITANCE_ICON, key="inherited.backup_opts.compression", tooltip=_t("config_gui.group_inherited"))), - sg.Text(_t("config_gui.backup_priority"), size=(20, 1)), - sg.Combo( - list(combo_boxes["priority"].values()), - key="backup_opts.priority", - size=(20, 1), - ), - sg.pin(sg.Image(INHERITANCE_ICON, key="inherited.backup_opts.backup_priority", tooltip=_t("config_gui.group_inherited"))) - ], - [ - sg.Column([ - [ - sg.Tree(sg.TreeData(), key="inherited.backup_opts.tags", headings=[], - col0_heading="Tags", col0_width=30, num_rows=3, expand_x=True, expand_y=True) - ] - ], pad=0, size=(295, 80)), - sg.Column([ - [ - sg.Button("+", key="--ADD-TAG--", size=(3, 1)) - ], + sg.Column( [ - sg.Button("-", key="--REMOVE-TAG--", size=(3, 1)) - ] - ], pad=0, size=(50, 80)), - sg.Column([ - [ - sg.Text(_t("config_gui.minimum_backup_size_error"), size=(40, 2)), - ], + [ + sg.Text(_t("config_gui.compression"), size=(20, None)), + sg.Combo(list(combo_boxes["compression"].values()), key="backup_opts.compression", size=(20, 1)), + sg.pin(sg.Image(INHERITANCE_ICON, key="inherited.backup_opts.compression", tooltip=_t("config_gui.group_inherited"))), + ], + [ + sg.Text(_t("config_gui.backup_priority"), size=(20, 1)), + sg.Combo( + list(combo_boxes["priority"].values()), + key="backup_opts.priority", + size=(20, 1), + ), + sg.pin(sg.Image(INHERITANCE_ICON, key="inherited.backup_opts.backup_priority", tooltip=_t("config_gui.group_inherited"))), + ], + [ + sg.Column([ + [ + sg.Button("+", key="--ADD-TAG--", size=(3, 1)) + ], + [ + sg.Button("-", key="--REMOVE-TAG--", size=(3, 1)) + ] + ], pad=0, size=(40, 80)), + sg.Column([ + [ + sg.Tree(sg.TreeData(), key="inherited.backup_opts.tags", headings=[], + col0_heading="Tags", col0_width=30, num_rows=3, expand_x=True, expand_y=True) + ] + ], pad=0, size=(300, 80)) + ] + ], pad=0 + ), + sg.Column( [ - sg.Input(key="backup_opts.minimum_backup_size_error", size=(20, 1)), - sg.Combo(byte_units, default_value=byte_units[3], key="bakcup_opts.minimum_backup_size_error_unit") - ] - ], pad=0, size=(300, 80)) + [ + sg.Text(_t("config_gui.minimum_backup_size_error"), size=(40, 2)), + ], + [ + sg.Input(key="backup_opts.minimum_backup_size_error", size=(8, 1)), + sg.Combo(byte_units, default_value="KB", key="bakcup_opts.minimum_backup_size_error_unit") + ], + [ + sg.Checkbox(textwrap.fill(f'{_t("config_gui.use_fs_snapshot")}', width=34), key="backup_opts.use_fs_snapshot", size=(40, 1), pad=0), + ] + ], pad=0 + ) ], [ - sg.Checkbox("", key="backup_opts.use_fs_snapshot", size=(1, 1)), - sg.Text( - textwrap.fill(f'{_t("config_gui.use_fs_snapshot")} ({_t("config_gui.windows_only")})', width=34), - size=(34, 2), - ), + sg.Text( _t("config_gui.additional_backup_only_parameters"), size=(40, 1) ), @@ -540,7 +549,7 @@ def object_layout() -> List[list]: [ sg.Input( - key="backup_opts.additional_backup_only_parameters", size=(50, 1) + key="backup_opts.additional_backup_only_parameters", size=(100, 1) ), ], @@ -555,11 +564,25 @@ def object_layout() -> List[list]: ), ], [ - sg.Text( - f"{_t('config_gui.exclude_patterns')}\n({_t('config_gui.one_per_line')})", - size=(40, 2), + sg.Column( + [ + [ + sg.Button("+", key="--ADD-TAG--", size=(3, 1)) + ], + [ + sg.Button("-", key="--REMOVE-TAG--", size=(3, 1)) + ] + ], pad=0, ), - sg.Multiline(key="backup_opts.exclude_patterns", size=(48, 4)), + sg.Column( + [ + [ + sg.Tree(sg.TreeData(), key="backup_opts.exclude_patterns", headings=[], + col0_heading=_t('config_gui.exclude_patterns'), + num_rows=3, expand_x=True, expand_y=True) + ] + ], pad=0, expand_x=True + ) ], [ sg.Text( @@ -688,29 +711,41 @@ def object_layout() -> List[list]: ), ], [ - sg.Text(_t("config_gui.keep"), size=(30, 1)), - sg.Input(key="repo_opts.retention_strategy.hourly", size=(3, 1)), - sg.Text(_t("config_gui.hourly"), size=(20, 1)), - ], - [ - sg.Text(_t("config_gui.keep"), size=(30, 1)), - sg.Input(key="repo_opts.retention_strategy.daily", size=(3, 1)), - sg.Text(_t("config_gui.daily"), size=(20, 1)), - ], - [ - sg.Text(_t("config_gui.keep"), size=(30, 1)), - sg.Input(key="repo_opts.retention_strategy.weekly", size=(3, 1)), - sg.Text(_t("config_gui.weekly"), size=(20, 1)), - ], - [ - sg.Text(_t("config_gui.keep"), size=(30, 1)), - sg.Input(key="repo_opts.retention_strategy.monthly", size=(3, 1)), - sg.Text(_t("config_gui.monthly"), size=(20, 1)), - ], - [ - sg.Text(_t("config_gui.keep"), size=(30, 1)), - sg.Input(key="repo_opts.retention_strategy.yearly", size=(3, 1)), - sg.Text(_t("config_gui.yearly"), size=(20, 1)), + sg.Column( + [ + [ + sg.Text(_t("config_gui.keep"), size=(30, 1)), + ] + ] + ), + sg.Column( + [ + [ + sg.Input(key="repo_opts.retention_strategy.hourly", size=(3, 1)), + sg.Text(_t("config_gui.hourly"), size=(20, 1)), + ], + [ + sg.Input(key="repo_opts.retention_strategy.daily", size=(3, 1)), + sg.Text(_t("config_gui.daily"), size=(20, 1)), + ], + [ + sg.Input(key="repo_opts.retention_strategy.weekly", size=(3, 1)), + sg.Text(_t("config_gui.weekly"), size=(20, 1)), + ] + ] + ), + sg.Column( + [ + [ + sg.Input(key="repo_opts.retention_strategy.monthly", size=(3, 1)), + sg.Text(_t("config_gui.monthly"), size=(20, 1)), + ], + [ + sg.Input(key="repo_opts.retention_strategy.yearly", size=(3, 1)), + sg.Text(_t("config_gui.yearly"), size=(20, 1)), + ] + ] + ) ], ] diff --git a/npbackup/translations/config_gui.en.yml b/npbackup/translations/config_gui.en.yml index 425749a..ab61aea 100644 --- a/npbackup/translations/config_gui.en.yml +++ b/npbackup/translations/config_gui.en.yml @@ -27,7 +27,7 @@ en: one_per_line: one per line backup_priority: Backup priority additional_parameters: Additional parameters - additional_backup_only_parameters: Additional backup only parmas + additional_backup_only_parameters: Additional backup only parmameters minimum_backup_age: Minimum delay between two backups backup_repo_uri: backup repo URI / path diff --git a/npbackup/translations/config_gui.fr.yml b/npbackup/translations/config_gui.fr.yml index 6f11b55..9c43580 100644 --- a/npbackup/translations/config_gui.fr.yml +++ b/npbackup/translations/config_gui.fr.yml @@ -28,7 +28,7 @@ fr: one_per_line: un par ligne backup_priority: Priorité de sauvegarde additional_parameters: Paramètres supplémentaires - additional_backup_only_parameters: Paramètres supp. sauvegarde + additional_backup_only_parameters: Paramètres supplémentaires de sauvegarde minimum_backup_age: Délai minimal entre deux sauvegardes backup_repo_uri: URL / chemin local dépot de sauvegarde From 5dd32a685aa688d4ea5dd1c7e4f2633d61b3ecc2 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Fri, 2 Feb 2024 12:45:23 +0100 Subject: [PATCH 215/328] Add bytes expansion for byte units --- npbackup/configuration.py | 34 +++++++++++++++++++++++++++------- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/npbackup/configuration.py b/npbackup/configuration.py index af203c5..b94c450 100644 --- a/npbackup/configuration.py +++ b/npbackup/configuration.py @@ -7,11 +7,11 @@ __author__ = "Orsiris de Jong" __copyright__ = "Copyright (C) 2022-2024 NetInvent" __license__ = "GPL-3.0-only" -__build__ = "2024010501" -__version__ = "2.0.0 for npbackup 2.3.0+" +__build__ = "2024020201" +__version__ = "2.0.0 for npbackup 3.0.0+" -MIN_CONF_VERSION = 2.3 -MAX_CONF_VERSION = 2.3 +MIN_CONF_VERSION = 3.0 +MAX_CONF_VERSION = 3.0 from typing import Tuple, Optional, List, Any, Union import sys @@ -26,7 +26,7 @@ import platform from cryptidy import symmetric_encryption as enc from ofunctions.random import random_string -from ofunctions.misc import replace_in_iterable +from ofunctions.misc import replace_in_iterable, BytesConverter from npbackup.customization import ID_STRING @@ -349,9 +349,10 @@ def _has_random_variables(value) -> Any: def evaluate_variables(repo_config: dict, full_config: dict) -> dict: """ Replace runtime variables with their corresponding value + Also replaces human bytes notation with ints """ - def _evaluate_variables(value): + def _evaluate_variables(key, value): if isinstance(value, str): if "${MACHINE_ID}" in value: machine_id = full_config.g("identity.machine_id") @@ -379,11 +380,28 @@ def _evaluate_variables(value): count = 0 maxcount = 4 * 2 * 2 while count < maxcount: - repo_config = replace_in_iterable(repo_config, _evaluate_variables) + repo_config = replace_in_iterable(repo_config, _evaluate_variables, callable_wants_key=True) count += 1 return repo_config +def expand_units(object_config: dict, unexpand: bool = False) -> dict: + """ + Evaluate human bytes notation + eg 50 KB to 500000 + and 500000 to 50 KB in unexpand mode + """ + def _expand_units(key, value): + if key in ("minimum_backup_size_error", "exclude_files_larger_than", "upload_speed", "download_speed"): + if unexpand: + return BytesConverter(value).human + return BytesConverter(value) + return value + + return replace_in_iterable(object_config, _expand_units, callable_wants_key=True) + + + def extract_permissions_from_full_config(full_config: dict) -> dict: """ Extract permissions and manager password from repo_uri tuple @@ -526,6 +544,7 @@ def _inherit_group_settings( if eval_variables: repo_config = evaluate_variables(repo_config, full_config) + repo_config = expand_units(repo_config, unexpand=True) return repo_config, config_inheritance @@ -540,6 +559,7 @@ def get_group_config( if eval_variables: group_config = evaluate_variables(group_config, full_config) + group_config = expand_units(group_config, unexpand=True) return group_config From 72c771a0da3d7c916cebb5bb6838fd4746b22cc1 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Fri, 2 Feb 2024 12:45:45 +0100 Subject: [PATCH 216/328] WIP Gui rewrite --- npbackup/gui/config.py | 119 ++++++++++++++++++++++++++++------------- 1 file changed, 82 insertions(+), 37 deletions(-) diff --git a/npbackup/gui/config.py b/npbackup/gui/config.py index f440fec..b8d43b2 100644 --- a/npbackup/gui/config.py +++ b/npbackup/gui/config.py @@ -10,8 +10,9 @@ __build__ = "2023121701" -from typing import List +from typing import List, Union import os +import pathlib from logging import getLogger import PySimpleGUI as sg import textwrap @@ -212,6 +213,31 @@ def update_gui_values(key, value, inherited, object_type, unencrypted): except (KeyError, TypeError): pass + # Update tree objects + if key in ("backup_opts.paths", "backup_opts.tags", "backup_opts.exclude_patterns", "backup_opts.exclude_files"): + print(value) + if not isinstance(value, list): + value = [value] + if key == "backup_opts.paths": + for val in value: + print(val, inherited) + if pathlib.Path(val).is_dir(): + icon = FOLDER_ICON + else: + icon = FILE_ICON + backup_paths_tree.insert('', val, val, val, icon=icon) + window['backup_opts.paths'].update(values=backup_paths_tree) + if key == "backup_opts.tags": + for val in value: + tags_tree.insert('', val, val, val, icon=icon) + window['backup_opts.tags'].Update(values=tags_tree) + return + + # Update units into separate value and unit combobox + if key in ["backup_opts.minimum_backup_size_error", "backup_opts.exclude_files_larger_than", "upload_speed", "download_speed"]: + value, unit = value.split(" ") + window[f"{key}_unit"].Update(unit) + if isinstance(value, list): value = "\n".join(value) if key in combo_boxes: @@ -457,6 +483,13 @@ def set_permissions(full_config: dict, object_name: str) -> dict: window.close() full_config.s(f"repos.{object_name}", repo_config) return full_config + + def is_inherited(key: str, values: Union[str, int, float, list]) -> bool: + """ + Checks if value(s) are inherited from group settings + """ + # TODO + return False def object_layout() -> List[list]: """ @@ -479,7 +512,7 @@ def object_layout() -> List[list]: ), ], [ - sg.Tree(sg.TreeData(), key="inherited.backup_opts.path", headings=[], + sg.Tree(sg.TreeData(), key="backup_opts.paths", headings=[], col0_heading=_t("generic.paths"), expand_x=True, expand_y=True) ], @@ -518,7 +551,7 @@ def object_layout() -> List[list]: ], pad=0, size=(40, 80)), sg.Column([ [ - sg.Tree(sg.TreeData(), key="inherited.backup_opts.tags", headings=[], + sg.Tree(sg.TreeData(), key="backup_opts.tags", headings=[], col0_heading="Tags", col0_width=30, num_rows=3, expand_x=True, expand_y=True) ] ], pad=0, size=(300, 80)) @@ -532,7 +565,7 @@ def object_layout() -> List[list]: ], [ sg.Input(key="backup_opts.minimum_backup_size_error", size=(8, 1)), - sg.Combo(byte_units, default_value="KB", key="bakcup_opts.minimum_backup_size_error_unit") + sg.Combo(byte_units, default_value=byte_units[3], key="backup_opts.minimum_backup_size_error_unit") ], [ sg.Checkbox(textwrap.fill(f'{_t("config_gui.use_fs_snapshot")}', width=34), key="backup_opts.use_fs_snapshot", size=(40, 1), pad=0), @@ -557,64 +590,72 @@ def object_layout() -> List[list]: exclusions_col = [ [ - sg.Checkbox("", key="backup_opts.ignore_cloud_files", size=(1, 1)), - sg.Text( - textwrap.fill(f'{_t("config_gui.ignore_cloud_files")} ({_t("config_gui.windows_only")})', width=34), - size=(34, 2), + sg.Column( + [ + [ + sg.Button("+", key="--ADD-EXCLUDE-PATTERN--", size=(3, 1)) + ], + [ + sg.Button("-", key="--REMOVE-EXCLUDE-PATTERN--", size=(3, 1)) + ] + ], pad=0, ), + sg.Column( + [ + [ + sg.Tree(sg.TreeData(), key="backup_opts.exclude_patterns", headings=[], + col0_heading=_t('config_gui.exclude_patterns'), + num_rows=4, expand_x=True, expand_y=True) + ] + ], pad=0, expand_x=True + ) + ], + [ + sg.HSeparator() ], [ sg.Column( [ [ - sg.Button("+", key="--ADD-TAG--", size=(3, 1)) + sg.Button("+", key="--ADD-EXCLUDE-FILES--", size=(3, 1)) ], [ - sg.Button("-", key="--REMOVE-TAG--", size=(3, 1)) + sg.Button("-", key="--REMOVE-EXCLUDE-FILES--", size=(3, 1)) ] ], pad=0, ), sg.Column( [ [ - sg.Tree(sg.TreeData(), key="backup_opts.exclude_patterns", headings=[], - col0_heading=_t('config_gui.exclude_patterns'), - num_rows=3, expand_x=True, expand_y=True) + sg.Tree(sg.TreeData(), key="backup_opts.exclude_files", headings=[], + col0_heading=_t('config_gui.exclude_files'), + num_rows=4, expand_x=True, expand_y=True) ] ], pad=0, expand_x=True ) ], + [ + sg.HSeparator() + ], [ sg.Text( _t("config_gui.exclude_files_larger_than"), - size=(40, 2), + size=(40, 1), ), - sg.Input(key="backup_opts.exclude_files_larger_than", size=(50, 1)), + sg.Input(key="backup_opts.exclude_files_larger_than", size=(8, 1)), + sg.Combo(byte_units, default_value=byte_units[3], key="backup_opts.exclude_files_larger_than_unit") ], [ - sg.Text( - f"{_t('config_gui.exclude_files')}\n({_t('config_gui.one_per_line')})", - size=(40, 2), - ), - sg.Multiline(key="backup_opts.exclude_files", size=(48, 4)), + sg.Checkbox(f'{_t("config_gui.ignore_cloud_files")} ({_t("config_gui.windows_only")})', key="backup_opts.ignore_cloud_files", size=(None, 1)), ], [ - sg.Text( - "{}\n({})".format( - _t("config_gui.excludes_case_ignore"), - _t("config_gui.windows_always"), - ), - size=(40, 2), - ), - sg.Checkbox("", key="backup_opts.excludes_case_ignore", size=(41, 1)), + sg.Checkbox(f'{_t("config_gui.excludes_case_ignore")} ({_t("config_gui.windows_always")})', key="backup_opts.excludes_case_ignore", size=(None, 1)), ], [ - sg.Text(_t("config_gui.exclude_cache_dirs"), size=(40, 1)), - sg.Checkbox("", key="backup_opts.exclude_caches", size=(41, 1)), + sg.Checkbox(_t("config_gui.exclude_cache_dirs"), key="backup_opts.exclude_caches", size=(None, 1)), ], [ - sg.Text(_t("config_gui.one_file_system"), size=(40, 1)), - sg.Checkbox("", key="backup_opts.one_file_system", size=(41, 1)), + sg.Checkbox(_t("config_gui.one_file_system"), key="backup_opts.one_file_system", size=(None, 1)), ], ] @@ -1067,6 +1108,8 @@ def config_layout() -> List[list]: backup_paths_tree = sg.TreeData() tags_tree = sg.TreeData() + exclude_patterns_tree = sg.TreeData() + exclude_files_tree = sg.TreeData() # Update gui with first default object (repo or group) update_object_gui(get_objects()[0], unencrypted=False) @@ -1107,19 +1150,21 @@ def config_layout() -> List[list]: node = values['--PATHS-ADD-FOLDER--'] icon = FOLDER_ICON backup_paths_tree.insert('', node, node, node, icon=icon) - window['inherited.backup_opts.path'].update(values=backup_paths_tree) + window['backup_opts.paths'].update(values=backup_paths_tree) if event == "--REMOVE-SELECTED-BACKUP-PATHS--": # TODO: prevent removing inherited values - for key in values['inherited.backup_opts.path']: + for key in values['backup_opts.paths']: backup_paths_tree.delete(key) - window['inherited.backup_opts.path'].update(values=backup_paths_tree) + window['backup_opts.paths'].update(values=backup_paths_tree) if event == "--ADD-TAG--": node = sg.PopupGetText(_t("config_gui.enter_tag")) if node: tags_tree.insert('', node, node, node) - window["inherited.backup_opts.tags"].Update(values=tags_tree) + window["backup_opts.tags"].Update(values=tags_tree) if event == "--REMOVE-TAG--": - for key in values["inherited.backup_opts.tags"]: + for key in values["backup_opts.tags"]: + if is_inherited("backup_opts.tags", values["backup_opts.tags"]) and object_type != "group": + sg.Popup("Cannot remove ") tags_tree.delete(key) window["inherited.backup_opts.tags"].Update(values=tags_tree) if event == "--ACCEPT--": From ce6798bfbb66eaa498839377f0b1744bfb583180 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Fri, 2 Feb 2024 12:45:54 +0100 Subject: [PATCH 217/328] Updated translations --- npbackup/translations/config_gui.en.yml | 3 ++- npbackup/translations/config_gui.fr.yml | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/npbackup/translations/config_gui.en.yml b/npbackup/translations/config_gui.en.yml index ab61aea..3a2a1d0 100644 --- a/npbackup/translations/config_gui.en.yml +++ b/npbackup/translations/config_gui.en.yml @@ -12,8 +12,9 @@ en: ignore_cloud_files: Ignore in-cloud files windows_only: Windows only exclude_patterns: Exclude patterns - exclude_files: Files containing exclusions + exclude_files: Files containing exclude patterns excludes_case_ignore: Ignore case for excludes patterns/files + exclude_files_larger_than: Exclude files larger than windows_always: always enabled for Windows exclude_cache_dirs: Exclude cache dirs one_file_system: Do not follow mountpoints diff --git a/npbackup/translations/config_gui.fr.yml b/npbackup/translations/config_gui.fr.yml index 9c43580..bef5056 100644 --- a/npbackup/translations/config_gui.fr.yml +++ b/npbackup/translations/config_gui.fr.yml @@ -13,7 +13,8 @@ fr: ignore_cloud_files: Exclure le fichiers dans le cloud windows_only: Windows seulement exclude_patterns: Patterns d'exclusion - exclude_files: Fichiers contenant des exclusions + exclude_files: Fichiers contenant des patterns d'exclusions + exclude_files_larger_than: Exclude les fichiers plus grands que excludes_case_ignore: Ignorer la casse des exclusions patterns/fichiers windows_always: toujours actif pour Windows exclude_cache_dirs: Exclure dossiers cache From e1f06464b849aa52c2ac766daf6be88d22a8013a Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Fri, 2 Feb 2024 19:33:54 +0100 Subject: [PATCH 218/328] Update file/folder/tree/inheritance icons --- npbackup/customization.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/npbackup/customization.py b/npbackup/customization.py index 27cc979..2132608 100644 --- a/npbackup/customization.py +++ b/npbackup/customization.py @@ -44,10 +44,16 @@ # OEM LOGO (base64 encoded png file) OEM_LOGO = b"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" OEM_ICON = b"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" + +# sg.Tree icons FOLDER_ICON = b"iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAAsSAAALEgHS3X78AAABnUlEQVQ4y8WSv2rUQRSFv7vZgJFFsQg2EkWb4AvEJ8hqKVilSmFn3iNvIAp21oIW9haihBRKiqwElMVsIJjNrprsOr/5dyzml3UhEQIWHhjmcpn7zblw4B9lJ8Xag9mlmQb3AJzX3tOX8Tngzg349q7t5xcfzpKGhOFHnjx+9qLTzW8wsmFTL2Gzk7Y2O/k9kCbtwUZbV+Zvo8Md3PALrjoiqsKSR9ljpAJpwOsNtlfXfRvoNU8Arr/NsVo0ry5z4dZN5hoGqEzYDChBOoKwS/vSq0XW3y5NAI/uN1cvLqzQur4MCpBGEEd1PQDfQ74HYR+LfeQOAOYAmgAmbly+dgfid5CHPIKqC74L8RDyGPIYy7+QQjFWa7ICsQ8SpB/IfcJSDVMAJUwJkYDMNOEPIBxA/gnuMyYPijXAI3lMse7FGnIKsIuqrxgRSeXOoYZUCI8pIKW/OHA7kD2YYcpAKgM5ABXk4qSsdJaDOMCsgTIYAlL5TQFTyUIZDmev0N/bnwqnylEBQS45UKnHx/lUlFvA3fo+jwR8ALb47/oNma38cuqiJ9AAAAAASUVORK5CYII=" +INHERITED_FOLDER_ICON = b"iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAACxEAAAsRAX9kX5EAAAJCSURBVDhPxVJNaBNhEH3bJPSHtCWtpRZbU4shJQoigi2IOfTQ2F5EUUEP2oNa0N5E8eBJUEHUg0IxGPAg6EGKeigo9CBSbEuVLNFkIWot1KYxzV/TNt1sdvdzvk3MehARPPhgdub7dt7bN8PiXyGUM0aO2HosVRjgtaywRf9z9REv+flPqAgk3vqUlu7TNmgZFDMi7o8GxqR5/TV16AJvq3QCs5IWmpX0KSq1ynV6xsccLR6wVBRyZg5yYRUqK0DQFDBdIb5WEqkCxmcQGbqh+Oj0rSIQG+9g9c17YW31orqxC5PTYbyceI9Pc0twuzpw6ngftjvrgeIC4pEXelvf5DGijZEecP6Qdahu6wnYPedQ0+TG9duP4R28DHdXAy6c2Y12x3fs6B0GS9wDlgNgcoTTavnDEBAYOhvbewE1CzEYxJVrTzA/PYKTgwX0uD5j+LAGWdxD46yAsSLAOKsEQ8CAmqRIIfwxhH7vFjg3LdGKVhCOZihyECOrxKM9QKcwFazlTLMt07scErEI2ppJV5fx6k0MNx98RTqnwLnZimd3W4mrUjMXKcF0QMth+Q/o3maBKGWpcQP9++yYeOjG2aMO6EynUbl97sJ0YArIUbKcw8B+O6ptDLcCX4iwYThZW1eoQaf5KRsiv3OgpomQpw+s4+mdTkwFsxA877DzoISro0nscllIg5MpfhmhsoPkYrxcCagji/5LtfBfrKH5i2hqoGuynoqTIyLn10yBnz+SneJAOf8N+BKCFCHj9B8B/ACq5vDyyzK0kwAAAABJRU5ErkJggg==" FILE_ICON = b"iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAAsSAAALEgHS3X78AAABU0lEQVQ4y52TzStEURiHn/ecc6XG54JSdlMkNhYWsiILS0lsJaUsLW2Mv8CfIDtr2VtbY4GUEvmIZnKbZsY977Uwt2HcyW1+dTZvt6fn9557BGB+aaNQKBR2ifkbgWR+cX13ubO1svz++niVTA1ArDHDg91UahHFsMxbKWycYsjze4muTsP64vT43v7hSf/A0FgdjQPQWAmco68nB+T+SFSqNUQgcIbN1bn8Z3RwvL22MAvcu8TACFgrpMVZ4aUYcn77BMDkxGgemAGOHIBXxRjBWZMKoCPA2h6qEUSRR2MF6GxUUMUaIUgBCNTnAcm3H2G5YQfgvccYIXAtDH7FoKq/AaqKlbrBj2trFVXfBPAea4SOIIsBeN9kkCwxsNkAqRWy7+B7Z00G3xVc2wZeMSI4S7sVYkSk5Z/4PyBWROqvox3A28PN2cjUwinQC9QyckKALxj4kv2auK0xAAAAAElFTkSuQmCC" +INHERITED_FILE_ICON = b"iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAACxEAAAsRAX9kX5EAAAJFSURBVDhPjVNNaBNREP6y2cRo29QfGmJNayBBWxWr+I/gP9VYxFLUnITVFgqCF8GLFyuKeND6g/UkSA8qBC9KDxYVUQQx1otNQ2q1VAWtUcMWQlvT7K4zb7NpdAv6wbe8mfdm5pt5bx0g7Gpp6yCchsHWX+ATBX/i3cjAqWOHDmS+fxm0vBJ/DN1AwFeByVweanYcP8ey01Sz+JYZQ7lHwtHmTcvPXO7umVe1sJ7CRHGZP7qhwyXLmOstI4v5JyZ/5eCg4y5ZQnt0R2gqf+vBCWXPdtr6XFQg0QGn0zEjZWKalPR/GEXqYxqrVywNUdhmjhUKNF2HRBlkp8gn8Cb+Ei+ePcankWHUBkPYu/8gKfQin9eEYoKHPyJCpwROSuCiBMybXZ1Qok0Ih5egrf04AoEaRPdtRcUcDyrLZ0PTRAIBU4GmCQXcYzLxFlcvnsPzeAKLArXi0Jp163FYaRVrrskFLUwroClx9eH3KWzZthPBxUHTHkoJDib7iwp1XRPBDPMWSAG34HZJUDM/4Pf7xfrpk0foutYJVVVJTQ26b8dEUGkLQoE1RM5eV1eP5IBZrbFxN+739EI50kqXbpQomKEFawaRSASz3G7cuH5F2MyJiXG6ZnOfyTOzUFBgtsDXyLxzN4a++Ct4y9zYsHYVLpw/i5UNDcX9UgUCyzY2dVBfRm5Ks/HraNrmiz18zf+BwrGFFgx6qvzqJBt9viqbzz4Deln81qmL/2JpAjKBBdWhyPzq8ElaVhJz7PsXhvp6LwG49xvFxfylO2UHaAAAAABJRU5ErkJggg==" +TREE_ICON = b"iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAACxEAAAsRAX9kX5EAAAEtSURBVDhPjZNdTsJAFIUPbalQUSEQNMQ16AJcga++uBJe9AE2wE5civvwzUDQhIClnjMz5Wc6gd7km85M7px7ettpgPH4Ukxfp5ig0MoLZZT7JtvG20PDrCINxRboD4HNGvj9AZaLA+bA/Hs/L2HsBbaskCTA5TXQG1Ds9phuHxjeAVddi4t7DUYAdCBDURwmJgtWzTKLiycN1oEEOAsdFs3Uutv8AasV0GrrFFoadgIRZwmTQ6QXQJuVRdZhfq5TNqwANyQQsw/nkBs1vQwjoI2IPTAVmeQ78KkKOAdJk0hAzxPk/ivkronqdh3CryAHql6DahOdQOgThgj2QD9SyG4IFSxj5+DUn+hTdVDwMlBAInU4FOCSF2T0/twZTcac3hDeyfPx9ZnOAHz8A2r8W8iBwGS0AAAAAElFTkSuQmCC" +INHERITED_TREE_ICON = b"iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAACxEAAAsRAX9kX5EAAAIKSURBVDhPjVPPS1RRFP7mzTj425GXM5MNjpHgQNKEP3AhWEE0zCKCNoKbapO7IETBH6BBtYiZTeqfIC5UcCGooItoEQxBRU0tNBgYUBLHRnQQtDfPc857b3w1E/XBdznnvnO/+91z33WAEL6vT/ZNYgI6Z3+AK6x5qTYwes0hmcKDngdUL3B6AuSOgMMDG7NAdv88tkg4F8jTDi4XUFUL1F8gMd/v9KiA1w/UeAyaCPAgAiAHbEhxlqaTeEC7VlYaNNHDA+1L61mApLjYwvfEW3x7s4q91CYamlvRfu8BOWyB9gsor5CSch6MI5CAQpGLBJjrMy8w1deLiy2tuD0wCDUQQPxuGyrYQTXVa7zKgOGAJljASVn6y0csx8bxPJGihUEputLVjRsPByRmk9x0C+KAJxTqAe/+YyuJqzcj8AWDRr6ZFG5//VBwaBcQB7rpwFVG17i/C4/fL/HnjTWsvH6FXPYnuWnCk9klWaTZjiAONLOJ3O1LoRDSyU8SX78TwcjyBm49ekxVuswxSx+BHZCfjmgUZW43VqdjkjNPj4/kmq3c3kQRsG7Buvenc/PYSrxDf5UDw51tWHz5DM3hcOF7yR7wDmyP4bvchKGFRYkPMxnUqPQr2sAbWig4+NufWOdVi+aKe6DTYyABFvkf2gUopQfSOBatbpwYorCOSG/y39h5744DWDgDprSgrrE6IGUAAAAASUVORK5CYII=" LOADER_ANIMATION = b"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" -INHERITANCE_ICON = b"iVBORw0KGgoAAAANSUhEUgAAABIAAAASCAYAAABWzo5XAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAACxMAAAsTAQCanBgAAAFPSURBVDhPrZS9LkRBFMevjxWVDa0obOGrl2g8gkdQoPIC3kKpofIEhFoUGq2CRMIWFDqyBfGR4Pe7e+/m7twZFP7Jb3fmnDPnzp055w5kaTWgBc18lmUdaMNHPvuDRmADjuEWngoca9NnzI+ahVP4+gVjjI1KxxXEFj7Ce2Azdgb65FZTOzmCSViF18JWcgKeZU++dzWgyi6oRXiG0L8OuczoIYYBJS9wDt5YzO/axiA/XvEChLqHbdiCO5iGmOahZSLrZFxLIH0+cQ824QJimoCmwSl5wCtg4CgMQVImsmItuJjcxQN4zXMaIrI0OibyEK2JmM6K/2UY7g5rcm3bRPbOoZZAb3DdHWZj4NV/5rN+HUCv/1IFuQNTsAT7YKKqv1aQKtUinmGsEC+h1iKldPiUcFGIMckkpdyqZW/F3kD5GXFs361B7XX+6cOWZd8L0Yd3yKkunwAAAABJRU5ErkJggg==" +INHERITANCE_ICON = b"iVBORw0KGgoAAAANSUhEUgAAAAwAAAAMCAYAAABWdVznAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAACxEAAAsRAX9kX5EAAAGrSURBVChTVZHPaxNRFIW/mbSZRNt0+iOpiiIRQ1dW1EShC0V05UK7EOxCXYgbIaDYleBCd2L/D6HUaheCLkXc1KBU3BYROhYF66Ilk9hm5nneG0S9cO97cL9z7zszHrT2vHnJ4oH9THW3wRiyUAfdPXsqfc97MHGs/tC3vcow7C5kDQvYTAVbcZJC5xd829gOLOsERkBRgtESjA0pQyjrLAoZHdS95PHj58YhoQUnsJNzOYi+Rly/NUvl8EnGaw1uNm8Tx20N9Mj7OwMig2yDan+fYebGHa5dvkC89o5O1OLR/SbDpRwD2p4k1pFDsyfNP3/F8ckJZqbPEfRrlLJ+tCZBgby2S+Aie5Lq6pc14k4XT2Dt1DT+eIMjZ67weT2CPtjpOfKv6YPVvYxY1xKsflrCbLWI1r872GhD71+BrRcvnWbpxWveLq84yGYQ5PU1MkHyn0AbRipDPHs6R/PuY7xigxNTV6lW9xGODTqnfzxojo+fS53w7Pk6Kx+fkKYpm5ttwlA/QdHeUjHZcAlm44XFex/K5bBijOm6lotdygRPWCc2wfvlntzDb05pgebgjy0sAAAAAElFTkSuQmCC" LICENSE_TEXT = """ GNU GENERAL PUBLIC LICENSE From 8c00ef919d441b4aa29ba84fe11397c3d9d3d3f5 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Fri, 2 Feb 2024 19:34:52 +0100 Subject: [PATCH 219/328] WIP: Make tree object inheritance visible --- npbackup/configuration.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/npbackup/configuration.py b/npbackup/configuration.py index b94c450..6a5d52d 100644 --- a/npbackup/configuration.py +++ b/npbackup/configuration.py @@ -26,7 +26,7 @@ import platform from cryptidy import symmetric_encryption as enc from ofunctions.random import random_string -from ofunctions.misc import replace_in_iterable, BytesConverter +from ofunctions.misc import replace_in_iterable, BytesConverter, iter_over_keys from npbackup.customization import ID_STRING @@ -501,7 +501,12 @@ def _inherit_group_settings( else: merged_lists = _group_config.g(key) _repo_config.s(key, merged_lists) - _config_inheritance.s(key, True) + _config_inheritance.s(key, {}) + for v in merged_lists: + if v in _group_config.g(key): + _config_inheritance.s(f"{key}.{v}", True) + else: + _config_inheritance.s(f"{key}.{v}", False) else: # repo_config may or may not already contain data if not _repo_config: @@ -600,6 +605,16 @@ def load_config(config_file: Path) -> Optional[dict]: return None config_file_is_updated = False + # Make sure we expand every key that should be a list into a list + # We'll use iter_over_keys instead of replace_in_iterable to avoid chaning list contents by lists + def _make_list(key: str, value: Union[str, int, float, dict, list]) -> Any: + if key in ("paths", "tags", "env_variables", "encrypted_env_variables"): + if not isinstance(value, list): + value = [value] + pass + return value + iter_over_keys(full_config, _make_list) + # Check if we need to encrypt some variables if not is_encrypted(full_config): logger.info("Encrypting non encrypted data in configuration file") From 7099851e7a8c0fac6c2b173ccb9cdbc1dd4bce0e Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Fri, 2 Feb 2024 19:35:52 +0100 Subject: [PATCH 220/328] GUI rewrite: Show tree inheritance --- npbackup/gui/config.py | 46 ++++++++++++++++++++++++++++++------------ 1 file changed, 33 insertions(+), 13 deletions(-) diff --git a/npbackup/gui/config.py b/npbackup/gui/config.py index b8d43b2..ca2b6d0 100644 --- a/npbackup/gui/config.py +++ b/npbackup/gui/config.py @@ -21,7 +21,7 @@ from ofunctions.misc import get_key_from_value from npbackup.core.i18n_helper import _t from npbackup.path_helper import CURRENT_EXECUTABLE -from npbackup.customization import INHERITANCE_ICON, FILE_ICON, FOLDER_ICON +from npbackup.customization import INHERITANCE_ICON, FILE_ICON, FOLDER_ICON, INHERITED_FILE_ICON, INHERITED_FOLDER_ICON, TREE_ICON, INHERITED_TREE_ICON if os.name == "nt": from npbackup.windows.task import create_scheduled_task @@ -215,20 +215,28 @@ def update_gui_values(key, value, inherited, object_type, unencrypted): # Update tree objects if key in ("backup_opts.paths", "backup_opts.tags", "backup_opts.exclude_patterns", "backup_opts.exclude_files"): - print(value) if not isinstance(value, list): value = [value] if key == "backup_opts.paths": for val in value: - print(val, inherited) if pathlib.Path(val).is_dir(): - icon = FOLDER_ICON + if inherited[val]: + icon = INHERITED_FOLDER_ICON + else: + icon = FOLDER_ICON else: - icon = FILE_ICON + if inherited[val]: + icon = INHERITED_FILE_ICON + else: + icon = FILE_ICON backup_paths_tree.insert('', val, val, val, icon=icon) window['backup_opts.paths'].update(values=backup_paths_tree) if key == "backup_opts.tags": for val in value: + if inherited[val]: + icon = INHERITED_TREE_ICON + else: + icon = TREE_ICON tags_tree.insert('', val, val, val, icon=icon) window['backup_opts.tags'].Update(values=tags_tree) return @@ -529,16 +537,17 @@ def object_layout() -> List[list]: [ sg.Text(_t("config_gui.compression"), size=(20, None)), sg.Combo(list(combo_boxes["compression"].values()), key="backup_opts.compression", size=(20, 1)), - sg.pin(sg.Image(INHERITANCE_ICON, key="inherited.backup_opts.compression", tooltip=_t("config_gui.group_inherited"))), + sg.pin(sg.Image(INHERITANCE_ICON, key="inherited.backup_opts.compression", tooltip=_t("config_gui.group_inherited"), pad=0)), ], [ sg.Text(_t("config_gui.backup_priority"), size=(20, 1)), + sg.pin(sg.Image(INHERITANCE_ICON, key="inherited.backup_opts.backup_priority", tooltip=_t("config_gui.group_inherited") ,pad=0)), sg.Combo( list(combo_boxes["priority"].values()), key="backup_opts.priority", - size=(20, 1), + size=(20, 1), pad=0 ), - sg.pin(sg.Image(INHERITANCE_ICON, key="inherited.backup_opts.backup_priority", tooltip=_t("config_gui.group_inherited"))), + ], [ sg.Column([ @@ -1145,10 +1154,16 @@ def config_layout() -> List[list]: if event in ("--PATHS-ADD-FILE--", '--PATHS-ADD-FOLDER--'): if event == "--PATHS-ADD-FILE--": node = values["--PATHS-ADD-FILE--"] - icon = FILE_ICON + if object_type == "group": + icon = INHERITED_FILE_ICON + else: + icon = FILE_ICON elif event == '--PATHS-ADD-FOLDER--': node = values['--PATHS-ADD-FOLDER--'] - icon = FOLDER_ICON + if object_type == "group": + icon = INHERITED_FOLDER_ICON + else: + icon = FOLDER_ICON backup_paths_tree.insert('', node, node, node, icon=icon) window['backup_opts.paths'].update(values=backup_paths_tree) if event == "--REMOVE-SELECTED-BACKUP-PATHS--": @@ -1159,14 +1174,19 @@ def config_layout() -> List[list]: if event == "--ADD-TAG--": node = sg.PopupGetText(_t("config_gui.enter_tag")) if node: - tags_tree.insert('', node, node, node) + if object_type == "group": + icon = INHERITED_TREE_ICON + else: + icon = TREE_ICON + tags_tree.insert('', node, node, node, icon=icon) window["backup_opts.tags"].Update(values=tags_tree) if event == "--REMOVE-TAG--": for key in values["backup_opts.tags"]: if is_inherited("backup_opts.tags", values["backup_opts.tags"]) and object_type != "group": - sg.Popup("Cannot remove ") + sg.Popup(_t("config_gui.cannot_remove_group_inherited_settings")) + continue tags_tree.delete(key) - window["inherited.backup_opts.tags"].Update(values=tags_tree) + window["backup_opts.tags"].Update(values=tags_tree) if event == "--ACCEPT--": if ( not values["repo_opts.repo_password"] From aacbabfb34eb56999761676931ed05bc20792b62 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Fri, 2 Feb 2024 19:36:10 +0100 Subject: [PATCH 221/328] Add new translations --- npbackup/translations/config_gui.en.yml | 1 + npbackup/translations/config_gui.fr.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/npbackup/translations/config_gui.en.yml b/npbackup/translations/config_gui.en.yml index 3a2a1d0..d75b451 100644 --- a/npbackup/translations/config_gui.en.yml +++ b/npbackup/translations/config_gui.en.yml @@ -128,6 +128,7 @@ en: are_you_sure_to_delete: Are you sure you want to delete repo_already_exists: Repo already exists group_already_exists: Group already exists + cannot_remove_group_inherited_settings: Cannot remove group inherited settings. Please remove directly in group configuration # permissions set_permissions: Set permissions diff --git a/npbackup/translations/config_gui.fr.yml b/npbackup/translations/config_gui.fr.yml index bef5056..840b36f 100644 --- a/npbackup/translations/config_gui.fr.yml +++ b/npbackup/translations/config_gui.fr.yml @@ -130,6 +130,7 @@ fr: are_you_sure_to_delete: Êtes-vous sûr de vouloir supprimer le repo_already_exists: Dépot déjà existant group_already_exists: Groupe déjà existant + cannot_remove_group_inherited_settings: Impossible de supprimer une option héritée de groupe. Veuillez supprimer l'option directement dans la configuration de groupe # permissions set_permissions: Régler les permissions From 9c7e3d5dc0bbc935b68d6e2338bce6cbd70e8987 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Fri, 2 Feb 2024 19:36:51 +0100 Subject: [PATCH 222/328] Update icon base files --- resources/file_icon.png | Bin 0 -> 417 bytes resources/folder_icon.png | Bin 0 -> 491 bytes resources/inheritance_icon.png | Bin 442 -> 534 bytes resources/inherited_file_icon.png | Bin 0 -> 688 bytes resources/inherited_folder_icon.png | Bin 0 -> 685 bytes resources/inherited_neutral_icon.png | Bin 0 -> 408 bytes resources/inherited_tree_icon.png | Bin 0 -> 629 bytes resources/tree_icon.png | Bin 0 -> 408 bytes 8 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 resources/file_icon.png create mode 100644 resources/folder_icon.png create mode 100644 resources/inherited_file_icon.png create mode 100644 resources/inherited_folder_icon.png create mode 100644 resources/inherited_neutral_icon.png create mode 100644 resources/inherited_tree_icon.png create mode 100644 resources/tree_icon.png diff --git a/resources/file_icon.png b/resources/file_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..6363eff857591fa0c529353376e83fecb0eed97d GIT binary patch literal 417 zcmV;S0bc%zP)lg%qcQ5c7x_ndR3#^-`kc2gua78bH13rk6CC8aDa zZH&LbpCCJH*;`v8ObtM+F~fu1RB92$u*_R2Y@A}q^LvS|PQ&`*^!VQX;Yt6% z&{!Rf1JGCroTINN2ju=pRH`*ZAaI7w)w%pdG^a(?>+ zz~sbe9+&}+9DrBF7{OVS3ZNsv+8(MAM3F~h1?X&4P{kS{NC61c=K;yLA7Qy+2jIQO z7$I;i41UF+s=ooMij}&-k85icRo?{g9&3b-Ad3Nb-(-+1F|gSHsTH#C;Cp9H2HzE2 z+Xh}SA~;LC6=FoB<)8RJAXY@`ucIBn+r!P-$kf6q&JNR5;7UlD}%yK@^3*yV-z|MX?Ar5=EQf3&bbLYAINyN?~W- zBX1xIcGiLx_7WAeharn(R8i`FAYlmvtc#0u~+^=5m?)&AD*kA7v-S z+Jo7pnFjX&oY&sdm+?8^&JFnXuI=X+AI_47gyBb?Jbw0~)6H)%vSCv%VY8FAHaqz} zkS6WHMq5|rZ==H<-0%yoIwGpVl1JHNqylNc>kYP7*L@r4HBSJpzs<2)G_Njm;nq#& z8V0CL*bFE^I)boE`_ijL*558o0FUlBS1&HC(7L`13Xnz+M|C{_-$T9!VIPZq2#+ZsrN20EJCHq?Vah-% z133T{~Pbe4lSz^s8X7y?rHO#xHMLKzNcuh8Gyp9-hSQ2{|NIZ&y`$N5y0Tfp7= hK92-^0Jiw=`VE<_{Br7|C(r-@002ovPDHLkV1hAT)LH-l literal 0 HcmV?d00001 diff --git a/resources/inheritance_icon.png b/resources/inheritance_icon.png index 176cbabc7896b5620bcf319d6bcf740e569ad5f6..56220be7ac6ce107753a11952444bf36ea9c6ce9 100644 GIT binary patch delta 506 zcmVL_t(2Q&o}AYZFlvg}<4!nMB)k`Xi}|A`wGZR@6kH3q^G0Lc0*` zLS2X(A)wfm;6is|{|}+mY8QeoMcmY&RNNL3Iu-@%q9v2qX6AX`8$`Wu?|X2+bMHGd z9<=tndCB5|{Y-VY!5Feo2i-k;e=2>?doXdV{?NC*$}sFMWDN`0Oa)tUl0xSfeB0O# zEb9bDKv9Cx5{wNgLn!Mk3K<>Lz2x!Z=f)wS1t+kRb2*5X$X*P8UyL?bI~>c!MKnuJCz1fBVc_F0VMqW)S4{H8Mf+pFkT%DnmSbI!E*F zW4vO66H`~IR`(H(4LYZPP&{akkDp7O@a*|ID<7YbrYW17ZNgALLDAk)G1+hlX5)p$ wdmqZ>uwsnqx|WMw$^=hXCpN+R<(}NbZ%%1}=HQPlEC2ui07*qoM6N<$g41N@-~a#s delta 413 zcmV;O0b>4^1iAwuiBL{Q4GJ0x0000DNk~Le0000I0000I2nGNE09MY9SCJtoe-i)z z3ljkVnw%H_00B=)L_t(IPpy=_E<`~T$FGkSl?|;ZY~ibyXgq>PP@wVx-a@IN@&trh z6dJ99M8XynIK~cd++A%+!+-9$!~Y&oHOUnojK9Bb2O%#WIdQ~3G?e`Ab|O2-w%7CypzU^8|G`s7wqJ2`?$ScTWZR&oNK zWlz3$HK58as6}{&(7z73%;+J80VOYR4&GSI@7l&7KVI=3(<#n$f+)$00000NkvXX Hu0mjfb`iA( diff --git a/resources/inherited_file_icon.png b/resources/inherited_file_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..10ae0fbced32c7c4d34f6c686ae83cb95c79de8f GIT binary patch literal 688 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 P00000NkvXXu0mjfn^q6s literal 0 HcmV?d00001 diff --git a/resources/tree_icon.png b/resources/tree_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..f31c3f7e570d6027d85910b8cdd1d6a760a25b21 GIT binary patch literal 408 zcmV;J0cZY+P)Px#1ZP1_K>z@;j|==^1poj532;bRa{vGf5dZ)S5dnW>Uy%R+0WC>HK~y+TjgwtY z!ax*-4{fPXQ6UgC#5L#wT!F8?xDsCU0XD!+T#Ebf%|OtEfTf-@Gv#MGf!^es&P?u{ zdwSbxfbsZJOkby%pwP+-Wt96S+s4~N!>l3=#TMuffz2BD{Q;JX1LnZ|dp|E>>;<;0 zkR-t6HP9PS?Dm*$9~j;NS6z#XdksbabRa`f9400UR?RYtizf}%fdn`U9Tv^hy4(B# zs};~{>l9iVb^;M+36n#l7eJepMb=?{u2VLw05>EMvk&Kx8#TQQBcP3lJunrSO2=+ifQpj!y6)a=t;MAY!Q*}`A zGEhJwbvP8@k{4w3{@fW&#+=>|-pTXv^_k89eEb7y{9DL@z+|)l0000 Date: Sat, 3 Feb 2024 11:27:23 +0100 Subject: [PATCH 223/328] Allow upload/download speed to have different units --- npbackup/configuration.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/npbackup/configuration.py b/npbackup/configuration.py index 6a5d52d..1db62af 100644 --- a/npbackup/configuration.py +++ b/npbackup/configuration.py @@ -180,7 +180,7 @@ def d(self, path, sep="."): # Minimum time between two backups, in minutes # Set to zero in order to disable time checks "minimum_backup_age": 1440, - "upload_speed": 1000000, # in KiB, use 0 for unlimited upload speed + "upload_speed": "100Mb", # Mb(its) or MB(ytes), use 0 for unlimited upload speed "download_speed": 0, # in KiB, use 0 for unlimited download speed "backend_connections": 0, # Fine tune simultaneous connections to backend, use 0 for standard configuration "retention_strategy": { From d97291ad800cdd9883b4c330ec488a8fb06c90cf Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Sat, 3 Feb 2024 11:27:40 +0100 Subject: [PATCH 224/328] Update icons --- npbackup/customization.py | 3 ++- .../{inheritance_icon.png => inherited_icon.png} | Bin resources/non_inherited_icon.png | Bin 0 -> 123 bytes 3 files changed, 2 insertions(+), 1 deletion(-) rename resources/{inheritance_icon.png => inherited_icon.png} (100%) create mode 100644 resources/non_inherited_icon.png diff --git a/npbackup/customization.py b/npbackup/customization.py index 2132608..07b1ee7 100644 --- a/npbackup/customization.py +++ b/npbackup/customization.py @@ -53,7 +53,8 @@ TREE_ICON = b"iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAACxEAAAsRAX9kX5EAAAEtSURBVDhPjZNdTsJAFIUPbalQUSEQNMQ16AJcga++uBJe9AE2wE5civvwzUDQhIClnjMz5Wc6gd7km85M7px7ettpgPH4Ukxfp5ig0MoLZZT7JtvG20PDrCINxRboD4HNGvj9AZaLA+bA/Hs/L2HsBbaskCTA5TXQG1Ds9phuHxjeAVddi4t7DUYAdCBDURwmJgtWzTKLiycN1oEEOAsdFs3Uutv8AasV0GrrFFoadgIRZwmTQ6QXQJuVRdZhfq5TNqwANyQQsw/nkBs1vQwjoI2IPTAVmeQ78KkKOAdJk0hAzxPk/ivkronqdh3CryAHql6DahOdQOgThgj2QD9SyG4IFSxj5+DUn+hTdVDwMlBAInU4FOCSF2T0/twZTcac3hDeyfPx9ZnOAHz8A2r8W8iBwGS0AAAAAElFTkSuQmCC" INHERITED_TREE_ICON = b"iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAACxEAAAsRAX9kX5EAAAIKSURBVDhPjVPPS1RRFP7mzTj425GXM5MNjpHgQNKEP3AhWEE0zCKCNoKbapO7IETBH6BBtYiZTeqfIC5UcCGooItoEQxBRU0tNBgYUBLHRnQQtDfPc857b3w1E/XBdznnvnO/+91z33WAEL6vT/ZNYgI6Z3+AK6x5qTYwes0hmcKDngdUL3B6AuSOgMMDG7NAdv88tkg4F8jTDi4XUFUL1F8gMd/v9KiA1w/UeAyaCPAgAiAHbEhxlqaTeEC7VlYaNNHDA+1L61mApLjYwvfEW3x7s4q91CYamlvRfu8BOWyB9gsor5CSch6MI5CAQpGLBJjrMy8w1deLiy2tuD0wCDUQQPxuGyrYQTXVa7zKgOGAJljASVn6y0csx8bxPJGihUEputLVjRsPByRmk9x0C+KAJxTqAe/+YyuJqzcj8AWDRr6ZFG5//VBwaBcQB7rpwFVG17i/C4/fL/HnjTWsvH6FXPYnuWnCk9klWaTZjiAONLOJ3O1LoRDSyU8SX78TwcjyBm49ekxVuswxSx+BHZCfjmgUZW43VqdjkjNPj4/kmq3c3kQRsG7Buvenc/PYSrxDf5UDw51tWHz5DM3hcOF7yR7wDmyP4bvchKGFRYkPMxnUqPQr2sAbWig4+NufWOdVi+aKe6DTYyABFvkf2gUopQfSOBatbpwYorCOSG/y39h5744DWDgDprSgrrE6IGUAAAAASUVORK5CYII=" LOADER_ANIMATION = b"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" -INHERITANCE_ICON = b"iVBORw0KGgoAAAANSUhEUgAAAAwAAAAMCAYAAABWdVznAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAACxEAAAsRAX9kX5EAAAGrSURBVChTVZHPaxNRFIW/mbSZRNt0+iOpiiIRQ1dW1EShC0V05UK7EOxCXYgbIaDYleBCd2L/D6HUaheCLkXc1KBU3BYROhYF66Ilk9hm5nneG0S9cO97cL9z7zszHrT2vHnJ4oH9THW3wRiyUAfdPXsqfc97MHGs/tC3vcow7C5kDQvYTAVbcZJC5xd829gOLOsERkBRgtESjA0pQyjrLAoZHdS95PHj58YhoQUnsJNzOYi+Rly/NUvl8EnGaw1uNm8Tx20N9Mj7OwMig2yDan+fYebGHa5dvkC89o5O1OLR/SbDpRwD2p4k1pFDsyfNP3/F8ckJZqbPEfRrlLJ+tCZBgby2S+Aie5Lq6pc14k4XT2Dt1DT+eIMjZ67weT2CPtjpOfKv6YPVvYxY1xKsflrCbLWI1r872GhD71+BrRcvnWbpxWveLq84yGYQ5PU1MkHyn0AbRipDPHs6R/PuY7xigxNTV6lW9xGODTqnfzxojo+fS53w7Pk6Kx+fkKYpm5ttwlA/QdHeUjHZcAlm44XFex/K5bBijOm6lotdygRPWCc2wfvlntzDb05pgebgjy0sAAAAAElFTkSuQmCC" +INHERITED_ICON = b"iVBORw0KGgoAAAANSUhEUgAAAAwAAAAMCAYAAABWdVznAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAACxEAAAsRAX9kX5EAAAGrSURBVChTVZHPaxNRFIW/mbSZRNt0+iOpiiIRQ1dW1EShC0V05UK7EOxCXYgbIaDYleBCd2L/D6HUaheCLkXc1KBU3BYROhYF66Ilk9hm5nneG0S9cO97cL9z7zszHrT2vHnJ4oH9THW3wRiyUAfdPXsqfc97MHGs/tC3vcow7C5kDQvYTAVbcZJC5xd829gOLOsERkBRgtESjA0pQyjrLAoZHdS95PHj58YhoQUnsJNzOYi+Rly/NUvl8EnGaw1uNm8Tx20N9Mj7OwMig2yDan+fYebGHa5dvkC89o5O1OLR/SbDpRwD2p4k1pFDsyfNP3/F8ckJZqbPEfRrlLJ+tCZBgby2S+Aie5Lq6pc14k4XT2Dt1DT+eIMjZ67weT2CPtjpOfKv6YPVvYxY1xKsflrCbLWI1r872GhD71+BrRcvnWbpxWveLq84yGYQ5PU1MkHyn0AbRipDPHs6R/PuY7xigxNTV6lW9xGODTqnfzxojo+fS53w7Pk6Kx+fkKYpm5ttwlA/QdHeUjHZcAlm44XFex/K5bBijOm6lotdygRPWCc2wfvlntzDb05pgebgjy0sAAAAAElFTkSuQmCC" +NON_INHERITED_ICON = b"iVBORw0KGgoAAAANSUhEUgAAAAwAAAAMCAYAAABWdVznAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAACxAAAAsQAa0jvXUAAAAQSURBVChTYxgFo4AqgIEBAAJMAAH8Lw67AAAAAElFTkSuQmCC" LICENSE_TEXT = """ GNU GENERAL PUBLIC LICENSE diff --git a/resources/inheritance_icon.png b/resources/inherited_icon.png similarity index 100% rename from resources/inheritance_icon.png rename to resources/inherited_icon.png diff --git a/resources/non_inherited_icon.png b/resources/non_inherited_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..870865ce0ca6b5274f78427c33528ffec1c7df10 GIT binary patch literal 123 zcmeAS@N?(olHy`uVBq!ia0vp^JRr=$1|-8uW1a&k#^NA%Cx&(BWL^R}Ea{HEjtmSN z`?>!lvI6;>1s;*b3=G@?Ak4T{d2cCDP{7m0F+?LcS%P(OgH}T$BLkBU1LGfkzTH4M N22WQ%mvv4FO#r*L8bAO5 literal 0 HcmV?d00001 From 9235e2065b5a8e1dc41aa30f1f1a6b5cd28d2313 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Sat, 3 Feb 2024 11:34:56 +0100 Subject: [PATCH 225/328] WIP: Making inheritance visible --- npbackup/gui/config.py | 73 +++++++++++++++++++++++++++++++++--------- 1 file changed, 57 insertions(+), 16 deletions(-) diff --git a/npbackup/gui/config.py b/npbackup/gui/config.py index ca2b6d0..78d09c7 100644 --- a/npbackup/gui/config.py +++ b/npbackup/gui/config.py @@ -21,7 +21,7 @@ from ofunctions.misc import get_key_from_value from npbackup.core.i18n_helper import _t from npbackup.path_helper import CURRENT_EXECUTABLE -from npbackup.customization import INHERITANCE_ICON, FILE_ICON, FOLDER_ICON, INHERITED_FILE_ICON, INHERITED_FOLDER_ICON, TREE_ICON, INHERITED_TREE_ICON +from npbackup.customization import INHERITED_ICON, NON_INHERITED_ICON, FILE_ICON, FOLDER_ICON, INHERITED_FILE_ICON, INHERITED_FOLDER_ICON, TREE_ICON, INHERITED_TREE_ICON if os.name == "nt": from npbackup.windows.task import create_scheduled_task @@ -191,6 +191,15 @@ def update_gui_values(key, value, inherited, object_type, unencrypted): """ Update gui values depending on their type """ + nonlocal backup_paths_tree + nonlocal tags_tree + nonlocal exclude_files_tree + nonlocal exclude_patterns_tree + nonlocal pre_exec_commands_tree + nonlocal post_exec_commands_tree + nonlocal env_variables_tree + nonlocal encrypted_env_variables_tree + if key in ("repo_uri", "repo_group"): if object_type == "group": window[key].Disabled = True @@ -256,7 +265,11 @@ def update_gui_values(key, value, inherited, object_type, unencrypted): # Enable inheritance icon when needed inheritance_key = f"inherited.{key}" if inheritance_key in window.AllKeysDict: - window[inheritance_key].update(visible=inherited) + print(inheritance_key, inherited) + if inherited: + window[inheritance_key].update(INHERITED_ICON) + else: + window[inheritance_key].update(NON_INHERITED_ICON) except KeyError: logger.error(f"No GUI equivalent for key {key}.") @@ -301,6 +314,13 @@ def _iter_over_config(object_config: dict, root_key=""): _iter_over_config(object_config, root_key) def update_object_gui(object_name=None, unencrypted=False): + nonlocal backup_paths_tree + nonlocal tags_tree + nonlocal exclude_files_tree + nonlocal exclude_patterns_tree + nonlocal env_variables_tree + nonlocal encrypted_env_variables_tree + # Load fist available repo or group if none given if not object_name: object_name = get_objects()[0] @@ -314,6 +334,9 @@ def update_object_gui(object_name=None, unencrypted=False): else: window[key]("") + # We also need to clear tree objects + backup_paths_tree = sg.TreeData() + object_type, object_name = get_object_from_combo(object_name) if object_type == "repo": @@ -346,6 +369,7 @@ def update_global_gui(full_config, unencrypted=False): # Only update global options gui with identified global keys for key in full_config.keys(): if key in ("identity", "global_options"): + print(key) global_config.s(key, full_config.g(key)) iter_over_config(global_config, None, "group", unencrypted, None) @@ -354,6 +378,7 @@ def update_config_dict(full_config, values): Update full_config with keys from GUI keys should always have form section.name or section.subsection.name """ + return object_type, object_name = get_object_from_combo(values["-OBJECT-SELECT-"]) if object_type == "repo": object_group = full_config.g(f"repos.{object_name}.repo_group") @@ -372,7 +397,7 @@ def update_config_dict(full_config, values): if key in combo_boxes: value = get_key_from_value(combo_boxes[key], value) # check whether we need to split into list - elif not isinstance(value, bool): + elif not isinstance(value, bool) and not isinstance(value, list): result = value.split("\n") if len(result) > 1: value = result @@ -398,7 +423,7 @@ def update_config_dict(full_config, values): if object_group: inheritance_key = f"groups.{object_group}.{key}" # If object is a list, check which values are inherited from group and remove them - if isinstance(value, list): + if isinstance(value, list): # WIP # TODO for entry in full_config.g(inheritance_key): if entry in value: value.remove(entry) @@ -536,17 +561,20 @@ def object_layout() -> List[list]: [ [ sg.Text(_t("config_gui.compression"), size=(20, None)), - sg.Combo(list(combo_boxes["compression"].values()), key="backup_opts.compression", size=(20, 1)), - sg.pin(sg.Image(INHERITANCE_ICON, key="inherited.backup_opts.compression", tooltip=_t("config_gui.group_inherited"), pad=0)), + sg.Image(NON_INHERITED_ICON, key="inherited.backup_opts.compression", tooltip=_t("config_gui.group_inherited"), pad=1), + sg.Combo( + list(combo_boxes["compression"].values()), + key="backup_opts.compression", + size=(20, 1), pad=0), ], [ sg.Text(_t("config_gui.backup_priority"), size=(20, 1)), - sg.pin(sg.Image(INHERITANCE_ICON, key="inherited.backup_opts.backup_priority", tooltip=_t("config_gui.group_inherited") ,pad=0)), + sg.Image(NON_INHERITED_ICON, key="inherited.backup_opts.priority", tooltip=_t("config_gui.group_inherited"), pad=1), sg.Combo( list(combo_boxes["priority"].values()), key="backup_opts.priority", size=(20, 1), pad=0 - ), + ) ], [ @@ -573,13 +601,15 @@ def object_layout() -> List[list]: sg.Text(_t("config_gui.minimum_backup_size_error"), size=(40, 2)), ], [ + sg.Image(NON_INHERITED_ICON, key="inherited.backup_opts.minimum_backup_size_error", tooltip=_t("config_gui.group_inherited"), pad=1), sg.Input(key="backup_opts.minimum_backup_size_error", size=(8, 1)), sg.Combo(byte_units, default_value=byte_units[3], key="backup_opts.minimum_backup_size_error_unit") ], [ + sg.Image(NON_INHERITED_ICON, key="inherited.backup_opts.fs_snapshot", tooltip=_t("config_gui.group_inherited"), pad=1), sg.Checkbox(textwrap.fill(f'{_t("config_gui.use_fs_snapshot")}', width=34), key="backup_opts.use_fs_snapshot", size=(40, 1), pad=0), ] - ], pad=0 + ] ) ], [ @@ -589,7 +619,7 @@ def object_layout() -> List[list]: ), ], [ - + sg.Image(NON_INHERITED_ICON, key="inherited.backup_opts.additional_backup_only_parameters", tooltip=_t("config_gui.group_inherited"), pad=1), sg.Input( key="backup_opts.additional_backup_only_parameters", size=(100, 1) ), @@ -651,19 +681,24 @@ def object_layout() -> List[list]: _t("config_gui.exclude_files_larger_than"), size=(40, 1), ), + sg.Image(NON_INHERITED_ICON, key="inherited.backup_opts.exclude_files_larger_than", tooltip=_t("config_gui.group_inherited"), pad=1), sg.Input(key="backup_opts.exclude_files_larger_than", size=(8, 1)), sg.Combo(byte_units, default_value=byte_units[3], key="backup_opts.exclude_files_larger_than_unit") ], [ + sg.Image(NON_INHERITED_ICON, key="inherited.backup_opts.ignore_cloud_files", tooltip=_t("config_gui.group_inherited"), pad=1), sg.Checkbox(f'{_t("config_gui.ignore_cloud_files")} ({_t("config_gui.windows_only")})', key="backup_opts.ignore_cloud_files", size=(None, 1)), ], [ + sg.Image(NON_INHERITED_ICON, key="inherited.backup_opts.excludes_case_ignore", tooltip=_t("config_gui.group_inherited"), pad=1), sg.Checkbox(f'{_t("config_gui.excludes_case_ignore")} ({_t("config_gui.windows_always")})', key="backup_opts.excludes_case_ignore", size=(None, 1)), ], [ + sg.Image(NON_INHERITED_ICON, key="inherited.backup_opts.exclude_caches", tooltip=_t("config_gui.group_inherited"), pad=1), sg.Checkbox(_t("config_gui.exclude_cache_dirs"), key="backup_opts.exclude_caches", size=(None, 1)), ], [ + sg.Image(NON_INHERITED_ICON, key="inherited.backup_opts.one_file_system", tooltip=_t("config_gui.group_inherited"), pad=1), sg.Checkbox(_t("config_gui.one_file_system"), key="backup_opts.one_file_system", size=(None, 1)), ], ] @@ -771,14 +806,17 @@ def object_layout() -> List[list]: sg.Column( [ [ + sg.Image(NON_INHERITED_ICON, key="inherited.repo_opts.retention_strategy.hourly", tooltip=_t("config_gui.group_inherited"), pad=1), sg.Input(key="repo_opts.retention_strategy.hourly", size=(3, 1)), sg.Text(_t("config_gui.hourly"), size=(20, 1)), ], [ + sg.Image(NON_INHERITED_ICON, key="inherited.repo_opts.retention_strategy.daily", tooltip=_t("config_gui.group_inherited"), pad=1), sg.Input(key="repo_opts.retention_strategy.daily", size=(3, 1)), sg.Text(_t("config_gui.daily"), size=(20, 1)), ], [ + sg.Image(NON_INHERITED_ICON, key="inherited.repo_opts.retention_strategy.weekly", tooltip=_t("config_gui.group_inherited"), pad=1), sg.Input(key="repo_opts.retention_strategy.weekly", size=(3, 1)), sg.Text(_t("config_gui.weekly"), size=(20, 1)), ] @@ -787,10 +825,12 @@ def object_layout() -> List[list]: sg.Column( [ [ + sg.Image(NON_INHERITED_ICON, key="inherited.repo_opts.retention_strategy.monthly", tooltip=_t("config_gui.group_inherited"), pad=1), sg.Input(key="repo_opts.retention_strategy.monthly", size=(3, 1)), sg.Text(_t("config_gui.monthly"), size=(20, 1)), ], [ + sg.Image(NON_INHERITED_ICON, key="inherited.repo_opts.retention_strategy.yearly", tooltip=_t("config_gui.group_inherited"), pad=1), sg.Input(key="repo_opts.retention_strategy.yearly", size=(3, 1)), sg.Text(_t("config_gui.yearly"), size=(20, 1)), ] @@ -1119,6 +1159,10 @@ def config_layout() -> List[list]: tags_tree = sg.TreeData() exclude_patterns_tree = sg.TreeData() exclude_files_tree = sg.TreeData() + pre_exec_commands_tree = sg.TreeData() + post_exec_commands_tree = sg.TreeData() + env_variables_tree = sg.TreeData() + encrypted_env_variables_tree = sg.TreeData() # Update gui with first default object (repo or group) update_object_gui(get_objects()[0], unencrypted=False) @@ -1129,12 +1173,9 @@ def config_layout() -> List[list]: if event in (sg.WIN_CLOSED, sg.WIN_X_EVENT, "--CANCEL--"): break if event == "-OBJECT-SELECT-": - try: - update_config_dict(full_config, values) - update_object_gui(values["-OBJECT-SELECT-"], unencrypted=False) - update_global_gui(full_config, unencrypted=False) - except AttributeError: - continue + update_config_dict(full_config, values) + update_object_gui(values["-OBJECT-SELECT-"], unencrypted=False) + update_global_gui(full_config, unencrypted=False) if event == "-OBJECT-DELETE-": full_config = delete_object(full_config, values["-OBJECT-SELECT-"]) update_object_selector() From e51be8eb9e2dd0e4f28ba71cdd0ddeee05382d4c Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Mon, 5 Feb 2024 12:50:54 +0100 Subject: [PATCH 226/328] WIP: Gui rewrite --- npbackup/gui/config.py | 136 ++++++++++++++++++++---- npbackup/translations/config_gui.en.yml | 7 +- npbackup/translations/config_gui.fr.yml | 7 +- 3 files changed, 124 insertions(+), 26 deletions(-) diff --git a/npbackup/gui/config.py b/npbackup/gui/config.py index 78d09c7..58f4a24 100644 --- a/npbackup/gui/config.py +++ b/npbackup/gui/config.py @@ -704,44 +704,74 @@ def object_layout() -> List[list]: ] pre_post_col = [ + [ + sg.Column( + [ [ - sg.Text( - f"{_t('config_gui.pre_exec_commands')}\n({_t('config_gui.one_per_line')})", - size=(40, 2), + sg.Button("+", key="--ADD-PRE-EXEC-COMMAND--", size=(3, 1)) + ], + [ + sg.Button("-", key="--REMOVE-PRE-EXEC-COMMAND--", size=(3, 1)) + ] + ], pad=0, ), - sg.Multiline(key="backup_opts.pre_exec_commands", size=(48, 4)), + sg.Column( + [ + [ + sg.Tree(sg.TreeData(), key="backup_opts.pre_exec_commands", headings=[], + col0_heading=_t('config_gui.pre_exec_commands'), + num_rows=4, expand_x=True, expand_y=True) + ] + ], pad=0, expand_x=True + ) ], [ sg.Text(_t("config_gui.maximum_exec_time"), size=(40, 1)), - sg.Input(key="backup_opts.pre_exec_per_command_timeout", size=(50, 1)), + sg.Input(key="backup_opts.pre_exec_per_command_timeout", size=(8, 1)), + sg.Text(_t("generic.seconds")) ], [ - sg.Text(_t("config_gui.exec_failure_is_fatal"), size=(40, 1)), sg.Checkbox( - "", key="backup_opts.pre_exec_failure_is_fatal", size=(41, 1) + _t("config_gui.exec_failure_is_fatal"), key="backup_opts.pre_exec_failure_is_fatal", size=(41, 1) ), ], [ - sg.Text( - f"{_t('config_gui.post_exec_commands')}\n({_t('config_gui.one_per_line')})", - size=(40, 2), + sg.HorizontalSeparator() + ], + [ + sg.Column( + [ + [ + sg.Button("+", key="--ADD-POST-EXEC-COMMAND--", size=(3, 1)) + ], + [ + sg.Button("-", key="--REMOVE-POST-EXEC-COMMAND--", size=(3, 1)) + ] + ], pad=0, ), - sg.Multiline(key="backup_opts.post_exec_commands", size=(48, 4)), + sg.Column( + [ + [ + sg.Tree(sg.TreeData(), key="backup_opts.post_exec_commands", headings=[], + col0_heading=_t('config_gui.post_exec_commands'), + num_rows=4, expand_x=True, expand_y=True) + ] + ], pad=0, expand_x=True + ) ], [ sg.Text(_t("config_gui.maximum_exec_time"), size=(40, 1)), - sg.Input(key="backup_opts.post_exec_per_command_timeout", size=(50, 1)), + sg.Input(key="backup_opts.post_exec_per_command_timeout", size=(8, 1)), + sg.Text(_t("generic.seconds")) ], [ - sg.Text(_t("config_gui.exec_failure_is_fatal"), size=(40, 1)), sg.Checkbox( - "", key="backup_opts.post_exec_failure_is_fatal", size=(41, 1) + _t("config_gui.exec_failure_is_fatal"), key="backup_opts.post_exec_failure_is_fatal", size=(41, 1) ), ], [ - sg.Text(_t("config_gui.execute_even_on_backup_error"), size=(40, 1)), sg.Checkbox( - "", + _t("config_gui.execute_even_on_backup_error"), key="backup_opts.post_exec_execute_even_on_backup_error", size=(41, 1), ), @@ -842,8 +872,7 @@ def object_layout() -> List[list]: prometheus_col = [ [sg.Text(_t("config_gui.available_variables"))], [ - sg.Text(_t("config_gui.enable_prometheus"), size=(40, 1)), - sg.Checkbox("", key="prometheus.metrics", size=(41, 1)), + sg.Checkbox(_t("config_gui.enable_prometheus"), key="prometheus.metrics", size=(41, 1)), ], [ sg.Text(_t("config_gui.job_name"), size=(40, 1)), @@ -1170,6 +1199,8 @@ def config_layout() -> List[list]: while True: event, values = window.read() + # Get object type for various delete operations + object_type, _ = get_object_from_combo(values["-OBJECT-SELECT-"]) if event in (sg.WIN_CLOSED, sg.WIN_X_EVENT, "--CANCEL--"): break if event == "-OBJECT-SELECT-": @@ -1208,12 +1239,57 @@ def config_layout() -> List[list]: backup_paths_tree.insert('', node, node, node, icon=icon) window['backup_opts.paths'].update(values=backup_paths_tree) if event == "--REMOVE-SELECTED-BACKUP-PATHS--": - # TODO: prevent removing inherited values for key in values['backup_opts.paths']: + if object_type != "group" and backup_paths_tree.tree_dict[key].icon in (INHERITED_FILE_ICON, INHERITED_FOLDER_ICON): + sg.PopupError(_t("config_gui.cannot_remove_group_inherited_settings")) + continue backup_paths_tree.delete(key) - window['backup_opts.paths'].update(values=backup_paths_tree) + window['backup_opts.paths'].update(values=backup_paths_tree) + if event in ( + "--ADD-TAG--", + "--ADD-EXCLUDE-PATTERN--", + "--ADD-PRE-EXEC-COMMAND--", + "--ADD-POST-EXEC-COMMAND--", + "--ADD-ENV-VARIABLES--", + "--ADD-ENCRYPTED-ENV-VARIABLES--", + "--REMOVE-TAG--", + "--REMOVE-EXCLUDE_PATTERN--" + + ): + if "TAG" in event: + popup_text = _t("config_gui.enter_tag") + tree = tags_tree + option_key = "backup_opts.tags" + if "EXCLUDE_PATTERN" in event: + popup_text = _t("config_gui.enter_pattern") + tree = exclude_patterns_tree + option_key = "backup_opts.exclude_patterns" + if "PRE-EXEC-COMMANDS" in event: + popup_text = _t("config_gui.entern_command") + tree = pre_exec_commands_tree + option_key = "backup_opts.pre_exec_commands" + if "POST-EXEC-COMMANDS" in event: + pass + + + if event.startswith("--ADD-"): + node = sg.PopupGetText(popup_text) + if node: + if object_type == "group": + icon = INHERITED_TREE_ICON + else: + icon = TREE_ICON + tree.insert('', node, node, node, icon=icon) + if event.startswith("--REMOVE-"): + for key in values[option_key]: + if object_type != "group" and tree.tree_dict[key].icon == INHERITED_TREE_ICON: + sg.PopupError(_t("config_gui.cannot_remove_group_inherited_settings")) + continue + tree.delete(key) + window[option_key].Update(values=tree) + """ if event == "--ADD-TAG--": - node = sg.PopupGetText(_t("config_gui.enter_tag")) + node = sg.PopupGetText() if node: if object_type == "group": icon = INHERITED_TREE_ICON @@ -1223,11 +1299,27 @@ def config_layout() -> List[list]: window["backup_opts.tags"].Update(values=tags_tree) if event == "--REMOVE-TAG--": for key in values["backup_opts.tags"]: - if is_inherited("backup_opts.tags", values["backup_opts.tags"]) and object_type != "group": + if object_type != "group" and tags_tree.tree_dict[key].icon == INHERITED_TREE_ICON: sg.Popup(_t("config_gui.cannot_remove_group_inherited_settings")) continue tags_tree.delete(key) window["backup_opts.tags"].Update(values=tags_tree) + if event == "--ADD-EXCLUDE-PATTERN--": + node = sg.PopupGetText(_t("config_gui.enter_pattern")) + if object_type == "group": + icon = INHERITED_TREE_ICON + else: + icon = TREE_ICON + exclude_patterns_tree.insert('', node, node, node, icon=icon) + window["backup_opts.exclude_patterns"].Update(values=exclude_patterns_tree) + if event == "--REMOVE-EXCLUDE-PATTERN--": + for key in values["backup_opts.exclude_patterns"]: + if object_type != "group" and exclude_patterns_tree.tree_dict[key].icon == INHERITED_TREE_ICON: + sg.Popup(_t("config_gui.cannot_remove_group_inherited_settings")) + continue + exclude_patterns_tree.delete(key) + window["backup_opts.exclude_patterns"].Update(values=exclude_patterns_tree) + """ if event == "--ACCEPT--": if ( not values["repo_opts.repo_password"] diff --git a/npbackup/translations/config_gui.en.yml b/npbackup/translations/config_gui.en.yml index d75b451..b8a3b3f 100644 --- a/npbackup/translations/config_gui.en.yml +++ b/npbackup/translations/config_gui.en.yml @@ -20,7 +20,7 @@ en: one_file_system: Do not follow mountpoints minimum_backup_size_error: Minimum size under which backup is considered failed pre_exec_commands: Pre-exec commands - maximum_exec_time: Maximum exec time (seconds) + maximum_exec_time: Maximum exec time exec_failure_is_fatal: Execution failure is fatal post_exec_commands: Post-exec commands execute_even_on_backup_error: Execute even if backup failed @@ -140,4 +140,7 @@ en: setting_permissions_requires_manager_password: Setting permissions requires manager password manager_password_too_short: Manager password is too short - unknown_error_see_logs: Unknown error, please check logs \ No newline at end of file + unknown_error_see_logs: Unknown error, please check logs + + enter_tag: Enter tag + enter_pattern: Enter pattern \ No newline at end of file diff --git a/npbackup/translations/config_gui.fr.yml b/npbackup/translations/config_gui.fr.yml index 840b36f..74eb228 100644 --- a/npbackup/translations/config_gui.fr.yml +++ b/npbackup/translations/config_gui.fr.yml @@ -21,7 +21,7 @@ fr: one_file_system: Ne pas suivre les points de montage minimum_backup_size_error: Taille minimale en dessous de laquelle la sauvegarde est considérée échouée pre_exec_commands: Commandes pré-sauvegarde - maximum_exec_time: Temps maximal d'execution (secondes) + maximum_exec_time: Temps maximal d'execution exec_failure_is_fatal: L'échec d'execution est fatal post_exec_commands: Commandes post-sauvegarde execute_even_on_backup_error: Executer même si la sauvegarde a échouée @@ -142,4 +142,7 @@ fr: setting_permissions_requires_manager_password: Un mot de passe gestionnaire est requis pour définir des permissions manager_password_too_short: Le mot de passe gestionnaire est trop court - unknown_error_see_logs: Erreur inconnue, merci de vérifier les journaux \ No newline at end of file + unknown_error_see_logs: Erreur inconnue, merci de vérifier les journaux + + enter_tag: Entrer tag + enter_pattern: Entrer pattern \ No newline at end of file From d9e6b689a4e8cc3ae9f0c949f58442b5bea89b85 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Mon, 5 Feb 2024 16:35:37 +0100 Subject: [PATCH 227/328] WIP: GUI UX --- npbackup/gui/config.py | 235 ++++++++++++++---------- npbackup/translations/config_gui.en.yml | 7 +- npbackup/translations/config_gui.fr.yml | 9 +- npbackup/translations/generic.en.yml | 1 + npbackup/translations/generic.fr.yml | 1 + 5 files changed, 155 insertions(+), 98 deletions(-) diff --git a/npbackup/gui/config.py b/npbackup/gui/config.py index 58f4a24..038893f 100644 --- a/npbackup/gui/config.py +++ b/npbackup/gui/config.py @@ -7,7 +7,7 @@ __author__ = "Orsiris de Jong" __copyright__ = "Copyright (C) 2022-2024 NetInvent" __license__ = "GPL-3.0-only" -__build__ = "2023121701" +__build__ = "2024020501" from typing import List, Union @@ -199,7 +199,7 @@ def update_gui_values(key, value, inherited, object_type, unencrypted): nonlocal post_exec_commands_tree nonlocal env_variables_tree nonlocal encrypted_env_variables_tree - + print("MY", key) if key in ("repo_uri", "repo_group"): if object_type == "group": window[key].Disabled = True @@ -223,7 +223,16 @@ def update_gui_values(key, value, inherited, object_type, unencrypted): pass # Update tree objects - if key in ("backup_opts.paths", "backup_opts.tags", "backup_opts.exclude_patterns", "backup_opts.exclude_files"): + if key in ( + "backup_opts.paths", + "backup_opts.tags", + "backup_opts.exclude_patterns", + "backup_opts.exclude_files", + "backup_opts.pre_exec_commands", + "backup_opts.post_exec_commands", + "env.env_variables", + "env.encrypted_env_variables" + ): if not isinstance(value, list): value = [value] if key == "backup_opts.paths": @@ -240,14 +249,37 @@ def update_gui_values(key, value, inherited, object_type, unencrypted): icon = FILE_ICON backup_paths_tree.insert('', val, val, val, icon=icon) window['backup_opts.paths'].update(values=backup_paths_tree) - if key == "backup_opts.tags": + elif key in ( + "backup_opts.tags", + "backup_opts.pre_exec_commands", + "backup_opts.post_exec_commands", + "backup_opts.exclude_files", + "backup_opts.exclude_patterns", + "env.env_variables", + "env.encrypted_env_variables" + + ): + if key == "backup_opts.tags": + tree = tags_tree + if key == "backup_opts.pre_exec_commands": + tree = pre_exec_commands_tree + if key == "backup_opts.post_exec_commands": + tree = post_exec_commands_tree + if key == "backup_opts.exclude_files": + tree = exclude_files_tree + if key == "backup_opts.exclude_patterns": + tree = exclude_patterns_tree + if key == "env.env_variables": + tree = env_variables_tree + if key == "env.encrypted_env_variables": + tree = encrypted_env_variables_tree for val in value: if inherited[val]: icon = INHERITED_TREE_ICON else: icon = TREE_ICON - tags_tree.insert('', val, val, val, icon=icon) - window['backup_opts.tags'].Update(values=tags_tree) + tree.insert('', val, val, val, icon=icon) + window[key].Update(values=tree) return # Update units into separate value and unit combobox @@ -257,15 +289,18 @@ def update_gui_values(key, value, inherited, object_type, unencrypted): if isinstance(value, list): value = "\n".join(value) + if key in combo_boxes: - window[key].Update(combo_boxes[key][value]) + print("combo key") + print(combo_boxes[key][value]) + window[key].Update(value=combo_boxes[key][value]) else: - window[key].Update(value) + print("nOKEy", key) + window[key].Update(value=value) # Enable inheritance icon when needed inheritance_key = f"inherited.{key}" if inheritance_key in window.AllKeysDict: - print(inheritance_key, inherited) if inherited: window[inheritance_key].update(INHERITED_ICON) else: @@ -273,8 +308,10 @@ def update_gui_values(key, value, inherited, object_type, unencrypted): except KeyError: logger.error(f"No GUI equivalent for key {key}.") + logger.debug("Trace:", exc_info=True) except TypeError as exc: logger.error(f"Error: {exc} for key {key}.") + logger.debug("Trace:", exc_info=True) def iter_over_config( object_config: dict, @@ -369,7 +406,6 @@ def update_global_gui(full_config, unencrypted=False): # Only update global options gui with identified global keys for key in full_config.keys(): if key in ("identity", "global_options"): - print(key) global_config.s(key, full_config.g(key)) iter_over_config(global_config, None, "group", unencrypted, None) @@ -550,11 +586,11 @@ def object_layout() -> List[list]: expand_x=True, expand_y=True) ], [ - sg.Input(visible=False, key="--PATHS-ADD-FILE--", enable_events=True), - sg.FilesBrowse(_t("generic.add_files"), target="--PATHS-ADD-FILE--"), - sg.Input(visible=False, key="--PATHS-ADD-FOLDER--", enable_events=True), - sg.FolderBrowse(_t("generic.add_folder"), target="--PATHS-ADD-FOLDER--"), - sg.Button(_t("generic.remove_selected"), key="--REMOVE-SELECTED-BACKUP-PATHS--") + sg.Input(visible=False, key="--ADD-PATHS-FILE--", enable_events=True), + sg.FilesBrowse(_t("generic.add_files"), target="--ADD-PATHS-FILE--"), + sg.Input(visible=False, key="--ADD-PATHS-FOLDER--", enable_events=True), + sg.FolderBrowse(_t("generic.add_folder"), target="--ADD-PATHS-FOLDER--"), + sg.Button(_t("generic.remove_selected"), key="--REMOVE-BACKUP-PATHS--") ], [ sg.Column( @@ -913,18 +949,52 @@ def object_layout() -> List[list]: env_col = [ [ - sg.Text( - f"{_t('config_gui.env_variables')}\n({_t('config_gui.one_per_line')}\n{_t('config_gui.format_equals')})", - size=(40, 3), + sg.Column( + [ + [ + sg.Button("+", key="--ADD-ENV-VARIABLE--", size=(3, 1)) + ], + [ + sg.Button("-", key="--REMOVE-ENV-VARIABLE--", size=(3, 1)) + ] + ], pad=0, ), - sg.Multiline(key="env.env_variables", size=(48, 5)), + sg.Column( + [ + [ + sg.Tree(sg.TreeData(), key="env.env_variables", headings=[_t("generic.value")], + col0_heading=_t('config_gui.env_variables'), + col0_width=1, + auto_size_columns=True, + justification="L", + num_rows=4, expand_x=True, expand_y=True) + ] + ], pad=0, expand_x=True + ) ], [ - sg.Text( - f"{_t('config_gui.encrypted_env_variables')}\n({_t('config_gui.one_per_line')}\n{_t('config_gui.format_equals')})", - size=(40, 3), + sg.Column( + [ + [ + sg.Button("+", key="--ADD-ENCRYPTED-ENV-VARIABLE--", size=(3, 1)) + ], + [ + sg.Button("-", key="--REMOVE-ENCRYPTED-ENV-VARIABLE--", size=(3, 1)) + ] + ], pad=0, ), - sg.Multiline(key="env.encrypted_env_variables", size=(48, 5)), + sg.Column( + [ + [ + sg.Tree(sg.TreeData(), key="env.encrypted_env_variables", headings=[_t("generic.value")], + col0_heading=_t('config_gui.encrypted_env_variables'), + col0_width=1, + auto_size_columns=True, + justification="L", + num_rows=4, expand_x=True, expand_y=True) + ] + ], pad=0, expand_x=True + ) ], [ sg.Text(_t("config_gui.additional_parameters"), size=(40, 1)), @@ -952,7 +1022,6 @@ def object_layout() -> List[list]: backup_col, font="helvetica 16", key="--tab-backup--", - #element_justification="L", expand_x=True, expand_y=True ) @@ -997,7 +1066,7 @@ def object_layout() -> List[list]: ], [ sg.Tab( - _t("config_gui.environment_variables"), + _t("config_gui.env_variables"), env_col, font="helvetica 16", key="--tab-env--", @@ -1008,7 +1077,6 @@ def object_layout() -> List[list]: _layout = [ [sg.Column(object_selector, - #element_justification="L" )], [ sg.TabGroup( @@ -1087,7 +1155,6 @@ def global_options_layout(): identity_col, font="helvetica 16", key="--tab-global-identification--", - #element_justification="L", ) ], [ @@ -1096,7 +1163,6 @@ def global_options_layout(): global_options_col, font="helvetica 16", key="--tab-global-options--", - #element_justification="L", ) ], [ @@ -1105,7 +1171,6 @@ def global_options_layout(): scheduled_task_col, font="helvetica 16", key="--tab-global-scheduled_task--", - #element_justification="L", ) ], ] @@ -1223,103 +1288,87 @@ def config_layout() -> List[list]: if ask_manager_password(manager_password): full_config = set_permissions(full_config, values["-OBJECT-SELECT-"]) continue - if event in ("--PATHS-ADD-FILE--", '--PATHS-ADD-FOLDER--'): - if event == "--PATHS-ADD-FILE--": - node = values["--PATHS-ADD-FILE--"] + if event in ("--ADD-PATHS-FILE--", '--ADD-PATHS-FOLDER--'): + if event == "--ADD-PATHS-FILE--": + node = values["--ADD-PATHS-FILE--"] if object_type == "group": icon = INHERITED_FILE_ICON else: icon = FILE_ICON - elif event == '--PATHS-ADD-FOLDER--': - node = values['--PATHS-ADD-FOLDER--'] + elif event == '--ADD-PATHS-FOLDER--': + node = values['--ADD-PATHS-FOLDER--'] if object_type == "group": icon = INHERITED_FOLDER_ICON else: icon = FOLDER_ICON backup_paths_tree.insert('', node, node, node, icon=icon) window['backup_opts.paths'].update(values=backup_paths_tree) - if event == "--REMOVE-SELECTED-BACKUP-PATHS--": - for key in values['backup_opts.paths']: - if object_type != "group" and backup_paths_tree.tree_dict[key].icon in (INHERITED_FILE_ICON, INHERITED_FOLDER_ICON): - sg.PopupError(_t("config_gui.cannot_remove_group_inherited_settings")) - continue - backup_paths_tree.delete(key) - window['backup_opts.paths'].update(values=backup_paths_tree) if event in ( "--ADD-TAG--", "--ADD-EXCLUDE-PATTERN--", "--ADD-PRE-EXEC-COMMAND--", "--ADD-POST-EXEC-COMMAND--", - "--ADD-ENV-VARIABLES--", - "--ADD-ENCRYPTED-ENV-VARIABLES--", + "--ADD-ENV-VARIABLE--", + "--ADD-ENCRYPTED-ENV-VARIABLE--", + "--REMOVE-BACKUP-PATHS", "--REMOVE-TAG--", - "--REMOVE-EXCLUDE_PATTERN--" - + "--REMOVE-EXCLUDE-PATTERN--", + "--REMOVE-EXCLUDE-FILE--", + "--REMOVE-PRE-EXEC-COMMAND--", + "--REMOVE-POST-EXEC-COMMAND--", + "--REMOVE-ENV-VARIABLE--", + "--REMOVE-ENCRYPTED-ENV-VARIABLE--" ): - if "TAG" in event: + if "PATHS" in event: + option_key = "backup_opts.paths" + tree = backup_paths_tree + elif "TAG" in event: popup_text = _t("config_gui.enter_tag") tree = tags_tree option_key = "backup_opts.tags" - if "EXCLUDE_PATTERN" in event: + elif "EXCLUDE_PATTERN" in event: popup_text = _t("config_gui.enter_pattern") tree = exclude_patterns_tree option_key = "backup_opts.exclude_patterns" - if "PRE-EXEC-COMMANDS" in event: - popup_text = _t("config_gui.entern_command") + elif "EXCLUDE-FILE" in event: + tree = exclude_files_tree + option_key = "backup_opts.exclude_files" + elif "PRE-EXEC-COMMANDS" in event: + popup_text = _t("config_gui.enter_command") tree = pre_exec_commands_tree option_key = "backup_opts.pre_exec_commands" - if "POST-EXEC-COMMANDS" in event: - pass + elif "POST-EXEC-COMMANDS" in event: + popup_text = _t("config_gui.enter_command") + tree = post_exec_commands_tree + option_key = "backup_opts.post_exec_commands" + elif "ENCRYPTED-ENV-VARIABLE" in event: + tree = encrypted_env_variables_tree + option_key = "env.encrypted_env_variables" + elif "ENV-VARIABLE" in event: + tree = env_variables_tree + option_key = "env.env_variables" - if event.startswith("--ADD-"): - node = sg.PopupGetText(popup_text) - if node: - if object_type == "group": - icon = INHERITED_TREE_ICON - else: - icon = TREE_ICON - tree.insert('', node, node, node, icon=icon) + if object_type == "group": + icon = INHERITED_TREE_ICON + else: + icon = TREE_ICON + if "ENV-VARIABLE" in event: + var_name = sg.PopupGetText(_t("config_gui.enter_var_name")) + var_value = sg.PopupGetText(_t("config_gui.enter_var_value")) + if var_name and var_value: + tree.insert('', var_name, var_name, var_value, icon=icon) + else: + node = sg.PopupGetText(popup_text) + if node: + tree.insert('', node, node, node, icon=icon) if event.startswith("--REMOVE-"): for key in values[option_key]: - if object_type != "group" and tree.tree_dict[key].icon == INHERITED_TREE_ICON: + if object_type != "group" and tree.tree_dict[key].icon in (INHERITED_TREE_ICON, INHERITED_FILE_ICON, INHERITED_FOLDER_ICON): sg.PopupError(_t("config_gui.cannot_remove_group_inherited_settings")) continue tree.delete(key) window[option_key].Update(values=tree) - """ - if event == "--ADD-TAG--": - node = sg.PopupGetText() - if node: - if object_type == "group": - icon = INHERITED_TREE_ICON - else: - icon = TREE_ICON - tags_tree.insert('', node, node, node, icon=icon) - window["backup_opts.tags"].Update(values=tags_tree) - if event == "--REMOVE-TAG--": - for key in values["backup_opts.tags"]: - if object_type != "group" and tags_tree.tree_dict[key].icon == INHERITED_TREE_ICON: - sg.Popup(_t("config_gui.cannot_remove_group_inherited_settings")) - continue - tags_tree.delete(key) - window["backup_opts.tags"].Update(values=tags_tree) - if event == "--ADD-EXCLUDE-PATTERN--": - node = sg.PopupGetText(_t("config_gui.enter_pattern")) - if object_type == "group": - icon = INHERITED_TREE_ICON - else: - icon = TREE_ICON - exclude_patterns_tree.insert('', node, node, node, icon=icon) - window["backup_opts.exclude_patterns"].Update(values=exclude_patterns_tree) - if event == "--REMOVE-EXCLUDE-PATTERN--": - for key in values["backup_opts.exclude_patterns"]: - if object_type != "group" and exclude_patterns_tree.tree_dict[key].icon == INHERITED_TREE_ICON: - sg.Popup(_t("config_gui.cannot_remove_group_inherited_settings")) - continue - exclude_patterns_tree.delete(key) - window["backup_opts.exclude_patterns"].Update(values=exclude_patterns_tree) - """ if event == "--ACCEPT--": if ( not values["repo_opts.repo_password"] diff --git a/npbackup/translations/config_gui.en.yml b/npbackup/translations/config_gui.en.yml index b8a3b3f..fbd30ee 100644 --- a/npbackup/translations/config_gui.en.yml +++ b/npbackup/translations/config_gui.en.yml @@ -55,7 +55,7 @@ en: saved_initial_config: If you saved your configuration, you may now reload this program bogus_config_file: Bogus configuration file found - encrypted_env_variables: Encrypted envrionment variables (ie TOKENS etc) + encrypted_env_variables: Encrypted envrionment variables env_variables: Environment variables format_equals: Format variable=value @@ -143,4 +143,7 @@ en: unknown_error_see_logs: Unknown error, please check logs enter_tag: Enter tag - enter_pattern: Enter pattern \ No newline at end of file + enter_pattern: Enter pattern + enter_command: Enter command + enter_var_name: Enter variable name + enter_var_value: Enter variable value \ No newline at end of file diff --git a/npbackup/translations/config_gui.fr.yml b/npbackup/translations/config_gui.fr.yml index 74eb228..8baa917 100644 --- a/npbackup/translations/config_gui.fr.yml +++ b/npbackup/translations/config_gui.fr.yml @@ -56,8 +56,8 @@ fr: saved_initial_config: Si vous avez enregistré une configuration, vous pouvez à présent recharger le programme. bogus_config_file: Fichier de configuration érroné - encrypted_environment_variables: Variables d'envrionnement chiffrées (ie TOKENS etc) - environment_variables: Variables d'environnement + encrypted_env_variables: Variables d'envrionnement chiffrées + env_variables: Variables d'environnement format_equals: Format variable=valeur no_runner: Impossible de se connecter au backend. Verifier les logs @@ -145,4 +145,7 @@ fr: unknown_error_see_logs: Erreur inconnue, merci de vérifier les journaux enter_tag: Entrer tag - enter_pattern: Entrer pattern \ No newline at end of file + enter_pattern: Entrer pattern + enter_command: Entrer command + enter_var_name: Entrer le nom de la variable + enter_var_value: Entrer sa valeur \ No newline at end of file diff --git a/npbackup/translations/generic.en.yml b/npbackup/translations/generic.en.yml index 5b2d54d..de55573 100644 --- a/npbackup/translations/generic.en.yml +++ b/npbackup/translations/generic.en.yml @@ -57,6 +57,7 @@ en: select_file: Select file name: Name type: Type + value: Value bogus_data_given: Bogus data given diff --git a/npbackup/translations/generic.fr.yml b/npbackup/translations/generic.fr.yml index c0bbf4f..ce23b30 100644 --- a/npbackup/translations/generic.fr.yml +++ b/npbackup/translations/generic.fr.yml @@ -57,6 +57,7 @@ fr: select_file: Selection fichier name: Nom type: Type + value: Valeur bogus_data_given: Données invalides From 308934afd2423cf94a255384380b1562ed904a5d Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Mon, 5 Feb 2024 18:37:36 +0100 Subject: [PATCH 228/328] Fix combo boxes not being updated due to wrong key names --- npbackup/gui/config.py | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/npbackup/gui/config.py b/npbackup/gui/config.py index 038893f..b307143 100644 --- a/npbackup/gui/config.py +++ b/npbackup/gui/config.py @@ -74,18 +74,18 @@ def config_gui(full_config: dict, config_file: str): ) combo_boxes = { - "compression": { + "backup_opts.compression": { "auto": _t("config_gui.auto"), "max": _t("config_gui.max"), "off": _t("config_gui.off"), }, - "source_type": { + "backup_opts.source_type": { "folder_list": _t("config_gui.folder_list"), "files_from": _t("config_gui.files_from"), "files_from_verbatim": _t("config_gui.files_from_verbatim"), "files_from_raw": _t("config_gui.files_from_raw"), }, - "priority": { + "backup_opts.priority": { "low": _t("config_gui.low"), "normal": _t("config_gui.normal"), "high": _t("config_gui.high"), @@ -199,7 +199,7 @@ def update_gui_values(key, value, inherited, object_type, unencrypted): nonlocal post_exec_commands_tree nonlocal env_variables_tree nonlocal encrypted_env_variables_tree - print("MY", key) + if key in ("repo_uri", "repo_group"): if object_type == "group": window[key].Disabled = True @@ -290,12 +290,9 @@ def update_gui_values(key, value, inherited, object_type, unencrypted): if isinstance(value, list): value = "\n".join(value) - if key in combo_boxes: - print("combo key") - print(combo_boxes[key][value]) + if key in combo_boxes.keys(): window[key].Update(value=combo_boxes[key][value]) else: - print("nOKEy", key) window[key].Update(value=value) # Enable inheritance icon when needed @@ -575,7 +572,7 @@ def object_layout() -> List[list]: size=(None, None), expand_x=True, justification='R' ), sg.Combo( - list(combo_boxes["source_type"].values()), + list(combo_boxes["backup_opts.source_type"].values()), key="backup_opts.source_type", size=(48, 1), ), @@ -599,7 +596,7 @@ def object_layout() -> List[list]: sg.Text(_t("config_gui.compression"), size=(20, None)), sg.Image(NON_INHERITED_ICON, key="inherited.backup_opts.compression", tooltip=_t("config_gui.group_inherited"), pad=1), sg.Combo( - list(combo_boxes["compression"].values()), + list(combo_boxes["backup_opts.compression"].values()), key="backup_opts.compression", size=(20, 1), pad=0), ], @@ -607,7 +604,7 @@ def object_layout() -> List[list]: sg.Text(_t("config_gui.backup_priority"), size=(20, 1)), sg.Image(NON_INHERITED_ICON, key="inherited.backup_opts.priority", tooltip=_t("config_gui.group_inherited"), pad=1), sg.Combo( - list(combo_boxes["priority"].values()), + list(combo_boxes["backup_opts.priority"].values()), key="backup_opts.priority", size=(20, 1), pad=0 ) From 99d8953c2b3d61eb11c8651041fb0f673e2f82b9 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Mon, 5 Feb 2024 18:54:08 +0100 Subject: [PATCH 229/328] WIP: GUI ux --- npbackup/gui/config.py | 48 ++++++++++++++++++++++++++++++------------ 1 file changed, 34 insertions(+), 14 deletions(-) diff --git a/npbackup/gui/config.py b/npbackup/gui/config.py index b307143..4bcc28e 100644 --- a/npbackup/gui/config.py +++ b/npbackup/gui/config.py @@ -283,7 +283,11 @@ def update_gui_values(key, value, inherited, object_type, unencrypted): return # Update units into separate value and unit combobox - if key in ["backup_opts.minimum_backup_size_error", "backup_opts.exclude_files_larger_than", "upload_speed", "download_speed"]: + if key in ( + "backup_opts.minimum_backup_size_error", + "backup_opts.exclude_files_larger_than", + "repo_opts.upload_speed", + "repo_opts.download_speed"): value, unit = value.split(" ") window[f"{key}_unit"].Update(unit) @@ -759,11 +763,13 @@ def object_layout() -> List[list]: ) ], [ + sg.Image(NON_INHERITED_ICON, key="inherited.backup_opts.pre_exec_per_command_timeout", tooltip=_t("config_gui.group_inherited"), pad=1), sg.Text(_t("config_gui.maximum_exec_time"), size=(40, 1)), sg.Input(key="backup_opts.pre_exec_per_command_timeout", size=(8, 1)), sg.Text(_t("generic.seconds")) ], [ + sg.Image(NON_INHERITED_ICON, key="inherited.backup_opts.pre_exec_failure_is_fatal", tooltip=_t("config_gui.group_inherited"), pad=1), sg.Checkbox( _t("config_gui.exec_failure_is_fatal"), key="backup_opts.pre_exec_failure_is_fatal", size=(41, 1) ), @@ -793,16 +799,19 @@ def object_layout() -> List[list]: ) ], [ + sg.Image(NON_INHERITED_ICON, key="inherited.backup_opts.post_exec_per_command_timeout", tooltip=_t("config_gui.group_inherited"), pad=1), sg.Text(_t("config_gui.maximum_exec_time"), size=(40, 1)), sg.Input(key="backup_opts.post_exec_per_command_timeout", size=(8, 1)), sg.Text(_t("generic.seconds")) ], [ + sg.Image(NON_INHERITED_ICON, key="inherited.backup_opts.post_exec_failure_is_fatal", tooltip=_t("config_gui.group_inherited"), pad=1), sg.Checkbox( _t("config_gui.exec_failure_is_fatal"), key="backup_opts.post_exec_failure_is_fatal", size=(41, 1) ), ], [ + sg.Image(NON_INHERITED_ICON, key="inherited.backup_opts.execute_even_on_backup_error", tooltip=_t("config_gui.group_inherited"), pad=1), sg.Checkbox( _t("config_gui.execute_even_on_backup_error"), key="backup_opts.post_exec_execute_even_on_backup_error", @@ -814,41 +823,49 @@ def object_layout() -> List[list]: repo_col = [ [ sg.Text(_t("config_gui.backup_repo_uri"), size=(40, 1)), - sg.Input(key="repo_uri", size=(50, 1)), + ], + [ + sg.Image(NON_INHERITED_ICON, pad=1), + sg.Input(key="repo_uri", size=(95, 1)), ], [ sg.Text(_t("config_gui.backup_repo_password"), size=(40, 1)), - sg.Input(key="repo_opts.repo_password", size=(50, 1)), + ], + [ + sg.Image(NON_INHERITED_ICON, key="inherited.repo_opts.repo_password", tooltip=_t("config_gui.group_inherited"), pad=1), + sg.Input(key="repo_opts.repo_password", size=(95, 1)), ], [ sg.Text(_t("config_gui.backup_repo_password_command"), size=(40, 1)), - sg.Input(key="repo_opts.repo_password_command", size=(50, 1)), + ], + [ + sg.Image(NON_INHERITED_ICON, key="inherited.repo_opts.repo_password_command", tooltip=_t("config_gui.group_inherited"), pad=1), + sg.Input(key="repo_opts.repo_password_command", size=(95, 1)), ], [sg.Button(_t("config_gui.set_permissions"), key="--SET-PERMISSIONS--")], [ sg.Text(_t("config_gui.repo_group"), size=(40, 1)), - sg.Input(key="repo_group", size=(50, 1)), + sg.Combo(values=[], key="repo_group") # TODO ], [ - sg.Text( - "{}\n({})".format( - _t("config_gui.minimum_backup_age"), _t("generic.minutes") - ), - size=(40, 2), + sg.Text(_t("config_gui.minimum_backup_age"), size=(40, 2), ), - sg.Input(key="repo_opts.minimum_backup_age", size=(50, 1)), + sg.Input(key="repo_opts.minimum_backup_age", size=(8, 1)), + sg.Text(_t("generic.minutes")) ], [ sg.Text(_t("config_gui.upload_speed"), size=(40, 1)), - sg.Input(key="repo_opts.upload_speed", size=(50, 1)), + sg.Input(key="repo_opts.upload_speed", size=(8, 1)), + sg.Combo(byte_units, default_value=byte_units[3], key="repo_opts.upload_speed_unit") ], [ sg.Text(_t("config_gui.download_speed"), size=(40, 1)), - sg.Input(key="repo_opts.download_speed", size=(50, 1)), + sg.Input(key="repo_opts.download_speed", size=(8, 1)), + sg.Combo(byte_units, default_value=byte_units[3], key="repo_opts.download_speed_unit") ], [ sg.Text(_t("config_gui.backend_connections"), size=(40, 1)), - sg.Input(key="repo_opts.backend_connections", size=(50, 1)), + sg.Input(key="repo_opts.backend_connections", size=(8, 1)), ], [sg.HorizontalSeparator()], [sg.Text(_t("config_gui.retention_policy"))], @@ -995,6 +1012,9 @@ def object_layout() -> List[list]: ], [ sg.Text(_t("config_gui.additional_parameters"), size=(40, 1)), + ], + [ + sg.Image(NON_INHERITED_ICON, key="inherited.backup_opts.additional_parameters", tooltip=_t("config_gui.group_inherited"), pad=1), sg.Input(key="backup_opts.additional_parameters", size=(50, 1)), ], ] From 07571862f4789ebc0d52c8942dcaadc468a6493c Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Mon, 5 Feb 2024 19:30:35 +0100 Subject: [PATCH 230/328] Allow IEC bytes units everywhere in config file --- npbackup/configuration.py | 2 +- npbackup/restic_metrics/__init__.py | 2 +- npbackup/restic_wrapper/__init__.py | 16 ++++++++++------ 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/npbackup/configuration.py b/npbackup/configuration.py index 1db62af..1e27cca 100644 --- a/npbackup/configuration.py +++ b/npbackup/configuration.py @@ -394,7 +394,7 @@ def expand_units(object_config: dict, unexpand: bool = False) -> dict: def _expand_units(key, value): if key in ("minimum_backup_size_error", "exclude_files_larger_than", "upload_speed", "download_speed"): if unexpand: - return BytesConverter(value).human + return BytesConverter(value).human_iec_bytes return BytesConverter(value) return value diff --git a/npbackup/restic_metrics/__init__.py b/npbackup/restic_metrics/__init__.py index e7fc6cc..eb0c18e 100644 --- a/npbackup/restic_metrics/__init__.py +++ b/npbackup/restic_metrics/__init__.py @@ -225,7 +225,7 @@ def restic_json_to_prometheus( backup_too_small = False if minimum_backup_size_error: - if restic_json["data_added"] < int(BytesConverter(str(minimum_backup_size_error).replace(" ", ""))): + if restic_json["data_added"] < int(BytesConverter(str(minimum_backup_size_error).replace(" ", "")).human_iec_bytes): backup_too_small = True good_backup = restic_result and not backup_too_small diff --git a/npbackup/restic_wrapper/__init__.py b/npbackup/restic_wrapper/__init__.py index 2ba0e6f..4cd5aa4 100644 --- a/npbackup/restic_wrapper/__init__.py +++ b/npbackup/restic_wrapper/__init__.py @@ -7,8 +7,8 @@ __author__ = "Orsiris de Jong" __copyright__ = "Copyright (C) 2022-2024 NetInvent" __license__ = "GPL-3.0-only" -__build__ = "2024010201" -__version__ = "2.0.0" +__build__ = "2024020501" +__version__ = "2.0.1" from typing import Tuple, List, Optional, Callable, Union @@ -22,6 +22,7 @@ import queue from functools import wraps from command_runner import command_runner +from ofunctions.misc import BytesConverter from npbackup.__debug__ import _DEBUG from npbackup.__env__ import FAST_COMMANDS_TIMEOUT, CHECK_INTERVAL @@ -369,9 +370,10 @@ def limit_upload(self): return self._limit_upload @limit_upload.setter - def limit_upload(self, value: int): + def limit_upload(self, value: str): try: - value = int(value) + # restic uses kbytes as upload speed unit + value = int(BytesConverter(value).kbytes) if value > 0: self._limit_upload = value except TypeError: @@ -382,9 +384,10 @@ def limit_download(self): return self._limit_download @limit_download.setter - def limit_download(self, value: int): + def limit_download(self, value: str): try: - value = int(value) + # restic uses kbytes as download speed unit + value = int(BytesConverter(value).kbytes) if value > 0: self._limit_download = value except TypeError: @@ -767,6 +770,7 @@ def backup( if exclude_caches: cmd += " --exclude-caches" if exclude_files_larger_than: + exclude_files_larger_than = BytesConverter(exclude_files_larger_than).bytes cmd += f" --exclude-files-larger-than {exclude_files_larger_than}" if one_file_system: cmd += " --one-file-system" From ae00a1db1ca36a6face8bcef556f9c824de9b12f Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Mon, 5 Feb 2024 19:36:25 +0100 Subject: [PATCH 231/328] Fix data added is in bytes --- npbackup/restic_metrics/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/npbackup/restic_metrics/__init__.py b/npbackup/restic_metrics/__init__.py index eb0c18e..2e3a0a0 100644 --- a/npbackup/restic_metrics/__init__.py +++ b/npbackup/restic_metrics/__init__.py @@ -225,7 +225,7 @@ def restic_json_to_prometheus( backup_too_small = False if minimum_backup_size_error: - if restic_json["data_added"] < int(BytesConverter(str(minimum_backup_size_error).replace(" ", "")).human_iec_bytes): + if restic_json["data_added"] < int(BytesConverter(str(minimum_backup_size_error).replace(" ", "")).bytes): backup_too_small = True good_backup = restic_result and not backup_too_small From eaf59fdd0e94ebee8ea3a5f169bc907da427eaed Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Mon, 5 Feb 2024 19:36:39 +0100 Subject: [PATCH 232/328] Update exclude_larger_than unit check --- npbackup/core/runner.py | 24 ++++++------------------ 1 file changed, 6 insertions(+), 18 deletions(-) diff --git a/npbackup/core/runner.py b/npbackup/core/runner.py index 43e125f..6b6190a 100644 --- a/npbackup/core/runner.py +++ b/npbackup/core/runner.py @@ -23,6 +23,7 @@ from command_runner import command_runner from ofunctions.threading import threaded from ofunctions.platform import os_arch +from ofunctions.misc import BytesConverter from npbackup.restic_metrics import restic_str_output_to_json, restic_json_to_prometheus, upload_metrics from npbackup.restic_wrapper import ResticRunner from npbackup.core.restic_source_binary import get_restic_internal_binary @@ -883,27 +884,14 @@ def backup(self, force: bool = False, read_from_stdin: bool = False, stdin_filen "backup_opts.exclude_files_larger_than" ) if exclude_files_larger_than: - if not exclude_files_larger_than[-1] in ( - "k", - "K", - "m", - "M", - "g", - "G", - "t", - "T", - ): - warning = f"Bogus suffix for exclude_files_larger_than value given: {exclude_files_larger_than}" + try: + BytesConverter(exclude_files_larger_than) + except ValueError: + warning = f"Bogus unit for exclude_files_larger_than value given: {exclude_files_larger_than}" self.write_logs( warning, level="warning") warnings.append(warning) exclude_files_larger_than = None - try: - float(exclude_files_larger_than[:-1]) - except (ValueError, TypeError): - warning = f"Cannot check whether excludes_files_larger_than is a float: {exclude_files_larger_than}" - self.write_logs(warning, level="warning") - warnings.append(warning) - exclude_files_larger_than = None + exclude_files_larger_than = None one_file_system = ( self.repo_config.g("backup_opts.one_file_system") From b0964dd15119caa6e764f20eb758d397a53d3fa4 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Mon, 5 Feb 2024 19:45:14 +0100 Subject: [PATCH 233/328] Fix backup size comparaison on backup failure --- npbackup/restic_metrics/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/npbackup/restic_metrics/__init__.py b/npbackup/restic_metrics/__init__.py index 2e3a0a0..69deea2 100644 --- a/npbackup/restic_metrics/__init__.py +++ b/npbackup/restic_metrics/__init__.py @@ -225,7 +225,8 @@ def restic_json_to_prometheus( backup_too_small = False if minimum_backup_size_error: - if restic_json["data_added"] < int(BytesConverter(str(minimum_backup_size_error).replace(" ", "")).bytes): + if not restic_json["data_added"] or \ + restic_json["data_added"] < int(BytesConverter(str(minimum_backup_size_error).replace(" ", "")).bytes): backup_too_small = True good_backup = restic_result and not backup_too_small From 8056ace75210e5033cdad28cfd2ac7cd7693c0e5 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Mon, 5 Feb 2024 19:45:32 +0100 Subject: [PATCH 234/328] Make sure the exclude files size is an int --- npbackup/restic_wrapper/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/npbackup/restic_wrapper/__init__.py b/npbackup/restic_wrapper/__init__.py index 4cd5aa4..f717ca8 100644 --- a/npbackup/restic_wrapper/__init__.py +++ b/npbackup/restic_wrapper/__init__.py @@ -770,8 +770,8 @@ def backup( if exclude_caches: cmd += " --exclude-caches" if exclude_files_larger_than: - exclude_files_larger_than = BytesConverter(exclude_files_larger_than).bytes - cmd += f" --exclude-files-larger-than {exclude_files_larger_than}" + exclude_files_larger_than = int(BytesConverter(exclude_files_larger_than).bytes) + cmd += f" --exclude-larger-than {exclude_files_larger_than}" if one_file_system: cmd += " --one-file-system" if use_fs_snapshot: From 56c21e3c8d4aa888919e0c57e8912fefc131a595 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Mon, 5 Feb 2024 19:48:07 +0100 Subject: [PATCH 235/328] WIP: UX refactor --- npbackup/gui/config.py | 37 +++++++++++++++++++++++++++++++++---- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/npbackup/gui/config.py b/npbackup/gui/config.py index 4bcc28e..f807027 100644 --- a/npbackup/gui/config.py +++ b/npbackup/gui/config.py @@ -197,6 +197,7 @@ def update_gui_values(key, value, inherited, object_type, unencrypted): nonlocal exclude_patterns_tree nonlocal pre_exec_commands_tree nonlocal post_exec_commands_tree + nonlocal prometheus_labels_tree nonlocal env_variables_tree nonlocal encrypted_env_variables_tree @@ -230,6 +231,7 @@ def update_gui_values(key, value, inherited, object_type, unencrypted): "backup_opts.exclude_files", "backup_opts.pre_exec_commands", "backup_opts.post_exec_commands", + "prometheus.additional_labels", "env.env_variables", "env.encrypted_env_variables" ): @@ -255,6 +257,7 @@ def update_gui_values(key, value, inherited, object_type, unencrypted): "backup_opts.post_exec_commands", "backup_opts.exclude_files", "backup_opts.exclude_patterns", + "prometheus.additional_labels", "env.env_variables", "env.encrypted_env_variables" @@ -269,6 +272,8 @@ def update_gui_values(key, value, inherited, object_type, unencrypted): tree = exclude_files_tree if key == "backup_opts.exclude_patterns": tree = exclude_patterns_tree + if key == "prometheus.additional_labels": + tree = prometheus_labels_tree if key == "env.env_variables": tree = env_variables_tree if key == "env.encrypted_env_variables": @@ -356,6 +361,9 @@ def update_object_gui(object_name=None, unencrypted=False): nonlocal tags_tree nonlocal exclude_files_tree nonlocal exclude_patterns_tree + nonlocal pre_exec_commands_tree + nonlocal post_exec_commands_tree + nonlocal prometheus_labels_tree nonlocal env_variables_tree nonlocal encrypted_env_variables_tree @@ -953,11 +961,25 @@ def object_layout() -> List[list]: sg.Input(key="prometheus.group", size=(50, 1)), ], [ - sg.Text( - f"{_t('config_gui.additional_labels')}\n({_t('config_gui.one_per_line')}\n{_t('config_gui.format_equals')})", - size=(40, 3), + sg.Column( + [ + [ + sg.Button("+", key="--ADD-PROMETHEUS-LABEL--", size=(3, 1)) + ], + [ + sg.Button("-", key="--REMOVE-PROMETHEUS-LABEL--", size=(3, 1)) + ] + ], pad=0, ), - sg.Multiline(key="prometheus.additional_labels", size=(48, 3)), + sg.Column( + [ + [ + sg.Tree(sg.TreeData(), key="prometheus.additional_labels", headings=[], + col0_heading=_t('config_gui.additional_labels'), + num_rows=4, expand_x=True, expand_y=True) + ] + ], pad=0, expand_x=True + ) ], ] @@ -1272,6 +1294,7 @@ def config_layout() -> List[list]: exclude_files_tree = sg.TreeData() pre_exec_commands_tree = sg.TreeData() post_exec_commands_tree = sg.TreeData() + prometheus_labels_tree = sg.TreeData() env_variables_tree = sg.TreeData() encrypted_env_variables_tree = sg.TreeData() @@ -1325,6 +1348,7 @@ def config_layout() -> List[list]: "--ADD-EXCLUDE-PATTERN--", "--ADD-PRE-EXEC-COMMAND--", "--ADD-POST-EXEC-COMMAND--", + "--ADD-PROMETHEUS-LABEL--", "--ADD-ENV-VARIABLE--", "--ADD-ENCRYPTED-ENV-VARIABLE--", "--REMOVE-BACKUP-PATHS", @@ -1333,6 +1357,7 @@ def config_layout() -> List[list]: "--REMOVE-EXCLUDE-FILE--", "--REMOVE-PRE-EXEC-COMMAND--", "--REMOVE-POST-EXEC-COMMAND--", + "--REMOVE-PROMETHEUS-LABEL--", "--REMOVE-ENV-VARIABLE--", "--REMOVE-ENCRYPTED-ENV-VARIABLE--" ): @@ -1358,6 +1383,10 @@ def config_layout() -> List[list]: popup_text = _t("config_gui.enter_command") tree = post_exec_commands_tree option_key = "backup_opts.post_exec_commands" + elif "PROMETHEUS-LABEL" in event: + popup_text = _t("config_gui.enter_label") + tree = prometheus_labels_tree + option_key = "prometheus.additional_labels" elif "ENCRYPTED-ENV-VARIABLE" in event: tree = encrypted_env_variables_tree option_key = "env.encrypted_env_variables" From 712bc6396d3c04991400178412e438bdda550d86 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Mon, 5 Feb 2024 19:48:19 +0100 Subject: [PATCH 236/328] Update & fix translations --- npbackup/translations/config_gui.en.yml | 3 ++- npbackup/translations/config_gui.fr.yml | 7 ++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/npbackup/translations/config_gui.en.yml b/npbackup/translations/config_gui.en.yml index fbd30ee..fd2c4e6 100644 --- a/npbackup/translations/config_gui.en.yml +++ b/npbackup/translations/config_gui.en.yml @@ -146,4 +146,5 @@ en: enter_pattern: Enter pattern enter_command: Enter command enter_var_name: Enter variable name - enter_var_value: Enter variable value \ No newline at end of file + enter_var_value: Enter variable value + enter_labvel: Enter label \ No newline at end of file diff --git a/npbackup/translations/config_gui.fr.yml b/npbackup/translations/config_gui.fr.yml index 8baa917..5d8eb73 100644 --- a/npbackup/translations/config_gui.fr.yml +++ b/npbackup/translations/config_gui.fr.yml @@ -14,7 +14,7 @@ fr: windows_only: Windows seulement exclude_patterns: Patterns d'exclusion exclude_files: Fichiers contenant des patterns d'exclusions - exclude_files_larger_than: Exclude les fichiers plus grands que + exclude_files_larger_than: Exclure les fichiers plus grands que excludes_case_ignore: Ignorer la casse des exclusions patterns/fichiers windows_always: toujours actif pour Windows exclude_cache_dirs: Exclure dossiers cache @@ -49,7 +49,7 @@ fr: metrics_username: Nom d'utilisateur métriques HTTP metrics_password: Mot de passe métriques HTTP instance: Instance Prometheus - additional_labels: Labels supplémentaires + additional_labels: Etiquettes supplémentaires no_config_available: Aucun fichier de configuration trouvé. Merci d'utiliser --config-file "chemin" pour spécifier un fichier, ou copier un fichier de configuration a côté du binaire NPBackup. create_new_config: Souhaitez-vous créer une nouvelle configuration ? @@ -148,4 +148,5 @@ fr: enter_pattern: Entrer pattern enter_command: Entrer command enter_var_name: Entrer le nom de la variable - enter_var_value: Entrer sa valeur \ No newline at end of file + enter_var_value: Entrer sa valeur + enter_label: Entrer étiquette \ No newline at end of file From 26c084bdfe431ec5a10011d099ebdd076ad4eea0 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Mon, 5 Feb 2024 22:55:29 +0100 Subject: [PATCH 237/328] GUI: Improve tags display --- npbackup/gui/__main__.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/npbackup/gui/__main__.py b/npbackup/gui/__main__.py index 8fdca6e..db60aa2 100644 --- a/npbackup/gui/__main__.py +++ b/npbackup/gui/__main__.py @@ -551,7 +551,10 @@ def get_gui_data(repo_config: dict) -> Tuple[bool, List[str]]: snapshot_hostname = snapshot["hostname"] snapshot_id = snapshot["short_id"] try: - snapshot_tags = " [TAGS: {}]".format(snapshot["tags"]) + tags = snapshot["tags"] + if isinstance(tags, list): + tags = ",".join(tags) + snapshot_tags = tags except KeyError: snapshot_tags = "" snapshot_list.append( From 83d452d426c8834fd9e0e90f0c1f6687e1d54d90 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Sat, 10 Feb 2024 11:21:41 +0100 Subject: [PATCH 238/328] Add a dict for inheritance of merged lists --- npbackup/configuration.py | 39 +++++++++++++++++++++++++++++++++++---- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/npbackup/configuration.py b/npbackup/configuration.py index 1e27cca..a62e9d0 100644 --- a/npbackup/configuration.py +++ b/npbackup/configuration.py @@ -466,6 +466,7 @@ def inherit_group_settings( """ iter over group settings, update repo_config, and produce an identical version of repo_config called config_inheritance, where every value is replaced with a boolean which states inheritance status + When lists are encountered, merge the lists, but product a dict in config_inheritance with list values: inheritance_bool """ _repo_config = deepcopy(repo_config) _group_config = deepcopy(group_config) @@ -489,17 +490,27 @@ def _inherit_group_settings( _repo_config.s(key, __repo_config) _config_inheritance.s(key, __config_inheritance) elif isinstance(value, list): - # TODO: Lists containing dicts won't be updated in repo_config here - # we need to have - # for elt in list: - # recurse into elt if elt is dict if isinstance(_repo_config.g(key), list): + merged_lists = _repo_config.g(key) + _group_config.g(key) # Case where repo config already contains non list info but group config has list elif _repo_config.g(key): merged_lists = [_repo_config.g(key)] + _group_config.g(key) else: merged_lists = _group_config.g(key) + + # Special case when merged lists contain multiple dicts, we'll need to merge dicts + # unless lists have other object types than dicts + merged_items_dict = {} + can_replace_merged_list = True + for list_elt in merged_lists: + if isinstance(list_elt, dict): + merged_items_dict.update(list_elt) + else: + can_replace_merged_list = False + if can_replace_merged_list: + merged_lists = merged_items_dict + _repo_config.s(key, merged_lists) _config_inheritance.s(key, {}) for v in merged_lists: @@ -518,7 +529,27 @@ def _inherit_group_settings( # Case where repo_config contains list but group info has single str elif isinstance(_repo_config.g(key), list) and value: merged_lists = _repo_config.g(key) + [value] + + # Special case when merged lists contain multiple dicts, we'll need to merge dicts + # unless lists have other object types than dicts + merged_items_dict = {} + can_replace_merged_list = True + for list_elt in merged_lists: + if isinstance(list_elt, dict): + merged_items_dict.update(list_elt) + else: + can_replace_merged_list = False + if can_replace_merged_list: + merged_lists = merged_items_dict + _repo_config.s(key, merged_lists) + + _config_inheritance.s(key, {}) + for v in merged_lists: + if v in _group_config.g(key): + _config_inheritance.s(f"{key}.{v}", True) + else: + _config_inheritance.s(f"{key}.{v}", False) else: # In other cases, just keep repo confg _config_inheritance.s(key, False) From dd1422bd21d4ff4d01da05025428317bcb11e06a Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Sat, 10 Feb 2024 11:37:17 +0100 Subject: [PATCH 239/328] Make sure every list entry is a list object when reading bad formatted yaml files --- npbackup/configuration.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/npbackup/configuration.py b/npbackup/configuration.py index a62e9d0..a34957e 100644 --- a/npbackup/configuration.py +++ b/npbackup/configuration.py @@ -638,11 +638,22 @@ def load_config(config_file: Path) -> Optional[dict]: # Make sure we expand every key that should be a list into a list # We'll use iter_over_keys instead of replace_in_iterable to avoid chaning list contents by lists + # This basically allows "bad" formatted (ie manually written yaml) to be processed correctly + # without having to deal with various errors def _make_list(key: str, value: Union[str, int, float, dict, list]) -> Any: - if key in ("paths", "tags", "env_variables", "encrypted_env_variables"): + if key in ( + "paths", + "tags", + "exclude_patterns", + "exclude_files", + "pre_exec_commands", + "post_exec_commands", + "additional_labels" + "env_variables", + "encrypted_env_variables" + ): if not isinstance(value, list): value = [value] - pass return value iter_over_keys(full_config, _make_list) From 57cb8a5d9294bcaceef0fb5d5fd24fcb64088223 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Sat, 10 Feb 2024 12:48:50 +0100 Subject: [PATCH 240/328] WIP Gui refactor --- npbackup/gui/config.py | 189 +++++++++++++++++++++++------------------ 1 file changed, 105 insertions(+), 84 deletions(-) diff --git a/npbackup/gui/config.py b/npbackup/gui/config.py index f807027..52470db 100644 --- a/npbackup/gui/config.py +++ b/npbackup/gui/config.py @@ -206,6 +206,7 @@ def update_gui_values(key, value, inherited, object_type, unencrypted): window[key].Disabled = True else: window[key].Disabled = False + try: # Don't bother to update repo name # Also permissions / manager_password are in a separate gui @@ -218,73 +219,72 @@ def update_gui_values(key, value, inherited, object_type, unencrypted): try: if value is None or value == "": return - if not str(value).startswith(configuration.ID_STRING): + if isinstance(value, dict): + for k in value.keys(): + value[k] = ENCRYPTED_DATA_PLACEHOLDER + elif not str(value).startswith(configuration.ID_STRING): value = ENCRYPTED_DATA_PLACEHOLDER except (KeyError, TypeError): pass # Update tree objects - if key in ( - "backup_opts.paths", + if key == "backup_opts.paths": + for val in value: + if pathlib.Path(val).is_dir(): + if inherited[val]: + icon = INHERITED_FOLDER_ICON + else: + icon = FOLDER_ICON + else: + if inherited[val]: + icon = INHERITED_FILE_ICON + else: + icon = FILE_ICON + backup_paths_tree.insert('', val, val, val, icon=icon) + window['backup_opts.paths'].update(values=backup_paths_tree) + return + elif key in ( "backup_opts.tags", - "backup_opts.exclude_patterns", - "backup_opts.exclude_files", "backup_opts.pre_exec_commands", "backup_opts.post_exec_commands", + "backup_opts.exclude_files", + "backup_opts.exclude_patterns", "prometheus.additional_labels", "env.env_variables", - "env.encrypted_env_variables" + "env.encrypted_env_variables" ): - if not isinstance(value, list): - value = [value] - if key == "backup_opts.paths": - for val in value: - if pathlib.Path(val).is_dir(): - if inherited[val]: - icon = INHERITED_FOLDER_ICON - else: - icon = FOLDER_ICON + if key == "backup_opts.tags": + tree = tags_tree + if key == "backup_opts.pre_exec_commands": + tree = pre_exec_commands_tree + if key == "backup_opts.post_exec_commands": + tree = post_exec_commands_tree + if key == "backup_opts.exclude_files": + tree = exclude_files_tree + if key == "backup_opts.exclude_patterns": + tree = exclude_patterns_tree + if key == "prometheus.additional_labels": + tree = prometheus_labels_tree + if key == "env.env_variables": + tree = env_variables_tree + if key == "env.encrypted_env_variables": + tree = encrypted_env_variables_tree + + if isinstance(value, dict): + for var_name, var_value in value.items(): + if inherited[var_name]: + icon = INHERITED_TREE_ICON else: - if inherited[val]: - icon = INHERITED_FILE_ICON - else: - icon = FILE_ICON - backup_paths_tree.insert('', val, val, val, icon=icon) - window['backup_opts.paths'].update(values=backup_paths_tree) - elif key in ( - "backup_opts.tags", - "backup_opts.pre_exec_commands", - "backup_opts.post_exec_commands", - "backup_opts.exclude_files", - "backup_opts.exclude_patterns", - "prometheus.additional_labels", - "env.env_variables", - "env.encrypted_env_variables" - - ): - if key == "backup_opts.tags": - tree = tags_tree - if key == "backup_opts.pre_exec_commands": - tree = pre_exec_commands_tree - if key == "backup_opts.post_exec_commands": - tree = post_exec_commands_tree - if key == "backup_opts.exclude_files": - tree = exclude_files_tree - if key == "backup_opts.exclude_patterns": - tree = exclude_patterns_tree - if key == "prometheus.additional_labels": - tree = prometheus_labels_tree - if key == "env.env_variables": - tree = env_variables_tree - if key == "env.encrypted_env_variables": - tree = encrypted_env_variables_tree + icon = TREE_ICON + tree.insert('', var_name, var_name, var_value, icon=icon) + else: for val in value: if inherited[val]: icon = INHERITED_TREE_ICON else: icon = TREE_ICON tree.insert('', val, val, val, icon=icon) - window[key].Update(values=tree) + window[key].Update(values=tree) return # Update units into separate value and unit combobox @@ -299,7 +299,7 @@ def update_gui_values(key, value, inherited, object_type, unencrypted): if isinstance(value, list): value = "\n".join(value) - if key in combo_boxes.keys(): + if key in combo_boxes.keys() and value: window[key].Update(value=combo_boxes[key][value]) else: window[key].Update(value=value) @@ -332,8 +332,9 @@ def iter_over_config( base_object = object_config def _iter_over_config(object_config: dict, root_key=""): - # Special case where env is a dict but we should pass it directly as it to update_gui_values - if isinstance(object_config, dict): + # We need to handle a special case here where env variables are dicts but shouldn't itered over here + # but handled in in update_gui_values + if isinstance(object_config, dict) and root_key not in ('env.env_variables', 'env.encrypted_env_variables'): for key in object_config.keys(): if root_key: _iter_over_config( @@ -382,6 +383,14 @@ def update_object_gui(object_name=None, unencrypted=False): # We also need to clear tree objects backup_paths_tree = sg.TreeData() + tags_tree = sg.TreeData() + exclude_patterns_tree = sg.TreeData() + exclude_files_tree = sg.TreeData() + pre_exec_commands_tree = sg.TreeData() + post_exec_commands_tree = sg.TreeData() + prometheus_labels_tree = sg.TreeData() + env_variables_tree = sg.TreeData() + encrypted_env_variables_tree = sg.TreeData() object_type, object_name = get_object_from_combo(object_name) @@ -423,7 +432,6 @@ def update_config_dict(full_config, values): Update full_config with keys from GUI keys should always have form section.name or section.subsection.name """ - return object_type, object_name = get_object_from_combo(values["-OBJECT-SELECT-"]) if object_type == "repo": object_group = full_config.g(f"repos.{object_name}.repo_group") @@ -431,6 +439,7 @@ def update_config_dict(full_config, values): object_group = None for key, value in values.items(): # Don't update placeholders ;) + # TODO exclude encrypted env vars if value == ENCRYPTED_DATA_PLACEHOLDER: continue if not isinstance(key, str) or (isinstance(key, str) and not "." in key): @@ -439,25 +448,21 @@ def update_config_dict(full_config, values): continue # Handle combo boxes first to transform translation into key - if key in combo_boxes: + if key in combo_boxes.keys(): value = get_key_from_value(combo_boxes[key], value) # check whether we need to split into list elif not isinstance(value, bool) and not isinstance(value, list): - result = value.split("\n") - if len(result) > 1: - value = result + # Try to convert ints and floats before committing + if "." in value: + try: + value = float(value) + except ValueError: + pass else: - # Try to convert ints and floats before committing - if "." in value: - try: - value = float(value) - except ValueError: - pass - else: - try: - value = int(value) - except ValueError: - pass + try: + value = int(value) + except ValueError: + pass active_object_key = f"{object_type}s.{object_name}.{key}" current_value = full_config.g(active_object_key) @@ -469,9 +474,11 @@ def update_config_dict(full_config, values): inheritance_key = f"groups.{object_group}.{key}" # If object is a list, check which values are inherited from group and remove them if isinstance(value, list): # WIP # TODO - for entry in full_config.g(inheritance_key): - if entry in value: - value.remove(entry) + inheritance_list = full_config.g(inheritance_key) + if inheritance_list: + for entry in inheritance_list: + if entry in value: + value.remove(entry) # check if value is inherited from group if full_config.g(inheritance_key) == value: continue @@ -596,9 +603,9 @@ def object_layout() -> List[list]: ], [ sg.Input(visible=False, key="--ADD-PATHS-FILE--", enable_events=True), - sg.FilesBrowse(_t("generic.add_files"), target="--ADD-PATHS-FILE--"), + sg.FilesBrowse(_t("generic.add_files"), target="--ADD-BACKUP-PATHS-FILE--"), sg.Input(visible=False, key="--ADD-PATHS-FOLDER--", enable_events=True), - sg.FolderBrowse(_t("generic.add_folder"), target="--ADD-PATHS-FOLDER--"), + sg.FolderBrowse(_t("generic.add_folder"), target="--ADD-BACKUP-PATHS-FOLDER--"), sg.Button(_t("generic.remove_selected"), key="--REMOVE-BACKUP-PATHS--") ], [ @@ -701,10 +708,11 @@ def object_layout() -> List[list]: sg.Column( [ [ - sg.Button("+", key="--ADD-EXCLUDE-FILES--", size=(3, 1)) + sg.Input(visible=False, key="--ADD-EXCLUDE-FILE--", enable_events=True), + sg.FilesBrowse('+', target="--ADD-EXCLUDE-FILE--", size=(3, 1)), ], [ - sg.Button("-", key="--REMOVE-EXCLUDE-FILES--", size=(3, 1)) + sg.Button("-", key="--REMOVE-EXCLUDE-FILE--", size=(3, 1)) ] ], pad=0, ), @@ -1328,21 +1336,33 @@ def config_layout() -> List[list]: if ask_manager_password(manager_password): full_config = set_permissions(full_config, values["-OBJECT-SELECT-"]) continue - if event in ("--ADD-PATHS-FILE--", '--ADD-PATHS-FOLDER--'): - if event == "--ADD-PATHS-FILE--": - node = values["--ADD-PATHS-FILE--"] + if event in ( + "--ADD-BACKUP-PATHS-FILE--", + '--ADD-BACKUP-PATHS-FOLDER--', + '--ADD-EXCLUDE-FILE--', + ): + if event in ("--ADD-BACKUP-PATHS-FILE--", '--ADD-EXCLUDE-FILE--'): + if event == '--ADD-BACKUP-PATHS-FILE--': + key = 'backup_opts.paths' + tree = backup_paths_tree + if event == '--ADD-EXCLUDE-FILE--': + key = 'backup_opts.exclude_files' + tree = exclude_files_tree + node = values[event] if object_type == "group": icon = INHERITED_FILE_ICON else: icon = FILE_ICON - elif event == '--ADD-PATHS-FOLDER--': - node = values['--ADD-PATHS-FOLDER--'] + elif event == '--ADD-BACKUP-PATHS-FOLDER--': + key = 'backup_opts.paths' + tree = backup_paths_tree + node = values[event] if object_type == "group": icon = INHERITED_FOLDER_ICON else: icon = FOLDER_ICON - backup_paths_tree.insert('', node, node, node, icon=icon) - window['backup_opts.paths'].update(values=backup_paths_tree) + tree.insert('', node, node, node, icon=icon) + window[key].update(values=tree) if event in ( "--ADD-TAG--", "--ADD-EXCLUDE-PATTERN--", @@ -1351,7 +1371,7 @@ def config_layout() -> List[list]: "--ADD-PROMETHEUS-LABEL--", "--ADD-ENV-VARIABLE--", "--ADD-ENCRYPTED-ENV-VARIABLE--", - "--REMOVE-BACKUP-PATHS", + "--REMOVE-BACKUP-PATHS--", "--REMOVE-TAG--", "--REMOVE-EXCLUDE-PATTERN--", "--REMOVE-EXCLUDE-FILE--", @@ -1368,11 +1388,12 @@ def config_layout() -> List[list]: popup_text = _t("config_gui.enter_tag") tree = tags_tree option_key = "backup_opts.tags" - elif "EXCLUDE_PATTERN" in event: + elif "EXCLUDE-PATTERN" in event: popup_text = _t("config_gui.enter_pattern") tree = exclude_patterns_tree option_key = "backup_opts.exclude_patterns" elif "EXCLUDE-FILE" in event: + popup_text = None tree = exclude_files_tree option_key = "backup_opts.exclude_files" elif "PRE-EXEC-COMMANDS" in event: From cd957099859589b70ff8d5ccad9cbc791a61e096 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Sat, 10 Feb 2024 12:49:03 +0100 Subject: [PATCH 241/328] Temp fix for sg.TreeData not allowing spaces --- npbackup/translations/config_gui.en.yml | 2 +- npbackup/translations/config_gui.fr.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/npbackup/translations/config_gui.en.yml b/npbackup/translations/config_gui.en.yml index fd2c4e6..5a73f42 100644 --- a/npbackup/translations/config_gui.en.yml +++ b/npbackup/translations/config_gui.en.yml @@ -5,7 +5,7 @@ en: exclusions: Exclusions pre_post: Pre/Post exec - encrypted_data: Encrypted Data + encrypted_data: Encrypted_Data compression: Compression backup_paths: Backup paths use_fs_snapshot: Use VSS snapshots diff --git a/npbackup/translations/config_gui.fr.yml b/npbackup/translations/config_gui.fr.yml index 5d8eb73..17a17c9 100644 --- a/npbackup/translations/config_gui.fr.yml +++ b/npbackup/translations/config_gui.fr.yml @@ -6,7 +6,7 @@ fr: pre_post: Pré/Post exec - encrypted_data: Donnée Chiffrée + encrypted_data: Donnée_Chiffrée compression: Compression backup_paths: Chemins à sauvegarder use_fs_snapshot: Utiliser les instantanés VSS From e594ac2ebdcc105e51c8e8fabdf19d389cb35fbf Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Sat, 10 Feb 2024 12:58:04 +0100 Subject: [PATCH 242/328] Remove alpha channel --- npbackup/gui/__main__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/npbackup/gui/__main__.py b/npbackup/gui/__main__.py index db60aa2..b3c3ba2 100644 --- a/npbackup/gui/__main__.py +++ b/npbackup/gui/__main__.py @@ -773,7 +773,7 @@ def get_config(config_file: str = None, window: sg.Window = None): no_titlebar=False, grab_anywhere=False, keep_on_top=False, - alpha_channel=0.9, + alpha_channel=1.0, default_button_element_size=(16, 1), right_click_menu=right_click_menu, finalize=True, From 5fb2c5ccf5b14f9d73e61a47f2eb678a481ee2b4 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Sat, 10 Feb 2024 13:16:42 +0100 Subject: [PATCH 243/328] Fix inheritance check in dicts --- npbackup/configuration.py | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/npbackup/configuration.py b/npbackup/configuration.py index a34957e..4116a56 100644 --- a/npbackup/configuration.py +++ b/npbackup/configuration.py @@ -514,10 +514,15 @@ def _inherit_group_settings( _repo_config.s(key, merged_lists) _config_inheritance.s(key, {}) for v in merged_lists: - if v in _group_config.g(key): - _config_inheritance.s(f"{key}.{v}", True) - else: - _config_inheritance.s(f"{key}.{v}", False) + _grp_conf = _group_config.g(key) + # Make sure we test inheritance against possible lists + if not isinstance(_grp_conf, list): + _grp_conf = [_grp_conf] + for _grp_conf_item in _grp_conf: + if v in _grp_conf_item: + _config_inheritance.s(f"{key}.{v}", True) + else: + _config_inheritance.s(f"{key}.{v}", False) else: # repo_config may or may not already contain data if not _repo_config: @@ -546,10 +551,15 @@ def _inherit_group_settings( _config_inheritance.s(key, {}) for v in merged_lists: - if v in _group_config.g(key): - _config_inheritance.s(f"{key}.{v}", True) - else: - _config_inheritance.s(f"{key}.{v}", False) + _grp_conf = _group_config.g(key) + # Make sure we test inheritance against possible lists + if not isinstance(_grp_conf, list): + _grp_conf = [_grp_conf] + for _grp_conf_item in _grp_conf: + if v in _grp_conf_item: + _config_inheritance.s(f"{key}.{v}", True) + else: + _config_inheritance.s(f"{key}.{v}", False) else: # In other cases, just keep repo confg _config_inheritance.s(key, False) From 1ac5d4065dc60161fd46e72d4e61168db490eee3 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Sat, 10 Feb 2024 13:16:56 +0100 Subject: [PATCH 244/328] Don't check on inheritance in groups --- npbackup/gui/config.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/npbackup/gui/config.py b/npbackup/gui/config.py index 52470db..b59763f 100644 --- a/npbackup/gui/config.py +++ b/npbackup/gui/config.py @@ -272,14 +272,14 @@ def update_gui_values(key, value, inherited, object_type, unencrypted): if isinstance(value, dict): for var_name, var_value in value.items(): - if inherited[var_name]: + if object_type != "group" and inherited[var_name]: icon = INHERITED_TREE_ICON else: icon = TREE_ICON tree.insert('', var_name, var_name, var_value, icon=icon) else: for val in value: - if inherited[val]: + if object_type != "group" and inherited[val]: icon = INHERITED_TREE_ICON else: icon = TREE_ICON @@ -1396,11 +1396,11 @@ def config_layout() -> List[list]: popup_text = None tree = exclude_files_tree option_key = "backup_opts.exclude_files" - elif "PRE-EXEC-COMMANDS" in event: + elif "PRE-EXEC-COMMAND" in event: popup_text = _t("config_gui.enter_command") tree = pre_exec_commands_tree option_key = "backup_opts.pre_exec_commands" - elif "POST-EXEC-COMMANDS" in event: + elif "POST-EXEC-COMMAND" in event: popup_text = _t("config_gui.enter_command") tree = post_exec_commands_tree option_key = "backup_opts.post_exec_commands" From 6cb3b67d93c31c234622db91b7cf4111c575c34f Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Sat, 10 Feb 2024 17:37:26 +0100 Subject: [PATCH 245/328] Avoid checking for inheritance in group settings --- npbackup/gui/config.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/npbackup/gui/config.py b/npbackup/gui/config.py index b59763f..3146a8e 100644 --- a/npbackup/gui/config.py +++ b/npbackup/gui/config.py @@ -231,12 +231,12 @@ def update_gui_values(key, value, inherited, object_type, unencrypted): if key == "backup_opts.paths": for val in value: if pathlib.Path(val).is_dir(): - if inherited[val]: + if object_type != "group" and inherited[val]: icon = INHERITED_FOLDER_ICON else: icon = FOLDER_ICON else: - if inherited[val]: + if object_type != "group" and inherited[val]: icon = INHERITED_FILE_ICON else: icon = FILE_ICON From 6fbb476284da76a73780346a38422364004d35ae Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Sat, 10 Feb 2024 17:51:20 +0100 Subject: [PATCH 246/328] Fix inheritance tree update --- npbackup/gui/config.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/npbackup/gui/config.py b/npbackup/gui/config.py index 3146a8e..f1f6a3c 100644 --- a/npbackup/gui/config.py +++ b/npbackup/gui/config.py @@ -279,11 +279,19 @@ def update_gui_values(key, value, inherited, object_type, unencrypted): tree.insert('', var_name, var_name, var_value, icon=icon) else: for val in value: - if object_type != "group" and inherited[val]: - icon = INHERITED_TREE_ICON + if isinstance(val, dict): + for var_name, var_value in val.items(): + if object_type != "group" and inherited[var_name]: + icon = INHERITED_TREE_ICON + else: + icon = TREE_ICON + tree.insert('', var_name, var_name, var_value, icon=icon) else: - icon = TREE_ICON - tree.insert('', val, val, val, icon=icon) + if object_type != "group" and inherited[val]: + icon = INHERITED_TREE_ICON + else: + icon = TREE_ICON + tree.insert('', val, val, val, icon=icon) window[key].Update(values=tree) return From a803097c69b54876c08d9a49b415ff4a8e238314 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Sat, 10 Feb 2024 17:54:06 +0100 Subject: [PATCH 247/328] Update default config schema --- npbackup/configuration.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/npbackup/configuration.py b/npbackup/configuration.py index 4116a56..1c5256c 100644 --- a/npbackup/configuration.py +++ b/npbackup/configuration.py @@ -135,8 +135,8 @@ def d(self, path, sep="."): "repo_opts": {}, "prometheus": {}, "env": { - "env_variables": [], - "encrypted_env_variables": [], + "env_variables": {}, + "encrypted_env_variables": {}, }, }, }, @@ -201,7 +201,10 @@ def d(self, path, sep="."): "backup_job": "${MACHINE_ID}", "group": "${MACHINE_GROUP}", }, - "env": {"env_variables": [], "encrypted_env_variables": []}, + "env": { + "env_variables": {}, + "encrypted_env_variables": {} + }, }, "identity": { "machine_id": "${HOSTNAME}__${RANDOM}[4]", From 327fba178af224d615133bc546ad93618154eef5 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Fri, 23 Feb 2024 16:49:28 +0100 Subject: [PATCH 248/328] Add kvm-qemu backup script --- examples/kvm-qemu/cube-backup.sh | 230 +++++++++++++++++++++++ examples/kvm-qemu/npbackup.cube.template | 85 +++++++++ 2 files changed, 315 insertions(+) create mode 100644 examples/kvm-qemu/cube-backup.sh create mode 100644 examples/kvm-qemu/npbackup.cube.template diff --git a/examples/kvm-qemu/cube-backup.sh b/examples/kvm-qemu/cube-backup.sh new file mode 100644 index 0000000..c4c11d5 --- /dev/null +++ b/examples/kvm-qemu/cube-backup.sh @@ -0,0 +1,230 @@ +#!/usr/bin/env bash + +# Script ver 2023112901 + +#TODO: support modding XML file from offline domains to remove snapshot and replace by backing file after qemu-img commit + +# Expects repository version 2 to already exist + +# List of machines + +# All active machines by default, adding --all includes inactive machines +VMS=$(virsh list --name --all) +# Optional machine selection +#VMS=(some.vm.local some.other.vm.local) + +LOG_FILE="/var/log/cube_npv1.log" +ROOT_DIR="/opt/cube" +BACKUP_IDENTIFIER="CUBE-BACKUP-NP.$(date +"%Y%m%dT%H%M%S" --utc)" +BACKUP_FILE_LIST="${ROOT_DIR}/npbackup_cube_file.lst" +NPBACKUP_CONF_FILE_TEMPLATE="${ROOT_DIR}/npbackup.cube.template" +NPBACKUP_CONF_FILE="${ROOT_DIR}/npbackup-cube.conf" + +function log { + local line="${1}" + + echo "${line}" >> "${LOG_FILE}" + echo "${line}" +} + +function ArrayContains () { + local needle="${1}" + local haystack="${2}" + local e + + if [ "$needle" != "" ] && [ "$haystack" != "" ]; then + for e in "${@:2}"; do + if [ "$e" == "$needle" ]; then + echo 1 + return + fi + done + fi + echo 0 + return +} + +function create_snapshot { + local vm="${1}" + local backup_identifier="${2}" + + # Ignore SC2068 here + # Add VM xml description from virsh + ## At least use a umask + virsh dumpxml --security-info $vm > "${ROOT_DIR}/$vm.xml" + echo "${ROOT_DIR}/$vm.xml" >> "$BACKUP_FILE_LIST" + + # Get current disk paths + for disk_path in $(virsh domblklist $vm --details | grep file | grep disk | awk '{print $4}'); do + if [ -f "${disk_path}" ]; then + # Add current disk path and all necessary backing files for current disk to backup file list + echo "${disk_path}" >> "$BACKUP_FILE_LIST" + qemu-img info --backing-chain -U "$disk_path" | grep "backing file:" | awk '{print $3}' >> "$BACKUP_FILE_LIST" + log "Current disk path: $disk_path" + else + log "$vm has a non existent disk path: $disk_path. Cannot backup this disk" + # Let's still include this file in the backup list so we are sure backup will be marked as failed + echo "${disk_path}" >> "$BACKUP_FILE_LIST" + fi + done + log "Creating snapshot for $vm" + virsh snapshot-create-as $vm --name "${backup_identifier}" --description "${backup_identifier}" --atomic --quiesce --disk-only >> "$LOG_FILE" 2>&1 + if [ $? -ne 0 ]; then + log "Failed to create snapshot for $vm with quiesce option. Trying without quiesce." + virsh snapshot-create-as $vm --name "${backup_identifier}" --description "${backup_identifier}.noquiesce" --atomic --disk-only >> "$LOG_FILE" 2>&1 + if [ $? -ne 0 ]; then + log "Failed to create snapshot for $vm without quiesce option. Cannot backup that file." + echo "$vm.SNAPSHOT_FAILED" >> "$BACKUP_FILE_LIST" + else + CURRENT_VM_SNAPSHOT="${vm}" + fi + else + CURRENT_VM_SNAPSHOT="${vm}" + fi + # Get list of snapshot files to delete "make sure we only use CUBE backup files here, since they are to be deleted later + for disk_path in $(virsh domblklist $vm --details | grep file | grep disk |grep "${backup_identifier}" | awk '{print $4}'); do + SNAPSHOTS_PATHS+=($disk_path) + log "Snapshotted disk path: $disk_path" + done +} + +function get_tenant { + # Optional extract a tenant name from a VM name. example. myvm.tenant.local returns tenant + local vm="${1}" + + # $(NF-1) means last column -1 + tenant=$(echo ${vm} |awk -F'.' '{print $(NF-1)}') + # Special case for me + if [ ${tenant} == "npf" ]; then + tenant="netperfect" + fi + # return this + if [ "${tenant}" != "" ] then + echo "${tenant}" + else + echo "unknown_tenant" + fi +} + +function run_backup { + local tenant="${1}" + local vm="${2}" + + log "Running backup for:" >> "$LOG_FILE" 2>&1 + cat "$BACKUP_FILE_LIST" >> "$LOG_FILE" 2>&1 + log "Running backup as ${tenant} for:" + cat "$BACKUP_FILE_LIST" + # Run backups + #/usr/local/bin/restic backup --compression=auto --files-from-verbatim "${BACKUP_FILE_LIST}" --tag "${backup_identifier}" -o rest.connections=15 -v >> "$LOG_FILE" 2>&1 + # Prepare config file + rm -f "${NPBACKUP_CONF_FILE}" + cp "${NPBACKUP_CONF_FILE_TEMPLATE}" "${NPBACKUP_CONF_FILE}" + sed -i "s%### TENANT ###%${tenant}%g" "${NPBACKUP_CONF_FILE}" + sed -i "s%### SOURCE ###%${BACKUP_FILE_LIST}%g" "${NPBACKUP_CONF_FILE}" + sed -i "s%### VM ###%${vm}%g" "${NPBACKUP_CONF_FILE}" + + /usr/local/bin/npbackup --config-file "${NPBACKUP_CONF_FILE}" --backup --force >> "$LOG_FILE" 2>&1 + if [ $? -ne 0 ]; then + log "Backup failure" + else + log "Backup success" + fi +} + +function remove_snapshot { + local vm="${1}" + local backup_identifier="${2}" + + can_delete_metadata=true + for disk_name in $(virsh domblklist $vm --details | grep file | grep disk | grep "${backup_identifier}" | awk '{print $3}'); do + disk_path=$(virsh domblklist $vm --details | grep file | grep disk | grep "${backup_identifier}" | grep "${disk_name}" | awk '{print $4}') + if [ $(ArrayContains "$disk_path" "${SNAPSHOTS_PATHS[@]}") -eq 0 ]; then + log "No snapshot found for $vm" + fi + + # virsh blockcommit only works if machine is running, else we need to use qemu-img + if [ "$(virsh domstate $vm)" == "running" ]; then + log "Trying to online blockcommit for $disk_name: $disk_path" + virsh blockcommit $vm "$disk_name" --active --pivot --verbose --delete >> "$LOG_FILE" 2>&1 + else + log "Trying to offline blockcommit for $disk_name: $disk_path" + qemu-img commit -dp "$disk_path" >> "$LOG_FILE" 2>&1 + log "Note that you will need to modify the XML manually" + + # TODO: test2 + virsh dumpxml --inactive --security-info "$vm" > "${ROOT_DIR}/$vm.xml.temp" + sed -i "s%${backup_identifier}//g" "${ROOT_DIR}/$vm.xml.temp" + virsh define "${ROOT_DIR}/$vm.xml.temp" + rm -f "${ROOT_DIR}/$vm.xml.temp" + + ##TODO WE NEED TO UPDATE DISK PATH IN XML OF OFFLINE FILE + fi + if [ $? -ne 0 ]; then + log "Failed to flatten snapshot $vm: $disk_name: $disk_path" + can_delete_metadata=false + else + # Delete if disk is not in use + if [ -f "$disk_path" ]; then + log "Trying to delete $disk_path" + if ! lsof "$disk_path" > /dev/null 2>&1; then + log "Deleting file ${disk_path}" + rm -f "$disk_path" + else + log "File $disk_path is in use" + fi + fi + CURRENT_VM_SNAPSHOT="" + fi + done + + # delete snapshot metadata + if [ $can_delete_metadata == true ]; then + log "Deleting metadata from snapshot ${backup_identifier} for $vm" + virsh snapshot-delete $vm --snapshotname "${backup_identifier}" --metadata >> "$LOG_FILE" 2>&1 + if [ $? -ne 0 ]; then + log "Cannot delete snapshot metadata for $vm: ${backup_identifier}" + fi + else + log "Will not delete metadata from snapshot ${backup_identifier} for $vm" + fi +} + + +function run { + for vm in ${VMS[@]}; do + # Empty file + : > "$BACKUP_FILE_LIST" + + CURRENT_VM_SNAPSHOT="" + + log "Running backup for ${vm}" + SNAPSHOTS_PATHS=() + create_snapshot "${vm}" "${BACKUP_IDENTIFIER}" + tenant=$(get_tenant "${vm}") + run_backup "${tenant}" "${vm}" + if [ "${CURRENT_VM_SNAPSHOT}" != "" ]; then + remove_snapshot "${CURRENT_VM_SNAPSHOT}" "${BACKUP_IDENTIFIER}" + fi + done +} + +function cleanup { + if [ "${CURRENT_VM_SNAPSHOT}" != "" ]; then + remove_snapshot "${CURRENT_VM_SNAPSHOT}" "${BACKUP_IDENTIFIER}" + fi + exit +} + + + +function main { + # Make sure we remove snapshots no matter what + trap 'cleanup' INT HUP TERM QUIT ERR EXIT + + log "#### Running backup `date`" >> "$LOG_FILE" 2>&1 + [ ! -d "${ROOT_DIR}" ] && mkdir "${ROOT_DIR}" + run +} + +# SCRIPT ENTRY POINT +main \ No newline at end of file diff --git a/examples/kvm-qemu/npbackup.cube.template b/examples/kvm-qemu/npbackup.cube.template new file mode 100644 index 0000000..e73d679 --- /dev/null +++ b/examples/kvm-qemu/npbackup.cube.template @@ -0,0 +1,85 @@ +# NPBackup config file for npbackup v2.2 +# (C) 2022-2023 NetInvent + +backup: + compression: auto + exclude_caches: true + exclude_files: + #- excludes/generic_excluded_extensions + #- excludes/generic_excludes + #- excludes/windows_excludes + # - excludes/linux_excludes + exclude_case_ignore: false # Exclusions will always have case ignored on Windows systems regarless of this setting + one_file_system: true + ## Paths can contain multiple values, one per line, without quotation marks + paths: ### SOURCE ### + source_type: files_from_verbatim + use_fs_snapshot: false # Use VSS snapshot on Windows (needs administrator rights), will fallback to non VSS on failure + ignore_cloud_files: false # Don't complain when pointers to files in cloud (onedrive, nextcloud...) cannot be backed up + pre_exec_command: '' + pre_exec_timeout: 3600 + pre_exec_failure_is_fatal: false + post_exec_command: '' + post_exec_timeout: 3600 + post_exec_failure_is_fatal: false + tags: ### VM ### + additional_parameters: + priority: low + +repo: + repository: + password: + password_command: + # Backup age, in minutes, which is the minimum time between two backups + minimum_backup_age: 0 + upload_speed: 0 # in KiB, use 0 for unlimited upload speed + download_speed: 0 # in KiB, use 0 for unlimited download speed + backend_connections: 0 # Fine tune simultaneous connections to backend, use 0 for standard configuration + +identity: + # ${HOSTNAME} is a variable containing the hostname as exposed by platform.node() + # ${RANDOM}[n] is a variable containing 'n' random alphanumeric char + machine_id: ${HOSTNAME} + machine_group: + +prometheus: + ## Supervision + metrics: true + # Available variables: ${HOSTNAME}, ${RANDOM}[n], ${MACHINE_ID}, ${MACHINE_GROUP}, ${BACKUP_JOB} + backup_job: ### VM ### + # Prometheus metrics destination can be a http / https server with optional basic authentication (pushgateway), or a file path for node textfile collector to pickup + # example: https://push.monitoring.example.tld/metrics/job/npbackup + # example: https://push.monitoring.example.tld/metrics/job/${BACKUP_JOB} where ${BACKUP_JOB} is defined in prometheus_backup_job + # example: /var/lib/prometheus/collector/mytextfile + destination: + no_cert_verify: false + # prometheus metrics upload password + # private keys + http_username: + http_password: + # prometheus instance, becomes exported_instance when using a push gateway + instance: ### VM ### + + # Arbitrary group to filter later backups on + group: ${MACHINE_GROUP} + + # Additional prometheus labels + additional_labels: + - tenant=### TENANT ### + - backup_type=server +env: + variables: + # - SOME_ENV=Value + +options: + auto_upgrade: true + auto_upgrade_server_url: + auto_upgrade_server_username: + auto_upgrade_server_password: + # every 10 NPBackup runs, we'll try an autoupgrade. Never set this lower than 2 since failed upgrades will prevent backups from succeeding + auto_upgrade_interval: 10 + # Available variables: ${HOSTNAME}, ${RANDOM}[n], ${MACHINE_ID}, ${MACHINE_GROUP}, ${BACKUP_JOB} + auto_upgrade_host_identity: ${MACHINE_ID} + auto_upgrade_group: ${MACHINE_GROUP} + + backup_admin_password: \ No newline at end of file From 466b3cca8b82ae64c547ab9d3cbaf5d256bc1c94 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Fri, 23 Feb 2024 16:53:01 +0100 Subject: [PATCH 249/328] Just a small header with docs --- examples/kvm-qemu/cube-backup.sh | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/examples/kvm-qemu/cube-backup.sh b/examples/kvm-qemu/cube-backup.sh index c4c11d5..c805a62 100644 --- a/examples/kvm-qemu/cube-backup.sh +++ b/examples/kvm-qemu/cube-backup.sh @@ -1,5 +1,9 @@ #!/usr/bin/env bash +# Script to create KVM snapshots using libvirt +# Have npbackup backup the qcow2 file + the xml file of the VM +# then have the script erase the snapshot + # Script ver 2023112901 #TODO: support modding XML file from offline domains to remove snapshot and replace by backing file after qemu-img commit From a65e4d6a6d9f1bce0d3ac094ee1529bfb1d9e982 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Sun, 25 Feb 2024 18:23:38 +0100 Subject: [PATCH 250/328] Simplify inheritance parser --- npbackup/configuration.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/npbackup/configuration.py b/npbackup/configuration.py index 1c5256c..8d17b0a 100644 --- a/npbackup/configuration.py +++ b/npbackup/configuration.py @@ -487,7 +487,7 @@ def _inherit_group_settings( if isinstance(value, dict): __repo_config, __config_inheritance = _inherit_group_settings( _repo_config.g(key), - _group_config.g(key), + value, _config_inheritance.g(key), ) _repo_config.s(key, __repo_config) @@ -495,12 +495,12 @@ def _inherit_group_settings( elif isinstance(value, list): if isinstance(_repo_config.g(key), list): - merged_lists = _repo_config.g(key) + _group_config.g(key) + merged_lists = _repo_config.g(key) + value # Case where repo config already contains non list info but group config has list elif _repo_config.g(key): - merged_lists = [_repo_config.g(key)] + _group_config.g(key) + merged_lists = [_repo_config.g(key)] + value else: - merged_lists = _group_config.g(key) + merged_lists = value # Special case when merged lists contain multiple dicts, we'll need to merge dicts # unless lists have other object types than dicts @@ -517,7 +517,7 @@ def _inherit_group_settings( _repo_config.s(key, merged_lists) _config_inheritance.s(key, {}) for v in merged_lists: - _grp_conf = _group_config.g(key) + _grp_conf = value # Make sure we test inheritance against possible lists if not isinstance(_grp_conf, list): _grp_conf = [_grp_conf] @@ -554,7 +554,7 @@ def _inherit_group_settings( _config_inheritance.s(key, {}) for v in merged_lists: - _grp_conf = _group_config.g(key) + _grp_conf = value # Make sure we test inheritance against possible lists if not isinstance(_grp_conf, list): _grp_conf = [_grp_conf] @@ -594,6 +594,7 @@ def _inherit_group_settings( if eval_variables: repo_config = evaluate_variables(repo_config, full_config) repo_config = expand_units(repo_config, unexpand=True) + return repo_config, config_inheritance From 1cfe363b6b38b967a3156a1dbe73b0d503cdb759 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Sun, 25 Feb 2024 18:54:33 +0100 Subject: [PATCH 251/328] Make sure initial config_inheritance dict is False --- npbackup/configuration.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/npbackup/configuration.py b/npbackup/configuration.py index 8d17b0a..6eb7d60 100644 --- a/npbackup/configuration.py +++ b/npbackup/configuration.py @@ -471,9 +471,12 @@ def inherit_group_settings( called config_inheritance, where every value is replaced with a boolean which states inheritance status When lists are encountered, merge the lists, but product a dict in config_inheritance with list values: inheritance_bool """ + _repo_config = deepcopy(repo_config) _group_config = deepcopy(group_config) _config_inheritance = deepcopy(repo_config) + # Make sure we make the initial config inheritance values False + _config_inheritance = replace_in_iterable(_config_inheritance, lambda _ : False) def _inherit_group_settings( _repo_config: dict, _group_config: dict, _config_inheritance: dict From bedfb00e06cc1faf2c253c919a6c218a35b2f5dc Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Sun, 25 Feb 2024 19:14:05 +0100 Subject: [PATCH 252/328] Config inheritance fixes --- npbackup/configuration.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/npbackup/configuration.py b/npbackup/configuration.py index 6eb7d60..1b8e076 100644 --- a/npbackup/configuration.py +++ b/npbackup/configuration.py @@ -525,8 +525,9 @@ def _inherit_group_settings( if not isinstance(_grp_conf, list): _grp_conf = [_grp_conf] for _grp_conf_item in _grp_conf: - if v in _grp_conf_item: + if v == _grp_conf_item: _config_inheritance.s(f"{key}.{v}", True) + break else: _config_inheritance.s(f"{key}.{v}", False) else: @@ -562,8 +563,9 @@ def _inherit_group_settings( if not isinstance(_grp_conf, list): _grp_conf = [_grp_conf] for _grp_conf_item in _grp_conf: - if v in _grp_conf_item: + if v == _grp_conf_item: _config_inheritance.s(f"{key}.{v}", True) + break else: _config_inheritance.s(f"{key}.{v}", False) else: From 25541b31eb6705eaa33572f2e524609ca39964f4 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Sun, 25 Feb 2024 19:58:30 +0100 Subject: [PATCH 253/328] Pin PysimpleGUI version to 4.60.5 --- npbackup/requirements.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/npbackup/requirements.txt b/npbackup/requirements.txt index 8738e98..49cc830 100644 --- a/npbackup/requirements.txt +++ b/npbackup/requirements.txt @@ -8,7 +8,8 @@ ofunctions.threading>=2.2.0 ofunctions.platform>=1.5.0 ofunctions.random python-pidfile>=3.0.0 -pysimplegui>=4.6.0 +# pysimplegui 5 has gone commercial, let's keep this version for now +pysimplegui==4.60.5 requests ruamel.yaml psutil From 36fb6b32314f22c49fe008e3ea39a20080eb4519 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Sun, 25 Feb 2024 19:59:16 +0100 Subject: [PATCH 254/328] Reformat files with black --- npbackup/__debug__.py | 6 +- npbackup/__main__.py | 31 +- npbackup/__version__.py | 18 +- npbackup/configuration.py | 64 ++- npbackup/core/runner.py | 114 +++-- npbackup/customization.py | 2 +- npbackup/gui/__main__.py | 81 ++-- npbackup/gui/config.py | 721 ++++++++++++++++++++-------- npbackup/gui/helpers.py | 4 +- npbackup/restic_metrics/__init__.py | 10 +- npbackup/restic_wrapper/__init__.py | 55 ++- npbackup/runner_interface.py | 7 +- 12 files changed, 746 insertions(+), 367 deletions(-) diff --git a/npbackup/__debug__.py b/npbackup/__debug__.py index 86a32dc..83ae785 100644 --- a/npbackup/__debug__.py +++ b/npbackup/__debug__.py @@ -48,10 +48,8 @@ def wrapper(self, *args, **kwargs): except Exception as exc: # pylint: disable=E1101 (no-member) operation = fn.__name__ - logger.error( - f"Function {operation} failed with: {exc}", level="error" - ) + logger.error(f"Function {operation} failed with: {exc}", level="error") logger.error("Trace:", exc_info=True) return None - return wrapper \ No newline at end of file + return wrapper diff --git a/npbackup/__main__.py b/npbackup/__main__.py index 590e54c..c1e4943 100644 --- a/npbackup/__main__.py +++ b/npbackup/__main__.py @@ -42,10 +42,7 @@ def json_error_logging(result: bool, msg: str, level: str): if _JSON: - js = { - "result": result, - "reason": msg - } + js = {"result": result, "reason": msg} print(json.dumps(js)) logger.__getattribute__(level)(msg) @@ -151,13 +148,10 @@ def cli_interface(): type=str, default=None, required=False, - help="Dump a specific file to stdout" + help="Dump a specific file to stdout", ) parser.add_argument( - "--stats", - action="store_true", - help="Get repository statistics" - + "--stats", action="store_true", help="Get repository statistics" ) parser.add_argument( "--raw", @@ -192,15 +186,13 @@ def cli_interface(): help="Run in JSON API mode. Nothing else than JSON will be printed to stdout", ) parser.add_argument( - "--stdin", - action="store_true", - help="Backup using data from stdin input" + "--stdin", action="store_true", help="Backup using data from stdin input" ) parser.add_argument( "--stdin-filename", type=str, default=None, - help="Alternate filename for stdin, defaults to 'stdin.data'" + help="Alternate filename for stdin, defaults to 'stdin.data'", ) parser.add_argument( "-v", "--verbose", action="store_true", help="Show verbose output" @@ -230,7 +222,7 @@ def cli_interface(): type=str, default=None, required=False, - help="Optional path for logfile" + help="Optional path for logfile", ) args = parser.parse_args() @@ -249,10 +241,7 @@ def cli_interface(): if args.version: if _JSON: - print(json.dumps({ - "result": True, - "version": version_dict - })) + print(json.dumps({"result": True, "version": version_dict})) else: print(version_string) sys.exit(0) @@ -318,7 +307,7 @@ def cli_interface(): cli_args["op_args"] = { "force": True, "read_from_stdin": True, - "stdin_filename": args.stdin_filename if args.stdin_filename else None + "stdin_filename": args.stdin_filename if args.stdin_filename else None, } elif args.backup: cli_args["operation"] = "backup" @@ -394,7 +383,9 @@ def main(): cli_interface() sys.exit(logger.get_worst_logger_level()) except KeyboardInterrupt as exc: - json_error_logging(False, f"Program interrupted by keyboard: {exc}", level="error") + json_error_logging( + False, f"Program interrupted by keyboard: {exc}", level="error" + ) logger.info("Trace:", exc_info=True) # EXIT_CODE 200 = keyboard interrupt sys.exit(200) diff --git a/npbackup/__version__.py b/npbackup/__version__.py index 23b2887..c5e3dd5 100644 --- a/npbackup/__version__.py +++ b/npbackup/__version__.py @@ -19,13 +19,13 @@ version_string = f"{__intname__} v{__version__}-{'priv' if IS_PRIV_BUILD else 'pub'}-{sys.version_info[0]}.{sys.version_info[1]}-{python_arch()} {__build__} - {__copyright__}" version_dict = { - 'name': __intname__, - 'version': __version__, - 'buildtype': "priv" if IS_PRIV_BUILD else "pub", - 'os': get_os_identifier(), - 'arch': python_arch(), - 'pv': sys.version_info, - 'comp': "__compiled__" in globals(), - 'build': __build__, - 'copyright': __copyright__ + "name": __intname__, + "version": __version__, + "buildtype": "priv" if IS_PRIV_BUILD else "pub", + "os": get_os_identifier(), + "arch": python_arch(), + "pv": sys.version_info, + "comp": "__compiled__" in globals(), + "build": __build__, + "copyright": __copyright__, } diff --git a/npbackup/configuration.py b/npbackup/configuration.py index 1b8e076..3519fa6 100644 --- a/npbackup/configuration.py +++ b/npbackup/configuration.py @@ -201,10 +201,7 @@ def d(self, path, sep="."): "backup_job": "${MACHINE_ID}", "group": "${MACHINE_GROUP}", }, - "env": { - "env_variables": {}, - "encrypted_env_variables": {} - }, + "env": {"env_variables": {}, "encrypted_env_variables": {}}, }, "identity": { "machine_id": "${HOSTNAME}__${RANDOM}[4]", @@ -383,7 +380,9 @@ def _evaluate_variables(key, value): count = 0 maxcount = 4 * 2 * 2 while count < maxcount: - repo_config = replace_in_iterable(repo_config, _evaluate_variables, callable_wants_key=True) + repo_config = replace_in_iterable( + repo_config, _evaluate_variables, callable_wants_key=True + ) count += 1 return repo_config @@ -394,8 +393,14 @@ def expand_units(object_config: dict, unexpand: bool = False) -> dict: eg 50 KB to 500000 and 500000 to 50 KB in unexpand mode """ + def _expand_units(key, value): - if key in ("minimum_backup_size_error", "exclude_files_larger_than", "upload_speed", "download_speed"): + if key in ( + "minimum_backup_size_error", + "exclude_files_larger_than", + "upload_speed", + "download_speed", + ): if unexpand: return BytesConverter(value).human_iec_bytes return BytesConverter(value) @@ -404,7 +409,6 @@ def _expand_units(key, value): return replace_in_iterable(object_config, _expand_units, callable_wants_key=True) - def extract_permissions_from_full_config(full_config: dict) -> dict: """ Extract permissions and manager password from repo_uri tuple @@ -435,16 +439,28 @@ def inject_permissions_into_full_config(full_config: dict) -> Tuple[bool, dict]: repo_uri = full_config.g(f"repos.{repo}.repo_uri") manager_password = full_config.g(f"repos.{repo}.manager_password") permissions = full_config.g(f"repos.{repo}.permissions") - __saved_manager_password = full_config.g(f"repos.{repo}.__saved_manager_password") + __saved_manager_password = full_config.g( + f"repos.{repo}.__saved_manager_password" + ) - if __saved_manager_password and manager_password and __saved_manager_password == manager_password: + if ( + __saved_manager_password + and manager_password + and __saved_manager_password == manager_password + ): updated_full_config = True - full_config.s(f"repos.{repo}.repo_uri", (repo_uri, permissions, manager_password)) + full_config.s( + f"repos.{repo}.repo_uri", (repo_uri, permissions, manager_password) + ) full_config.s(f"repos.{repo}.is_protected", True) else: - logger.info(f"Permissions are already set for repo {repo}. Will not update them unless manager password is given") - - full_config.d(f"repos.{repo}.__saved_manager_password") # Don't keep decrypted manager password + logger.info( + f"Permissions are already set for repo {repo}. Will not update them unless manager password is given" + ) + + full_config.d( + f"repos.{repo}.__saved_manager_password" + ) # Don't keep decrypted manager password full_config.d(f"repos.{repo}.permissions") full_config.d(f"repos.{repo}.manager_password") return updated_full_config, full_config @@ -476,7 +492,7 @@ def inherit_group_settings( _group_config = deepcopy(group_config) _config_inheritance = deepcopy(repo_config) # Make sure we make the initial config inheritance values False - _config_inheritance = replace_in_iterable(_config_inheritance, lambda _ : False) + _config_inheritance = replace_in_iterable(_config_inheritance, lambda _: False) def _inherit_group_settings( _repo_config: dict, _group_config: dict, _config_inheritance: dict @@ -497,14 +513,13 @@ def _inherit_group_settings( _config_inheritance.s(key, __config_inheritance) elif isinstance(value, list): if isinstance(_repo_config.g(key), list): - merged_lists = _repo_config.g(key) + value # Case where repo config already contains non list info but group config has list elif _repo_config.g(key): merged_lists = [_repo_config.g(key)] + value else: merged_lists = value - + # Special case when merged lists contain multiple dicts, we'll need to merge dicts # unless lists have other object types than dicts merged_items_dict = {} @@ -541,7 +556,7 @@ def _inherit_group_settings( # Case where repo_config contains list but group info has single str elif isinstance(_repo_config.g(key), list) and value: merged_lists = _repo_config.g(key) + [value] - + # Special case when merged lists contain multiple dicts, we'll need to merge dicts # unless lists have other object types than dicts merged_items_dict = {} @@ -667,13 +682,13 @@ def _make_list(key: str, value: Union[str, int, float, dict, list]) -> Any: "exclude_files", "pre_exec_commands", "post_exec_commands", - "additional_labels" - "env_variables", - "encrypted_env_variables" - ): + "additional_labels" "env_variables", + "encrypted_env_variables", + ): if not isinstance(value, list): value = [value] return value + iter_over_keys(full_config, _make_list) # Check if we need to encrypt some variables @@ -759,6 +774,9 @@ def get_repos_by_group(full_config: dict, group: str) -> List[str]: repo_list = [] if full_config: for repo in list(full_config.g("repos").keys()): - if full_config.g(f"repos.{repo}.repo_group") == group and group not in repo_list: + if ( + full_config.g(f"repos.{repo}.repo_group") == group + and group not in repo_list + ): repo_list.append(repo) - return repo_list \ No newline at end of file + return repo_list diff --git a/npbackup/core/runner.py b/npbackup/core/runner.py index 6b6190a..7927223 100644 --- a/npbackup/core/runner.py +++ b/npbackup/core/runner.py @@ -24,7 +24,11 @@ from ofunctions.threading import threaded from ofunctions.platform import os_arch from ofunctions.misc import BytesConverter -from npbackup.restic_metrics import restic_str_output_to_json, restic_json_to_prometheus, upload_metrics +from npbackup.restic_metrics import ( + restic_str_output_to_json, + restic_json_to_prometheus, + upload_metrics, +) from npbackup.restic_wrapper import ResticRunner from npbackup.core.restic_source_binary import get_restic_internal_binary from npbackup.path_helper import CURRENT_DIR, BASEDIR @@ -41,9 +45,7 @@ def metric_writer( backup_too_small = False minimum_backup_size_error = repo_config.g("backup_opts.minimum_backup_size_error") try: - labels = { - "npversion": f"{NAME}{VERSION}" - } + labels = {"npversion": f"{NAME}{VERSION}"} if repo_config.g("prometheus.metrics"): labels["instance"] = repo_config.g("prometheus.instance") labels["backup_job"] = repo_config.g("prometheus.backup_job") @@ -51,7 +53,7 @@ def metric_writer( no_cert_verify = repo_config.g("prometheus.no_cert_verify") destination = repo_config.g("prometheus.destination") prometheus_additional_labels = repo_config.g("prometheus.additional_labels") - + if not isinstance(prometheus_additional_labels, list): prometheus_additional_labels = [prometheus_additional_labels] @@ -78,7 +80,10 @@ def metric_writer( restic_result = restic_str_output_to_json(restic_result, result_string) errors, metrics, backup_too_small = restic_json_to_prometheus( - restic_result=restic_result, restic_json=restic_result, labels=labels, minimum_backup_size_error=minimum_backup_size_error + restic_result=restic_result, + restic_json=restic_result, + labels=labels, + minimum_backup_size_error=minimum_backup_size_error, ) if errors or not restic_result: logger.error("Restic finished with errors.") @@ -94,9 +99,7 @@ def metric_writer( logger.info("No metrics authentication present.") authentication = None if not dry_run: - upload_metrics( - destination, authentication, no_cert_verify, metrics - ) + upload_metrics(destination, authentication, no_cert_verify, metrics) else: logger.info("Not uploading metrics in dry run mode") else: @@ -179,7 +182,6 @@ def verbose(self, value): self.write_logs(msg, level="critical", raise_error="ValueError") self._verbose = value - @property def json_output(self): return self._json_output @@ -279,7 +281,11 @@ def wrapper(self, *args, **kwargs): result = fn(self, *args, **kwargs) self.exec_time = (datetime.utcnow() - start_time).total_seconds() # Optional patch result with exec time - if self.restic_runner and self.restic_runner.json_output and isinstance(result, dict): + if ( + self.restic_runner + and self.restic_runner.json_output + and isinstance(result, dict) + ): result["exec_time"] = self.exec_time # pylint: disable=E1101 (no-member) self.write_logs( @@ -329,7 +335,7 @@ def wrapper(self, *args, **kwargs): js = { "result": False, "operation": operation, - "reason": "backend not ready" + "reason": "backend not ready", } return js self.write_logs( @@ -375,13 +381,13 @@ def wrapper(self, *args, **kwargs): else: # pylint: disable=E1101 (no-member) operation = fn.__name__ - + current_permissions = self.repo_config.g("permissions") self.write_logs( f"Permissions required are {required_permissions[operation]}, current permissions are {current_permissions}", level="info", ) - has_permissions = True # TODO: enforce permissions + has_permissions = True # TODO: enforce permissions if not has_permissions: raise PermissionError except (IndexError, KeyError, PermissionError): @@ -390,7 +396,7 @@ def wrapper(self, *args, **kwargs): js = { "result": False, "operation": operation, - "reason": "Not enough permissions" + "reason": "Not enough permissions", } return js return False @@ -481,7 +487,7 @@ def wrapper(self, *args, **kwargs): js = { "result": False, "operation": operation, - "reason": f"Exception: {exc}" + "reason": f"Exception: {exc}", } return js return False @@ -677,8 +683,14 @@ def _apply_config_to_restic_runner(self) -> bool: self.restic_runner.stderr = self.stderr return True - - def convert_to_json_output(self, result: bool, output: str = None, backend_js: dict = None, warnings: str = None): + + def convert_to_json_output( + self, + result: bool, + output: str = None, + backend_js: dict = None, + warnings: str = None, + ): if self.json_output: if backend_js: js = backend_js @@ -696,7 +708,7 @@ def convert_to_json_output(self, result: bool, output: str = None, backend_js: d js["reason"] = output return js return result - + ########################### # ACTUAL RUNNER FUNCTIONS # ########################### @@ -796,9 +808,7 @@ def has_recent_snapshot(self) -> bool: ) # Temporarily disable verbose and enable json result self.restic_runner.verbose = False - data = self.restic_runner.has_recent_snapshot( - self.minimum_backup_age - ) + data = self.restic_runner.has_recent_snapshot(self.minimum_backup_age) self.restic_runner.verbose = self.verbose if self.json_output: return data @@ -835,7 +845,12 @@ def has_recent_snapshot(self) -> bool: @is_ready @apply_config_to_restic_runner @catch_exceptions - def backup(self, force: bool = False, read_from_stdin: bool = False, stdin_filename: str = "stdin.data") -> bool: + def backup( + self, + force: bool = False, + read_from_stdin: bool = False, + stdin_filename: str = "stdin.data", + ) -> bool: """ Run backup after checking if no recent backup exists, unless force == True """ @@ -846,7 +861,9 @@ def backup(self, force: bool = False, read_from_stdin: bool = False, stdin_filen if not read_from_stdin: paths = self.repo_config.g("backup_opts.paths") if not paths: - msg = f"No paths to backup defined for repo {self.repo_config.g('name')}" + msg = ( + f"No paths to backup defined for repo {self.repo_config.g('name')}" + ) self.write_logs(msg, level="critical") return self.convert_to_json_output(False, msg) @@ -877,9 +894,11 @@ def backup(self, force: bool = False, read_from_stdin: bool = False, stdin_filen if not isinstance(exclude_files, list): exclude_files = [exclude_files] - excludes_case_ignore = self.repo_config.g("backup_opts.excludes_case_ignore") + excludes_case_ignore = self.repo_config.g( + "backup_opts.excludes_case_ignore" + ) exclude_caches = self.repo_config.g("backup_opts.exclude_caches") - + exclude_files_larger_than = self.repo_config.g( "backup_opts.exclude_files_larger_than" ) @@ -888,7 +907,7 @@ def backup(self, force: bool = False, read_from_stdin: bool = False, stdin_filen BytesConverter(exclude_files_larger_than) except ValueError: warning = f"Bogus unit for exclude_files_larger_than value given: {exclude_files_larger_than}" - self.write_logs( warning, level="warning") + self.write_logs(warning, level="warning") warnings.append(warning) exclude_files_larger_than = None exclude_files_larger_than = None @@ -934,7 +953,9 @@ def backup(self, force: bool = False, read_from_stdin: bool = False, stdin_filen self.json_output = False # Since we don't want to close queues nor create a subthread, we need to change behavior here # pylint: disable=E1123 (unexpected-keyword-arg) - has_recent_snapshots, backup_tz = self.has_recent_snapshot(__close_queues=False, __no_threads=True) + has_recent_snapshots, backup_tz = self.has_recent_snapshot( + __close_queues=False, __no_threads=True + ) self.json_output = json_output # We also need to "reapply" the json setting to backend self.restic_runner.json_output = json_output @@ -957,7 +978,10 @@ def backup(self, force: bool = False, read_from_stdin: bool = False, stdin_filen level="info", ) else: - self.write_logs(f"Running backup of piped stdin data as name {stdin_filename} to repo {self.repo_config.g('name')}", level="info") + self.write_logs( + f"Running backup of piped stdin data as name {stdin_filename} to repo {self.repo_config.g('name')}", + level="info", + ) pre_exec_commands_success = True if pre_exec_commands: @@ -999,16 +1023,21 @@ def backup(self, force: bool = False, read_from_stdin: bool = False, stdin_filen read_from_stdin=read_from_stdin, stdin_filename=stdin_filename, tags=tags, - additional_backup_only_parameters=additional_backup_only_parameters + additional_backup_only_parameters=additional_backup_only_parameters, ) - self.write_logs(f"Restic output:\n{self.restic_runner.backup_result_content}", level="debug") - + self.write_logs( + f"Restic output:\n{self.restic_runner.backup_result_content}", level="debug" + ) + # Extract backup size from result_string # Metrics will not be in json format, since we need to diag cloud issues until # there is a fix for https://github.com/restic/restic/issues/4155 backup_too_small = metric_writer( - self.repo_config, result, self.restic_runner.backup_result_content, self.restic_runner.dry_run + self.repo_config, + result, + self.restic_runner.backup_result_content, + self.restic_runner.dry_run, ) if backup_too_small: self.write_logs("Backup is smaller than expected", level="error") @@ -1034,10 +1063,15 @@ def backup(self, force: bool = False, read_from_stdin: bool = False, stdin_filen ) operation_result = ( - result and pre_exec_commands_success and post_exec_commands_success and not backup_too_small + result + and pre_exec_commands_success + and post_exec_commands_success + and not backup_too_small ) msg = f"Operation finished with {'success' if operation_result else 'failure'}" - self.write_logs(msg, level="info" if operation_result else "error", + self.write_logs( + msg, + level="info" if operation_result else "error", ) if not operation_result: # patch result if json @@ -1192,7 +1226,9 @@ def unlock(self) -> bool: @apply_config_to_restic_runner @catch_exceptions def dump(self, path: str) -> bool: - self.write_logs(f"Dumping {path} from {self.repo_config.g('name')}", level="info") + self.write_logs( + f"Dumping {path} from {self.repo_config.g('name')}", level="info" + ) result = self.restic_runner.dump(path) return result @@ -1205,9 +1241,11 @@ def dump(self, path: str) -> bool: @apply_config_to_restic_runner @catch_exceptions def stats(self) -> bool: - self.write_logs(f"Getting stats of repo {self.repo_config.g('name')}", level="info") + self.write_logs( + f"Getting stats of repo {self.repo_config.g('name')}", level="info" + ) result = self.restic_runner.stats() - return result + return result @threaded @close_queues diff --git a/npbackup/customization.py b/npbackup/customization.py index 07b1ee7..ea5c559 100644 --- a/npbackup/customization.py +++ b/npbackup/customization.py @@ -731,4 +731,4 @@ the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read . -""" \ No newline at end of file +""" diff --git a/npbackup/gui/__main__.py b/npbackup/gui/__main__.py index b3c3ba2..2511b5b 100644 --- a/npbackup/gui/__main__.py +++ b/npbackup/gui/__main__.py @@ -223,7 +223,12 @@ def _make_treedata_from_json(ls_result: List[dict]) -> sg.TreeData: def ls_window(repo_config: dict, snapshot_id: str) -> bool: result = gui_thread_runner( - repo_config, "ls", snapshot=snapshot_id, __stdout=False, __autoclose=True, __compact=True + repo_config, + "ls", + snapshot=snapshot_id, + __stdout=False, + __autoclose=True, + __compact=True, ) if not result["result"]: sg.Popup("main_gui.snapshot_is_empty") @@ -397,12 +402,12 @@ def _main_gui(viewer_mode: bool): global logger parser = ArgumentParser( - prog=f"{__intname__}", - description="""Portable Network Backup Client\n + prog=f"{__intname__}", + description="""Portable Network Backup Client\n This program is distributed under the GNU General Public License and comes with ABSOLUTELY NO WARRANTY.\n This is free software, and you are welcome to redistribute it under certain conditions; Please type --license for more info.""", - ) - + ) + parser.add_argument( "-c", "--config-file", @@ -425,7 +430,7 @@ def _main_gui(viewer_mode: bool): type=str, default=None, required=False, - help="Optional path for logfile" + help="Optional path for logfile", ) args = parser.parse_args() if args.log_file: @@ -602,7 +607,7 @@ def get_config_file(config_file: str = None) -> str: return None, None def get_config(config_file: str = None, window: sg.Window = None): - full_config, config_file = get_config_file(config_file = config_file) + full_config, config_file = get_config_file(config_file=config_file) if full_config and config_file: repo_config, config_inheritance = npbackup.configuration.get_repo_config( full_config @@ -621,12 +626,12 @@ def get_config(config_file: str = None, window: sg.Window = None): if config_file: window.set_title(f"{SHORT_PRODUCT_NAME} - {config_file}") if not viewer_mode and config_file: - window['--LAUNCH-BACKUP--'].Update(disabled=False) - window['--OPERATIONS--'].Update(disabled=False) - window['--FORGET--'].Update(disabled=False) - window['--CONFIGURE--'].Update(disabled=False) + window["--LAUNCH-BACKUP--"].Update(disabled=False) + window["--OPERATIONS--"].Update(disabled=False) + window["--FORGET--"].Update(disabled=False) + window["--CONFIGURE--"].Update(disabled=False) if repo_list: - window['-active_repo-'].Update(values=repo_list, value=repo_list[0]) + window["-active_repo-"].Update(values=repo_list, value=repo_list[0]) return ( full_config, config_file, @@ -646,7 +651,7 @@ def get_config(config_file: str = None, window: sg.Window = None): backend_type, repo_uri, repo_list, - ) = get_config(config_file = config_file) + ) = get_config(config_file=config_file) else: # Let's try to read standard restic repository env variables viewer_repo_uri = os.environ.get("RESTIC_REPOSITORY", None) @@ -681,7 +686,10 @@ def get_config(config_file: str = None, window: sg.Window = None): [sg.Text(_t("main_gui.viewer_mode"))] if viewer_mode else [], - [sg.Text("{} ".format(_t("main_gui.backup_state"))), sg.Text("", key="-backend_type-")], + [ + sg.Text("{} ".format(_t("main_gui.backup_state"))), + sg.Text("", key="-backend_type-"), + ], [ sg.Button( _t("generic.unknown"), @@ -696,8 +704,15 @@ def get_config(config_file: str = None, window: sg.Window = None): ), ], [ - sg.Text(_t("main_gui.no_config"), font=("Arial", 14), text_color="red", key="-NO-CONFIG-", visible=False) - ] if not viewer_mode + sg.Text( + _t("main_gui.no_config"), + font=("Arial", 14), + text_color="red", + key="-NO-CONFIG-", + visible=False, + ) + ] + if not viewer_mode else [], [ sg.Text(_t("main_gui.backup_list_to")), @@ -706,7 +721,7 @@ def get_config(config_file: str = None, window: sg.Window = None): key="-active_repo-", default_value=repo_list[0] if repo_list else None, enable_events=True, - size=(20, 1) + size=(20, 1), ), ] if not viewer_mode @@ -719,7 +734,7 @@ def get_config(config_file: str = None, window: sg.Window = None): justification="left", key="snapshot-list", select_mode="extended", - size=(None, 10) + size=(None, 10), ) ], [ @@ -731,23 +746,31 @@ def get_config(config_file: str = None, window: sg.Window = None): sg.Button( _t("main_gui.launch_backup"), key="--LAUNCH-BACKUP--", - disabled=viewer_mode or (not viewer_mode and not config_file), + disabled=viewer_mode + or (not viewer_mode and not config_file), ), - sg.Button(_t("main_gui.see_content"), key="--SEE-CONTENT--", - disabled=not viewer_mode and not config_file + sg.Button( + _t("main_gui.see_content"), + key="--SEE-CONTENT--", + disabled=not viewer_mode and not config_file, ), sg.Button( - _t("generic.forget"), key="--FORGET--", disabled=viewer_mode or (not viewer_mode and not config_file) + _t("generic.forget"), + key="--FORGET--", + disabled=viewer_mode + or (not viewer_mode and not config_file), ), # TODO , visible=False if repo_config.g("permissions") != "full" else True), sg.Button( _t("main_gui.operations"), key="--OPERATIONS--", - disabled=viewer_mode or (not viewer_mode and not config_file), + disabled=viewer_mode + or (not viewer_mode and not config_file), ), sg.Button( _t("generic.configure"), key="--CONFIGURE--", - disabled=viewer_mode or (not viewer_mode and not config_file), + disabled=viewer_mode + or (not viewer_mode and not config_file), ), sg.Button( _t("main_gui.load_config"), @@ -793,7 +816,7 @@ def get_config(config_file: str = None, window: sg.Window = None): backup_tz = None snapshot_list = [] gui_update_state() - + while True: event, values = window.read(timeout=60000) @@ -856,9 +879,13 @@ def get_config(config_file: str = None, window: sg.Window = None): # Make sure we trigger a GUI refresh when configuration is changed event = "--STATE-BUTTON--" if event == "--OPEN-REPO--": - viewer_repo_uri, viewer_repo_password = viewer_repo_gui(viewer_repo_uri, viewer_repo_password) + viewer_repo_uri, viewer_repo_password = viewer_repo_gui( + viewer_repo_uri, viewer_repo_password + ) if not viewer_repo_uri or not viewer_repo_password: - sg.Popup(_t("main_gui.repo_and_password_cannot_be_empty"), keep_on_top=True) + sg.Popup( + _t("main_gui.repo_and_password_cannot_be_empty"), keep_on_top=True + ) continue repo_config = viewer_create_repo(viewer_repo_uri, viewer_repo_password) event = "--STATE-BUTTON--" diff --git a/npbackup/gui/config.py b/npbackup/gui/config.py index f1f6a3c..ae0974a 100644 --- a/npbackup/gui/config.py +++ b/npbackup/gui/config.py @@ -21,7 +21,16 @@ from ofunctions.misc import get_key_from_value from npbackup.core.i18n_helper import _t from npbackup.path_helper import CURRENT_EXECUTABLE -from npbackup.customization import INHERITED_ICON, NON_INHERITED_ICON, FILE_ICON, FOLDER_ICON, INHERITED_FILE_ICON, INHERITED_FOLDER_ICON, TREE_ICON, INHERITED_TREE_ICON +from npbackup.customization import ( + INHERITED_ICON, + NON_INHERITED_ICON, + FILE_ICON, + FOLDER_ICON, + INHERITED_FILE_ICON, + INHERITED_FOLDER_ICON, + TREE_ICON, + INHERITED_TREE_ICON, +) if os.name == "nt": from npbackup.windows.task import create_scheduled_task @@ -31,11 +40,13 @@ # Monkeypatching PySimpleGUI def delete(self, key): - if key == '': + if key == "": return False try: node = self.tree_dict[key] - key_list = [key, ] + key_list = [ + key, + ] parent_node = self.tree_dict[node.parent] parent_node.children.remove(node) while key_list != []: @@ -99,7 +110,6 @@ def config_gui(full_config: dict, config_file: str): byte_units = ["B", "KB", "KiB", "MB", "MiB", "GB", "GiB", "TB", "TiB", "PB", "PiB"] - ENCRYPTED_DATA_PLACEHOLDER = "<{}>".format(_t("config_gui.encrypted_data")) def get_objects() -> List[str]: @@ -240,8 +250,8 @@ def update_gui_values(key, value, inherited, object_type, unencrypted): icon = INHERITED_FILE_ICON else: icon = FILE_ICON - backup_paths_tree.insert('', val, val, val, icon=icon) - window['backup_opts.paths'].update(values=backup_paths_tree) + backup_paths_tree.insert("", val, val, val, icon=icon) + window["backup_opts.paths"].update(values=backup_paths_tree) return elif key in ( "backup_opts.tags", @@ -251,7 +261,7 @@ def update_gui_values(key, value, inherited, object_type, unencrypted): "backup_opts.exclude_patterns", "prometheus.additional_labels", "env.env_variables", - "env.encrypted_env_variables" + "env.encrypted_env_variables", ): if key == "backup_opts.tags": tree = tags_tree @@ -276,37 +286,40 @@ def update_gui_values(key, value, inherited, object_type, unencrypted): icon = INHERITED_TREE_ICON else: icon = TREE_ICON - tree.insert('', var_name, var_name, var_value, icon=icon) + tree.insert("", var_name, var_name, var_value, icon=icon) else: for val in value: - if isinstance(val, dict): + if isinstance(val, dict): for var_name, var_value in val.items(): if object_type != "group" and inherited[var_name]: icon = INHERITED_TREE_ICON else: icon = TREE_ICON - tree.insert('', var_name, var_name, var_value, icon=icon) + tree.insert( + "", var_name, var_name, var_value, icon=icon + ) else: if object_type != "group" and inherited[val]: icon = INHERITED_TREE_ICON else: icon = TREE_ICON - tree.insert('', val, val, val, icon=icon) + tree.insert("", val, val, val, icon=icon) window[key].Update(values=tree) return - + # Update units into separate value and unit combobox if key in ( "backup_opts.minimum_backup_size_error", "backup_opts.exclude_files_larger_than", "repo_opts.upload_speed", - "repo_opts.download_speed"): + "repo_opts.download_speed", + ): value, unit = value.split(" ") window[f"{key}_unit"].Update(unit) if isinstance(value, list): value = "\n".join(value) - + if key in combo_boxes.keys() and value: window[key].Update(value=combo_boxes[key][value]) else: @@ -342,7 +355,10 @@ def iter_over_config( def _iter_over_config(object_config: dict, root_key=""): # We need to handle a special case here where env variables are dicts but shouldn't itered over here # but handled in in update_gui_values - if isinstance(object_config, dict) and root_key not in ('env.env_variables', 'env.encrypted_env_variables'): + if isinstance(object_config, dict) and root_key not in ( + "env.env_variables", + "env.encrypted_env_variables", + ): for key in object_config.keys(): if root_key: _iter_over_config( @@ -375,7 +391,7 @@ def update_object_gui(object_name=None, unencrypted=False): nonlocal prometheus_labels_tree nonlocal env_variables_tree nonlocal encrypted_env_variables_tree - + # Load fist available repo or group if none given if not object_name: object_name = get_objects()[0] @@ -481,7 +497,7 @@ def update_config_dict(full_config, values): if object_group: inheritance_key = f"groups.{object_group}.{key}" # If object is a list, check which values are inherited from group and remove them - if isinstance(value, list): # WIP # TODO + if isinstance(value, list): # WIP # TODO inheritance_list = full_config.g(inheritance_key) if inheritance_list: for entry in inheritance_list: @@ -576,7 +592,7 @@ def set_permissions(full_config: dict, object_name: str) -> dict: window.close() full_config.s(f"repos.{object_name}", repo_config) return full_config - + def is_inherited(key: str, values: Union[str, int, float, list]) -> bool: """ Checks if value(s) are inherited from group settings @@ -592,11 +608,14 @@ def object_layout() -> List[list]: [ sg.Text( textwrap.fill(f"{_t('config_gui.backup_paths')}"), - size=(None, None), expand_x=True, + size=(None, None), + expand_x=True, ), sg.Text( textwrap.fill(f"{_t('config_gui.source_type')}"), - size=(None, None), expand_x=True, justification='R' + size=(None, None), + expand_x=True, + justification="R", ), sg.Combo( list(combo_boxes["backup_opts.source_type"].values()), @@ -605,162 +624,283 @@ def object_layout() -> List[list]: ), ], [ - sg.Tree(sg.TreeData(), key="backup_opts.paths", headings=[], - col0_heading=_t("generic.paths"), - expand_x=True, expand_y=True) + sg.Tree( + sg.TreeData(), + key="backup_opts.paths", + headings=[], + col0_heading=_t("generic.paths"), + expand_x=True, + expand_y=True, + ) ], [ sg.Input(visible=False, key="--ADD-PATHS-FILE--", enable_events=True), - sg.FilesBrowse(_t("generic.add_files"), target="--ADD-BACKUP-PATHS-FILE--"), + sg.FilesBrowse( + _t("generic.add_files"), target="--ADD-BACKUP-PATHS-FILE--" + ), sg.Input(visible=False, key="--ADD-PATHS-FOLDER--", enable_events=True), - sg.FolderBrowse(_t("generic.add_folder"), target="--ADD-BACKUP-PATHS-FOLDER--"), - sg.Button(_t("generic.remove_selected"), key="--REMOVE-BACKUP-PATHS--") + sg.FolderBrowse( + _t("generic.add_folder"), target="--ADD-BACKUP-PATHS-FOLDER--" + ), + sg.Button(_t("generic.remove_selected"), key="--REMOVE-BACKUP-PATHS--"), ], [ sg.Column( [ [ sg.Text(_t("config_gui.compression"), size=(20, None)), - sg.Image(NON_INHERITED_ICON, key="inherited.backup_opts.compression", tooltip=_t("config_gui.group_inherited"), pad=1), + sg.Image( + NON_INHERITED_ICON, + key="inherited.backup_opts.compression", + tooltip=_t("config_gui.group_inherited"), + pad=1, + ), sg.Combo( list(combo_boxes["backup_opts.compression"].values()), key="backup_opts.compression", - size=(20, 1), pad=0), + size=(20, 1), + pad=0, + ), ], [ sg.Text(_t("config_gui.backup_priority"), size=(20, 1)), - sg.Image(NON_INHERITED_ICON, key="inherited.backup_opts.priority", tooltip=_t("config_gui.group_inherited"), pad=1), + sg.Image( + NON_INHERITED_ICON, + key="inherited.backup_opts.priority", + tooltip=_t("config_gui.group_inherited"), + pad=1, + ), sg.Combo( list(combo_boxes["backup_opts.priority"].values()), key="backup_opts.priority", - size=(20, 1), pad=0 - ) - + size=(20, 1), + pad=0, + ), ], [ - sg.Column([ + sg.Column( [ - sg.Button("+", key="--ADD-TAG--", size=(3, 1)) + [sg.Button("+", key="--ADD-TAG--", size=(3, 1))], + [sg.Button("-", key="--REMOVE-TAG--", size=(3, 1))], ], + pad=0, + size=(40, 80), + ), + sg.Column( [ - sg.Button("-", key="--REMOVE-TAG--", size=(3, 1)) - ] - ], pad=0, size=(40, 80)), - sg.Column([ - [ - sg.Tree(sg.TreeData(), key="backup_opts.tags", headings=[], - col0_heading="Tags", col0_width=30, num_rows=3, expand_x=True, expand_y=True) - ] - ], pad=0, size=(300, 80)) - ] - ], pad=0 + [ + sg.Tree( + sg.TreeData(), + key="backup_opts.tags", + headings=[], + col0_heading="Tags", + col0_width=30, + num_rows=3, + expand_x=True, + expand_y=True, + ) + ] + ], + pad=0, + size=(300, 80), + ), + ], + ], + pad=0, ), sg.Column( [ [ - sg.Text(_t("config_gui.minimum_backup_size_error"), size=(40, 2)), + sg.Text( + _t("config_gui.minimum_backup_size_error"), size=(40, 2) + ), ], [ - sg.Image(NON_INHERITED_ICON, key="inherited.backup_opts.minimum_backup_size_error", tooltip=_t("config_gui.group_inherited"), pad=1), - sg.Input(key="backup_opts.minimum_backup_size_error", size=(8, 1)), - sg.Combo(byte_units, default_value=byte_units[3], key="backup_opts.minimum_backup_size_error_unit") + sg.Image( + NON_INHERITED_ICON, + key="inherited.backup_opts.minimum_backup_size_error", + tooltip=_t("config_gui.group_inherited"), + pad=1, + ), + sg.Input( + key="backup_opts.minimum_backup_size_error", size=(8, 1) + ), + sg.Combo( + byte_units, + default_value=byte_units[3], + key="backup_opts.minimum_backup_size_error_unit", + ), ], [ - sg.Image(NON_INHERITED_ICON, key="inherited.backup_opts.fs_snapshot", tooltip=_t("config_gui.group_inherited"), pad=1), - sg.Checkbox(textwrap.fill(f'{_t("config_gui.use_fs_snapshot")}', width=34), key="backup_opts.use_fs_snapshot", size=(40, 1), pad=0), - ] + sg.Image( + NON_INHERITED_ICON, + key="inherited.backup_opts.fs_snapshot", + tooltip=_t("config_gui.group_inherited"), + pad=1, + ), + sg.Checkbox( + textwrap.fill( + f'{_t("config_gui.use_fs_snapshot")}', width=34 + ), + key="backup_opts.use_fs_snapshot", + size=(40, 1), + pad=0, + ), + ], ] - ) + ), ], [ - sg.Text( _t("config_gui.additional_backup_only_parameters"), size=(40, 1) ), ], [ - sg.Image(NON_INHERITED_ICON, key="inherited.backup_opts.additional_backup_only_parameters", tooltip=_t("config_gui.group_inherited"), pad=1), + sg.Image( + NON_INHERITED_ICON, + key="inherited.backup_opts.additional_backup_only_parameters", + tooltip=_t("config_gui.group_inherited"), + pad=1, + ), sg.Input( key="backup_opts.additional_backup_only_parameters", size=(100, 1) ), ], - ] - + exclusions_col = [ [ sg.Column( [ - [ - sg.Button("+", key="--ADD-EXCLUDE-PATTERN--", size=(3, 1)) - ], - [ - sg.Button("-", key="--REMOVE-EXCLUDE-PATTERN--", size=(3, 1)) - ] - ], pad=0, + [sg.Button("+", key="--ADD-EXCLUDE-PATTERN--", size=(3, 1))], + [sg.Button("-", key="--REMOVE-EXCLUDE-PATTERN--", size=(3, 1))], + ], + pad=0, ), sg.Column( [ [ - sg.Tree(sg.TreeData(), key="backup_opts.exclude_patterns", headings=[], - col0_heading=_t('config_gui.exclude_patterns'), - num_rows=4, expand_x=True, expand_y=True) + sg.Tree( + sg.TreeData(), + key="backup_opts.exclude_patterns", + headings=[], + col0_heading=_t("config_gui.exclude_patterns"), + num_rows=4, + expand_x=True, + expand_y=True, + ) ] - ], pad=0, expand_x=True - ) - ], - [ - sg.HSeparator() + ], + pad=0, + expand_x=True, + ), ], + [sg.HSeparator()], [ sg.Column( [ [ - sg.Input(visible=False, key="--ADD-EXCLUDE-FILE--", enable_events=True), - sg.FilesBrowse('+', target="--ADD-EXCLUDE-FILE--", size=(3, 1)), + sg.Input( + visible=False, + key="--ADD-EXCLUDE-FILE--", + enable_events=True, + ), + sg.FilesBrowse( + "+", target="--ADD-EXCLUDE-FILE--", size=(3, 1) + ), ], - [ - sg.Button("-", key="--REMOVE-EXCLUDE-FILE--", size=(3, 1)) - ] - ], pad=0, + [sg.Button("-", key="--REMOVE-EXCLUDE-FILE--", size=(3, 1))], + ], + pad=0, ), sg.Column( [ [ - sg.Tree(sg.TreeData(), key="backup_opts.exclude_files", headings=[], - col0_heading=_t('config_gui.exclude_files'), - num_rows=4, expand_x=True, expand_y=True) + sg.Tree( + sg.TreeData(), + key="backup_opts.exclude_files", + headings=[], + col0_heading=_t("config_gui.exclude_files"), + num_rows=4, + expand_x=True, + expand_y=True, + ) ] - ], pad=0, expand_x=True - ) - ], - [ - sg.HSeparator() + ], + pad=0, + expand_x=True, + ), ], + [sg.HSeparator()], [ sg.Text( _t("config_gui.exclude_files_larger_than"), size=(40, 1), ), - sg.Image(NON_INHERITED_ICON, key="inherited.backup_opts.exclude_files_larger_than", tooltip=_t("config_gui.group_inherited"), pad=1), + sg.Image( + NON_INHERITED_ICON, + key="inherited.backup_opts.exclude_files_larger_than", + tooltip=_t("config_gui.group_inherited"), + pad=1, + ), sg.Input(key="backup_opts.exclude_files_larger_than", size=(8, 1)), - sg.Combo(byte_units, default_value=byte_units[3], key="backup_opts.exclude_files_larger_than_unit") + sg.Combo( + byte_units, + default_value=byte_units[3], + key="backup_opts.exclude_files_larger_than_unit", + ), ], [ - sg.Image(NON_INHERITED_ICON, key="inherited.backup_opts.ignore_cloud_files", tooltip=_t("config_gui.group_inherited"), pad=1), - sg.Checkbox(f'{_t("config_gui.ignore_cloud_files")} ({_t("config_gui.windows_only")})', key="backup_opts.ignore_cloud_files", size=(None, 1)), + sg.Image( + NON_INHERITED_ICON, + key="inherited.backup_opts.ignore_cloud_files", + tooltip=_t("config_gui.group_inherited"), + pad=1, + ), + sg.Checkbox( + f'{_t("config_gui.ignore_cloud_files")} ({_t("config_gui.windows_only")})', + key="backup_opts.ignore_cloud_files", + size=(None, 1), + ), ], [ - sg.Image(NON_INHERITED_ICON, key="inherited.backup_opts.excludes_case_ignore", tooltip=_t("config_gui.group_inherited"), pad=1), - sg.Checkbox(f'{_t("config_gui.excludes_case_ignore")} ({_t("config_gui.windows_always")})', key="backup_opts.excludes_case_ignore", size=(None, 1)), + sg.Image( + NON_INHERITED_ICON, + key="inherited.backup_opts.excludes_case_ignore", + tooltip=_t("config_gui.group_inherited"), + pad=1, + ), + sg.Checkbox( + f'{_t("config_gui.excludes_case_ignore")} ({_t("config_gui.windows_always")})', + key="backup_opts.excludes_case_ignore", + size=(None, 1), + ), ], [ - sg.Image(NON_INHERITED_ICON, key="inherited.backup_opts.exclude_caches", tooltip=_t("config_gui.group_inherited"), pad=1), - sg.Checkbox(_t("config_gui.exclude_cache_dirs"), key="backup_opts.exclude_caches", size=(None, 1)), + sg.Image( + NON_INHERITED_ICON, + key="inherited.backup_opts.exclude_caches", + tooltip=_t("config_gui.group_inherited"), + pad=1, + ), + sg.Checkbox( + _t("config_gui.exclude_cache_dirs"), + key="backup_opts.exclude_caches", + size=(None, 1), + ), ], [ - sg.Image(NON_INHERITED_ICON, key="inherited.backup_opts.one_file_system", tooltip=_t("config_gui.group_inherited"), pad=1), - sg.Checkbox(_t("config_gui.one_file_system"), key="backup_opts.one_file_system", size=(None, 1)), + sg.Image( + NON_INHERITED_ICON, + key="inherited.backup_opts.one_file_system", + tooltip=_t("config_gui.group_inherited"), + pad=1, + ), + sg.Checkbox( + _t("config_gui.one_file_system"), + key="backup_opts.one_file_system", + size=(None, 1), + ), ], ] @@ -768,74 +908,119 @@ def object_layout() -> List[list]: [ sg.Column( [ + [sg.Button("+", key="--ADD-PRE-EXEC-COMMAND--", size=(3, 1))], [ - sg.Button("+", key="--ADD-PRE-EXEC-COMMAND--", size=(3, 1)) + sg.Button( + "-", key="--REMOVE-PRE-EXEC-COMMAND--", size=(3, 1) + ) ], - [ - sg.Button("-", key="--REMOVE-PRE-EXEC-COMMAND--", size=(3, 1)) - ] - ], pad=0, + ], + pad=0, ), sg.Column( [ [ - sg.Tree(sg.TreeData(), key="backup_opts.pre_exec_commands", headings=[], - col0_heading=_t('config_gui.pre_exec_commands'), - num_rows=4, expand_x=True, expand_y=True) + sg.Tree( + sg.TreeData(), + key="backup_opts.pre_exec_commands", + headings=[], + col0_heading=_t("config_gui.pre_exec_commands"), + num_rows=4, + expand_x=True, + expand_y=True, + ) ] - ], pad=0, expand_x=True - ) + ], + pad=0, + expand_x=True, + ), ], [ - sg.Image(NON_INHERITED_ICON, key="inherited.backup_opts.pre_exec_per_command_timeout", tooltip=_t("config_gui.group_inherited"), pad=1), + sg.Image( + NON_INHERITED_ICON, + key="inherited.backup_opts.pre_exec_per_command_timeout", + tooltip=_t("config_gui.group_inherited"), + pad=1, + ), sg.Text(_t("config_gui.maximum_exec_time"), size=(40, 1)), sg.Input(key="backup_opts.pre_exec_per_command_timeout", size=(8, 1)), - sg.Text(_t("generic.seconds")) + sg.Text(_t("generic.seconds")), ], [ - sg.Image(NON_INHERITED_ICON, key="inherited.backup_opts.pre_exec_failure_is_fatal", tooltip=_t("config_gui.group_inherited"), pad=1), + sg.Image( + NON_INHERITED_ICON, + key="inherited.backup_opts.pre_exec_failure_is_fatal", + tooltip=_t("config_gui.group_inherited"), + pad=1, + ), sg.Checkbox( - _t("config_gui.exec_failure_is_fatal"), key="backup_opts.pre_exec_failure_is_fatal", size=(41, 1) + _t("config_gui.exec_failure_is_fatal"), + key="backup_opts.pre_exec_failure_is_fatal", + size=(41, 1), ), ], - [ - sg.HorizontalSeparator() - ], + [sg.HorizontalSeparator()], [ sg.Column( [ + [sg.Button("+", key="--ADD-POST-EXEC-COMMAND--", size=(3, 1))], [ - sg.Button("+", key="--ADD-POST-EXEC-COMMAND--", size=(3, 1)) + sg.Button( + "-", key="--REMOVE-POST-EXEC-COMMAND--", size=(3, 1) + ) ], - [ - sg.Button("-", key="--REMOVE-POST-EXEC-COMMAND--", size=(3, 1)) - ] - ], pad=0, + ], + pad=0, ), sg.Column( [ [ - sg.Tree(sg.TreeData(), key="backup_opts.post_exec_commands", headings=[], - col0_heading=_t('config_gui.post_exec_commands'), - num_rows=4, expand_x=True, expand_y=True) + sg.Tree( + sg.TreeData(), + key="backup_opts.post_exec_commands", + headings=[], + col0_heading=_t("config_gui.post_exec_commands"), + num_rows=4, + expand_x=True, + expand_y=True, + ) ] - ], pad=0, expand_x=True - ) + ], + pad=0, + expand_x=True, + ), ], [ - sg.Image(NON_INHERITED_ICON, key="inherited.backup_opts.post_exec_per_command_timeout", tooltip=_t("config_gui.group_inherited"), pad=1), + sg.Image( + NON_INHERITED_ICON, + key="inherited.backup_opts.post_exec_per_command_timeout", + tooltip=_t("config_gui.group_inherited"), + pad=1, + ), sg.Text(_t("config_gui.maximum_exec_time"), size=(40, 1)), sg.Input(key="backup_opts.post_exec_per_command_timeout", size=(8, 1)), - sg.Text(_t("generic.seconds")) + sg.Text(_t("generic.seconds")), ], [ - sg.Image(NON_INHERITED_ICON, key="inherited.backup_opts.post_exec_failure_is_fatal", tooltip=_t("config_gui.group_inherited"), pad=1), + sg.Image( + NON_INHERITED_ICON, + key="inherited.backup_opts.post_exec_failure_is_fatal", + tooltip=_t("config_gui.group_inherited"), + pad=1, + ), sg.Checkbox( - _t("config_gui.exec_failure_is_fatal"), key="backup_opts.post_exec_failure_is_fatal", size=(41, 1) + _t("config_gui.exec_failure_is_fatal"), + key="backup_opts.post_exec_failure_is_fatal", + size=(41, 1), ), ], [ - sg.Image(NON_INHERITED_ICON, key="inherited.backup_opts.execute_even_on_backup_error", tooltip=_t("config_gui.group_inherited"), pad=1), + sg.Image( + NON_INHERITED_ICON, + key="inherited.backup_opts.execute_even_on_backup_error", + tooltip=_t("config_gui.group_inherited"), + pad=1, + ), sg.Checkbox( _t("config_gui.execute_even_on_backup_error"), key="backup_opts.post_exec_execute_even_on_backup_error", @@ -856,36 +1041,56 @@ def object_layout() -> List[list]: sg.Text(_t("config_gui.backup_repo_password"), size=(40, 1)), ], [ - sg.Image(NON_INHERITED_ICON, key="inherited.repo_opts.repo_password", tooltip=_t("config_gui.group_inherited"), pad=1), + sg.Image( + NON_INHERITED_ICON, + key="inherited.repo_opts.repo_password", + tooltip=_t("config_gui.group_inherited"), + pad=1, + ), sg.Input(key="repo_opts.repo_password", size=(95, 1)), ], [ sg.Text(_t("config_gui.backup_repo_password_command"), size=(40, 1)), ], [ - sg.Image(NON_INHERITED_ICON, key="inherited.repo_opts.repo_password_command", tooltip=_t("config_gui.group_inherited"), pad=1), + sg.Image( + NON_INHERITED_ICON, + key="inherited.repo_opts.repo_password_command", + tooltip=_t("config_gui.group_inherited"), + pad=1, + ), sg.Input(key="repo_opts.repo_password_command", size=(95, 1)), ], [sg.Button(_t("config_gui.set_permissions"), key="--SET-PERMISSIONS--")], [ sg.Text(_t("config_gui.repo_group"), size=(40, 1)), - sg.Combo(values=[], key="repo_group") # TODO + sg.Combo(values=[], key="repo_group"), # TODO ], [ - sg.Text(_t("config_gui.minimum_backup_age"), size=(40, 2), + sg.Text( + _t("config_gui.minimum_backup_age"), + size=(40, 2), ), sg.Input(key="repo_opts.minimum_backup_age", size=(8, 1)), - sg.Text(_t("generic.minutes")) + sg.Text(_t("generic.minutes")), ], [ sg.Text(_t("config_gui.upload_speed"), size=(40, 1)), sg.Input(key="repo_opts.upload_speed", size=(8, 1)), - sg.Combo(byte_units, default_value=byte_units[3], key="repo_opts.upload_speed_unit") + sg.Combo( + byte_units, + default_value=byte_units[3], + key="repo_opts.upload_speed_unit", + ), ], [ sg.Text(_t("config_gui.download_speed"), size=(40, 1)), sg.Input(key="repo_opts.download_speed", size=(8, 1)), - sg.Combo(byte_units, default_value=byte_units[3], key="repo_opts.download_speed_unit") + sg.Combo( + byte_units, + default_value=byte_units[3], + key="repo_opts.download_speed_unit", + ), ], [ sg.Text(_t("config_gui.backend_connections"), size=(40, 1)), @@ -910,43 +1115,82 @@ def object_layout() -> List[list]: sg.Column( [ [ - sg.Image(NON_INHERITED_ICON, key="inherited.repo_opts.retention_strategy.hourly", tooltip=_t("config_gui.group_inherited"), pad=1), - sg.Input(key="repo_opts.retention_strategy.hourly", size=(3, 1)), + sg.Image( + NON_INHERITED_ICON, + key="inherited.repo_opts.retention_strategy.hourly", + tooltip=_t("config_gui.group_inherited"), + pad=1, + ), + sg.Input( + key="repo_opts.retention_strategy.hourly", size=(3, 1) + ), sg.Text(_t("config_gui.hourly"), size=(20, 1)), ], [ - sg.Image(NON_INHERITED_ICON, key="inherited.repo_opts.retention_strategy.daily", tooltip=_t("config_gui.group_inherited"), pad=1), - sg.Input(key="repo_opts.retention_strategy.daily", size=(3, 1)), + sg.Image( + NON_INHERITED_ICON, + key="inherited.repo_opts.retention_strategy.daily", + tooltip=_t("config_gui.group_inherited"), + pad=1, + ), + sg.Input( + key="repo_opts.retention_strategy.daily", size=(3, 1) + ), sg.Text(_t("config_gui.daily"), size=(20, 1)), ], [ - sg.Image(NON_INHERITED_ICON, key="inherited.repo_opts.retention_strategy.weekly", tooltip=_t("config_gui.group_inherited"), pad=1), - sg.Input(key="repo_opts.retention_strategy.weekly", size=(3, 1)), + sg.Image( + NON_INHERITED_ICON, + key="inherited.repo_opts.retention_strategy.weekly", + tooltip=_t("config_gui.group_inherited"), + pad=1, + ), + sg.Input( + key="repo_opts.retention_strategy.weekly", size=(3, 1) + ), sg.Text(_t("config_gui.weekly"), size=(20, 1)), - ] + ], ] ), sg.Column( [ [ - sg.Image(NON_INHERITED_ICON, key="inherited.repo_opts.retention_strategy.monthly", tooltip=_t("config_gui.group_inherited"), pad=1), - sg.Input(key="repo_opts.retention_strategy.monthly", size=(3, 1)), + sg.Image( + NON_INHERITED_ICON, + key="inherited.repo_opts.retention_strategy.monthly", + tooltip=_t("config_gui.group_inherited"), + pad=1, + ), + sg.Input( + key="repo_opts.retention_strategy.monthly", size=(3, 1) + ), sg.Text(_t("config_gui.monthly"), size=(20, 1)), ], [ - sg.Image(NON_INHERITED_ICON, key="inherited.repo_opts.retention_strategy.yearly", tooltip=_t("config_gui.group_inherited"), pad=1), - sg.Input(key="repo_opts.retention_strategy.yearly", size=(3, 1)), + sg.Image( + NON_INHERITED_ICON, + key="inherited.repo_opts.retention_strategy.yearly", + tooltip=_t("config_gui.group_inherited"), + pad=1, + ), + sg.Input( + key="repo_opts.retention_strategy.yearly", size=(3, 1) + ), sg.Text(_t("config_gui.yearly"), size=(20, 1)), - ] + ], ] - ) + ), ], ] prometheus_col = [ [sg.Text(_t("config_gui.available_variables"))], [ - sg.Checkbox(_t("config_gui.enable_prometheus"), key="prometheus.metrics", size=(41, 1)), + sg.Checkbox( + _t("config_gui.enable_prometheus"), + key="prometheus.metrics", + size=(41, 1), + ), ], [ sg.Text(_t("config_gui.job_name"), size=(40, 1)), @@ -979,23 +1223,32 @@ def object_layout() -> List[list]: [ sg.Column( [ + [sg.Button("+", key="--ADD-PROMETHEUS-LABEL--", size=(3, 1))], [ - sg.Button("+", key="--ADD-PROMETHEUS-LABEL--", size=(3, 1)) + sg.Button( + "-", key="--REMOVE-PROMETHEUS-LABEL--", size=(3, 1) + ) ], - [ - sg.Button("-", key="--REMOVE-PROMETHEUS-LABEL--", size=(3, 1)) - ] - ], pad=0, + ], + pad=0, ), sg.Column( [ [ - sg.Tree(sg.TreeData(), key="prometheus.additional_labels", headings=[], - col0_heading=_t('config_gui.additional_labels'), - num_rows=4, expand_x=True, expand_y=True) + sg.Tree( + sg.TreeData(), + key="prometheus.additional_labels", + headings=[], + col0_heading=_t("config_gui.additional_labels"), + num_rows=4, + expand_x=True, + expand_y=True, + ) ] - ], pad=0, expand_x=True - ) + ], + pad=0, + expand_x=True, + ), ], ] @@ -1003,56 +1256,81 @@ def object_layout() -> List[list]: [ sg.Column( [ - [ - sg.Button("+", key="--ADD-ENV-VARIABLE--", size=(3, 1)) - ], - [ - sg.Button("-", key="--REMOVE-ENV-VARIABLE--", size=(3, 1)) - ] - ], pad=0, + [sg.Button("+", key="--ADD-ENV-VARIABLE--", size=(3, 1))], + [sg.Button("-", key="--REMOVE-ENV-VARIABLE--", size=(3, 1))], + ], + pad=0, ), sg.Column( [ [ - sg.Tree(sg.TreeData(), key="env.env_variables", headings=[_t("generic.value")], - col0_heading=_t('config_gui.env_variables'), + sg.Tree( + sg.TreeData(), + key="env.env_variables", + headings=[_t("generic.value")], + col0_heading=_t("config_gui.env_variables"), col0_width=1, auto_size_columns=True, justification="L", - num_rows=4, expand_x=True, expand_y=True) + num_rows=4, + expand_x=True, + expand_y=True, + ) ] - ], pad=0, expand_x=True - ) + ], + pad=0, + expand_x=True, + ), ], [ sg.Column( [ [ - sg.Button("+", key="--ADD-ENCRYPTED-ENV-VARIABLE--", size=(3, 1)) + sg.Button( + "+", key="--ADD-ENCRYPTED-ENV-VARIABLE--", size=(3, 1) + ) ], [ - sg.Button("-", key="--REMOVE-ENCRYPTED-ENV-VARIABLE--", size=(3, 1)) - ] - ], pad=0, + sg.Button( + "-", + key="--REMOVE-ENCRYPTED-ENV-VARIABLE--", + size=(3, 1), + ) + ], + ], + pad=0, ), sg.Column( [ [ - sg.Tree(sg.TreeData(), key="env.encrypted_env_variables", headings=[_t("generic.value")], - col0_heading=_t('config_gui.encrypted_env_variables'), + sg.Tree( + sg.TreeData(), + key="env.encrypted_env_variables", + headings=[_t("generic.value")], + col0_heading=_t("config_gui.encrypted_env_variables"), col0_width=1, auto_size_columns=True, justification="L", - num_rows=4, expand_x=True, expand_y=True) + num_rows=4, + expand_x=True, + expand_y=True, + ) ] - ], pad=0, expand_x=True - ) + ], + pad=0, + expand_x=True, + ), ], [ sg.Text(_t("config_gui.additional_parameters"), size=(40, 1)), ], [ - sg.Image(NON_INHERITED_ICON, key="inherited.backup_opts.additional_parameters", tooltip=_t("config_gui.group_inherited"), pad=1), + sg.Image( + NON_INHERITED_ICON, + key="inherited.backup_opts.additional_parameters", + tooltip=_t("config_gui.group_inherited"), + pad=1, + ), sg.Input(key="backup_opts.additional_parameters", size=(50, 1)), ], ] @@ -1078,7 +1356,7 @@ def object_layout() -> List[list]: font="helvetica 16", key="--tab-backup--", expand_x=True, - expand_y=True + expand_y=True, ) ], [ @@ -1087,7 +1365,8 @@ def object_layout() -> List[list]: repo_col, font="helvetica 16", key="--tab-repo--", - expand_x=True, expand_y=True, + expand_x=True, + expand_y=True, ) ], [ @@ -1097,7 +1376,7 @@ def object_layout() -> List[list]: font="helvetica 16", key="--tab-exclusions--", expand_x=True, - expand_y=True + expand_y=True, ) ], [ @@ -1107,7 +1386,7 @@ def object_layout() -> List[list]: font="helvetica 16", key="--tab-hooks--", expand_x=True, - expand_y=True + expand_y=True, ) ], [ @@ -1116,7 +1395,8 @@ def object_layout() -> List[list]: prometheus_col, font="helvetica 16", key="--tab-prometheus--", - expand_x=True, expand_y=True, + expand_x=True, + expand_y=True, ) ], [ @@ -1125,14 +1405,18 @@ def object_layout() -> List[list]: env_col, font="helvetica 16", key="--tab-env--", - expand_x=True, expand_y=True, + expand_x=True, + expand_y=True, ) ], ] _layout = [ - [sg.Column(object_selector, - )], + [ + sg.Column( + object_selector, + ) + ], [ sg.TabGroup( tab_group_layout, enable_events=True, key="--object-tabgroup--" @@ -1262,7 +1546,7 @@ def config_layout() -> List[list]: key="--repo-group-config--", expand_x=True, expand_y=True, - pad=0 + pad=0, ) ], [ @@ -1272,7 +1556,7 @@ def config_layout() -> List[list]: key="--global-config--", expand_x=True, expand_y=True, - pad=0 + pad=0, ) ], ] @@ -1280,11 +1564,20 @@ def config_layout() -> List[list]: _global_layout = [ [ sg.TabGroup( - tab_group_layout, enable_events=True, key="--configtabgroup--", expand_x=True, expand_y=True, pad=0, + tab_group_layout, + enable_events=True, + key="--configtabgroup--", + expand_x=True, + expand_y=True, + pad=0, ) ], - [sg.Push(), sg.Column(buttons, - )], + [ + sg.Push(), + sg.Column( + buttons, + ), + ], ] return _global_layout @@ -1292,7 +1585,7 @@ def config_layout() -> List[list]: window = sg.Window( "Configuration", config_layout(), - #size=(800, 650), + # size=(800, 650), auto_size_text=True, auto_size_buttons=False, no_titlebar=False, @@ -1346,30 +1639,30 @@ def config_layout() -> List[list]: continue if event in ( "--ADD-BACKUP-PATHS-FILE--", - '--ADD-BACKUP-PATHS-FOLDER--', - '--ADD-EXCLUDE-FILE--', - ): - if event in ("--ADD-BACKUP-PATHS-FILE--", '--ADD-EXCLUDE-FILE--'): - if event == '--ADD-BACKUP-PATHS-FILE--': - key = 'backup_opts.paths' + "--ADD-BACKUP-PATHS-FOLDER--", + "--ADD-EXCLUDE-FILE--", + ): + if event in ("--ADD-BACKUP-PATHS-FILE--", "--ADD-EXCLUDE-FILE--"): + if event == "--ADD-BACKUP-PATHS-FILE--": + key = "backup_opts.paths" tree = backup_paths_tree - if event == '--ADD-EXCLUDE-FILE--': - key = 'backup_opts.exclude_files' + if event == "--ADD-EXCLUDE-FILE--": + key = "backup_opts.exclude_files" tree = exclude_files_tree node = values[event] if object_type == "group": icon = INHERITED_FILE_ICON else: icon = FILE_ICON - elif event == '--ADD-BACKUP-PATHS-FOLDER--': - key = 'backup_opts.paths' + elif event == "--ADD-BACKUP-PATHS-FOLDER--": + key = "backup_opts.paths" tree = backup_paths_tree node = values[event] if object_type == "group": icon = INHERITED_FOLDER_ICON else: icon = FOLDER_ICON - tree.insert('', node, node, node, icon=icon) + tree.insert("", node, node, node, icon=icon) window[key].update(values=tree) if event in ( "--ADD-TAG--", @@ -1387,7 +1680,7 @@ def config_layout() -> List[list]: "--REMOVE-POST-EXEC-COMMAND--", "--REMOVE-PROMETHEUS-LABEL--", "--REMOVE-ENV-VARIABLE--", - "--REMOVE-ENCRYPTED-ENV-VARIABLE--" + "--REMOVE-ENCRYPTED-ENV-VARIABLE--", ): if "PATHS" in event: option_key = "backup_opts.paths" @@ -1432,15 +1725,21 @@ def config_layout() -> List[list]: var_name = sg.PopupGetText(_t("config_gui.enter_var_name")) var_value = sg.PopupGetText(_t("config_gui.enter_var_value")) if var_name and var_value: - tree.insert('', var_name, var_name, var_value, icon=icon) + tree.insert("", var_name, var_name, var_value, icon=icon) else: node = sg.PopupGetText(popup_text) if node: - tree.insert('', node, node, node, icon=icon) + tree.insert("", node, node, node, icon=icon) if event.startswith("--REMOVE-"): for key in values[option_key]: - if object_type != "group" and tree.tree_dict[key].icon in (INHERITED_TREE_ICON, INHERITED_FILE_ICON, INHERITED_FOLDER_ICON): - sg.PopupError(_t("config_gui.cannot_remove_group_inherited_settings")) + if object_type != "group" and tree.tree_dict[key].icon in ( + INHERITED_TREE_ICON, + INHERITED_FILE_ICON, + INHERITED_FOLDER_ICON, + ): + sg.PopupError( + _t("config_gui.cannot_remove_group_inherited_settings") + ) continue tree.delete(key) window[option_key].Update(values=tree) diff --git a/npbackup/gui/helpers.py b/npbackup/gui/helpers.py index 1422bef..3ab7e74 100644 --- a/npbackup/gui/helpers.py +++ b/npbackup/gui/helpers.py @@ -112,7 +112,7 @@ def _upgrade_from_compact_view(): # So we don't always init repo_config, since runner.group_runner would do that itself if __repo_config: runner.repo_config = __repo_config - + fn = getattr(runner, __fn_name) logger.debug( f"gui_thread_runner runs {fn.__name__} {'with' if USE_THREADING else 'without'} threads" @@ -194,7 +194,7 @@ def _upgrade_from_compact_view(): _t("generic.close"), key="--EXIT--", button_color=(TXT_COLOR_LDR, BG_COLOR_LDR), - disabled=True + disabled=True, ) ], ] diff --git a/npbackup/restic_metrics/__init__.py b/npbackup/restic_metrics/__init__.py index 69deea2..63d3b1f 100644 --- a/npbackup/restic_metrics/__init__.py +++ b/npbackup/restic_metrics/__init__.py @@ -168,7 +168,10 @@ def restic_str_output_to_json( def restic_json_to_prometheus( - restic_result: bool, restic_json: dict, labels: dict = None, minimum_backup_size_error: str = None, + restic_result: bool, + restic_json: dict, + labels: dict = None, + minimum_backup_size_error: str = None, ) -> Tuple[bool, List[str], bool]: """ Transform a restic JSON result into prometheus metrics @@ -225,8 +228,9 @@ def restic_json_to_prometheus( backup_too_small = False if minimum_backup_size_error: - if not restic_json["data_added"] or \ - restic_json["data_added"] < int(BytesConverter(str(minimum_backup_size_error).replace(" ", "")).bytes): + if not restic_json["data_added"] or restic_json["data_added"] < int( + BytesConverter(str(minimum_backup_size_error).replace(" ", "")).bytes + ): backup_too_small = True good_backup = restic_result and not backup_too_small diff --git a/npbackup/restic_wrapper/__init__.py b/npbackup/restic_wrapper/__init__.py index f717ca8..438f2d0 100644 --- a/npbackup/restic_wrapper/__init__.py +++ b/npbackup/restic_wrapper/__init__.py @@ -30,7 +30,8 @@ logger = getLogger() -fn_name = lambda n=0: sys._getframe(n + 1).f_code.co_name # TODO go to ofunctions.misc +fn_name = lambda n=0: sys._getframe(n + 1).f_code.co_name # TODO go to ofunctions.misc + class ResticRunner: def __init__( @@ -186,7 +187,7 @@ def dry_run(self, value: bool): self._dry_run = value else: raise ValueError("Bogus dry run value givne") - + @property def json_output(self) -> bool: return self._json_output @@ -256,8 +257,7 @@ def executor( errors_allowed: bool = False, no_output_queues: bool = False, timeout: int = None, - stdin: sys.stdin = None - + stdin: sys.stdin = None, ) -> Tuple[bool, str]: """ Executes restic with given command @@ -271,7 +271,7 @@ def executor( else "" ) _cmd = f'"{self._binary}" {additional_parameters}{cmd}{self.generic_arguments}' - + self._executor_running = True self.write_logs(f"Running command: [{_cmd}]", level="debug") self._make_env() @@ -512,7 +512,9 @@ def init( self.is_init = True return True else: - if re.search(".*already exists|.*already initialized", output, re.IGNORECASE): + if re.search( + ".*already exists|.*already initialized", output, re.IGNORECASE + ): self.write_logs("Repo is already initialized.", level="info") self.is_init = True return True @@ -528,7 +530,9 @@ def is_init(self): We'll just check if snapshots can be read """ cmd = "snapshots" - self._is_init, output = self.executor(cmd, timeout=FAST_COMMANDS_TIMEOUT, errors_allowed=True) + self._is_init, output = self.executor( + cmd, timeout=FAST_COMMANDS_TIMEOUT, errors_allowed=True + ) if not self._is_init: self.write_logs(output, level="error") return self._is_init @@ -558,7 +562,8 @@ def wrapper(self, *args, **kwargs): if fn.__name__ == "backup": if not self.init(): self.write_logs( - f"Could not initialize repo for backup operation", level="critical" + f"Could not initialize repo for backup operation", + level="critical", ) return None else: @@ -572,7 +577,7 @@ def wrapper(self, *args, **kwargs): return fn(self, *args, **kwargs) return wrapper - + def convert_to_json_output(self, result, output, msg=None, **kwargs): """ result, output = command_runner results @@ -589,7 +594,7 @@ def convert_to_json_output(self, result, output, msg=None, **kwargs): "result": result, "operation": operation, "args": kwargs, - "output": None + "output": None, } if result: if output: @@ -612,7 +617,7 @@ def convert_to_json_output(self, result, output, msg=None, **kwargs): try: js["output"] = json.loads(line) except json.decoder.JSONDecodeError: - js["output"] = {'data': line} + js["output"] = {"data": line} if msg: self.write_logs(msg, level="info") else: @@ -622,7 +627,7 @@ def convert_to_json_output(self, result, output, msg=None, **kwargs): else: js["reason"] = output return js - + if result: if msg: self.write_logs(msg, level="info") @@ -631,7 +636,6 @@ def convert_to_json_output(self, result, output, msg=None, **kwargs): self.write_logs(msg, level="error") return False - @check_if_init def list(self, subject: str) -> Union[bool, str, dict]: """ @@ -650,7 +654,6 @@ def list(self, subject: str) -> Union[bool, str, dict]: msg = f"Failed to list {subject} objects:\n{output}" return self.convert_to_json_output(result, output, msg=msg, **kwargs) - @check_if_init def ls(self, snapshot: str) -> Union[bool, str, dict]: """ @@ -660,7 +663,7 @@ def ls(self, snapshot: str) -> Union[bool, str, dict]: # snapshot db125b40 of [C:\\GIT\\npbackup] filtered by [] at 2023-01-03 09:41:30.9104257 +0100 CET): return output.split("\n", 2)[2] - Using --json here does not return actual json content, but lines with each file being a json... + Using --json here does not return actual json content, but lines with each file being a json... """ kwargs = locals() @@ -674,7 +677,6 @@ def ls(self, snapshot: str) -> Union[bool, str, dict]: msg = f"Could not list snapshot {snapshot} content:\n{output}" return self.convert_to_json_output(result, output, msg=msg, **kwargs) - # @check_if_init # We don't need to run if init before checking snapshots since if init searches for snapshots def snapshots(self) -> Union[bool, str, dict]: """ @@ -683,7 +685,7 @@ def snapshots(self) -> Union[bool, str, dict]: """ kwargs = locals() kwargs.pop("self") - + cmd = "snapshots" result, output = self.executor(cmd, timeout=FAST_COMMANDS_TIMEOUT) if result: @@ -770,7 +772,9 @@ def backup( if exclude_caches: cmd += " --exclude-caches" if exclude_files_larger_than: - exclude_files_larger_than = int(BytesConverter(exclude_files_larger_than).bytes) + exclude_files_larger_than = int( + BytesConverter(exclude_files_larger_than).bytes + ) cmd += f" --exclude-larger-than {exclude_files_larger_than}" if one_file_system: cmd += " --one-file-system" @@ -799,7 +803,8 @@ def backup( result, output = self.executor(cmd) if ( - not read_from_stdin and use_fs_snapshot + not read_from_stdin + and use_fs_snapshot and not result and re.search("VSS Error", output, re.IGNORECASE) ): @@ -833,9 +838,10 @@ def find(self, path: str) -> Union[bool, str, dict]: msg = f"Could not find path {path}:\n{output}" return self.convert_to_json_output(result, output, msg=msg, **kwargs) - @check_if_init - def restore(self, snapshot: str, target: str, includes: List[str] = None) -> Union[bool, str, dict]: + def restore( + self, snapshot: str, target: str, includes: List[str] = None + ) -> Union[bool, str, dict]: """ Restore given snapshot to directory """ @@ -858,7 +864,6 @@ def restore(self, snapshot: str, target: str, includes: List[str] = None) -> Uni msg = f"Data not restored:\n{output}" return self.convert_to_json_output(result, output, msg=msg, **kwargs) - @check_if_init def forget( self, @@ -938,7 +943,6 @@ def prune( msg = "Could not prune repository" return self.convert_to_json_output(result, output=output, msg=msg, **kwargs) - @check_if_init def check(self, read_data: bool = True) -> Union[bool, str, dict]: """ @@ -955,7 +959,6 @@ def check(self, read_data: bool = True) -> Union[bool, str, dict]: msg = "Repo check failed" return self.convert_to_json_output(result, output, msg=msg, **kwargs) - @check_if_init def repair(self, subject: str) -> Union[bool, str, dict]: """ @@ -990,7 +993,7 @@ def unlock(self) -> Union[bool, str, dict]: else: msg = f"Repo unlock failed:\n{output}" return self.convert_to_json_output(result, output, msg=msg, **kwargs) - + @check_if_init def dump(self, path: str) -> Union[bool, str, dict]: """ @@ -1088,7 +1091,7 @@ def has_recent_snapshot(self, delta: int = None) -> Tuple[bool, Optional[datetim if not delta: if self.json_output: msg = "No delta given" - self.convert_to_json_output(False, None, msg=msg **kwargs) + self.convert_to_json_output(False, None, msg=msg**kwargs) return False, None try: # Make sure we run with json support for this one diff --git a/npbackup/runner_interface.py b/npbackup/runner_interface.py index 6d5e9ca..f8d1de8 100644 --- a/npbackup/runner_interface.py +++ b/npbackup/runner_interface.py @@ -27,9 +27,9 @@ def serialize_datetime(obj): By default, datetime objects aren't serialisable to json directly Here's a quick converter from https://www.geeksforgeeks.org/how-to-fix-datetime-datetime-not-json-serializable-in-python/ """ - if isinstance(obj, datetime.datetime): - return obj.isoformat() - raise TypeError("Type not serializable") + if isinstance(obj, datetime.datetime): + return obj.isoformat() + raise TypeError("Type not serializable") def entrypoint(*args, **kwargs): @@ -48,6 +48,7 @@ def entrypoint(*args, **kwargs): print(json.dumps(result, default=serialize_datetime)) sys.exit(0) + def auto_upgrade(full_config: dict): pass From 2f78d09b7666d0eff33e1905043bfa77009a2dd6 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Sun, 25 Feb 2024 20:02:14 +0100 Subject: [PATCH 255/328] Pylint fix --- npbackup/restic_wrapper/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/npbackup/restic_wrapper/__init__.py b/npbackup/restic_wrapper/__init__.py index 438f2d0..03247ce 100644 --- a/npbackup/restic_wrapper/__init__.py +++ b/npbackup/restic_wrapper/__init__.py @@ -559,6 +559,7 @@ def check_if_init(fn: Callable): @wraps(fn) def wrapper(self, *args, **kwargs): if not self.is_init: + # pylint: disable=E1101 (no-member) if fn.__name__ == "backup": if not self.init(): self.write_logs( @@ -569,7 +570,7 @@ def wrapper(self, *args, **kwargs): else: # pylint: disable=E1101 (no-member) self.write_logs( - f"Backend is not ready to perform operation {fn.__name__}", + f"Backend is not ready to perform operation {fn.__name__}", # pylint: disable=E1101 (no-member) level="error", ) return None From ebd1f4be08af9c7c33230a7b9f7ed7b9ac431391 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Sun, 25 Feb 2024 20:55:42 +0100 Subject: [PATCH 256/328] Reformat files with black --- npbackup/gui/__main__.py | 58 ++++++++++++++++------------- npbackup/restic_wrapper/__init__.py | 2 +- 2 files changed, 33 insertions(+), 27 deletions(-) diff --git a/npbackup/gui/__main__.py b/npbackup/gui/__main__.py index 2511b5b..32ba636 100644 --- a/npbackup/gui/__main__.py +++ b/npbackup/gui/__main__.py @@ -683,9 +683,11 @@ def get_config(config_file: str = None, window: sg.Window = None): sg.Column( [ [sg.Text(OEM_STRING, font="Arial 14")], - [sg.Text(_t("main_gui.viewer_mode"))] - if viewer_mode - else [], + ( + [sg.Text(_t("main_gui.viewer_mode"))] + if viewer_mode + else [] + ), [ sg.Text("{} ".format(_t("main_gui.backup_state"))), sg.Text("", key="-backend_type-"), @@ -703,29 +705,33 @@ def get_config(config_file: str = None, window: sg.Window = None): vertical_alignment="top", ), ], - [ - sg.Text( - _t("main_gui.no_config"), - font=("Arial", 14), - text_color="red", - key="-NO-CONFIG-", - visible=False, - ) - ] - if not viewer_mode - else [], - [ - sg.Text(_t("main_gui.backup_list_to")), - sg.Combo( - repo_list, - key="-active_repo-", - default_value=repo_list[0] if repo_list else None, - enable_events=True, - size=(20, 1), - ), - ] - if not viewer_mode - else [], + ( + [ + sg.Text( + _t("main_gui.no_config"), + font=("Arial", 14), + text_color="red", + key="-NO-CONFIG-", + visible=False, + ) + ] + if not viewer_mode + else [] + ), + ( + [ + sg.Text(_t("main_gui.backup_list_to")), + sg.Combo( + repo_list, + key="-active_repo-", + default_value=repo_list[0] if repo_list else None, + enable_events=True, + size=(20, 1), + ), + ] + if not viewer_mode + else [] + ), [ sg.Table( values=[[]], diff --git a/npbackup/restic_wrapper/__init__.py b/npbackup/restic_wrapper/__init__.py index 03247ce..a1726ad 100644 --- a/npbackup/restic_wrapper/__init__.py +++ b/npbackup/restic_wrapper/__init__.py @@ -570,7 +570,7 @@ def wrapper(self, *args, **kwargs): else: # pylint: disable=E1101 (no-member) self.write_logs( - f"Backend is not ready to perform operation {fn.__name__}", # pylint: disable=E1101 (no-member) + f"Backend is not ready to perform operation {fn.__name__}", # pylint: disable=E1101 (no-member) level="error", ) return None From 12f8cec2e9f7aee229f1a1eac8460cac29bd2b19 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Sun, 25 Feb 2024 20:55:50 +0100 Subject: [PATCH 257/328] Bump ofunctions.misc requirement --- npbackup/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/npbackup/requirements.txt b/npbackup/requirements.txt index 49cc830..ecd156a 100644 --- a/npbackup/requirements.txt +++ b/npbackup/requirements.txt @@ -2,7 +2,7 @@ command_runner>=1.6.0 cryptidy>=1.2.2 python-dateutil ofunctions.logger_utils>=2.4.1 -ofunctions.misc>=1.6.4 +ofunctions.misc>=1.7.1 ofunctions.process>=2.0.0 ofunctions.threading>=2.2.0 ofunctions.platform>=1.5.0 From c30d4dc9198cf6023f5c49f52c0f59653d90f4aa Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Mon, 26 Feb 2024 12:19:06 +0100 Subject: [PATCH 258/328] Apply @catch_exceptions earlier, also, don't check permissions on group_runner --- npbackup/core/runner.py | 33 ++++++++++++++++----------------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/npbackup/core/runner.py b/npbackup/core/runner.py index 7927223..ea601f2 100644 --- a/npbackup/core/runner.py +++ b/npbackup/core/runner.py @@ -722,13 +722,13 @@ def convert_to_json_output( # but @catch_exceptions should come last, since we aren't supposed to have errors in decorators @threaded + @catch_exceptions @close_queues @exec_timer @check_concurrency @has_permission @is_ready @apply_config_to_restic_runner - @catch_exceptions def snapshots(self) -> Optional[dict]: self.write_logs( f"Listing snapshots of repo {self.repo_config.g('name')}", level="info" @@ -737,13 +737,13 @@ def snapshots(self) -> Optional[dict]: return snapshots @threaded + @catch_exceptions @close_queues @exec_timer @check_concurrency @has_permission @is_ready @apply_config_to_restic_runner - @catch_exceptions def list(self, subject: str) -> Optional[dict]: self.write_logs( f"Listing {subject} objects of repo {self.repo_config.g('name')}", @@ -752,13 +752,13 @@ def list(self, subject: str) -> Optional[dict]: return self.restic_runner.list(subject) @threaded + @catch_exceptions @close_queues @exec_timer @check_concurrency @has_permission @is_ready @apply_config_to_restic_runner - @catch_exceptions def find(self, path: str) -> bool: self.write_logs( f"Searching for path {path} in repo {self.repo_config.g('name')}", @@ -770,13 +770,13 @@ def find(self, path: str) -> bool: return self.convert_to_json_output(result, None) @threaded + @catch_exceptions @close_queues @exec_timer @check_concurrency @has_permission @is_ready @apply_config_to_restic_runner - @catch_exceptions def ls(self, snapshot: str) -> Optional[dict]: self.write_logs( f"Showing content of snapshot {snapshot} in repo {self.repo_config.g('name')}", @@ -786,13 +786,13 @@ def ls(self, snapshot: str) -> Optional[dict]: return result @threaded + @catch_exceptions @close_queues @exec_timer @check_concurrency @has_permission @is_ready @apply_config_to_restic_runner - @catch_exceptions def has_recent_snapshot(self) -> bool: """ Checks for backups in timespan @@ -838,13 +838,13 @@ def has_recent_snapshot(self) -> bool: return result, backup_tz @threaded + @catch_exceptions @close_queues @exec_timer @check_concurrency @has_permission @is_ready @apply_config_to_restic_runner - @catch_exceptions def backup( self, force: bool = False, @@ -1082,13 +1082,13 @@ def backup( return self.convert_to_json_output(result, msg) @threaded + @catch_exceptions @close_queues @exec_timer @check_concurrency @has_permission @is_ready @apply_config_to_restic_runner - @catch_exceptions def restore(self, snapshot: str, target: str, restore_includes: List[str]) -> bool: self.write_logs(f"Launching restore to {target}", level="info") result = self.restic_runner.restore( @@ -1099,13 +1099,13 @@ def restore(self, snapshot: str, target: str, restore_includes: List[str]) -> bo return result @threaded + @catch_exceptions @close_queues @exec_timer @check_concurrency @has_permission @is_ready @apply_config_to_restic_runner - @catch_exceptions def forget( self, snapshots: Optional[Union[List[str], str]] = None, use_policy: bool = None ) -> bool: @@ -1146,13 +1146,13 @@ def forget( return self.convert_to_json_output(result) @threaded + @catch_exceptions @close_queues @exec_timer @check_concurrency @has_permission @is_ready @apply_config_to_restic_runner - @catch_exceptions def check(self, read_data: bool = True) -> bool: if read_data: self.write_logs( @@ -1168,13 +1168,13 @@ def check(self, read_data: bool = True) -> bool: return result @threaded + @catch_exceptions @close_queues @exec_timer @check_concurrency @has_permission @is_ready @apply_config_to_restic_runner - @catch_exceptions def prune(self, max: bool = False) -> bool: self.write_logs( f"Pruning snapshots for repo {self.repo_config.g('name')}", level="info" @@ -1190,13 +1190,13 @@ def prune(self, max: bool = False) -> bool: return result @threaded + @catch_exceptions @close_queues @exec_timer @check_concurrency @has_permission @is_ready @apply_config_to_restic_runner - @catch_exceptions def repair(self, subject: str) -> bool: self.write_logs( f"Repairing {subject} in repo {self.repo_config.g('name')}", level="info" @@ -1205,26 +1205,26 @@ def repair(self, subject: str) -> bool: return result @threaded + @catch_exceptions @close_queues @exec_timer @check_concurrency @has_permission @is_ready @apply_config_to_restic_runner - @catch_exceptions def unlock(self) -> bool: self.write_logs(f"Unlocking repo {self.repo_config.g('name')}", level="info") result = self.restic_runner.unlock() return result @threaded + @catch_exceptions @close_queues @exec_timer @check_concurrency @has_permission @is_ready @apply_config_to_restic_runner - @catch_exceptions def dump(self, path: str) -> bool: self.write_logs( f"Dumping {path} from {self.repo_config.g('name')}", level="info" @@ -1233,13 +1233,13 @@ def dump(self, path: str) -> bool: return result @threaded + @catch_exceptions @close_queues @exec_timer @check_concurrency @has_permission @is_ready @apply_config_to_restic_runner - @catch_exceptions def stats(self) -> bool: self.write_logs( f"Getting stats of repo {self.repo_config.g('name')}", level="info" @@ -1248,23 +1248,22 @@ def stats(self) -> bool: return result @threaded + @catch_exceptions @close_queues @exec_timer @check_concurrency @has_permission @is_ready @apply_config_to_restic_runner - @catch_exceptions def raw(self, command: str) -> bool: self.write_logs(f"Running raw command: {command}", level="info") result = self.restic_runner.raw(command=command) return result @threaded + @catch_exceptions @close_queues @exec_timer - @has_permission - @catch_exceptions def group_runner(self, repo_config_list: List, operation: str, **kwargs) -> bool: group_result = True From ebb1b073903a48014374b79a4398fc24989c565b Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Mon, 26 Feb 2024 12:20:27 +0100 Subject: [PATCH 259/328] Reformat file with black --- npbackup/gui/operations.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/npbackup/gui/operations.py b/npbackup/gui/operations.py index da5cce3..7e86e0b 100644 --- a/npbackup/gui/operations.py +++ b/npbackup/gui/operations.py @@ -60,15 +60,18 @@ def _select_groups(): auto_size_columns=True, justification="left", expand_x=True, - expand_y=True + expand_y=True, ) ], [ sg.Push(), sg.Button(_t("generic.cancel"), key="--CANCEL--"), - sg.Button(_t("operations_gui.apply_to_selected_groups"), key="--SELECTED_GROUPS--"), - sg.Button(_t("operations_gui.apply_to_all"), key="--APPLY_TO_ALL--") - ] + sg.Button( + _t("operations_gui.apply_to_selected_groups"), + key="--SELECTED_GROUPS--", + ), + sg.Button(_t("operations_gui.apply_to_all"), key="--APPLY_TO_ALL--"), + ], ] select_group_window = sg.Window("Group", selector_layout) @@ -84,15 +87,15 @@ def _select_groups(): repo_list = [] for group_index in values["-GROUP_LIST-"]: group_name = group_list[group_index] - print(group_name, configuration.get_repos_by_group(full_config, group_name)) - repo_list += configuration.get_repos_by_group(full_config, group_name) + repo_list += configuration.get_repos_by_group( + full_config, group_name + ) result = repo_list break if event == "--APPLY_TO_ALL--": result = complete_repo_list break select_group_window.close() - print(result) return result # This is a stupid hack to make sure uri column is large enough @@ -225,7 +228,7 @@ def _select_groups(): "--FORGET--", "--STANDARD-PRUNE--", "--MAX-PRUNE--", - "--STATS--" + "--STATS--", ): if not values["repo-list"]: repos = _select_groups() From 5b9bee53db6fcc16f5604e58dcd75f49fb76df10 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Mon, 26 Feb 2024 12:21:03 +0100 Subject: [PATCH 260/328] Fix repo name list when selecting all repos --- npbackup/gui/operations.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/npbackup/gui/operations.py b/npbackup/gui/operations.py index 7e86e0b..fdf73e8 100644 --- a/npbackup/gui/operations.py +++ b/npbackup/gui/operations.py @@ -93,7 +93,9 @@ def _select_groups(): result = repo_list break if event == "--APPLY_TO_ALL--": - result = complete_repo_list + result = [] + for value in complete_repo_list: + result.append(value[0]) break select_group_window.close() return result @@ -281,7 +283,7 @@ def _select_groups(): operation = "stats" op_args = {} gui_msg = _t("operations_gui.stats") - result = gui_thread_runner( + gui_thread_runner( None, "group_runner", operation=operation, From 7ee89821613e258f5a28b58353f9222f3a11c587 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Mon, 26 Feb 2024 12:40:01 +0100 Subject: [PATCH 261/328] Minor fixes --- npbackup/gui/config.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/npbackup/gui/config.py b/npbackup/gui/config.py index ae0974a..86337e6 100644 --- a/npbackup/gui/config.py +++ b/npbackup/gui/config.py @@ -10,7 +10,7 @@ __build__ = "2024020501" -from typing import List, Union +from typing import List, Union, Tuple import os import pathlib from logging import getLogger @@ -183,7 +183,7 @@ def update_object_selector() -> None: window["-OBJECT-SELECT-"].Update(objects) window["-OBJECT-SELECT-"].Update(value=objects[0]) - def get_object_from_combo(combo_value: str) -> (str, str): + def get_object_from_combo(combo_value: str) -> Tuple[str, str]: """ Extracts selected object from combobox Returns object type and name @@ -593,12 +593,6 @@ def set_permissions(full_config: dict, object_name: str) -> dict: full_config.s(f"repos.{object_name}", repo_config) return full_config - def is_inherited(key: str, values: Union[str, int, float, list]) -> bool: - """ - Checks if value(s) are inherited from group settings - """ - # TODO - return False def object_layout() -> List[list]: """ From 1d69cbdb3cbe4b9e5267800628faa412bfe4922c Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Mon, 26 Feb 2024 12:42:17 +0100 Subject: [PATCH 262/328] Various new config fixes --- npbackup/configuration.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/npbackup/configuration.py b/npbackup/configuration.py index 3519fa6..ad56fc2 100644 --- a/npbackup/configuration.py +++ b/npbackup/configuration.py @@ -181,7 +181,7 @@ def d(self, path, sep="."): # Set to zero in order to disable time checks "minimum_backup_age": 1440, "upload_speed": "100Mb", # Mb(its) or MB(ytes), use 0 for unlimited upload speed - "download_speed": 0, # in KiB, use 0 for unlimited download speed + "download_speed": "0 MB", # in KiB, use 0 for unlimited download speed "backend_connections": 0, # Fine tune simultaneous connections to backend, use 0 for standard configuration "retention_strategy": { "last": 0, @@ -401,9 +401,14 @@ def _expand_units(key, value): "upload_speed", "download_speed", ): - if unexpand: - return BytesConverter(value).human_iec_bytes - return BytesConverter(value) + if value: + if unexpand: + return BytesConverter(value).human_iec_bytes + return BytesConverter(value) + else: + if unexpand: + return BytesConverter(0).human_iec_bytes + return BytesConverter(0) return value return replace_in_iterable(object_config, _expand_units, callable_wants_key=True) @@ -648,7 +653,7 @@ def _load_config_file(config_file: Path) -> Union[bool, dict]: conf_version = float(full_config.g("conf_version")) if conf_version < MIN_CONF_VERSION or conf_version > MAX_CONF_VERSION: logger.critical( - f"Config file version {conf_version} is not required version min={MIN_CONF_VERSION}, max={MAX_CONF_VERSION}" + f"Config file version {conf_version} is not in required version range min={MIN_CONF_VERSION}, max={MAX_CONF_VERSION}" ) return False except (AttributeError, TypeError): From 63b1722aded73f5c9b61c3fc61298f3502e0a1b3 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Mon, 26 Feb 2024 14:40:31 +0100 Subject: [PATCH 263/328] Reformat file with black --- npbackup/gui/config.py | 1 - 1 file changed, 1 deletion(-) diff --git a/npbackup/gui/config.py b/npbackup/gui/config.py index 86337e6..85550ed 100644 --- a/npbackup/gui/config.py +++ b/npbackup/gui/config.py @@ -593,7 +593,6 @@ def set_permissions(full_config: dict, object_name: str) -> dict: full_config.s(f"repos.{object_name}", repo_config) return full_config - def object_layout() -> List[list]: """ Returns the GUI layout depending on the object type From 688f6e766837b75f0cdc66fc60648a05bf0453b7 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Mon, 26 Feb 2024 14:42:33 +0100 Subject: [PATCH 264/328] Don't build no-gui versions anymore --- bin/COMPILE.sh | 8 -------- 1 file changed, 8 deletions(-) diff --git a/bin/COMPILE.sh b/bin/COMPILE.sh index 8edc16b..18b7683 100755 --- a/bin/COMPILE.sh +++ b/bin/COMPILE.sh @@ -9,14 +9,6 @@ cd /opt/npbackup OLD_PYTHONPATH="$PYTHONPATH" export PYTHONPATH=/opt/npbackup -if [ "$(printf %.3s $machine)" = "arm" ] || [ "$machine" = "aarch64" ]; then - opts=" --no-gui" - echo "BUILDING WITHOUT GUI because arm detected" -else - otps="" - echo "BUILDING WITH GUI" -fi - /opt/npbackup/venv/bin/python bin/compile.py --audience all $opts export PYTHONPATH="$OLD_PYTHONPATH" \ No newline at end of file From caf34a5d4ad2cdcade5ee9c5ab82d57dacae27da Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Tue, 27 Feb 2024 19:29:51 +0100 Subject: [PATCH 265/328] Add viewer target --- bin/compile.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/bin/compile.py b/bin/compile.py index 4e88fe2..6086175 100644 --- a/bin/compile.py +++ b/bin/compile.py @@ -29,7 +29,7 @@ from ofunctions.platform import python_arch, get_os AUDIENCES = ["public", "private"] -BUILD_TYPES = ["cli", "gui"] +BUILD_TYPES = ["cli", "gui", "viewer"] # Insert parent dir as path se we get to use npbackup as package sys.path.insert(0, os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))) @@ -41,12 +41,15 @@ PRODUCT_NAME, FILE_DESCRIPTION, COPYRIGHT, - LICENSE_FILE, ) from npbackup.core.restic_source_binary import get_restic_internal_binary from npbackup.path_helper import BASEDIR import glob + +LICENSE_FILE = os.path.join(BASEDIR, os.pardir, 'LICENSE') +print(LICENSE_FILE) + del sys.path[0] @@ -251,7 +254,7 @@ def compile(arch: str, audience: str, build_type: str): excludes_dir_source = os.path.join(BASEDIR, os.pardir, excludes_dir) excludes_dir_dest = excludes_dir - NUITKA_OPTIONS = "" + NUITKA_OPTIONS = " --clang" NUITKA_OPTIONS += " --enable-plugin=data-hiding" if have_nuitka_commercial() else "" # Stupid fix for synology RS816 where /tmp is mounted with `noexec`. @@ -367,7 +370,7 @@ def __call__(self, parser, namespace, values, option_string=None): dest="build_type", default=None, required=False, - help="Build CLI or GUI target" + help="Build cli, gui or viewer target" ) args = parser.parse_args() @@ -394,7 +397,7 @@ def __call__(self, parser, namespace, values, option_string=None): for audience in audiences: move_audience_files(audience) - npbackup_version = get_metadata(os.path.join(BASEDIR, "__main__.py"))[ + npbackup_version = get_metadata(os.path.join(BASEDIR, "__version__.py"))[ "version" ] installer_version = get_metadata( From 5781599dde9df5bb2c391948138ada0ec477075e Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Tue, 27 Feb 2024 19:30:19 +0100 Subject: [PATCH 266/328] Fix typo --- npbackup/restic_metrics/__init__.py | 2 +- tests/test_npbackup-cli.py | 12 +++++++++++- tests/test_restic_metrics.py | 2 +- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/npbackup/restic_metrics/__init__.py b/npbackup/restic_metrics/__init__.py index 63d3b1f..b806397 100644 --- a/npbackup/restic_metrics/__init__.py +++ b/npbackup/restic_metrics/__init__.py @@ -5,7 +5,7 @@ __intname__ = "restic_metrics" __author__ = "Orsiris de Jong" __copyright__ = "Copyright (C) 2022-2024 NetInvent" -__licence__ = "BSD-3-Clause" +__license__ = "BSD-3-Clause" __version__ = "2.0.0" __build__ = "2024010101" __description__ = ( diff --git a/tests/test_npbackup-cli.py b/tests/test_npbackup-cli.py index 3625c2a..b3e40db 100644 --- a/tests/test_npbackup-cli.py +++ b/tests/test_npbackup-cli.py @@ -5,7 +5,7 @@ __intname__ = "npbackup_cli_tests" __author__ = "Orsiris de Jong" __copyright__ = "Copyright (C) 2022-2024 NetInvent" -__licence__ = "BSD-3-Clause" +__license__ = "BSD-3-Clause" __build__ = "2024011501" __compat__ = "python3.6+" @@ -66,6 +66,16 @@ def test_npbackup_cli_snapshots(): print(logs) +def test_npbackup_cli_create_backup(): + sys.argv = ['', '-c' 'npbackup-cli-test.conf', '-b'] + try: + with RedirectedStdout() as logs: + e = __main__.main() + print(e) + except SystemExit: + print(logs) + + if __name__ == "__main__": test_npbackup_cli_no_config() diff --git a/tests/test_restic_metrics.py b/tests/test_restic_metrics.py index 7d07aeb..b5567f1 100644 --- a/tests/test_restic_metrics.py +++ b/tests/test_restic_metrics.py @@ -5,7 +5,7 @@ __intname__ = "restic_metrics_tests" __author__ = "Orsiris de Jong" __copyright__ = "Copyright (C) 2022-2024 NetInvent" -__licence__ = "BSD-3-Clause" +__license__ = "BSD-3-Clause" __build__ = "2024010101" __description__ = "Converts restic command line output to a text file node_exporter can scrape" __compat__ = "python3.6+" From d8983e4324837c367eef544b2d9a53a3a30b205c Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Tue, 27 Feb 2024 19:31:41 +0100 Subject: [PATCH 267/328] Update CHANGELOG --- CHANGELOG | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 51 insertions(+), 4 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 3bd880a..1d60ce9 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -13,22 +13,69 @@ - NPBackup Operation mode - manages multiple repos with generic key (restic key add) or specified key -## 2.3.0 - XX +## 3.0.0 !- Multi repository support (everything except repo URI can be inherited from groups) + - Major config file rewrite, now repo can inherit common settings from repo groups + ! - New option --show-final-config to show inheritance in cli mode (gui mode has visual indicators) !- Group settings for repositories !- New operation planifier for backups / cleaning / checking repos - Current backup state now shows more precise backup state, including last backup date when relevant !- Implemented retention policies ! - Optional time server update to make sure we don't drift before doing retention operations ! - Optional repo check befoire doing retention operations - !- Implemented repo check / repair actions - !- --check --repair - - Split npbackup into CLI and GUI so GUI doesn't have a console window anymore + + !- Backup admin password is now stored in a more secure way + !- Added backup client privileges + !- Pre and post-execution scripts + ! - Multiple pre and post execution scripts are now allowed + ! - Post-execution script can now be force run on error / exit + ! - Script result now has prometheus metrics + !- NTP server + !-permissions + +## Features + - New viewer mode allowing to browse/restore restic repositories without any NPBackup configuation + !- Viewer can have a configuration file + - Multi repository support + !- Operation center + -- GUI operation center allowing to mass execute actions on repos / groups + !- CLI operation center via `--group-operation --repo-group=default_group` + !- Implemented retention policies + !- Operation planifier allows to create scheduled tasks for operations + !- Implemented scheduled task creator for Windows & Unix + !(simple list of tasks, actions, stop on error) + - Implemented repo quick check / full check / repair index / repair snapshots / unlock / forget / prune / dump / stats commands + ! Added permissions management - Added snapshot tag to snapshot list on main window + - Split npbackup into separate CLI and GUI + - Status window has been refactored so GUI now has full stdout / stderr returns from runner and backend + - Implemented file size based exclusion + - CLI can now fully operate in API compatible mode via --json parameter + - Parses non json compliant restic output + - Always returns a result boolean and a reason when failing + - CLI now accepts --stdin parameter to backup streams sent to CLI + - Added minimum backup size upon which we declare that backup has failed + - All bytes units now have automatic conversion of units (K/M/G/T/P bits/bytes or IEC bytes) + - Refactored GUI and overall UX of configuration + +## Fixes + - Default exit code is now worst log level called - Show anonymized repo uri in GUI - Fix deletion failed message for en lang - Fix Google cloud storage backend detection in repository uri +## Misc + - Concurrency checks (pidfile checks) are now directly part of the runner + - Allow a 30 seconds grace period for child processes to close before asking them nicely, and than not nicely to quit + - Fully refactored prometheus metrics parser to be able to read restic standard or json outputs + - Added initial tests + +## 2.2.2 - 14/12/2023 (internal build only) + - Fixed backup paths of '/' root partitions + - Properly display repository init errors + - Logs didn't show proper error status + - Fixed wrong init detection for S3 backend + ## 2.2.1 - 28/08/2023 - Added snapshot deletion option in GUI - Fix ignore_cloud_files parameter did not work on some non systems (still an issue, see https://github.com/restic/restic/issues/4155) From 10326cf8295136a644418f4c8b71c2c6447d62db Mon Sep 17 00:00:00 2001 From: deajan Date: Tue, 5 Mar 2024 12:02:45 +0100 Subject: [PATCH 268/328] Be more clear on permission message --- npbackup/core/runner.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/npbackup/core/runner.py b/npbackup/core/runner.py index ea601f2..294dd9d 100644 --- a/npbackup/core/runner.py +++ b/npbackup/core/runner.py @@ -384,7 +384,7 @@ def wrapper(self, *args, **kwargs): current_permissions = self.repo_config.g("permissions") self.write_logs( - f"Permissions required are {required_permissions[operation]}, current permissions are {current_permissions}", + f"Permissions required for operation \'{operation}\' are {required_permissions[operation]}, current permissions are {current_permissions}", level="info", ) has_permissions = True # TODO: enforce permissions From 09d228b33cf9afb98274eeecc0b198948d9f4794 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Sun, 10 Mar 2024 15:04:11 +0100 Subject: [PATCH 269/328] Add missing backend_type variable for viewer mode --- npbackup/gui/__main__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/npbackup/gui/__main__.py b/npbackup/gui/__main__.py index 32ba636..d49a5c3 100644 --- a/npbackup/gui/__main__.py +++ b/npbackup/gui/__main__.py @@ -662,6 +662,8 @@ def get_config(config_file: str = None, window: sg.Window = None): repo_config = None config_file = None full_config = None + backend_type = None + right_click_menu = ["", [_t("generic.destination")]] headings = [ From faa2e97d08229866bc7fb657c662bc1e39d1dd45 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Sun, 10 Mar 2024 15:04:19 +0100 Subject: [PATCH 270/328] Fix typo --- npbackup/restic_wrapper/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/npbackup/restic_wrapper/__init__.py b/npbackup/restic_wrapper/__init__.py index a1726ad..1f317a1 100644 --- a/npbackup/restic_wrapper/__init__.py +++ b/npbackup/restic_wrapper/__init__.py @@ -692,7 +692,7 @@ def snapshots(self) -> Union[bool, str, dict]: if result: msg = "Snapshots listed successfully" else: - msg = f"Could not list snapshots:n{output}" + msg = f"Could not list snapshots:\n{output}" return self.convert_to_json_output(result, output, msg=msg, **kwargs) @check_if_init From 1c091729bd311ad7f1a7f61ee466533bd64d9a1a Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Sun, 10 Mar 2024 15:17:22 +0100 Subject: [PATCH 271/328] Rename backup path button keys --- npbackup/gui/config.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/npbackup/gui/config.py b/npbackup/gui/config.py index 85550ed..3e50d7f 100644 --- a/npbackup/gui/config.py +++ b/npbackup/gui/config.py @@ -629,13 +629,13 @@ def object_layout() -> List[list]: [ sg.Input(visible=False, key="--ADD-PATHS-FILE--", enable_events=True), sg.FilesBrowse( - _t("generic.add_files"), target="--ADD-BACKUP-PATHS-FILE--" + _t("generic.add_files"), target="--ADD-PATHS-FILE--" ), sg.Input(visible=False, key="--ADD-PATHS-FOLDER--", enable_events=True), sg.FolderBrowse( - _t("generic.add_folder"), target="--ADD-BACKUP-PATHS-FOLDER--" + _t("generic.add_folder"), target="--ADD-PATHS-FOLDER--" ), - sg.Button(_t("generic.remove_selected"), key="--REMOVE-BACKUP-PATHS--"), + sg.Button(_t("generic.remove_selected"), key="--REMOVE-PATHS--"), ], [ sg.Column( @@ -1631,12 +1631,12 @@ def config_layout() -> List[list]: full_config = set_permissions(full_config, values["-OBJECT-SELECT-"]) continue if event in ( - "--ADD-BACKUP-PATHS-FILE--", - "--ADD-BACKUP-PATHS-FOLDER--", + "--ADD-PATHS-FILE--", + "--ADD-PATHS-FOLDER--", "--ADD-EXCLUDE-FILE--", ): - if event in ("--ADD-BACKUP-PATHS-FILE--", "--ADD-EXCLUDE-FILE--"): - if event == "--ADD-BACKUP-PATHS-FILE--": + if event in ("--ADD-PATHS-FILE--", "--ADD-EXCLUDE-FILE--"): + if event == "--ADD-PATHS-FILE--": key = "backup_opts.paths" tree = backup_paths_tree if event == "--ADD-EXCLUDE-FILE--": @@ -1647,7 +1647,7 @@ def config_layout() -> List[list]: icon = INHERITED_FILE_ICON else: icon = FILE_ICON - elif event == "--ADD-BACKUP-PATHS-FOLDER--": + elif event == "--ADD-PATHS-FOLDER--": key = "backup_opts.paths" tree = backup_paths_tree node = values[event] @@ -1665,7 +1665,7 @@ def config_layout() -> List[list]: "--ADD-PROMETHEUS-LABEL--", "--ADD-ENV-VARIABLE--", "--ADD-ENCRYPTED-ENV-VARIABLE--", - "--REMOVE-BACKUP-PATHS--", + "--REMOVE-PATHS--", "--REMOVE-TAG--", "--REMOVE-EXCLUDE-PATTERN--", "--REMOVE-EXCLUDE-FILE--", From b90f79edfc5b71890a84f850298e59f36137748b Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Sun, 10 Mar 2024 21:31:29 +0100 Subject: [PATCH 272/328] Create ANTIVIRUS.md --- ANTIVIRUS.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 ANTIVIRUS.md diff --git a/ANTIVIRUS.md b/ANTIVIRUS.md new file mode 100644 index 0000000..a811cd3 --- /dev/null +++ b/ANTIVIRUS.md @@ -0,0 +1,27 @@ +## Antivirus reports from various Nuitka builds for Windows + +### 2024/03/10 + +Build type: Onefile +Compiler: Nuitka 2.1 Commercial +Backend: MSVC +Signed: No +Result: 11/73 security vendors and no sandboxes flagged this file as malicious + +Build type: Standalone +Compiler: Nuitka 2.1 Commercial +Backend: MSVC +Signed: No +Result: 5/72 security vendors and no sandboxes flagged this file as malicious + +Build type: Onefile +Compiler: Nuitka 2.1 Commercial +Backend: MSVC +Signed: Yes (EV Code signing certificate) +Result: 4/73 security vendors and no sandboxes flagged this file as malicious + +Build type: Standalone +Compiler: Nuitka 2.1 Commercial +Backend: MSVC +Signed: Yes (EV Code signing certificate) +Result: 1/73 security vendor and no sandboxes flagged this file as malicious \ No newline at end of file From ae7bec9c7a4d94e38a4d05ff53e6ec289eed9df8 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Sun, 10 Mar 2024 23:03:27 +0100 Subject: [PATCH 273/328] Fix viewer build must include npbackup.gui interface --- bin/compile.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/bin/compile.py b/bin/compile.py index 6086175..ccc6c29 100644 --- a/bin/compile.py +++ b/bin/compile.py @@ -48,7 +48,6 @@ LICENSE_FILE = os.path.join(BASEDIR, os.pardir, 'LICENSE') -print(LICENSE_FILE) del sys.path[0] @@ -254,18 +253,21 @@ def compile(arch: str, audience: str, build_type: str): excludes_dir_source = os.path.join(BASEDIR, os.pardir, excludes_dir) excludes_dir_dest = excludes_dir - NUITKA_OPTIONS = " --clang" + #NUITKA_OPTIONS = " --clang" + NUITKA_OPTIONS = "" NUITKA_OPTIONS += " --enable-plugin=data-hiding" if have_nuitka_commercial() else "" # Stupid fix for synology RS816 where /tmp is mounted with `noexec`. if "arm" in arch: NUITKA_OPTIONS += " --onefile-tempdir-spec=/var/tmp" - if build_type == "gui": + if build_type in ("gui", "viewer"): NUITKA_OPTIONS += " --plugin-enable=tk-inter --disable-console" else: NUITKA_OPTIONS += " --plugin-disable=tk-inter --nofollow-import-to=PySimpleGUI --nofollow-import-to=_tkinter --nofollow-import-to=npbackup.gui" - + + if build_type == "gui": + NUITKA_OPTIONS +" --nofollow-import-to=npbackup.gui.config --nofollow-import-to=npbackup.__main__" if os.name != "nt": NUITKA_OPTIONS += " --nofollow-import-to=npbackup.windows" From e5aff7a0fea82b33fbba93af9137392ba48dea3e Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Sun, 10 Mar 2024 23:03:44 +0100 Subject: [PATCH 274/328] Bump version --- npbackup/__version__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/npbackup/__version__.py b/npbackup/__version__.py index c5e3dd5..fe116c5 100644 --- a/npbackup/__version__.py +++ b/npbackup/__version__.py @@ -9,8 +9,8 @@ __description__ = "NetPerfect Backup Client" __copyright__ = "Copyright (C) 2022-2024 NetInvent" __license__ = "GPL-3.0-only" -__build__ = "2023010301" -__version__ = "3.0.0-dev" +__build__ = "2024031001" +__version__ = "3.0.0-alpha3" import sys From a05bd923a33ec0113c1e1b4c7bc299f57018ec21 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Mon, 11 Mar 2024 10:24:31 +0100 Subject: [PATCH 275/328] Update ANTIVIRUS.md --- ANTIVIRUS.md | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/ANTIVIRUS.md b/ANTIVIRUS.md index a811cd3..c5c8a37 100644 --- a/ANTIVIRUS.md +++ b/ANTIVIRUS.md @@ -2,26 +2,36 @@ ### 2024/03/10 +#### Viewer compilation + Build type: Onefile Compiler: Nuitka 2.1 Commercial -Backend: MSVC +Backend: gcc 13.2.0 Signed: No -Result: 11/73 security vendors and no sandboxes flagged this file as malicious +Build target: npbackup-viewer-x64.exe +Result: 9/73 security vendors and no sandboxes flagged this file as malicious +Link: https://www.virustotal.com/gui/file/efb327149ae84878fee9a2ffa5c8c24dbdad2ba1ebb6d383b6a2b23c4d5b723f Build type: Standalone Compiler: Nuitka 2.1 Commercial -Backend: MSVC +Backend: gcc 13.2.0 Signed: No -Result: 5/72 security vendors and no sandboxes flagged this file as malicious +Build target: npbackup-viewer-x64.exe +Result: 3/73 security vendors and no sandboxes flagged this file as malicious +Link: https://www.virustotal.com/gui/file/019ca063a250f79f38582b980f983c1e1c5ad67f36cfe4376751e0473f1fbb29 Build type: Onefile Compiler: Nuitka 2.1 Commercial -Backend: MSVC +Backend: gcc 13.2.0 Signed: Yes (EV Code signing certificate) +Build target: npbackup-viewer-x64.exe Result: 4/73 security vendors and no sandboxes flagged this file as malicious +Link: https://www.virustotal.com/gui/file/48211bf5d53fd8c010e60d46aacd04cf4dc889180f9abc6159fde9c6fad65f61 Build type: Standalone Compiler: Nuitka 2.1 Commercial -Backend: MSVC +Backend: gcc 13.2.0 Signed: Yes (EV Code signing certificate) -Result: 1/73 security vendor and no sandboxes flagged this file as malicious \ No newline at end of file +Build target: npbackup-viewer-x64.exe +Result: 2/73 security vendor and no sandboxes flagged this file as malicious +Link: https://www.virustotal.com/gui/file/31fbd01a763b25111c879b61c79b5045c1a95d32f02bf2c26aa9a45a9a8583ea \ No newline at end of file From 94fc40237f343b48f2ee40091f9a15635bef2bf9 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Mon, 11 Mar 2024 10:25:20 +0100 Subject: [PATCH 276/328] Update README.md --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 9d59b4a..b03055c 100644 --- a/README.md +++ b/README.md @@ -209,6 +209,11 @@ signer = SignTool() signer.sign(r"c:\path\to\executable", bitness=64) ``` +Or as singleliner to use in scripts: +``` +python.exe -c "from windows_tools.signtool import SignTool; s=SignTool(); s.sign(r'C:\GIT\npbackup\BUILDS\public\windows\x64\npbackup-viewer-x64.exe')" +``` + ## Misc NPBackup supports internationalization and automatically detects system's locale. From 3accc9532a384eed45a3f1cc255f5733b408dfc5 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Thu, 28 Mar 2024 21:46:49 +0100 Subject: [PATCH 277/328] Update deprecrated utcnow() usage --- npbackup/gui/__main__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/npbackup/gui/__main__.py b/npbackup/gui/__main__.py index d49a5c3..af2a30f 100644 --- a/npbackup/gui/__main__.py +++ b/npbackup/gui/__main__.py @@ -935,7 +935,7 @@ def get_config(config_file: str = None, window: sg.Window = None): def main_gui(viewer_mode=False): atexit.register( npbackup.common.execution_logs, - datetime.utcnow(), + datetime.now(datetime.UTC), ) # kill_childs normally would not be necessary, but let's just be foolproof here (kills restic subprocess in all cases) atexit.register(kill_childs, os.getpid(), grace_period=30) From dd4c6bec7183a85628e4f0eac6a0d74f84e31d06 Mon Sep 17 00:00:00 2001 From: Orsiris de Jong Date: Thu, 28 Mar 2024 21:49:40 +0100 Subject: [PATCH 278/328] Fix snapshot date regex to include all timezones --- npbackup/gui/__main__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/npbackup/gui/__main__.py b/npbackup/gui/__main__.py index af2a30f..ebf7aac 100644 --- a/npbackup/gui/__main__.py +++ b/npbackup/gui/__main__.py @@ -544,7 +544,7 @@ def get_gui_data(repo_config: dict) -> Tuple[bool, List[str]]: snapshots.reverse() # Let's show newer snapshots first for snapshot in snapshots: if re.match( - r"[0-9]{4}-[0-1][0-9]-[0-3][0-9]T[0-2][0-9]:[0-5][0-9]:[0-5][0-9]\..*\+[0-2][0-9]:[0-9]{2}", + r"[0-9]{4}-[0-1][0-9]-[0-3][0-9]T[0-2][0-9]:[0-5][0-9]:[0-5][0-9]\..*[+-][0-2][0-9]:[0-9]{2}", snapshot["time"], ): snapshot_date = dateutil.parser.parse(snapshot["time"]).strftime( From 4456072804a85f8a4ba02e91c51250cc30639aea Mon Sep 17 00:00:00 2001 From: deajan Date: Wed, 10 Apr 2024 10:03:21 +0200 Subject: [PATCH 279/328] Update datetime.utcnow to newer syntax --- npbackup/__main__.py | 4 ++-- npbackup/common.py | 4 ++-- npbackup/core/runner.py | 6 +++--- npbackup/gui/__main__.py | 4 ++-- npbackup/restic_metrics/__init__.py | 6 +++--- npbackup/restic_wrapper/__init__.py | 4 ++-- upgrade_server/upgrade_server/crud.py | 4 ++-- 7 files changed, 16 insertions(+), 16 deletions(-) diff --git a/npbackup/__main__.py b/npbackup/__main__.py index c1e4943..fd06969 100644 --- a/npbackup/__main__.py +++ b/npbackup/__main__.py @@ -11,7 +11,7 @@ from pathlib import Path import atexit from argparse import ArgumentParser -from datetime import datetime +from datetime import datetime, timezone import logging import json import ofunctions.logger_utils @@ -375,7 +375,7 @@ def main(): # Make sure we log execution time and error state at the end of the program atexit.register( execution_logs, - datetime.utcnow(), + datetime.now(timezone.utc), ) # kill_childs normally would not be necessary, but let's just be foolproof here (kills restic subprocess in all cases) atexit.register(kill_childs, os.getpid(), grace_period=30) diff --git a/npbackup/common.py b/npbackup/common.py index be4edce..2fee570 100644 --- a/npbackup/common.py +++ b/npbackup/common.py @@ -12,7 +12,7 @@ __build__ = "2023121801" -from datetime import datetime +from datetime import datetime, timezone from logging import getLogger import ofunctions.logger_utils @@ -35,7 +35,7 @@ def execution_logs(start_time: datetime) -> None: Makes sense ;) """ - end_time = datetime.utcnow() + end_time = datetime.now(timezone.utc) logger_worst_level = 0 for flt in logger.filters: diff --git a/npbackup/core/runner.py b/npbackup/core/runner.py index 294dd9d..3cc5a9c 100644 --- a/npbackup/core/runner.py +++ b/npbackup/core/runner.py @@ -16,7 +16,7 @@ import tempfile import pidfile import queue -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from functools import wraps import queue from copy import deepcopy @@ -276,10 +276,10 @@ def exec_timer(fn: Callable): @wraps(fn) def wrapper(self, *args, **kwargs): - start_time = datetime.utcnow() + start_time = datetime.now(timezone.utc) # pylint: disable=E1102 (not-callable) result = fn(self, *args, **kwargs) - self.exec_time = (datetime.utcnow() - start_time).total_seconds() + self.exec_time = (datetime.now(timezone.utc) - start_time).total_seconds() # Optional patch result with exec time if ( self.restic_runner diff --git a/npbackup/gui/__main__.py b/npbackup/gui/__main__.py index ebf7aac..0d534e8 100644 --- a/npbackup/gui/__main__.py +++ b/npbackup/gui/__main__.py @@ -18,7 +18,7 @@ from pathlib import Path from logging import getLogger import ofunctions.logger_utils -from datetime import datetime +from datetime import datetime, timezone import dateutil from time import sleep from ruamel.yaml.comments import CommentedMap @@ -935,7 +935,7 @@ def get_config(config_file: str = None, window: sg.Window = None): def main_gui(viewer_mode=False): atexit.register( npbackup.common.execution_logs, - datetime.now(datetime.UTC), + datetime.now(timezone.utc), ) # kill_childs normally would not be necessary, but let's just be foolproof here (kills restic subprocess in all cases) atexit.register(kill_childs, os.getpid(), grace_period=30) diff --git a/npbackup/restic_metrics/__init__.py b/npbackup/restic_metrics/__init__.py index b806397..21df568 100644 --- a/npbackup/restic_metrics/__init__.py +++ b/npbackup/restic_metrics/__init__.py @@ -22,7 +22,7 @@ import logging import platform import requests -from datetime import datetime +from datetime import datetime, timezone from argparse import ArgumentParser from ofunctions.misc import BytesConverter, convert_time_to_seconds @@ -236,7 +236,7 @@ def restic_json_to_prometheus( prom_metrics.append( 'restic_backup_failure{{{},timestamp="{}"}} {}'.format( - labels, int(datetime.utcnow().timestamp()), 1 if not good_backup else 0 + labels, int(datetime.now(timezone.utc).timestamp()), 1 if not good_backup else 0 ) ) @@ -431,7 +431,7 @@ def restic_output_2_metrics(restic_result, output, labels=None): metrics.append( 'restic_backup_failure{{{},timestamp="{}"}} {}'.format( - labels, int(datetime.utcnow().timestamp()), 1 if errors else 0 + labels, int(datetime.now(timezone.utc).timestamp()), 1 if errors else 0 ) ) return errors, metrics diff --git a/npbackup/restic_wrapper/__init__.py b/npbackup/restic_wrapper/__init__.py index 1f317a1..fd5fede 100644 --- a/npbackup/restic_wrapper/__init__.py +++ b/npbackup/restic_wrapper/__init__.py @@ -264,7 +264,7 @@ def executor( errors_allowed is needed since we're testing if repo is already initialized no_output_queues is needed since we don't want is_init output to be logged """ - start_time = datetime.utcnow() + start_time = datetime.now(timezone.utc) additional_parameters = ( f" {self.additional_parameters.strip()} " if self.additional_parameters @@ -298,7 +298,7 @@ def executor( # _executor_running = False is also set via on_exit function call self._executor_running = False - self.exec_time = (datetime.utcnow() - start_time).total_seconds + self.exec_time = (datetime.now(timezone.utc) - start_time).total_seconds if exit_code == 0: self.last_command_status = True diff --git a/upgrade_server/upgrade_server/crud.py b/upgrade_server/upgrade_server/crud.py index da113f3..270bd42 100644 --- a/upgrade_server/upgrade_server/crud.py +++ b/upgrade_server/upgrade_server/crud.py @@ -15,7 +15,7 @@ from logging import getLogger import hashlib from argparse import ArgumentParser -from datetime import datetime +from datetime import datetime, timezone from upgrade_server.models.files import FileGet, FileSend from upgrade_server.models.oper import CurrentVersion import upgrade_server.configuration as configuration @@ -59,7 +59,7 @@ def is_enabled() -> bool: def store_host_info(destination: str, host_id: dict) -> None: try: data = ( - datetime.utcnow().isoformat() + datetime.now(timezone.utc).isoformat() + "," + ",".join([value if value else "" for value in host_id.values()]) + "\n" From 1bcd032d56517487c1c8e9829903f0fd80b885a6 Mon Sep 17 00:00:00 2001 From: deajan Date: Wed, 10 Apr 2024 10:10:36 +0200 Subject: [PATCH 280/328] Improve permission manager --- npbackup/configuration.py | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/npbackup/configuration.py b/npbackup/configuration.py index ad56fc2..bc94749 100644 --- a/npbackup/configuration.py +++ b/npbackup/configuration.py @@ -428,7 +428,7 @@ def extract_permissions_from_full_config(full_config: dict) -> dict: full_config.s(f"repos.{repo}.repo_uri", repo_uri) full_config.s(f"repos.{repo}.permissions", permissions) full_config.s(f"repos.{repo}.manager_password", manager_password) - full_config.s(f"repos.{repo}.__saved_manager_password", manager_password) + full_config.s(f"repos.{repo}.__current_manager_password", manager_password) return full_config @@ -444,27 +444,26 @@ def inject_permissions_into_full_config(full_config: dict) -> Tuple[bool, dict]: repo_uri = full_config.g(f"repos.{repo}.repo_uri") manager_password = full_config.g(f"repos.{repo}.manager_password") permissions = full_config.g(f"repos.{repo}.permissions") - __saved_manager_password = full_config.g( - f"repos.{repo}.__saved_manager_password" + __current_manager_password = full_config.g( + f"repos.{repo}.__current_manager_password" ) if ( - __saved_manager_password - and manager_password - and __saved_manager_password == manager_password + __current_manager_password and manager_password ): - updated_full_config = True - full_config.s( - f"repos.{repo}.repo_uri", (repo_uri, permissions, manager_password) - ) - full_config.s(f"repos.{repo}.is_protected", True) + if __current_manager_password == manager_password: + updated_full_config = True + full_config.s( + f"repos.{repo}.repo_uri", (repo_uri, permissions, manager_password) + ) + full_config.s(f"repos.{repo}.is_protected", True) + else: + logger.error(f"Wrong manager password given for repo {repo}. Will not update permissions") else: - logger.info( - f"Permissions are already set for repo {repo}. Will not update them unless manager password is given" - ) + logger.debug(f"Permissions exist for repo {repo}") full_config.d( - f"repos.{repo}.__saved_manager_password" + f"repos.{repo}.__current_manager_password" ) # Don't keep decrypted manager password full_config.d(f"repos.{repo}.permissions") full_config.d(f"repos.{repo}.manager_password") From b436dc2aad4c7ca65bcd867ab88457aabef9cdeb Mon Sep 17 00:00:00 2001 From: deajan Date: Thu, 11 Apr 2024 00:52:03 +0200 Subject: [PATCH 281/328] Make CLI non josn output visible --- npbackup/runner_interface.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/npbackup/runner_interface.py b/npbackup/runner_interface.py index f8d1de8..8be392b 100644 --- a/npbackup/runner_interface.py +++ b/npbackup/runner_interface.py @@ -43,6 +43,8 @@ def entrypoint(*args, **kwargs): **kwargs.pop("op_args"), __no_threads=True ) if not json_output: + if not isinstance(result, bool): + logger.info(f"{result}") logger.info(f"Operation finished with {'success' if result else 'failure'}") else: print(json.dumps(result, default=serialize_datetime)) From 62798493369be687986c5be6b44296d00118af6f Mon Sep 17 00:00:00 2001 From: deajan Date: Thu, 11 Apr 2024 01:31:33 +0200 Subject: [PATCH 282/328] Enforce permissions --- CHANGELOG | 4 ++-- SECURITY.md | 2 +- npbackup/configuration.py | 27 +++++++++++++-------------- npbackup/core/runner.py | 18 ++++++++++++------ 4 files changed, 28 insertions(+), 23 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 1d60ce9..47c98cf 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -31,7 +31,6 @@ ! - Post-execution script can now be force run on error / exit ! - Script result now has prometheus metrics !- NTP server - !-permissions ## Features - New viewer mode allowing to browse/restore restic repositories without any NPBackup configuation @@ -45,7 +44,8 @@ !- Implemented scheduled task creator for Windows & Unix !(simple list of tasks, actions, stop on error) - Implemented repo quick check / full check / repair index / repair snapshots / unlock / forget / prune / dump / stats commands - ! Added permissions management + - Added per repo permission management + - Repos now have backup, restore and full privileges, allowing to restrict access for end users - Added snapshot tag to snapshot list on main window - Split npbackup into separate CLI and GUI - Status window has been refactored so GUI now has full stdout / stderr returns from runner and backend diff --git a/SECURITY.md b/SECURITY.md index 2c61479..ff351c1 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -28,7 +28,7 @@ Viewer mode permissions are set to "restore". # NPF-SEC-00006: Never inject permissions if some are already present -Since v2.3.0, we insert permissions directly into the encrypted repo URI. +Since v3.0.0, we insert permissions directly into the encrypted repo URI. Hence, update permissions should only happen in two cases: - CLI: Recreate repo_uri entry and add permission field from YAML file - GUI: Enter permission password to update permissions diff --git a/npbackup/configuration.py b/npbackup/configuration.py index bc94749..359a38a 100644 --- a/npbackup/configuration.py +++ b/npbackup/configuration.py @@ -7,8 +7,8 @@ __author__ = "Orsiris de Jong" __copyright__ = "Copyright (C) 2022-2024 NetInvent" __license__ = "GPL-3.0-only" -__build__ = "2024020201" -__version__ = "2.0.0 for npbackup 3.0.0+" +__build__ = "2024041101" +__version__ = "npbackup 3.0.0+" MIN_CONF_VERSION = 3.0 MAX_CONF_VERSION = 3.0 @@ -422,13 +422,18 @@ def extract_permissions_from_full_config(full_config: dict) -> dict: """ for repo in full_config.g("repos").keys(): repo_uri = full_config.g(f"repos.{repo}.repo_uri") - if isinstance(repo_uri, tuple): + # Extract permissions and manager password from repo_uri if set as string + if "," in repo_uri: + repo_uri = [item.strip() for item in repo_uri.split(",")] + if isinstance(repo_uri, tuple) or isinstance(repo_uri, list): repo_uri, permissions, manager_password = repo_uri # Overwrite existing permissions / password if it was set in repo_uri full_config.s(f"repos.{repo}.repo_uri", repo_uri) full_config.s(f"repos.{repo}.permissions", permissions) full_config.s(f"repos.{repo}.manager_password", manager_password) full_config.s(f"repos.{repo}.__current_manager_password", manager_password) + else: + logger.info(f"No extra information for repo {repo} found") return full_config @@ -439,7 +444,6 @@ def inject_permissions_into_full_config(full_config: dict) -> Tuple[bool, dict]: NPF-SEC-00006: Never inject permissions if some are already present unless current manager password equals initial one """ - updated_full_config = False for repo in full_config.g("repos").keys(): repo_uri = full_config.g(f"repos.{repo}.repo_uri") manager_password = full_config.g(f"repos.{repo}.manager_password") @@ -452,7 +456,6 @@ def inject_permissions_into_full_config(full_config: dict) -> Tuple[bool, dict]: __current_manager_password and manager_password ): if __current_manager_password == manager_password: - updated_full_config = True full_config.s( f"repos.{repo}.repo_uri", (repo_uri, permissions, manager_password) ) @@ -467,7 +470,7 @@ def inject_permissions_into_full_config(full_config: dict) -> Tuple[bool, dict]: ) # Don't keep decrypted manager password full_config.d(f"repos.{repo}.permissions") full_config.d(f"repos.{repo}.manager_password") - return updated_full_config, full_config + return full_config def get_manager_password(full_config: dict, repo_name: str) -> str: @@ -725,13 +728,7 @@ def _make_list(key: str, value: Union[str, int, float, dict, list]) -> Any: config_file_is_updated = True logger.info("Handling random variables in configuration files") - # Inject permissions into conf file if needed - is_modified, full_config = inject_permissions_into_full_config(full_config) - if is_modified: - config_file_is_updated = True - logger.info("Handling permissions in configuration file") - - # Extract permissions / password from repo + # Extract permissions / password from repo if set full_config = extract_permissions_from_full_config(full_config) # save config file if needed @@ -744,7 +741,7 @@ def _make_list(key: str, value: Union[str, int, float, dict, list]) -> Any: def save_config(config_file: Path, full_config: dict) -> bool: try: with open(config_file, "w", encoding="utf-8") as file_handle: - _, full_config = inject_permissions_into_full_config(full_config) + full_config = inject_permissions_into_full_config(full_config) if not is_encrypted(full_config): full_config = crypt_config( @@ -756,6 +753,8 @@ def save_config(config_file: Path, full_config: dict) -> bool: full_config = crypt_config( full_config, AES_KEY, ENCRYPTED_OPTIONS, operation="decrypt" ) + # We also need to extract permissions again + full_config = extract_permissions_from_full_config(full_config) return True except OSError: logger.critical(f"Cannot save configuration file to {config_file}") diff --git a/npbackup/core/runner.py b/npbackup/core/runner.py index 3cc5a9c..f646a6c 100644 --- a/npbackup/core/runner.py +++ b/npbackup/core/runner.py @@ -351,6 +351,13 @@ def wrapper(self, *args, **kwargs): def has_permission(fn: Callable): """ Decorator that checks permissions before running functions + + Possible permissions are: + - backup: Backup and list backups + - restore: Backup, restore and list snapshots + - full: Full permissions + + Only one permission can be set per repo """ @wraps(fn) @@ -383,12 +390,11 @@ def wrapper(self, *args, **kwargs): operation = fn.__name__ current_permissions = self.repo_config.g("permissions") - self.write_logs( - f"Permissions required for operation \'{operation}\' are {required_permissions[operation]}, current permissions are {current_permissions}", - level="info", - ) - has_permissions = True # TODO: enforce permissions - if not has_permissions: + if not current_permissions in required_permissions[operation]: + self.write_logs( + f"Permissions required for operation \'{operation}\' are {required_permissions[operation]}, current permissions are {current_permissions}", + level="critical", + ) raise PermissionError except (IndexError, KeyError, PermissionError): self.write_logs("You don't have sufficient permissions", level="error") From a801925571c348df2e4c7ff652a95ac3307a7629 Mon Sep 17 00:00:00 2001 From: deajan Date: Thu, 11 Apr 2024 01:32:30 +0200 Subject: [PATCH 283/328] Reformat files with black --- npbackup/configuration.py | 8 ++++---- npbackup/core/runner.py | 2 +- npbackup/gui/__main__.py | 1 - npbackup/gui/config.py | 4 +--- npbackup/restic_metrics/__init__.py | 4 +++- 5 files changed, 9 insertions(+), 10 deletions(-) diff --git a/npbackup/configuration.py b/npbackup/configuration.py index 359a38a..6bd0185 100644 --- a/npbackup/configuration.py +++ b/npbackup/configuration.py @@ -452,16 +452,16 @@ def inject_permissions_into_full_config(full_config: dict) -> Tuple[bool, dict]: f"repos.{repo}.__current_manager_password" ) - if ( - __current_manager_password and manager_password - ): + if __current_manager_password and manager_password: if __current_manager_password == manager_password: full_config.s( f"repos.{repo}.repo_uri", (repo_uri, permissions, manager_password) ) full_config.s(f"repos.{repo}.is_protected", True) else: - logger.error(f"Wrong manager password given for repo {repo}. Will not update permissions") + logger.error( + f"Wrong manager password given for repo {repo}. Will not update permissions" + ) else: logger.debug(f"Permissions exist for repo {repo}") diff --git a/npbackup/core/runner.py b/npbackup/core/runner.py index f646a6c..f45376e 100644 --- a/npbackup/core/runner.py +++ b/npbackup/core/runner.py @@ -392,7 +392,7 @@ def wrapper(self, *args, **kwargs): current_permissions = self.repo_config.g("permissions") if not current_permissions in required_permissions[operation]: self.write_logs( - f"Permissions required for operation \'{operation}\' are {required_permissions[operation]}, current permissions are {current_permissions}", + f"Permissions required for operation '{operation}' are {required_permissions[operation]}, current permissions are {current_permissions}", level="critical", ) raise PermissionError diff --git a/npbackup/gui/__main__.py b/npbackup/gui/__main__.py index 0d534e8..2257988 100644 --- a/npbackup/gui/__main__.py +++ b/npbackup/gui/__main__.py @@ -663,7 +663,6 @@ def get_config(config_file: str = None, window: sg.Window = None): config_file = None full_config = None backend_type = None - right_click_menu = ["", [_t("generic.destination")]] headings = [ diff --git a/npbackup/gui/config.py b/npbackup/gui/config.py index 3e50d7f..c615741 100644 --- a/npbackup/gui/config.py +++ b/npbackup/gui/config.py @@ -628,9 +628,7 @@ def object_layout() -> List[list]: ], [ sg.Input(visible=False, key="--ADD-PATHS-FILE--", enable_events=True), - sg.FilesBrowse( - _t("generic.add_files"), target="--ADD-PATHS-FILE--" - ), + sg.FilesBrowse(_t("generic.add_files"), target="--ADD-PATHS-FILE--"), sg.Input(visible=False, key="--ADD-PATHS-FOLDER--", enable_events=True), sg.FolderBrowse( _t("generic.add_folder"), target="--ADD-PATHS-FOLDER--" diff --git a/npbackup/restic_metrics/__init__.py b/npbackup/restic_metrics/__init__.py index 21df568..15c4bb3 100644 --- a/npbackup/restic_metrics/__init__.py +++ b/npbackup/restic_metrics/__init__.py @@ -236,7 +236,9 @@ def restic_json_to_prometheus( prom_metrics.append( 'restic_backup_failure{{{},timestamp="{}"}} {}'.format( - labels, int(datetime.now(timezone.utc).timestamp()), 1 if not good_backup else 0 + labels, + int(datetime.now(timezone.utc).timestamp()), + 1 if not good_backup else 0, ) ) From a396382fb388013fa95ab4787bc35e493b1560af Mon Sep 17 00:00:00 2001 From: deajan Date: Thu, 11 Apr 2024 09:01:12 +0200 Subject: [PATCH 284/328] Permission error should be critical --- npbackup/core/runner.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/npbackup/core/runner.py b/npbackup/core/runner.py index f45376e..8dfb3c9 100644 --- a/npbackup/core/runner.py +++ b/npbackup/core/runner.py @@ -397,7 +397,7 @@ def wrapper(self, *args, **kwargs): ) raise PermissionError except (IndexError, KeyError, PermissionError): - self.write_logs("You don't have sufficient permissions", level="error") + self.write_logs("You don't have sufficient permissions", level="critical") if self.json_output: js = { "result": False, From 0f82f2f49a673158d109545e822238f52aa8b0a5 Mon Sep 17 00:00:00 2001 From: deajan Date: Thu, 11 Apr 2024 19:22:29 +0200 Subject: [PATCH 285/328] Add live-output default for non json CLI output --- npbackup/core/runner.py | 15 ++++++++++++++- npbackup/restic_wrapper/__init__.py | 13 +++++++++++++ npbackup/runner_interface.py | 4 +++- 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/npbackup/core/runner.py b/npbackup/core/runner.py index 8dfb3c9..14f0d6f 100644 --- a/npbackup/core/runner.py +++ b/npbackup/core/runner.py @@ -136,6 +136,7 @@ def __init__(self): self._dry_run = False self._verbose = False + self._live_output = False self._json_output = False self.restic_runner = None self.minimum_backup_age = None @@ -182,6 +183,17 @@ def verbose(self, value): self.write_logs(msg, level="critical", raise_error="ValueError") self._verbose = value + @property + def live_output(self): + return self._live_output + + @live_output.setter + def live_output(self, value): + if not isinstance(value, bool): + msg = f"Bogus live_output parameter given: {value}" + self.write_logs(msg, level="critical", raise_error="ValueError") + self._live_output = value + @property def json_output(self): return self._json_output @@ -392,7 +404,7 @@ def wrapper(self, *args, **kwargs): current_permissions = self.repo_config.g("permissions") if not current_permissions in required_permissions[operation]: self.write_logs( - f"Permissions required for operation '{operation}' are {required_permissions[operation]}, current permissions are {current_permissions}", + f"Required permissions for operation '{operation}' must be in {required_permissions[operation]}, current permission is [{current_permissions}]", level="critical", ) raise PermissionError @@ -684,6 +696,7 @@ def _apply_config_to_restic_runner(self) -> bool: self.minimum_backup_age = 0 self.restic_runner.verbose = self.verbose + self.restic_runner.live_output = self.live_output self.restic_runner.json_output = self.json_output self.restic_runner.stdout = self.stdout self.restic_runner.stderr = self.stderr diff --git a/npbackup/restic_wrapper/__init__.py b/npbackup/restic_wrapper/__init__.py index fd5fede..e99eaac 100644 --- a/npbackup/restic_wrapper/__init__.py +++ b/npbackup/restic_wrapper/__init__.py @@ -46,6 +46,7 @@ def __init__( self.repository = str(repository).strip() self.password = str(password).strip() self._verbose = False + self._live_output = False self._dry_run = False self._json_output = False @@ -176,6 +177,17 @@ def verbose(self, value): self._verbose = value else: raise ValueError("Bogus verbose value given") + + @property + def live_output(self) -> bool: + return self._live_output + + @live_output.setter + def live_output(self, value): + if isinstance(value, bool): + self._live_output = value + else: + raise ValueError("Bogus live_output value given") @property def dry_run(self) -> bool: @@ -289,6 +301,7 @@ def executor( stop_on=self.stop_on, on_exit=self.on_exit, method="poller", + live_output=self._live_output, # Only on CLI non json mode check_interval=CHECK_INTERVAL, priority=self._priority, io_priority=self._priority, diff --git a/npbackup/runner_interface.py b/npbackup/runner_interface.py index 8be392b..dbfb3eb 100644 --- a/npbackup/runner_interface.py +++ b/npbackup/runner_interface.py @@ -33,11 +33,13 @@ def serialize_datetime(obj): def entrypoint(*args, **kwargs): + json_output = kwargs.pop("json_output") + npbackup_runner = NPBackupRunner() npbackup_runner.repo_config = kwargs.pop("repo_config") npbackup_runner.dry_run = kwargs.pop("dry_run") npbackup_runner.verbose = kwargs.pop("verbose") - json_output = kwargs.pop("json_output") + npbackup_runner.live_output = not json_output npbackup_runner.json_output = json_output result = npbackup_runner.__getattribute__(kwargs.pop("operation"))( **kwargs.pop("op_args"), __no_threads=True From bfb84d070f554ae3e705240ab338cbc9cea8ba4e Mon Sep 17 00:00:00 2001 From: deajan Date: Thu, 11 Apr 2024 19:22:58 +0200 Subject: [PATCH 286/328] GUI: fix permission value instead of key in config file --- npbackup/gui/config.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/npbackup/gui/config.py b/npbackup/gui/config.py index c615741..e6127ae 100644 --- a/npbackup/gui/config.py +++ b/npbackup/gui/config.py @@ -546,7 +546,7 @@ def set_permissions(full_config: dict, object_name: str) -> dict: layout = [ [ sg.Text(_t("config_gui.permissions"), size=(40, 1)), - sg.Combo(permissions, default_value=default_perm, key="-PERMISSIONS-"), + sg.Combo(permissions, default_value=default_perm, key="permissions"), ], [sg.HorizontalSeparator()], [ @@ -583,10 +583,12 @@ def set_permissions(full_config: dict, object_name: str) -> dict: _t("config_gui.manager_password_too_short"), keep_on_top=True ) continue - if not values["-PERMISSIONS-"] in permissions: + if not values["permissions"] in permissions: sg.PopupError(_t("generic.bogus_data_given"), keep_on_top=True) continue - repo_config.s("permissions", values["-PERMISSIONS-"]) + # Transform translet permission value into key + permission = get_key_from_value(combo_boxes["permissions"], values["permissions"]) + repo_config.s("permissions", permission) repo_config.s("manager_password", values["-MANAGER-PASSWORD-"]) break window.close() From 1e81274773d55aa7754d54a24097ee3d97f44233 Mon Sep 17 00:00:00 2001 From: deajan Date: Sat, 13 Apr 2024 14:33:06 +0200 Subject: [PATCH 287/328] Minimum backup size must be checked against total processed size --- npbackup/restic_metrics/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/npbackup/restic_metrics/__init__.py b/npbackup/restic_metrics/__init__.py index 15c4bb3..9a48ad6 100644 --- a/npbackup/restic_metrics/__init__.py +++ b/npbackup/restic_metrics/__init__.py @@ -228,7 +228,7 @@ def restic_json_to_prometheus( backup_too_small = False if minimum_backup_size_error: - if not restic_json["data_added"] or restic_json["data_added"] < int( + if not restic_json["total_bytes_processed"] or restic_json["total_bytes_processed"] < int( BytesConverter(str(minimum_backup_size_error).replace(" ", "")).bytes ): backup_too_small = True From a5d2e79bc47c57ae4ea19b0c09d038ba920cc18e Mon Sep 17 00:00:00 2001 From: deajan Date: Mon, 15 Apr 2024 20:59:57 +0200 Subject: [PATCH 288/328] Avoid double logging with live output --- npbackup/core/runner.py | 7 +++++-- npbackup/restic_wrapper/__init__.py | 5 +++++ npbackup/runner_interface.py | 13 +++++++++++-- 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/npbackup/core/runner.py b/npbackup/core/runner.py index 14f0d6f..97fde40 100644 --- a/npbackup/core/runner.py +++ b/npbackup/core/runner.py @@ -466,9 +466,9 @@ def wrapper(self, *args, **kwargs): with pidfile.PIDFile(pid_file): # pylint: disable=E1102 (not-callable) result = fn(self, *args, **kwargs) - except pidfile.AlreadyRunningError: + except pidfile.AlreadyRunningError as exc: self.write_logs( - f"There is already an {operation} operation running by NPBackup. Will not continue", + f"There is already an {operation} operation running by NPBackup: {exc}. Will not continue", level="critical", ) return False @@ -827,8 +827,11 @@ def has_recent_snapshot(self) -> bool: ) # Temporarily disable verbose and enable json result self.restic_runner.verbose = False + # Temporarily disable CLI live output which we don't really need here + self.restic_runner.live_output = False data = self.restic_runner.has_recent_snapshot(self.minimum_backup_age) self.restic_runner.verbose = self.verbose + self.restic_runner.live_output = self.live_output if self.json_output: return data diff --git a/npbackup/restic_wrapper/__init__.py b/npbackup/restic_wrapper/__init__.py index e99eaac..e070ce3 100644 --- a/npbackup/restic_wrapper/__init__.py +++ b/npbackup/restic_wrapper/__init__.py @@ -543,9 +543,14 @@ def is_init(self): We'll just check if snapshots can be read """ cmd = "snapshots" + + # Disable live output for this check + live_output = self.live_output + self.live_output = False self._is_init, output = self.executor( cmd, timeout=FAST_COMMANDS_TIMEOUT, errors_allowed=True ) + self.live_output = live_output if not self._is_init: self.write_logs(output, level="error") return self._is_init diff --git a/npbackup/runner_interface.py b/npbackup/runner_interface.py index dbfb3eb..0acf6e7 100644 --- a/npbackup/runner_interface.py +++ b/npbackup/runner_interface.py @@ -34,7 +34,7 @@ def serialize_datetime(obj): def entrypoint(*args, **kwargs): json_output = kwargs.pop("json_output") - + npbackup_runner = NPBackupRunner() npbackup_runner.repo_config = kwargs.pop("repo_config") npbackup_runner.dry_run = kwargs.pop("dry_run") @@ -46,7 +46,16 @@ def entrypoint(*args, **kwargs): ) if not json_output: if not isinstance(result, bool): - logger.info(f"{result}") + + # We need to temprarily remove the stdout handler + # Since we already get live output from the runner + # But we still need to log the result to our logfile + for handler in logger.handlers: + if handler.stream == sys.stdout: + logger.removeHandler(handler) + break + logger.info(f"\n{result}") + logger.addHandler(handler) logger.info(f"Operation finished with {'success' if result else 'failure'}") else: print(json.dumps(result, default=serialize_datetime)) From aad0b1a7cbc15169f119d2b7b9727ca8df82e7dc Mon Sep 17 00:00:00 2001 From: deajan Date: Mon, 15 Apr 2024 21:05:44 +0200 Subject: [PATCH 289/328] Fix inverted metrics result --- npbackup/core/runner.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/npbackup/core/runner.py b/npbackup/core/runner.py index 97fde40..8be9273 100644 --- a/npbackup/core/runner.py +++ b/npbackup/core/runner.py @@ -79,13 +79,13 @@ def metric_writer( if isinstance(result_string, str): restic_result = restic_str_output_to_json(restic_result, result_string) - errors, metrics, backup_too_small = restic_json_to_prometheus( + good_backup, metrics, backup_too_small = restic_json_to_prometheus( restic_result=restic_result, restic_json=restic_result, labels=labels, minimum_backup_size_error=minimum_backup_size_error, ) - if errors or not restic_result: + if not good_backup or not restic_result: logger.error("Restic finished with errors.") if repo_config.g("prometheus.metrics") and destination: logger.debug("Uploading metrics to {}".format(destination)) From 58f2bd87337a16e6fa50bddc32d98990566f5b4b Mon Sep 17 00:00:00 2001 From: deajan Date: Mon, 15 Apr 2024 21:41:19 +0200 Subject: [PATCH 290/328] WIP: config GUI must ignore some keys --- npbackup/gui/config.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/npbackup/gui/config.py b/npbackup/gui/config.py index e6127ae..bf545f4 100644 --- a/npbackup/gui/config.py +++ b/npbackup/gui/config.py @@ -220,7 +220,7 @@ def update_gui_values(key, value, inherited, object_type, unencrypted): try: # Don't bother to update repo name # Also permissions / manager_password are in a separate gui - if key in ("name", "permissions", "manager_password"): + if key in ("name", "permissions", "manager_password", "__current_manager_password", "is_protected"): return # Don't show sensible info unless unencrypted requested if not unencrypted: @@ -578,6 +578,7 @@ def set_permissions(full_config: dict, object_name: str) -> dict: keep_on_top=True, ) continue + # TODO: Check password strength in a better way than this ^^ if len(values["-MANAGER-PASSWORD-"]) < 8: sg.PopupError( _t("config_gui.manager_password_too_short"), keep_on_top=True From 94f26468d6bc32ed9def9e17f74f3694206e09f6 Mon Sep 17 00:00:00 2001 From: deajan Date: Mon, 15 Apr 2024 22:20:35 +0200 Subject: [PATCH 291/328] Don't show group icons when adding elements to repos/groups --- npbackup/gui/config.py | 23 +++++++++-------------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/npbackup/gui/config.py b/npbackup/gui/config.py index bf545f4..87707d4 100644 --- a/npbackup/gui/config.py +++ b/npbackup/gui/config.py @@ -511,7 +511,11 @@ def update_config_dict(full_config, values): inherited = full_config.g(inheritance_key) else: inherited = False - # WIP print(f"UPDATING {active_object_key} curr={current_value} inherited={inherited} new={value}") + + if object_type == "group": + print(f"UPDATING {active_object_key} curr={current_value} new={value}") + else: + print(f"UPDATING {active_object_key} curr={current_value} inherited={inherited} new={value}") # if not full_config.g(active_object_key): # full_config.s(active_object_key, CommentedMap()) @@ -1612,7 +1616,7 @@ def config_layout() -> List[list]: if event in (sg.WIN_CLOSED, sg.WIN_X_EVENT, "--CANCEL--"): break if event == "-OBJECT-SELECT-": - update_config_dict(full_config, values) + full_config = update_config_dict(full_config, values) update_object_gui(values["-OBJECT-SELECT-"], unencrypted=False) update_global_gui(full_config, unencrypted=False) if event == "-OBJECT-DELETE-": @@ -1644,18 +1648,12 @@ def config_layout() -> List[list]: key = "backup_opts.exclude_files" tree = exclude_files_tree node = values[event] - if object_type == "group": - icon = INHERITED_FILE_ICON - else: - icon = FILE_ICON + icon = FILE_ICON elif event == "--ADD-PATHS-FOLDER--": key = "backup_opts.paths" tree = backup_paths_tree node = values[event] - if object_type == "group": - icon = INHERITED_FOLDER_ICON - else: - icon = FOLDER_ICON + icon = FOLDER_ICON tree.insert("", node, node, node, icon=icon) window[key].update(values=tree) if event in ( @@ -1711,10 +1709,7 @@ def config_layout() -> List[list]: option_key = "env.env_variables" if event.startswith("--ADD-"): - if object_type == "group": - icon = INHERITED_TREE_ICON - else: - icon = TREE_ICON + icon = TREE_ICON if "ENV-VARIABLE" in event: var_name = sg.PopupGetText(_t("config_gui.enter_var_name")) var_value = sg.PopupGetText(_t("config_gui.enter_var_value")) From fb0e296e85072ee748e81e0ca6311cdc64a37ac7 Mon Sep 17 00:00:00 2001 From: deajan Date: Mon, 15 Apr 2024 23:17:09 +0200 Subject: [PATCH 292/328] Patch PySimpleGUI values not getting Treedata values --- npbackup/gui/config.py | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/npbackup/gui/config.py b/npbackup/gui/config.py index 87707d4..363cfa8 100644 --- a/npbackup/gui/config.py +++ b/npbackup/gui/config.py @@ -39,6 +39,7 @@ # Monkeypatching PySimpleGUI +# @PySimpleGUI: Why is there no delete method for TreeData ? def delete(self, key): if key == "": return False @@ -442,7 +443,7 @@ def update_object_gui(object_name=None, unencrypted=False): object_config, config_inheritance, object_type, unencrypted, None ) - def update_global_gui(full_config, unencrypted=False): + def update_global_gui(full_config, unencrypted: bool = False): global_config = CommentedMap() # Only update global options gui with identified global keys @@ -451,7 +452,7 @@ def update_global_gui(full_config, unencrypted=False): global_config.s(key, full_config.g(key)) iter_over_config(global_config, None, "group", unencrypted, None) - def update_config_dict(full_config, values): + def update_config_dict(full_config, values: dict) -> dict: """ Update full_config with keys from GUI keys should always have form section.name or section.subsection.name @@ -1615,7 +1616,28 @@ def config_layout() -> List[list]: object_type, _ = get_object_from_combo(values["-OBJECT-SELECT-"]) if event in (sg.WIN_CLOSED, sg.WIN_X_EVENT, "--CANCEL--"): break + + # We need to patch values since sg.Tree() only returns selected data from TreeData() + # @PysimpleGUI: there should be a get_all_values() method or something + tree_data_keys = [ + "backup_opts.paths", + "backup_opts.tags", + "backup_opts.pre_exec_commands", + "backup_opts.post_exec_commands", + "backup_opts.exclude_files", + "backup_opts.exclude_patterns", + "prometheus.additional_labels", + "env.env_variables", + "env.encrypted_env_variables", + ] + for tree_data_key in tree_data_keys: + values[tree_data_key] = [] + for node in window[tree_data_key].TreeData.tree_dict.values(): + if node.values: + values[tree_data_key].append(node.values) + if event == "-OBJECT-SELECT-": + # Update gui with earlier modifications full_config = update_config_dict(full_config, values) update_object_gui(values["-OBJECT-SELECT-"], unencrypted=False) update_global_gui(full_config, unencrypted=False) From f5421abc9514cc65df5e00ced8a6674282a5c5e9 Mon Sep 17 00:00:00 2001 From: deajan Date: Mon, 15 Apr 2024 23:48:58 +0200 Subject: [PATCH 293/328] Update config dict from current object before changing object --- npbackup/gui/config.py | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/npbackup/gui/config.py b/npbackup/gui/config.py index 363cfa8..1a7e62e 100644 --- a/npbackup/gui/config.py +++ b/npbackup/gui/config.py @@ -452,12 +452,11 @@ def update_global_gui(full_config, unencrypted: bool = False): global_config.s(key, full_config.g(key)) iter_over_config(global_config, None, "group", unencrypted, None) - def update_config_dict(full_config, values: dict) -> dict: + def update_config_dict(full_config, object_type, object_name, values: dict) -> dict: """ Update full_config with keys from GUI keys should always have form section.name or section.subsection.name """ - object_type, object_name = get_object_from_combo(values["-OBJECT-SELECT-"]) if object_type == "repo": object_group = full_config.g(f"repos.{object_name}.repo_group") else: @@ -1610,10 +1609,16 @@ def config_layout() -> List[list]: update_object_gui(get_objects()[0], unencrypted=False) update_global_gui(full_config, unencrypted=False) + # These contain object name/type so on object change we can update the current object before loading new one + current_object_type = None + current_object_name = None + while True: event, values = window.read() # Get object type for various delete operations - object_type, _ = get_object_from_combo(values["-OBJECT-SELECT-"]) + object_type, object_name = get_object_from_combo(values["-OBJECT-SELECT-"]) + if not current_object_type and not current_object_name: + current_object_type, current_object_name = object_type, object_name if event in (sg.WIN_CLOSED, sg.WIN_X_EVENT, "--CANCEL--"): break @@ -1637,10 +1642,12 @@ def config_layout() -> List[list]: values[tree_data_key].append(node.values) if event == "-OBJECT-SELECT-": - # Update gui with earlier modifications - full_config = update_config_dict(full_config, values) + # Update full_config with current object before updating + full_config = update_config_dict(full_config, current_object_type, current_object_name, values) + current_object_type, current_object_name = object_type, object_name update_object_gui(values["-OBJECT-SELECT-"], unencrypted=False) update_global_gui(full_config, unencrypted=False) + continue if event == "-OBJECT-DELETE-": full_config = delete_object(full_config, values["-OBJECT-SELECT-"]) update_object_selector() @@ -1650,7 +1657,6 @@ def config_layout() -> List[list]: update_object_selector() continue if event == "--SET-PERMISSIONS--": - object_type, object_name = get_object_from_combo(values["-OBJECT-SELECT-"]) manager_password = configuration.get_manager_password( full_config, object_name ) @@ -1678,6 +1684,7 @@ def config_layout() -> List[list]: icon = FOLDER_ICON tree.insert("", node, node, node, icon=icon) window[key].update(values=tree) + continue if event in ( "--ADD-TAG--", "--ADD-EXCLUDE-PATTERN--", @@ -1754,6 +1761,7 @@ def config_layout() -> List[list]: continue tree.delete(key) window[option_key].Update(values=tree) + continue if event == "--ACCEPT--": if ( not values["repo_opts.repo_password"] @@ -1769,14 +1777,15 @@ def config_layout() -> List[list]: break sg.PopupError(_t("config_gui.cannot_save_configuration"), keep_on_top=True) logger.info("Could not save configuration") + continue if event == _t("config_gui.show_decrypted"): - object_type, object_name = get_object_from_combo(values["-OBJECT-SELECT-"]) manager_password = configuration.get_manager_password( full_config, object_name ) if ask_manager_password(manager_password): update_object_gui(values["-OBJECT-SELECT-"], unencrypted=True) update_global_gui(full_config, unencrypted=True) + continue if event == "create_task": if os.name == "nt": result = create_scheduled_task( @@ -1788,5 +1797,6 @@ def config_layout() -> List[list]: sg.PopupError(_t("config_gui.scheduled_task_creation_failure")) else: sg.PopupError(_t("config_gui.scheduled_task_creation_failure")) + continue window.close() return full_config From e5165f5c6ec7bb3d965e4e4deb53f2e5ac9077fc Mon Sep 17 00:00:00 2001 From: deajan Date: Mon, 15 Apr 2024 23:49:59 +0200 Subject: [PATCH 294/328] Remove unnecessary import --- npbackup/gui/config.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/npbackup/gui/config.py b/npbackup/gui/config.py index 1a7e62e..7059a79 100644 --- a/npbackup/gui/config.py +++ b/npbackup/gui/config.py @@ -7,10 +7,10 @@ __author__ = "Orsiris de Jong" __copyright__ = "Copyright (C) 2022-2024 NetInvent" __license__ = "GPL-3.0-only" -__build__ = "2024020501" +__build__ = "2024041501" -from typing import List, Union, Tuple +from typing import List, Tuple import os import pathlib from logging import getLogger From b938378802f12c468997729a4add7626d414f123 Mon Sep 17 00:00:00 2001 From: deajan Date: Tue, 16 Apr 2024 00:08:09 +0200 Subject: [PATCH 295/328] Fix update_config_dict call args --- npbackup/gui/config.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/npbackup/gui/config.py b/npbackup/gui/config.py index 7059a79..44c6cc1 100644 --- a/npbackup/gui/config.py +++ b/npbackup/gui/config.py @@ -491,8 +491,8 @@ def update_config_dict(full_config, object_type, object_name, values: dict) -> d active_object_key = f"{object_type}s.{object_name}.{key}" current_value = full_config.g(active_object_key) - # Don't bother with inheritance on global options - if not key.startswith("global_options."): + # Don't bother with inheritance on global options and host identity + if not key.startswith("global_options.") and not key.startswith("identity."): # Don't update items that have been inherited from groups if object_group: inheritance_key = f"groups.{object_group}.{key}" @@ -526,7 +526,8 @@ def update_config_dict(full_config, object_type, object_name, values: dict) -> d if current_value == value: continue - # full_config.s(active_object_key, value) + full_config.s(active_object_key, value) + return full_config # TODO: Do we actually save every modified object or just the last ? @@ -1769,7 +1770,7 @@ def config_layout() -> List[list]: ): sg.PopupError(_t("config_gui.repo_password_cannot_be_empty")) continue - full_config = update_config_dict(full_config, values) + full_config = update_config_dict(full_config, current_object_type, current_object_name, values) result = configuration.save_config(config_file, full_config) if result: sg.Popup(_t("config_gui.configuration_saved"), keep_on_top=True) From dfb1000eb972d700f48581660a1392c310c90948 Mon Sep 17 00:00:00 2001 From: deajan Date: Tue, 16 Apr 2024 00:32:30 +0200 Subject: [PATCH 296/328] WIP config file update from GUI --- npbackup/gui/config.py | 28 +++++++++++++--------------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/npbackup/gui/config.py b/npbackup/gui/config.py index 44c6cc1..cf0414c 100644 --- a/npbackup/gui/config.py +++ b/npbackup/gui/config.py @@ -488,12 +488,14 @@ def update_config_dict(full_config, object_type, object_name, values: dict) -> d except ValueError: pass - active_object_key = f"{object_type}s.{object_name}.{key}" - current_value = full_config.g(active_object_key) - # Don't bother with inheritance on global options and host identity - if not key.startswith("global_options.") and not key.startswith("identity."): - # Don't update items that have been inherited from groups + if key.startswith("global_options") or key.startswith("identity"): + active_object_key = f"{key}" + current_value = full_config.g(active_object_key) + else: + active_object_key = f"{object_type}s.{object_name}.{key}" + current_value = full_config.g(active_object_key) + if object_group: inheritance_key = f"groups.{object_group}.{key}" # If object is a list, check which values are inherited from group and remove them @@ -511,13 +513,6 @@ def update_config_dict(full_config, object_type, object_name, values: dict) -> d inherited = full_config.g(inheritance_key) else: inherited = False - - if object_type == "group": - print(f"UPDATING {active_object_key} curr={current_value} new={value}") - else: - print(f"UPDATING {active_object_key} curr={current_value} inherited={inherited} new={value}") - # if not full_config.g(active_object_key): - # full_config.s(active_object_key, CommentedMap()) # Don't bother to update empty strings, empty lists and None if not current_value and not value: @@ -526,10 +521,13 @@ def update_config_dict(full_config, object_type, object_name, values: dict) -> d if current_value == value: continue + # Finally, update the config dictionary + if object_type == "group": + print(f"UPDATING {active_object_key} curr={current_value} new={value}") + else: + print(f"UPDATING {active_object_key} curr={current_value} inherited={inherited} new={value}") full_config.s(active_object_key, value) - return full_config - # TODO: Do we actually save every modified object or just the last ? def set_permissions(full_config: dict, object_name: str) -> dict: """ @@ -562,7 +560,7 @@ def set_permissions(full_config: dict, object_name: str) -> dict: size=(50, 1), password_char="*", ), - # sg.Button(_t("generic.change"), key="--CHANGE-MANAGER-PASSWORD--") + # sg.Button(_t("generic.change"), key="--CHANGE-MANAGER-PASSWORD--") # TODO ], [ sg.Push(), From 23905120852dd9bf021fa8eb0fe29b8547291f15 Mon Sep 17 00:00:00 2001 From: deajan Date: Tue, 16 Apr 2024 01:01:37 +0200 Subject: [PATCH 297/328] GUI: Special treatment for unit keys --- npbackup/gui/config.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/npbackup/gui/config.py b/npbackup/gui/config.py index cf0414c..69bb3b8 100644 --- a/npbackup/gui/config.py +++ b/npbackup/gui/config.py @@ -488,6 +488,24 @@ def update_config_dict(full_config, object_type, object_name, values: dict) -> d except ValueError: pass + # Glue value and units back together for config file + if key in ( + "backup_opts.minimum_backup_size_error", + "backup_opts.exclude_files_larger_than", + "repo_opts.upload_speed", + "repo_opts.download_speed", + ): + value = f"{value} {values[f'{key}_unit']}" + + # Don't update unit keys + if key in ( + "backup_opts.minimum_backup_size_error_unit", + "backup_opts.exclude_files_larger_than_unit", + "repo_opts.upload_speed_unit", + "repo_opts.download_speed_unit", + ): + continue + # Don't bother with inheritance on global options and host identity if key.startswith("global_options") or key.startswith("identity"): active_object_key = f"{key}" From 58917f011ff27eaf86027da3155e7fa40cc9115d Mon Sep 17 00:00:00 2001 From: deajan Date: Tue, 16 Apr 2024 01:14:19 +0200 Subject: [PATCH 298/328] Reformat files with black --- npbackup/core/runner.py | 4 +++- npbackup/gui/config.py | 28 +++++++++++++++++++++------- npbackup/restic_metrics/__init__.py | 6 +++--- npbackup/restic_wrapper/__init__.py | 6 +++--- 4 files changed, 30 insertions(+), 14 deletions(-) diff --git a/npbackup/core/runner.py b/npbackup/core/runner.py index 8be9273..648951a 100644 --- a/npbackup/core/runner.py +++ b/npbackup/core/runner.py @@ -409,7 +409,9 @@ def wrapper(self, *args, **kwargs): ) raise PermissionError except (IndexError, KeyError, PermissionError): - self.write_logs("You don't have sufficient permissions", level="critical") + self.write_logs( + "You don't have sufficient permissions", level="critical" + ) if self.json_output: js = { "result": False, diff --git a/npbackup/gui/config.py b/npbackup/gui/config.py index 69bb3b8..ebd864a 100644 --- a/npbackup/gui/config.py +++ b/npbackup/gui/config.py @@ -221,7 +221,13 @@ def update_gui_values(key, value, inherited, object_type, unencrypted): try: # Don't bother to update repo name # Also permissions / manager_password are in a separate gui - if key in ("name", "permissions", "manager_password", "__current_manager_password", "is_protected"): + if key in ( + "name", + "permissions", + "manager_password", + "__current_manager_password", + "is_protected", + ): return # Don't show sensible info unless unencrypted requested if not unencrypted: @@ -496,7 +502,7 @@ def update_config_dict(full_config, object_type, object_name, values: dict) -> d "repo_opts.download_speed", ): value = f"{value} {values[f'{key}_unit']}" - + # Don't update unit keys if key in ( "backup_opts.minimum_backup_size_error_unit", @@ -505,7 +511,7 @@ def update_config_dict(full_config, object_type, object_name, values: dict) -> d "repo_opts.download_speed_unit", ): continue - + # Don't bother with inheritance on global options and host identity if key.startswith("global_options") or key.startswith("identity"): active_object_key = f"{key}" @@ -543,7 +549,9 @@ def update_config_dict(full_config, object_type, object_name, values: dict) -> d if object_type == "group": print(f"UPDATING {active_object_key} curr={current_value} new={value}") else: - print(f"UPDATING {active_object_key} curr={current_value} inherited={inherited} new={value}") + print( + f"UPDATING {active_object_key} curr={current_value} inherited={inherited} new={value}" + ) full_config.s(active_object_key, value) return full_config @@ -609,7 +617,9 @@ def set_permissions(full_config: dict, object_name: str) -> dict: sg.PopupError(_t("generic.bogus_data_given"), keep_on_top=True) continue # Transform translet permission value into key - permission = get_key_from_value(combo_boxes["permissions"], values["permissions"]) + permission = get_key_from_value( + combo_boxes["permissions"], values["permissions"] + ) repo_config.s("permissions", permission) repo_config.s("manager_password", values["-MANAGER-PASSWORD-"]) break @@ -1660,7 +1670,9 @@ def config_layout() -> List[list]: if event == "-OBJECT-SELECT-": # Update full_config with current object before updating - full_config = update_config_dict(full_config, current_object_type, current_object_name, values) + full_config = update_config_dict( + full_config, current_object_type, current_object_name, values + ) current_object_type, current_object_name = object_type, object_name update_object_gui(values["-OBJECT-SELECT-"], unencrypted=False) update_global_gui(full_config, unencrypted=False) @@ -1786,7 +1798,9 @@ def config_layout() -> List[list]: ): sg.PopupError(_t("config_gui.repo_password_cannot_be_empty")) continue - full_config = update_config_dict(full_config, current_object_type, current_object_name, values) + full_config = update_config_dict( + full_config, current_object_type, current_object_name, values + ) result = configuration.save_config(config_file, full_config) if result: sg.Popup(_t("config_gui.configuration_saved"), keep_on_top=True) diff --git a/npbackup/restic_metrics/__init__.py b/npbackup/restic_metrics/__init__.py index 9a48ad6..d802b8a 100644 --- a/npbackup/restic_metrics/__init__.py +++ b/npbackup/restic_metrics/__init__.py @@ -228,9 +228,9 @@ def restic_json_to_prometheus( backup_too_small = False if minimum_backup_size_error: - if not restic_json["total_bytes_processed"] or restic_json["total_bytes_processed"] < int( - BytesConverter(str(minimum_backup_size_error).replace(" ", "")).bytes - ): + if not restic_json["total_bytes_processed"] or restic_json[ + "total_bytes_processed" + ] < int(BytesConverter(str(minimum_backup_size_error).replace(" ", "")).bytes): backup_too_small = True good_backup = restic_result and not backup_too_small diff --git a/npbackup/restic_wrapper/__init__.py b/npbackup/restic_wrapper/__init__.py index e070ce3..a46613f 100644 --- a/npbackup/restic_wrapper/__init__.py +++ b/npbackup/restic_wrapper/__init__.py @@ -177,7 +177,7 @@ def verbose(self, value): self._verbose = value else: raise ValueError("Bogus verbose value given") - + @property def live_output(self) -> bool: return self._live_output @@ -301,7 +301,7 @@ def executor( stop_on=self.stop_on, on_exit=self.on_exit, method="poller", - live_output=self._live_output, # Only on CLI non json mode + live_output=self._live_output, # Only on CLI non json mode check_interval=CHECK_INTERVAL, priority=self._priority, io_priority=self._priority, @@ -543,7 +543,7 @@ def is_init(self): We'll just check if snapshots can be read """ cmd = "snapshots" - + # Disable live output for this check live_output = self.live_output self.live_output = False From b4dbba087d963ca3f39958dfdd3a921d3b9a88ec Mon Sep 17 00:00:00 2001 From: deajan Date: Tue, 16 Apr 2024 01:14:27 +0200 Subject: [PATCH 299/328] Bump version --- npbackup/__version__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/npbackup/__version__.py b/npbackup/__version__.py index fe116c5..7f70918 100644 --- a/npbackup/__version__.py +++ b/npbackup/__version__.py @@ -9,8 +9,8 @@ __description__ = "NetPerfect Backup Client" __copyright__ = "Copyright (C) 2022-2024 NetInvent" __license__ = "GPL-3.0-only" -__build__ = "2024031001" -__version__ = "3.0.0-alpha3" +__build__ = "2024041601" +__version__ = "3.0.0-alpha4" import sys From 38c0287fd438e50da470770437841be2178f2828 Mon Sep 17 00:00:00 2001 From: deajan Date: Tue, 16 Apr 2024 01:17:50 +0200 Subject: [PATCH 300/328] Fix pylint false positive --- npbackup/gui/config.py | 1 + 1 file changed, 1 insertion(+) diff --git a/npbackup/gui/config.py b/npbackup/gui/config.py index ebd864a..1c807a8 100644 --- a/npbackup/gui/config.py +++ b/npbackup/gui/config.py @@ -1664,6 +1664,7 @@ def config_layout() -> List[list]: ] for tree_data_key in tree_data_keys: values[tree_data_key] = [] + # pylint: disable=E1101 (no-member) for node in window[tree_data_key].TreeData.tree_dict.values(): if node.values: values[tree_data_key].append(node.values) From fb4905aa030ad3c1cfdc25e167f71544a0185b79 Mon Sep 17 00:00:00 2001 From: deajan Date: Tue, 16 Apr 2024 07:39:58 +0200 Subject: [PATCH 301/328] CLI: Add --show-config parameter --- CHANGELOG | 3 +-- npbackup/__main__.py | 10 ++++++++++ npbackup/configuration.py | 26 +++++++++++++++++++++++++- 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 47c98cf..8228358 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -16,7 +16,6 @@ ## 3.0.0 !- Multi repository support (everything except repo URI can be inherited from groups) - Major config file rewrite, now repo can inherit common settings from repo groups - ! - New option --show-final-config to show inheritance in cli mode (gui mode has visual indicators) !- Group settings for repositories !- New operation planifier for backups / cleaning / checking repos - Current backup state now shows more precise backup state, including last backup date when relevant @@ -57,7 +56,7 @@ - Added minimum backup size upon which we declare that backup has failed - All bytes units now have automatic conversion of units (K/M/G/T/P bits/bytes or IEC bytes) - Refactored GUI and overall UX of configuration - + - New option --show-config to show inheritance in CLI mode (GUI has visual indicators) ## Fixes - Default exit code is now worst log level called - Show anonymized repo uri in GUI diff --git a/npbackup/__main__.py b/npbackup/__main__.py index fd06969..7b99929 100644 --- a/npbackup/__main__.py +++ b/npbackup/__main__.py @@ -224,6 +224,11 @@ def cli_interface(): required=False, help="Optional path for logfile", ) + parser.add_argument( + "--show-config", + action="store_true", + help="Show full inherited configuration for current repo" + ) args = parser.parse_args() if args.log_file: @@ -290,6 +295,11 @@ def cli_interface(): msg = "Cannot find repo config" json_error_logging(False, msg, "critical") sys.exit(72) + + if args.show_config: + repo_config = npbackup.configuration.get_anonymous_repo_config(repo_config) + print(json.dumps(repo_config, indent=4)) + sys.exit(0) # Prepare program run cli_args = { diff --git a/npbackup/configuration.py b/npbackup/configuration.py index 6bd0185..d8e1a73 100644 --- a/npbackup/configuration.py +++ b/npbackup/configuration.py @@ -244,7 +244,7 @@ def convert_to( return convert_to(full_config) -def key_should_be_encrypted(key, encrypted_options: List[str]): +def key_should_be_encrypted(key: str, encrypted_options: List[str]): """ Checks whether key should be encrypted """ @@ -783,3 +783,27 @@ def get_repos_by_group(full_config: dict, group: str) -> List[str]: ): repo_list.append(repo) return repo_list + + +def get_anonymous_repo_config(repo_config: dict) -> dict: + """ + Replace each encrypted value with + """ + def _get_anonymous_repo_config(key: str, value: Any) -> Any: + if key_should_be_encrypted(key, ENCRYPTED_OPTIONS): + if isinstance(value, list): + for i, _ in enumerate(value): + value[i] = "__(o_O)__" + else: + value = "__(o_O)__" + return value + + # NPF-SEC-00008: Don't show manager password / sensible data with --show-config + repo_config.pop("manager_password", None) + repo_config.pop("__current_manager_password", None) + return replace_in_iterable( + repo_config, + _get_anonymous_repo_config, + callable_wants_key=True, + callable_wants_root_key=True, + ) \ No newline at end of file From 3a9ce781b178ea1e24af4184eeee412676dd996d Mon Sep 17 00:00:00 2001 From: deajan Date: Tue, 16 Apr 2024 08:15:05 +0200 Subject: [PATCH 302/328] CLI: Make binary configurable --- npbackup/__main__.py | 16 ++++++++++++ npbackup/core/runner.py | 38 +++++++++++++++++++---------- npbackup/restic_wrapper/__init__.py | 3 ++- npbackup/runner_interface.py | 3 +++ 4 files changed, 46 insertions(+), 14 deletions(-) diff --git a/npbackup/__main__.py b/npbackup/__main__.py index 7b99929..ff20020 100644 --- a/npbackup/__main__.py +++ b/npbackup/__main__.py @@ -229,6 +229,13 @@ def cli_interface(): action="store_true", help="Show full inherited configuration for current repo" ) + parser.add_argument( + "--external-backend-binary", + type=str, + default=None, + required=False, + help="Full path to alternative external backend binary" + ) args = parser.parse_args() if args.log_file: @@ -296,6 +303,14 @@ def cli_interface(): json_error_logging(False, msg, "critical") sys.exit(72) + binary = None + if args.external_backend_binary: + binary = args.external_backend_binary + if not os.path.isfile(binary): + msg = f"External backend binary {binary} cannot be found." + json_error_logging(False, msg, "critical") + sys.exit(73) + if args.show_config: repo_config = npbackup.configuration.get_anonymous_repo_config(repo_config) print(json.dumps(repo_config, indent=4)) @@ -308,6 +323,7 @@ def cli_interface(): "dry_run": args.dry_run, "debug": args.debug, "json_output": args.json, + "binary": binary, "operation": None, "op_args": {}, } diff --git a/npbackup/core/runner.py b/npbackup/core/runner.py index 648951a..72c228c 100644 --- a/npbackup/core/runner.py +++ b/npbackup/core/runner.py @@ -138,6 +138,7 @@ def __init__(self): self._verbose = False self._live_output = False self._json_output = False + self._binary = None self.restic_runner = None self.minimum_backup_age = None self._exec_time = None @@ -235,6 +236,19 @@ def stderr(self, value): raise ValueError("Bogus stdout parameter given: {}".format(value)) self._stderr = value + @property + def binary(self): + return self._binary + + @binary.setter + def binary(self, value): + if ( + not isinstance(value, str) + or not os.path.isfile(value) + ): + raise ValueError("Backend binary {value} is not readable") + self._binary = value + @property def has_binary(self) -> bool: if self._is_ready: @@ -575,19 +589,6 @@ def create_restic_runner(self) -> bool: binary_search_paths=[BASEDIR, CURRENT_DIR], ) - if self.restic_runner.binary is None: - # Let's try to load our internal binary for dev purposes - arch = os_arch() - binary = get_restic_internal_binary(arch) - if binary: - self.restic_runner.binary = binary - version = self.restic_runner.binary_version - self.write_logs(f"Using dev binary {version}", level="info") - else: - self._is_ready = False - return False - return True - def _apply_config_to_restic_runner(self) -> bool: if not isinstance(self.restic_runner, ResticRunner): self.write_logs("Backend not ready", level="error") @@ -702,7 +703,18 @@ def _apply_config_to_restic_runner(self) -> bool: self.restic_runner.json_output = self.json_output self.restic_runner.stdout = self.stdout self.restic_runner.stderr = self.stderr + if self.binary: + self.restic_runner.binary = self.binary + if self.restic_runner.binary is None: + # Let's try to load our internal binary for dev purposes + arch = os_arch() + binary = get_restic_internal_binary(arch) + if binary: + self.restic_runner.binary = binary + else: + self._is_ready = False + return False return True def convert_to_json_output( diff --git a/npbackup/restic_wrapper/__init__.py b/npbackup/restic_wrapper/__init__.py index a46613f..3c54d49 100644 --- a/npbackup/restic_wrapper/__init__.py +++ b/npbackup/restic_wrapper/__init__.py @@ -54,7 +54,6 @@ def __init__( self._binary = None self.binary_search_paths = binary_search_paths - self._get_binary() self._is_init = None self._exec_time = None @@ -462,6 +461,8 @@ def binary(self, value): if not os.path.isfile(value): raise ValueError("Non existent binary given: {}".format(value)) self._binary = value + version = self.binary_version + self.write_logs(f"Using binary {version}", level="info") @property def binary_version(self) -> Optional[str]: diff --git a/npbackup/runner_interface.py b/npbackup/runner_interface.py index 0acf6e7..5fcddc6 100644 --- a/npbackup/runner_interface.py +++ b/npbackup/runner_interface.py @@ -34,6 +34,7 @@ def serialize_datetime(obj): def entrypoint(*args, **kwargs): json_output = kwargs.pop("json_output") + binary = kwargs.pop("binary", None) npbackup_runner = NPBackupRunner() npbackup_runner.repo_config = kwargs.pop("repo_config") @@ -41,6 +42,8 @@ def entrypoint(*args, **kwargs): npbackup_runner.verbose = kwargs.pop("verbose") npbackup_runner.live_output = not json_output npbackup_runner.json_output = json_output + if binary: + npbackup_runner.binary = binary result = npbackup_runner.__getattribute__(kwargs.pop("operation"))( **kwargs.pop("op_args"), __no_threads=True ) From e63fb7843a2f2c4134713d4f9cd580f63244af2c Mon Sep 17 00:00:00 2001 From: deajan Date: Tue, 16 Apr 2024 08:15:19 +0200 Subject: [PATCH 303/328] Update Changelog --- CHANGELOG | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 8228358..7a35de0 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -14,11 +14,9 @@ - manages multiple repos with generic key (restic key add) or specified key ## 3.0.0 - !- Multi repository support (everything except repo URI can be inherited from groups) - Major config file rewrite, now repo can inherit common settings from repo groups - !- Group settings for repositories + !- New operation planifier for backups / cleaning / checking repos - - Current backup state now shows more precise backup state, including last backup date when relevant !- Implemented retention policies ! - Optional time server update to make sure we don't drift before doing retention operations ! - Optional repo check befoire doing retention operations @@ -35,6 +33,7 @@ - New viewer mode allowing to browse/restore restic repositories without any NPBackup configuation !- Viewer can have a configuration file - Multi repository support + - Group settings for repositories !- Operation center -- GUI operation center allowing to mass execute actions on repos / groups !- CLI operation center via `--group-operation --repo-group=default_group` @@ -57,6 +56,8 @@ - All bytes units now have automatic conversion of units (K/M/G/T/P bits/bytes or IEC bytes) - Refactored GUI and overall UX of configuration - New option --show-config to show inheritance in CLI mode (GUI has visual indicators) + - Allow using external restic binary via --external-backend-binary parameter in CLI mode + ## Fixes - Default exit code is now worst log level called - Show anonymized repo uri in GUI @@ -64,6 +65,7 @@ - Fix Google cloud storage backend detection in repository uri ## Misc + - Current backup state now shows more precise backup state, including last backup date when relevant - Concurrency checks (pidfile checks) are now directly part of the runner - Allow a 30 seconds grace period for child processes to close before asking them nicely, and than not nicely to quit - Fully refactored prometheus metrics parser to be able to read restic standard or json outputs From 1d29b68e980410e59d3b6115a927d549fb047640 Mon Sep 17 00:00:00 2001 From: deajan Date: Tue, 16 Apr 2024 14:39:52 +0200 Subject: [PATCH 304/328] GUI: Fix missing result code when not restoring --- npbackup/gui/__main__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/npbackup/gui/__main__.py b/npbackup/gui/__main__.py index 2257988..f5ab1a2 100644 --- a/npbackup/gui/__main__.py +++ b/npbackup/gui/__main__.py @@ -351,6 +351,7 @@ def _restore_window( window = sg.Window( _t("main_gui.restoration"), layout=layout, grab_anywhere=True, keep_on_top=False ) + result = None while True: event, values = window.read() if event in (sg.WIN_CLOSED, sg.WIN_X_EVENT, "cancel"): From ccf16ca12e8040f79adc90d8b627941f70a7d249 Mon Sep 17 00:00:00 2001 From: deajan Date: Wed, 17 Apr 2024 23:56:41 +0200 Subject: [PATCH 305/328] No permission restrictions means all permissions --- npbackup/core/runner.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/npbackup/core/runner.py b/npbackup/core/runner.py index 72c228c..026199d 100644 --- a/npbackup/core/runner.py +++ b/npbackup/core/runner.py @@ -384,6 +384,7 @@ def has_permission(fn: Callable): - full: Full permissions Only one permission can be set per repo + When no permission is set, assume full permissions """ @wraps(fn) @@ -416,7 +417,7 @@ def wrapper(self, *args, **kwargs): operation = fn.__name__ current_permissions = self.repo_config.g("permissions") - if not current_permissions in required_permissions[operation]: + if current_permissions and not current_permissions in required_permissions[operation]: self.write_logs( f"Required permissions for operation '{operation}' must be in {required_permissions[operation]}, current permission is [{current_permissions}]", level="critical", From 04bc7b6a1d1ef0bad2ac240baa0fe05510fa2474 Mon Sep 17 00:00:00 2001 From: deajan Date: Thu, 18 Apr 2024 00:39:42 +0200 Subject: [PATCH 306/328] Runner: group_runner is now json compatible --- npbackup/core/runner.py | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/npbackup/core/runner.py b/npbackup/core/runner.py index 026199d..d7cf912 100644 --- a/npbackup/core/runner.py +++ b/npbackup/core/runner.py @@ -1314,19 +1314,28 @@ def group_runner(self, repo_config_list: List, operation: str, **kwargs) -> bool }, } + js = {"result": None, "details": []} + for repo_config in repo_config_list: repo_name = repo_config.g("name") self.write_logs(f"Running {operation} for repo {repo_name}", level="info") self.repo_config = repo_config result = self.__getattribute__(operation)(**kwargs) - if result: - self.write_logs( - f"Finished {operation} for repo {repo_name}", level="info" - ) + if self.json_output: + js["details"].append({repo_name: result}) else: - self.write_logs( - f"Operation {operation} failed for repo {repo_name}", level="error" - ) + if result: + self.write_logs( + f"Finished {operation} for repo {repo_name}", level="info" + ) + else: + self.write_logs( + f"Operation {operation} failed for repo {repo_name}", level="error" + ) + if not result: group_result = False self.write_logs("Finished execution group operations", level="info") + if self.json_output: + js["result"] = group_result + return js return group_result From 4b8af5d9180e947448a7e893a748c664c59e1323 Mon Sep 17 00:00:00 2001 From: deajan Date: Thu, 18 Apr 2024 00:40:06 +0200 Subject: [PATCH 307/328] get_repos_by_group() now accetps __all__ parameter --- npbackup/configuration.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/npbackup/configuration.py b/npbackup/configuration.py index d8e1a73..c2a4215 100644 --- a/npbackup/configuration.py +++ b/npbackup/configuration.py @@ -7,7 +7,7 @@ __author__ = "Orsiris de Jong" __copyright__ = "Copyright (C) 2022-2024 NetInvent" __license__ = "GPL-3.0-only" -__build__ = "2024041101" +__build__ = "2024041701" __version__ = "npbackup 3.0.0+" MIN_CONF_VERSION = 3.0 @@ -113,7 +113,7 @@ def d(self, path, sep="."): "repo_opts.repo_password", "repo_opts.repo_password_command", "prometheus.http_username", - "prometheus.http_username", + "prometheus.http_password", "env.encrypted_env_variables", "global_options.auto_upgrade_server_username", "global_options.auto_upgrade_server_password", @@ -604,9 +604,12 @@ def _inherit_group_settings( try: # Let's make a copy of config since it's a "pointer object" repo_config = deepcopy(full_config.g(f"repos.{repo_name}")) + if not repo_config: + logger.error(f"No repo with name {repo_name} found in config") + return None, None except KeyError: logger.error(f"No repo with name {repo_name} found in config") - return None + return None, None try: repo_group = full_config.g(f"repos.{repo_name}.repo_group") group_config = full_config.g(f"groups.{repo_group}") @@ -774,11 +777,15 @@ def get_group_list(full_config: dict) -> List[str]: def get_repos_by_group(full_config: dict, group: str) -> List[str]: + """ + Return repo list by group + If special group __all__ is given, return all repos + """ repo_list = [] if full_config: for repo in list(full_config.g("repos").keys()): if ( - full_config.g(f"repos.{repo}.repo_group") == group + (full_config.g(f"repos.{repo}.repo_group") == group or group == "__all__") and group not in repo_list ): repo_list.append(repo) From d08e8ce7538b1c5e115355f25d26333940776576 Mon Sep 17 00:00:00 2001 From: deajan Date: Thu, 18 Apr 2024 00:40:41 +0200 Subject: [PATCH 308/328] CLI: Implement --group-operation --- npbackup/__main__.py | 115 +++++++++++++++++++++++++---------- npbackup/runner_interface.py | 81 ++---------------------- 2 files changed, 86 insertions(+), 110 deletions(-) diff --git a/npbackup/__main__.py b/npbackup/__main__.py index ff20020..6baada9 100644 --- a/npbackup/__main__.py +++ b/npbackup/__main__.py @@ -71,9 +71,16 @@ def cli_interface(): "--repo-name", dest="repo_name", type=str, - default="default", + default=None, required=False, - help="Name of the repository to work with. Defaults to 'default'", + help="Name of the repository to work with. Defaults to 'default'. In group operation mode, this can be a comma separated list of repo names", + ) + parser.add_argument( + "--repo-group", + type=str, + default=None, + required=False, + help="Comme separated list of groups to work with. Can accept special name '__all__' to work with all repositories." ) parser.add_argument("-b", "--backup", action="store_true", help="Run a backup") parser.add_argument( @@ -167,11 +174,11 @@ def cli_interface(): help="Check if a recent snapshot exists", ) parser.add_argument( - "--restore-include", + "--restore-includes", type=str, default=None, required=False, - help="Restore only paths within include path", + help="Restore only paths within include path, comma separated list accepted", ) parser.add_argument( "--snapshot-id", @@ -236,6 +243,13 @@ def cli_interface(): required=False, help="Full path to alternative external backend binary" ) + parser.add_argument( + "--group-operation", + type=str, + default=None, + required=False, + help="Launch an operation on a group of repositories given by --repo-group" + ) args = parser.parse_args() if args.log_file: @@ -289,19 +303,22 @@ def cli_interface(): sys.exit(70) full_config = npbackup.configuration.load_config(CONFIG_FILE) - if full_config: - repo_config, _ = npbackup.configuration.get_repo_config( - full_config, args.repo_name - ) - else: + if not full_config: msg = "Cannot obtain repo config" json_error_logging(False, msg, "critical") sys.exit(71) - - if not repo_config: - msg = "Cannot find repo config" - json_error_logging(False, msg, "critical") - sys.exit(72) + + if not args.group_operation: + repo_name = None + if not args.repo_name: + repo_name = "default" + repo_config, _ = npbackup.configuration.get_repo_config(full_config, repo_name) + if not repo_config: + msg = "Cannot find repo config" + json_error_logging(False, msg, "critical") + sys.exit(72) + else: + repo_config = None binary = None if args.external_backend_binary: @@ -312,6 +329,7 @@ def cli_interface(): sys.exit(73) if args.show_config: + # Load an anonymous version of the repo config repo_config = npbackup.configuration.get_anonymous_repo_config(repo_config) print(json.dumps(repo_config, indent=4)) sys.exit(0) @@ -328,6 +346,8 @@ def cli_interface(): "op_args": {}, } + # On group operations, we also need to set op_args + if args.stdin: cli_args["operation"] = "backup" cli_args["op_args"] = { @@ -335,62 +355,91 @@ def cli_interface(): "read_from_stdin": True, "stdin_filename": args.stdin_filename if args.stdin_filename else None, } - elif args.backup: + elif args.backup or args.group_operation == "backup": cli_args["operation"] = "backup" cli_args["op_args"] = {"force": args.force} - elif args.restore: + elif args.restore or args.group_operation == "restore": + if args.restore_includes: + restore_includes = [include.strip() for include in args.restore_includes.split(',')] + else: + restore_includes = None cli_args["operation"] = "restore" cli_args["op_args"] = { "snapshot": args.snapshot_id, "target": args.restore, - "restore_include": args.restore_include, + "restore_includes": restore_includes, } - elif args.snapshots: + elif args.snapshots or args.group_operation == "snapshots": cli_args["operation"] = "snapshots" - elif args.list: + elif args.list or args.group_operation == "list": cli_args["operation"] = "list" cli_args["op_args"] = {"subject": args.list} - elif args.ls: + elif args.ls or args.group_operation == "ls": cli_args["operation"] = "ls" cli_args["op_args"] = {"snapshot": args.snapshot_id} - elif args.find: + elif args.find or args.group_operation == "find": cli_args["operation"] = "find" cli_args["op_args"] = {"path": args.find} - elif args.forget: + elif args.forget or args.group_operation == "forget": cli_args["operation"] = "forget" if args.forget == "policy": cli_args["op_args"] = {"use_policy": True} else: cli_args["op_args"] = {"snapshots": args.forget} - elif args.quick_check: + elif args.quick_check or args.group_operation == "quick_check": cli_args["operation"] = "check" - elif args.full_check: + elif args.full_check or args.group_operation == "full_check": cli_args["operation"] = "check" cli_args["op_args"] = {"read_data": True} - elif args.prune: + elif args.prune or args.group_operation == "prune": cli_args["operation"] = "prune" - elif args.prune_max: + elif args.prune_max or args.group_operation == "prune_max": cli_args["operation"] = "prune" cli_args["op_args"] = {"max": True} - elif args.unlock: + elif args.unlock or args.group_operation == "unlock": cli_args["operation"] = "unlock" - elif args.repair_index: + elif args.repair_index or args.group_operation == "repair_index": cli_args["operation"] = "repair" cli_args["op_args"] = {"subject": "index"} - elif args.repair_snapshots: + elif args.repair_snapshots or args.group_operation == "repair_snapshots": cli_args["operation"] = "repair" cli_args["op_args"] = {"subject": "snapshots"} - elif args.dump: + elif args.dump or args.group_operation == "dump": cli_args["operation"] = "dump" cli_args["op_args"] = {"path": args.dump} - elif args.stats: + elif args.stats or args.group_operation == "stats": cli_args["operation"] = "stats" - elif args.raw: + elif args.raw or args.group_operation == "raw": cli_args["operation"] = "raw" cli_args["op_args"] = {"command": args.raw} - elif args.has_recent_snapshot: + elif args.has_recent_snapshot or args.group_operation == "has_recent_snapshot": cli_args["operation"] = "has_recent_snapshot" + # Group operation mode + repo_config_list = [] + if args.group_operation: + if args.repo_group: + groups = [group.strip() for group in args.repo_group.split(',')] + for group in groups: + repos = npbackup.configuration.get_repos_by_group(full_config, group) + elif args.repo_name: + repos = [repo.strip() for repo in args.repo_name.split(',')] + else: + logger.critical("No repository names or groups have been provided for group operation. Please use --repo-group or --repo-name") + sys.exit(74) + for repo in repos: + repo_config, _ = npbackup.configuration.get_repo_config(full_config, repo) + repo_config_list.append(repo_config) + + logger.info(f"Running group operations for repos: {', '.join(repos)}") + + cli_args["operation"] = "group_runner" + cli_args["op_args"] = { + "repo_config_list": repo_config_list, + "operation": args.group_operation, + **cli_args["op_args"] + } + if cli_args["operation"]: entrypoint(**cli_args) else: diff --git a/npbackup/runner_interface.py b/npbackup/runner_interface.py index 5fcddc6..c76c540 100644 --- a/npbackup/runner_interface.py +++ b/npbackup/runner_interface.py @@ -33,11 +33,13 @@ def serialize_datetime(obj): def entrypoint(*args, **kwargs): + repo_config = kwargs.pop("repo_config") json_output = kwargs.pop("json_output") binary = kwargs.pop("binary", None) npbackup_runner = NPBackupRunner() - npbackup_runner.repo_config = kwargs.pop("repo_config") + if repo_config: + npbackup_runner.repo_config = repo_config npbackup_runner.dry_run = kwargs.pop("dry_run") npbackup_runner.verbose = kwargs.pop("verbose") npbackup_runner.live_output = not json_output @@ -66,79 +68,4 @@ def entrypoint(*args, **kwargs): def auto_upgrade(full_config: dict): - pass - - -""" -def interface(): - - # Program entry - if args.create_scheduled_task: - try: - result = create_scheduled_task( - executable_path=CURRENT_EXECUTABLE, - interval_minutes=int(args.create_scheduled_task), - ) - if result: - sys.exit(0) - else: - sys.exit(22) - except ValueError: - sys.exit(23) - - if args.upgrade_conf: - # Whatever we need to add here for future releases - # Eg: - - logger.info("Upgrading configuration file to version %s", __version__) - try: - config_dict["identity"] - except KeyError: - # Create new section identity, as per upgrade 2.2.0rc2 - config_dict["identity"] = {"machine_id": "${HOSTNAME}"} - configuration.save_config(CONFIG_FILE, config_dict) - sys.exit(0) - - # Try to perform an auto upgrade if needed - try: - auto_upgrade = config_dict["options"]["auto_upgrade"] - except KeyError: - auto_upgrade = True - try: - auto_upgrade_interval = config_dict["options"]["interval"] - except KeyError: - auto_upgrade_interval = 10 - - if (auto_upgrade and need_upgrade(auto_upgrade_interval)) or args.auto_upgrade: - if args.auto_upgrade: - logger.info("Running user initiated auto upgrade") - else: - logger.info("Running program initiated auto upgrade") - result = run_upgrade(full_config) - if result: - sys.exit(0) - elif args.auto_upgrade: - sys.exit(23) - - if args.list: - result = npbackup_runner.list() - if result: - for snapshot in result: - try: - tags = snapshot["tags"] - except KeyError: - tags = None - logger.info( - "ID: {} Hostname: {}, Username: {}, Tags: {}, source: {}, time: {}".format( - snapshot["short_id"], - snapshot["hostname"], - snapshot["username"], - tags, - snapshot["paths"], - dateutil.parser.parse(snapshot["time"]), - ) - ) - sys.exit(0) - else: - sys.exit(2) -""" + pass # TODO From 251ac5ae96d06e54ada8700a01620a9dbc7f328c Mon Sep 17 00:00:00 2001 From: deajan Date: Thu, 18 Apr 2024 00:41:00 +0200 Subject: [PATCH 309/328] Update README --- README.md | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index b03055c..ad7c72e 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,11 @@ Works on x64 **Linux** , **NAS** solutions based on arm/arm64, **Windows** x64 a - Windows pre-built executables - Windows installer - Additional security - - repository uri / password, http metrics and upgrade server passwords are AES-256 encrypted + - Repository uri / password, http metrics and upgrade server passwords are AES-256 encrypted + - Repository permissions allowing to limit clients + - Backup only permission + - Backup, list and restore permissions + - Full permissions including destructive operations - Encrypted data viewing requires additional password - AES-256 keys can't be guessed in executables thanks to Nuitka Commercial compiler - Easy configuration via YAML file (or via GUI) @@ -167,6 +171,12 @@ The current NPBackup dashboard: While admin user experience is important, NPBackup also offers a GUI for end user experience, allowing to list all backup contents, navigate and restore files, without the need of an admin. The end user can also check if they have a recent backup completed, and launch backups manually if needed. +## CLI usage + +`--group-operation [operation]` allows to run an operation on multiple repos. This paramater also requires `--repo-group` or `--repo-name` parameter. For operations requiring arguments, provide the argument to the original operation parameter. +`--repo-name` allows to specify one or multiple comma separated repo names +`--repo-group` allows to specify one or multiple comme separated repo group names + ## Security NPBackup inherits all security measures of it's backup backend (currently restic with AES-256 client side encryption including metadata), append only mode REST server backend. @@ -174,6 +184,17 @@ NPBackup inherits all security measures of it's backup backend (currently restic On top of those, NPBackup itself encrypts sensible information like the repo uri and password, as well as the metrics http username and password. This ensures that end users can restore data without the need to know any password, without compromising a secret. Note that in order to use this function, one needs to use the compiled version of NPBackup, so AES-256 keys are never exposed. Internally, NPBackup never directly uses the AES-256 key, so even a memory dump won't be enough to get the key. +## Permission restriction system + +By default, npbackup is allowed to execute all operations on a repo. +There are some situations where an administrator needs to restrict repo operations for end users. +In that case, you can set permissions via the GUI or directly in the configuration file. + +Permissions are: +- full: Set by default, allows all including destructive operations +- restore: Allows everything backup does plus restore, check and dump operations +- backup: Allows, backup and snapshot/object listing operations + ## Upgrade server NPBackup comes with integrated auto upgrade function that will run regardless of program failures, in order to lessen the maintenance burden. From 45a314ef9d674e994caf5fe34505aec9aa43b22d Mon Sep 17 00:00:00 2001 From: deajan Date: Thu, 18 Apr 2024 00:41:31 +0200 Subject: [PATCH 310/328] Update current progress --- CHANGELOG | 25 +++++++++++++------------ ROADMAP.md | 12 ++++-------- SECURITY.md | 7 ++++++- 3 files changed, 23 insertions(+), 21 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 7a35de0..e9da5f3 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,4 +1,4 @@ -## Current master +## Shortly planned ! - Add policy like restic forget --keep-within-daily 30d --keep-within-weekly 1m --keep-within-monthly 1y --keep-within-yearly 3y default policy restic forget --keep-within-hourly 72h --keep-within-daily 30d --keep-within-weekly 1m --keep-within-monthly 1y --keep-within-yearly 3y @@ -12,38 +12,38 @@ - Launch now - NPBackup Operation mode - manages multiple repos with generic key (restic key add) or specified key - + !- NTP server + !- Viewer can have a configuration file + ! - Reimplement autoupgrade + ## 3.0.0 - - Major config file rewrite, now repo can inherit common settings from repo groups + - This is a major rewrite that allows using multiple repositories, adds repository groups and implements repository settings inheritance from group settings !- New operation planifier for backups / cleaning / checking repos !- Implemented retention policies ! - Optional time server update to make sure we don't drift before doing retention operations - ! - Optional repo check befoire doing retention operations - - !- Backup admin password is now stored in a more secure way - !- Added backup client privileges + ! - Optional repo check before doing retention operations !- Pre and post-execution scripts ! - Multiple pre and post execution scripts are now allowed ! - Post-execution script can now be force run on error / exit ! - Script result now has prometheus metrics - !- NTP server + ## Features - New viewer mode allowing to browse/restore restic repositories without any NPBackup configuation - !- Viewer can have a configuration file + - Multi repository support - Group settings for repositories !- Operation center - -- GUI operation center allowing to mass execute actions on repos / groups - !- CLI operation center via `--group-operation --repo-group=default_group` + - GUI operation center allowing to mass execute actions on repos / groups + - CLI operation center via `--group-operation --repo-group=somegroup` !- Implemented retention policies !- Operation planifier allows to create scheduled tasks for operations !- Implemented scheduled task creator for Windows & Unix !(simple list of tasks, actions, stop on error) - Implemented repo quick check / full check / repair index / repair snapshots / unlock / forget / prune / dump / stats commands - Added per repo permission management - - Repos now have backup, restore and full privileges, allowing to restrict access for end users + - Repos now have backup, restore and full privileges, optionally allowing to restrict access for end users - Added snapshot tag to snapshot list on main window - Split npbackup into separate CLI and GUI - Status window has been refactored so GUI now has full stdout / stderr returns from runner and backend @@ -63,6 +63,7 @@ - Show anonymized repo uri in GUI - Fix deletion failed message for en lang - Fix Google cloud storage backend detection in repository uri + - Backup admin password is now stored in a more secure way ## Misc - Current backup state now shows more precise backup state, including last backup date when relevant diff --git a/ROADMAP.md b/ROADMAP.md index 5917e20..d76217f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,21 +1,19 @@ ## What's planned / considered ### Daemon mode - Instead of relying on scheduled tasks, we could launch backup & housekeeping operations as deamon. Caveats: - We need a windows service (nuitka commercial implements one) - We need to use apscheduler (wait for v4) - We need a resurrect service config for systemd and windows service -### Web interface - +### Web interface (planned) Since runner can discuss in JSON mode, we could simply wrap it all in FastAPI Caveats: - - We'll need a web interface, with templates, whistles and belles + - We'll need a web interface, with templates, whistles and bells - We'll probably need an executor (Celery ?) in order to not block threads -### KVM Backup plugin +### KVM Backup plugin (planned, already exists as external script) Since we run cube backup, we could "bake in" full KVM support Caveats: - We'll need to re-implement libvirt controller class for linux @@ -28,11 +26,9 @@ In the latter case, shell (bash, zsh, ksh) would need `shopt -o pipefail`, and m The pipefail will not be given to npbackup-cli, so we'd need to wrap everything into a script, which defeats the prometheus metrics. ### Key management - Possibility to add new keys to current repo, and delete old keys if more than one key present -### Provision server - +### Provision server (planned) Possibility to auto load repo settings for new instances from central server We actually could improve upgrade_server to do so diff --git a/SECURITY.md b/SECURITY.md index ff351c1..779f3f8 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -38,4 +38,9 @@ Hence, update permissions should only happen in two cases: Since encryption is symmetric, we need to protect our sensible data. Best ways: - Compile with alternative aes-key -- Use --aes-key with alternative aes-key which is protected by system \ No newline at end of file +- Use --aes-key with alternative aes-key which is protected by system + +# NPF-SEC-00008: Don't show manager password / sensible data with --show-config + +Since v3.0.0, we have config inheritance. Showing the actual config helps diag issues, but we need to be careful not +to show actual secrets. \ No newline at end of file From f863de24361cdb447259ee4ec82a0d783e36feb5 Mon Sep 17 00:00:00 2001 From: deajan Date: Thu, 18 Apr 2024 12:23:33 +0200 Subject: [PATCH 311/328] Regorganized auto upgrade code --- npbackup/core/upgrade_runner.py | 77 +++++++++++++++++++++++++++-- npbackup/upgrade_client/upgrader.py | 70 +------------------------- 2 files changed, 75 insertions(+), 72 deletions(-) diff --git a/npbackup/core/upgrade_runner.py b/npbackup/core/upgrade_runner.py index 0093189..7bea344 100644 --- a/npbackup/core/upgrade_runner.py +++ b/npbackup/core/upgrade_runner.py @@ -7,24 +7,93 @@ __author__ = "Orsiris de Jong" __copyright__ = "Copyright (C) 2022-2024 NetInvent" __license__ = "GPL-3.0-only" -__build__ = "2023040401" +__build__ = "2024041801" +import os +from typing import Optional +import tempfile from logging import getLogger -from npbackup import configuration from npbackup.upgrade_client.upgrader import auto_upgrader, _check_new_version from npbackup.__version__ import __version__ as npbackup_version +from npbackup.path_helper import CURRENT_DIR logger = getLogger() +def need_upgrade(upgrade_interval: int) -> bool: + """ + Basic counter which allows an upgrade only every X times this is called so failed operations won't end in an endless upgrade loop + + We need to make to select a write counter file that is writable + So we actually test a local file and a temp file (less secure for obvious reasons) + We just have to make sure that once we can write to one file, we stick to it unless proven otherwise + + The for loop logic isn't straight simple, but allows file fallback + """ + # file counter, local, home, or temp if not available + counter_file = "npbackup.autoupgrade.log" + + def _write_count(file: str, count: int) -> bool: + try: + with open(file, "w") as fpw: + fpw.write(str(count)) + return True + except OSError: + # We may not have write privileges, hence we need a backup plan + return False + + def _get_count(file: str) -> Optional[int]: + try: + with open(file, "r") as fpr: + count = int(fpr.read()) + return count + except OSError: + # We may not have read privileges + None + except ValueError: + logger.error("Bogus upgrade counter in %s", file) + return None + + try: + upgrade_interval = int(upgrade_interval) + except ValueError: + logger.error("Bogus upgrade interval given. Will not upgrade") + return False + + for file in [ + os.path.join(CURRENT_DIR, counter_file), + os.path.join(tempfile.gettempdir(), counter_file), + ]: + if not os.path.isfile(file): + if _write_count(file, 1): + logger.debug("Initial upgrade counter written to %s", file) + else: + logger.debug("Cannot write to upgrade counter file %s", file) + continue + count = _get_count(file) + # Make sure we can write to the file before we make any assumptions + result = _write_count(file, count + 1) + if result: + if count >= upgrade_interval: + # Reinitialize upgrade counter before we actually approve upgrades + if _write_count(file, 1): + logger.info("Auto upgrade has decided upgrade check is required") + return True + break + else: + logger.debug("Cannot write upgrade counter to %s", file) + continue + return False + + def check_new_version(full_config: dict) -> bool: upgrade_url = full_config.g("global_options.auto_upgrade_server_url") username = full_config.g("global_options.auto_upgrade_server_username") password = full_config.g("global_options.auto_upgrade_server_password") if not upgrade_url or not username or not password: - logger.error(f"Missing auto upgrade info, cannot launch auto upgrade") + logger.warning(f"Missing auto upgrade info, cannot launch auto upgrade") return None else: return _check_new_version(upgrade_url, username, password) @@ -35,7 +104,7 @@ def run_upgrade(full_config: dict) -> bool: username = full_config.g("global_options.auto_upgrade_server_username") password = full_config.g("global_options.auto_upgrade_server_password") if not upgrade_url or not username or not password: - logger.error(f"Missing auto upgrade info, cannot launch auto upgrade") + logger.warning(f"Missing auto upgrade info, cannot launch auto upgrade") return False auto_upgrade_host_identity = full_config.g( diff --git a/npbackup/upgrade_client/upgrader.py b/npbackup/upgrade_client/upgrader.py index 9e7de79..ab508a4 100644 --- a/npbackup/upgrade_client/upgrader.py +++ b/npbackup/upgrade_client/upgrader.py @@ -7,7 +7,7 @@ __author__ = "Orsiris de Jong" __copyright__ = "Copyright (C) 2023-2024 NetInvent" __license__ = "BSD-3-Clause" -__build__ = "20263040401" +__build__ = "2024041801" from typing import Optional @@ -21,7 +21,7 @@ from ofunctions.process import kill_childs from command_runner import deferred_command from npbackup.upgrade_client.requestor import Requestor -from npbackup.path_helper import CURRENT_DIR, CURRENT_EXECUTABLE +from npbackup.path_helper import CURRENT_EXECUTABLE from npbackup.core.nuitka_helper import IS_COMPILED from npbackup.__version__ import __version__ as npbackup_version @@ -41,72 +41,6 @@ def sha256sum_data(data): return sha256.hexdigest() -def need_upgrade(upgrade_interval: int) -> bool: - """ - Basic counter which allows an upgrade only every X times this is called so failed operations won't end in an endless upgrade loop - - We need to make to select a write counter file that is writable - So we actually test a local file and a temp file (less secure for obvious reasons) - We just have to make sure that once we can write to one file, we stick to it unless proven otherwise - - The for loop logic isn't straight simple, but allows file fallback - """ - # file counter, local, home, or temp if not available - counter_file = "npbackup.autoupgrade.log" - - def _write_count(file: str, count: int) -> bool: - try: - with open(file, "w") as fpw: - fpw.write(str(count)) - return True - except OSError: - # We may not have write privileges, hence we need a backup plan - return False - - def _get_count(file: str) -> Optional[int]: - try: - with open(file, "r") as fpr: - count = int(fpr.read()) - return count - except OSError: - # We may not have read privileges - None - except ValueError: - logger.error("Bogus upgrade counter in %s", file) - return None - - try: - upgrade_interval = int(upgrade_interval) - except ValueError: - logger.error("Bogus upgrade interval given. Will not upgrade") - return False - - for file in [ - os.path.join(CURRENT_DIR, counter_file), - os.path.join(tempfile.gettempdir(), counter_file), - ]: - if not os.path.isfile(file): - if _write_count(file, 1): - logger.debug("Initial upgrade counter written to %s", file) - else: - logger.debug("Cannot write to upgrade counter file %s", file) - continue - count = _get_count(file) - # Make sure we can write to the file before we make any assumptions - result = _write_count(file, count + 1) - if result: - if count >= upgrade_interval: - # Reinitialize upgrade counter before we actually approve upgrades - if _write_count(file, 1): - logger.info("Auto upgrade has decided upgrade check is required") - return True - break - else: - logger.debug("Cannot write upgrade counter to %s", file) - continue - return False - - def _check_new_version(upgrade_url: str, username: str, password: str) -> bool: """ Check if we have a newer version of npbackup From cb5cf0595caacfe038a7079698fcdc7e05dec518 Mon Sep 17 00:00:00 2001 From: deajan Date: Thu, 18 Apr 2024 12:23:45 +0200 Subject: [PATCH 312/328] CLI: re-implement auto upgrade --- npbackup/__main__.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/npbackup/__main__.py b/npbackup/__main__.py index 6baada9..ed2735c 100644 --- a/npbackup/__main__.py +++ b/npbackup/__main__.py @@ -23,6 +23,7 @@ from npbackup.__version__ import version_string, version_dict from npbackup.__debug__ import _DEBUG from npbackup.common import execution_logs +from npbackup.core import upgrade_runner from npbackup.core.i18n_helper import _t if os.name == "nt": @@ -334,6 +335,30 @@ def cli_interface(): print(json.dumps(repo_config, indent=4)) sys.exit(0) + # Try to perform an auto upgrade if needed + try: + auto_upgrade = full_config["global_options"]["auto_upgrade"] + except KeyError: + auto_upgrade = True + try: + auto_upgrade_interval = full_config["global_options"]["auto_upgrade_interval"] + except KeyError: + auto_upgrade_interval = 10 + + if (auto_upgrade and upgrade_runner.need_upgrade(auto_upgrade_interval)) or args.auto_upgrade: + if args.auto_upgrade: + logger.info("Running user initiated auto upgrade") + else: + logger.info("Running program initiated auto upgrade") + result = upgrade_runner.run_upgrade(full_config) + if result: + sys.exit(0) + elif args.auto_upgrade: + logger.error("Auto upgrade failed") + sys.exit(23) + else: + logger.warning("Interval initiated auto upgrade failed") + # Prepare program run cli_args = { "repo_config": repo_config, From ef35b90372a771948c15bdff1c6b6b9ff77ee712 Mon Sep 17 00:00:00 2001 From: deajan Date: Thu, 18 Apr 2024 16:51:26 +0200 Subject: [PATCH 313/328] GUI: reimplement auto upgrade --- npbackup/gui/__main__.py | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/npbackup/gui/__main__.py b/npbackup/gui/__main__.py index f5ab1a2..779a74c 100644 --- a/npbackup/gui/__main__.py +++ b/npbackup/gui/__main__.py @@ -50,7 +50,7 @@ from npbackup.gui.operations import operations_gui from npbackup.gui.helpers import get_anon_repo_uri, gui_thread_runner from npbackup.core.i18n_helper import _t -from npbackup.core.upgrade_runner import run_upgrade, check_new_version +from npbackup.core import upgrade_runner from npbackup.path_helper import CURRENT_DIR from npbackup.__version__ import version_string from npbackup.__debug__ import _DEBUG @@ -64,11 +64,7 @@ sg.SetOptions(icon=OEM_ICON) -def about_gui(version_string: str, full_config: dict = None) -> None: - if full_config and full_config.g("global_options.auto_upgrade_server_url"): - auto_upgrade_result = check_new_version(full_config) - else: - auto_upgrade_result = None +def about_gui(version_string: str, full_config: dict = None, auto_upgrade_result: bool = False) -> None: if auto_upgrade_result: new_version = [ sg.Button( @@ -107,7 +103,7 @@ def about_gui(version_string: str, full_config: dict = None) -> None: ) if result == "OK": logger.info("Running GUI initiated upgrade") - sub_result = run_upgrade(full_config) + sub_result = upgrade_runner.upgrade(full_config) if sub_result: sys.exit(0) else: @@ -449,6 +445,17 @@ def _main_gui(viewer_mode: bool): if args.repo_name: repo_name = args.repo_name + def check_for_auto_upgrade(full_config: dict) -> None: + if full_config and full_config.g("global_options.auto_upgrade_server_url"): + auto_upgrade_result = upgrade_runner.check_new_version(full_config) + if auto_upgrade_result: + r = sg.Popup(_t("config_gui.auto_upgrade_launch"), custom_text=(_t("generic.yes"), _t("generic.no"))) + if r == _t("generic.yes"): + result = upgrade_runner.run_upgrade(full_config) + if not result: + sg.Popup(_t("config_gui.auto_upgrade_failed")) + + def select_config_file(config_file: str = None) -> None: """ Option to select a configuration file @@ -794,6 +801,8 @@ def get_config(config_file: str = None, window: sg.Window = None): ] ] + check_for_auto_upgrade(full_config) + window = sg.Window( SHORT_PRODUCT_NAME, layout, From 4d200dd265f8a43bdbe5d14517f3d35f99bd741c Mon Sep 17 00:00:00 2001 From: deajan Date: Thu, 18 Apr 2024 18:03:03 +0200 Subject: [PATCH 314/328] Notes about IEC bytes --- npbackup/configuration.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/npbackup/configuration.py b/npbackup/configuration.py index c2a4215..a17d8d6 100644 --- a/npbackup/configuration.py +++ b/npbackup/configuration.py @@ -164,7 +164,7 @@ def d(self, path, sep="."): "exclude_files_larger_than": None, "additional_parameters": None, "additional_backup_only_parameters": None, - "minimum_backup_size_error": "10MiB", # allows BytesConverter units + "minimum_backup_size_error": "10 MiB", # allows BytesConverter units "pre_exec_commands": [], "pre_exec_per_command_timeout": 3600, "pre_exec_failure_is_fatal": False, @@ -180,8 +180,8 @@ def d(self, path, sep="."): # Minimum time between two backups, in minutes # Set to zero in order to disable time checks "minimum_backup_age": 1440, - "upload_speed": "100Mb", # Mb(its) or MB(ytes), use 0 for unlimited upload speed - "download_speed": "0 MB", # in KiB, use 0 for unlimited download speed + "upload_speed": "100 Mib", # Mib(its) or MiB(ytes), use 0 for unlimited upload speed + "download_speed": "0", # in KiB, use 0 for unlimited download speed "backend_connections": 0, # Fine tune simultaneous connections to backend, use 0 for standard configuration "retention_strategy": { "last": 0, @@ -390,8 +390,9 @@ def _evaluate_variables(key, value): def expand_units(object_config: dict, unexpand: bool = False) -> dict: """ Evaluate human bytes notation - eg 50 KB to 500000 - and 500000 to 50 KB in unexpand mode + eg 50 KB to 50000 bytes + eg 50 KiB to 51200 bytes + and 50000 to 50 KB in unexpand mode """ def _expand_units(key, value): From d000673686c92a7dbc6d19bb205589f6832f8565 Mon Sep 17 00:00:00 2001 From: deajan Date: Thu, 18 Apr 2024 18:03:33 +0200 Subject: [PATCH 315/328] GUI: add repo group selector --- npbackup/gui/config.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/npbackup/gui/config.py b/npbackup/gui/config.py index 1c807a8..0c87ee4 100644 --- a/npbackup/gui/config.py +++ b/npbackup/gui/config.py @@ -217,10 +217,13 @@ def update_gui_values(key, value, inherited, object_type, unencrypted): window[key].Disabled = True else: window[key].Disabled = False + window[key].Update(value=value) + return try: # Don't bother to update repo name # Also permissions / manager_password are in a separate gui + # And we don't want to show __current_manager_password if key in ( "name", "permissions", @@ -321,6 +324,8 @@ def update_gui_values(key, value, inherited, object_type, unencrypted): "repo_opts.upload_speed", "repo_opts.download_speed", ): + # We don't need a better split here since the value string comes from BytesConverter + # which always provides "0 MiB" or "5 KB" etc. value, unit = value.split(" ") window[f"{key}_unit"].Update(unit) @@ -1089,7 +1094,7 @@ def object_layout() -> List[list]: [sg.Button(_t("config_gui.set_permissions"), key="--SET-PERMISSIONS--")], [ sg.Text(_t("config_gui.repo_group"), size=(40, 1)), - sg.Combo(values=[], key="repo_group"), # TODO + sg.Combo(values=configuration.get_group_list(full_config), key="repo_group"), ], [ sg.Text( From b99661e7801866349dfba037de683b2695f1c123 Mon Sep 17 00:00:00 2001 From: deajan Date: Thu, 18 Apr 2024 18:04:53 +0200 Subject: [PATCH 316/328] Remove duplicate license file --- npbackup/LICENSE.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 npbackup/LICENSE.md diff --git a/npbackup/LICENSE.md b/npbackup/LICENSE.md deleted file mode 100644 index e69de29..0000000 From 537fd1201178ec701b6eaaa773911904403cddee Mon Sep 17 00:00:00 2001 From: deajan Date: Thu, 18 Apr 2024 18:05:21 +0200 Subject: [PATCH 317/328] Add feature provider info --- README.md | 45 +++++++++++++++++++++++++-------------------- 1 file changed, 25 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index ad7c72e..3a8e72e 100644 --- a/README.md +++ b/README.md @@ -12,39 +12,42 @@ Works on x64 **Linux** , **NAS** solutions based on arm/arm64, **Windows** x64 a ## Features -- Data deduplication and fast zstd compression -- Client side data encryption -- Wide storage backend support - - local files - - SFTP - - High performance HTTP REST server - - Amazon S3/Minio/Wasabi - - Blackblaze B2 - - Microsoft Azure Blob Storage - - Google Cloud Storage - - OpenStack Swift - - Alibaba Cloud (Aliyun) Object Storage System (OSS) -- Resume on interrupted backups -- Full CLI interface for scheduled task usage +- Multiple repositories support + - Repository group settings + - Group operations +- Data deduplication and fast zstd compression* +- Client side data encryption* +- Wide storage backend support* + - local files* + - SFTP* + - High performance HTTP REST server* + - Amazon S3/Minio/Wasabi* + - Blackblaze B2* + - Microsoft Azure Blob Storage* + - Google Cloud Storage* + - OpenStack Swift* + - Alibaba Cloud (Aliyun) Object Storage System (OSS)* +- Resume on interrupted backups* +- Full CLI interface with all previous options, including --json API mode - Checks for recent backups before launching a backup - End User GUI - Backups create, list, viewer and restore - Full configuration interface - - Internationalization support (en, fr as of feb 2023) + - Internationalization support (en, fr as of Apr 2024) - Performance - Backup process and IO priority settings - - Upload / download speed limits - - Remote connectivity concurrency settings + - Upload / download speed limits* + - Remote connectivity concurrency settings* - Comes with full exclusion lists for Linux and Windows - First class prometheus support - - Restic results metric generatioion + - Restic results metric generation - Grafana dashboard included - node_exporter file collector support - Optional push gateway metrics uploading - First class Windows support - - VSS snapshots + - VSS snapshots* - Automatic cloud file exclusions (reparse points) - - Windows pre-built executables + - Windows pre-built executables* - Windows installer - Additional security - Repository uri / password, http metrics and upgrade server passwords are AES-256 encrypted @@ -58,6 +61,8 @@ Works on x64 **Linux** , **NAS** solutions based on arm/arm64, **Windows** x64 a - Remote automatic self upgrade capacity - Included upgrade server ready to run in production +(*) Feature provided by [restic](https://restic.net) backup backend + ## About So, a new backup solution out of nowhere, packed with too much features for it's own good ? Not really ! From 24bddcb63746008c762f169624971d000f36ff78 Mon Sep 17 00:00:00 2001 From: deajan Date: Sun, 21 Apr 2024 11:41:03 +0200 Subject: [PATCH 318/328] Move PySimpleGUI to inline version for #51 --- npbackup/gui/PySimpleGUI.py | 27013 ++++++++++++++++++++++++++++++++++ npbackup/gui/__main__.py | 2 +- npbackup/gui/config.py | 2 +- npbackup/gui/helpers.py | 2 +- npbackup/gui/operations.py | 2 +- npbackup/requirements.txt | 4 +- 6 files changed, 27019 insertions(+), 6 deletions(-) create mode 100644 npbackup/gui/PySimpleGUI.py diff --git a/npbackup/gui/PySimpleGUI.py b/npbackup/gui/PySimpleGUI.py new file mode 100644 index 0000000..fbbfd08 --- /dev/null +++ b/npbackup/gui/PySimpleGUI.py @@ -0,0 +1,27013 @@ +#!/usr/bin/python3 + +version = __version__ = "4.61.0.206 Unreleased" + +_change_log = """ + Changelog since 4.60.0 released to PyPI on 8-May-2022 + + 4.61.0.1 + main_open_github_issue - prefill the "Details" using the platform module (thank you macdeport!) + Fills Mac, Windows and Linux with details + 4.61.0.2 + Fix for the "jumping window problem on Linux". Major credit to Chr0nic for his amazing "stick with it" work on this problem! + 4.61.0.3 + Removed the previous fix attempt for jumping window on linux + Added ability for Mac users to specify file_type in Browse and popup_get_file + This feature must be ENABLED by the user in the Mac control panel that can be found in the PySimpleGUI Global Settings + The default is this feature is OFF + 4.61.0.4 + New location parameter option for Windows. Setting location=None tells PySimpleGUI to not set any location when window is created. It's up to the OS to decide. + The docstring for Window has been changed, but not all the other places (like popup). Want to make sure this works before making all those changes. + 4.61.0.5 + Added check for None invalid values parm when creating a Listbox element + 4.61.0.6 + Column docstring changed to add reminder to call contents_changed if changing the contents of a scrollable column + 4.61.0.7 + Fixed crash when horizontal_scroll=True for Listbox element + 4.61.0.8 + Added readonly to Input.update + 4.61.0.9 + Added Window.set_resizable - can change the X and Y axis resizing after window is created + 4.61.0.10 + Added wrap parameter to Spin element - if True, wraps back to the first value when at the end + Temp test code added for a new verification feature + 4.61.0.11 + Fixed Spin Element docstring - readonly was not correct + 4.61.0.12 + Output element - addition of wrap_lines and horizontal_scroll parameters + Multiline element - addition of wrap_lines parameter + 4.61.0.13 + Added Window.unbind + 4.61.0.14 + Added (None, None) to the Window docstring + 4.61.0.15 + Fix for continuous Graph element mouse up events when reading with a timeout=0. Big thank you to @davesmivers (THANKS DAVE!!) for finding and fixing + 4.61.0.16 + Added platform (Windows, Mac, Linux) and platform version information to the get_versions function + 4.61.0.17 + Added a fix for the file_types Mac problem that doesn't require the system settings to be used... let's give it a go! + 4.61.0.18 + Added ubiquitious Edit Me to the right click menu + 4.61.0.19 + PySimpleGUI Anniversary sale on Udemy course coupon + 4.61.0.20 + Fix for bind_return_key - if a button has been disabled, then the event shouldn't be generated for the return key being pressed + 4.61.0.21 + Added cols_justification for Table element - list or tuple of strings that indicates how each column should be justified + 4.61.0.22 + Better error handling for table element's new justification list. If a bad value is found, will use the default value + 4.61.0.23 + Additional mac filetype testing.... added more combinations that specify + 4.61.0.24 + Added * *.* to the Mac filetypes to check for + 4.61.0.25 + New logic for checking for the * case for Mac filetypes + 4.61.0.26 + Docstring update - TabGroup visible parameter marked as deprecated . Use a Column element to make a TabGroup invisible + 4.61.0.27 + Docstring update for the pin helper function that describes the shrinking of the container that it helps provide. + Also added explanation that it's the elements you want to make visible/invisible that are what you want to pin + 4.61.0.28 + Applied same Mac file_types fix to popup_get_file + Removed filetypes setting from Mac Feature Control Panel + 4.61.0.29 + Addition of enable_window_config_events to the Window object. This will cause a EVENT_WIMDOW_CONFIG event to be returned + if the window is moved or resized. + 4.61.0.30 + Made upgrade from GitHub window resizable so can screencapture the entire session + 4.61.0.31 + Added new constant TKINTER_CURSORS which contains a list of the standard tkinter cursor names + 4.61.0.32 + Added erase_all parameter to cprint (like the Debug Print already has) + 4.61.0.33 + Fix popup_scrolled - was only adding the Sizegrip when there was no titlebar. It should be added to all windows + unless the no_sizegrip parameter is set. + popup_scrolled - added no_buttons option. If True then there will not be a row at the bottom where the buttons normally are. + User will have to close the window with the "X" + 4.61.0.34 + popup_scrolled - added button_justification parameter. Wanted to make scrolled popups consistent with other popups which have left justified + buttons. But since they've been right justified in the past, want to give users the ability to retain that look. + Since the Sizegrip works correctly now, it increases the changes of accidently clicking a button if it's right justified. + 4.61.0.35 + Added default_color to ColorChooser button + 4.61.0.36 + Added to Button element error message that images must be in PNG or GIF format + 4.61.0.37 + Added exapnd_x and expand_y to all of the "lazy buttons" and Chooser buttons + 4.61.0.38 + Column element - added horizontal_scroll_only parameter (fingers crossed on this one....) + 4.61.0.39 + New signature testing + 4.61.0.40 + Exposed the Table Element's ttk style using member variable TABLE.table_ttk_style_name + 4.61.0.41 + New signature format + 4.61.0.42 + Backed out the changes from 4.61.0.38 (horizontal_scroll_only parameter). Those changes broke code in the scrollable columns. Need to make time to work on this feature more. + 4.61.0.43 + Added a print if get an exception trying to set the alpha channel after a window is created (troubleshooting a Mac problem) + 4.61.0.44 + Updated Menubar docstring to clarify the Menubar iself cannot have colors changed, only the submenus. Use MenubarCustom if you want full control + Format of file-signature changed + 4.61.0.45 + Further refinement of Menubar docstring + 4.61.0.46 + Added suggestion of using the Demo Browser to the checklist item of "Look for Demo Programs similar to your problem" + 4.61.0.47 + Testing some importing methods + Delay rerouting stdout and stderr in Output and Multiline elements until window is being built instead of when element is initialized + 4.61.0.48 + Additional window movement capability. If Control+Mouse movement feature is enabled, then Control+Arrow key will move the window 1 pixel + in the indicated direction + 4.61.0.49 + Added Window.set_size to match the other settings that are performed through method calls. There is also the Window.size property, but + since PySimpleGUI rarely uses properties, it makes sense to include a method as well as a property + 4.61.0.50 + Fix for ColorChooser button filling in a None value when cancel from color choise dialog box. Nothing will be filled in target if dialog cancelled + 4.61.0.51 + vtop, vcenter, vbottom helper functions gets background_color parameter + vcenter and vbottom - added USING the expand_x and expand_y parms that were already defined. (HOPE NOTHING BREAKS!) + 4.61.0.52 + justification parameter added to Listbox (default is left.. can be right and center now too) + 4.61.0.53 + Made settings dictionary multiline in test harness write-only. New coupon code + 4.61.0.54 + alpha_channel added to set_options. This sets the default value for the alpha_channel for all windows both user generated and PySimpleGUI generated (such as popups). + 4.61.0.55 + Allow Browse/Chooser buttons (that have a target) to indicate a target key that is a tuple. + 4.61.0.55 + While not actually correct.... 4.60.1 was released in the middle of the development above... I'm changing the version to look as + if this release is based on 4.60.1. This code DOES have the same code that's in 4.60.1 so it's more a matter of symantics. + Hoping this clears up confusion. Sorry for the dot-release causing so much confusion. + 4.61.0.56 + Fix for Window.extend_layout. Was not picking up the background color of the container that the rows were being added to. + 4.61.0.57 + Fixed Text element's update method docstring to indicate that value can be "Any" type not just strings + 4.61.0.58 + Addition of without_titlebar paramter to Window.current_location. Defaults to False. If True, then the location of the main portion of the window + will be returned (i.e. will not have the titlebar) + 4.61.0.59 + Fix for crash if COLOR_SYSTEM_DEFAULT specified in parameter disabled_readonly_background_color or disabled_readonly_text_color for Input Element. + Also applied similar fix for Tab element's focus color + 4.61.0.60 + Addition of set_option parameter hide_window_when_creating. If set to False then window will not be hidden while creating and moving + 4.61.0.61 + Changed the documentation location to PySimpleGUI.org (updated some comments as well as the SDK Reference Window's links) + New coupon code. Make the Udemy button in the test harness now include the coupon code automatically + 4.61.0.62 + Removed the "NOT avoilable on the MAC" from file_types parameter in the docstrings + Use Withdraw to hide window during creation + 4.61.0.63 + Addition of checklist item when logging new issue to GitHub - upgraded to latest version of PySimpleGUI on PyPI + Listbox justification parameter found to not be implemented on some early verions of tkinter so had to protect this situation. This new feature crashed on the Pi for example + 4.61.0.64 + Allow set_options(window_location=None) to indicate the OS should provide the window location. + This will stop the Alpha channel being set to 0 when the window is created + 4.61.0.65 + Addition of new Mac Control Panel option and emergency patch for MacOS version 12.3+ + If MacOS version 12.3 or greater than option is ON by default + When ON, the default Alpha channel for all windows is set to 0.99. + This can either be turned off, or can be overridden by calling set_options in your application + 4.61.0.65 + Bumping version number to avoid confusion. An emergency 4.60.2 release was posted to PyPI. This change was added to this current GitHub version of PySimpleGUI. + 4.61.0.66 + Fixed bug in checking Mac OS version number that is being released as 4.60.3 + 4.61.0.67 + Correctly check for Mac 12.3+ AND 13+ this time. + 4.61.0.68 + Roll in the changes being released to PyPI as 4.60.3 + 4.61.0.69 + Test to see if the additional pack of Notebook in Tab code was causing expansion problems + 4.61.0.70 + Debug Print - fix for bug caused by no_button being set with non_blocking... a lesson in thorough testing... assumption was either blocking OR no_button (or else app would + close without seeing the output... unless something else blocked. (DOH) + 4.61.0.71 + "Window closed" check added to update methods for elements. This will prevent a crash and instead show an error popup + Will be helpful for users that forget to check for closed window event in their event loop and try to call update after window closed. + 4.61.0.72 + Output element now automatically sets auto_refresh to True. Should this not be desired, switch to using the Multiline element. There will likely be + no impact to this change as it seems like the windows are alredy refreshing OK, but adding it just to be sure. + 4.61.0.73 + Addition of Window.key_is_good(key) method. Returns True if key is used in the window. Saves from having to understand the window's key dictionary. + Makes for easier code completion versus writing "if key in window.key_dict" + 4.61.0.74 + Combo - if readonly, then set the select colors to be "transparent" (text=text color, background=background color) + 4.61.0.75 + Better description of bar_color parm for the ProgressMeter element and the one_line_progress_meter function + Combo element - addition of select parameter to enable easier selection of the contents of clearing of the selection of the contents. + 4.61.0.76 + Changed the _this_elements_window_closed to use a flag "quick_check" for cheking is the window is closed. Found that calling tkinter.update takes over 500ms sometimes! + For appllications that call update frequently, this caused a catestrophic slowdown for complex windows. + 4.61.0.77 + New Window method - get_scaling - gets the scaling value from tkinter. Returns DEFAULT_SCALING if error. + 4.61.0.78 + Custom Titlebar - Support added to Window.minimize, Window.maximize, and Window.normal + 4.61.0.79 + Fix for Mulitline showing constant error messages after a Window is closed. + Fix for correctly restoring stdout, stderr after they've been rerouted. THIS CODE IS NOT YET COMPLETE! Shooting for this weekend to get it done! + Image element - more speicific with tkinter when chaning to a new image so that pypy would stop crashing due to garbage collect not running. + This change didn't fix the pypy problem but it also didn't hurt the code to have it + 4.61.0.80 + Quick and dirty addition of Alt-shortcuts for Buttons (like exists for Menus) + For backward compatablity, must be enabled using set_options with use_button_shortcuts=True + Fixed docstring errors in set_options docstring + 4.61.0.81 + Completed restoration of stdout & stderr + If an Output Element is used or a Multline element to reroute stdout and/or stderr, then this hasn't worked quite right in the past + Hopefuly now, it does. A LIFO list (stack) is used to keep track of the current output device and is scrubbed for closed windows and restored if one is closed + 4.61.0.82 + Addition of Style Names for horizontaland vertical ttk scrollbars - hsb_style_name and vsb_style_name so that scrollbar colors can be changed in user code + 4.61.0.83 + Output element - now automatically reroutes cprint to here as well. Assumption is that you want stuff to go here without + needing to specify each thing. If want more control, then use the Multiline directly + 4.61.0.84 + Output element - updated docstring + 4.61.0.85 + Combo Element - new parameter enable_per_char_events. When True will get an event when individual characters are entered. + 4.61.0.86 + Added path to the interpreter to the get_versions information for better debugging + 4.61.0.87 + Dark Gray 16 theme added + 4.61.0.88 + New batch of Emojis! + 4.61.0.89 + Addition of TITLEBAR_TEXT_KEY to provide access to custom titlebar titles + 4.61.0.90 + Implemented the visible parameter for TabGroup. Was not being honored when creating element. Added TabGroup.update so it can be made visible. + 4.61.0.91 + Added support for Custom Titlebar to the Window.set_title method + 4.61.0.92 + Addition of starting_row_number parameter to the Table element. Sets the value for the first row in the table. + 4.61.0.93 + Added 2 parameters to popup - drop_whitespace is passed to the wraptext.fill method. right_justify_buttons will "push" buttons to + the right side if set to True + 4.61.0.94 + Added Element.save_element_screenshot_to_disk - uses the same PIL integration that the save window screenshot to disk uses but applied to a single element + 4.61.0.95 + Changed popup again - replaced right_justify_buttons with button_justification. Also removed the extra padding that was being added to the buttons. This + matches a changed made to popup_scrolled earlier + 4.61.0.96 + More emojis? Yes... more emojis... + 4.61.0.97 + The main test harness now shows the python interpreter used to launch the test harness to make clearer what's running + 4.61.0.98 + Better alignment of text in test harness + Fixed mispelling in SystemTray.show_message - crashed if an int was passed in as the time value + 4.61.0.99 + popup_get_text - Addition of history feature to bring up to same level as other popup_get_ functions. + 4.61.0.100 + Set the "Active" foreground and background colors for Menu and ButtonMenu items. Automatically uses the swapped foreground and background colors. + This impacts both inside the menus themseleves as well as the ButtonMenus so that when they are used in a MenubarCustom they mouseover nicely now. + 4.61.0.101 + Added Window.is_hidden method. Returns True if the window is currently hidden + 4.61.0.102 + Fixed error in the main test harness "not modal" popup test. Was setting the "Force Modal" setting to true after the popup test. + 4.61.0.103 + Trinket is detected using a new mechansim now. The previous one was waayyy too simnple and as a result has broken in the past week. + 4.61.0.104 + Version bump to keep up with the PyPI emergency 4.60.4 release + 4.61.0.105 + Added SYMBOL_BULLET character + 4.61.0.106 + Neon Green, Blue, Yellow themes... was writing some tests using them and thought why not start a new theme color category... "neon" + 4.61.0.107 + Fixed an unreported problem perhaps... Added saving new menu into the Menu.Widget memeber variable in the Menu.update method. + 4.61.0.108 + Added drop_whitespace to the docstring for popup. Parm has been in the code for quite some time but forgot the docstring so it's not in the SDK reference. + 4.61.0.109 + Changed error message in error window when an element reuse has been detected in a layout. Previous message wasn't clear why there was an error. + 4.61.0.110 + Added very detailed information to popup_error_with_traceback if the Exception information is passed in as one of the arguments + 4.61.0.111 + Menu Element - delete all items in existing Menu widget rather than making a new one when the Menu definition changes + 4.61.0.112 + Input.update - added font parameter + 4.61.0.113 + Dark Blue 18 theme, a materially kinda theme, added - tip - experiment like PySimpleGUI does when "computing" colors. Grab a color of a part of a theme and use it as a background or a secondary button color. In other words, mix and match since the colors should all work together by design. + 4.61.0.114 + Added execute_py_get_running_interpreter to differentiate between the one in the settings file versus currently running interpreter + 4.61.0.115 + Image Element... added Zooooooooommmmm parameter + 4.61.0.116 + Proliferiation/infection of the zoom parameter to more elements with images - Button, ButtonMenu + Note that zoom and subsample can BOTH be used. This enables fractional scaling. Want 2/3 the size of the image? subsample=3, zoom=2 + Tab is the remaining element this is being added to + The Buttons implemented as functions need this addition as well + Addition of the image_source parameter to Button and Button.update. This way of specifying images is commonly used in other elements + Fixed ButtonMenu bug - subsample was not being applied to initial image in the layout + 4.61.0.117 + Fix for set_vscroll_position not working correctly for a scrollable Column + 4.61.0.118 + Completed addition of zoom options for images by adding image_zoom parameter to Tab element + 4.61.0.119 + Fixed Neon Yellow theme. Had an extra "#" in a color. + 4.61.0.120 + New coupon code + 4.61.0.121 + New Jedi emoji + 4.61.0.122 + Swapped Push and Stretch, VPush and VStretch. Made Push and VPush the function and Stratch and VStresth the aliases. Did this because + Push is used almost universally, not Stretch. + 4.61.0.123 + Fix for incorrect values for Element.ttk_style and Element.ttk_style_name. Some elements had values overwritten if a scrollbar, etc, was used + Changed a number of the ttk elements (Button for example) to use the base name as the parm to creating the custom style to achieve + a more predictable naming style (relies on the formula used in the create style function rather than ad hoc adding "custom" onto name) + 4.61.0.124 + Multiline Element docstring fixes + 4.61.0.125 + Addition of 2 overrides to Window.find_element so that more control is available to applications wishing to perform special key lookups + 4.61.0.126 + Made button_color parameter's docstring value consistent across all calls. Now set to - (str, str) | str + 4.61.0.127 + User settings delete calls - aded silent_on_error option so deletes of non-existant entries can uappen silently if desired. + popup_quick_message - now defaults to keep-on-top to True + 4.61.0.128 + Cleaned up User Settings API code for porting + 4.61.0.129 + button_color parm added to ButtonMenu.update + 4.61.0.130 + New coupon + 4.61.0.131 + Window timers feature added. Get a single or repeating timer events for your Window by calling window.timer_start + 4.61.0.132 + Added the Window.stop_all method to stop all timers for a window + 4.61.0.133 + Added Window.timer_get_active_timers to get a list of the active timers for the window + 4.61.0.134 + popup_get_date - exposed the fonts as parameters so that the user code and modify them (specifically to get around a Mac font bug) + 4.61.0.135 + Renamed QuickMeter to _QuickMeter so that it's clear that it's not an object meant to be used by users + 4.61.0.136 + Tree element - if headings is set to None, no headings area is shown + 4.61.0.137 + "Take me to error" button is disabled in error traceback popup if not editor is configured. Also adds instructions if no editor. + 4.61.0.138 + Added begin_at_sunday_plus to the CalendarButton docstring + 4.61.0.139 + Moved debugger constants to inside of the debugger class. Simplified the locals and globals popups. + 4.61.0.140 + Experimental change.... Table.get now returns the values from the widget's selection method + 4.61.0.141 + Made the Debugger's use of popups change the theme to the same dark gray theme used in the rest of the debugger windows. + 4.61.0.142 + Added selected_text_color & selected_background_color to Input element. Will override the default values + 4.61.0.143 + Fixed bug in Combo.update - the width of the element wasn't getting updated to match new values + 4.61.0.144 + Added selected_text_color & selected_background_color to Multiline element. Will override the default values + 4.61.0.145 + Fixed bind_return_key docstrings in the pre-defined buttons. Made the Button bind_return_key docstring more descriptive + 4.61.0.146 + Changed version numbers to 4.61.0 to try and fix the confusion about what's been released to PyPI. + 4.61.0.147 + New Udemy coupon code + 4.61.0.148 + Removed the print when the Mac Alpha Channel 0.99 patch is applied + 4.61.0.149 + Removed second print when Mac patch applied + 4.61.0.150 + Tree Element new parameter - click_toggles_select - if True then clicking a selected item will unselect it + 4.61.0.151 + Fixed problem with TabGroups when the text was blank for a Tab. Was not correctly identifying the active tab in the Values dictionary + 4.61.0.152 + Updated TabGroup.get to use the same method to find the currently active tab that was just added above. + 4.61.0.153 + Updated layout error messages to include "sometimes" in the description of what may be causing error + 4.61.0.154 + Updated Window.start_timer docstring to include the constants EVENT_TIMER and TIMER_KEY since the call reference doesn't show the variable names but rather the string value. + 4.61.0.155 + Multiline new parameter autoscroll_only_at_bottom. When True, the element will autoscroll (keep scrollbar at the bottom) only if the scrollbar is already at the bottom. + 4.61.0.156 + Added the new Multiline parameter autoscroll_only_at_bottom so that the Output element can also use this option + 4.61.0.157 + Added the _optional_window_data function that is used to help with local PySimpleGUI testing of release candidates. Not meant to be used by end-users. + 4.61.0.158 + Changed Checkbox activeforeground to be the same as the text so mouseover doesn't change color + 4.61.0.159 + New Global Settings feature - Window watermarking. Can be forced on temporarily by settings watermark=True in your Window creation + 4.61.0.160 + Fix "Bold" crash from watermarking feature + 4.61.0.161 + New set_options to control user-defined watermarks + 4.61.0.162 + Addition of new parms to Combo.update - text color, background color. Also font now applied correctly to dropdown list + 4.61.0.163 + Checkbox - added highlight thickness parm to control how thick the focus ring is. Defaults to 1 still but now changable + 4.61.0.164 + Input element - fixed problem where the input 'cursor' (the I-beam) was being set to the THEME'S color, not the color indicated by the individual element + 4.61.0.165 + Multiline & Spin - Applied same fix for input "cursor" (I-Beam) color that was added to the Input element. + Added new method - set_ibeam_color to Input, Multiline and Spin elements. Combo is a ttk element so it's not available using this call yet + 4.61.0.166 + New Udemy coupon + 4.61.0.167 + New Udemy coupon + Fix for bad user settings key for user watermark. Added Python version to watermark + 4.61.0.168 + Changed Radio activeforeground to be the same as the text so mouseover doesn't change color + 4.61.0.169 + Allow no end-key to be specified for perform_long_operation/start_thread. Careful with backward compatibility! If you skip adding parm on old versions of PySimpleGUI then it'll not work. + 4.61.0.170 + Possible fix for Mac Input Element issue that's been happening with no-titlebar windows on MacOS 13.2.1 Ventura + 4.61.0.171 + Added formatted_datetime_now function for easy timestamps for logging + 4.61.0.172 + Added upgrade service - No notification popups should be shown yet. Don't want to SPAM users while testing + 4.61.0.173 + Made changing the "Show only critical" setting in global settings take effect immediately rather than waiting until closed settings window + Added timer_stop_usec to return timer value in microseconds + 4.61.0.174 + Overwrite upgrade data if any portion has changed + 4.61.0.175 + Notification window - added countdown counter. Added hand cursor if message is a link and enable clicking of link to open the browser to that link + 4.61.0.176 + Improved linux distro detection + 4.61.0.177 + Custom Titlebar - Support for disabling resizing (maximizing too), support for disable minimize and disable close + 4.61.0.178 + Input element - fix for bug with text color & logic wasn't quite right with the "read for disabled" stuff in the update as well as when making window + 4.61.0.179 + New Udemy coupon + 4.61.0.180 + Removed Security tab from system settings + 4.61.0.181 + Added check for None and COLOR_SYSTEM_DEFAULT before any colors being set in Input.update + 4.61.0.182 + Only enable the Mac alpha channel 0.99 patch when tkinter version is 8.6.12. Have learned this is not needed for any other tkinter version + 4.61.0.183 + Show Critical upgrade service messages. Removed the extra upgrade from github button from tab. + 4.61.0.184 + Fix for Combo.update background color changing incorrect widget setting. + 4.61.0.185 + Fix for crash when no headings specified for a table by casting values into strings + 4.61.0.186 + Fix for popup_get_file when using no_window=True. Now returns None if cancelled or window closed + 4.61.0.187 + Corrected the Table.get docstring to reflect that it returns a list of ints + 4.61.0.188 + Finished correcting the Table.get docstring.... think I got it right this time.... + 4.61.0.189 + Changed Table click events to be generated on Button Release instead of Button (down). Was not getting the + correct selected rows in the values dictionary when the click event was generated using down. Now the selected rows is correct + 4.61.0.190 + Addition of black2 theme + Fix typo of text in _widget_was_created + 4.61.0.191 + Fixed bug in Button.update. Was setting the activeforeground and activebackground which broke the mouseover or mouse press colors + 4.61.0.192 + Fixed bug in Button.update. Corrected when activeforeground and activebackground are set. Removing them in version above was a mistake + 4.61.0.193 + Fixed spelling errors... resuse should have been reuse + 4.61.0.194 + Added Listbox.select_index and Listbox.set_index_color + 4.61.0.195 + New Udemy Coupon + 4.61.0.196 + Added highlight colors to the set_index_color method. Parms highlight_text_color & highlight_background_color control changing the highlight colors + 4.61.0.197 + Made Table Element Header mouse-over and clicked be the inverse of the normal header colors. Makes for a much nicer experience + 4.61.0.198 + Added no_buffering option to popup_animated + 4.61.0.199 + Updated Udemy coupon code + 4.61.0.200 + Fix for grab anywhere window movement and control+left_mouse_drag. Window move smoother, including the Move-All-Windows feature. Thank you JASON for the help! + 4.61.0.201 + Added init for _mouse_offset_x and y in case tkinter doesn't call the mouse down callback + 4.61.0.202 + Added doctring and destroy previous right click menu to set_right_click_menu + 4.61.0.203 + Changed Sizer element to use Canvas instead of Column element + 4.61.0.204 + One more change to sizer so that it uses pad instead of size. + 4.61.0.205 + Fixed docstring for execute_command_subprocess. The command description was incorrect + 4.61.0.206 + New Udemy Coupon code + + + """ + +__version__ = version.split()[0] # For PEP 396 and PEP 345 + +# The shortened version of version +try: + ver = version.split(' ')[0] +except: + ver = '' + +# __version__ = version + +port = 'PySimpleGUI' + +# 8""""8 8""""8 8""""8 8 8 8 +# 8 8 e e 8 e eeeeeee eeeee e eeee 8 " 8 8 8 +# 8eeee8 8 8 8eeeee 8 8 8 8 8 8 8 8 8e 8e 8 8e +# 88 8eeee8 88 8e 8e 8 8 8eee8 8e 8eee 88 ee 88 8 88 +# 88 88 e 88 88 88 8 8 88 88 88 88 8 88 8 88 +# 88 88 8eee88 88 88 8 8 88 88eee 88ee 88eee8 88ee8 88 + + +""" + Copyright 2018, 2019, 2020, 2021, 2022, 2023 PySimpleGUI(tm) + + Before getting into the details, let's talk about the high level goals of the PySimpleGUI project. + + From the inception these have been the project principals upon which it is all built + 1. Fun - it's a serious goal of the project. If we're not having FUN while making stuff, then something's not right + 2. Successful - you need to be successful or it's all for naught + 3. You are the important party - It's your success that determines the success of PySimpleGUI + + If these 3 things are kept at the forefront, then the rest tends to fall into place. + + PySimpleGUI is a "system", not just a program. There are 4 components of the "PySimpleGUI system" + 1. This software - PySimpleGUI.com + 2. The documentation - PySimpleGUI.org + * PySimpleGUI.org + * Calls.PySimpleGUI.org + * Cookbook.PySimpleGUI.org + 3. Demo Programs - Demos.PySimpleGUI.org + 4. Support - Issues.PySimpleGUI.org + 5. eCookbook - eCookbook.PySimpleGUI.org + + + Now available - "The Official PySimpleGUI Course" on Udemy! + https://www.udemy.com/pysimplegui + + Watch for a coupon codes in the documentation on PySimpleGUI.org + + Please consider sponsoring all open source developers that make software you or your business use. They need your help. + + + This software is available for your use under a LGPL3+ license + + This notice, these first 150 lines of code shall remain unchanged + + + + 888 .d8888b. 8888888b. 888 .d8888b. + 888 d88P Y88b 888 Y88b 888 d88P Y88b + 888 888 888 888 888 888 .d88P + 888 888 888 d88P 888 8888" 888 + 888 888 88888 8888888P" 888 "Y8b. 8888888 + 888 888 888 888 888 888 888 888 + 888 Y88b d88P 888 888 Y88b d88P + 88888888 "Y8888P88 888 88888888 "Y8888P" + + + In addition to the normal publishing requirements of LGPL3+, these also apply: + 1. These and all comments are to remain in the source code + 2. The "Official" version of PySimpleGUI and the associated documentation lives on two (and **only** two) places: + 1. GitHub - (http://www.PySimpleGUI.com) currently pointing at: + https://github.com/PySimpleGUI/PySimpleGUI + 2. PyPI - pip install PySimpleGUI is the customary way of obtaining the latest release + + THE official documentation location is: + https://www.PySimpleGUI.org - Main documentation + There are also a lot of subdomains... many of which you can guess.. + https://SDK.PySimpleGUI.org - The SDK Reference tab + https://Calls.PySimpleGUI.org - The SDK Reference tab + https://Cookbook.PySimpleGUI.org - The Cookbook tab + https://eCookbook.PySimpleGUI.org - The eCookbook located on Trinket + https://Anncouncements.PySimpleGUI.org - The Announcements Issue on GitHub + https://Install.PySimpleGUI.org - The "How to install" section of the docs + https://Upgrading.PySimpleGUI.org - The "How to upgrade" section of the docs + https://Udemy.PySimpleGUI.org - The Udemy course + https://GitHub.PySimpleGUI.org - The PySimpleGUI GitHub (also the located at PySimpleGUI.com) + https://Issues.PySimpleGUI.org - Open a new issue on GitHub + https://Bugs.PySimpleGUI.org - Open a new issue on GitHub + etc..... + + If you've obtained this software in any other way, then those listed here, then SUPPORT WILL NOT BE PROVIDED. + 3. If you use PySimpleGUI in your project/product, a notice of its use needs to be displayed in your readme file as per the license agreement + + ----------------------------------------------------------------------------------------------------------------- + + + The first bit of good news for you is that literally 100s of pages of documentation await you. + 300 Demo Programs have been written as a "jump start" mechanism to get your running as quickly as possible. + + Some general bits of advice: + Upgrade your software! python -m pip install --upgrade --no-cache-dir PySimpleGUI + If you're thinking of filing an Issue or posting a problem, Upgrade your software first + There are constantly something new and interesting coming out of this project so stay current if you can + + The FASTEST WAY to learn PySimpleGUI is to begin to use it in conjunction with the materials provided by the project. + http://www.PySimpleGUI.org + http://Calls.PySimpleGUI.org + http://Cookbook.PySimpleGUI.org + + The User Manual and the Cookbook are both designed to paint some nice looking GUIs on your screen within 5 minutes of you deciding to PySimpleGUI out. + + A final note from mike... + + “Don’t aim at success. The more you aim at it and make it a target, the more you are going to miss it. + For success, like happiness, cannot be pursued; it must ensue, and it only does so as the unintended side effect of one’s personal dedication to a cause greater.” + — Viktor Frankl + + I first saw this quote in a truncated format: + "Happiness, cannot be pursued; it must ensue, and it only does so as the unintended side effect of one’s personal dedication to a cause greater." + + Everyone is different, but my experience with the PySimpleGUI project matches this theory. It's taken a lifetime of trying and "failing" and trying + to find happiness before I finally figured this truth-for-me out. If I do a long list of things, and live life in a kind & loving way, then the + result is happiness. It's a biproduct, not a directly produced thing. This should be taught in school. Or maybe it can't. + I hope you find happiness, but more importantly, or maybe first, I hope you find that bigger-than-you thing. For me it's always been programming. It seems to be + the giving back part, not just the calling, that makes the happiness fusion-reactor operate. + + "Thank you" has fueled this project. I'm incredibly grateful to have users that are in turn grateful. It's a feedback loop of gratitude. What a fantastic thing! +""" +# all of the tkinter involved imports +import tkinter as tk +from tkinter import filedialog +from tkinter.colorchooser import askcolor +from tkinter import ttk +# import tkinter.scrolledtext as tkst +import tkinter.font +from uuid import uuid4 + +# end of tkinter specific imports +# get the tkinter detailed version +tclversion_detailed = tkinter.Tcl().eval('info patchlevel') +framework_version = tclversion_detailed + +import time +import pickle +import calendar +import datetime +import textwrap + +import socket +from hashlib import sha256 as hh +import inspect +import traceback +import difflib +import copy +import pprint +try: # Because Raspberry Pi is still on 3.4....it's not critical if this module isn't imported on the Pi + from typing import List, Any, Union, Tuple, Dict, SupportsAbs, Optional # because this code has to run on 2.7 can't use real type hints. Must do typing only in comments +except: + print('*** Skipping import of Typing module. "pip3 install typing" to remove this warning ***') +import random +import warnings +from math import floor +from math import fabs +from functools import wraps + +try: # Because Raspberry Pi is still on 3.4.... + # from subprocess import run, PIPE, Popen + import subprocess +except Exception as e: + print('** Import error {} **'.format(e)) + +import threading +import itertools +import json +import configparser +import queue + +try: + import webbrowser + + webbrowser_available = True +except: + webbrowser_available = False +# used for github upgrades +import urllib.request +import urllib.error +import urllib.parse +import pydoc +from urllib import request +import os +import sys +import re +import tempfile +import ctypes +import platform + +pil_import_attempted = pil_imported = False + +warnings.simplefilter('always', UserWarning) + +g_time_start = 0 +g_time_end = 0 +g_time_delta = 0 + + +# These timer routines are to help you quickly time portions of code. Place the timer_start call at the point +# you want to start timing and the timer_stop at the end point. The delta between the start and stop calls +# is returned from calling timer_stop + +def timer_start(): + """ + Time your code easily.... starts the timer. + Uses the time.time value, a technique known to not be terribly accurage, but tis' gclose enough for our purposes + """ + global g_time_start + + g_time_start = time.time() + + +def timer_stop(): + """ + Time your code easily.... stop the timer and print the number of MILLISECONDS since the timer start + + :return: delta in MILLISECONDS from timer_start was called + :rtype: int + """ + global g_time_delta, g_time_end + + g_time_end = time.time() + g_time_delta = g_time_end - g_time_start + return int(g_time_delta * 1000) + +def timer_stop_usec(): + """ + Time your code easily.... stop the timer and print the number of MICROSECONDS since the timer start + + :return: delta in MICROSECONDS from timer_start was called + :rtype: int + """ + global g_time_delta, g_time_end + + g_time_end = time.time() + g_time_delta = g_time_end - g_time_start + return int(g_time_delta * 1000000) + + +def _timeit(func): + """ + Put @_timeit as a decorator to a function to get the time spent in that function printed out + + :param func: Decorated function + :type func: + :return: Execution time for the decorated function + :rtype: + """ + + @wraps(func) + def wrapper(*args, **kwargs): + start = time.time() + result = func(*args, **kwargs) + end = time.time() + print('{} executed in {:.4f} seconds'.format(func.__name__, end - start)) + return result + + return wrapper + + +_timeit_counter = 0 +MAX_TIMEIT_COUNT = 1000 +_timeit_total = 0 + + +def _timeit_summary(func): + """ + Same as the timeit decorator except that the value is shown as an averave + Put @_timeit_summary as a decorator to a function to get the time spent in that function printed out + + :param func: Decorated function + :type func: + :return: Execution time for the decorated function + :rtype: + """ + + @wraps(func) + def wrapper(*args, **kwargs): + global _timeit_counter, _timeit_total + + start = time.time() + result = func(*args, **kwargs) + end = time.time() + _timeit_counter += 1 + _timeit_total += end - start + if _timeit_counter > MAX_TIMEIT_COUNT: + print('{} executed in {:.4f} seconds'.format(func.__name__, _timeit_total / MAX_TIMEIT_COUNT)) + _timeit_counter = 0 + _timeit_total = 0 + return result + + return wrapper + + +def formatted_datetime_now(): + """ + Returns a string with current date and time formatted YYYY-MM-DD HH:MM:SS for easy logging + + :return: String with date and time formatted YYYY-MM-DD HH:MM:SS + :rtype: (str) + """ + now = datetime.datetime.now() + current_time = now.strftime("%Y-%m-%d %H:%M:%S") + return current_time + + + +def running_linux(): + """ + Determines the OS is Linux by using sys.platform + + Returns True if Linux + + :return: True if sys.platform indicates running Linux + :rtype: (bool) + """ + return sys.platform.startswith('linux') + + +def running_mac(): + """ + Determines the OS is Mac by using sys.platform + + Returns True if Mac + + :return: True if sys.platform indicates running Mac + :rtype: (bool) + """ + return sys.platform.startswith('darwin') + + +def running_windows(): + """ + Determines the OS is Windows by using sys.platform + + Returns True if Windows + + :return: True if sys.platform indicates running Windows + :rtype: (bool) + """ + return sys.platform.startswith('win') + + +def running_trinket(): + """ + A special case for Trinket. Checks both the OS and the number of environment variables + Currently, Trinket only has ONE environment variable. This fact is used to figure out if Trinket is being used. + + Returns True if "Trinket" (in theory) + + :return: True if sys.platform indicates Linux and the number of environment variables is 1 + :rtype: (bool) + """ + if sys.platform.startswith('linux') and socket.gethostname().startswith('pygame-'): + return True + return False + + +def running_replit(): + """ + A special case for REPLIT. Checks both the OS and for the existance of the number of environment variable REPL_OWNER + Currently, Trinket only has ONE environment variable. This fact is used to figure out if Trinket is being used. + + Returns True if running on "replit" + + :return: True if sys.platform indicates Linux and setting REPL_OWNER is found in the environment variables + :rtype: (bool) + """ + if 'REPL_OWNER' in os.environ and sys.platform.startswith('linux'): + return True + return False + + + + +# Handy python statements to increment and decrement with wrapping that I don't want to forget +# count = (count + (MAX - 1)) % MAX # Decrement - roll over to MAX from 0 +# count = (count + 1) % MAX # Increment to MAX then roll over to 0 + +""" + Welcome to the "core" PySimpleGUI code.... + + It's a mess.... really... it's a mess internally... it's the external-facing interfaces that + are not a mess. The Elements and the methods for them are well-designed. + PEP8 - this code is far far from PEP8 compliant. + It was written PRIOR to learning that PEP8 existed. + + I'll be honest.... started learning Python in Nov 2017, started writing PySimpleGUI in Feb 2018. + Released PySimpleGUI in July 2018. I knew so little about Python that my parameters were all named + using CamelCase. DOH! Someone on Reddit set me straight on that. So overnight I renamed all of the + parameters to lower case. Unfortunately, the internal naming conventions have been set. Mixing them + with PEP8 at this moment would be even MORE confusing. + + Code I write now, outside PySimpleGUI, IS PEP8 compliant. + + The variable and function naming in particular are not compliant. There is + liberal use of CamelVariableAndFunctionNames, but for anything externally facing, there are aliases + available for all functions. If you've got a serious enough problem with 100% PEP8 compliance + that you'll pass on this package, then that's your right and I invite you to do so. However, if + perhaps you're a practical thinker where it's the results that matter, then you'll have no + trouble with this code base. There is consisency however. + + I truly hope you get a lot of enjoyment out of using PySimpleGUI. It came from good intentions. +""" + +# ----====----====----==== Constants the user CAN safely change ====----====----====----# + +# Base64 encoded GIF file +DEFAULT_BASE64_ICON = b'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' + +DEFAULT_BASE64_ICON_16_BY_16 = b'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAKCSURBVDhPVZNbSFRRFIb35YwXItBIGtDsiqENEUTRjJlZkJggPSUYBD0UhULElE6hBY6ID/ZSpD1IDxaCEPhUaFLRQyWRNxIJe8syMxCjMCbB07fOsaMt+GftvWf//7/2Whyt1sTei/fCpDqQBTrGOi9Myrk7URwhnQUfQLeOvErJuUQgADlK6gObvAOl5sHx0doHljwARFRiCpxG5J1sjPxALiYNgn9kiQ3gafdYUYzseCd+FICX7sShw7LR++q6cl3XHaXQHFdOJLxFsJtvKHnbUr1nqp01hhStpXAzo7TZZXOjJ+9orT9pY74aY3ZobZZYW8D/GpjM19Ob088fmJxW2tkC4AJt17Oeg2MLrHX6jXWes16w1sbBkrFWBTB2nTLpv5VJg7wGNhRDwCS0tR1cbECkidwMQohAdoScqiz8/FCZUKlPCgSWlQ71elOI1fcco9hCXp1kS7dX3u+qVOm2L4nW8qE4Neetvl8v83NOb++9703BcUI/cU3imuWV7JedKtv5LdFaMRzHLW+N+zJoVDZzRLj6SFNfPlMYwy5bDiRcCojmz15tKx+6hKPv7LvjrG/Q2RoOwjSyzNDlahyzA2dAJeNtFcMHA2cfLn24STNr6P4I728jJ7hvf/lEGuaXLnkRAp0PyFK+hlyLSJGyGWnKyeBi2oJU0IPIjNd15uuL2f2PJgueQBKhVRETCgNeYU+xaeEpnWaw8cQPRM7g/McT8eF0De9u7P+49TqXF7no98BDEEkdvvXem8LAtfJniFRB/A5XeiAiG2+/icgHVQUW5d5KyAhl3M2y+U+ysv1FDukyKGQW3Y+vHJWvU7mz8RJSPZgDd3H2RqiUUn8BSQuaBvGjGpsAAAAASUVORK5CYII=' + +DEFAULT_BASE64_LOADING_GIF = b'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' + +# Old debugger logo +# PSG_DEBUGGER_LOGO = b'R0lGODlhMgAtAPcAAAAAADD/2akK/4yz0pSxyZWyy5u3zZ24zpW30pG52J250J+60aC60KS90aDC3a3E163F2K3F2bPI2bvO3rzP3qvJ4LHN4rnR5P/zuf/zuv/0vP/0vsDS38XZ6cnb6f/xw//zwv/yxf/1w//zyP/1yf/2zP/3z//30wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACH5BAEAAP8ALAAAAAAyAC0AAAj/AP8JHEiwoMGDCBMqXMiwoUOFAiJGXBigYoAPDxlK3CigwUGLIAOEyIiQI8cCBUOqJFnQpEkGA1XKZPlPgkuXBATK3JmRws2bB3TuXNmQw8+jQoeCbHj0qIGkSgNobNoUqlKIVJs++BfV4oiEWalaHVpyosCwJidw7Sr1YMQFBDn+y4qSbUW3AiDElXiWqoK1bPEKGLixr1jAXQ9GuGn4sN22Bl02roo4Kla+c8OOJbsQM9rNPJlORlr5asbPpTk/RP2YJGu7rjWnDm2RIQLZrSt3zgp6ZmqwmkHAng3ccWDEMe8Kpnw8JEHlkXnPdh6SxHPILaU/dp60LFUP07dfRq5aYntohAO0m+c+nvT6pVMPZ3jv8AJu8xktyNbw+ATJDtKFBx9NlA20gWU0DVQBYwZhsJMICRrkwEYJJGRCSBtEqGGCAQEAOw==' + +PSG_DEBUGGER_LOGO = b'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' + +UDEMY_ICON = b'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' + +BLANK_BASE64 = b'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAANSURBVBhXY2BgYGAAAAAFAAGKM+MAAAAAAElFTkSuQmCC' +BLANK_BASE64 = b'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=' + + +DEFAULT_WINDOW_ICON = DEFAULT_BASE64_ICON + +DEFAULT_ELEMENT_SIZE = (45, 1) # In CHARACTERS +DEFAULT_BUTTON_ELEMENT_SIZE = (10, 1) # In CHARACTERS +DEFAULT_MARGINS = (10, 5) # Margins for each LEFT/RIGHT margin is first term +DEFAULT_ELEMENT_PADDING = (5, 3) # Padding between elements (row, col) in pixels +DEFAULT_AUTOSIZE_TEXT = True +DEFAULT_AUTOSIZE_BUTTONS = True +DEFAULT_FONT = ("Helvetica", 10) +DEFAULT_TEXT_JUSTIFICATION = 'left' +DEFAULT_BORDER_WIDTH = 1 +DEFAULT_AUTOCLOSE_TIME = 3 # time in seconds to show an autoclose form +DEFAULT_DEBUG_WINDOW_SIZE = (80, 20) +DEFAULT_WINDOW_LOCATION = (None, None) +MAX_SCROLLED_TEXT_BOX_HEIGHT = 50 +DEFAULT_TOOLTIP_TIME = 400 +DEFAULT_TOOLTIP_OFFSET = (0, -20) +DEFAULT_KEEP_ON_TOP = None +DEFAULT_SCALING = None +DEFAULT_ALPHA_CHANNEL = 1.0 +DEFAULT_HIDE_WINDOW_WHEN_CREATING = True +TOOLTIP_BACKGROUND_COLOR = "#ffffe0" +TOOLTIP_FONT = None +DEFAULT_USE_BUTTON_SHORTCUTS = False +#################### COLOR STUFF #################### +BLUES = ("#082567", "#0A37A3", "#00345B") +PURPLES = ("#480656", "#4F2398", "#380474") +GREENS = ("#01826B", "#40A860", "#96D2AB", "#00A949", "#003532") +YELLOWS = ("#F3FB62", "#F0F595") +TANS = ("#FFF9D5", "#F4EFCF", "#DDD8BA") +NICE_BUTTON_COLORS = ((GREENS[3], TANS[0]), + ('#000000', '#FFFFFF'), + ('#FFFFFF', '#000000'), + (YELLOWS[0], PURPLES[1]), + (YELLOWS[0], GREENS[3]), + (YELLOWS[0], BLUES[2])) + +COLOR_SYSTEM_DEFAULT = '1234567890' # A Magic Number kind of signal to PySimpleGUI that the color should not be set at all +DEFAULT_BUTTON_COLOR = ('white', BLUES[0]) # Foreground, Background (None, None) == System Default +OFFICIAL_PYSIMPLEGUI_BUTTON_COLOR = ('white', BLUES[0]) + +# The "default PySimpleGUI theme" +OFFICIAL_PYSIMPLEGUI_THEME = CURRENT_LOOK_AND_FEEL = 'Dark Blue 3' + +DEFAULT_ERROR_BUTTON_COLOR = ("#FFFFFF", "#FF0000") +DEFAULT_BACKGROUND_COLOR = None +DEFAULT_ELEMENT_BACKGROUND_COLOR = None +DEFAULT_ELEMENT_TEXT_COLOR = COLOR_SYSTEM_DEFAULT +DEFAULT_TEXT_ELEMENT_BACKGROUND_COLOR = None +DEFAULT_TEXT_COLOR = COLOR_SYSTEM_DEFAULT +DEFAULT_INPUT_ELEMENTS_COLOR = COLOR_SYSTEM_DEFAULT +DEFAULT_INPUT_TEXT_COLOR = COLOR_SYSTEM_DEFAULT +DEFAULT_SCROLLBAR_COLOR = None +# DEFAULT_BUTTON_COLOR = (YELLOWS[0], PURPLES[0]) # (Text, Background) or (Color "on", Color) as a way to remember +# DEFAULT_BUTTON_COLOR = (GREENS[3], TANS[0]) # Foreground, Background (None, None) == System Default +# DEFAULT_BUTTON_COLOR = (YELLOWS[0], GREENS[4]) # Foreground, Background (None, None) == System Default +# DEFAULT_BUTTON_COLOR = ('white', 'black') # Foreground, Background (None, None) == System Default +# DEFAULT_BUTTON_COLOR = (YELLOWS[0], PURPLES[2]) # Foreground, Background (None, None) == System Default +# DEFAULT_PROGRESS_BAR_COLOR = (GREENS[2], GREENS[0]) # a nice green progress bar +# DEFAULT_PROGRESS_BAR_COLOR = (BLUES[1], BLUES[1]) # a nice green progress bar +# DEFAULT_PROGRESS_BAR_COLOR = (BLUES[0], BLUES[0]) # a nice green progress bar +# DEFAULT_PROGRESS_BAR_COLOR = (PURPLES[1],PURPLES[0]) # a nice purple progress bar + + +# A transparent button is simply one that matches the background +# TRANSPARENT_BUTTON = 'This constant has been depricated. You must set your button background = background it is on for it to be transparent appearing' + + +# -------------------------------------------------------------------------------- +# Progress Bar Relief Choices +RELIEF_RAISED = 'raised' +RELIEF_SUNKEN = 'sunken' +RELIEF_FLAT = 'flat' +RELIEF_RIDGE = 'ridge' +RELIEF_GROOVE = 'groove' +RELIEF_SOLID = 'solid' +RELIEF_LIST = (RELIEF_RAISED, RELIEF_FLAT, RELIEF_SUNKEN, RELIEF_RIDGE, RELIEF_SOLID, RELIEF_GROOVE) + +# These are the spepific themes that tkinter offers +THEME_DEFAULT = 'default' # this is a TTK theme, not a PSG theme!!! +THEME_WINNATIVE = 'winnative' +THEME_CLAM = 'clam' +THEME_ALT = 'alt' +THEME_CLASSIC = 'classic' +THEME_VISTA = 'vista' +THEME_XPNATIVE = 'xpnative' + +# The theme to use by default for all windows +DEFAULT_TTK_THEME = THEME_DEFAULT +ttk_theme_in_use = None +# TTK_THEME_LIST = ('default', 'winnative', 'clam', 'alt', 'classic', 'vista', 'xpnative') + + +USE_TTK_BUTTONS = None + +DEFAULT_PROGRESS_BAR_COLOR = ("#01826B", '#D0D0D0') # a nice green progress bar +DEFAULT_PROGRESS_BAR_COMPUTE = ('#000000', '#000000') # Means that the progress bar colors should be computed from other colors +DEFAULT_PROGRESS_BAR_COLOR_OFFICIAL = ("#01826B", '#D0D0D0') # a nice green progress bar +DEFAULT_PROGRESS_BAR_SIZE = (20, 20) # Size of Progress Bar (characters for length, pixels for width) +DEFAULT_PROGRESS_BAR_BORDER_WIDTH = 1 +DEFAULT_PROGRESS_BAR_RELIEF = RELIEF_GROOVE +# PROGRESS_BAR_STYLES = ('default', 'winnative', 'clam', 'alt', 'classic', 'vista', 'xpnative') +DEFAULT_PROGRESS_BAR_STYLE = DEFAULT_TTK_THEME +DEFAULT_METER_ORIENTATION = 'Horizontal' +DEFAULT_SLIDER_ORIENTATION = 'vertical' +DEFAULT_SLIDER_BORDER_WIDTH = 1 +DEFAULT_SLIDER_RELIEF = tk.FLAT +DEFAULT_FRAME_RELIEF = tk.GROOVE + +DEFAULT_LISTBOX_SELECT_MODE = tk.SINGLE +SELECT_MODE_MULTIPLE = tk.MULTIPLE +LISTBOX_SELECT_MODE_MULTIPLE = 'multiple' +SELECT_MODE_BROWSE = tk.BROWSE +LISTBOX_SELECT_MODE_BROWSE = 'browse' +SELECT_MODE_EXTENDED = tk.EXTENDED +LISTBOX_SELECT_MODE_EXTENDED = 'extended' +SELECT_MODE_SINGLE = tk.SINGLE +LISTBOX_SELECT_MODE_SINGLE = 'single' + +TABLE_SELECT_MODE_NONE = tk.NONE +TABLE_SELECT_MODE_BROWSE = tk.BROWSE +TABLE_SELECT_MODE_EXTENDED = tk.EXTENDED +DEFAULT_TABLE_SELECT_MODE = TABLE_SELECT_MODE_EXTENDED +TABLE_CLICKED_INDICATOR = '+CLICKED+' # Part of the tuple returned as an event when a Table element has click events enabled +DEFAULT_MODAL_WINDOWS_ENABLED = True +DEFAULT_MODAL_WINDOWS_FORCED = False + +TAB_LOCATION_TOP = 'top' +TAB_LOCATION_TOP_LEFT = 'topleft' +TAB_LOCATION_TOP_RIGHT = 'topright' +TAB_LOCATION_LEFT = 'left' +TAB_LOCATION_LEFT_TOP = 'lefttop' +TAB_LOCATION_LEFT_BOTTOM = 'leftbottom' +TAB_LOCATION_RIGHT = 'right' +TAB_LOCATION_RIGHT_TOP = 'righttop' +TAB_LOCATION_RIGHT_BOTTOM = 'rightbottom' +TAB_LOCATION_BOTTOM = 'bottom' +TAB_LOCATION_BOTTOM_LEFT = 'bottomleft' +TAB_LOCATION_BOTTOM_RIGHT = 'bottomright' + + +TITLE_LOCATION_TOP = tk.N +TITLE_LOCATION_BOTTOM = tk.S +TITLE_LOCATION_LEFT = tk.W +TITLE_LOCATION_RIGHT = tk.E +TITLE_LOCATION_TOP_LEFT = tk.NW +TITLE_LOCATION_TOP_RIGHT = tk.NE +TITLE_LOCATION_BOTTOM_LEFT = tk.SW +TITLE_LOCATION_BOTTOM_RIGHT = tk.SE + +TEXT_LOCATION_TOP = tk.N +TEXT_LOCATION_BOTTOM = tk.S +TEXT_LOCATION_LEFT = tk.W +TEXT_LOCATION_RIGHT = tk.E +TEXT_LOCATION_TOP_LEFT = tk.NW +TEXT_LOCATION_TOP_RIGHT = tk.NE +TEXT_LOCATION_BOTTOM_LEFT = tk.SW +TEXT_LOCATION_BOTTOM_RIGHT = tk.SE +TEXT_LOCATION_CENTER = tk.CENTER + +GRAB_ANYWHERE_IGNORE_THESE_WIDGETS = (ttk.Sizegrip, tk.Scale, ttk.Scrollbar, tk.Scrollbar, tk.Entry, tk.Text, tk.PanedWindow, tk.Listbox, tk.OptionMenu, ttk.Treeview) + +# ----====----====----==== Constants the user should NOT f-with ====----====----====----# +ThisRow = 555666777 # magic number + +# DEFAULT_WINDOW_ICON = '' +MESSAGE_BOX_LINE_WIDTH = 60 + +# "Special" Key Values.. reserved +# Key representing a Read timeout +EVENT_TIMEOUT = TIMEOUT_EVENT = TIMEOUT_KEY = '__TIMEOUT__' +EVENT_TIMER = TIMER_KEY = '__TIMER EVENT__' +WIN_CLOSED = WINDOW_CLOSED = None +WINDOW_CLOSE_ATTEMPTED_EVENT = WIN_X_EVENT = WIN_CLOSE_ATTEMPTED_EVENT = '-WINDOW CLOSE ATTEMPTED-' +WINDOW_CONFIG_EVENT = '__WINDOW CONFIG__' +TITLEBAR_MINIMIZE_KEY = '__TITLEBAR MINIMIZE__' +TITLEBAR_MAXIMIZE_KEY = '__TITLEBAR MAXIMIZE__' +TITLEBAR_CLOSE_KEY = '__TITLEBAR CLOSE__' +TITLEBAR_IMAGE_KEY = '__TITLEBAR IMAGE__' +TITLEBAR_TEXT_KEY = '__TITLEBAR TEXT__' +TITLEBAR_DO_NOT_USE_AN_ICON = '__TITLEBAR_NO_ICON__' + +# Key indicating should not create any return values for element +WRITE_ONLY_KEY = '__WRITE ONLY__' + +MENU_DISABLED_CHARACTER = '!' +MENU_SHORTCUT_CHARACTER = '&' +MENU_KEY_SEPARATOR = '::' +MENU_SEPARATOR_LINE = '---' +MENU_RIGHT_CLICK_EDITME_EXIT = ['', ['Edit Me', 'Exit']] +MENU_RIGHT_CLICK_EDITME_VER_EXIT = ['', ['Edit Me', 'Version', 'Exit']] +MENU_RIGHT_CLICK_EDITME_VER_LOC_EXIT = ['', ['Edit Me', 'Version', 'File Location', 'Exit']] +MENU_RIGHT_CLICK_EDITME_VER_SETTINGS_EXIT = ['', ['Edit Me', 'Settings', 'Version', 'Exit']] +MENU_RIGHT_CLICK_EXIT = ['', ['Exit']] +MENU_RIGHT_CLICK_DISABLED = ['', []] +_MENU_RIGHT_CLICK_TABGROUP_DEFAULT = ['TABGROUP DEFAULT', []] +ENABLE_TK_WINDOWS = False + +USE_CUSTOM_TITLEBAR = None +CUSTOM_TITLEBAR_BACKGROUND_COLOR = None +CUSTOM_TITLEBAR_TEXT_COLOR = None +CUSTOM_TITLEBAR_ICON = None +CUSTOM_TITLEBAR_FONT = None +TITLEBAR_METADATA_MARKER = 'This window has a titlebar' + +CUSTOM_MENUBAR_METADATA_MARKER = 'This is a custom menubar' + +SUPPRESS_ERROR_POPUPS = False +SUPPRESS_RAISE_KEY_ERRORS = True +SUPPRESS_KEY_GUESSING = False +SUPPRESS_WIDGET_NOT_FINALIZED_WARNINGS = False +ENABLE_TREEVIEW_869_PATCH = True + +# These are now set based on the global settings file +ENABLE_MAC_NOTITLEBAR_PATCH = False +ENABLE_MAC_MODAL_DISABLE_PATCH = False +ENABLE_MAC_DISABLE_GRAB_ANYWHERE_WITH_TITLEBAR = True +ENABLE_MAC_ALPHA_99_PATCH= False + +OLD_TABLE_TREE_SELECTED_ROW_COLORS = ('#FFFFFF', '#4A6984') +ALTERNATE_TABLE_AND_TREE_SELECTED_ROW_COLORS = ('SystemHighlightText', 'SystemHighlight') + +# Some handy unicode symbols +SYMBOL_SQUARE = '█' +SYMBOL_CIRCLE = '⚫' +SYMBOL_CIRCLE_OUTLINE = '◯' +SYMBOL_BULLET = '•' +SYMBOL_UP = '▲' +SYMBOL_RIGHT = '►' +SYMBOL_LEFT = '◄' +SYMBOL_DOWN = '▼' +SYMBOL_X = '❎' +SYMBOL_CHECK = '✅' +SYMBOL_CHECK_SMALL = '✓' +SYMBOL_X_SMALL = '✗' +SYMBOL_BALLOT_X = '☒' +SYMBOL_BALLOT_CHECK = '☑' +SYMBOL_LEFT_DOUBLE = '«' +SYMBOL_RIGHT_DOUBLE = '»' +SYMBOL_LEFT_ARROWHEAD = '⮜' +SYMBOL_RIGHT_ARROWHEAD = '⮞' +SYMBOL_UP_ARROWHEAD = '⮝' +SYMBOL_DOWN_ARROWHEAD = '⮟' + +if sum([int(i) for i in tclversion_detailed.split('.')]) > 19: + SYMBOL_TITLEBAR_MINIMIZE = '_' + SYMBOL_TITLEBAR_MAXIMIZE = '◻' + SYMBOL_TITLEBAR_CLOSE = 'X' +else: + SYMBOL_TITLEBAR_MINIMIZE = '_' + SYMBOL_TITLEBAR_MAXIMIZE = 'O' + SYMBOL_TITLEBAR_CLOSE = 'X' + +#################### PATHS for user_settings APIs #################### +# These paths are passed to os.path.expanduser to get the default path for user_settings +# They can be changed using set_options + +DEFAULT_USER_SETTINGS_WIN_PATH = r'~\AppData\Local\PySimpleGUI\settings' +DEFAULT_USER_SETTINGS_LINUX_PATH = r'~/.config/PySimpleGUI/settings' +DEFAULT_USER_SETTINGS_MAC_PATH = r'~/Library/Application Support/PySimpleGUI/settings' +DEFAULT_USER_SETTINGS_TRINKET_PATH = r'.' +DEFAULT_USER_SETTINGS_REPLIT_PATH = r'.' +DEFAULT_USER_SETTINGS_UNKNOWN_OS_PATH = r'~/Library/Application Support/PySimpleGUI/settings' +DEFAULT_USER_SETTINGS_PATH = None # value set by user to override all paths above +DEFAULT_USER_SETTINGS_PYSIMPLEGUI_PATH = None # location of the global PySimpleGUI settings +DEFAULT_USER_SETTINGS_PYSIMPLEGUI_FILENAME = '_PySimpleGUI_settings_global_.json' # location of the global PySimpleGUI settings + + +# ====================================================================== # +# One-liner functions that are handy as f_ck # +# ====================================================================== # +def rgb(red, green, blue): + """ + Given integer values of Red, Green, Blue, return a color string "#RRGGBB" + :param red: Red portion from 0 to 255 + :type red: (int) + :param green: Green portion from 0 to 255 + :type green: (int) + :param blue: Blue portion from 0 to 255 + :type blue: (int) + :return: A single RGB String in the format "#RRGGBB" where each pair is a hex number. + :rtype: (str) + """ + red = min(int(red), 255) if red > 0 else 0 + blue = min(int(blue), 255) if blue > 0 else 0 + green = min(int(green), 255) if green > 0 else 0 + return '#%02x%02x%02x' % (red, green, blue) + + +# ====================================================================== # +# Enums for types # +# ====================================================================== # +# ------------------------- Button types ------------------------- # +# uncomment this line and indent to go back to using Enums +BUTTON_TYPE_BROWSE_FOLDER = 1 +BUTTON_TYPE_BROWSE_FILE = 2 +BUTTON_TYPE_BROWSE_FILES = 21 +BUTTON_TYPE_SAVEAS_FILE = 3 +BUTTON_TYPE_CLOSES_WIN = 5 +BUTTON_TYPE_CLOSES_WIN_ONLY = 6 +BUTTON_TYPE_READ_FORM = 7 +BUTTON_TYPE_REALTIME = 9 +BUTTON_TYPE_CALENDAR_CHOOSER = 30 +BUTTON_TYPE_COLOR_CHOOSER = 40 +BUTTON_TYPE_SHOW_DEBUGGER = 50 + +BROWSE_FILES_DELIMITER = ';' # the delimiter to be used between each file in the returned string + +FILE_TYPES_ALL_FILES = (("ALL Files", "*.* *"),) + +BUTTON_DISABLED_MEANS_IGNORE = 'ignore' + +# ------------------------- Element types ------------------------- # + +ELEM_TYPE_TEXT = 'text' +ELEM_TYPE_INPUT_TEXT = 'input' +ELEM_TYPE_INPUT_COMBO = 'combo' +ELEM_TYPE_INPUT_OPTION_MENU = 'option menu' +ELEM_TYPE_INPUT_RADIO = 'radio' +ELEM_TYPE_INPUT_MULTILINE = 'multiline' +ELEM_TYPE_INPUT_CHECKBOX = 'checkbox' +ELEM_TYPE_INPUT_SPIN = 'spind' +ELEM_TYPE_BUTTON = 'button' +ELEM_TYPE_IMAGE = 'image' +ELEM_TYPE_CANVAS = 'canvas' +ELEM_TYPE_FRAME = 'frame' +ELEM_TYPE_GRAPH = 'graph' +ELEM_TYPE_TAB = 'tab' +ELEM_TYPE_TAB_GROUP = 'tabgroup' +ELEM_TYPE_INPUT_SLIDER = 'slider' +ELEM_TYPE_INPUT_LISTBOX = 'listbox' +ELEM_TYPE_OUTPUT = 'output' +ELEM_TYPE_COLUMN = 'column' +ELEM_TYPE_MENUBAR = 'menubar' +ELEM_TYPE_PROGRESS_BAR = 'progressbar' +ELEM_TYPE_BLANK = 'blank' +ELEM_TYPE_TABLE = 'table' +ELEM_TYPE_TREE = 'tree' +ELEM_TYPE_ERROR = 'error' +ELEM_TYPE_SEPARATOR = 'separator' +ELEM_TYPE_STATUSBAR = 'statusbar' +ELEM_TYPE_PANE = 'pane' +ELEM_TYPE_BUTTONMENU = 'buttonmenu' +ELEM_TYPE_TITLEBAR = 'titlebar' +ELEM_TYPE_SIZEGRIP = 'sizegrip' + +# STRETCH == ERROR ELEMENT as a filler + +# ------------------------- Popup Buttons Types ------------------------- # +POPUP_BUTTONS_YES_NO = 1 +POPUP_BUTTONS_CANCELLED = 2 +POPUP_BUTTONS_ERROR = 3 +POPUP_BUTTONS_OK_CANCEL = 4 +POPUP_BUTTONS_OK = 0 +POPUP_BUTTONS_NO_BUTTONS = 5 + + + +PSG_THEME_PART_BUTTON_TEXT = 'Button Text Color' +PSG_THEME_PART_BUTTON_BACKGROUND = 'Button Background Color' +PSG_THEME_PART_BACKGROUND = 'Background Color' +PSG_THEME_PART_INPUT_BACKGROUND = 'Input Element Background Color' +PSG_THEME_PART_INPUT_TEXT = 'Input Element Text Color' +PSG_THEME_PART_TEXT = 'Text Color' +PSG_THEME_PART_SLIDER = 'Slider Color' +PSG_THEME_PART_LIST = [PSG_THEME_PART_BACKGROUND, PSG_THEME_PART_BUTTON_BACKGROUND, PSG_THEME_PART_BUTTON_TEXT,PSG_THEME_PART_INPUT_BACKGROUND, PSG_THEME_PART_INPUT_TEXT, PSG_THEME_PART_TEXT, PSG_THEME_PART_SLIDER ] + +# theme_button + +TTK_SCROLLBAR_PART_TROUGH_COLOR = 'Trough Color' +TTK_SCROLLBAR_PART_BACKGROUND_COLOR = 'Background Color' +TTK_SCROLLBAR_PART_ARROW_BUTTON_ARROW_COLOR = 'Arrow Button Arrow Color' +TTK_SCROLLBAR_PART_FRAME_COLOR = 'Frame Color' +TTK_SCROLLBAR_PART_SCROLL_WIDTH = 'Frame Width' +TTK_SCROLLBAR_PART_ARROW_WIDTH = 'Arrow Width' +TTK_SCROLLBAR_PART_RELIEF = 'Relief' +TTK_SCROLLBAR_PART_LIST = [TTK_SCROLLBAR_PART_TROUGH_COLOR, TTK_SCROLLBAR_PART_BACKGROUND_COLOR, TTK_SCROLLBAR_PART_ARROW_BUTTON_ARROW_COLOR, + TTK_SCROLLBAR_PART_FRAME_COLOR, TTK_SCROLLBAR_PART_SCROLL_WIDTH, TTK_SCROLLBAR_PART_ARROW_WIDTH, TTK_SCROLLBAR_PART_RELIEF] +TTK_SCROLLBAR_PART_THEME_BASED_LIST = [TTK_SCROLLBAR_PART_TROUGH_COLOR, TTK_SCROLLBAR_PART_BACKGROUND_COLOR, TTK_SCROLLBAR_PART_ARROW_BUTTON_ARROW_COLOR, + TTK_SCROLLBAR_PART_FRAME_COLOR] +DEFAULT_TTK_PART_MAPPING_DICT = {TTK_SCROLLBAR_PART_TROUGH_COLOR: PSG_THEME_PART_SLIDER, + TTK_SCROLLBAR_PART_BACKGROUND_COLOR : PSG_THEME_PART_BUTTON_BACKGROUND, + TTK_SCROLLBAR_PART_ARROW_BUTTON_ARROW_COLOR :PSG_THEME_PART_BUTTON_TEXT, + TTK_SCROLLBAR_PART_FRAME_COLOR : PSG_THEME_PART_BACKGROUND, + TTK_SCROLLBAR_PART_SCROLL_WIDTH : 12, + TTK_SCROLLBAR_PART_ARROW_WIDTH: 12, + TTK_SCROLLBAR_PART_RELIEF: RELIEF_RAISED} + +ttk_part_mapping_dict = copy.copy(DEFAULT_TTK_PART_MAPPING_DICT) + +class TTKPartOverrides(): + """ + This class contains "overrides" to the defaults for ttk scrollbars that are defined in the global settings file. + This class is used in every element, in the Window class and there's a global one that is used by set_options. + """ + def __init__(self, sbar_trough_color=None, sbar_background_color=None, sbar_arrow_color=None, sbar_width=None, sbar_arrow_width=None, sbar_frame_color=None, sbar_relief=None): + self.sbar_trough_color = sbar_trough_color + self.sbar_background_color = sbar_background_color + self.sbar_arrow_color = sbar_arrow_color + self.sbar_width = sbar_width + self.sbar_arrow_width = sbar_arrow_width + self.sbar_frame_color = sbar_frame_color + self.sbar_relief = sbar_relief + +ttk_part_overrides_from_options = TTKPartOverrides() + + +# ------------------------- tkinter BASIC cursors... there are some OS dependent ones too ------------------------- # +TKINTER_CURSORS = ['X_cursor', 'arrow', 'based_arrow_down', 'based_arrow_up', 'boat', + 'bogosity', 'bottom_left_corner', 'bottom_right_corner', 'bottom_side', + 'bottom_tee', 'box_spiral', 'center_ptr', 'circle', 'clock', + 'coffee_mug', 'cross', 'cross_reverse', 'crosshair', 'diamond_cross', + 'dot', 'dotbox', 'double_arrow', 'draft_large', 'draft_small', 'draped_box', + 'exchange', 'fleur', 'gobbler', 'gumby', 'hand1', 'hand2', 'heart', + 'icon', 'iron_cross', 'left_ptr', 'left_side', 'left_tee', 'leftbutton', + 'll_angle', 'lr_angle', 'man', 'middlebutton', 'mouse', 'pencil', 'pirate', + 'plus', 'question_arrow', 'right_ptr', 'right_side', 'right_tee', + 'rightbutton', 'rtl_logo', 'sailboat', 'sb_down_arrow', 'sb_h_double_arrow', + 'sb_left_arrow', 'sb_right_arrow', 'sb_up_arrow', 'sb_v_double_arrow', + 'shuttle', 'sizing', 'spider', 'spraycan', 'star', 'target', 'tcross', + 'top_left_arrow', 'top_left_corner', 'top_right_corner', 'top_side', 'top_tee', + 'trek', 'ul_angle', 'umbrella', 'ur_angle', 'watch', 'xterm'] + + +TKINTER_CURSORS = ['X_cursor', 'arrow', 'based_arrow_down', 'based_arrow_up', 'boat', 'bogosity', 'bottom_left_corner', 'bottom_right_corner', 'bottom_side', 'bottom_tee', 'box_spiral', 'center_ptr', 'circle', 'clock', 'coffee_mug', 'cross', 'cross_reverse', 'crosshair', 'diamond_cross', 'dot', 'dotbox', 'double_arrow', 'draft_large', 'draft_small', 'draped_box', 'exchange', 'fleur', 'gobbler', 'gumby', 'hand1', 'hand2', 'heart', 'ibeam', 'icon', 'iron_cross', 'left_ptr', 'left_side', 'left_tee', 'leftbutton', 'll_angle', 'lr_angle', 'man', 'middlebutton', 'mouse', 'no', 'none', 'pencil', 'pirate', 'plus', 'question_arrow', 'right_ptr', 'right_side', 'right_tee', 'rightbutton', 'rtl_logo', 'sailboat', 'sb_down_arrow', 'sb_h_double_arrow', 'sb_left_arrow', 'sb_right_arrow', 'sb_up_arrow', 'sb_v_double_arrow', 'shuttle', 'size', 'size_ne_sw', 'size_ns', 'size_nw_se', 'size_we', 'sizing', 'spider', 'spraycan', 'star', 'starting', 'target', 'tcross', 'top_left_arrow', 'top_left_corner', 'top_right_corner', 'top_side', 'top_tee', 'trek', 'ul_angle', 'umbrella', 'uparrow', 'ur_angle', 'wait', 'watch', 'xterm'] +# ------------------------- tkinter key codes for bindings ------------------------- # + +# The keycode that when pressed will take a snapshot of the current window +DEFAULT_WINDOW_SNAPSHOT_KEY_CODE = None +DEFAULT_WINDOW_SNAPSHOT_KEY = '--SCREENSHOT THIS WINDOW--' + +tkinter_keysyms = ('space', 'exclam', 'quotedbl', 'numbersign', 'dollar', 'percent', 'ampersand', 'quoteright', 'parenleft', 'parenright', 'asterisk', 'plus', 'comma', 'minus', 'period', 'slash', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'colon', 'semicolon', 'less', 'equal', 'greater', 'question', 'at', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 'bracketleft', 'backslash', 'bracketright', 'asciicircum', 'underscore', 'quoteleft', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', 'braceleft', 'bar', 'braceright', 'asciitilde', 'nobreakspace', 'exclamdown', 'cent', 'sterling', 'currency', 'yen', 'brokenbar', 'section', 'diaeresis', 'copyright', 'ordfeminine', 'guillemotleft', 'notsign', 'hyphen', 'registered', 'macron', 'degree', 'plusminus', 'twosuperior', 'threesuperior', 'acute', 'mu', 'paragraph', 'periodcentered', 'cedilla', 'onesuperior', 'masculine', 'guillemotright', 'onequarter', 'onehalf', 'threequarters', 'questiondown', 'Agrave', 'Aacute', 'Acircumflex', 'Atilde', 'Adiaeresis', 'Aring', 'AE', 'Ccedilla', 'Egrave', 'Eacute', 'Ecircumflex', 'Ediaeresis', 'Igrave', 'Iacute', 'Icircumflex', 'Idiaeresis', 'Eth', 'Ntilde', 'Ograve', 'Oacute', 'Ocircumflex', 'Otilde', 'Odiaeresis', 'multiply', 'Ooblique', 'Ugrave', 'Uacute', 'Ucircumflex', 'Udiaeresis', 'Yacute', 'Thorn', 'ssharp', 'agrave', 'aacute', 'acircumflex', 'atilde', 'adiaeresis', 'aring', 'ae', 'ccedilla', 'egrave', 'eacute', 'ecircumflex', 'ediaeresis', 'igrave', 'iacute', 'icircumflex', 'idiaeresis', 'eth', 'ntilde', 'ograve', 'oacute', 'ocircumflex', 'otilde', 'odiaeresis', 'division', 'oslash', 'ugrave', 'uacute', 'ucircumflex', 'udiaeresis', 'yacute', 'thorn', 'ydiaeresis', 'Aogonek', 'breve', 'Lstroke', 'Lcaron', 'Sacute', 'Scaron', 'Scedilla', 'Tcaron', 'Zacute', 'Zcaron', 'Zabovedot', 'aogonek', 'ogonek', 'lstroke', 'lcaron', 'sacute', 'caron', 'scaron', 'scedilla', 'tcaron', 'zacute', 'doubleacute', 'zcaron', 'zabovedot', 'Racute', 'Abreve', 'Cacute', 'Ccaron', 'Eogonek', 'Ecaron', 'Dcaron', 'Nacute', 'Ncaron', 'Odoubleacute', 'Rcaron', 'Uring', 'Udoubleacute', 'Tcedilla', 'racute', 'abreve', 'cacute', 'ccaron', 'eogonek', 'ecaron', 'dcaron', 'nacute', 'ncaron', 'odoubleacute', 'rcaron', 'uring', 'udoubleacute', 'tcedilla', 'abovedot', 'Hstroke', 'Hcircumflex', 'Iabovedot', 'Gbreve', 'Jcircumflex', 'hstroke', 'hcircumflex', 'idotless', 'gbreve', 'jcircumflex', 'Cabovedot', 'Ccircumflex', 'Gabovedot', 'Gcircumflex', 'Ubreve', 'Scircumflex', 'cabovedot', 'ccircumflex', 'gabovedot', 'gcircumflex', 'ubreve', 'scircumflex', 'kappa', 'Rcedilla', 'Itilde', 'Lcedilla', 'Emacron', 'Gcedilla', 'Tslash', 'rcedilla', 'itilde', 'lcedilla', 'emacron', 'gacute', 'tslash', 'ENG', 'eng', 'Amacron', 'Iogonek', 'Eabovedot', 'Imacron', 'Ncedilla', 'Omacron', 'Kcedilla', 'Uogonek', 'Utilde', 'Umacron', 'amacron', 'iogonek', 'eabovedot', 'imacron', 'ncedilla', 'omacron', 'kcedilla', 'uogonek', 'utilde', 'umacron', 'overline', 'kana_fullstop', 'kana_openingbracket', 'kana_closingbracket', 'kana_comma', 'kana_middledot', 'kana_WO', 'kana_a', 'kana_i', 'kana_u', 'kana_e', 'kana_o', 'kana_ya', 'kana_yu', 'kana_yo', 'kana_tu', 'prolongedsound', 'kana_A', 'kana_I', 'kana_U', 'kana_E', 'kana_O', 'kana_KA', 'kana_KI', 'kana_KU', 'kana_KE', 'kana_KO', 'kana_SA', 'kana_SHI', 'kana_SU', 'kana_SE', 'kana_SO', 'kana_TA', 'kana_TI', 'kana_TU', 'kana_TE', 'kana_TO', 'kana_NA', 'kana_NI', 'kana_NU', 'kana_NE', 'kana_NO', 'kana_HA', 'kana_HI', 'kana_HU', 'kana_HE', 'kana_HO', 'kana_MA', 'kana_MI', 'kana_MU', 'kana_ME', 'kana_MO', 'kana_YA', 'kana_YU', 'kana_YO', 'kana_RA', 'kana_RI', 'kana_RU', 'kana_RE', 'kana_RO', 'kana_WA', 'kana_N', 'voicedsound', 'semivoicedsound', 'Arabic_comma', 'Arabic_semicolon', 'Arabic_question_mark', 'Arabic_hamza', 'Arabic_maddaonalef', 'Arabic_hamzaonalef', 'Arabic_hamzaonwaw', 'Arabic_hamzaunderalef', 'Arabic_hamzaonyeh', 'Arabic_alef', 'Arabic_beh', 'Arabic_tehmarbuta', 'Arabic_teh', 'Arabic_theh', 'Arabic_jeem', 'Arabic_hah', 'Arabic_khah', 'Arabic_dal', 'Arabic_thal', 'Arabic_ra', 'Arabic_zain', 'Arabic_seen', 'Arabic_sheen', 'Arabic_sad', 'Arabic_dad', 'Arabic_tah', 'Arabic_zah', 'Arabic_ain', 'Arabic_ghain', 'Arabic_tatweel', 'Arabic_feh', 'Arabic_qaf', 'Arabic_kaf', 'Arabic_lam', 'Arabic_meem', 'Arabic_noon', 'Arabic_heh', 'Arabic_waw', 'Arabic_alefmaksura', 'Arabic_yeh', 'Arabic_fathatan', 'Arabic_dammatan', 'Arabic_kasratan', 'Arabic_fatha', 'Arabic_damma', 'Arabic_kasra', 'Arabic_shadda', 'Arabic_sukun', 'Serbian_dje', 'Macedonia_gje', 'Cyrillic_io', 'Ukranian_je', 'Macedonia_dse', 'Ukranian_i', 'Ukranian_yi', 'Serbian_je', 'Serbian_lje', 'Serbian_nje', 'Serbian_tshe', 'Macedonia_kje', 'Byelorussian_shortu', 'Serbian_dze', 'numerosign', 'Serbian_DJE', 'Macedonia_GJE', 'Cyrillic_IO', 'Ukranian_JE', 'Macedonia_DSE', 'Ukranian_I', 'Ukranian_YI', 'Serbian_JE', 'Serbian_LJE', 'Serbian_NJE', 'Serbian_TSHE', 'Macedonia_KJE', 'Byelorussian_SHORTU', 'Serbian_DZE', 'Cyrillic_yu', 'Cyrillic_a', 'Cyrillic_be', 'Cyrillic_tse', 'Cyrillic_de', 'Cyrillic_ie', 'Cyrillic_ef', 'Cyrillic_ghe', 'Cyrillic_ha', 'Cyrillic_i', 'Cyrillic_shorti', 'Cyrillic_ka', 'Cyrillic_el', 'Cyrillic_em', 'Cyrillic_en', 'Cyrillic_o', 'Cyrillic_pe', 'Cyrillic_ya', 'Cyrillic_er', 'Cyrillic_es', 'Cyrillic_te', 'Cyrillic_u', 'Cyrillic_zhe', 'Cyrillic_ve', 'Cyrillic_softsign', 'Cyrillic_yeru', 'Cyrillic_ze', 'Cyrillic_sha', 'Cyrillic_e', 'Cyrillic_shcha', 'Cyrillic_che', 'Cyrillic_hardsign', 'Cyrillic_YU', 'Cyrillic_A', 'Cyrillic_BE', 'Cyrillic_TSE', 'Cyrillic_DE', 'Cyrillic_IE', 'Cyrillic_EF', 'Cyrillic_GHE', 'Cyrillic_HA', 'Cyrillic_I', 'Cyrillic_SHORTI', 'Cyrillic_KA', 'Cyrillic_EL', 'Cyrillic_EM', 'Cyrillic_EN', 'Cyrillic_O', 'Cyrillic_PE', 'Cyrillic_YA', 'Cyrillic_ER', 'Cyrillic_ES', 'Cyrillic_TE', 'Cyrillic_U', 'Cyrillic_ZHE', 'Cyrillic_VE', 'Cyrillic_SOFTSIGN', 'Cyrillic_YERU', 'Cyrillic_ZE', 'Cyrillic_SHA', 'Cyrillic_E', 'Cyrillic_SHCHA', 'Cyrillic_CHE', 'Cyrillic_HARDSIGN', 'Greek_ALPHAaccent', 'Greek_EPSILONaccent', 'Greek_ETAaccent', 'Greek_IOTAaccent', 'Greek_IOTAdiaeresis', 'Greek_IOTAaccentdiaeresis', 'Greek_OMICRONaccent', 'Greek_UPSILONaccent', 'Greek_UPSILONdieresis', 'Greek_UPSILONaccentdieresis', 'Greek_OMEGAaccent', 'Greek_alphaaccent', 'Greek_epsilonaccent', 'Greek_etaaccent', 'Greek_iotaaccent', 'Greek_iotadieresis', 'Greek_iotaaccentdieresis', 'Greek_omicronaccent', 'Greek_upsilonaccent', 'Greek_upsilondieresis', 'Greek_upsilonaccentdieresis', 'Greek_omegaaccent', 'Greek_ALPHA', 'Greek_BETA', 'Greek_GAMMA', 'Greek_DELTA', 'Greek_EPSILON', 'Greek_ZETA', 'Greek_ETA', 'Greek_THETA', 'Greek_IOTA', 'Greek_KAPPA', 'Greek_LAMBDA', 'Greek_MU', 'Greek_NU', 'Greek_XI', 'Greek_OMICRON', 'Greek_PI', 'Greek_RHO', 'Greek_SIGMA', 'Greek_TAU', 'Greek_UPSILON', 'Greek_PHI', 'Greek_CHI', 'Greek_PSI', 'Greek_OMEGA', 'Greek_alpha', 'Greek_beta', 'Greek_gamma', 'Greek_delta', 'Greek_epsilon', 'Greek_zeta', 'Greek_eta', 'Greek_theta', 'Greek_iota', 'Greek_kappa', 'Greek_lambda', 'Greek_mu', 'Greek_nu', 'Greek_xi', 'Greek_omicron', 'Greek_pi', 'Greek_rho', 'Greek_sigma', 'Greek_finalsmallsigma', 'Greek_tau', 'Greek_upsilon', 'Greek_phi', 'Greek_chi', 'Greek_psi', 'Greek_omega', 'leftradical', 'topleftradical', 'horizconnector', 'topintegral', 'botintegral', 'vertconnector', 'topleftsqbracket', 'botleftsqbracket', 'toprightsqbracket', 'botrightsqbracket', 'topleftparens', 'botleftparens', 'toprightparens', 'botrightparens', 'leftmiddlecurlybrace', 'rightmiddlecurlybrace', 'topleftsummation', 'botleftsummation', 'topvertsummationconnector', 'botvertsummationconnector', 'toprightsummation', 'botrightsummation', 'rightmiddlesummation', 'lessthanequal', 'notequal', 'greaterthanequal', 'integral', 'therefore', 'variation', 'infinity', 'nabla', 'approximate', 'similarequal', 'ifonlyif', 'implies', 'identical', 'radical', 'includedin', 'includes', 'intersection', 'union', 'logicaland', 'logicalor', 'partialderivative', 'function', 'leftarrow', 'uparrow', 'rightarrow', 'downarrow', 'blank', 'soliddiamond', 'checkerboard', 'ht', 'ff', 'cr', 'lf', 'nl', 'vt', 'lowrightcorner', 'uprightcorner', 'upleftcorner', 'lowleftcorner', 'crossinglines', 'horizlinescan1', 'horizlinescan3', 'horizlinescan5', 'horizlinescan7', 'horizlinescan9', 'leftt', 'rightt', 'bott', 'topt', 'vertbar', 'emspace', 'enspace', 'em3space', 'em4space', 'digitspace', 'punctspace', 'thinspace', 'hairspace', 'emdash', 'endash', 'signifblank', 'ellipsis', 'doubbaselinedot', 'onethird', 'twothirds', 'onefifth', 'twofifths', 'threefifths', 'fourfifths', 'onesixth', 'fivesixths', 'careof', 'figdash', 'leftanglebracket', 'decimalpoint', 'rightanglebracket', 'marker', 'oneeighth', 'threeeighths', 'fiveeighths', 'seveneighths', 'trademark', 'signaturemark', 'trademarkincircle', 'leftopentriangle', 'rightopentriangle', 'emopencircle', 'emopenrectangle', 'leftsinglequotemark', 'rightsinglequotemark', 'leftdoublequotemark', 'rightdoublequotemark', 'prescription', 'minutes', 'seconds', 'latincross', 'hexagram', 'filledrectbullet', 'filledlefttribullet', 'filledrighttribullet', 'emfilledcircle', 'emfilledrect', 'enopencircbullet', 'enopensquarebullet', 'openrectbullet', 'opentribulletup', 'opentribulletdown', 'openstar', 'enfilledcircbullet', 'enfilledsqbullet', 'filledtribulletup', 'filledtribulletdown', 'leftpointer', 'rightpointer', 'club', 'diamond', 'heart', 'maltesecross', 'dagger', 'doubledagger', 'checkmark', 'ballotcross', 'musicalsharp', 'musicalflat', 'malesymbol', 'femalesymbol', 'telephone', 'telephonerecorder', 'phonographcopyright', 'caret', 'singlelowquotemark', 'doublelowquotemark', 'cursor', 'leftcaret', 'rightcaret', 'downcaret', 'upcaret', 'overbar', 'downtack', 'upshoe', 'downstile', 'underbar', 'jot', 'quad', 'uptack', 'circle', 'upstile', 'downshoe', 'rightshoe', 'leftshoe', 'lefttack', 'righttack', 'hebrew_aleph', 'hebrew_beth', 'hebrew_gimmel', 'hebrew_daleth', 'hebrew_he', 'hebrew_waw', 'hebrew_zayin', 'hebrew_het', 'hebrew_teth', 'hebrew_yod', 'hebrew_finalkaph', 'hebrew_kaph', 'hebrew_lamed', 'hebrew_finalmem', 'hebrew_mem', 'hebrew_finalnun', 'hebrew_nun', 'hebrew_samekh', 'hebrew_ayin', 'hebrew_finalpe', 'hebrew_pe', 'hebrew_finalzadi', 'hebrew_zadi', 'hebrew_kuf', 'hebrew_resh', 'hebrew_shin', 'hebrew_taf', 'BackSpace', 'Tab', 'Linefeed', 'Clear', 'Return', 'Pause', 'Scroll_Lock', 'Sys_Req', 'Escape', 'Multi_key', 'Kanji', 'Home', 'Left', 'Up', 'Right', 'Down', 'Prior', 'Next', 'End', 'Begin', 'Win_L', 'Win_R', 'App', 'Select', 'Print', 'Execute', 'Insert', 'Undo', 'Redo', 'Menu', 'Find', 'Cancel', 'Help', 'Break', 'Hebrew_switch', 'Num_Lock', 'KP_Space', 'KP_Tab', 'KP_Enter', 'KP_F1', 'KP_F2', 'KP_F3', 'KP_F4', 'KP_Multiply', 'KP_Add', 'KP_Separator', 'KP_Subtract', 'KP_Decimal', 'KP_Divide', 'KP_0', 'KP_1', 'KP_2', 'KP_3', 'KP_4', 'KP_5', 'KP_6', 'KP_7', 'KP_8', 'KP_9', 'KP_Equal', 'F1', 'F2', 'F3', 'F4', 'F5', 'F6', 'F7', 'F8', 'F9', 'F10', 'F11', 'F12', 'L1', 'L2', 'L3', 'L4', 'L5', 'L6', 'L7', 'L8', 'L9', 'L10', 'R1', 'R2', 'R3', 'R4', 'R5', 'R6', 'R7', 'R8', 'R9', 'R10', 'R11', 'R12', 'F33', 'R14', 'R15', 'Shift_L', 'Shift_R', 'Control_L', 'Control_R', 'Caps_Lock', 'Shift_Lock', 'Meta_L', 'Meta_R', 'Alt_L', 'Alt_R', 'Super_L', 'Super_R', 'Hyper_L', 'Hyper_R', 'Delete') + + + + + +# ------------------------------------------------------------------------- # +# ToolTip used by the Elements # +# ------------------------------------------------------------------------- # + +class ToolTip: + """ + Create a tooltip for a given widget + (inspired by https://stackoverflow.com/a/36221216) + This is an INTERNALLY USED only class. Users should not refer to this class at all. + """ + + def __init__(self, widget, text, timeout=DEFAULT_TOOLTIP_TIME): + """ + :param widget: The tkinter widget + :type widget: widget type varies + :param text: text for the tooltip. It can inslude \n + :type text: (str) + :param timeout: Time in milliseconds that mouse must remain still before tip is shown + :type timeout: (int) + """ + self.widget = widget + self.text = text + self.timeout = timeout + # self.wraplength = wraplength if wraplength else widget.winfo_screenwidth() // 2 + self.tipwindow = None + self.id = None + self.x = self.y = 0 + self.widget.bind("", self.enter) + self.widget.bind("", self.leave) + self.widget.bind("", self.leave) + + def enter(self, event=None): + """ + Called by tkinter when mouse enters a widget + :param event: from tkinter. Has x,y coordinates of mouse + :type event: + + """ + self.x = event.x + self.y = event.y + self.schedule() + + def leave(self, event=None): + """ + Called by tktiner when mouse exits a widget + :param event: from tkinter. Event info that's not used by function. + :type event: + + """ + self.unschedule() + self.hidetip() + + def schedule(self): + """ + Schedule a timer to time how long mouse is hovering + """ + self.unschedule() + self.id = self.widget.after(self.timeout, self.showtip) + + def unschedule(self): + """ + Cancel timer used to time mouse hover + """ + if self.id: + self.widget.after_cancel(self.id) + self.id = None + + def showtip(self): + """ + Creates a topoltip window with the tooltip text inside of it + """ + if self.tipwindow: + return + x = self.widget.winfo_rootx() + self.x + DEFAULT_TOOLTIP_OFFSET[0] + y = self.widget.winfo_rooty() + self.y + DEFAULT_TOOLTIP_OFFSET[1] + self.tipwindow = tk.Toplevel(self.widget) + # if not sys.platform.startswith('darwin'): + try: + self.tipwindow.wm_overrideredirect(True) + # if running_mac() and ENABLE_MAC_NOTITLEBAR_PATCH: + if _mac_should_apply_notitlebar_patch(): + self.tipwindow.wm_overrideredirect(False) + except Exception as e: + print('* Error performing wm_overrideredirect in showtip *', e) + self.tipwindow.wm_geometry("+%d+%d" % (x, y)) + self.tipwindow.wm_attributes("-topmost", 1) + + label = ttk.Label(self.tipwindow, text=self.text, justify=tk.LEFT, + background=TOOLTIP_BACKGROUND_COLOR, relief=tk.SOLID, borderwidth=1) + if TOOLTIP_FONT is not None: + label.config(font=TOOLTIP_FONT) + label.pack() + + def hidetip(self): + """ + Destroy the tooltip window + """ + if self.tipwindow: + self.tipwindow.destroy() + self.tipwindow = None + + +# ---------------------------------------------------------------------- # +# Cascading structure.... Objects get larger # +# Button # +# Element # +# Row # +# Form # +# ---------------------------------------------------------------------- # +# ------------------------------------------------------------------------- # +# Element CLASS # +# ------------------------------------------------------------------------- # +class Element(): + """ The base class for all Elements. Holds the basic description of an Element like size and colors """ + + def __init__(self, type, size=(None, None), auto_size_text=None, font=None, background_color=None, text_color=None, key=None, pad=None, tooltip=None, + visible=True, metadata=None, + sbar_trough_color=None, sbar_background_color=None, sbar_arrow_color=None, sbar_width=None, sbar_arrow_width=None, sbar_frame_color=None, sbar_relief=None): + """ + Element base class. Only used internally. User will not create an Element object by itself + + :param type: The type of element. These constants all start with "ELEM_TYPE_" + :type type: (int) (could be enum) + :param size: w=characters-wide, h=rows-high. If an int instead of a tuple is supplied, then height is auto-set to 1 + :type size: (int, int) | (None, None) | int + :param auto_size_text: True if the Widget should be shrunk to exactly fit the number of chars to show + :type auto_size_text: bool + :param font: specifies the font family, size. Tuple or Single string format 'name size styles'. Styles: italic * roman bold normal underline overstrike + :type font: (str or (str, int[, str]) or None) + :param background_color: color of background. Can be in #RRGGBB format or a color name "black" + :type background_color: (str) + :param text_color: element's text color. Can be in #RRGGBB format or a color name "black" + :type text_color: (str) + :param key: Identifies an Element. Should be UNIQUE to this window. + :type key: str | int | tuple | object + :param pad: Amount of padding to put around element in pixels (left/right, top/bottom). If an int is given, then auto-converted to tuple (int, int) + :type pad: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param tooltip: text, that will appear when mouse hovers over the element + :type tooltip: (str) + :param visible: set visibility state of the element (Default = True) + :type visible: (bool) + :param metadata: User metadata that can be set to ANYTHING + :type metadata: (Any) + :param sbar_trough_color: Scrollbar color of the trough + :type sbar_trough_color: (str) + :param sbar_background_color: Scrollbar color of the background of the arrow buttons at the ends AND the color of the "thumb" (the thing you grab and slide). Switches to arrow color when mouse is over + :type sbar_background_color: (str) + :param sbar_arrow_color: Scrollbar color of the arrow at the ends of the scrollbar (it looks like a button). Switches to background color when mouse is over + :type sbar_arrow_color: (str) + :param sbar_width: Scrollbar width in pixels + :type sbar_width: (int) + :param sbar_arrow_width: Scrollbar width of the arrow on the scrollbar. It will potentially impact the overall width of the scrollbar + :type sbar_arrow_width: (int) + :param sbar_frame_color: Scrollbar Color of frame around scrollbar (available only on some ttk themes) + :type sbar_frame_color: (str) + :param sbar_relief: Scrollbar relief that will be used for the "thumb" of the scrollbar (the thing you grab that slides). Should be a constant that is defined at starting with "RELIEF_" - RELIEF_RAISED, RELIEF_SUNKEN, RELIEF_FLAT, RELIEF_RIDGE, RELIEF_GROOVE, RELIEF_SOLID + :type sbar_relief: (str) + """ + + if size is not None and size != (None, None): + if isinstance(size, int): + size = (size, 1) + if isinstance(size, tuple) and len(size) == 1: + size = (size[0], 1) + + if pad is not None and pad != (None, None): + if isinstance(pad, int): + pad = (pad, pad) + + + self.Size = size + self.Type = type + self.AutoSizeText = auto_size_text + + self.Pad = pad + self.Font = font + + self.TKStringVar = None + self.TKIntVar = None + self.TKText = None + self.TKEntry = None + self.TKImage = None + self.ttk_style_name = '' # The ttk style name (if this is a ttk widget) + self.ttk_style = None # The ttk Style object (if this is a ttk widget) + self._metadata = None # type: Any + + self.ParentForm = None # type: Window + self.ParentContainer = None # will be a Form, Column, or Frame element # UNBIND + self.TextInputDefault = None + self.Position = (0, 0) # Default position Row 0, Col 0 + self.BackgroundColor = background_color if background_color is not None else DEFAULT_ELEMENT_BACKGROUND_COLOR + self.TextColor = text_color if text_color is not None else DEFAULT_ELEMENT_TEXT_COLOR + self.Key = key # dictionary key for return values + self.Tooltip = tooltip + self.TooltipObject = None + self._visible = visible + self.TKRightClickMenu = None + self.Widget = None # Set when creating window. Has the main tkinter widget for element + self.Tearoff = False # needed because of right click menu code + self.ParentRowFrame = None # type tk.Frame + self.metadata = metadata + self.user_bind_dict = {} # Used when user defines a tkinter binding using bind method - convert bind string to key modifier + self.user_bind_event = None # Used when user defines a tkinter binding using bind method - event data from tkinter + # self.pad_used = (0, 0) # the amount of pad used when was inserted into the layout + self._popup_menu_location = (None, None) + self.pack_settings = None + self.vsb_style_name = None # ttk style name used for the verical scrollbar if one is attached to element + self.hsb_style_name = None # ttk style name used for the horizontal scrollbar if one is attached to element + self.vsb_style = None # The ttk style used for the vertical scrollbar if one is attached to element + self.hsb_style = None # The ttk style used for the horizontal scrollbar if one is attached to element + self.hsb = None # The horizontal scrollbar if one is attached to element + self.vsb = None # The vertical scrollbar if one is attached to element + ## TTK Scrollbar Settings + self.ttk_part_overrides = TTKPartOverrides(sbar_trough_color=sbar_trough_color, sbar_background_color=sbar_background_color, sbar_arrow_color=sbar_arrow_color, sbar_width=sbar_width, sbar_arrow_width=sbar_arrow_width, sbar_frame_color=sbar_frame_color, sbar_relief=sbar_relief) + + PSG_THEME_PART_FUNC_MAP = {PSG_THEME_PART_BACKGROUND: theme_background_color, + PSG_THEME_PART_BUTTON_BACKGROUND: theme_button_color_background, + PSG_THEME_PART_BUTTON_TEXT: theme_button_color_text, + PSG_THEME_PART_INPUT_BACKGROUND: theme_input_background_color, + PSG_THEME_PART_INPUT_TEXT: theme_input_text_color, + PSG_THEME_PART_TEXT: theme_text_color, + PSG_THEME_PART_SLIDER: theme_slider_color} + + # class Theme_Parts(): + # PSG_THEME_PART_FUNC_MAP = {PSG_THEME_PART_BACKGROUND: theme_background_color, + if sbar_trough_color is not None: + self.scroll_trough_color = sbar_trough_color + else: + self.scroll_trough_color = PSG_THEME_PART_FUNC_MAP.get(ttk_part_mapping_dict[TTK_SCROLLBAR_PART_TROUGH_COLOR], ttk_part_mapping_dict[TTK_SCROLLBAR_PART_TROUGH_COLOR]) + if callable(self.scroll_trough_color): + self.scroll_trough_color = self.scroll_trough_color() + + + if sbar_background_color is not None: + self.scroll_background_color = sbar_background_color + else: + self.scroll_background_color = PSG_THEME_PART_FUNC_MAP.get(ttk_part_mapping_dict[TTK_SCROLLBAR_PART_BACKGROUND_COLOR], ttk_part_mapping_dict[TTK_SCROLLBAR_PART_BACKGROUND_COLOR]) + if callable(self.scroll_background_color): + self.scroll_background_color = self.scroll_background_color() + + + if sbar_arrow_color is not None: + self.scroll_arrow_color = sbar_arrow_color + else: + self.scroll_arrow_color = PSG_THEME_PART_FUNC_MAP.get(ttk_part_mapping_dict[TTK_SCROLLBAR_PART_ARROW_BUTTON_ARROW_COLOR], ttk_part_mapping_dict[TTK_SCROLLBAR_PART_ARROW_BUTTON_ARROW_COLOR]) + if callable(self.scroll_arrow_color): + self.scroll_arrow_color = self.scroll_arrow_color() + + + if sbar_frame_color is not None: + self.scroll_frame_color = sbar_frame_color + else: + self.scroll_frame_color = PSG_THEME_PART_FUNC_MAP.get(ttk_part_mapping_dict[TTK_SCROLLBAR_PART_FRAME_COLOR], ttk_part_mapping_dict[TTK_SCROLLBAR_PART_FRAME_COLOR]) + if callable(self.scroll_frame_color): + self.scroll_frame_color = self.scroll_frame_color() + + if sbar_relief is not None: + self.scroll_relief = sbar_relief + else: + self.scroll_relief = ttk_part_mapping_dict[TTK_SCROLLBAR_PART_RELIEF] + + if sbar_width is not None: + self.scroll_width = sbar_width + else: + self.scroll_width = ttk_part_mapping_dict[TTK_SCROLLBAR_PART_SCROLL_WIDTH] + + if sbar_arrow_width is not None: + self.scroll_arrow_width = sbar_arrow_width + else: + self.scroll_arrow_width = ttk_part_mapping_dict[TTK_SCROLLBAR_PART_ARROW_WIDTH] + + + if not hasattr(self, 'DisabledTextColor'): + self.DisabledTextColor = None + if not hasattr(self, 'ItemFont'): + self.ItemFont = None + if not hasattr(self, 'RightClickMenu'): + self.RightClickMenu = None + if not hasattr(self, 'Disabled'): + self.Disabled = None # in case the element hasn't defined this, add it here + + @property + def visible(self): + """ + Returns visibility state for the element. This is a READONLY property + :return: Visibility state for element + :rtype: (bool) + """ + return self._visible + + @property + def metadata(self): + """ + Metadata is an Element property that you can use at any time to hold any value + :return: the current metadata value + :rtype: (Any) + """ + return self._metadata + + @metadata.setter + def metadata(self, value): + """ + Metadata is an Element property that you can use at any time to hold any value + :param value: Anything you want it to be + :type value: (Any) + """ + self._metadata = value + + @property + def key(self): + """ + Returns key for the element. This is a READONLY property. + Keys can be any hashable object (basically anything except a list... tuples are ok, but not lists) + :return: The window's Key + :rtype: (Any) + """ + return self.Key + + + + @property + def widget(self): + """ + Returns tkinter widget for the element. This is a READONLY property. + The implementation is that the Widget member variable is returned. This is a backward compatible addition + :return: The element's underlying tkinter widget + :rtype: (tkinter.Widget) + """ + return self.Widget + + + + + def _RightClickMenuCallback(self, event): + """ + Callback function that's called when a right click happens. Shows right click menu as result + + :param event: information provided by tkinter about the event including x,y location of click + :type event: + + """ + if self.Type == ELEM_TYPE_TAB_GROUP: + try: + index = self.Widget.index('@{},{}'.format(event.x,event.y)) + tab = self.Widget.tab(index, 'text') + key = self.find_key_from_tab_name(tab) + tab_element = self.ParentForm.key_dict[key] + if tab_element.RightClickMenu is None: # if this tab didn't explicitly have a menu, then don't show anything + return + tab_element.TKRightClickMenu.tk_popup(event.x_root, event.y_root, 0) + self.TKRightClickMenu.grab_release() + except: + pass + return + self.TKRightClickMenu.tk_popup(event.x_root, event.y_root, 0) + self.TKRightClickMenu.grab_release() + if self.Type == ELEM_TYPE_GRAPH: + self._update_position_for_returned_values(event) + + def _tearoff_menu_callback(self, parent, menu): + """ + Callback function that's called when a right click menu is torn off. + The reason for this function is to relocate the torn-off menu. It will default to 0,0 otherwise + This callback moves the right click menu window to the location of the current window + + :param parent: information provided by tkinter - the parent of the Meny + :type parent: + :param menu: information provided by tkinter - the menu window + :type menu: + + """ + if self._popup_menu_location == (None, None): + winx, winy = self.ParentForm.current_location() + else: + winx, winy = self._popup_menu_location + # self.ParentForm.TKroot.update() + self.ParentForm.TKroot.tk.call('wm', 'geometry', menu, "+{}+{}".format(winx, winy)) + + def _MenuItemChosenCallback(self, item_chosen): # TEXT Menu item callback + """ + Callback function called when user chooses a menu item from menubar, Button Menu or right click menu + + :param item_chosen: String holding the value chosen. + :type item_chosen: str + + """ + # print('IN MENU ITEM CALLBACK', item_chosen) + self.MenuItemChosen = item_chosen + self.ParentForm.LastButtonClicked = self.MenuItemChosen + self.ParentForm.FormRemainedOpen = True + _exit_mainloop(self.ParentForm) + # Window._window_that_exited = self.ParentForm + # self.ParentForm.TKroot.quit() # kick the users out of the mainloop + + def _FindReturnKeyBoundButton(self, form): + """ + Searches for which Button has the flag Button.BindReturnKey set. It is called recursively when a + "Container Element" is encountered. Func has to walk entire window including these "sub-forms" + + :param form: the Window object to search + :type form: + :return: Button Object if a button is found, else None + :rtype: Button | None + """ + for row in form.Rows: + for element in row: + if element.Type == ELEM_TYPE_BUTTON: + if element.BindReturnKey: + return element + if element.Type == ELEM_TYPE_COLUMN: + rc = self._FindReturnKeyBoundButton(element) + if rc is not None: + return rc + if element.Type == ELEM_TYPE_FRAME: + rc = self._FindReturnKeyBoundButton(element) + if rc is not None: + return rc + if element.Type == ELEM_TYPE_TAB_GROUP: + rc = self._FindReturnKeyBoundButton(element) + if rc is not None: + return rc + if element.Type == ELEM_TYPE_TAB: + rc = self._FindReturnKeyBoundButton(element) + if rc is not None: + return rc + if element.Type == ELEM_TYPE_PANE: + rc = self._FindReturnKeyBoundButton(element) + if rc is not None: + return rc + return None + + def _TextClickedHandler(self, event): + """ + Callback that's called when a text element is clicked on with events enabled on the Text Element. + Result is that control is returned back to user (quits mainloop). + + :param event: + :type event: + + """ + # If this is a minimize button for a custom titlebar, then minimize the window + if self.Key in (TITLEBAR_MINIMIZE_KEY, TITLEBAR_MAXIMIZE_KEY, TITLEBAR_CLOSE_KEY): + self.ParentForm._custom_titlebar_callback(self.Key) + self._generic_callback_handler(self.DisplayText) + return + + + def _ReturnKeyHandler(self, event): + """ + Internal callback for the ENTER / RETURN key. Results in calling the ButtonCallBack for element that has the return key bound to it, just as if button was clicked. + + :param event: + :type event: + + """ + # if the element is disabled, ignore the event + if self.Disabled: + return + + MyForm = self.ParentForm + button_element = self._FindReturnKeyBoundButton(MyForm) + if button_element is not None: + # if the Button has been disabled, then don't perform the callback + if button_element.Disabled: + return + button_element.ButtonCallBack() + + def _generic_callback_handler(self, alternative_to_key=None, force_key_to_be=None): + """ + Peforms the actions that were in many of the callback functions previously. Combined so that it's + easier to modify and is in 1 place now + + :param event: From tkinter and is not used + :type event: Any + :param alternate_to_key: If key is None, then use this value instead + :type alternate_to_key: Any + """ + if force_key_to_be is not None: + self.ParentForm.LastButtonClicked = force_key_to_be + elif self.Key is not None: + self.ParentForm.LastButtonClicked = self.Key + else: + self.ParentForm.LastButtonClicked = alternative_to_key + self.ParentForm.FormRemainedOpen = True + + _exit_mainloop(self.ParentForm) + # if self.ParentForm.CurrentlyRunningMainloop: + # Window._window_that_exited = self.ParentForm + # self.ParentForm.TKroot.quit() # kick the users out of the mainloop + + def _ListboxSelectHandler(self, event): + """ + Internal callback function for when a listbox item is selected + + :param event: Information from tkinter about the callback + :type event: + + """ + self._generic_callback_handler('') + + def _ComboboxSelectHandler(self, event): + """ + Internal callback function for when an entry is selected in a Combobox. + :param event: Event data from tkinter (not used) + :type event: + + """ + self._generic_callback_handler('') + + + def _SpinboxSelectHandler(self, event=None): + """ + Internal callback function for when an entry is selected in a Spinbox. + Note that the parm is optional because it's not used if arrows are used to change the value + but if the return key is pressed, it will include the event parm + :param event: Event data passed in by tkinter (not used) + :type event: + """ + self._generic_callback_handler('') + + def _RadioHandler(self): + """ + Internal callback for when a radio button is selected and enable events was set for radio + """ + self._generic_callback_handler('') + + def _CheckboxHandler(self): + """ + Internal callback for when a checkbnox is selected and enable events was set for checkbox + """ + self._generic_callback_handler('') + + def _TabGroupSelectHandler(self, event): + """ + Internal callback for when a Tab is selected and enable events was set for TabGroup + + :param event: Event data passed in by tkinter (not used) + :type event: + """ + self._generic_callback_handler('') + + def _KeyboardHandler(self, event): + """ + Internal callback for when a key is pressed andd return keyboard events was set for window + + :param event: Event data passed in by tkinter (not used) + :type event: + """ + + # if the element is disabled, ignore the event + if self.Disabled: + return + self._generic_callback_handler('') + + def _ClickHandler(self, event): + """ + Internal callback for when a mouse was clicked... I think. + + :param event: Event data passed in by tkinter (not used) + :type event: + """ + self._generic_callback_handler('') + + def _this_elements_window_closed(self, quick_check=True): + if self.ParentForm is not None: + return self.ParentForm.is_closed(quick_check=quick_check) + + return True + + def _user_bind_callback(self, bind_string, event, propagate=True): + """ + Used when user binds a tkinter event directly to an element + + :param bind_string: The event that was bound so can lookup the key modifier + :type bind_string: (str) + :param event: Event data passed in by tkinter (not used) + :type event: (Any) + :param propagate: If True then tkinter will be told to propagate the event to the element + :type propagate: (bool) + """ + key_suffix = self.user_bind_dict.get(bind_string, '') + self.user_bind_event = event + if self.Type == ELEM_TYPE_GRAPH: + self._update_position_for_returned_values(event) + if self.Key is not None: + if isinstance(self.Key, str): + key = self.Key + str(key_suffix) + else: + key = (self.Key, key_suffix) # old way (pre 2021) was to make a brand new tuple + # key = self.Key + (key_suffix,) # in 2021 tried this. It will break existing applications though - if key is a tuple, add one more item + else: + key = bind_string + + self._generic_callback_handler(force_key_to_be=key) + + return 'break' if propagate is not True else None + + + def bind(self, bind_string, key_modifier, propagate=True): + """ + Used to add tkinter events to an Element. + The tkinter specific data is in the Element's member variable user_bind_event + :param bind_string: The string tkinter expected in its bind function + :type bind_string: (str) + :param key_modifier: Additional data to be added to the element's key when event is returned + :type key_modifier: (str) + :param propagate: If True then tkinter will be told to propagate the event to the element + :type propagate: (bool) + """ + if not self._widget_was_created(): # if widget hasn't been created yet, then don't allow + return + + try: + self.Widget.bind(bind_string, lambda evt: self._user_bind_callback(bind_string, evt, propagate)) + except Exception as e: + self.Widget.unbind_all(bind_string) + return + + self.user_bind_dict[bind_string] = key_modifier + + def unbind(self, bind_string): + """ + Removes a previously bound tkinter event from an Element. + :param bind_string: The string tkinter expected in its bind function + :type bind_string: (str) + """ + if not self._widget_was_created(): # if widget hasn't been created yet, then don't allow + return + self.Widget.unbind(bind_string) + self.user_bind_dict.pop(bind_string, None) + + def set_tooltip(self, tooltip_text): + """ + Called by application to change the tooltip text for an Element. Normally invoked using the Element Object such as: window.Element('key').SetToolTip('New tip'). + + :param tooltip_text: the text to show in tooltip. + :type tooltip_text: (str) + """ + + if self.TooltipObject: + try: + self.TooltipObject.leave() + except: + pass + + self.TooltipObject = ToolTip(self.Widget, text=tooltip_text, timeout=DEFAULT_TOOLTIP_TIME) + + def set_focus(self, force=False): + """ + Sets the current focus to be on this element + + :param force: if True will call focus_force otherwise calls focus_set + :type force: bool + """ + if not self._widget_was_created(): # if widget hasn't been created yet, then don't allow + return + try: + if force: + self.Widget.focus_force() + else: + self.Widget.focus_set() + except Exception as e: + _error_popup_with_traceback("Exception blocking focus. Check your element's Widget", e) + + + def block_focus(self, block=True): + """ + Enable or disable the element from getting focus by using the keyboard. + If the block parameter is True, then this element will not be given focus by using + the keyboard to go from one element to another. + You CAN click on the element and utilize it. + + :param block: if True the element will not get focus via the keyboard + :type block: bool + """ + if not self._widget_was_created(): # if widget hasn't been created yet, then don't allow + return + try: + self.ParentForm.TKroot.focus_force() + if block: + self.Widget.configure(takefocus=0) + else: + self.Widget.configure(takefocus=1) + except Exception as e: + _error_popup_with_traceback("Exception blocking focus. Check your element's Widget", e) + + + def get_next_focus(self): + """ + Gets the next element that should get focus after this element. + + :return: Element that will get focus after this one + :rtype: (Element) + """ + if not self._widget_was_created(): # if widget hasn't been created yet, then don't allow + return None + + try: + next_widget_focus = self.widget.tk_focusNext() + return self.ParentForm.widget_to_element(next_widget_focus) + except Exception as e: + _error_popup_with_traceback("Exception getting next focus. Check your element's Widget", e) + + + def get_previous_focus(self): + """ + Gets the element that should get focus previous to this element. + + :return: Element that should get the focus before this one + :rtype: (Element) + """ + if not self._widget_was_created(): # if widget hasn't been created yet, then don't allow + return None + try: + next_widget_focus = self.widget.tk_focusPrev() # tkinter.Widget + return self.ParentForm.widget_to_element(next_widget_focus) + except Exception as e: + _error_popup_with_traceback("Exception getting previous focus. Check your element's Widget", e) + + + def set_size(self, size=(None, None)): + """ + Changes the size of an element to a specific size. + It's possible to specify None for one of sizes so that only 1 of the element's dimensions are changed. + + :param size: The size in characters, rows typically. In some cases they are pixels + :type size: (int, int) + """ + try: + if size[0] != None: + self.Widget.config(width=size[0]) + except: + print('Warning, error setting width on element with key=', self.Key) + try: + if size[1] != None: + self.Widget.config(height=size[1]) + except: + try: + self.Widget.config(length=size[1]) + except: + print('Warning, error setting height on element with key=', self.Key) + + if self.Type == ELEM_TYPE_GRAPH: + self.CanvasSize = size + + + def get_size(self): + """ + Return the size of an element in Pixels. Care must be taken as some elements use characters to specify their size but will return pixels when calling this get_size method. + :return: width and height of the element + :rtype: (int, int) + """ + try: + w = self.Widget.winfo_width() + h = self.Widget.winfo_height() + except: + print('Warning, error getting size of element', self.Key) + w = h = None + return w, h + + def hide_row(self): + """ + Hide the entire row an Element is located on. + Use this if you must have all space removed when you are hiding an element, including the row container + """ + try: + self.ParentRowFrame.pack_forget() + except: + print('Warning, error hiding element row for key =', self.Key) + + def unhide_row(self): + """ + Unhides (makes visible again) the row container that the Element is located on. + Note that it will re-appear at the bottom of the window / container, most likely. + """ + try: + self.ParentRowFrame.pack() + except: + print('Warning, error hiding element row for key =', self.Key) + + def expand(self, expand_x=False, expand_y=False, expand_row=True): + """ + Causes the Element to expand to fill available space in the X and Y directions. Can specify which or both directions + + :param expand_x: If True Element will expand in the Horizontal directions + :type expand_x: (bool) + :param expand_y: If True Element will expand in the Vertical directions + :type expand_y: (bool) + :param expand_row: If True the row containing the element will also expand. Without this your element is "trapped" within the row + :type expand_row: (bool) + """ + if expand_x and expand_y: + fill = tk.BOTH + elif expand_x: + fill = tk.X + elif expand_y: + fill = tk.Y + else: + return + + if not self._widget_was_created(): + return + self.Widget.pack(expand=True, fill=fill) + self.ParentRowFrame.pack(expand=expand_row, fill=fill) + if self.element_frame is not None: + self.element_frame.pack(expand=True, fill=fill) + + def set_cursor(self, cursor=None, cursor_color=None): + """ + Sets the cursor for the current Element. + "Cursor" is used in 2 different ways in this call. + For the parameter "cursor" it's actually the mouse pointer. + If you do not want any mouse pointer, then use the string "none" + For the parameter "cursor_color" it's the color of the beam used when typing into an input element + + :param cursor: The tkinter cursor name + :type cursor: (str) + :param cursor_color: color to set the "cursor" to + :type cursor_color: (str) + """ + if not self._widget_was_created(): + return + if cursor is not None: + try: + self.Widget.config(cursor=cursor) + except Exception as e: + print('Warning bad cursor specified ', cursor) + print(e) + if cursor_color is not None: + try: + self.Widget.config(insertbackground=cursor_color) + except Exception as e: + print('Warning bad cursor color', cursor_color) + print(e) + + def set_vscroll_position(self, percent_from_top): + """ + Attempts to set the vertical scroll postition for an element's Widget + :param percent_from_top: From 0 to 1.0, the percentage from the top to move scrollbar to + :type percent_from_top: (float) + """ + if self.Type == ELEM_TYPE_COLUMN and self.Scrollable: + widget = self.widget.canvas # scrollable column is a special case + else: + widget = self.widget + + try: + widget.yview_moveto(percent_from_top) + except Exception as e: + print('Warning setting the vertical scroll (yview_moveto failed)') + print(e) + + def _widget_was_created(self): + """ + Determines if a Widget was created for this element. + + :return: True if a Widget has been created previously (Widget is not None) + :rtype: (bool) + """ + if self.Widget is not None: + return True + else: + if SUPPRESS_WIDGET_NOT_FINALIZED_WARNINGS: + return False + + warnings.warn('You cannot Update element with key = {} until the window.read() is called or set finalize=True when creating window'.format(self.Key), UserWarning) + if not SUPPRESS_ERROR_POPUPS: + _error_popup_with_traceback('Unable to complete operation on element with key {}'.format(self.Key), + 'You cannot perform operations (such as calling update) on an Element until:', + ' window.read() is called or finalize=True when Window created.', + 'Adding a "finalize=True" parameter to your Window creation will likely fix this.', + _create_error_message(), + ) + return False + + + def _grab_anywhere_on_using_control_key(self): + """ + Turns on Grab Anywhere functionality AFTER a window has been created. Don't try on a window that's not yet + been Finalized or Read. + """ + self.Widget.bind("", self.ParentForm._StartMove) + self.Widget.bind("", self.ParentForm._StopMove) + self.Widget.bind("", self.ParentForm._OnMotion) + + + def _grab_anywhere_on(self): + """ + Turns on Grab Anywhere functionality AFTER a window has been created. Don't try on a window that's not yet + been Finalized or Read. + """ + self.Widget.bind("", self.ParentForm._StartMove) + self.Widget.bind("", self.ParentForm._StopMove) + self.Widget.bind("", self.ParentForm._OnMotion) + + def _grab_anywhere_off(self): + """ + Turns off Grab Anywhere functionality AFTER a window has been created. Don't try on a window that's not yet + been Finalized or Read. + """ + self.Widget.unbind("") + self.Widget.unbind("") + self.Widget.unbind("") + + def grab_anywhere_exclude(self): + """ + Excludes this element from being used by the grab_anywhere feature + Handy for elements like a Graph element when dragging is enabled. You want the Graph element to get the drag events instead of the window dragging. + """ + self.ParentForm._grab_anywhere_ignore_these_list.append(self.Widget) + + def grab_anywhere_include(self): + """ + Includes this element in the grab_anywhere feature + This will allow you to make a Multline element drag a window for example + """ + self.ParentForm._grab_anywhere_include_these_list.append(self.Widget) + + + + def set_right_click_menu(self, menu=None): + """ + Sets a right click menu for an element. + If a menu is already set for the element, it will call the tkinter destroy method to remove it + :param menu: A list of lists of Menu items to show when this element is right clicked. See user docs for exact format. + :type menu: List[List[ List[str] | str ]] + """ + if menu == MENU_RIGHT_CLICK_DISABLED: + return + if menu is None: + menu = self.ParentForm.RightClickMenu + if menu is None: + return + if menu: + # If previously had a menu destroy it + if self.TKRightClickMenu: + try: + self.TKRightClickMenu.destroy() + except: + pass + top_menu = tk.Menu(self.ParentForm.TKroot, tearoff=self.ParentForm.right_click_menu_tearoff, tearoffcommand=self._tearoff_menu_callback) + + if self.ParentForm.right_click_menu_background_color not in (COLOR_SYSTEM_DEFAULT, None): + top_menu.config(bg=self.ParentForm.right_click_menu_background_color) + if self.ParentForm.right_click_menu_text_color not in (COLOR_SYSTEM_DEFAULT, None): + top_menu.config(fg=self.ParentForm.right_click_menu_text_color) + if self.ParentForm.right_click_menu_disabled_text_color not in (COLOR_SYSTEM_DEFAULT, None): + top_menu.config(disabledforeground=self.ParentForm.right_click_menu_disabled_text_color) + if self.ParentForm.right_click_menu_font is not None: + top_menu.config(font=self.ParentForm.right_click_menu_font) + + if self.ParentForm.right_click_menu_selected_colors[0] not in (COLOR_SYSTEM_DEFAULT, None): + top_menu.config(activeforeground=self.ParentForm.right_click_menu_selected_colors[0]) + if self.ParentForm.right_click_menu_selected_colors[1] not in (COLOR_SYSTEM_DEFAULT, None): + top_menu.config(activebackground=self.ParentForm.right_click_menu_selected_colors[1]) + AddMenuItem(top_menu, menu[1], self, right_click_menu=True) + self.TKRightClickMenu = top_menu + if self.ParentForm.RightClickMenu: # if the top level has a right click menu, then setup a callback for the Window itself + if self.ParentForm.TKRightClickMenu is None: + self.ParentForm.TKRightClickMenu = top_menu + if (running_mac()): + self.ParentForm.TKroot.bind('', self.ParentForm._RightClickMenuCallback) + else: + self.ParentForm.TKroot.bind('', self.ParentForm._RightClickMenuCallback) + if (running_mac()): + self.Widget.bind('', self._RightClickMenuCallback) + else: + self.Widget.bind('', self._RightClickMenuCallback) + + + def save_element_screenshot_to_disk(self, filename=None): + """ + Saves an image of the PySimpleGUI window provided into the filename provided + + :param filename: Optional filename to save screenshot to. If not included, the User Settinds are used to get the filename + :return: A PIL ImageGrab object that can be saved or manipulated + :rtype: (PIL.ImageGrab | None) + """ + global pil_import_attempted, pil_imported, PIL, ImageGrab, Image + + if not pil_import_attempted: + try: + import PIL as PIL + from PIL import ImageGrab + from PIL import Image + pil_imported = True + pil_import_attempted = True + except: + pil_imported = False + pil_import_attempted = True + print('FAILED TO IMPORT PIL!') + return None + try: + # Add a little to the X direction if window has a titlebar + rect = (self.widget.winfo_rootx(), self.widget.winfo_rooty(), self.widget.winfo_rootx() + self.widget.winfo_width(), self.widget.winfo_rooty() + self.widget.winfo_height()) + + grab = ImageGrab.grab(bbox=rect) + # Save the grabbed image to disk + except Exception as e: + # print(e) + popup_error_with_traceback('Screen capture failure', 'Error happened while trying to save screencapture of an element', e) + return None + + # return grab + if filename is None: + folder = pysimplegui_user_settings.get('-screenshots folder-', '') + filename = pysimplegui_user_settings.get('-screenshots filename-', '') + full_filename = os.path.join(folder, filename) + else: + full_filename = filename + if full_filename: + try: + grab.save(full_filename) + except Exception as e: + popup_error_with_traceback('Screen capture failure', 'Error happened while trying to save screencapture', e) + else: + popup_error_with_traceback('Screen capture failure', 'You have attempted a screen capture but have not set up a good filename to save to') + return grab + + + + + def _pack_forget_save_settings(self, alternate_widget=None): + """ + Performs a pack_forget which will make a widget invisible. + This method saves the pack settings so that they can be restored if the element is made visible again + + :param alternate_widget: Widget to use that's different than the one defined in Element.Widget. These are usually Frame widgets + :type alternate_widget: (tk.Widget) + """ + + if alternate_widget is not None and self.Widget is None: + return + + widget = alternate_widget if alternate_widget is not None else self.Widget + # if the widget is already invisible (i.e. not packed) then will get an error + try: + pack_settings = widget.pack_info() + self.pack_settings = pack_settings + widget.pack_forget() + except: + pass + + def _pack_restore_settings(self, alternate_widget=None): + """ + Restores a previously packated widget which will make it visible again. + If no settings were saved, then the widget is assumed to have not been unpacked and will not try to pack it again + + :param alternate_widget: Widget to use that's different than the one defined in Element.Widget. These are usually Frame widgets + :type alternate_widget: (tk.Widget) + """ + + # if there are no saved pack settings, then assume it hasnb't been packaed before. The request will be ignored + if self.pack_settings is None: + return + + widget = alternate_widget if alternate_widget is not None else self.Widget + if widget is not None: + widget.pack(**self.pack_settings) + + + def update(self, *args, **kwargs): + """ + A dummy update call. This will only be called if an element hasn't implemented an update method + It is provided here for docstring purposes. If you got here by browing code via PyCharm, know + that this is not the function that will be called. Your actual element's update method will be called. + + If you call update, you must call window.refresh if you want the change to happen prior to your next + window.read() call. Normally uou don't do this as the window.read call is likely going to happen next. + """ + print('* Base Element Class update was called. Your element does not seem to have an update method') + + + def __call__(self, *args, **kwargs): + """ + Makes it possible to "call" an already existing element. When you do make the "call", it actually calls + the Update method for the element. + Example: If this text element was in yoiur layout: + sg.Text('foo', key='T') + Then you can call the Update method for that element by writing: + window.find_element('T')('new text value') + """ + return self.update(*args, **kwargs) + + + + + + + SetTooltip = set_tooltip + SetFocus = set_focus + + +# ---------------------------------------------------------------------- # +# Input Class # +# ---------------------------------------------------------------------- # +class Input(Element): + """ + Display a single text input field. Based on the tkinter Widget `Entry` + """ + + def __init__(self, default_text='', size=(None, None), s=(None, None), disabled=False, password_char='', + justification=None, background_color=None, text_color=None, font=None, tooltip=None, border_width=None, + change_submits=False, enable_events=False, do_not_clear=True, key=None, k=None, focus=False, pad=None, p=None, + use_readonly_for_disable=True, readonly=False, disabled_readonly_background_color=None, disabled_readonly_text_color=None, selected_text_color=None, selected_background_color=None, expand_x=False, expand_y=False, + right_click_menu=None, visible=True, metadata=None): + """ + :param default_text: Text initially shown in the input box as a default value(Default value = ''). Will automatically be converted to string + :type default_text: (Any) + :param size: w=characters-wide, h=rows-high. If an int is supplied rather than a tuple, then a tuple is created width=int supplied and heigh=1 + :type size: (int, int) | (int, None) | int + :param s: Same as size parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, size will be used + :type s: (int, int) | (None, None) | int + :param disabled: set disable state for element (Default = False) + :type disabled: (bool) + :param password_char: Password character if this is a password field (Default value = '') + :type password_char: (char) + :param justification: justification for data display. Valid choices - left, right, center + :type justification: (str) + :param background_color: color of background in one of the color formats + :type background_color: (str) + :param text_color: color of the text + :type text_color: (str) + :param font: specifies the font family, size. Tuple or Single string format 'name size styles'. Styles: italic * roman bold normal underline overstrike + :type font: (str or (str, int[, str]) or None) + :param tooltip: text, that will appear when mouse hovers over the element + :type tooltip: (str) + :param border_width: width of border around element in pixels + :type border_width: (int) + :param change_submits: * DEPRICATED DO NOT USE. Use `enable_events` instead + :type change_submits: (bool) + :param enable_events: If True then changes to this element are immediately reported as an event. Use this instead of change_submits (Default = False) + :type enable_events: (bool) + :param do_not_clear: If False then the field will be set to blank after ANY event (button, any event) (Default = True) + :type do_not_clear: (bool) + :param key: Value that uniquely identifies this element from all other elements. Used when Finding an element or in return values. Must be unique to the window + :type key: str | int | tuple | object + :param k: Same as the Key. You can use either k or key. Which ever is set will be used. + :type k: str | int | tuple | object + :param focus: Determines if initial focus should go to this element. + :type focus: (bool) + :param pad: Amount of padding to put around element. Normally (horizontal pixels, vertical pixels) but can be split apart further into ((horizontal left, horizontal right), (vertical above, vertical below)). If int is given, then converted to tuple (int, int) with the value provided duplicated + :type pad: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param p: Same as pad parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, pad will be used + :type p: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param use_readonly_for_disable: If True (the default) tkinter state set to 'readonly'. Otherwise state set to 'disabled' + :type use_readonly_for_disable: (bool) + :param readonly: If True tkinter state set to 'readonly'. Use this in place of use_readonly_for_disable as another way of achieving readonly. Note cannot set BOTH readonly and disabled as tkinter only supplies a single flag + :type readonly: (bool) + :param disabled_readonly_background_color: If state is set to readonly or disabled, the color to use for the background + :type disabled_readonly_background_color: (str) + :param disabled_readonly_text_color: If state is set to readonly or disabled, the color to use for the text + :type disabled_readonly_text_color: (str) + :param selected_text_color: Color of text when it is selected (using mouse or control+A, etc) + :type selected_text_color: (str) + :param selected_background_color: Color of background when it is selected (using mouse or control+A, etc) + :type selected_background_color: (str) + :param expand_x: If True the element will automatically expand in the X direction to fill available space + :type expand_x: (bool) + :param expand_y: If True the element will automatically expand in the Y direction to fill available space + :type expand_y: (bool) + :param right_click_menu: A list of lists of Menu items to show when this element is right clicked. See user docs for exact format. + :type right_click_menu: List[List[ List[str] | str ]] + :param visible: set visibility state of the element (Default = True) + :type visible: (bool) + :param metadata: User metadata that can be set to ANYTHING + :type metadata: (Any) + """ + + + self.DefaultText = default_text if default_text is not None else '' + self.PasswordCharacter = password_char + bg = background_color if background_color is not None else DEFAULT_INPUT_ELEMENTS_COLOR + fg = text_color if text_color is not None else DEFAULT_INPUT_TEXT_COLOR + self.selected_text_color = selected_text_color + self.selected_background_color = selected_background_color + self.Focus = focus + self.do_not_clear = do_not_clear + self.Justification = justification + self.Disabled = disabled + self.ChangeSubmits = change_submits or enable_events + self.RightClickMenu = right_click_menu + self.UseReadonlyForDisable = use_readonly_for_disable + self.disabled_readonly_background_color = disabled_readonly_background_color + self.disabled_readonly_text_color = disabled_readonly_text_color + self.ReadOnly = readonly + self.BorderWidth = border_width if border_width is not None else DEFAULT_BORDER_WIDTH + self.TKEntry = self.Widget = None # type: tk.Entry + key = key if key is not None else k + sz = size if size != (None, None) else s + pad = pad if pad is not None else p + self.expand_x = expand_x + self.expand_y = expand_y + + super().__init__(ELEM_TYPE_INPUT_TEXT, size=sz, background_color=bg, text_color=fg, key=key, pad=pad, + font=font, tooltip=tooltip, visible=visible, metadata=metadata) + + def update(self, value=None, disabled=None, select=None, visible=None, text_color=None, background_color=None, font=None, move_cursor_to='end', password_char=None, paste=None, readonly=None): + """ + Changes some of the settings for the Input Element. Must call `Window.Read` or `Window.Finalize` prior. + Changes will not be visible in your window until you call window.read or window.refresh. + + If you change visibility, your element may MOVE. If you want it to remain stationary, use the "layout helper" + function "pin" to ensure your element is "pinned" to that location in your layout so that it returns there + when made visible. + + :param value: new text to display as default text in Input field + :type value: (str) + :param disabled: disable or enable state of the element (sets Entry Widget to readonly or normal) + :type disabled: (bool) + :param select: if True, then the text will be selected + :type select: (bool) + :param visible: change visibility of element + :type visible: (bool) + :param text_color: change color of text being typed + :type text_color: (str) + :param background_color: change color of the background + :type background_color: (str) + :param font: specifies the font family, size. Tuple or Single string format 'name size styles'. Styles: italic * roman bold normal underline overstrike + :type font: (str or (str, int[, str]) or None) + :param move_cursor_to: Moves the cursor to a particular offset. Defaults to 'end' + :type move_cursor_to: int | str + :param password_char: Password character if this is a password field + :type password_char: str + :param paste: If True "Pastes" the value into the element rather than replacing the entire element. If anything is selected it is replaced. The text is inserted at the current cursor location. + :type paste: bool + :param readonly: if True make element readonly (user cannot change any choices). Enables the element if either choice are made. + :type readonly: (bool) + """ + if not self._widget_was_created(): # if widget hasn't been created yet, then don't allow + return + + if self._this_elements_window_closed(): + _error_popup_with_traceback('Error in Input.update - The window was closed') + return + + if background_color not in (None, COLOR_SYSTEM_DEFAULT): + self.TKEntry.configure(background=background_color) + self.BackgroundColor = background_color + if text_color not in (None, COLOR_SYSTEM_DEFAULT): + self.TKEntry.configure(fg=text_color) + self.TextColor = text_color + + if disabled is True: + if self.UseReadonlyForDisable: + if self.disabled_readonly_text_color not in (None, COLOR_SYSTEM_DEFAULT): + self.TKEntry.configure(fg=self.disabled_readonly_text_color) + self.TKEntry['state'] = 'readonly' + else: + if self.TextColor not in (None, COLOR_SYSTEM_DEFAULT): + self.TKEntry.configure(fg=self.TextColor) + self.TKEntry['state'] = 'disabled' + self.Disabled = True + elif disabled is False: + self.TKEntry['state'] = 'normal' + if self.TextColor not in (None, COLOR_SYSTEM_DEFAULT): + self.TKEntry.configure(fg=self.TextColor) + self.Disabled = False + + if readonly is True: + self.TKEntry['state'] = 'readonly' + elif readonly is False: + self.TKEntry['state'] = 'normal' + + + + + if value is not None: + if paste is not True: + try: + self.TKStringVar.set(value) + except: + pass + self.DefaultText = value + if paste is True: + try: + self.TKEntry.delete('sel.first', 'sel.last') + except: + pass + self.TKEntry.insert("insert", value) + if move_cursor_to == 'end': + self.TKEntry.icursor(tk.END) + elif move_cursor_to is not None: + self.TKEntry.icursor(move_cursor_to) + if select: + self.TKEntry.select_range(0, 'end') + if visible is False: + self._pack_forget_save_settings() + # self.TKEntry.pack_forget() + elif visible is True: + self._pack_restore_settings() + # self.TKEntry.pack(padx=self.pad_used[0], pady=self.pad_used[1]) + # self.TKEntry.pack(padx=self.pad_used[0], pady=self.pad_used[1], in_=self.ParentRowFrame) + if visible is not None: + self._visible = visible + if password_char is not None: + self.TKEntry.configure(show=password_char) + self.PasswordCharacter = password_char + if font is not None: + self.TKEntry.configure(font=font) + + + + def set_ibeam_color(self, ibeam_color=None): + """ + Sets the color of the I-Beam that is used to "insert" characters. This is oftens called a "Cursor" by + many users. To keep from being confused with tkinter's definition of cursor (the mouse pointer), the term + ibeam is used in this case. + :param ibeam_color: color to set the "I-Beam" used to indicate where characters will be inserted + :type ibeam_color: (str) + """ + + if not self._widget_was_created(): + return + if ibeam_color is not None: + try: + self.Widget.config(insertbackground=ibeam_color) + except Exception as e: + _error_popup_with_traceback('Error setting I-Beam color in set_ibeam_color', + 'The element has a key:', self.Key, + 'The color passed in was:', ibeam_color) + + + + + def get(self): + """ + Read and return the current value of the input element. Must call `Window.Read` or `Window.Finalize` prior + + :return: current value of Input field or '' if error encountered + :rtype: (str) + """ + try: + text = self.TKStringVar.get() + except: + text = '' + return text + + Get = get + Update = update + + +# ------------------------- INPUT TEXT Element lazy functions ------------------------- # +In = Input +InputText = Input +I = Input + + +# ---------------------------------------------------------------------- # +# Combo # +# ---------------------------------------------------------------------- # +class Combo(Element): + """ + ComboBox Element - A combination of a single-line input and a drop-down menu. User can type in their own value or choose from list. + """ + + def __init__(self, values, default_value=None, size=(None, None), s=(None, None), auto_size_text=None, background_color=None, text_color=None, button_background_color=None, button_arrow_color=None, bind_return_key=False, change_submits=False, enable_events=False, enable_per_char_events=None, disabled=False, key=None, k=None, pad=None, p=None, expand_x=False, expand_y=False, tooltip=None, readonly=False, font=None, visible=True, metadata=None): + """ + :param values: values to choose. While displayed as text, the items returned are what the caller supplied, not text + :type values: List[Any] or Tuple[Any] + :param default_value: Choice to be displayed as initial value. Must match one of values variable contents + :type default_value: (Any) + :param size: width, height. Width = characters-wide, height = NOTE it's the number of entries to show in the list. If an Int is passed rather than a tuple, then height is auto-set to 1 and width is value of the int + :type size: (int, int) | (None, None) | int + :param s: Same as size parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, size will be used + :type s: (int, int) | (None, None) | int + :param auto_size_text: True if element should be the same size as the contents + :type auto_size_text: (bool) + :param background_color: color of background + :type background_color: (str) + :param text_color: color of the text + :type text_color: (str) + :param button_background_color: The color of the background of the button on the combo box + :type button_background_color: (str) + :param button_arrow_color: The color of the arrow on the button on the combo box + :type button_arrow_color: (str) + :param bind_return_key: If True, then the return key will cause a the Combo to generate an event when return key is pressed + :type bind_return_key: (bool) + :param change_submits: DEPRICATED DO NOT USE. Use `enable_events` instead + :type change_submits: (bool) + :param enable_events: Turns on the element specific events. Combo event is when a choice is made + :type enable_events: (bool) + :param enable_per_char_events: Enables generation of events for every character that's input. This is like the Input element's events + :type enable_per_char_events: (bool) + :param disabled: set disable state for element + :type disabled: (bool) + :param key: Used with window.find_element and with return values to uniquely identify this element + :type key: str | int | tuple | object + :param k: Same as the Key. You can use either k or key. Which ever is set will be used. + :type k: str | int | tuple | object + :param pad: Amount of padding to put around element in pixels (left/right, top/bottom) or ((left, right), (top, bottom)) or an int. If an int, then it's converted into a tuple (int, int) + :type pad: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param p: Same as pad parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, pad will be used + :type p: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param expand_x: If True the element will automatically expand in the X direction to fill available space + :type expand_x: (bool) + :param expand_y: If True the element will automatically expand in the Y direction to fill available space + :type expand_y: (bool) + :param tooltip: text that will appear when mouse hovers over this element + :type tooltip: (str) + :param readonly: make element readonly (user can't change). True means user cannot change + :type readonly: (bool) + :param font: specifies the font family, size, etc. Tuple or Single string format 'name size styles'. Styles: italic * roman bold normal underline overstrike + :type font: (str or (str, int[, str]) or None) + :param visible: set visibility state of the element + :type visible: (bool) + :param metadata: User metadata that can be set to ANYTHING + :type metadata: (Any) + """ + + + self.Values = values + self.DefaultValue = default_value + self.ChangeSubmits = change_submits or enable_events + self.Widget = self.TKCombo = None # type: ttk.Combobox + self.Disabled = disabled + self.Readonly = readonly + self.BindReturnKey = bind_return_key + bg = background_color if background_color else DEFAULT_INPUT_ELEMENTS_COLOR + fg = text_color if text_color is not None else DEFAULT_INPUT_TEXT_COLOR + key = key if key is not None else k + sz = size if size != (None, None) else s + pad = pad if pad is not None else p + self.expand_x = expand_x + self.expand_y = expand_y + if button_background_color is None: + self.button_background_color = theme_button_color()[1] + else: + self.button_background_color = button_background_color + if button_arrow_color is None: + self.button_arrow_color = theme_button_color()[0] + else: + self.button_arrow_color = button_arrow_color + self.enable_per_char_events = enable_per_char_events + + super().__init__(ELEM_TYPE_INPUT_COMBO, size=sz, auto_size_text=auto_size_text, background_color=bg, + text_color=fg, key=key, pad=pad, tooltip=tooltip, font=font or DEFAULT_FONT, visible=visible, metadata=metadata) + + def update(self, value=None, values=None, set_to_index=None, disabled=None, readonly=None, font=None, visible=None, size=(None, None), select=None, text_color=None, background_color=None): + """ + Changes some of the settings for the Combo Element. Must call `Window.Read` or `Window.Finalize` prior. + Note that the state can be in 3 states only.... enabled, disabled, readonly even + though more combinations are available. The easy way to remember is that if you + change the readonly parameter then you are enabling the element. + + Changes will not be visible in your window until you call window.read or window.refresh. + + If you change visibility, your element may MOVE. If you want it to remain stationary, use the "layout helper" + function "pin" to ensure your element is "pinned" to that location in your layout so that it returns there + when made visible. + + :param value: change which value is current selected based on new list of previous list of choices + :type value: (Any) + :param values: change list of choices + :type values: List[Any] + :param set_to_index: change selection to a particular choice starting with index = 0 + :type set_to_index: (int) + :param disabled: disable or enable state of the element + :type disabled: (bool) + :param readonly: if True make element readonly (user cannot change any choices). Enables the element if either choice are made. + :type readonly: (bool) + :param font: specifies the font family, size, etc. Tuple or Single string format 'name size styles'. Styles: italic * roman bold normal underline overstrike + :type font: (str or (str, int[, str]) or None) + :param visible: control visibility of element + :type visible: (bool) + :param size: width, height. Width = characters-wide, height = NOTE it's the number of entries to show in the list + :type size: (int, int) + :param select: if True, then the text will be selected, if False then selection will be cleared + :type select: (bool) + :param background_color: color of background + :type background_color: (str) + :param text_color: color of the text + :type text_color: (str) + """ + + if size != (None, None): + if isinstance(size, int): + size = (size, 1) + if isinstance(size, tuple) and len(size) == 1: + size = (size[0], 1) + + if not self._widget_was_created(): # if widget hasn't been created yet, then don't allow + return + + if self._this_elements_window_closed(): + _error_popup_with_traceback('Error in Combo.update - The window was closed') + return + + + if values is not None: + try: + self.TKCombo['values'] = values + # self.TKCombo.current(0) # don't set any value if a new set of values was made + except: + pass + self.Values = values + if value is None: + self.TKCombo.set('') + if size == (None, None): + max_line_len = max([len(str(l)) for l in self.Values]) if len(self.Values) else 0 + if self.AutoSizeText is False: + width = self.Size[0] + else: + width = max_line_len + 1 + self.TKCombo.configure(width=width) + else: + self.TKCombo.configure(height=size[1]) + self.TKCombo.configure(width=size[0]) + if value is not None: + if value not in self.Values: + self.TKCombo.set(value) + else: + for index, v in enumerate(self.Values): + if v == value: + try: + self.TKCombo.current(index) + except: + pass + self.DefaultValue = value + break + if set_to_index is not None: + try: + self.TKCombo.current(set_to_index) + self.DefaultValue = self.Values[set_to_index] + except: + pass + if readonly: + self.Readonly = True + self.TKCombo['state'] = 'readonly' + elif readonly is False: + self.Readonly = False + self.TKCombo['state'] = 'enable' + if disabled is True: + self.TKCombo['state'] = 'disable' + elif disabled is False and self.Readonly is True: + self.TKCombo['state'] = 'readonly' + elif disabled is False and self.Readonly is False: + self.TKCombo['state'] = 'enable' + self.Disabled = disabled if disabled is not None else self.Disabled + + combostyle = self.ttk_style + style_name = self.ttk_style_name + if text_color is not None: + combostyle.configure(style_name, foreground=text_color) + combostyle.configure(style_name, selectforeground=text_color) + combostyle.configure(style_name, insertcolor=text_color) + combostyle.map(style_name, fieldforeground=[('readonly', text_color)]) + self.TextColor = text_color + if background_color is not None: + combostyle.configure(style_name, selectbackground=background_color) + combostyle.map(style_name, fieldbackground=[('readonly', background_color)]) + combostyle.configure(style_name, fieldbackground=background_color) + self.BackgroundColor = background_color + + if self.Readonly is True: + if text_color not in (None, COLOR_SYSTEM_DEFAULT): + combostyle.configure(style_name, selectforeground=text_color) + if background_color not in (None, COLOR_SYSTEM_DEFAULT): + combostyle.configure(style_name, selectbackground=background_color) + + + if font is not None: + self.Font = font + self.TKCombo.configure(font=font) + self._dropdown_newfont = tkinter.font.Font(font=font) + self.ParentRowFrame.option_add("*TCombobox*Listbox*Font", self._dropdown_newfont) + + + # make tcl call to deal with colors for the drop-down formatting + try: + if self.BackgroundColor not in (None, COLOR_SYSTEM_DEFAULT) and \ + self.TextColor not in (None, COLOR_SYSTEM_DEFAULT): + self.Widget.tk.eval( + '[ttk::combobox::PopdownWindow {}].f.l configure -foreground {} -background {} -selectforeground {} -selectbackground {} -font {}'.format(self.Widget, self.TextColor, self.BackgroundColor, self.BackgroundColor, self.TextColor, self._dropdown_newfont)) + except Exception as e: + pass # going to let this one slide + + if visible is False: + self._pack_forget_save_settings() + # self.TKCombo.pack_forget() + elif visible is True: + self._pack_restore_settings() + # self.TKCombo.pack(padx=self.pad_used[0], pady=self.pad_used[1]) + if visible is not None: + self._visible = visible + if select is True: + self.TKCombo.select_range(0, tk.END) + elif select is False: + self.TKCombo.select_clear() + + + def get(self): + """ + Returns the current (right now) value of the Combo. DO NOT USE THIS AS THE NORMAL WAY OF READING A COMBO! + You should be using values from your call to window.read instead. Know what you're doing if you use it. + + :return: Returns the value of what is currently chosen + :rtype: Any | None + """ + try: + if self.TKCombo.current() == -1: # if the current value was not in the original list + value = self.TKCombo.get() # then get the value typed in by user + else: + value = self.Values[self.TKCombo.current()] # get value from original list given index + except: + value = None # only would happen if user closes window + return value + + Get = get + Update = update + + +# ------------------------- INPUT COMBO Element lazy functions ------------------------- # +InputCombo = Combo +DropDown = InputCombo +Drop = InputCombo +DD = Combo + + +# ---------------------------------------------------------------------- # +# Option Menu # +# ---------------------------------------------------------------------- # +class OptionMenu(Element): + """ + Option Menu is an Element available ONLY on the tkinter port of PySimpleGUI. It's is a widget that is unique + to tkinter. However, it looks much like a ComboBox. Instead of an arrow to click to pull down the list of + choices, another little graphic is shown on the widget to indicate where you click. After clicking to activate, + it looks like a Combo Box that you scroll to select a choice. + """ + + def __init__(self, values, default_value=None, size=(None, None), s=(None, None), disabled=False, auto_size_text=None, expand_x=False, expand_y=False, + background_color=None, text_color=None, key=None, k=None, pad=None, p=None, tooltip=None, visible=True, metadata=None): + """ + :param values: Values to be displayed + :type values: List[Any] or Tuple[Any] + :param default_value: the value to choose by default + :type default_value: (Any) + :param size: (width, height) size in characters (wide), height is ignored and present to be consistent with other elements + :type size: (int, int) (width, UNUSED) + :param s: Same as size parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, size will be used + :type s: (int, int) | (None, None) | int + :param disabled: control enabled / disabled + :type disabled: (bool) + :param auto_size_text: True if size of Element should match the contents of the items + :type auto_size_text: (bool) + :param expand_x: If True the element will automatically expand in the X direction to fill available space + :type expand_x: (bool) + :param expand_y: If True the element will automatically expand in the Y direction to fill available space + :type expand_y: (bool) + :param background_color: color of background + :type background_color: (str) + :param text_color: color of the text + :type text_color: (str) + :param key: Used with window.find_element and with return values to uniquely identify this element + :type key: str | int | tuple | object + :param k: Same as the Key. You can use either k or key. Which ever is set will be used. + :type k: str | int | tuple | object + :param pad: Amount of padding to put around element in pixels (left/right, top/bottom) or ((left, right), (top, bottom)) or an int. If an int, then it's converted into a tuple (int, int) + :type pad: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param p: Same as pad parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, pad will be used + :type p: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param tooltip: text that will appear when mouse hovers over this element + :type tooltip: (str) + :param visible: set visibility state of the element + :type visible: (bool) + :param metadata: User metadata that can be set to ANYTHING + :type metadata: (Any) + """ + + self.Values = values + self.DefaultValue = default_value + self.Widget = self.TKOptionMenu = None # type: tk.OptionMenu + self.Disabled = disabled + bg = background_color if background_color else DEFAULT_INPUT_ELEMENTS_COLOR + fg = text_color if text_color is not None else DEFAULT_INPUT_TEXT_COLOR + key = key if key is not None else k + sz = size if size != (None, None) else s + pad = pad if pad is not None else p + self.expand_x = expand_x + self.expand_y = expand_y + + + super().__init__(ELEM_TYPE_INPUT_OPTION_MENU, size=sz, auto_size_text=auto_size_text, background_color=bg, + text_color=fg, key=key, pad=pad, tooltip=tooltip, visible=visible, metadata=metadata) + + def update(self, value=None, values=None, disabled=None, visible=None, size=(None, None)): + """ + Changes some of the settings for the OptionMenu Element. Must call `Window.Read` or `Window.Finalize` prior + + Changes will not be visible in your window until you call window.read or window.refresh. + + If you change visibility, your element may MOVE. If you want it to remain stationary, use the "layout helper" + function "pin" to ensure your element is "pinned" to that location in your layout so that it returns there + when made visible. + + :param value: the value to choose by default + :type value: (Any) + :param values: Values to be displayed + :type values: List[Any] + :param disabled: disable or enable state of the element + :type disabled: (bool) + :param visible: control visibility of element + :type visible: (bool) + :param size: (width, height) size in characters (wide), height is ignored and present to be consistent with other elements + :type size: (int, int) (width, UNUSED) + """ + if not self._widget_was_created(): # if widget hasn't been created yet, then don't allow + return + + if self._this_elements_window_closed(): + _error_popup_with_traceback('Error in OptionMenu.update - The window was closed') + return + + + if values is not None: + self.Values = values + self.TKOptionMenu['menu'].delete(0, 'end') + + # Insert list of new options (tk._setit hooks them up to var) + # self.TKStringVar.set(self.Values[0]) + for new_value in self.Values: + self.TKOptionMenu['menu'].add_command(label=new_value, command=tk._setit(self.TKStringVar, new_value)) + if value is None: + self.TKStringVar.set('') + + if size == (None, None): + max_line_len = max([len(str(l)) for l in self.Values]) if len(self.Values) else 0 + if self.AutoSizeText is False: + width = self.Size[0] + else: + width = max_line_len + 1 + self.TKOptionMenu.configure(width=width) + else: + self.TKOptionMenu.configure(width=size[0]) + + if value is not None: + self.DefaultValue = value + self.TKStringVar.set(value) + + if disabled is True: + self.TKOptionMenu['state'] = 'disabled' + elif disabled is False: + self.TKOptionMenu['state'] = 'normal' + self.Disabled = disabled if disabled is not None else self.Disabled + if visible is False: + self._pack_forget_save_settings() + # self.TKOptionMenu.pack_forget() + elif visible is True: + self._pack_restore_settings() + # self.TKOptionMenu.pack(padx=self.pad_used[0], pady=self.pad_used[1]) + if visible is not None: + self._visible = visible + + Update = update + + +# ------------------------- OPTION MENU Element lazy functions ------------------------- # +InputOptionMenu = OptionMenu + + +# ---------------------------------------------------------------------- # +# Listbox # +# ---------------------------------------------------------------------- # +class Listbox(Element): + """ + A List Box. Provide a list of values for the user to choose one or more of. Returns a list of selected rows + when a window.read() is executed. + """ + + def __init__(self, values, default_values=None, select_mode=None, change_submits=False, enable_events=False, + bind_return_key=False, size=(None, None), s=(None, None), disabled=False, justification=None, auto_size_text=None, font=None, no_scrollbar=False, horizontal_scroll=False, + background_color=None, text_color=None, highlight_background_color=None, highlight_text_color=None, + sbar_trough_color=None, sbar_background_color=None, sbar_arrow_color=None, sbar_width=None, sbar_arrow_width=None, sbar_frame_color=None, sbar_relief=None, + key=None, k=None, pad=None, p=None, tooltip=None, expand_x=False, expand_y=False,right_click_menu=None, visible=True, metadata=None): + """ + :param values: list of values to display. Can be any type including mixed types as long as they have __str__ method + :type values: List[Any] or Tuple[Any] + :param default_values: which values should be initially selected + :type default_values: List[Any] + :param select_mode: Select modes are used to determine if only 1 item can be selected or multiple and how they can be selected. Valid choices begin with "LISTBOX_SELECT_MODE_" and include: LISTBOX_SELECT_MODE_SINGLE LISTBOX_SELECT_MODE_MULTIPLE LISTBOX_SELECT_MODE_BROWSE LISTBOX_SELECT_MODE_EXTENDED + :type select_mode: [enum] + :param change_submits: DO NOT USE. Only listed for backwards compat - Use enable_events instead + :type change_submits: (bool) + :param enable_events: Turns on the element specific events. Listbox generates events when an item is clicked + :type enable_events: (bool) + :param bind_return_key: If True, then the return key will cause a the Listbox to generate an event when return key is pressed + :type bind_return_key: (bool) + :param size: w=characters-wide, h=rows-high. If an int instead of a tuple is supplied, then height is auto-set to 1 + :type size: (int, int) | (int, None) | int + :param s: Same as size parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, size will be used + :type s: (int, int) | (None, None) | int + :param disabled: set disable state for element + :type disabled: (bool) + :param justification: justification for items in listbox. Valid choices - left, right, center. Default is left. NOTE - on some older versions of tkinter, not available + :type justification: (str) + :param auto_size_text: True if element should be the same size as the contents + :type auto_size_text: (bool) + :param font: specifies the font family, size, etc. Tuple or Single string format 'name size styles'. Styles: italic * roman bold normal underline overstrike + :type font: (str or (str, int[, str]) or None) + :param no_scrollbar: Controls if a scrollbar should be shown. If True, no scrollbar will be shown + :type no_scrollbar: (bool) + :param horizontal_scroll: Controls if a horizontal scrollbar should be shown. If True a horizontal scrollbar will be shown in addition to vertical + :type horizontal_scroll: (bool) + :param background_color: color of background + :type background_color: (str) + :param text_color: color of the text + :type text_color: (str) + :param highlight_background_color: color of the background when an item is selected. Defaults to normal text color (a reverse look) + :type highlight_background_color: (str) + :param highlight_text_color: color of the text when an item is selected. Defaults to the normal background color (a rerverse look) + :type highlight_text_color: (str) + :param sbar_trough_color: Scrollbar color of the trough + :type sbar_trough_color: (str) + :param sbar_background_color: Scrollbar color of the background of the arrow buttons at the ends AND the color of the "thumb" (the thing you grab and slide). Switches to arrow color when mouse is over + :type sbar_background_color: (str) + :param sbar_arrow_color: Scrollbar color of the arrow at the ends of the scrollbar (it looks like a button). Switches to background color when mouse is over + :type sbar_arrow_color: (str) + :param sbar_width: Scrollbar width in pixels + :type sbar_width: (int) + :param sbar_arrow_width: Scrollbar width of the arrow on the scrollbar. It will potentially impact the overall width of the scrollbar + :type sbar_arrow_width: (int) + :param sbar_frame_color: Scrollbar Color of frame around scrollbar (available only on some ttk themes) + :type sbar_frame_color: (str) + :param sbar_relief: Scrollbar relief that will be used for the "thumb" of the scrollbar (the thing you grab that slides). Should be a constant that is defined at starting with "RELIEF_" - RELIEF_RAISED, RELIEF_SUNKEN, RELIEF_FLAT, RELIEF_RIDGE, RELIEF_GROOVE, RELIEF_SOLID + :type sbar_relief: (str) + :param key: Used with window.find_element and with return values to uniquely identify this element + :type key: str | int | tuple | object + :param k: Same as the Key. You can use either k or key. Which ever is set will be used. + :type k: str | int | tuple | object + :param pad: Amount of padding to put around element in pixels (left/right, top/bottom) or ((left, right), (top, bottom)) or an int. If an int, then it's converted into a tuple (int, int) + :type pad: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param p: Same as pad parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, pad will be used + :type p: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param tooltip: text, that will appear when mouse hovers over the element + :type tooltip: (str) + :param expand_x: If True the element will automatically expand in the X direction to fill available space + :type expand_x: (bool) + :param expand_y: If True the element will automatically expand in the Y direction to fill available space + :type expand_y: (bool) + :param right_click_menu: A list of lists of Menu items to show when this element is right clicked. See user docs for exact format. + :type right_click_menu: List[List[ List[str] | str ]] + :param visible: set visibility state of the element + :type visible: (bool) + :param metadata: User metadata that can be set to ANYTHING + :type metadata: (Any) + """ + + if values is None: + _error_popup_with_traceback('Error in your Listbox definition - The values parameter cannot be None', 'Use an empty list if you want no values in your Listbox') + + self.Values = values + self.DefaultValues = default_values + self.TKListbox = None + self.ChangeSubmits = change_submits or enable_events + self.BindReturnKey = bind_return_key + self.Disabled = disabled + if select_mode == LISTBOX_SELECT_MODE_BROWSE: + self.SelectMode = SELECT_MODE_BROWSE + elif select_mode == LISTBOX_SELECT_MODE_EXTENDED: + self.SelectMode = SELECT_MODE_EXTENDED + elif select_mode == LISTBOX_SELECT_MODE_MULTIPLE: + self.SelectMode = SELECT_MODE_MULTIPLE + elif select_mode == LISTBOX_SELECT_MODE_SINGLE: + self.SelectMode = SELECT_MODE_SINGLE + else: + self.SelectMode = DEFAULT_LISTBOX_SELECT_MODE + bg = background_color if background_color is not None else theme_input_background_color() + fg = text_color if text_color is not None else theme_input_text_color() + self.HighlightBackgroundColor = highlight_background_color if highlight_background_color is not None else fg + self.HighlightTextColor = highlight_text_color if highlight_text_color is not None else bg + self.RightClickMenu = right_click_menu + self.vsb = None # type: tk.Scrollbar or None + self.hsb = None # type: tk.Scrollbar | None + self.TKListbox = self.Widget = None # type: tk.Listbox + self.element_frame = None # type: tk.Frame + self.NoScrollbar = no_scrollbar + self.HorizontalScroll = horizontal_scroll + key = key if key is not None else k + sz = size if size != (None, None) else s + pad = pad if pad is not None else p + self.expand_x = expand_x + self.expand_y = expand_y + self.justification = justification + + super().__init__(ELEM_TYPE_INPUT_LISTBOX, size=sz, auto_size_text=auto_size_text, font=font, + background_color=bg, text_color=fg, key=key, pad=pad, tooltip=tooltip, visible=visible, metadata=metadata, + sbar_trough_color=sbar_trough_color, sbar_background_color=sbar_background_color, sbar_arrow_color=sbar_arrow_color, sbar_width=sbar_width, sbar_arrow_width=sbar_arrow_width, sbar_frame_color=sbar_frame_color, sbar_relief=sbar_relief) + + def update(self, values=None, disabled=None, set_to_index=None, scroll_to_index=None, select_mode=None, visible=None): + """ + Changes some of the settings for the Listbox Element. Must call `Window.Read` or `Window.Finalize` prior + Changes will not be visible in your window until you call window.read or window.refresh. + + If you change visibility, your element may MOVE. If you want it to remain stationary, use the "layout helper" + function "pin" to ensure your element is "pinned" to that location in your layout so that it returns there + when made visible. + + :param values: new list of choices to be shown to user + :type values: List[Any] + :param disabled: disable or enable state of the element + :type disabled: (bool) + :param set_to_index: highlights the item(s) indicated. If parm is an int one entry will be set. If is a list, then each entry in list is highlighted + :type set_to_index: int | list | tuple + :param scroll_to_index: scroll the listbox so that this index is the first shown + :type scroll_to_index: (int) + :param select_mode: changes the select mode according to tkinter's listbox widget + :type select_mode: (str) + :param visible: control visibility of element + :type visible: (bool) + """ + + if not self._widget_was_created(): # if widget hasn't been created yet, then don't allow + return + + if self._this_elements_window_closed(): + _error_popup_with_traceback('Error in Listbox.update - The window was closed') + return + + if disabled is True: + self.TKListbox.configure(state='disabled') + elif disabled is False: + self.TKListbox.configure(state='normal') + self.Disabled = disabled if disabled is not None else self.Disabled + + if values is not None: + self.TKListbox.delete(0, 'end') + for item in list(values): + self.TKListbox.insert(tk.END, item) + # self.TKListbox.selection_set(0, 0) + self.Values = list(values) + if set_to_index is not None: + self.TKListbox.selection_clear(0, len(self.Values)) # clear all listbox selections + if type(set_to_index) in (tuple, list): + for i in set_to_index: + try: + self.TKListbox.selection_set(i, i) + except: + warnings.warn('* Listbox Update selection_set failed with index {}*'.format(set_to_index)) + else: + try: + self.TKListbox.selection_set(set_to_index, set_to_index) + except: + warnings.warn('* Listbox Update selection_set failed with index {}*'.format(set_to_index)) + if visible is False: + self._pack_forget_save_settings(self.element_frame) + elif visible is True: + self._pack_restore_settings(self.element_frame) + if scroll_to_index is not None and len(self.Values): + self.TKListbox.yview_moveto(scroll_to_index / len(self.Values)) + if select_mode is not None: + try: + self.TKListbox.config(selectmode=select_mode) + except: + print('Listbox.update error trying to change mode to: ', select_mode) + if visible is not None: + self._visible = visible + + def set_value(self, values): + """ + Set listbox highlighted choices + + :param values: new values to choose based on previously set values + :type values: List[Any] | Tuple[Any] + + """ + for index, item in enumerate(self.Values): + try: + if item in values: + self.TKListbox.selection_set(index) + else: + self.TKListbox.selection_clear(index) + except: + pass + self.DefaultValues = values + + def get_list_values(self): + # type: (Listbox) -> List[Any] + """ + Returns list of Values provided by the user in the user's format + + :return: List of values. Can be any / mixed types -> [] + :rtype: List[Any] + """ + return self.Values + + def get_indexes(self): + """ + Returns the items currently selected as a list of indexes + + :return: A list of offsets into values that is currently selected + :rtype: List[int] + """ + return self.TKListbox.curselection() + + def get(self): + """ + Returns the list of items currently selected in this listbox. It should be identical + to the value you would receive when performing a window.read() call. + + :return: The list of currently selected items. The actual items are returned, not the indexes + :rtype: List[Any] + """ + try: + items = self.TKListbox.curselection() + value = [self.Values[int(item)] for item in items] + except: + value = [] + return value + + + def select_index(self, index, highlight_text_color=None, highlight_background_color=None): + """ + Selects an index while providing capability to setting the selected color for the index to specific text/background color + + :param index: specifies which item to change. index starts at 0 and goes to length of values list minus one + :type index: (int) + :param highlight_text_color: color of the text when this item is selected. + :type highlight_text_color: (str) + :param highlight_background_color: color of the background when this item is selected + :type highlight_background_color: (str) + """ + + if not self._widget_was_created(): # if widget hasn't been created yet, then don't allow + return + + if self._this_elements_window_closed(): + _error_popup_with_traceback('Error in Listbox.select_item - The window was closed') + return + + if index >= len(self.Values): + _error_popup_with_traceback('Index {} is out of range for Listbox.select_index. Max allowed index is {}.'.format(index, len(self.Values)-1)) + return + + self.TKListbox.selection_set(index, index) + + if highlight_text_color is not None: + self.widget.itemconfig(index, selectforeground=highlight_text_color) + if highlight_background_color is not None: + self.widget.itemconfig(index, selectbackground=highlight_background_color) + + + def set_index_color(self, index, text_color=None, background_color=None, highlight_text_color=None, highlight_background_color=None): + """ + Sets the color of a specific item without selecting it + + :param index: specifies which item to change. index starts at 0 and goes to length of values list minus one + :type index: (int) + :param text_color: color of the text for this item + :type text_color: (str) + :param background_color: color of the background for this item + :type background_color: (str) + :param highlight_text_color: color of the text when this item is selected. + :type highlight_text_color: (str) + :param highlight_background_color: color of the background when this item is selected + :type highlight_background_color: (str) + """ + + if not self._widget_was_created(): # if widget hasn't been created yet, then don't allow + return + + if self._this_elements_window_closed(): + _error_popup_with_traceback('Error in Listbox.set_item_color - The window was closed') + return + + if index >= len(self.Values): + _error_popup_with_traceback('Index {} is out of range for Listbox.set_index_color. Max allowed index is {}.'.format(index, len(self.Values)-1)) + return + + if text_color is not None: + self.widget.itemconfig(index, fg=text_color) + if background_color is not None: + self.widget.itemconfig(index, bg=background_color) + if highlight_text_color is not None: + self.widget.itemconfig(index, selectforeground=highlight_text_color) + if highlight_background_color is not None: + self.widget.itemconfig(index, selectbackground=highlight_background_color) + + + + + GetIndexes = get_indexes + GetListValues = get_list_values + SetValue = set_value + Update = update + + +LBox = Listbox +LB = Listbox + + +# ---------------------------------------------------------------------- # +# Radio # +# ---------------------------------------------------------------------- # +class Radio(Element): + """ + Radio Button Element - Used in a group of other Radio Elements to provide user with ability to select only + 1 choice in a list of choices. + """ + + def __init__(self, text, group_id, default=False, disabled=False, size=(None, None), s=(None, None), auto_size_text=None, + background_color=None, text_color=None, circle_color=None, font=None, key=None, k=None, pad=None, p=None, tooltip=None, + change_submits=False, enable_events=False, right_click_menu=None, expand_x=False, expand_y=False, visible=True, metadata=None): + """ + :param text: Text to display next to button + :type text: (str) + :param group_id: Groups together multiple Radio Buttons. Any type works + :type group_id: (Any) + :param default: Set to True for the one element of the group you want initially selected + :type default: (bool) + :param disabled: set disable state + :type disabled: (bool) + :param size: (w, h) w=characters-wide, h=rows-high. If an int instead of a tuple is supplied, then height is auto-set to 1 + :type size: (int, int) | (None, None) | int + :param s: Same as size parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, size will be used + :type s: (int, int) | (None, None) | int + :param auto_size_text: if True will size the element to match the length of the text + :type auto_size_text: (bool) + :param background_color: color of background + :type background_color: (str) + :param text_color: color of the text + :type text_color: (str) + :param circle_color: color of background of the circle that has the dot selection indicator in it + :type circle_color: (str) + :param font: specifies the font family, size, etc. Tuple or Single string format 'name size styles'. Styles: italic * roman bold normal underline overstrike + :type font: (str or (str, int[, str]) or None) + :param key: Used with window.find_element and with return values to uniquely identify this element + :type key: str | int | tuple | object + :param k: Same as the Key. You can use either k or key. Which ever is set will be used. + :type k: str | int | tuple | object + :param pad: Amount of padding to put around element in pixels (left/right, top/bottom) or ((left, right), (top, bottom)) or an int. If an int, then it's converted into a tuple (int, int) + :type pad: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param p: Same as pad parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, pad will be used + :type p: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param tooltip: text, that will appear when mouse hovers over the element + :type tooltip: (str) + :param change_submits: DO NOT USE. Only listed for backwards compat - Use enable_events instead + :type change_submits: (bool) + :param enable_events: Turns on the element specific events. Radio Button events happen when an item is selected + :type enable_events: (bool) + :param right_click_menu: A list of lists of Menu items to show when this element is right clicked. See user docs for exact format. + :type right_click_menu: List[List[ List[str] | str ]] + :param expand_x: If True the element will automatically expand in the X direction to fill available space + :type expand_x: (bool) + :param expand_y: If True the element will automatically expand in the Y direction to fill available space + :type expand_y: (bool) + :param visible: set visibility state of the element + :type visible: (bool) + :param metadata: User metadata that can be set to ANYTHING + :type metadata: (Any) + """ + + + self.InitialState = default + self.Text = text + self.Widget = self.TKRadio = None # type: tk.Radiobutton + self.GroupID = group_id + self.Value = None + self.Disabled = disabled + self.TextColor = text_color if text_color else theme_text_color() + self.RightClickMenu = right_click_menu + + if circle_color is None: + # ---- compute color of circle background --- + try: # something in here will fail if a color is not specified in Hex + text_hsl = _hex_to_hsl(self.TextColor) + background_hsl = _hex_to_hsl(background_color if background_color else theme_background_color()) + l_delta = abs(text_hsl[2] - background_hsl[2]) / 10 + if text_hsl[2] > background_hsl[2]: # if the text is "lighter" than the background then make background darker + bg_rbg = _hsl_to_rgb(background_hsl[0], background_hsl[1], background_hsl[2] - l_delta) + else: + bg_rbg = _hsl_to_rgb(background_hsl[0], background_hsl[1], background_hsl[2] + l_delta) + self.CircleBackgroundColor = rgb(*bg_rbg) + except: + self.CircleBackgroundColor = background_color if background_color else theme_background_color() + else: + self.CircleBackgroundColor = circle_color + self.ChangeSubmits = change_submits or enable_events + self.EncodedRadioValue = None + key = key if key is not None else k + sz = size if size != (None, None) else s + pad = pad if pad is not None else p + self.expand_x = expand_x + self.expand_y = expand_y + + super().__init__(ELEM_TYPE_INPUT_RADIO, size=sz, auto_size_text=auto_size_text, font=font, + background_color=background_color, text_color=self.TextColor, key=key, pad=pad, + tooltip=tooltip, visible=visible, metadata=metadata) + + def update(self, value=None, text=None, background_color=None, text_color=None, circle_color=None, disabled=None, visible=None): + """ + Changes some of the settings for the Radio Button Element. Must call `Window.read` or `Window.finalize` prior + + Changes will not be visible in your window until you call window.read or window.refresh. + + If you change visibility, your element may MOVE. If you want it to remain stationary, use the "layout helper" + function "pin" to ensure your element is "pinned" to that location in your layout so that it returns there + when made visible. + + :param value: if True change to selected and set others in group to unselected + :type value: (bool) + :param text: Text to display next to radio button + :type text: (str) + :param background_color: color of background + :type background_color: (str) + :param text_color: color of the text. Note this also changes the color of the selection dot + :type text_color: (str) + :param circle_color: color of background of the circle that has the dot selection indicator in it + :type circle_color: (str) + :param disabled: disable or enable state of the element + :type disabled: (bool) + :param visible: control visibility of element + :type visible: (bool) + """ + + if not self._widget_was_created(): # if widget hasn't been created yet, then don't allow + return + + if self._this_elements_window_closed(): + _error_popup_with_traceback('Error in Radio.update - The window was closed') + return + + + if value is not None: + try: + if value is True: + self.TKIntVar.set(self.EncodedRadioValue) + elif value is False: + if self.TKIntVar.get() == self.EncodedRadioValue: + self.TKIntVar.set(0) + except: + print('Error updating Radio') + self.InitialState = value + if text is not None: + self.Text = str(text) + self.TKRadio.configure(text=self.Text) + if background_color not in (None, COLOR_SYSTEM_DEFAULT): + self.TKRadio.configure(background=background_color) + self.BackgroundColor = background_color + if text_color not in (None, COLOR_SYSTEM_DEFAULT): + self.TKRadio.configure(fg=text_color) + self.TextColor = text_color + + if circle_color not in (None, COLOR_SYSTEM_DEFAULT): + self.CircleBackgroundColor = circle_color + self.TKRadio.configure(selectcolor=self.CircleBackgroundColor) # The background of the radio button + elif text_color or background_color: + if self.TextColor not in (None, COLOR_SYSTEM_DEFAULT) and self.BackgroundColor not in (None, COLOR_SYSTEM_DEFAULT) and self.TextColor.startswith( + '#') and self.BackgroundColor.startswith('#'): + # ---- compute color of circle background --- + text_hsl = _hex_to_hsl(self.TextColor) + background_hsl = _hex_to_hsl(self.BackgroundColor if self.BackgroundColor else theme_background_color()) + l_delta = abs(text_hsl[2] - background_hsl[2]) / 10 + if text_hsl[2] > background_hsl[2]: # if the text is "lighter" than the background then make background darker + bg_rbg = _hsl_to_rgb(background_hsl[0], background_hsl[1], background_hsl[2] - l_delta) + else: + bg_rbg = _hsl_to_rgb(background_hsl[0], background_hsl[1], background_hsl[2] + l_delta) + self.CircleBackgroundColor = rgb(*bg_rbg) + self.TKRadio.configure(selectcolor=self.CircleBackgroundColor) # The background of the checkbox + + if disabled is True: + self.TKRadio['state'] = 'disabled' + elif disabled is False: + self.TKRadio['state'] = 'normal' + self.Disabled = disabled if disabled is not None else self.Disabled + + if visible is False: + self._pack_forget_save_settings() + elif visible is True: + self._pack_restore_settings() + if visible is not None: + self._visible = visible + + def reset_group(self): + """ + Sets all Radio Buttons in the group to not selected + """ + self.TKIntVar.set(0) + + def get(self): + # type: (Radio) -> bool + """ + A snapshot of the value of Radio Button -> (bool) + + :return: True if this radio button is selected + :rtype: (bool) + """ + return self.TKIntVar.get() == self.EncodedRadioValue + + Get = get + ResetGroup = reset_group + Update = update + + +R = Radio +Rad = Radio + + +# ---------------------------------------------------------------------- # +# Checkbox # +# ---------------------------------------------------------------------- # +class Checkbox(Element): + """ + Checkbox Element - Displays a checkbox and text next to it + """ + + def __init__(self, text, default=False, size=(None, None), s=(None, None), auto_size_text=None, font=None, background_color=None, + text_color=None, checkbox_color=None, highlight_thickness=1, change_submits=False, enable_events=False, disabled=False, key=None, k=None, pad=None, p=None, tooltip=None, + right_click_menu=None, expand_x=False, expand_y=False, visible=True, metadata=None): + """ + :param text: Text to display next to checkbox + :type text: (str) + :param default: Set to True if you want this checkbox initially checked + :type default: (bool) + :param size: (w, h) w=characters-wide, h=rows-high. If an int instead of a tuple is supplied, then height is auto-set to 1 + :type size: (int, int) | (None, None) | int + :param s: Same as size parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, size will be used + :type s: (int, int) | (None, None) | int + :param auto_size_text: if True will size the element to match the length of the text + :type auto_size_text: (bool) + :param font: specifies the font family, size, etc. Tuple or Single string format 'name size styles'. Styles: italic * roman bold normal underline overstrike + :type font: (str or (str, int[, str]) or None) + :param background_color: color of background + :type background_color: (str) + :param text_color: color of the text + :type text_color: (str) + :param checkbox_color: color of background of the box that has the check mark in it. The checkmark is the same color as the text + :type checkbox_color: (str) + :param highlight_thickness: thickness of border around checkbox when gets focus + :type highlight_thickness: (int) + :param change_submits: DO NOT USE. Only listed for backwards compat - Use enable_events instead + :type change_submits: (bool) + :param enable_events: Turns on the element specific events. Checkbox events happen when an item changes + :type enable_events: (bool) + :param disabled: set disable state + :type disabled: (bool) + :param key: Used with window.find_element and with return values to uniquely identify this element + :type key: str | int | tuple | object + :param k: Same as the Key. You can use either k or key. Which ever is set will be used. + :type k: str | int | tuple | object + :param pad: Amount of padding to put around element in pixels (left/right, top/bottom) or ((left, right), (top, bottom)) or an int. If an int, then it's converted into a tuple (int, int) + :type pad: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param p: Same as pad parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, pad will be used + :type p: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param tooltip: text, that will appear when mouse hovers over the element + :type tooltip: (str) + :param right_click_menu: A list of lists of Menu items to show when this element is right clicked. See user docs for exact format. + :type right_click_menu: List[List[ List[str] | str ]] + :param expand_x: If True the element will automatically expand in the X direction to fill available space + :type expand_x: (bool) + :param expand_y: If True the element will automatically expand in the Y direction to fill available space + :type expand_y: (bool) + :param visible: set visibility state of the element + :type visible: (bool) + :param metadata: User metadata that can be set to ANYTHING + :type metadata: (Any) + """ + + + + self.Text = text + self.InitialState = bool(default) + self.Value = None + self.TKCheckbutton = self.Widget = None # type: tk.Checkbutton + self.Disabled = disabled + self.TextColor = text_color if text_color else theme_text_color() + self.RightClickMenu = right_click_menu + self.highlight_thickness = highlight_thickness + + # ---- compute color of circle background --- + if checkbox_color is None: + try: # something in here will fail if a color is not specified in Hex + text_hsl = _hex_to_hsl(self.TextColor) + background_hsl = _hex_to_hsl(background_color if background_color else theme_background_color()) + l_delta = abs(text_hsl[2] - background_hsl[2]) / 10 + if text_hsl[2] > background_hsl[2]: # if the text is "lighter" than the background then make background darker + bg_rbg = _hsl_to_rgb(background_hsl[0], background_hsl[1], background_hsl[2] - l_delta) + else: + bg_rbg = _hsl_to_rgb(background_hsl[0], background_hsl[1], background_hsl[2] + l_delta) + self.CheckboxBackgroundColor = rgb(*bg_rbg) + except: + self.CheckboxBackgroundColor = background_color if background_color else theme_background_color() + else: + self.CheckboxBackgroundColor = checkbox_color + self.ChangeSubmits = change_submits or enable_events + key = key if key is not None else k + sz = size if size != (None, None) else s + pad = pad if pad is not None else p + self.expand_x = expand_x + self.expand_y = expand_y + + super().__init__(ELEM_TYPE_INPUT_CHECKBOX, size=sz, auto_size_text=auto_size_text, font=font, + background_color=background_color, text_color=self.TextColor, key=key, pad=pad, + tooltip=tooltip, visible=visible, metadata=metadata) + + def get(self): + # type: (Checkbox) -> bool + """ + Return the current state of this checkbox + + :return: Current state of checkbox + :rtype: (bool) + """ + return self.TKIntVar.get() != 0 + + def update(self, value=None, text=None, background_color=None, text_color=None, checkbox_color=None, disabled=None, visible=None): + """ + Changes some of the settings for the Checkbox Element. Must call `Window.Read` or `Window.Finalize` prior. + Note that changing visibility may cause element to change locations when made visible after invisible + + Changes will not be visible in your window until you call window.read or window.refresh. + + If you change visibility, your element may MOVE. If you want it to remain stationary, use the "layout helper" + function "pin" to ensure your element is "pinned" to that location in your layout so that it returns there + when made visible. + + :param value: if True checks the checkbox, False clears it + :type value: (bool) + :param text: Text to display next to checkbox + :type text: (str) + :param background_color: color of background + :type background_color: (str) + :param text_color: color of the text. Note this also changes the color of the checkmark + :type text_color: (str) + :param disabled: disable or enable element + :type disabled: (bool) + :param visible: control visibility of element + :type visible: (bool) + """ + + if not self._widget_was_created(): # if widget hasn't been created yet, then don't allow + return + + if self._this_elements_window_closed(): + _error_popup_with_traceback('Error in Checkbox.update - The window was closed') + return + + + if value is not None: + value = bool(value) + try: + self.TKIntVar.set(value) + self.InitialState = value + except: + print('Checkbox update failed') + if disabled is True: + self.TKCheckbutton.configure(state='disabled') + elif disabled is False: + self.TKCheckbutton.configure(state='normal') + self.Disabled = disabled if disabled is not None else self.Disabled + + if text is not None: + self.Text = str(text) + self.TKCheckbutton.configure(text=self.Text) + if background_color not in (None, COLOR_SYSTEM_DEFAULT): + self.TKCheckbutton.configure(background=background_color) + self.BackgroundColor = background_color + if text_color not in (None, COLOR_SYSTEM_DEFAULT): + self.TKCheckbutton.configure(fg=text_color) + self.TextColor = text_color + # Color the checkbox itself + if checkbox_color not in (None, COLOR_SYSTEM_DEFAULT): + self.CheckboxBackgroundColor = checkbox_color + self.TKCheckbutton.configure(selectcolor=self.CheckboxBackgroundColor) # The background of the checkbox + elif text_color or background_color: + if self.CheckboxBackgroundColor is not None and self.TextColor is not None and self.BackgroundColor is not None and self.TextColor.startswith( + '#') and self.BackgroundColor.startswith('#'): + # ---- compute color of checkbox background --- + text_hsl = _hex_to_hsl(self.TextColor) + background_hsl = _hex_to_hsl(self.BackgroundColor if self.BackgroundColor else theme_background_color()) + l_delta = abs(text_hsl[2] - background_hsl[2]) / 10 + if text_hsl[2] > background_hsl[2]: # if the text is "lighter" than the background then make background darker + bg_rbg = _hsl_to_rgb(background_hsl[0], background_hsl[1], background_hsl[2] - l_delta) + else: + bg_rbg = _hsl_to_rgb(background_hsl[0], background_hsl[1], background_hsl[2] + l_delta) + self.CheckboxBackgroundColor = rgb(*bg_rbg) + self.TKCheckbutton.configure(selectcolor=self.CheckboxBackgroundColor) # The background of the checkbox + + if visible is False: + self._pack_forget_save_settings() + elif visible is True: + self._pack_restore_settings() + + if visible is not None: + self._visible = visible + + Get = get + Update = update + + +# ------------------------- CHECKBOX Element lazy functions ------------------------- # +CB = Checkbox +CBox = Checkbox +Check = Checkbox + + +# ---------------------------------------------------------------------- # +# Spin # +# ---------------------------------------------------------------------- # + +class Spin(Element): + """ + A spinner with up/down buttons and a single line of text. Choose 1 values from list + """ + + def __init__(self, values, initial_value=None, disabled=False, change_submits=False, enable_events=False, readonly=False, + size=(None, None), s=(None, None), auto_size_text=None, bind_return_key=None, font=None, background_color=None, text_color=None, key=None, k=None, pad=None, p=None, wrap=None, + tooltip=None, right_click_menu=None, expand_x=False, expand_y=False, visible=True, metadata=None): + """ + :param values: List of valid values + :type values: Tuple[Any] or List[Any] + :param initial_value: Initial item to show in window. Choose from list of values supplied + :type initial_value: (Any) + :param disabled: set disable state + :type disabled: (bool) + :param change_submits: DO NOT USE. Only listed for backwards compat - Use enable_events instead + :type change_submits: (bool) + :param enable_events: Turns on the element specific events. Spin events happen when an item changes + :type enable_events: (bool) + :param readonly: If True, then users cannot type in values. Only values from the values list are allowed. + :type readonly: (bool) + :param size: (w, h) w=characters-wide, h=rows-high. If an int instead of a tuple is supplied, then height is auto-set to 1 + :type size: (int, int) | (None, None) | int + :param s: Same as size parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, size will be used + :type s: (int, int) | (None, None) | int + :param auto_size_text: if True will size the element to match the length of the text + :type auto_size_text: (bool) + :param bind_return_key: If True, then the return key will cause a the element to generate an event when return key is pressed + :type bind_return_key: (bool) + :param font: specifies the font family, size, etc. Tuple or Single string format 'name size styles'. Styles: italic * roman bold normal underline overstrike + :type font: (str or (str, int[, str]) or None) + :param background_color: color of background + :type background_color: (str) + :param text_color: color of the text + :type text_color: (str) + :param key: Used with window.find_element and with return values to uniquely identify this element + :type key: str | int | tuple | object + :param k: Same as the Key. You can use either k or key. Which ever is set will be used. + :type k: str | int | tuple | object + :param pad: Amount of padding to put around element in pixels (left/right, top/bottom) or ((left, right), (top, bottom)) or an int. If an int, then it's converted into a tuple (int, int) + :type pad: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param p: Same as pad parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, pad will be used + :type p: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param wrap: Determines if the values should "Wrap". Default is False. If True, when reaching last value, will continue back to the first value. + :type wrap: (bool) + :param tooltip: text, that will appear when mouse hovers over the element + :type tooltip: (str) + :param right_click_menu: A list of lists of Menu items to show when this element is right clicked. See user docs for exact format. + :type right_click_menu: List[List[ List[str] | str ]] + :param expand_x: If True the element will automatically expand in the X direction to fill available space + :type expand_x: (bool) + :param expand_y: If True the element will automatically expand in the Y direction to fill available space + :type expand_y: (bool) + :param visible: set visibility state of the element + :type visible: (bool) + :param metadata: User metadata that can be set to ANYTHING + :type metadata: (Any) + """ + + + self.Values = values + self.DefaultValue = initial_value + self.ChangeSubmits = change_submits or enable_events + self.TKSpinBox = self.Widget = None # type: tk.Spinbox + self.Disabled = disabled + self.Readonly = readonly + self.RightClickMenu = right_click_menu + self.BindReturnKey = bind_return_key + self.wrap = wrap + + bg = background_color if background_color else DEFAULT_INPUT_ELEMENTS_COLOR + fg = text_color if text_color is not None else DEFAULT_INPUT_TEXT_COLOR + key = key if key is not None else k + sz = size if size != (None, None) else s + pad = pad if pad is not None else p + self.expand_x = expand_x + self.expand_y = expand_y + + + super().__init__(ELEM_TYPE_INPUT_SPIN, size=sz, auto_size_text=auto_size_text, font=font, background_color=bg, text_color=fg, + key=key, pad=pad, tooltip=tooltip, visible=visible, metadata=metadata) + return + + def update(self, value=None, values=None, disabled=None, readonly=None, visible=None): + """ + Changes some of the settings for the Spin Element. Must call `Window.Read` or `Window.Finalize` prior + Note that the state can be in 3 states only.... enabled, disabled, readonly even + though more combinations are available. The easy way to remember is that if you + change the readonly parameter then you are enabling the element. + + Changes will not be visible in your window until you call window.read or window.refresh. + + If you change visibility, your element may MOVE. If you want it to remain stationary, use the "layout helper" + function "pin" to ensure your element is "pinned" to that location in your layout so that it returns there + when made visible. + + :param value: set the current value from list of choices + :type value: (Any) + :param values: set available choices + :type values: List[Any] + :param disabled: disable. Note disabled and readonly cannot be mixed. It must be one OR the other + :type disabled: (bool) + :param readonly: make element readonly. Note disabled and readonly cannot be mixed. It must be one OR the other + :type readonly: (bool) + :param visible: control visibility of element + :type visible: (bool) + """ + + if not self._widget_was_created(): # if widget hasn't been created yet, then don't allow + return + + if self._this_elements_window_closed(): + _error_popup_with_traceback('Error in Spin.update - The window was closed') + return + + if values != None: + old_value = self.TKStringVar.get() + self.Values = values + self.TKSpinBox.configure(values=values) + self.TKStringVar.set(old_value) + if value is not None: + try: + self.TKStringVar.set(value) + self.DefaultValue = value + except: + pass + + if readonly is True: + self.Readonly = True + self.TKSpinBox['state'] = 'readonly' + elif readonly is False: + self.Readonly = False + self.TKSpinBox['state'] = 'normal' + if disabled is True: + self.TKSpinBox['state'] = 'disable' + elif disabled is False: + if self.Readonly: + self.TKSpinBox['state'] = 'readonly' + else: + self.TKSpinBox['state'] = 'normal' + self.Disabled = disabled if disabled is not None else self.Disabled + + if visible is False: + self._pack_forget_save_settings() + elif visible is True: + self._pack_restore_settings() + if visible is not None: + self._visible = visible + + + def _SpinChangedHandler(self, event): + """ + Callback function. Used internally only. Called by tkinter when Spinbox Widget changes. Results in Window.Read() call returning + + :param event: passed in from tkinter + :type event: + """ + # first, get the results table built + if self.Key is not None: + self.ParentForm.LastButtonClicked = self.Key + else: + self.ParentForm.LastButtonClicked = '' + self.ParentForm.FormRemainedOpen = True + _exit_mainloop(self.ParentForm) + # if self.ParentForm.CurrentlyRunningMainloop: + # Window._window_that_exited = self.ParentForm + # self.ParentForm.TKroot.quit() # kick the users out of the mainloop + + + + + def set_ibeam_color(self, ibeam_color=None): + """ + Sets the color of the I-Beam that is used to "insert" characters. This is oftens called a "Cursor" by + many users. To keep from being confused with tkinter's definition of cursor (the mouse pointer), the term + ibeam is used in this case. + :param ibeam_color: color to set the "I-Beam" used to indicate where characters will be inserted + :type ibeam_color: (str) + """ + + if not self._widget_was_created(): + return + if ibeam_color is not None: + try: + self.Widget.config(insertbackground=ibeam_color) + except Exception as e: + _error_popup_with_traceback('Error setting I-Beam color in set_ibeam_color', + 'The element has a key:', self.Key, + 'The color passed in was:', ibeam_color) + + + + def get(self): + """ + Return the current chosen value showing in spinbox. + This value will be the same as what was provided as list of choices. If list items are ints, then the + item returned will be an int (not a string) + + :return: The currently visible entry + :rtype: (Any) + """ + value = self.TKStringVar.get() + for v in self.Values: + if str(v) == value: + value = v + break + return value + + Get = get + Update = update + + +Sp = Spin # type: Spin + + +# ---------------------------------------------------------------------- # +# Multiline # +# ---------------------------------------------------------------------- # +class Multiline(Element): + """ + Multiline Element - Display and/or read multiple lines of text. This is both an input and output element. + Other PySimpleGUI ports have a separate MultilineInput and MultilineOutput elements. May want to split this + one up in the future too. + """ + + def __init__(self, default_text='', enter_submits=False, disabled=False, autoscroll=False, autoscroll_only_at_bottom=False, border_width=None, + size=(None, None), s=(None, None), auto_size_text=None, background_color=None, text_color=None, selected_text_color=None, selected_background_color=None, horizontal_scroll=False, change_submits=False, + enable_events=False, do_not_clear=True, key=None, k=None, write_only=False, auto_refresh=False, reroute_stdout=False, reroute_stderr=False, reroute_cprint=False, echo_stdout_stderr=False, focus=False, font=None, pad=None, p=None, tooltip=None, justification=None, no_scrollbar=False, wrap_lines=None, + sbar_trough_color=None, sbar_background_color=None, sbar_arrow_color=None, sbar_width=None, sbar_arrow_width=None, sbar_frame_color=None, sbar_relief=None, + expand_x=False, expand_y=False, rstrip=True, right_click_menu=None, visible=True, metadata=None): + """ + :param default_text: Initial text to show + :type default_text: (Any) + :param enter_submits: if True, the Window.read call will return is enter key is pressed in this element + :type enter_submits: (bool) + :param disabled: set disable state + :type disabled: (bool) + :param autoscroll: If True the contents of the element will automatically scroll as more data added to the end + :type autoscroll: (bool) + :param autoscroll_only_at_bottom: If True the contents of the element will automatically scroll only if the scrollbar is at the bottom of the multiline + :type autoscroll_only_at_bottom: (bool) + :param border_width: width of border around element in pixels + :type border_width: (int) + :param size: (w, h) w=characters-wide, h=rows-high. If an int instead of a tuple is supplied, then height is auto-set to 1 + :type size: (int, int) | (None, None) | int + :param s: Same as size parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, size will be used + :type s: (int, int) | (None, None) | int + :param auto_size_text: if True will size the element to match the length of the text + :type auto_size_text: (bool) + :param background_color: color of background + :type background_color: (str) + :param text_color: color of the text + :type text_color: (str) + :param selected_text_color: Color of text when it is selected (using mouse or control+A, etc) + :type selected_text_color: (str) + :param selected_background_color: Color of background when it is selected (using mouse or control+A, etc) + :type selected_background_color: (str) + :param horizontal_scroll: Controls if a horizontal scrollbar should be shown. If True a horizontal scrollbar will be shown in addition to vertical + :type horizontal_scroll: (bool) + :param change_submits: DO NOT USE. Only listed for backwards compat - Use enable_events instead + :type change_submits: (bool) + :param enable_events: If True then any key press that happens when the element has focus will generate an event. + :type enable_events: (bool) + :param do_not_clear: if False the element will be cleared any time the Window.read call returns + :type do_not_clear: (bool) + :param key: Used with window.find_element and with return values to uniquely identify this element to uniquely identify this element + :type key: str | int | tuple | object + :param k: Same as the Key. You can use either k or key. Which ever is set will be used. + :type k: str | int | tuple | object + :param write_only: If True then no entry will be added to the values dictionary when the window is read + :type write_only: bool + :param auto_refresh: If True then anytime the element is updated, the window will be refreshed so that the change is immediately displayed + :type auto_refresh: (bool) + :param reroute_stdout: If True then all output to stdout will be output to this element + :type reroute_stdout: (bool) + :param reroute_stderr: If True then all output to stderr will be output to this element + :type reroute_stderr: (bool) + :param reroute_cprint: If True your cprint calls will output to this element. It's the same as you calling cprint_set_output_destination + :type reroute_cprint: (bool) + :param echo_stdout_stderr: If True then output to stdout and stderr will be output to this element AND also to the normal console location + :type echo_stdout_stderr: (bool) + :param focus: if True initial focus will go to this element + :type focus: (bool) + :param font: specifies the font family, size, etc. Tuple or Single string format 'name size styles'. Styles: italic * roman bold normal underline overstrike + :type font: (str or (str, int[, str]) or None) + :param pad: Amount of padding to put around element in pixels (left/right, top/bottom) or ((left, right), (top, bottom)) or an int. If an int, then it's converted into a tuple (int, int) + :type pad: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param p: Same as pad parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, pad will be used + :type p: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param tooltip: text, that will appear when mouse hovers over the element + :type tooltip: (str) + :param justification: text justification. left, right, center. Can use single characters l, r, c. + :type justification: (str) + :param no_scrollbar: If False then a vertical scrollbar will be shown (the default) + :type no_scrollbar: (bool) + :param wrap_lines: If True, the lines will be wrapped automatically. Other parms affect this setting, but this one will override them all. Default is it does nothing and uses previous settings for wrapping. + :type wrap_lines: (bool) + :param sbar_trough_color: Scrollbar color of the trough + :type sbar_trough_color: (str) + :param sbar_background_color: Scrollbar color of the background of the arrow buttons at the ends AND the color of the "thumb" (the thing you grab and slide). Switches to arrow color when mouse is over + :type sbar_background_color: (str) + :param sbar_arrow_color: Scrollbar color of the arrow at the ends of the scrollbar (it looks like a button). Switches to background color when mouse is over + :type sbar_arrow_color: (str) + :param sbar_width: Scrollbar width in pixels + :type sbar_width: (int) + :param sbar_arrow_width: Scrollbar width of the arrow on the scrollbar. It will potentially impact the overall width of the scrollbar + :type sbar_arrow_width: (int) + :param sbar_frame_color: Scrollbar Color of frame around scrollbar (available only on some ttk themes) + :type sbar_frame_color: (str) + :param sbar_relief: Scrollbar relief that will be used for the "thumb" of the scrollbar (the thing you grab that slides). Should be a constant that is defined at starting with "RELIEF_" - RELIEF_RAISED, RELIEF_SUNKEN, RELIEF_FLAT, RELIEF_RIDGE, RELIEF_GROOVE, RELIEF_SOLID + :type sbar_relief: (str) + :param expand_x: If True the element will automatically expand in the X direction to fill available space + :type expand_x: (bool) + :param expand_y: If True the element will automatically expand in the Y direction to fill available space + :type expand_y: (bool) + :param rstrip: If True the value returned in will have whitespace stripped from the right side + :type rstrip: (bool) + :param right_click_menu: A list of lists of Menu items to show when this element is right clicked. See user docs for exact format. + :type right_click_menu: List[List[ List[str] | str ]] + :param visible: set visibility state of the element + :type visible: (bool) + :param metadata: User metadata that can be set to ANYTHING + :type metadata: (Any) + """ + + + self.DefaultText = str(default_text) + self.EnterSubmits = enter_submits + bg = background_color if background_color else DEFAULT_INPUT_ELEMENTS_COLOR + self.Focus = focus + self.do_not_clear = do_not_clear + fg = text_color if text_color is not None else DEFAULT_INPUT_TEXT_COLOR + fg = text_color if text_color is not None else DEFAULT_INPUT_TEXT_COLOR + self.selected_text_color = selected_text_color + self.selected_background_color = selected_background_color + self.Autoscroll = autoscroll + self.Disabled = disabled + self.ChangeSubmits = change_submits or enable_events + self.RightClickMenu = right_click_menu + self.BorderWidth = border_width if border_width is not None else DEFAULT_BORDER_WIDTH + self.TagCounter = 0 + self.TKText = self.Widget = None # type: tk.Text + self.element_frame = None # type: tk.Frame + self.HorizontalScroll = horizontal_scroll + self.tags = set() + self.WriteOnly = write_only + self.AutoRefresh = auto_refresh + key = key if key is not None else k + self.reroute_cprint = reroute_cprint + self.echo_stdout_stderr = echo_stdout_stderr + self.Justification = 'left' if justification is None else justification + self.justification_tag = self.just_center_tag = self.just_left_tag = self.just_right_tag = None + pad = pad if pad is not None else p + self.expand_x = expand_x + self.expand_y = expand_y + self.rstrip = rstrip + self.wrap_lines = wrap_lines + self.reroute_stdout = reroute_stdout + self.reroute_stderr = reroute_stderr + self.no_scrollbar = no_scrollbar + self.hscrollbar = None # The horizontal scrollbar + self.auto_scroll_only_at_bottom = autoscroll_only_at_bottom + sz = size if size != (None, None) else s + + super().__init__(ELEM_TYPE_INPUT_MULTILINE, size=sz, auto_size_text=auto_size_text, background_color=bg, + text_color=fg, key=key, pad=pad, tooltip=tooltip, font=font or DEFAULT_FONT, visible=visible, metadata=metadata, + sbar_trough_color=sbar_trough_color, sbar_background_color=sbar_background_color, sbar_arrow_color=sbar_arrow_color, sbar_width=sbar_width, sbar_arrow_width=sbar_arrow_width, sbar_frame_color=sbar_frame_color, sbar_relief=sbar_relief) + return + + def update(self, value=None, disabled=None, append=False, font=None, text_color=None, background_color=None, text_color_for_value=None, + background_color_for_value=None, visible=None, autoscroll=None, justification=None, font_for_value=None): + """ + Changes some of the settings for the Multiline Element. Must call `Window.read` or set finalize=True when creating window. + + Changes will not be visible in your window until you call window.read or window.refresh. + + If you change visibility, your element may MOVE. If you want it to remain stationary, use the "layout helper" + function "pin" to ensure your element is "pinned" to that location in your layout so that it returns there + when made visible. + + :param value: new text to display + :type value: (Any) + :param disabled: disable or enable state of the element + :type disabled: (bool) + :param append: if True then new value will be added onto the end of the current value. if False then contents will be replaced. + :type append: (bool) + :param font: specifies the font family, size, etc. Tuple or Single string format 'name size styles'. Styles: italic * roman bold normal underline overstrike for the entire element + :type font: (str or (str, int[, str]) or None) + :param text_color: color of the text + :type text_color: (str) + :param background_color: color of background + :type background_color: (str) + :param text_color_for_value: color of the new text being added (the value paramter) + :type text_color_for_value: (str) + :param background_color_for_value: color of the new background of the text being added (the value paramter) + :type background_color_for_value: (str) + :param visible: set visibility state of the element + :type visible: (bool) + :param autoscroll: if True then contents of element are scrolled down when new text is added to the end + :type autoscroll: (bool) + :param justification: text justification. left, right, center. Can use single characters l, r, c. Sets only for this value, not entire element + :type justification: (str) + :param font_for_value: specifies the font family, size, etc. Tuple or Single string format 'name size styles'. Styles: italic * roman bold normal underline overstrike for the value being updated + :type font_for_value: str | (str, int) + """ + + if not self._widget_was_created(): # if widget hasn't been created yet, then don't allow + return + + if self._this_elements_window_closed(): + # _error_popup_with_traceback('Error in Multiline.update - The window was closed') + return + + + if autoscroll is not None: + self.Autoscroll = autoscroll + current_scroll_position = self.TKText.yview()[1] + + if justification is not None: + if justification.startswith('l'): + just_tag = 'left' + if justification.startswith('r'): + just_tag = 'right' + if justification.startswith('c'): + just_tag = 'center' + else: + just_tag = self.justification_tag + + starting_point = self.Widget.index(tk.INSERT) + tag = None + if value is not None: + value = str(value) + if background_color_for_value is not None or text_color_for_value is not None or font_for_value is not None: + try: + tag = 'Multiline(' + str(text_color_for_value) + ',' + str(background_color_for_value) + ',' + str(font_for_value) + ')' + if tag not in self.tags: + self.tags.add(tag) + if background_color_for_value is not None: + self.TKText.tag_configure(tag, background=background_color_for_value) + if text_color_for_value is not None: + self.TKText.tag_configure(tag, foreground=text_color_for_value) + if font_for_value is not None: + self.TKText.tag_configure(tag, font=font_for_value) + except Exception as e: + print('* Multiline.update - bad color likely specified:', e) + if self.Disabled: + self.TKText.configure(state='normal') + try: + if not append: + self.TKText.delete('1.0', tk.END) + if tag is not None or just_tag is not None: + self.TKText.insert(tk.END, value, (just_tag, tag)) + else: + self.TKText.insert(tk.END, value) + + # self.TKText.tag_add(just_tag, starting_point, starting_point) + + except Exception as e: + print("* Error setting multiline *", e) + if self.Disabled: + self.TKText.configure(state='disabled') + self.DefaultText = value + + # if self.Autoscroll: + # self.TKText.see(tk.END) + if self.Autoscroll: + if not self.auto_scroll_only_at_bottom or (self.auto_scroll_only_at_bottom and current_scroll_position == 1.0): + self.TKText.see(tk.END) + if disabled is True: + self.TKText.configure(state='disabled') + elif disabled is False: + self.TKText.configure(state='normal') + self.Disabled = disabled if disabled is not None else self.Disabled + + if background_color not in (None, COLOR_SYSTEM_DEFAULT): + self.TKText.configure(background=background_color) + if text_color not in (None, COLOR_SYSTEM_DEFAULT): + self.TKText.configure(fg=text_color) + if font is not None: + self.TKText.configure(font=font) + + + if visible is False: + self._pack_forget_save_settings(alternate_widget=self.element_frame) + # self.element_frame.pack_forget() + elif visible is True: + self._pack_restore_settings(alternate_widget=self.element_frame) + # self.element_frame.pack(padx=self.pad_used[0], pady=self.pad_used[1]) + + if self.AutoRefresh and self.ParentForm: + try: # in case the window was destroyed + self.ParentForm.refresh() + except: + pass + if visible is not None: + self._visible = visible + + def get(self): + """ + Return current contents of the Multiline Element + + :return: current contents of the Multiline Element (used as an input type of Multiline + :rtype: (str) + """ + value = str(self.TKText.get(1.0, tk.END)) + if self.rstrip: + return value.rstrip() + return value + + + def print(self, *args, end=None, sep=None, text_color=None, background_color=None, justification=None, font=None, colors=None, t=None, b=None, c=None, + autoscroll=True): + """ + Print like Python normally prints except route the output to a multiline element and also add colors if desired + + colors -(str, str) or str. A combined text/background color definition in a single parameter + + There are also "aliases" for text_color, background_color and colors (t, b, c) + t - An alias for color of the text (makes for shorter calls) + b - An alias for the background_color parameter + c - (str, str) - "shorthand" way of specifying color. (foreground, backgrouned) + c - str - can also be a string of the format "foreground on background" ("white on red") + + With the aliases it's possible to write the same print but in more compact ways: + cprint('This will print white text on red background', c=('white', 'red')) + cprint('This will print white text on red background', c='white on red') + cprint('This will print white text on red background', text_color='white', background_color='red') + cprint('This will print white text on red background', t='white', b='red') + + :param args: The arguments to print + :type args: (Any) + :param end: The end char to use just like print uses + :type end: (str) + :param sep: The separation character like print uses + :type sep: (str) + :param text_color: The color of the text + :type text_color: (str) + :param background_color: The background color of the line + :type background_color: (str) + :param justification: text justification. left, right, center. Can use single characters l, r, c. Sets only for this value, not entire element + :type justification: (str) + :param font: specifies the font family, size, etc. Tuple or Single string format 'name size styles'. Styles: italic * roman bold normal underline overstrike for the args being printed + :type font: (str or (str, int[, str]) or None) + :param colors: Either a tuple or a string that has both the text and background colors. Or just the text color + :type colors: (str) or (str, str) + :param t: Color of the text + :type t: (str) + :param b: The background color of the line + :type b: (str) + :param c: Either a tuple or a string that has both the text and background colors or just tex color (same as the color parm) + :type c: (str) or (str, str) + :param autoscroll: If True the contents of the element will automatically scroll as more data added to the end + :type autoscroll: (bool) + """ + + kw_text_color = text_color or t + kw_background_color = background_color or b + dual_color = colors or c + try: + if isinstance(dual_color, tuple): + kw_text_color = dual_color[0] + kw_background_color = dual_color[1] + elif isinstance(dual_color, str): + if ' on ' in dual_color: # if has "on" in the string, then have both text and background + kw_text_color = dual_color.split(' on ')[0] + kw_background_color = dual_color.split(' on ')[1] + else: # if no "on" then assume the color string is just the text color + kw_text_color = dual_color + except Exception as e: + print('* multiline print warning * you messed up with color formatting', e) + + _print_to_element(self, *args, end=end, sep=sep, text_color=kw_text_color, background_color=kw_background_color, justification=justification, + autoscroll=autoscroll, font=font) + + def reroute_stdout_to_here(self): + """ + Sends stdout (prints) to this element + """ + # if nothing on the stack, then need to save the very first stdout + if len(Window._rerouted_stdout_stack) == 0: + Window._original_stdout = sys.stdout + Window._rerouted_stdout_stack.insert(0, (self.ParentForm, self)) + sys.stdout = self + + def reroute_stderr_to_here(self): + """ + Sends stderr to this element + """ + if len(Window._rerouted_stderr_stack) == 0: + Window._original_stderr = sys.stderr + Window._rerouted_stderr_stack.insert(0, (self.ParentForm, self)) + sys.stderr = self + + def restore_stdout(self): + """ + Restore a previously re-reouted stdout back to the original destination + """ + Window._restore_stdout() + + def restore_stderr(self): + """ + Restore a previously re-reouted stderr back to the original destination + """ + Window._restore_stderr() + + def write(self, txt): + """ + Called by Python (not tkinter?) when stdout or stderr wants to write + + :param txt: text of output + :type txt: (str) + """ + try: + self.update(txt, append=True) + # if need to echo, then send the same text to the destinatoin that isn't thesame as this one + if self.echo_stdout_stderr: + if sys.stdout != self: + sys.stdout.write(txt) + elif sys.stderr != self: + sys.stderr.write(txt) + except: + pass + + def flush(self): + """ + Flush parameter was passed into a print statement. + For now doing nothing. Not sure what action should be taken to ensure a flush happens regardless. + """ + # try: + # self.previous_stdout.flush() + # except: + # pass + return + + + + + + def set_ibeam_color(self, ibeam_color=None): + """ + Sets the color of the I-Beam that is used to "insert" characters. This is oftens called a "Cursor" by + many users. To keep from being confused with tkinter's definition of cursor (the mouse pointer), the term + ibeam is used in this case. + :param ibeam_color: color to set the "I-Beam" used to indicate where characters will be inserted + :type ibeam_color: (str) + """ + + if not self._widget_was_created(): + return + if ibeam_color is not None: + try: + self.Widget.config(insertbackground=ibeam_color) + except Exception as e: + _error_popup_with_traceback('Error setting I-Beam color in set_ibeam_color', + 'The element has a key:', self.Key, + 'The color passed in was:', ibeam_color) + + + + def __del__(self): + """ + AT ONE TIME --- If this Widget is deleted, be sure and restore the old stdout, stderr + Now the restore is done differently. Do not want to RELY on Python to call this method + in order for stdout and stderr to be restored. Instead explicit restores are called. + + """ + + return + + + Get = get + Update = update + + +ML = Multiline +MLine = Multiline + + +# ---------------------------------------------------------------------- # +# Text # +# ---------------------------------------------------------------------- # +class Text(Element): + """ + Text - Display some text in the window. Usually this means a single line of text. However, the text can also be multiple lines. If multi-lined there are no scroll bars. + """ + + def __init__(self, text='', size=(None, None), s=(None, None), auto_size_text=None, click_submits=False, enable_events=False, relief=None, font=None, + text_color=None, background_color=None, border_width=None, justification=None, pad=None, p=None, key=None, k=None, right_click_menu=None, expand_x=False, expand_y=False, grab=None, + tooltip=None, visible=True, metadata=None): + """ + :param text: The text to display. Can include /n to achieve multiple lines. Will convert (optional) parameter into a string + :type text: Any + :param size: (w, h) w=characters-wide, h=rows-high. If an int instead of a tuple is supplied, then height is auto-set to 1 + :type size: (int, int) | (int, None) | (None, None) | (int, ) | int + :param s: Same as size parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, size will be used + :type s: (int, int) | (int, None) | (None, None) | (int, ) | int + :param auto_size_text: if True size of the Text Element will be sized to fit the string provided in 'text' parm + :type auto_size_text: (bool) + :param click_submits: DO NOT USE. Only listed for backwards compat - Use enable_events instead + :type click_submits: (bool) + :param enable_events: Turns on the element specific events. Text events happen when the text is clicked + :type enable_events: (bool) + :param relief: relief style around the text. Values are same as progress meter relief values. Should be a constant that is defined at starting with RELIEF - RELIEF_RAISED, RELIEF_SUNKEN, RELIEF_FLAT, RELIEF_RIDGE, RELIEF_GROOVE, RELIEF_SOLID + :type relief: (str) + :param font: specifies the font family, size, etc. Tuple or Single string format 'name size styles'. Styles: italic * roman bold normal underline overstrike + :type font: (str or (str, int[, str]) or None) + :param text_color: color of the text + :type text_color: (str) + :param background_color: color of background + :type background_color: (str) + :param border_width: number of pixels for the border (if using a relief) + :type border_width: (int) + :param justification: how string should be aligned within space provided by size. Valid choices = `left`, `right`, `center` + :type justification: (str) + :param pad: Amount of padding to put around element in pixels (left/right, top/bottom) or ((left, right), (top, bottom)) or an int. If an int, then it's converted into a tuple (int, int) + :type pad: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param p: Same as pad parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, pad will be used + :type p: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param key: Used with window.find_element and with return values to uniquely identify this element to uniquely identify this element + :type key: str or int or tuple or object + :param k: Same as the Key. You can use either k or key. Which ever is set will be used. + :type k: str | int | tuple | object + :param right_click_menu: A list of lists of Menu items to show when this element is right clicked. See user docs for exact format. + :type right_click_menu: List[List[ List[str] | str ]] + :param expand_x: If True the element will automatically expand in the X direction to fill available space + :type expand_x: (bool) + :param expand_y: If True the element will automatically expand in the Y direction to fill available space + :type expand_y: (bool) + :param grab: If True can grab this element and move the window around. Default is False + :type grab: (bool) + :param tooltip: text, that will appear when mouse hovers over the element + :type tooltip: (str) + :param visible: set visibility state of the element + :type visible: (bool) + :param metadata: User metadata that can be set to ANYTHING + :type metadata: (Any) + """ + + self.DisplayText = str(text) + self.TextColor = text_color if text_color else DEFAULT_TEXT_COLOR + self.Justification = justification + self.Relief = relief + self.ClickSubmits = click_submits or enable_events + if background_color is None: + bg = DEFAULT_TEXT_ELEMENT_BACKGROUND_COLOR + else: + bg = background_color + self.RightClickMenu = right_click_menu + self.TKRightClickMenu = None + self.BorderWidth = border_width + self.Grab = grab + key = key if key is not None else k + sz = size if size != (None, None) else s + pad = pad if pad is not None else p + self.expand_x = expand_x + self.expand_y = expand_y + + super().__init__(ELEM_TYPE_TEXT, auto_size_text=auto_size_text, size=sz, background_color=bg, font=font if font else DEFAULT_FONT, + text_color=self.TextColor, pad=pad, key=key, tooltip=tooltip, visible=visible, metadata=metadata) + + def update(self, value=None, background_color=None, text_color=None, font=None, visible=None): + """ + Changes some of the settings for the Text Element. Must call `Window.Read` or `Window.Finalize` prior + + Changes will not be visible in your window until you call window.read or window.refresh. + + If you change visibility, your element may MOVE. If you want it to remain stationary, use the "layout helper" + function "pin" to ensure your element is "pinned" to that location in your layout so that it returns there + when made visible. + + :param value: new text to show + :type value: (Any) + :param background_color: color of background + :type background_color: (str) + :param text_color: color of the text + :type text_color: (str) + :param font: specifies the font family, size, etc. Tuple or Single string format 'name size styles'. Styles: italic * roman bold normal underline overstrike + :type font: (str or (str, int[, str]) or None) + :param visible: set visibility state of the element + :type visible: (bool) + """ + + if not self._widget_was_created(): # if widget hasn't been created yet, then don't allow + return + + if self._this_elements_window_closed(): + _error_popup_with_traceback('Error in Text.update - The window was closed') + return + + if value is not None: + self.DisplayText = str(value) + self.TKStringVar.set(str(value)) + if background_color not in (None, COLOR_SYSTEM_DEFAULT): + self.TKText.configure(background=background_color) + if text_color not in (None, COLOR_SYSTEM_DEFAULT): + self.TKText.configure(fg=text_color) + if font is not None: + self.TKText.configure(font=font) + if visible is False: + self._pack_forget_save_settings() + # self.TKText.pack_forget() + elif visible is True: + self._pack_restore_settings() + # self.TKText.pack(padx=self.pad_used[0], pady=self.pad_used[1]) + if visible is not None: + self._visible = visible + + def get(self): + """ + Gets the current value of the displayed text + + :return: The current value + :rtype: (str) + """ + try: + text = self.TKStringVar.get() + except: + text = '' + return text + + + @classmethod + def fonts_installed_list(cls): + """ + Returns a list of strings that tkinter reports as the installed fonts + + :return: List of the installed font names + :rtype: List[str] + """ + # A window must exist before can perform this operation. Create the hidden master root if it doesn't exist + _get_hidden_master_root() + + fonts = list(tkinter.font.families()) + fonts.sort() + + return fonts + + + @classmethod + def char_width_in_pixels(cls, font, character='W'): + """ + Get the with of the character "W" in pixels for the font being passed in or + the character of your choosing if "W" is not a good representative character. + Cannot be used until a window has been created. + If an error occurs, 0 will be returned + :param font: specifies the font family, size, etc. Tuple or Single string format 'name size styles'. Styles: italic * roman bold normal underline overstrike, to be measured + :type font: (str or (str, int[, str]) or None) + :param character: specifies a SINGLE CHARACTER character to measure + :type character: (str) + :return: Width in pixels of "A" + :rtype: (int) + """ + # A window must exist before can perform this operation. Create the hidden master root if it doesn't exist + _get_hidden_master_root() + + size = 0 + try: + size = tkinter.font.Font(font=font).measure(character) # single character width + except Exception as e: + _error_popup_with_traceback('Exception retrieving char width in pixels', e) + + return size + + @classmethod + def char_height_in_pixels(cls, font): + """ + Get the height of a string if using the supplied font in pixels. + Cannot be used until a window has been created. + If an error occurs, 0 will be returned + :param font: specifies the font family, size, etc. Tuple or Single string format 'name size styles'. Styles: italic * roman bold normal underline overstrike, to be measured + :type font: (str or (str, int[, str]) or None) + :return: Height in pixels of "A" + :rtype: (int) + """ + + # A window must exist before can perform this operation. Create the hidden master root if it doesn't exist + _get_hidden_master_root() + + + size = 0 + try: + size = tkinter.font.Font(font=font).metrics('linespace') + except Exception as e: + _error_popup_with_traceback('Exception retrieving char height in pixels', e) + + return size + + @classmethod + def string_width_in_pixels(cls, font, string): + """ + Get the with of the supplied string in pixels for the font being passed in. + If an error occurs, 0 will be returned + :param font: specifies the font family, size, etc. Tuple or Single string format 'name size styles'. Styles: italic * roman bold normal underline overstrike, to be measured + :type font: (str or (str, int[, str]) or None) + :param string: the string to measure + :type string: str + :return: Width in pixels of string + :rtype: (int) + """ + + # A window must exist before can perform this operation. Create the hidden master root if it doesn't exist + _get_hidden_master_root() + + size = 0 + try: + size = tkinter.font.Font(font=font).measure(string) # string's width + except Exception as e: + _error_popup_with_traceback('Exception retrieving string width in pixels', e) + + return size + + def _print_to_element(self, *args, end=None, sep=None, text_color=None, background_color=None, autoscroll=None, justification=None, font=None, append=None): + """ + Print like Python normally prints except route the output to a multiline element and also add colors if desired + + :param multiline_element: The multiline element to be output to + :type multiline_element: (Multiline) + :param args: The arguments to print + :type args: List[Any] + :param end: The end char to use just like print uses + :type end: (str) + :param sep: The separation character like print uses + :type sep: (str) + :param text_color: color of the text + :type text_color: (str) + :param background_color: The background color of the line + :type background_color: (str) + :param autoscroll: If True (the default), the element will scroll to bottom after updating + :type autoscroll: (bool) + :param font: specifies the font family, size, etc. Tuple or Single string format 'name size styles'. Styles: italic * roman bold normal underline overstrike for the value being updated + :type font: str | (str, int) + """ + end_str = str(end) if end is not None else '\n' + sep_str = str(sep) if sep is not None else ' ' + + outstring = '' + num_args = len(args) + for i, arg in enumerate(args): + outstring += str(arg) + if i != num_args - 1: + outstring += sep_str + outstring += end_str + if append: + outstring = self.get() + outstring + + self.update(outstring, text_color=text_color, background_color=background_color, font=font) + + try: # if the element is set to autorefresh, then refresh the parent window + if self.AutoRefresh: + self.ParentForm.refresh() + except: + pass + + def print(self, *args, end=None, sep=None, text_color=None, background_color=None, justification=None, font=None, colors=None, t=None, b=None, c=None, autoscroll=True, append=True): + """ + Print like Python normally prints except route the output to a multiline element and also add colors if desired + + colors -(str, str) or str. A combined text/background color definition in a single parameter + + There are also "aliases" for text_color, background_color and colors (t, b, c) + t - An alias for color of the text (makes for shorter calls) + b - An alias for the background_color parameter + c - (str, str) - "shorthand" way of specifying color. (foreground, backgrouned) + c - str - can also be a string of the format "foreground on background" ("white on red") + + With the aliases it's possible to write the same print but in more compact ways: + cprint('This will print white text on red background', c=('white', 'red')) + cprint('This will print white text on red background', c='white on red') + cprint('This will print white text on red background', text_color='white', background_color='red') + cprint('This will print white text on red background', t='white', b='red') + + :param args: The arguments to print + :type args: (Any) + :param end: The end char to use just like print uses + :type end: (str) + :param sep: The separation character like print uses + :type sep: (str) + :param text_color: The color of the text + :type text_color: (str) + :param background_color: The background color of the line + :type background_color: (str) + :param justification: text justification. left, right, center. Can use single characters l, r, c. Sets only for this value, not entire element + :type justification: (str) + :param font: specifies the font family, size, etc. Tuple or Single string format 'name size styles'. Styles: italic * roman bold normal underline overstrike for the args being printed + :type font: (str or (str, int[, str]) or None) + :param colors: Either a tuple or a string that has both the text and background colors. Or just the text color + :type colors: (str) or (str, str) + :param t: Color of the text + :type t: (str) + :param b: The background color of the line + :type b: (str) + :param c: Either a tuple or a string that has both the text and background colors or just tex color (same as the color parm) + :type c: (str) or (str, str) + :param autoscroll: If True the contents of the element will automatically scroll as more data added to the end + :type autoscroll: (bool) + """ + + kw_text_color = text_color or t + kw_background_color = background_color or b + dual_color = colors or c + try: + if isinstance(dual_color, tuple): + kw_text_color = dual_color[0] + kw_background_color = dual_color[1] + elif isinstance(dual_color, str): + if ' on ' in dual_color: # if has "on" in the string, then have both text and background + kw_text_color = dual_color.split(' on ')[0] + kw_background_color = dual_color.split(' on ')[1] + else: # if no "on" then assume the color string is just the text color + kw_text_color = dual_color + except Exception as e: + print('* multiline print warning * you messed up with color formatting', e) + + self._print_to_element( *args, end=end, sep=sep, text_color=kw_text_color, background_color=kw_background_color, justification=justification, autoscroll=autoscroll, font=font, append=append) + + + Get = get + Update = update + + +# ------------------------- Text Element lazy functions ------------------------- # + +Txt = Text # type: Text +T = Text # type: Text + + +# ---------------------------------------------------------------------- # +# StatusBar # +# ---------------------------------------------------------------------- # +class StatusBar(Element): + """ + A StatusBar Element creates the sunken text-filled strip at the bottom. Many Windows programs have this line + """ + + def __init__(self, text, size=(None, None), s=(None, None), auto_size_text=None, click_submits=None, enable_events=False, + relief=RELIEF_SUNKEN, font=None, text_color=None, background_color=None, justification=None, pad=None, p=None, + key=None, k=None, right_click_menu=None, expand_x=False, expand_y=False, tooltip=None, visible=True, metadata=None): + """ + :param text: Text that is to be displayed in the widget + :type text: (str) + :param size: (w, h) w=characters-wide, h=rows-high. If an int instead of a tuple is supplied, then height is auto-set to 1 + :type size: (int, int) | (int, None) | int + :param s: Same as size parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, size will be used + :type s: (int, int) | (None, None) | int + :param auto_size_text: True if size should fit the text length + :type auto_size_text: (bool) + :param click_submits: DO NOT USE. Only listed for backwards compat - Use enable_events instead + :type click_submits: (bool) + :param enable_events: Turns on the element specific events. StatusBar events occur when the bar is clicked + :type enable_events: (bool) + :param relief: relief style. Values are same as progress meter relief values. Can be a constant or a string: `RELIEF_RAISED RELIEF_SUNKEN RELIEF_FLAT RELIEF_RIDGE RELIEF_GROOVE RELIEF_SOLID` + :type relief: (enum) + :param font: specifies the font family, size, etc. Tuple or Single string format 'name size styles'. Styles: italic * roman bold normal underline overstrike + :type font: (str or (str, int[, str]) or None) + :param text_color: color of the text + :type text_color: (str) + :param background_color: color of background + :type background_color: (str) + :param justification: how string should be aligned within space provided by size. Valid choices = `left`, `right`, `center` + :type justification: (str) + :param pad: Amount of padding to put around element in pixels (left/right, top/bottom) or ((left, right), (top, bottom)) or an int. If an int, then it's converted into a tuple (int, int) + :type pad: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param p: Same as pad parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, pad will be used + :type p: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param key: Used with window.find_element and with return values to uniquely identify this element to uniquely identify this element + :type key: str | int | tuple | object + :param k: Same as the Key. You can use either k or key. Which ever is set will be used. + :type k: str | int | tuple | object + :param right_click_menu: A list of lists of Menu items to show when this element is right clicked. See user docs for exact format. + :type right_click_menu: List[List[ List[str] | str ]] + :param expand_x: If True the element will automatically expand in the X direction to fill available space + :type expand_x: (bool) + :param expand_y: If True the element will automatically expand in the Y direction to fill available space + :type expand_y: (bool) + :param tooltip: text, that will appear when mouse hovers over the element + :type tooltip: (str) + :param visible: set visibility state of the element + :type visible: (bool) + :param metadata: User metadata that can be set to ANYTHING + :type metadata: (Any) + """ + + + self.DisplayText = text + self.TextColor = text_color if text_color else DEFAULT_TEXT_COLOR + self.Justification = justification + self.Relief = relief + self.ClickSubmits = click_submits or enable_events + if background_color is None: + bg = DEFAULT_TEXT_ELEMENT_BACKGROUND_COLOR + else: + bg = background_color + self.TKText = self.Widget = None # type: tk.Label + key = key if key is not None else k + self.RightClickMenu = right_click_menu + sz = size if size != (None, None) else s + pad = pad if pad is not None else p + self.expand_x = expand_x + self.expand_y = expand_y + + super().__init__(ELEM_TYPE_STATUSBAR, size=sz, auto_size_text=auto_size_text, background_color=bg, + font=font or DEFAULT_FONT, text_color=self.TextColor, pad=pad, key=key, tooltip=tooltip, + visible=visible, metadata=metadata) + return + + def update(self, value=None, background_color=None, text_color=None, font=None, visible=None): + """ + Changes some of the settings for the Status Bar Element. Must call `Window.Read` or `Window.Finalize` prior + + Changes will not be visible in your window until you call window.read or window.refresh. + + If you change visibility, your element may MOVE. If you want it to remain stationary, use the "layout helper" + function "pin" to ensure your element is "pinned" to that location in your layout so that it returns there + when made visible. + + :param value: new text to show + :type value: (str) + :param background_color: color of background + :type background_color: (str) + :param text_color: color of the text + :type text_color: (str) + :param font: specifies the font family, size, etc. Tuple or Single string format 'name size styles'. Styles: italic * roman bold normal underline overstrike + :type font: (str or (str, int[, str]) or None) + :param visible: set visibility state of the element + :type visible: (bool) + """ + + if not self._widget_was_created(): # if widget hasn't been created yet, then don't allow + return + + if self._this_elements_window_closed(): + _error_popup_with_traceback('Error in StatusBar.update - The window was closed') + return + + if value is not None: + self.DisplayText = value + stringvar = self.TKStringVar + stringvar.set(value) + if background_color not in (None, COLOR_SYSTEM_DEFAULT): + self.TKText.configure(background=background_color) + if text_color not in (None, COLOR_SYSTEM_DEFAULT): + self.TKText.configure(fg=text_color) + if font is not None: + self.TKText.configure(font=font) + if visible is False: + self._pack_forget_save_settings() + # self.TKText.pack_forget() + elif visible is True: + self._pack_restore_settings() + # self.TKText.pack(padx=self.pad_used[0], pady=self.pad_used[1]) + if visible is not None: + self._visible = visible + + Update = update + + +SBar = StatusBar + + +# ---------------------------------------------------------------------- # +# TKProgressBar # +# Emulate the TK ProgressBar using canvas and rectangles +# ---------------------------------------------------------------------- # + +class TKProgressBar(): + uniqueness_counter = 0 + + def __init__(self, root, max, length=400, width=DEFAULT_PROGRESS_BAR_SIZE[1], ttk_theme=DEFAULT_TTK_THEME, style_name='', + relief=DEFAULT_PROGRESS_BAR_RELIEF, border_width=DEFAULT_PROGRESS_BAR_BORDER_WIDTH, + orientation='horizontal', BarColor=(None, None), key=None): + """ + :param root: The root window bar is to be shown in + :type root: tk.Tk | tk.TopLevel + :param max: Maximum value the bar will be measuring + :type max: (int) + :param length: length in pixels of the bar + :type length: (int) + :param width: width in pixels of the bar + :type width: (int) + :param style_name: Progress bar style to use. Set in the packer function + :type style_name: (str) + :param ttk_theme: Progress bar style defined as one of these 'default', 'winnative', 'clam', 'alt', 'classic', 'vista', 'xpnative' + :type ttk_theme: (str) + :param relief: relief style. Values are same as progress meter relief values. Can be a constant or a string: `RELIEF_RAISED RELIEF_SUNKEN RELIEF_FLAT RELIEF_RIDGE RELIEF_GROOVE RELIEF_SOLID` (Default value = DEFAULT_PROGRESS_BAR_RELIEF) + :type relief: (str) + :param border_width: The amount of pixels that go around the outside of the bar + :type border_width: (int) + :param orientation: 'horizontal' or 'vertical' ('h' or 'v' work) (Default value = 'vertical') + :type orientation: (str) + :param BarColor: The 2 colors that make up a progress bar. One is the background, the other is the bar + :type BarColor: (str, str) + :param key: Used with window.find_element and with return values to uniquely identify this element to uniquely identify this element + :type key: str | int | tuple | object + """ + + self.Length = length + self.Width = width + self.Max = max + self.Orientation = orientation + self.Count = None + self.PriorCount = 0 + self.style_name = style_name + + TKProgressBar.uniqueness_counter += 1 + + if orientation.lower().startswith('h'): + s = ttk.Style() + _change_ttk_theme(s, ttk_theme) + + # self.style_name = str(key) + str(TKProgressBar.uniqueness_counter) + "my.Horizontal.TProgressbar" + if BarColor != COLOR_SYSTEM_DEFAULT and BarColor[0] != COLOR_SYSTEM_DEFAULT: + s.configure(self.style_name, background=BarColor[0], troughcolor=BarColor[1], + troughrelief=relief, borderwidth=border_width, thickness=width) + else: + s.configure(self.style_name, troughrelief=relief, borderwidth=border_width, thickness=width) + + self.TKProgressBarForReal = ttk.Progressbar(root, maximum=self.Max, style=self.style_name, length=length, orient=tk.HORIZONTAL, mode='determinate') + else: + s = ttk.Style() + _change_ttk_theme(s, ttk_theme) + # self.style_name = str(key) + str(TKProgressBar.uniqueness_counter) + "my.Vertical.TProgressbar" + if BarColor != COLOR_SYSTEM_DEFAULT and BarColor[0] != COLOR_SYSTEM_DEFAULT: + + s.configure(self.style_name, background=BarColor[0], + troughcolor=BarColor[1], troughrelief=relief, borderwidth=border_width, thickness=width) + else: + s.configure(self.style_name, troughrelief=relief, borderwidth=border_width, thickness=width) + + self.TKProgressBarForReal = ttk.Progressbar(root, maximum=self.Max, style=self.style_name, length=length, orient=tk.VERTICAL, mode='determinate') + + def Update(self, count=None, max=None): + """ + Update the current value of the bar and/or update the maximum value the bar can reach + :param count: current value + :type count: (int) + :param max: the maximum value + :type max: (int) + """ + if max is not None: + self.Max = max + try: + self.TKProgressBarForReal.config(maximum=max) + except: + return False + if count is not None: + try: + self.TKProgressBarForReal['value'] = count + except: + return False + return True + + +# ---------------------------------------------------------------------- # +# Output # +# Routes stdout, stderr to a scrolled window # +# ---------------------------------------------------------------------- # +class Output(Multiline): + """ + Output Element - a multi-lined text area to where stdout, stderr, cprint are rerouted. + + The Output Element is now based on the Multiline Element. When you make an Output Element, you're + creating a Multiline Element with some specific settings set: + auto_refresh = True + auto_scroll = True + reroute_stdout = True + reroute_stderr = True + reroute_cprint = True + write_only = True + + If you choose to use a Multiline element to replace an Output element, be sure an turn on the write_only paramter in the Multiline + so that an item is not included in the values dictionary on every window.read call + """ + + def __init__(self, size=(None, None), s=(None, None), background_color=None, text_color=None, pad=None, p=None, autoscroll_only_at_bottom=False, echo_stdout_stderr=False, font=None, tooltip=None, + key=None, k=None, right_click_menu=None, expand_x=False, expand_y=False, visible=True, metadata=None, wrap_lines=None, horizontal_scroll=None, + sbar_trough_color=None, sbar_background_color=None, sbar_arrow_color=None, sbar_width=None, sbar_arrow_width=None, sbar_frame_color=None, sbar_relief=None): + """ + :param size: (w, h) w=characters-wide, h=rows-high. If an int instead of a tuple is supplied, then height is auto-set to 1 + :type size: (int, int) | (None, None) | int + :param s: Same as size parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, size will be used + :type s: (int, int) | (None, None) | int + :param background_color: color of background + :type background_color: (str) + :param text_color: color of the text + :type text_color: (str) + :param pad: Amount of padding to put around element in pixels (left/right, top/bottom) or ((left, right), (top, bottom)) or an int. If an int, then it's converted into a tuple (int, int) + :type pad: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param p: Same as pad parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, pad will be used + :type p: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param autoscroll_only_at_bottom: If True the contents of the element will automatically scroll only if the scrollbar is at the bottom of the multiline + :type autoscroll_only_at_bottom: (bool) + :param echo_stdout_stderr: If True then output to stdout will be output to this element AND also to the normal console location + :type echo_stdout_stderr: (bool) + :param font: specifies the font family, size, etc. Tuple or Single string format 'name size styles'. Styles: italic * roman bold normal underline overstrike + :type font: (str or (str, int[, str]) or None) + :param tooltip: text, that will appear when mouse hovers over the element + :type tooltip: (str) + :param key: Used with window.find_element and with return values to uniquely identify this element to uniquely identify this element + :type key: str | int | tuple | object + :param k: Same as the Key. You can use either k or key. Which ever is set will be used. + :type k: str | int | tuple | object + :param right_click_menu: A list of lists of Menu items to show when this element is right clicked. See user docs for exact format. + :type right_click_menu: List[List[ List[str] | str ]] + :param expand_x: If True the element will automatically expand in the X direction to fill available space + :type expand_x: (bool) + :param expand_y: If True the element will automatically expand in the Y direction to fill available space + :type expand_y: (bool) + :param visible: set visibility state of the element + :type visible: (bool) + :param metadata: User metadata that can be set to ANYTHING + :type metadata: (Any) + :param wrap_lines: If True, the lines will be wrapped automatically. Other parms affect this setting, but this one will override them all. Default is it does nothing and uses previous settings for wrapping. + :type wrap_lines: (bool) + :param horizontal_scroll: Controls if a horizontal scrollbar should be shown. If True, then line wrapping will be off by default + :type horizontal_scroll: (bool) + :param sbar_trough_color: Scrollbar color of the trough + :type sbar_trough_color: (str) + :param sbar_background_color: Scrollbar color of the background of the arrow buttons at the ends AND the color of the "thumb" (the thing you grab and slide). Switches to arrow color when mouse is over + :type sbar_background_color: (str) + :param sbar_arrow_color: Scrollbar color of the arrow at the ends of the scrollbar (it looks like a button). Switches to background color when mouse is over + :type sbar_arrow_color: (str) + :param sbar_width: Scrollbar width in pixels + :type sbar_width: (int) + :param sbar_arrow_width: Scrollbar width of the arrow on the scrollbar. It will potentially impact the overall width of the scrollbar + :type sbar_arrow_width: (int) + :param sbar_frame_color: Scrollbar Color of frame around scrollbar (available only on some ttk themes) + :type sbar_frame_color: (str) + :param sbar_relief: Scrollbar relief that will be used for the "thumb" of the scrollbar (the thing you grab that slides). Should be a constant that is defined at starting with "RELIEF_" - RELIEF_RAISED, RELIEF_SUNKEN, RELIEF_FLAT, RELIEF_RIDGE, RELIEF_GROOVE, RELIEF_SOLID + :type sbar_relief: (str) + """ + + + super().__init__(size=size, s=s, background_color=background_color, autoscroll_only_at_bottom=autoscroll_only_at_bottom, text_color=text_color, pad=pad, p=p, echo_stdout_stderr=echo_stdout_stderr, font=font, tooltip=tooltip, wrap_lines=wrap_lines, horizontal_scroll=horizontal_scroll, key=key, k=k, right_click_menu=right_click_menu, write_only=True, reroute_stdout=True, reroute_stderr=True, reroute_cprint=True, autoscroll=True, auto_refresh=True, expand_x=expand_x, expand_y=expand_y, visible=visible, metadata=metadata, sbar_trough_color=sbar_trough_color, sbar_background_color=sbar_background_color, sbar_arrow_color=sbar_arrow_color, sbar_width=sbar_width, sbar_arrow_width=sbar_arrow_width, sbar_frame_color=sbar_frame_color, sbar_relief=sbar_relief) + + + +# ---------------------------------------------------------------------- # +# Button Class # +# ---------------------------------------------------------------------- # +class Button(Element): + """ + Button Element - Defines all possible buttons. The shortcuts such as Submit, FileBrowse, ... each create a Button + """ + + def __init__(self, button_text='', button_type=BUTTON_TYPE_READ_FORM, target=(None, None), tooltip=None, + file_types=FILE_TYPES_ALL_FILES, initial_folder=None, default_extension='', disabled=False, change_submits=False, + enable_events=False, image_filename=None, image_data=None, image_size=(None, None), + image_subsample=None, image_zoom=None, image_source=None, border_width=None, size=(None, None), s=(None, None), auto_size_button=None, button_color=None, + disabled_button_color=None, + highlight_colors=None, mouseover_colors=(None, None), use_ttk_buttons=None, font=None, bind_return_key=False, focus=False, pad=None, p=None, key=None, + k=None, right_click_menu=None, expand_x=False, expand_y=False, visible=True, metadata=None): + """ + :param button_text: Text to be displayed on the button + :type button_text: (str) + :param button_type: You should NOT be setting this directly. ONLY the shortcut functions set this + :type button_type: (int) + :param target: key or (row,col) target for the button. Note that -1 for column means 1 element to the left of this one. The constant ThisRow is used to indicate the current row. The Button itself is a valid target for some types of button + :type target: str | (int, int) + :param tooltip: text, that will appear when mouse hovers over the element + :type tooltip: (str) + :param file_types: the filetypes that will be used to match files. To indicate all files: (("ALL Files", "*.* *"),). + :type file_types: Tuple[(str, str), ...] + :param initial_folder: starting path for folders and files + :type initial_folder: (str) + :param default_extension: If no extension entered by user, add this to filename (only used in saveas dialogs) + :type default_extension: (str) + :param disabled: If True button will be created disabled. If BUTTON_DISABLED_MEANS_IGNORE then the button will be ignored rather than disabled using tkinter + :type disabled: (bool | str) + :param change_submits: DO NOT USE. Only listed for backwards compat - Use enable_events instead + :type change_submits: (bool) + :param enable_events: Turns on the element specific events. If this button is a target, should it generate an event when filled in + :type enable_events: (bool) + :param image_source: Image to place on button. Use INSTEAD of the image_filename and image_data. Unifies these into 1 easier to use parm + :type image_source: (str | bytes) + :param image_filename: image filename if there is a button image. GIFs and PNGs only. + :type image_filename: (str) + :param image_data: Raw or Base64 representation of the image to put on button. Choose either filename or data + :type image_data: bytes | str + :param image_size: Size of the image in pixels (width, height) + :type image_size: (int, int) + :param image_subsample: amount to reduce the size of the image. Divides the size by this number. 2=1/2, 3=1/3, 4=1/4, etc + :type image_subsample: (int) + :param image_zoom: amount to increase the size of the image. 2=twice size, 3=3 times, etc + :type image_zoom: (int) + :param border_width: width of border around button in pixels + :type border_width: (int) + :param size: (w, h) w=characters-wide, h=rows-high. If an int instead of a tuple is supplied, then height is auto-set to 1 + :type size: (int | None, int | None) | (None, None) | int + :param s: Same as size parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, size will be used + :type s: (int | None, int | None) | (None, None) | int + :param auto_size_button: if True the button size is sized to fit the text + :type auto_size_button: (bool) + :param button_color: Color of button. default is from theme or the window. Easy to remember which is which if you say "ON" between colors. "red" on "green". Normally a tuple, but can be a simplified-button-color-string "foreground on background". Can be a single color if want to set only the background. + :type button_color: (str, str) | str + :param disabled_button_color: colors to use when button is disabled (text, background). Use None for a color if don't want to change. Only ttk buttons support both text and background colors. tk buttons only support changing text color + :type disabled_button_color: (str, str) | str + :param highlight_colors: colors to use when button has focus (has focus, does not have focus). None will use colors based on theme. Only used by Linux and only for non-TTK button + :type highlight_colors: (str, str) + :param mouseover_colors: Important difference between Linux & Windows! Linux - Colors when mouse moved over button. Windows - colors when button is pressed. The default is to switch the text and background colors (an inverse effect) + :type mouseover_colors: (str, str) | str + :param use_ttk_buttons: True = use ttk buttons. False = do not use ttk buttons. None (Default) = use ttk buttons only if on a Mac and not with button images + :type use_ttk_buttons: (bool) + :param font: specifies the font family, size, etc. Tuple or Single string format 'name size styles'. Styles: italic * roman bold normal underline overstrike + :type font: (str or (str, int[, str]) or None) + :param bind_return_key: If True then pressing the return key in an Input or Multiline Element will cause this button to appear to be clicked (generates event with this button's key + :type bind_return_key: (bool) + :param focus: if True, initial focus will be put on this button + :type focus: (bool) + :param pad: Amount of padding to put around element in pixels (left/right, top/bottom) or ((left, right), (top, bottom)) or an int. If an int, then it's converted into a tuple (int, int) + :type pad: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param p: Same as pad parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, pad will be used + :type p: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param key: Used with window.find_element and with return values to uniquely identify this element to uniquely identify this element + :type key: str | int | tuple | object + :param k: Same as the Key. You can use either k or key. Which ever is set will be used. + :type k: str | int | tuple | object + :param right_click_menu: A list of lists of Menu items to show when this element is right clicked. See user docs for exact format. + :type right_click_menu: List[List[ List[str] | str ]] + :param expand_x: If True the element will automatically expand in the X direction to fill available space + :type expand_x: (bool) + :param expand_y: If True the element will automatically expand in the Y direction to fill available space + :type expand_y: (bool) + :param visible: set visibility state of the element + :type visible: (bool) + :param metadata: User metadata that can be set to ANYTHING + :type metadata: (Any) + """ + + + self.AutoSizeButton = auto_size_button + self.BType = button_type + if file_types is not None and len(file_types) == 2 and isinstance(file_types[0], str) and isinstance(file_types[1], str): + warnings.warn('file_types parameter not correctly specified. This parameter is a LIST of TUPLES. You have passed (str,str) rather than ((str, str),). Fixing it for you this time.\nchanging {} to {}\nPlease correct your code'.format(file_types, ((file_types[0], file_types[1]),)), UserWarning) + file_types = ((file_types[0], file_types[1]),) + self.FileTypes = file_types + self.Widget = self.TKButton = None # type: tk.Button + self.Target = target + self.ButtonText = str(button_text) + self.RightClickMenu = right_click_menu + # Button colors can be a tuple (text, background) or a string with format "text on background" + # bc = button_color + # if button_color is None: + # bc = DEFAULT_BUTTON_COLOR + # else: + # try: + # if isinstance(button_color,str): + # bc = button_color.split(' on ') + # except Exception as e: + # print('* cprint warning * you messed up with color formatting', e) + # if bc[1] is None: + # bc = (bc[0], theme_button_color()[1]) + # self.ButtonColor = bc + self.ButtonColor = button_color_to_tuple(button_color) + + # experimental code to compute disabled button text color + # if disabled_button_color is None: + # try: + # disabled_button_color = (get_complimentary_hex(theme_button_color()[0]), theme_button_color()[1]) + # # disabled_button_color = disabled_button_color + # except: + # print('* Problem computing disabled button color *') + self.DisabledButtonColor = button_color_to_tuple(disabled_button_color) if disabled_button_color is not None else (None, None) + if image_source is not None: + if isinstance(image_source, bytes): + image_data = image_source + elif isinstance(image_source, str): + image_filename = image_source + self.ImageFilename = image_filename + self.ImageData = image_data + self.ImageSize = image_size + self.ImageSubsample = image_subsample + self.zoom = int(image_zoom) if image_zoom is not None else None + self.UserData = None + self.BorderWidth = border_width if border_width is not None else DEFAULT_BORDER_WIDTH + self.BindReturnKey = bind_return_key + self.Focus = focus + self.TKCal = None + self.calendar_default_date_M_D_Y = (None, None, None) + self.calendar_close_when_chosen = False + self.calendar_locale = None + self.calendar_format = None + self.calendar_location = (None, None) + self.calendar_no_titlebar = True + self.calendar_begin_at_sunday_plus = 0 + self.calendar_month_names = None + self.calendar_day_abbreviations = None + self.calendar_title = '' + self.calendar_selection = '' + self.default_button = None + self.InitialFolder = initial_folder + self.DefaultExtension = default_extension + self.Disabled = disabled + self.ChangeSubmits = change_submits or enable_events + self.UseTtkButtons = use_ttk_buttons + self._files_delimiter = BROWSE_FILES_DELIMITER # used by the file browse button. used when multiple files are selected by user + if use_ttk_buttons is None and running_mac(): + self.UseTtkButtons = True + # if image_filename or image_data: + # self.UseTtkButtons = False # if an image is to be displayed, then force the button to not be a TTK Button + if key is None and k is None: + _key = self.ButtonText + if DEFAULT_USE_BUTTON_SHORTCUTS is True: + pos = _key.find(MENU_SHORTCUT_CHARACTER) + if pos != -1: + if pos < len(MENU_SHORTCUT_CHARACTER) or _key[pos - len(MENU_SHORTCUT_CHARACTER)] != "\\": + _key = _key[:pos] + _key[pos + len(MENU_SHORTCUT_CHARACTER):] + else: + _key = _key.replace('\\'+MENU_SHORTCUT_CHARACTER, MENU_SHORTCUT_CHARACTER) + else: + _key = key if key is not None else k + if highlight_colors is not None: + self.HighlightColors = highlight_colors + else: + self.HighlightColors = self._compute_highlight_colors() + + if mouseover_colors != (None, None): + self.MouseOverColors = button_color_to_tuple(mouseover_colors) + elif button_color != None: + self.MouseOverColors = (self.ButtonColor[1], self.ButtonColor[0]) + else: + self.MouseOverColors = (theme_button_color()[1], theme_button_color()[0]) + pad = pad if pad is not None else p + self.expand_x = expand_x + self.expand_y = expand_y + + sz = size if size != (None, None) else s + super().__init__(ELEM_TYPE_BUTTON, size=sz, font=font, pad=pad, key=_key, tooltip=tooltip, visible=visible, metadata=metadata) + return + + def _compute_highlight_colors(self): + """ + Determines the color to use to indicate the button has focus. This setting is only used by Linux. + :return: Pair of colors. (Highlight, Highlight Background) + :rtype: (str, str) + """ + highlight_color = highlight_background = COLOR_SYSTEM_DEFAULT + if self.ButtonColor != COLOR_SYSTEM_DEFAULT and theme_background_color() != COLOR_SYSTEM_DEFAULT: + highlight_background = theme_background_color() + if self.ButtonColor != COLOR_SYSTEM_DEFAULT and self.ButtonColor[0] != COLOR_SYSTEM_DEFAULT: + if self.ButtonColor[0] != theme_background_color(): + highlight_color = self.ButtonColor[0] + else: + highlight_color = 'red' + return (highlight_color, highlight_background) + + # Realtime button release callback + + def ButtonReleaseCallBack(self, parm): + """ + Not a user callable function. Called by tkinter when a "realtime" button is released + + :param parm: the event info from tkinter + :type parm: + + """ + self.LastButtonClickedWasRealtime = False + self.ParentForm.LastButtonClicked = None + + # Realtime button callback + def ButtonPressCallBack(self, parm): + """ + Not a user callable method. Callback called by tkinter when a "realtime" button is pressed + + :param parm: Event info passed in by tkinter + :type parm: + + """ + self.ParentForm.LastButtonClickedWasRealtime = True + if self.Key is not None: + self.ParentForm.LastButtonClicked = self.Key + else: + self.ParentForm.LastButtonClicked = self.ButtonText + # if self.ParentForm.CurrentlyRunningMainloop: + # Window._window_that_exited = self.ParentForm + # self.ParentForm.TKroot.quit() # kick out of loop if read was called + _exit_mainloop(self.ParentForm) + + def _find_target(self): + target = self.Target + target_element = None + + if target[0] == ThisRow: + target = [self.Position[0], target[1]] + if target[1] < 0: + target[1] = self.Position[1] + target[1] + strvar = None + should_submit_window = False + if target == (None, None): + strvar = self.TKStringVar + else: + # Need a try-block because if the target is not hashable, the "in" test will raise exception + try: + if target in self.ParentForm.AllKeysDict: + target_element = self.ParentForm.AllKeysDict[target] + except: + pass + # if target not found or the above try got exception, then keep looking.... + if target_element is None: + if not isinstance(target, str): + if target[0] < 0: + target = [self.Position[0] + target[0], target[1]] + target_element = self.ParentContainer._GetElementAtLocation(target) + else: + target_element = self.ParentForm.find_element(target) + try: + strvar = target_element.TKStringVar + except: + pass + try: + if target_element.ChangeSubmits: + should_submit_window = True + except: + pass + return target_element, strvar, should_submit_window + + # ------- Button Callback ------- # + def ButtonCallBack(self): + """ + Not user callable! Called by tkinter when a button is clicked. This is where all the fun begins! + """ + + if self.Disabled == BUTTON_DISABLED_MEANS_IGNORE: + return + target_element, strvar, should_submit_window = self._find_target() + + filetypes = FILE_TYPES_ALL_FILES if self.FileTypes is None else self.FileTypes + + if self.BType == BUTTON_TYPE_BROWSE_FOLDER: + if running_mac(): # macs don't like seeing the parent window (go firgure) + folder_name = tk.filedialog.askdirectory(initialdir=self.InitialFolder) # show the 'get folder' dialog box + else: + folder_name = tk.filedialog.askdirectory(initialdir=self.InitialFolder, parent=self.ParentForm.TKroot) # show the 'get folder' dialog box + if folder_name: + try: + strvar.set(folder_name) + self.TKStringVar.set(folder_name) + except: + pass + else: # if "cancel" button clicked, don't generate an event + should_submit_window = False + elif self.BType == BUTTON_TYPE_BROWSE_FILE: + if running_mac(): + # Workaround for the "*.*" issue on Mac + is_all = [(x, y) for (x, y) in filetypes if all(ch in '* .' for ch in y)] + if not len(set(filetypes)) > 1 and (len(is_all) != 0 or filetypes == FILE_TYPES_ALL_FILES): + file_name = tk.filedialog.askopenfilename(initialdir=self.InitialFolder) + else: + file_name = tk.filedialog.askopenfilename(initialdir=self.InitialFolder, filetypes=filetypes) # show the 'get file' dialog box + # elif _mac_allow_filetypes(): + # file_name = tk.filedialog.askopenfilename(initialdir=self.InitialFolder, filetypes=filetypes) # show the 'get file' dialog box + # else: + # file_name = tk.filedialog.askopenfilename(initialdir=self.InitialFolder) # show the 'get file' dialog box + else: + file_name = tk.filedialog.askopenfilename(filetypes=filetypes, initialdir=self.InitialFolder, parent=self.ParentForm.TKroot) # show the 'get file' dialog box + + if file_name: + strvar.set(file_name) + self.TKStringVar.set(file_name) + else: # if "cancel" button clicked, don't generate an event + should_submit_window = False + elif self.BType == BUTTON_TYPE_COLOR_CHOOSER: + color = tk.colorchooser.askcolor(parent=self.ParentForm.TKroot, color=self.default_color) # show the 'get file' dialog box + color = color[1] # save only the #RRGGBB portion + if color is not None: + strvar.set(color) + self.TKStringVar.set(color) + elif self.BType == BUTTON_TYPE_BROWSE_FILES: + if running_mac(): + # Workaround for the "*.*" issue on Mac + is_all = [(x, y) for (x, y) in filetypes if all(ch in '* .' for ch in y)] + if not len(set(filetypes)) > 1 and (len(is_all) != 0 or filetypes == FILE_TYPES_ALL_FILES): + file_name = tk.filedialog.askopenfilenames(initialdir=self.InitialFolder) + else: + file_name = tk.filedialog.askopenfilenames(filetypes=filetypes, initialdir=self.InitialFolder) + # elif _mac_allow_filetypes(): + # file_name = tk.filedialog.askopenfilenames(filetypes=filetypes, initialdir=self.InitialFolder) + # else: + # file_name = tk.filedialog.askopenfilenames(initialdir=self.InitialFolder) + else: + file_name = tk.filedialog.askopenfilenames(filetypes=filetypes, initialdir=self.InitialFolder, parent=self.ParentForm.TKroot) + + if file_name: + file_name = self._files_delimiter.join(file_name) # normally a ';' + strvar.set(file_name) + self.TKStringVar.set(file_name) + else: # if "cancel" button clicked, don't generate an event + should_submit_window = False + elif self.BType == BUTTON_TYPE_SAVEAS_FILE: + # show the 'get file' dialog box + if running_mac(): + # Workaround for the "*.*" issue on Mac + is_all = [(x, y) for (x, y) in filetypes if all(ch in '* .' for ch in y)] + if not len(set(filetypes)) > 1 and (len(is_all) != 0 or filetypes == FILE_TYPES_ALL_FILES): + file_name = tk.filedialog.asksaveasfilename(defaultextension=self.DefaultExtension, initialdir=self.InitialFolder) + else: + file_name = tk.filedialog.asksaveasfilename(filetypes=filetypes, defaultextension=self.DefaultExtension, initialdir=self.InitialFolder) + # elif _mac_allow_filetypes(): + # file_name = tk.filedialog.asksaveasfilename(filetypes=filetypes, defaultextension=self.DefaultExtension, initialdir=self.InitialFolder) + # else: + # file_name = tk.filedialog.asksaveasfilename(defaultextension=self.DefaultExtension, initialdir=self.InitialFolder) + else: + file_name = tk.filedialog.asksaveasfilename(filetypes=filetypes, defaultextension=self.DefaultExtension, initialdir=self.InitialFolder, parent=self.ParentForm.TKroot) + + if file_name: + strvar.set(file_name) + self.TKStringVar.set(file_name) + else: # if "cancel" button clicked, don't generate an event + should_submit_window = False + elif self.BType == BUTTON_TYPE_CLOSES_WIN: # this is a return type button so GET RESULTS and destroy window + # first, get the results table built + # modify the Results table in the parent FlexForm object + if self.Key is not None: + self.ParentForm.LastButtonClicked = self.Key + else: + self.ParentForm.LastButtonClicked = self.ButtonText + self.ParentForm.FormRemainedOpen = False + self.ParentForm._Close() + _exit_mainloop(self.ParentForm) + + if self.ParentForm.NonBlocking: + self.ParentForm.TKroot.destroy() + Window._DecrementOpenCount() + elif self.BType == BUTTON_TYPE_READ_FORM: # LEAVE THE WINDOW OPEN!! DO NOT CLOSE + # This is a PLAIN BUTTON + # first, get the results table built + # modify the Results table in the parent FlexForm object + if self.Key is not None: + self.ParentForm.LastButtonClicked = self.Key + else: + self.ParentForm.LastButtonClicked = self.ButtonText + self.ParentForm.FormRemainedOpen = True + _exit_mainloop(self.ParentForm) + elif self.BType == BUTTON_TYPE_CLOSES_WIN_ONLY: # special kind of button that does not exit main loop + self.ParentForm._Close(without_event=True) + self.ParentForm.TKroot.destroy() # close the window with tkinter + Window._DecrementOpenCount() + elif self.BType == BUTTON_TYPE_CALENDAR_CHOOSER: # this is a return type button so GET RESULTS and destroy window + # ------------ new chooser code ------------- + self.ParentForm.LastButtonClicked = self.Key # key should have been generated already if not set by user + self.ParentForm.FormRemainedOpen = True + should_submit_window = False + _exit_mainloop(self.ParentForm) + # elif self.BType == BUTTON_TYPE_SHOW_DEBUGGER: + # **** DEPRICATED ***** + # if self.ParentForm.DebuggerEnabled: + # show_debugger_popout_window() + + if should_submit_window: + self.ParentForm.LastButtonClicked = target_element.Key + self.ParentForm.FormRemainedOpen = True + _exit_mainloop(self.ParentForm) + + return + + def update(self, text=None, button_color=(None, None), disabled=None, image_source=None, image_data=None, image_filename=None, + visible=None, image_subsample=None, image_zoom=None, disabled_button_color=(None, None), image_size=None): + """ + Changes some of the settings for the Button Element. Must call `Window.Read` or `Window.Finalize` prior + + Changes will not be visible in your window until you call window.read or window.refresh. + + If you change visibility, your element may MOVE. If you want it to remain stationary, use the "layout helper" + function "pin" to ensure your element is "pinned" to that location in your layout so that it returns there + when made visible. + + :param text: sets button text + :type text: (str) + :param button_color: Color of button. default is from theme or the window. Easy to remember which is which if you say "ON" between colors. "red" on "green". Normally a tuple, but can be a simplified-button-color-string "foreground on background". Can be a single color if want to set only the background. + :type button_color: (str, str) | str + :param disabled: True/False to enable/disable at the GUI level. Use BUTTON_DISABLED_MEANS_IGNORE to ignore clicks (won't change colors) + :type disabled: (bool | str) + :param image_source: Image to place on button. Use INSTEAD of the image_filename and image_data. Unifies these into 1 easier to use parm + :type image_source: (str | bytes) + :param image_data: Raw or Base64 representation of the image to put on button. Choose either filename or data + :type image_data: bytes | str + :param image_filename: image filename if there is a button image. GIFs and PNGs only. + :type image_filename: (str) + :param disabled_button_color: colors to use when button is disabled (text, background). Use None for a color if don't want to change. Only ttk buttons support both text and background colors. tk buttons only support changing text color + :type disabled_button_color: (str, str) + :param visible: control visibility of element + :type visible: (bool) + :param image_subsample: amount to reduce the size of the image. Divides the size by this number. 2=1/2, 3=1/3, 4=1/4, etc + :type image_subsample: (int) + :param image_zoom: amount to increase the size of the image. 2=twice size, 3=3 times, etc + :type image_zoom: (int) + :param image_size: Size of the image in pixels (width, height) + :type image_size: (int, int) + """ + + if not self._widget_was_created(): # if widget hasn't been created yet, then don't allow + return + + if self._this_elements_window_closed(): + _error_popup_with_traceback('Error in Button.update - The window was closed') + return + + if image_source is not None: + if isinstance(image_source, bytes): + image_data = image_source + elif isinstance(image_source, str): + image_filename = image_source + + if self.UseTtkButtons: + style_name = self.ttk_style_name # created when made initial window (in the pack) + # style_name = str(self.Key) + 'custombutton.TButton' + button_style = ttk.Style() + if text is not None: + btext = text + if DEFAULT_USE_BUTTON_SHORTCUTS is True: + pos = btext.find(MENU_SHORTCUT_CHARACTER) + if pos != -1: + if pos < len(MENU_SHORTCUT_CHARACTER) or btext[pos - len(MENU_SHORTCUT_CHARACTER)] != "\\": + btext = btext[:pos] + btext[pos + len(MENU_SHORTCUT_CHARACTER):] + else: + btext = btext.replace('\\'+MENU_SHORTCUT_CHARACTER, MENU_SHORTCUT_CHARACTER) + pos = -1 + if pos != -1: + self.TKButton.config(underline=pos) + self.TKButton.configure(text=btext) + self.ButtonText = text + if button_color != (None, None) and button_color != COLOR_SYSTEM_DEFAULT: + bc = button_color_to_tuple(button_color, self.ButtonColor) + # if isinstance(button_color, str): + # try: + # button_color = button_color.split(' on ') + # except Exception as e: + # print('** Error in formatting your button color **', button_color, e) + if self.UseTtkButtons: + if bc[0] not in (None, COLOR_SYSTEM_DEFAULT): + button_style.configure(style_name, foreground=bc[0]) + if bc[1] not in (None, COLOR_SYSTEM_DEFAULT): + button_style.configure(style_name, background=bc[1]) + else: + if bc[0] not in (None, COLOR_SYSTEM_DEFAULT): + self.TKButton.config(foreground=bc[0], activebackground=bc[0]) + if bc[1] not in (None, COLOR_SYSTEM_DEFAULT): + self.TKButton.config(background=bc[1], activeforeground=bc[1]) + self.ButtonColor = bc + if disabled is True: + self.TKButton['state'] = 'disabled' + elif disabled is False: + self.TKButton['state'] = 'normal' + elif disabled == BUTTON_DISABLED_MEANS_IGNORE: + self.TKButton['state'] = 'normal' + self.Disabled = disabled if disabled is not None else self.Disabled + + if image_data is not None: + image = tk.PhotoImage(data=image_data) + if image_subsample: + image = image.subsample(image_subsample) + if image_zoom is not None: + image = image.zoom(int(image_zoom)) + if image_size is not None: + width, height = image_size + else: + width, height = image.width(), image.height() + if self.UseTtkButtons: + button_style.configure(style_name, image=image, width=width, height=height) + else: + self.TKButton.config(image=image, width=width, height=height) + self.TKButton.image = image + if image_filename is not None: + image = tk.PhotoImage(file=image_filename) + if image_subsample: + image = image.subsample(image_subsample) + if image_zoom is not None: + image = image.zoom(int(image_zoom)) + if image_size is not None: + width, height = image_size + else: + width, height = image.width(), image.height() + if self.UseTtkButtons: + button_style.configure(style_name, image=image, width=width, height=height) + else: + self.TKButton.config(highlightthickness=0, image=image, width=width, height=height) + self.TKButton.image = image + if visible is False: + self._pack_forget_save_settings() + elif visible is True: + self._pack_restore_settings() + if disabled_button_color != (None, None) and disabled_button_color != COLOR_SYSTEM_DEFAULT: + if not self.UseTtkButtons: + self.TKButton['disabledforeground'] = disabled_button_color[0] + else: + if disabled_button_color[0] is not None: + button_style.map(style_name, foreground=[('disabled', disabled_button_color[0])]) + if disabled_button_color[1] is not None: + button_style.map(style_name, background=[('disabled', disabled_button_color[1])]) + self.DisabledButtonColor = (disabled_button_color[0] if disabled_button_color[0] is not None else self.DisabledButtonColor[0], + disabled_button_color[1] if disabled_button_color[1] is not None else self.DisabledButtonColor[1]) + + if visible is not None: + self._visible = visible + + def get_text(self): + """ + Returns the current text shown on a button + + :return: The text currently displayed on the button + :rtype: (str) + """ + return self.ButtonText + + def click(self): + """ + Generates a click of the button as if the user clicked the button + Calls the tkinter invoke method for the button + """ + try: + self.TKButton.invoke() + except: + print('Exception clicking button') + + Click = click + GetText = get_text + Update = update + + +# ------------------------- Button lazy functions ------------------------- # +B = Button +Btn = Button + + +# ---------------------------------------------------------------------- # +# ButtonMenu Class # +# ---------------------------------------------------------------------- # +class ButtonMenu(Element): + """ + The Button Menu Element. Creates a button that when clicked will show a menu similar to right click menu + """ + + def __init__(self, button_text, menu_def, tooltip=None, disabled=False, image_source=None, + image_filename=None, image_data=None, image_size=(None, None), image_subsample=None, image_zoom=None, border_width=None, + size=(None, None), s=(None, None), auto_size_button=None, button_color=None, text_color=None, background_color=None, disabled_text_color=None, + font=None, item_font=None, pad=None, p=None, expand_x=False, expand_y=False, key=None, k=None, tearoff=False, visible=True, + metadata=None): + """ + :param button_text: Text to be displayed on the button + :type button_text: (str) + :param menu_def: A list of lists of Menu items to show when this element is clicked. See docs for format as they are the same for all menu types + :type menu_def: List[List[str]] + :param tooltip: text, that will appear when mouse hovers over the element + :type tooltip: (str) + :param disabled: If True button will be created disabled + :type disabled: (bool) + :param image_source: Image to place on button. Use INSTEAD of the image_filename and image_data. Unifies these into 1 easier to use parm + :type image_source: (str | bytes) + :param image_filename: image filename if there is a button image. GIFs and PNGs only. + :type image_filename: (str) + :param image_data: Raw or Base64 representation of the image to put on button. Choose either filename or data + :type image_data: bytes | str + :param image_size: Size of the image in pixels (width, height) + :type image_size: (int, int) + :param image_subsample: amount to reduce the size of the image. Divides the size by this number. 2=1/2, 3=1/3, 4=1/4, etc + :type image_subsample: (int) + :param image_zoom: amount to increase the size of the image. 2=twice size, 3=3 times, etc + :type image_zoom: (int) + :param border_width: width of border around button in pixels + :type border_width: (int) + :param size: (w, h) w=characters-wide, h=rows-high. If an int instead of a tuple is supplied, then height is auto-set to 1 + :type size: (int, int) | (None, None) | int + :param s: Same as size parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, size will be used + :type s: (int, int) | (None, None) | int + :param auto_size_button: if True the button size is sized to fit the text + :type auto_size_button: (bool) + :param button_color: of button. Easy to remember which is which if you say "ON" between colors. "red" on "green" + :type button_color: (str, str) | str + :param background_color: color of the background + :type background_color: (str) + :param text_color: element's text color. Can be in #RRGGBB format or a color name "black" + :type text_color: (str) + :param disabled_text_color: color to use for text when item is disabled. Can be in #RRGGBB format or a color name "black" + :type disabled_text_color: (str) + :param font: specifies the font family, size, etc. Tuple or Single string format 'name size styles'. Styles: italic * roman bold normal underline overstrike + :type font: (str or (str, int[, str]) or None) + :param item_font: specifies the font family, size, etc. Tuple or Single string format 'name size styles'. Styles: italic * roman bold normal underline overstrike, for the menu items + :type item_font: (str or (str, int[, str]) or None) + :param pad: Amount of padding to put around element in pixels (left/right, top/bottom) or ((left, right), (top, bottom)) or an int. If an int, then it's converted into a tuple (int, int) + :type pad: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param p: Same as pad parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, pad will be used + :type p: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param expand_x: If True the element will automatically expand in the X direction to fill available space + :type expand_x: (bool) + :param expand_y: If True the element will automatically expand in the Y direction to fill available space + :type expand_y: (bool) + :param key: Used with window.find_element and with return values to uniquely identify this element to uniquely identify this element + :type key: str | int | tuple | object + :param k: Same as the Key. You can use either k or key. Which ever is set will be used. + :type k: str | int | tuple | object + :param tearoff: Determines if menus should allow them to be torn off + :type tearoff: (bool) + :param visible: set visibility state of the element + :type visible: (bool) + :param metadata: User metadata that can be set to ANYTHING + :type metadata: (Any) + """ + + + self.MenuDefinition = copy.deepcopy(menu_def) + + self.AutoSizeButton = auto_size_button + self.ButtonText = button_text + self.ButtonColor = button_color_to_tuple(button_color) + # self.TextColor = self.ButtonColor[0] + # self.BackgroundColor = self.ButtonColor[1] + self.BackgroundColor = background_color if background_color is not None else theme_input_background_color() + self.TextColor = text_color if text_color is not None else theme_input_text_color() + self.DisabledTextColor = disabled_text_color if disabled_text_color is not None else COLOR_SYSTEM_DEFAULT + self.ItemFont = item_font + self.BorderWidth = border_width if border_width is not None else DEFAULT_BORDER_WIDTH + if image_source is not None: + if isinstance(image_source, str): + image_filename = image_source + elif isinstance(image_source, bytes): + image_data = image_source + else: + warnings.warn('ButtonMenu element - image_source is not a valid type: {}'.format(type(image_source)), UserWarning) + + self.ImageFilename = image_filename + self.ImageData = image_data + self.ImageSize = image_size + self.ImageSubsample = image_subsample + self.zoom = int(image_zoom) if image_zoom is not None else None + self.Disabled = disabled + self.IsButtonMenu = True + self.MenuItemChosen = None + self.Widget = self.TKButtonMenu = None # type: tk.Menubutton + self.TKMenu = None # type: tk.Menu + self.part_of_custom_menubar = False + self.custom_menubar_key = None + # self.temp_size = size if size != (NONE, NONE) else + key = key if key is not None else k + sz = size if size != (None, None) else s + pad = pad if pad is not None else p + self.expand_x = expand_x + self.expand_y = expand_y + + super().__init__(ELEM_TYPE_BUTTONMENU, size=sz, font=font, pad=pad, key=key, tooltip=tooltip, + text_color=self.TextColor, background_color=self.BackgroundColor, visible=visible, metadata=metadata) + self.Tearoff = tearoff + + + def _MenuItemChosenCallback(self, item_chosen): # ButtonMenu Menu Item Chosen Callback + """ + Not a user callable function. Called by tkinter when an item is chosen from the menu. + + :param item_chosen: The menu item chosen. + :type item_chosen: (str) + """ + # print('IN MENU ITEM CALLBACK', item_chosen) + self.MenuItemChosen = item_chosen + self.ParentForm.LastButtonClicked = self.Key + self.ParentForm.FormRemainedOpen = True + # if self.ParentForm.CurrentlyRunningMainloop: + # self.ParentForm.TKroot.quit() # kick the users out of the mainloop + _exit_mainloop(self.ParentForm) + + + def update(self, menu_definition=None, visible=None, image_source=None, image_size=(None, None), image_subsample=None, image_zoom=None, button_text=None, button_color=None): + """ + Changes some of the settings for the ButtonMenu Element. Must call `Window.Read` or `Window.Finalize` prior + + Changes will not be visible in your window until you call window.read or window.refresh. + + If you change visibility, your element may MOVE. If you want it to remain stationary, use the "layout helper" + function "pin" to ensure your element is "pinned" to that location in your layout so that it returns there + when made visible. + + :param menu_definition: (New menu definition (in menu definition format) + :type menu_definition: List[List] + :param visible: control visibility of element + :type visible: (bool) + :param image_source: new image if image is to be changed. Can be a filename or a base64 encoded byte-string + :type image_source: (str | bytes) + :param image_size: Size of the image in pixels (width, height) + :type image_size: (int, int) + :param image_subsample: amount to reduce the size of the image. Divides the size by this number. 2=1/2, 3=1/3, 4=1/4, etc + :type image_subsample: (int) + :param image_zoom: amount to increase the size of the image. 2=twice size, 3=3 times, etc + :type image_zoom: (int) + :param button_text: Text to be shown on the button + :type button_text: (str) + :param button_color: Normally a tuple, but can be a simplified-button-color-string "foreground on background". Can be a single color if want to set only the background. + :type button_color: (str, str) | str + """ + + if not self._widget_was_created(): # if widget hasn't been created yet, then don't allow + return + + if self._this_elements_window_closed(): + _error_popup_with_traceback('Error in ButtonMenu.update - The window was closed') + return + + + if menu_definition is not None: + self.MenuDefinition = copy.deepcopy(menu_definition) + top_menu = self.TKMenu = tk.Menu(self.TKButtonMenu, tearoff=self.Tearoff, font=self.ItemFont, tearoffcommand=self._tearoff_menu_callback) + + if self.BackgroundColor not in (COLOR_SYSTEM_DEFAULT, None): + top_menu.config(bg=self.BackgroundColor) + if self.TextColor not in (COLOR_SYSTEM_DEFAULT, None): + top_menu.config(fg=self.TextColor) + if self.DisabledTextColor not in (COLOR_SYSTEM_DEFAULT, None): + top_menu.config(disabledforeground=self.DisabledTextColor) + if self.ItemFont is not None: + top_menu.config(font=self.ItemFont) + AddMenuItem(self.TKMenu, self.MenuDefinition[1], self) + self.TKButtonMenu.configure(menu=self.TKMenu) + if image_source is not None: + filename = data = None + if image_source is not None: + if isinstance(image_source, bytes): + data = image_source + elif isinstance(image_source, str): + filename = image_source + else: + warnings.warn('ButtonMenu element - image_source is not a valid type: {}'.format(type(image_source)), UserWarning) + image = None + if filename is not None: + image = tk.PhotoImage(file=filename) + if image_subsample is not None: + image = image.subsample(image_subsample) + if image_zoom is not None: + image = image.zoom(int(image_zoom)) + elif data is not None: + # if type(data) is bytes: + try: + image = tk.PhotoImage(data=data) + if image_subsample is not None: + image = image.subsample(image_subsample) + if image_zoom is not None: + image = image.zoom(int(image_zoom)) + except Exception as e: + image = data + + if image is not None: + if type(image) is not bytes: + width, height = image_size[0] if image_size[0] is not None else image.width(), image_size[1] if image_size[1] is not None else image.height() + else: + width, height = image_size + + self.TKButtonMenu.config(image=image, compound=tk.CENTER, width=width, height=height) + self.TKButtonMenu.image = image + if button_text is not None: + self.TKButtonMenu.configure(text=button_text) + self.ButtonText = button_text + if visible is False: + self._pack_forget_save_settings() + elif visible is True: + self._pack_restore_settings() + if visible is not None: + self._visible = visible + if button_color != (None, None) and button_color != COLOR_SYSTEM_DEFAULT: + bc = button_color_to_tuple(button_color, self.ButtonColor) + if bc[0] not in (None, COLOR_SYSTEM_DEFAULT): + self.TKButtonMenu.config(foreground=bc[0], activeforeground=bc[0]) + if bc[1] not in (None, COLOR_SYSTEM_DEFAULT): + self.TKButtonMenu.config(background=bc[1], activebackground=bc[1]) + self.ButtonColor = bc + + def click(self): + """ + Generates a click of the button as if the user clicked the button + Calls the tkinter invoke method for the button + """ + try: + self.TKMenu.invoke(1) + except: + print('Exception clicking button') + + Update = update + Click = click + +BMenu = ButtonMenu +BM = ButtonMenu + + +# ---------------------------------------------------------------------- # +# ProgreessBar # +# ---------------------------------------------------------------------- # +class ProgressBar(Element): + """ + Progress Bar Element - Displays a colored bar that is shaded as progress of some operation is made + """ + + def __init__(self, max_value, orientation=None, size=(None, None), s=(None, None), size_px=(None, None), auto_size_text=None, bar_color=None, style=None, border_width=None, + relief=None, key=None, k=None, pad=None, p=None, right_click_menu=None, expand_x=False, expand_y=False, visible=True, metadata=None): + """ + :param max_value: max value of progressbar + :type max_value: (int) + :param orientation: 'horizontal' or 'vertical' + :type orientation: (str) + :param size: Size of the bar. If horizontal (chars long, pixels wide), vert (chars high, pixels wide). Vert height measured using horizontal chars units. + :type size: (int, int) | (int, None) + :param s: Same as size parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, size will be used + :type s: (int, int) | (None, None) + :param size_px: Size in pixels (length, width). Will be used in place of size parm if specified + :type size_px: (int, int) | (None, None) + :param auto_size_text: Not sure why this is here + :type auto_size_text: (bool) + :param bar_color: The 2 colors that make up a progress bar. Either a tuple of 2 strings or a string. Tuple - (bar, background). A string with 1 color changes the background of the bar only. A string with 2 colors separated by "on" like "red on blue" specifies a red bar on a blue background. + :type bar_color: (str, str) or str + :param style: Progress bar style defined as one of these 'default', 'winnative', 'clam', 'alt', 'classic', 'vista', 'xpnative' + :type style: (str) + :param border_width: The amount of pixels that go around the outside of the bar + :type border_width: (int) + :param relief: relief style. Values are same as progress meter relief values. Can be a constant or a string: `RELIEF_RAISED RELIEF_SUNKEN RELIEF_FLAT RELIEF_RIDGE RELIEF_GROOVE RELIEF_SOLID` (Default value = DEFAULT_PROGRESS_BAR_RELIEF) + :type relief: (str) + :param key: Used with window.find_element and with return values to uniquely identify this element to uniquely identify this element + :type key: str | int | tuple | object + :param k: Same as the Key. You can use either k or key. Which ever is set will be used. + :type k: str | int | tuple | object + :param pad: Amount of padding to put around element in pixels (left/right, top/bottom) or ((left, right), (top, bottom)) or an int. If an int, then it's converted into a tuple (int, int) + :type pad: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param p: Same as pad parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, pad will be used + :type p: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param right_click_menu: A list of lists of Menu items to show when this element is right clicked. See user docs for exact format. + :type right_click_menu: List[List[ List[str] | str ]] + :param expand_x: If True the element will automatically expand in the X direction to fill available space + :type expand_x: (bool) + :param expand_y: If True the element will automatically expand in the Y direction to fill available space + :type expand_y: (bool) + :param visible: set visibility state of the element + :type visible: (bool) + :param metadata: User metadata that can be set to ANYTHING + :type metadata: (Any) + """ + + + self.MaxValue = max_value + self.TKProgressBar = None # type: TKProgressBar + self.Cancelled = False + self.NotRunning = True + self.Orientation = orientation if orientation else DEFAULT_METER_ORIENTATION + self.RightClickMenu = right_click_menu + # Progress Bar colors can be a tuple (text, background) or a string with format "bar on background" - examples "red on white" or ("red", "white") + if bar_color is None: + bar_color = DEFAULT_PROGRESS_BAR_COLOR + else: + bar_color = _simplified_dual_color_to_tuple(bar_color, default=DEFAULT_PROGRESS_BAR_COLOR) + + self.BarColor = bar_color # should be a tuple at this point + self.BarStyle = style if style else DEFAULT_TTK_THEME + self.BorderWidth = border_width if border_width else DEFAULT_PROGRESS_BAR_BORDER_WIDTH + self.Relief = relief if relief else DEFAULT_PROGRESS_BAR_RELIEF + self.BarExpired = False + key = key if key is not None else k + sz = size if size != (None, None) else s + pad = pad if pad is not None else p + self.expand_x = expand_x + self.expand_y = expand_y + self.size_px = size_px + + super().__init__(ELEM_TYPE_PROGRESS_BAR, size=sz, auto_size_text=auto_size_text, key=key, pad=pad, + visible=visible, metadata=metadata) + + # returns False if update failed + def update_bar(self, current_count, max=None): + """ + DEPRECATED BUT STILL USABLE - has been combined with the normal ProgressBar.update method. + Change what the bar shows by changing the current count and optionally the max count + + :param current_count: sets the current value + :type current_count: (int) + :param max: changes the max value + :type max: (int) + """ + + if self.ParentForm.TKrootDestroyed: + return False + self.TKProgressBar.Update(current_count, max=max) + try: + self.ParentForm.TKroot.update() + except: + Window._DecrementOpenCount() + # _my_windows.Decrement() + return False + return True + + def update(self, current_count=None, max=None, bar_color=None, visible=None): + """ + Changes some of the settings for the ProgressBar Element. Must call `Window.Read` or `Window.Finalize` prior + Now has the ability to modify the count so that the update_bar method is not longer needed separately + + Changes will not be visible in your window until you call window.read or window.refresh. + + If you change visibility, your element may MOVE. If you want it to remain stationary, use the "layout helper" + function "pin" to ensure your element is "pinned" to that location in your layout so that it returns there + when made visible. + + :param current_count: sets the current value + :type current_count: (int) + :param max: changes the max value + :type max: (int) + :param bar_color: The 2 colors that make up a progress bar. Easy to remember which is which if you say "ON" between colors. "red" on "green". + :type bar_color: (str, str) or str + :param visible: control visibility of element + :type visible: (bool) + :return: Returns True if update was OK. False means something wrong with window or it was closed + :rtype: (bool) + """ + if not self._widget_was_created(): # if widget hasn't been created yet, then don't allow + return False + + if self._this_elements_window_closed(): + _error_popup_with_traceback('Error in ProgressBar.update - The window was closed') + return + + + if self.ParentForm.TKrootDestroyed: + return False + + if visible is False: + self._pack_forget_save_settings() + elif visible is True: + self._pack_restore_settings() + + if visible is not None: + self._visible = visible + if bar_color is not None: + bar_color = _simplified_dual_color_to_tuple(bar_color, default=DEFAULT_PROGRESS_BAR_COLOR) + self.BarColor = bar_color + style = ttk.Style() + style.configure(self.ttk_style_name, background=bar_color[0], troughcolor=bar_color[1]) + if current_count is not None: + self.TKProgressBar.Update(current_count, max=max) + + try: + self.ParentForm.TKroot.update() + except: + # Window._DecrementOpenCount() + # _my_windows.Decrement() + return False + return True + + Update = update + UpdateBar = update_bar + + +PBar = ProgressBar +Prog = ProgressBar +Progress = ProgressBar + + +# ---------------------------------------------------------------------- # +# Image # +# ---------------------------------------------------------------------- # +class Image(Element): + """ + Image Element - show an image in the window. Should be a GIF or a PNG only + """ + + def __init__(self, source=None, filename=None, data=None, background_color=None, size=(None, None), s=(None, None), pad=None, p=None, key=None, k=None, tooltip=None, subsample=None, zoom=None, right_click_menu=None, expand_x=False, expand_y=False, visible=True, enable_events=False, metadata=None): + """ + :param source: A filename or a base64 bytes. Will automatically detect the type and fill in filename or data for you. + :type source: str | bytes | None + :param filename: image filename if there is a button image. GIFs and PNGs only. + :type filename: str | None + :param data: Raw or Base64 representation of the image to put on button. Choose either filename or data + :type data: bytes | str | None + :param background_color: color of background + :type background_color: + :param size: (width, height) size of image in pixels + :type size: (int, int) + :param s: Same as size parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, size will be used + :type s: (int, int) | (None, None) | int + :param pad: Amount of padding to put around element in pixels (left/right, top/bottom) or ((left, right), (top, bottom)) or an int. If an int, then it's converted into a tuple (int, int) + :type pad: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param p: Same as pad parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, pad will be used + :type p: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param key: Used with window.find_element and with return values to uniquely identify this element to uniquely identify this element + :type key: str | int | tuple | object + :param k: Same as the Key. You can use either k or key. Which ever is set will be used. + :type k: str | int | tuple | object + :param tooltip: text, that will appear when mouse hovers over the element + :type tooltip: (str) + :param subsample: amount to reduce the size of the image. Divides the size by this number. 2=1/2, 3=1/3, 4=1/4, etc + :type subsample: (int) + :param zoom: amount to increase the size of the image. + :type zoom: (int) + :param right_click_menu: A list of lists of Menu items to show when this element is right clicked. See user docs for exact format. + :type right_click_menu: List[List[ List[str] | str ]] + :param expand_x: If True the element will automatically expand in the X direction to fill available space + :type expand_x: (bool) + :param expand_y: If True the element will automatically expand in the Y direction to fill available space + :type expand_y: (bool) + :param visible: set visibility state of the element + :type visible: (bool) + :param enable_events: Turns on the element specific events. For an Image element, the event is "image clicked" + :type enable_events: (bool) + :param metadata: User metadata that can be set to ANYTHING + :type metadata: (Any) + """ + + if source is not None: + if isinstance(source, bytes): + data = source + elif isinstance(source, str): + filename = source + else: + warnings.warn('Image element - source is not a valid type: {}'.format(type(source)), UserWarning) + + self.Filename = filename + self.Data = data + self.Widget = self.tktext_label = None # type: tk.Label + self.BackgroundColor = background_color + if data is None and filename is None: + self.Filename = '' + self.EnableEvents = enable_events + self.RightClickMenu = right_click_menu + self.AnimatedFrames = None + self.CurrentFrameNumber = 0 + self.TotalAnimatedFrames = 0 + self.LastFrameTime = 0 + self.ImageSubsample = subsample + self.zoom = int(zoom) if zoom is not None else None + + self.Source = filename if filename is not None else data + key = key if key is not None else k + sz = size if size != (None, None) else s + pad = pad if pad is not None else p + self.expand_x = expand_x + self.expand_y = expand_y + + + super().__init__(ELEM_TYPE_IMAGE, size=sz, background_color=background_color, pad=pad, key=key, + tooltip=tooltip, visible=visible, metadata=metadata) + return + + def update(self, source=None, filename=None, data=None, size=(None, None), subsample=None, zoom=None, visible=None): + """ + Changes some of the settings for the Image Element. Must call `Window.Read` or `Window.Finalize` prior. + To clear an image that's been displayed, call with NONE of the options set. A blank update call will + delete the previously shown image. + + Changes will not be visible in your window until you call window.read or window.refresh. + + If you change visibility, your element may MOVE. If you want it to remain stationary, use the "layout helper" + function "pin" to ensure your element is "pinned" to that location in your layout so that it returns there + when made visible. + + :param source: A filename or a base64 bytes. Will automatically detect the type and fill in filename or data for you. + :type source: str | bytes | None + :param filename: filename to the new image to display. + :type filename: (str) + :param data: Base64 encoded string OR a tk.PhotoImage object + :type data: str | tkPhotoImage + :param size: (width, height) size of image in pixels + :type size: Tuple[int,int] + :param subsample: amount to reduce the size of the image. Divides the size by this number. 2=1/2, 3=1/3, 4=1/4, etc + :type subsample: (int) + :param zoom: amount to increase the size of the image + :type zoom: (int) + :param visible: control visibility of element + :type visible: (bool) + """ + + if not self._widget_was_created(): # if widget hasn't been created yet, then don't allow + return + + if self._this_elements_window_closed(): + _error_popup_with_traceback('Error in Image.update - The window was closed') + return + + + if source is not None: + if isinstance(source, bytes): + data = source + elif isinstance(source, str): + filename = source + else: + warnings.warn('Image element - source is not a valid type: {}'.format(type(source)), UserWarning) + + image = None + if filename is not None: + try: + image = tk.PhotoImage(file=filename) + if subsample is not None: + image = image.subsample(subsample) + if zoom is not None: + image = image.zoom(int(zoom)) + except Exception as e: + _error_popup_with_traceback('Exception updating Image element', e) + + elif data is not None: + # if type(data) is bytes: + try: + image = tk.PhotoImage(data=data) + if subsample is not None: + image = image.subsample(subsample) + if zoom is not None: + image = image.zoom(int(zoom)) + except Exception as e: + image = data + # return # an error likely means the window has closed so exit + + if image is not None: + self.tktext_label.configure(image='') # clear previous image + if self.tktext_label.image is not None: + del self.tktext_label.image + if type(image) is not bytes: + width, height = size[0] if size[0] is not None else image.width(), size[1] if size[1] is not None else image.height() + else: + width, height = size + try: # sometimes crashes if user closed with X + self.tktext_label.configure(image=image, width=width, height=height) + except Exception as e: + _error_popup_with_traceback('Exception updating Image element', e) + self.tktext_label.image = image + if visible is False: + self._pack_forget_save_settings() + elif visible is True: + self._pack_restore_settings() + + # if everything is set to None, then delete the image + if filename is None and image is None and visible is None and size == (None, None): + # Using a try because the image may have been previously deleted and don't want an error if that's happened + try: + self.tktext_label.configure(image='', width=1, height=1, bd=0) + self.tktext_label.image = None + except: + pass + + if visible is not None: + self._visible = visible + + def update_animation(self, source, time_between_frames=0): + """ + Show an Animated GIF. Call the function as often as you like. The function will determine when to show the next frame and will automatically advance to the next frame at the right time. + NOTE - does NOT perform a sleep call to delay + :param source: Filename or Base64 encoded string containing Animated GIF + :type source: str | bytes | None + :param time_between_frames: Number of milliseconds to wait between showing frames + :type time_between_frames: (int) + """ + + if self.Source != source: + self.AnimatedFrames = None + self.Source = source + + if self.AnimatedFrames is None: + self.TotalAnimatedFrames = 0 + self.AnimatedFrames = [] + # Load up to 1000 frames of animation. stops when a bad frame is returns by tkinter + for i in range(1000): + if type(source) is not bytes: + try: + self.AnimatedFrames.append(tk.PhotoImage(file=source, format='gif -index %i' % (i))) + except Exception as e: + break + else: + try: + self.AnimatedFrames.append(tk.PhotoImage(data=source, format='gif -index %i' % (i))) + except Exception as e: + break + self.TotalAnimatedFrames = len(self.AnimatedFrames) + self.LastFrameTime = time.time() + self.CurrentFrameNumber = -1 # start at -1 because it is incremented before every frame is shown + # show the frame + + now = time.time() + + if time_between_frames: + if (now - self.LastFrameTime) * 1000 > time_between_frames: + self.LastFrameTime = now + self.CurrentFrameNumber = (self.CurrentFrameNumber + 1) % self.TotalAnimatedFrames + else: # don't reshow the frame again if not time for new frame + return + else: + self.CurrentFrameNumber = (self.CurrentFrameNumber + 1) % self.TotalAnimatedFrames + image = self.AnimatedFrames[self.CurrentFrameNumber] + try: # needed in case the window was closed with an "X" + self.tktext_label.configure(image=image, width=image.width(), heigh=image.height()) + except Exception as e: + print('Exception in update_animation', e) + + + def update_animation_no_buffering(self, source, time_between_frames=0): + """ + Show an Animated GIF. Call the function as often as you like. The function will determine when to show the next frame and will automatically advance to the next frame at the right time. + NOTE - does NOT perform a sleep call to delay + + :param source: Filename or Base64 encoded string containing Animated GIF + :type source: str | bytes + :param time_between_frames: Number of milliseconds to wait between showing frames + :type time_between_frames: (int) + """ + + if self.Source != source: + self.AnimatedFrames = None + self.Source = source + self.frame_num = 0 + + now = time.time() + + if time_between_frames: + if (now - self.LastFrameTime) * 1000 > time_between_frames: + self.LastFrameTime = now + else: # don't reshow the frame again if not time for new frame + return + + # read a frame + while True: + if type(source) is not bytes: + try: + self.image = tk.PhotoImage(file=source, format='gif -index %i' % (self.frame_num)) + self.frame_num += 1 + except: + self.frame_num = 0 + else: + try: + self.image = tk.PhotoImage(data=source, format='gif -index %i' % (self.frame_num)) + self.frame_num += 1 + except: + self.frame_num = 0 + if self.frame_num: + break + + try: # needed in case the window was closed with an "X" + self.tktext_label.configure(image=self.image, width=self.image.width(), heigh=self.image.height()) + + except: + pass + + Update = update + UpdateAnimation = update_animation + + +Im = Image + + +# ---------------------------------------------------------------------- # +# Canvas # +# ---------------------------------------------------------------------- # +class Canvas(Element): + + def __init__(self, canvas=None, background_color=None, size=(None, None), s=(None, None), pad=None, p=None, key=None, k=None, tooltip=None, + right_click_menu=None, expand_x=False, expand_y=False, visible=True, border_width=0, metadata=None): + """ + :param canvas: Your own tk.Canvas if you already created it. Leave blank to create a Canvas + :type canvas: (tk.Canvas) + :param background_color: color of background + :type background_color: (str) + :param size: (width in char, height in rows) size in pixels to make canvas + :type size: (int,int) | (None, None) + :param s: Same as size parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, size will be used + :type s: (int, int) | (None, None) | int + :param pad: Amount of padding to put around element in pixels (left/right, top/bottom) or ((left, right), (top, bottom)) or an int. If an int, then it's converted into a tuple (int, int) + :type pad: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param p: Same as pad parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, pad will be used + :type p: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param key: Used with window.find_element and with return values to uniquely identify this element + :type key: str | int | tuple | object + :param k: Same as the Key. You can use either k or key. Which ever is set will be used. + :type k: str | int | tuple | object + :param tooltip: text, that will appear when mouse hovers over the element + :type tooltip: (str) + :param right_click_menu: A list of lists of Menu items to show when this element is right clicked. See user docs for exact format. + :type right_click_menu: List[List[ List[str] | str ]] + :param expand_x: If True the element will automatically expand in the X direction to fill available space + :type expand_x: (bool) + :param expand_y: If True the element will automatically expand in the Y direction to fill available space + :type expand_y: (bool) + :param visible: set visibility state of the element + :type visible: (bool) + :param border_width: width of border around element in pixels. Not normally used with Canvas element + :type border_width: (int) + :param metadata: User metadata that can be set to ANYTHING + :type metadata: (Any) + """ + + + self.BackgroundColor = background_color if background_color is not None else DEFAULT_BACKGROUND_COLOR + self._TKCanvas = self.Widget = canvas + self.RightClickMenu = right_click_menu + self.BorderWidth = border_width + key = key if key is not None else k + sz = size if size != (None, None) else s + pad = pad if pad is not None else p + self.expand_x = expand_x + self.expand_y = expand_y + + super().__init__(ELEM_TYPE_CANVAS, background_color=background_color, size=sz, pad=pad, key=key, + tooltip=tooltip, visible=visible, metadata=metadata) + return + + + def update(self, background_color=None, visible=None): + """ + + :param background_color: color of background + :type background_color: (str) + :param visible: set visibility state of the element + :type visible: (bool) + """ + + if not self._widget_was_created(): # if widget hasn't been created yet, then don't allow + return + + if self._this_elements_window_closed(): + _error_popup_with_traceback('Error in Canvas.update - The window was closed') + return + + if background_color not in (None, COLOR_SYSTEM_DEFAULT): + self._TKCanvas.configure(background=background_color) + if visible is False: + self._pack_forget_save_settings() + elif visible is True: + self._pack_restore_settings() + if visible is not None: + self._visible = visible + + + @property + def tk_canvas(self): + """ + Returns the underlying tkiner Canvas widget + + :return: The tkinter canvas widget + :rtype: (tk.Canvas) + """ + if self._TKCanvas is None: + print('*** Did you forget to call Finalize()? Your code should look something like: ***') + print('*** window = sg.Window("My Form", layout, finalize=True) ***') + return self._TKCanvas + + TKCanvas = tk_canvas + + +# ---------------------------------------------------------------------- # +# Graph # +# ---------------------------------------------------------------------- # +class Graph(Element): + """ + Creates an area for you to draw on. The MAGICAL property this Element has is that you interact + with the element using your own coordinate system. This is an important point!! YOU define where the location + is for (0,0). Want (0,0) to be in the middle of the graph like a math 4-quadrant graph? No problem! Set your + lower left corner to be (-100,-100) and your upper right to be (100,100) and you've got yourself a graph with + (0,0) at the center. + One of THE coolest of the Elements. + You can also use float values. To do so, be sure and set the float_values parameter. + Mouse click and drag events are possible and return the (x,y) coordinates of the mouse + Drawing primitives return an "id" that is referenced when you want to operation on that item (e.g. to erase it) + """ + + def __init__(self, canvas_size, graph_bottom_left, graph_top_right, background_color=None, pad=None, p=None, + change_submits=False, drag_submits=False, enable_events=False, motion_events=False, key=None, k=None, tooltip=None, + right_click_menu=None, expand_x=False, expand_y=False, visible=True, float_values=False, border_width=0, metadata=None): + """ + :param canvas_size: size of the canvas area in pixels + :type canvas_size: (int, int) + :param graph_bottom_left: (x,y) The bottoms left corner of your coordinate system + :type graph_bottom_left: (int, int) + :param graph_top_right: (x,y) The top right corner of your coordinate system + :type graph_top_right: (int, int) + :param background_color: background color of the drawing area + :type background_color: (str) + :param pad: Amount of padding to put around element in pixels (left/right, top/bottom) or ((left, right), (top, bottom)) or an int. If an int, then it's converted into a tuple (int, int) + :type pad: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param p: Same as pad parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, pad will be used + :type p: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param change_submits: * DEPRICATED DO NOT USE. Use `enable_events` instead + :type change_submits: (bool) + :param drag_submits: if True and Events are enabled for the Graph, will report Events any time the mouse moves while button down. When the mouse button is released, you'll get an event = graph key + '+UP' (if key is a string.. if not a string, it'll be made into a tuple) + :type drag_submits: (bool) + :param enable_events: If True then clicks on the Graph are immediately reported as an event. Use this instead of change_submits + :type enable_events: (bool) + :param motion_events: If True then if no button is down and the mouse is moved, an event is generated with key = graph key + '+MOVE' (if key is a string, it not a string then a tuple is returned) + :type motion_events: (bool) + :param key: Value that uniquely identifies this element from all other elements. Used when Finding an element or in return values. Must be unique to the window + :type key: str | int | tuple | object + :param k: Same as the Key. You can use either k or key. Which ever is set will be used. + :type k: str | int | tuple | object + :param tooltip: text, that will appear when mouse hovers over the element + :type tooltip: (str) + :param right_click_menu: A list of lists of Menu items to show when this element is right clicked. See user docs for exact format. + :type right_click_menu: List[List[ List[str] | str ]] + :param expand_x: If True the element will automatically expand in the X direction to fill available space + :type expand_x: (bool) + :param expand_y: If True the element will automatically expand in the Y direction to fill available space + :type expand_y: (bool) + :param visible: set visibility state of the element (Default = True) + :type visible: (bool) + :param float_values: If True x,y coordinates are returned as floats, not ints + :type float_values: (bool) + :param border_width: width of border around element in pixels. Not normally used for Graph Elements + :type border_width: (int) + :param metadata: User metadata that can be set to ANYTHING + :type metadata: (Any) + """ + + self.CanvasSize = canvas_size + self.BottomLeft = graph_bottom_left + self.TopRight = graph_top_right + # self._TKCanvas = None # type: tk.Canvas + self._TKCanvas2 = self.Widget = None # type: tk.Canvas + self.ChangeSubmits = change_submits or enable_events + self.DragSubmits = drag_submits + self.ClickPosition = (None, None) + self.MouseButtonDown = False + self.Images = {} + self.RightClickMenu = right_click_menu + self.FloatValues = float_values + self.BorderWidth = border_width + key = key if key is not None else k + pad = pad if pad is not None else p + self.expand_x = expand_x + self.expand_y = expand_y + self.motion_events = motion_events + + super().__init__(ELEM_TYPE_GRAPH, background_color=background_color, size=canvas_size, pad=pad, key=key, + tooltip=tooltip, visible=visible, metadata=metadata) + return + + def _convert_xy_to_canvas_xy(self, x_in, y_in): + """ + Not user callable. Used to convert user's coordinates into the ones used by tkinter + :param x_in: The x coordinate to convert + :type x_in: int | float + :param y_in: The y coordinate to convert + :type y_in: int | float + :return: (int, int) The converted canvas coordinates + :rtype: (int, int) + """ + if None in (x_in, y_in): + return None, None + try: + scale_x = (self.CanvasSize[0] - 0) / (self.TopRight[0] - self.BottomLeft[0]) + scale_y = (0 - self.CanvasSize[1]) / (self.TopRight[1] - self.BottomLeft[1]) + except: + scale_x = scale_y = 0 + + new_x = 0 + scale_x * (x_in - self.BottomLeft[0]) + new_y = self.CanvasSize[1] + scale_y * (y_in - self.BottomLeft[1]) + return new_x, new_y + + def _convert_canvas_xy_to_xy(self, x_in, y_in): + """ + Not user callable. Used to convert tkinter Canvas coords into user's coordinates + + :param x_in: The x coordinate in canvas coordinates + :type x_in: (int) + :param y_in: (int) The y coordinate in canvas coordinates + :type y_in: + :return: The converted USER coordinates + :rtype: (int, int) | Tuple[float, float] + """ + if None in (x_in, y_in): + return None, None + scale_x = (self.CanvasSize[0] - 0) / (self.TopRight[0] - self.BottomLeft[0]) + scale_y = (0 - self.CanvasSize[1]) / (self.TopRight[1] - self.BottomLeft[1]) + + new_x = x_in / scale_x + self.BottomLeft[0] + new_y = (y_in - self.CanvasSize[1]) / scale_y + self.BottomLeft[1] + if self.FloatValues: + return new_x, new_y + else: + return floor(new_x), floor(new_y) + + def draw_line(self, point_from, point_to, color='black', width=1): + """ + Draws a line from one point to another point using USER'S coordinates. Can set the color and width of line + :param point_from: Starting point for line + :type point_from: (int, int) | Tuple[float, float] + :param point_to: Ending point for line + :type point_to: (int, int) | Tuple[float, float] + :param color: Color of the line + :type color: (str) + :param width: width of line in pixels + :type width: (int) + :return: id returned from tktiner or None if user closed the window. id is used when you + :rtype: int | None + """ + if point_from == (None, None): + return + converted_point_from = self._convert_xy_to_canvas_xy(point_from[0], point_from[1]) + converted_point_to = self._convert_xy_to_canvas_xy(point_to[0], point_to[1]) + if self._TKCanvas2 is None: + print('*** WARNING - The Graph element has not been finalized and cannot be drawn upon ***') + print('Call Window.Finalize() prior to this operation') + return None + try: # in case window was closed with an X + id = self._TKCanvas2.create_line(converted_point_from, converted_point_to, width=width, fill=color) + except: + id = None + return id + + def draw_lines(self, points, color='black', width=1): + """ + Draw a series of lines given list of points + + :param points: list of points that define the polygon + :type points: List[(int, int) | Tuple[float, float]] + :param color: Color of the line + :type color: (str) + :param width: width of line in pixels + :type width: (int) + :return: id returned from tktiner or None if user closed the window. id is used when you + :rtype: int | None + """ + converted_points = [self._convert_xy_to_canvas_xy(point[0], point[1]) for point in points] + + try: # in case window was closed with an X + id = self._TKCanvas2.create_line(*converted_points, width=width, fill=color) + except: + if self._TKCanvas2 is None: + print('*** WARNING - The Graph element has not been finalized and cannot be drawn upon ***') + print('Call Window.Finalize() prior to this operation') + id = None + return id + + def draw_point(self, point, size=2, color='black'): + """ + Draws a "dot" at the point you specify using the USER'S coordinate system + :param point: Center location using USER'S coordinate system + :type point: (int, int) | Tuple[float, float] + :param size: Radius? (Or is it the diameter?) in user's coordinate values. + :type size: int | float + :param color: color of the point to draw + :type color: (str) + :return: id returned from tkinter that you'll need if you want to manipulate the point + :rtype: int | None + """ + if point == (None, None): + return + converted_point = self._convert_xy_to_canvas_xy(point[0], point[1]) + size_converted = self._convert_xy_to_canvas_xy(point[0] + size, point[1]) + size = size_converted[0] - converted_point[0] + if self._TKCanvas2 is None: + print('*** WARNING - The Graph element has not been finalized and cannot be drawn upon ***') + print('Call Window.Finalize() prior to this operation') + return None + try: # needed in case window was closed with an X + point1 = converted_point[0] - size // 2, converted_point[1] - size // 2 + point2 = converted_point[0] + size // 2, converted_point[1] + size // 2 + id = self._TKCanvas2.create_oval(point1[0], point1[1], + point2[0], point2[1], + width=0, + fill=color, + outline=color) + except: + id = None + return id + + def draw_circle(self, center_location, radius, fill_color=None, line_color='black', line_width=1): + """ + Draws a circle, cenetered at the location provided. Can set the fill and outline colors + :param center_location: Center location using USER'S coordinate system + :type center_location: (int, int) | Tuple[float, float] + :param radius: Radius in user's coordinate values. + :type radius: int | float + :param fill_color: color of the point to draw + :type fill_color: (str) + :param line_color: color of the outer line that goes around the circle (sorry, can't set thickness) + :type line_color: (str) + :param line_width: width of the line around the circle, the outline, in pixels + :type line_width: (int) + :return: id returned from tkinter that you'll need if you want to manipulate the circle + :rtype: int | None + """ + if center_location == (None, None): + return + converted_point = self._convert_xy_to_canvas_xy(center_location[0], center_location[1]) + radius_converted = self._convert_xy_to_canvas_xy(center_location[0] + radius, center_location[1]) + radius = radius_converted[0] - converted_point[0] + # radius = radius_converted[1]-5 + if self._TKCanvas2 is None: + print('*** WARNING - The Graph element has not been finalized and cannot be drawn upon ***') + print('Call Window.Finalize() prior to this operation') + return None + # print('Oval parms', int(converted_point[0]) - int(radius), int(converted_point[1]) - int(radius), + # int(converted_point[0]) + int(radius), int(converted_point[1]) + int(radius)) + try: # needed in case the window was closed with an X + id = self._TKCanvas2.create_oval(int(converted_point[0]) - int(radius), int(converted_point[1]) - int(radius), + int(converted_point[0]) + int(radius), int(converted_point[1]) + int(radius), fill=fill_color, + outline=line_color, width=line_width) + except: + id = None + return id + + def draw_oval(self, top_left, bottom_right, fill_color=None, line_color=None, line_width=1): + """ + Draws an oval based on coordinates in user coordinate system. Provide the location of a "bounding rectangle" + :param top_left: the top left point of bounding rectangle + :type top_left: (int, int) | Tuple[float, float] + :param bottom_right: the bottom right point of bounding rectangle + :type bottom_right: (int, int) | Tuple[float, float] + :param fill_color: color of the interrior + :type fill_color: (str) + :param line_color: color of outline of oval + :type line_color: (str) + :param line_width: width of the line around the oval, the outline, in pixels + :type line_width: (int) + :return: id returned from tkinter that you'll need if you want to manipulate the oval + :rtype: int | None + """ + converted_top_left = self._convert_xy_to_canvas_xy(top_left[0], top_left[1]) + converted_bottom_right = self._convert_xy_to_canvas_xy(bottom_right[0], bottom_right[1]) + if self._TKCanvas2 is None: + print('*** WARNING - The Graph element has not been finalized and cannot be drawn upon ***') + print('Call Window.Finalize() prior to this operation') + return None + try: # in case windows close with X + id = self._TKCanvas2.create_oval(converted_top_left[0], converted_top_left[1], converted_bottom_right[0], + converted_bottom_right[1], fill=fill_color, outline=line_color, width=line_width) + except: + id = None + + return id + + def draw_arc(self, top_left, bottom_right, extent, start_angle, style=None, arc_color='black', line_width=1, fill_color=None): + """ + Draws different types of arcs. Uses a "bounding box" to define location + :param top_left: the top left point of bounding rectangle + :type top_left: (int, int) | Tuple[float, float] + :param bottom_right: the bottom right point of bounding rectangle + :type bottom_right: (int, int) | Tuple[float, float] + :param extent: Andle to end drawing. Used in conjunction with start_angle + :type extent: (float) + :param start_angle: Angle to begin drawing. Used in conjunction with extent + :type start_angle: (float) + :param style: Valid choices are One of these Style strings- 'pieslice', 'chord', 'arc', 'first', 'last', 'butt', 'projecting', 'round', 'bevel', 'miter' + :type style: (str) + :param arc_color: color to draw arc with + :type arc_color: (str) + :param fill_color: color to fill the area + :type fill_color: (str) + :return: id returned from tkinter that you'll need if you want to manipulate the arc + :rtype: int | None + """ + converted_top_left = self._convert_xy_to_canvas_xy(top_left[0], top_left[1]) + converted_bottom_right = self._convert_xy_to_canvas_xy(bottom_right[0], bottom_right[1]) + tkstyle = tk.PIESLICE if style is None else style + if self._TKCanvas2 is None: + print('*** WARNING - The Graph element has not been finalized and cannot be drawn upon ***') + print('Call Window.Finalize() prior to this operation') + return None + try: # in case closed with X + id = self._TKCanvas2.create_arc(converted_top_left[0], converted_top_left[1], converted_bottom_right[0], + converted_bottom_right[1], extent=extent, start=start_angle, style=tkstyle, + outline=arc_color, width=line_width, fill=fill_color) + except Exception as e: + print('Error encountered drawing arc.', e) + id = None + return id + + def draw_rectangle(self, top_left, bottom_right, fill_color=None, line_color=None, line_width=None): + """ + Draw a rectangle given 2 points. Can control the line and fill colors + + :param top_left: the top left point of rectangle + :type top_left: (int, int) | Tuple[float, float] + :param bottom_right: the bottom right point of rectangle + :type bottom_right: (int, int) | Tuple[float, float] + :param fill_color: color of the interior + :type fill_color: (str) + :param line_color: color of outline + :type line_color: (str) + :param line_width: width of the line in pixels + :type line_width: (int) + :return: int | None id returned from tkinter that you'll need if you want to manipulate the rectangle + :rtype: int | None + """ + + converted_top_left = self._convert_xy_to_canvas_xy(top_left[0], top_left[1]) + converted_bottom_right = self._convert_xy_to_canvas_xy(bottom_right[0], bottom_right[1]) + if self._TKCanvas2 is None: + print('*** WARNING - The Graph element has not been finalized and cannot be drawn upon ***') + print('Call Window.Finalize() prior to this operation') + return None + if line_width is None: + line_width = 1 + try: # in case closed with X + id = self._TKCanvas2.create_rectangle(converted_top_left[0], converted_top_left[1], + converted_bottom_right[0], + converted_bottom_right[1], fill=fill_color, outline=line_color, width=line_width) + except: + id = None + return id + + def draw_polygon(self, points, fill_color=None, line_color=None, line_width=None): + """ + Draw a polygon given list of points + + :param points: list of points that define the polygon + :type points: List[(int, int) | Tuple[float, float]] + :param fill_color: color of the interior + :type fill_color: (str) + :param line_color: color of outline + :type line_color: (str) + :param line_width: width of the line in pixels + :type line_width: (int) + :return: id returned from tkinter that you'll need if you want to manipulate the rectangle + :rtype: int | None + """ + + converted_points = [self._convert_xy_to_canvas_xy(point[0], point[1]) for point in points] + if self._TKCanvas2 is None: + print('*** WARNING - The Graph element has not been finalized and cannot be drawn upon ***') + print('Call Window.Finalize() prior to this operation') + return None + try: # in case closed with X + id = self._TKCanvas2.create_polygon(converted_points, fill=fill_color, outline=line_color, width=line_width) + except: + id = None + return id + + def draw_text(self, text, location, color='black', font=None, angle=0, text_location=TEXT_LOCATION_CENTER): + """ + Draw some text on your graph. This is how you label graph number lines for example + + :param text: text to display + :type text: (Any) + :param location: location to place first letter + :type location: (int, int) | Tuple[float, float] + :param color: text color + :type color: (str) + :param font: specifies the font family, size, etc. Tuple or Single string format 'name size styles'. Styles: italic * roman bold normal underline overstrike + :type font: (str or (str, int[, str]) or None) + :param angle: Angle 0 to 360 to draw the text. Zero represents horizontal text + :type angle: (float) + :param text_location: "anchor" location for the text. Values start with TEXT_LOCATION_ + :type text_location: (enum) + :return: id returned from tkinter that you'll need if you want to manipulate the text + :rtype: int | None + """ + text = str(text) + if location == (None, None): + return + converted_point = self._convert_xy_to_canvas_xy(location[0], location[1]) + if self._TKCanvas2 is None: + print('*** WARNING - The Graph element has not been finalized and cannot be drawn upon ***') + print('Call Window.Finalize() prior to this operation') + return None + try: # in case closed with X + id = self._TKCanvas2.create_text(converted_point[0], converted_point[1], text=text, font=font, fill=color, angle=angle, anchor=text_location) + except: + id = None + return id + + def draw_image(self, filename=None, data=None, location=(None, None)): + """ + Places an image onto your canvas. It's a really important method for this element as it enables so much + + :param filename: if image is in a file, path and filename for the image. (GIF and PNG only!) + :type filename: (str) + :param data: if image is in Base64 format or raw? format then use instead of filename + :type data: str | bytes + :param location: the (x,y) location to place image's top left corner + :type location: (int, int) | Tuple[float, float] + :return: id returned from tkinter that you'll need if you want to manipulate the image + :rtype: int | None + """ + if location == (None, None): + return + if filename is not None: + image = tk.PhotoImage(file=filename) + elif data is not None: + # if type(data) is bytes: + try: + image = tk.PhotoImage(data=data) + except: + return None # an error likely means the window has closed so exit + converted_point = self._convert_xy_to_canvas_xy(location[0], location[1]) + if self._TKCanvas2 is None: + print('*** WARNING - The Graph element has not been finalized and cannot be drawn upon ***') + print('Call Window.Finalize() prior to this operation') + return None + try: # in case closed with X + id = self._TKCanvas2.create_image(converted_point, image=image, anchor=tk.NW) + self.Images[id] = image + except: + id = None + return id + + def erase(self): + """ + Erase the Graph - Removes all figures previously "drawn" using the Graph methods (e.g. DrawText) + """ + if self._TKCanvas2 is None: + print('*** WARNING - The Graph element has not been finalized and cannot be drawn upon ***') + print('Call Window.Finalize() prior to this operation') + return None + self.Images = {} + try: # in case window was closed with X + self._TKCanvas2.delete('all') + except: + pass + + def delete_figure(self, id): + """ + Remove from the Graph the figure represented by id. The id is given to you anytime you call a drawing primitive + + :param id: the id returned to you when calling one of the drawing methods + :type id: (int) + """ + try: + self._TKCanvas2.delete(id) + except: + print('DeleteFigure - bad ID {}'.format(id)) + try: + del self.Images[id] # in case was an image. If wasn't an image, then will get exception + except: + pass + + def update(self, background_color=None, visible=None): + """ + Changes some of the settings for the Graph Element. Must call `Window.Read` or `Window.Finalize` prior + + Changes will not be visible in your window until you call window.read or window.refresh. + + If you change visibility, your element may MOVE. If you want it to remain stationary, use the "layout helper" + function "pin" to ensure your element is "pinned" to that location in your layout so that it returns there + when made visible. + + :param background_color: color of background + :type background_color: ??? + :param visible: control visibility of element + :type visible: (bool) + """ + if not self._widget_was_created(): # if widget hasn't been created yet, then don't allow + return + + if self._this_elements_window_closed(): + _error_popup_with_traceback('Error in Graph.update - The window was closed') + return + + if background_color is not None and background_color != COLOR_SYSTEM_DEFAULT: + self._TKCanvas2.configure(background=background_color) + + if visible is False: + self._pack_forget_save_settings() + elif visible is True: + self._pack_restore_settings() + + if visible is not None: + self._visible = visible + + def move(self, x_direction, y_direction): + """ + Moves the entire drawing area (the canvas) by some delta from the current position. Units are indicated in your coordinate system indicated number of ticks in your coordinate system + + :param x_direction: how far to move in the "X" direction in your coordinates + :type x_direction: int | float + :param y_direction: how far to move in the "Y" direction in your coordinates + :type y_direction: int | float + """ + zero_converted = self._convert_xy_to_canvas_xy(0, 0) + shift_converted = self._convert_xy_to_canvas_xy(x_direction, y_direction) + shift_amount = (shift_converted[0] - zero_converted[0], shift_converted[1] - zero_converted[1]) + if self._TKCanvas2 is None: + print('*** WARNING - The Graph element has not been finalized and cannot be drawn upon ***') + print('Call Window.Finalize() prior to this operation') + return None + self._TKCanvas2.move('all', shift_amount[0], shift_amount[1]) + + def move_figure(self, figure, x_direction, y_direction): + """ + Moves a previously drawn figure using a "delta" from current position + + :param figure: Previously obtained figure-id. These are returned from all Draw methods + :type figure: (id) + :param x_direction: delta to apply to position in the X direction + :type x_direction: int | float + :param y_direction: delta to apply to position in the Y direction + :type y_direction: int | float + """ + zero_converted = self._convert_xy_to_canvas_xy(0, 0) + shift_converted = self._convert_xy_to_canvas_xy(x_direction, y_direction) + shift_amount = (shift_converted[0] - zero_converted[0], shift_converted[1] - zero_converted[1]) + if figure is None: + print('* move_figure warning - your figure is None *') + return None + self._TKCanvas2.move(figure, shift_amount[0], shift_amount[1]) + + def relocate_figure(self, figure, x, y): + """ + Move a previously made figure to an arbitrary (x,y) location. This differs from the Move methods because it + uses absolute coordinates versus relative for Move + + :param figure: Previously obtained figure-id. These are returned from all Draw methods + :type figure: (id) + :param x: location on X axis (in user coords) to move the upper left corner of the figure + :type x: int | float + :param y: location on Y axis (in user coords) to move the upper left corner of the figure + :type y: int | float + """ + + zero_converted = self._convert_xy_to_canvas_xy(0, 0) + shift_converted = self._convert_xy_to_canvas_xy(x, y) + shift_amount = (shift_converted[0] - zero_converted[0], shift_converted[1] - zero_converted[1]) + if figure is None: + print('*** WARNING - Your figure is None. It most likely means your did not Finalize your Window ***') + print('Call Window.Finalize() prior to all graph operations') + return None + xy = self._TKCanvas2.coords(figure) + self._TKCanvas2.move(figure, shift_converted[0] - xy[0], shift_converted[1] - xy[1]) + + def send_figure_to_back(self, figure): + """ + Changes Z-order of figures on the Graph. Sends the indicated figure to the back of all other drawn figures + + :param figure: value returned by tkinter when creating the figure / drawing + :type figure: (int) + """ + self.TKCanvas.tag_lower(figure) # move figure to the "bottom" of all other figure + + def bring_figure_to_front(self, figure): + """ + Changes Z-order of figures on the Graph. Brings the indicated figure to the front of all other drawn figures + + :param figure: value returned by tkinter when creating the figure / drawing + :type figure: (int) + """ + self.TKCanvas.tag_raise(figure) # move figure to the "top" of all other figures + + def get_figures_at_location(self, location): + """ + Returns a list of figures located at a particular x,y location within the Graph + + :param location: point to check + :type location: (int, int) | Tuple[float, float] + :return: a list of previously drawn "Figures" (returned from the drawing primitives) + :rtype: List[int] + """ + x, y = self._convert_xy_to_canvas_xy(location[0], location[1]) + ids = self.TKCanvas.find_overlapping(x, y, x, y) + return ids + + def get_bounding_box(self, figure): + """ + Given a figure, returns the upper left and lower right bounding box coordinates + + :param figure: a previously drawing figure + :type figure: object + :return: upper left x, upper left y, lower right x, lower right y + :rtype: Tuple[int, int, int, int] | Tuple[float, float, float, float] + """ + box = self.TKCanvas.bbox(figure) + top_left = self._convert_canvas_xy_to_xy(box[0], box[1]) + bottom_right = self._convert_canvas_xy_to_xy(box[2], box[3]) + return top_left, bottom_right + + def change_coordinates(self, graph_bottom_left, graph_top_right): + """ + Changes the corrdinate system to a new one. The same 2 points in space are used to define the coorinate + system - the bottom left and the top right values of your graph. + + :param graph_bottom_left: The bottoms left corner of your coordinate system + :type graph_bottom_left: (int, int) (x,y) + :param graph_top_right: The top right corner of your coordinate system + :type graph_top_right: (int, int) (x,y) + """ + self.BottomLeft = graph_bottom_left + self.TopRight = graph_top_right + + @property + def tk_canvas(self): + """ + Returns the underlying tkiner Canvas widget + + :return: The tkinter canvas widget + :rtype: (tk.Canvas) + """ + if self._TKCanvas2 is None: + print('*** Did you forget to call Finalize()? Your code should look something like: ***') + print('*** form = sg.Window("My Form").Layout(layout).Finalize() ***') + return self._TKCanvas2 + + # button release callback + def button_release_call_back(self, event): + """ + Not a user callable method. Used to get Graph click events. Called by tkinter when button is released + + :param event: (event) event info from tkinter. Note not used in this method + :type event: + """ + if not self.DragSubmits: + return # only report mouse up for drag operations + self.ClickPosition = self._convert_canvas_xy_to_xy(event.x, event.y) + self.ParentForm.LastButtonClickedWasRealtime = False + if self.Key is not None: + self.ParentForm.LastButtonClicked = self.Key + else: + self.ParentForm.LastButtonClicked = '__GRAPH__' # need to put something rather than None + _exit_mainloop(self.ParentForm) + if isinstance(self.ParentForm.LastButtonClicked, str): + self.ParentForm.LastButtonClicked = self.ParentForm.LastButtonClicked + '+UP' + else: + self.ParentForm.LastButtonClicked = (self.ParentForm.LastButtonClicked, '+UP') + self.MouseButtonDown = False + + + # button callback + def button_press_call_back(self, event): + """ + Not a user callable method. Used to get Graph click events. Called by tkinter when button is released + + :param event: (event) event info from tkinter. Contains the x and y coordinates of a click + :type event: + """ + + self.ClickPosition = self._convert_canvas_xy_to_xy(event.x, event.y) + self.ParentForm.LastButtonClickedWasRealtime = self.DragSubmits + if self.Key is not None: + self.ParentForm.LastButtonClicked = self.Key + else: + self.ParentForm.LastButtonClicked = '__GRAPH__' # need to put something rather than None + # if self.ParentForm.CurrentlyRunningMainloop: + # self.ParentForm.TKroot.quit() # kick out of loop if read was called + _exit_mainloop(self.ParentForm) + self.MouseButtonDown = True + + def _update_position_for_returned_values(self, event): + """ + Updates the variable that's used when the values dictionary is returned from a window read. + + Not called by the user. It's called from another method/function that tkinter calledback + + :param event: (event) event info from tkinter. Contains the x and y coordinates of a click + :type event: + """ + """ + Updates the variable that's used when the values dictionary is returned from a window read. + + Not called by the user. It's called from another method/function that tkinter calledback + + :param event: (event) event info from tkinter. Contains the x and y coordinates of a click + :type event: + """ + + self.ClickPosition = self._convert_canvas_xy_to_xy(event.x, event.y) + + # button callback + def motion_call_back(self, event): + """ + Not a user callable method. Used to get Graph mouse motion events. Called by tkinter when mouse moved + + :param event: (event) event info from tkinter. Contains the x and y coordinates of a mouse + :type event: + """ + + if not self.MouseButtonDown and not self.motion_events: + return + self.ClickPosition = self._convert_canvas_xy_to_xy(event.x, event.y) + self.ParentForm.LastButtonClickedWasRealtime = self.DragSubmits + if self.Key is not None: + self.ParentForm.LastButtonClicked = self.Key + else: + self.ParentForm.LastButtonClicked = '__GRAPH__' # need to put something rather than None + # if self.ParentForm.CurrentlyRunningMainloop: + # self.ParentForm.TKroot.quit() # kick out of loop if read was called + if self.motion_events and not self.MouseButtonDown: + if isinstance(self.ParentForm.LastButtonClicked, str): + self.ParentForm.LastButtonClicked = self.ParentForm.LastButtonClicked + '+MOVE' + else: + self.ParentForm.LastButtonClicked = (self.ParentForm.LastButtonClicked, '+MOVE') + _exit_mainloop(self.ParentForm) + + BringFigureToFront = bring_figure_to_front + ButtonPressCallBack = button_press_call_back + ButtonReleaseCallBack = button_release_call_back + DeleteFigure = delete_figure + DrawArc = draw_arc + DrawCircle = draw_circle + DrawImage = draw_image + DrawLine = draw_line + DrawOval = draw_oval + DrawPoint = draw_point + DrawPolygon = draw_polygon + DrawLines = draw_lines + DrawRectangle = draw_rectangle + DrawText = draw_text + GetFiguresAtLocation = get_figures_at_location + GetBoundingBox = get_bounding_box + Erase = erase + MotionCallBack = motion_call_back + Move = move + MoveFigure = move_figure + RelocateFigure = relocate_figure + SendFigureToBack = send_figure_to_back + TKCanvas = tk_canvas + Update = update + + +G = Graph + + +# ---------------------------------------------------------------------- # +# Frame # +# ---------------------------------------------------------------------- # +class Frame(Element): + """ + A Frame Element that contains other Elements. Encloses with a line around elements and a text label. + """ + + def __init__(self, title, layout, title_color=None, background_color=None, title_location=None, + relief=DEFAULT_FRAME_RELIEF, size=(None, None), s=(None, None), font=None, pad=None, p=None, border_width=None, key=None, k=None, + tooltip=None, right_click_menu=None, expand_x=False, expand_y=False, grab=None, visible=True, element_justification='left', vertical_alignment=None, metadata=None): + """ + :param title: text that is displayed as the Frame's "label" or title + :type title: (str) + :param layout: The layout to put inside the Frame + :type layout: List[List[Elements]] + :param title_color: color of the title text + :type title_color: (str) + :param background_color: background color of the Frame + :type background_color: (str) + :param title_location: location to place the text title. Choices include: TITLE_LOCATION_TOP TITLE_LOCATION_BOTTOM TITLE_LOCATION_LEFT TITLE_LOCATION_RIGHT TITLE_LOCATION_TOP_LEFT TITLE_LOCATION_TOP_RIGHT TITLE_LOCATION_BOTTOM_LEFT TITLE_LOCATION_BOTTOM_RIGHT + :type title_location: (enum) + :param relief: relief style. Values are same as other elements with reliefs. Choices include RELIEF_RAISED RELIEF_SUNKEN RELIEF_FLAT RELIEF_RIDGE RELIEF_GROOVE RELIEF_SOLID + :type relief: (enum) + :param size: (width, height) Sets an initial hard-coded size for the Frame. This used to be a problem, but was fixed in 4.53.0 and works better than Columns when using the size paramter + :type size: (int, int) + :param s: Same as size parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, size will be used + :type s: (int, int) | (None, None) | int + :param font: specifies the font family, size, etc. for the TITLE. Tuple or Single string format 'name size styles'. Styles: italic * roman bold normal underline overstrike + :type font: (str or (str, int[, str]) or None) + :param pad: Amount of padding to put around element in pixels (left/right, top/bottom) or ((left, right), (top, bottom)) or an int. If an int, then it's converted into a tuple (int, int) + :type pad: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param p: Same as pad parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, pad will be used + :type p: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param border_width: width of border around element in pixels + :type border_width: (int) + :param key: Value that uniquely identifies this element from all other elements. Used when Finding an element or in return values. Must be unique to the window + :type key: str | int | tuple | object + :param k: Same as the Key. You can use either k or key. Which ever is set will be used. + :type k: str | int | tuple | object + :param tooltip: text, that will appear when mouse hovers over the element + :type tooltip: (str) + :param right_click_menu: A list of lists of Menu items to show when this element is right clicked. See user docs for exact format. + :type right_click_menu: List[List[ List[str] | str ]] + :param expand_x: If True the element will automatically expand in the X direction to fill available space + :type expand_x: (bool) + :param expand_y: If True the element will automatically expand in the Y direction to fill available space + :type expand_y: (bool) + :param grab: If True can grab this element and move the window around. Default is False + :type grab: (bool) + :param visible: set visibility state of the element + :type visible: (bool) + :param element_justification: All elements inside the Frame will have this justification 'left', 'right', 'center' are valid values + :type element_justification: (str) + :param vertical_alignment: Place the Frame at the 'top', 'center', 'bottom' of the row (can also use t,c,r). Defaults to no setting (tkinter decides) + :type vertical_alignment: (str) + :param metadata: User metadata that can be set to ANYTHING + :type metadata: (Any) + """ + + + self.UseDictionary = False + self.ReturnValues = None + self.ReturnValuesList = [] + self.ReturnValuesDictionary = {} + self.DictionaryKeyCounter = 0 + self.ParentWindow = None + self.Rows = [] + # self.ParentForm = None + self.TKFrame = None + self.Title = title + self.Relief = relief + self.TitleLocation = title_location + self.BorderWidth = border_width + self.BackgroundColor = background_color if background_color is not None else DEFAULT_BACKGROUND_COLOR + self.RightClickMenu = right_click_menu + self.ContainerElemementNumber = Window._GetAContainerNumber() + self.ElementJustification = element_justification + self.VerticalAlignment = vertical_alignment + self.Widget = None # type: tk.LabelFrame + self.Grab = grab + self.Layout(layout) + key = key if key is not None else k + sz = size if size != (None, None) else s + pad = pad if pad is not None else p + self.expand_x = expand_x + self.expand_y = expand_y + + super().__init__(ELEM_TYPE_FRAME, background_color=background_color, text_color=title_color, size=sz, + font=font, pad=pad, key=key, tooltip=tooltip, visible=visible, metadata=metadata) + return + + + def add_row(self, *args): + """ + Not recommended user call. Used to add rows of Elements to the Frame Element. + + :param *args: The list of elements for this row + :type *args: List[Element] + """ + NumRows = len(self.Rows) # number of existing rows is our row number + CurrentRowNumber = NumRows # this row's number + CurrentRow = [] # start with a blank row and build up + # ------------------------- Add the elements to a row ------------------------- # + for i, element in enumerate(args): # Loop through list of elements and add them to the row + if type(element) == list: + PopupError('Error creating Frame layout', + 'Layout has a LIST instead of an ELEMENT', + 'This sometimes means you have a badly placed ]', + 'The offensive list is:', + element, + 'This list will be stripped from your layout', + keep_on_top=True + ) + continue + elif callable(element) and not isinstance(element, Element): + PopupError('Error creating Frame layout', + 'Layout has a FUNCTION instead of an ELEMENT', + 'This likely means you are missing () from your layout', + 'The offensive list is:', + element, + 'This item will be stripped from your layout', + keep_on_top=True) + continue + if element.ParentContainer is not None: + warnings.warn( + '*** YOU ARE ATTEMPTING TO REUSE AN ELEMENT IN YOUR LAYOUT! Once placed in a layout, an element cannot be used in another layout. ***', + UserWarning) + _error_popup_with_traceback('Error creating Frame layout', + 'The layout specified has already been used', + 'You MUST start witha "clean", unused layout every time you create a window', + 'The offensive Element = ', + element, + 'and has a key = ', element.Key, + 'This item will be stripped from your layout', + 'Hint - try printing your layout and matching the IDs "print(layout)"', + ) + continue + element.Position = (CurrentRowNumber, i) + element.ParentContainer = self + CurrentRow.append(element) + if element.Key is not None: + self.UseDictionary = True + # ------------------------- Append the row to list of Rows ------------------------- # + self.Rows.append(CurrentRow) + + def layout(self, rows): + """ + Can use like the Window.Layout method, but it's better to use the layout parameter when creating + + :param rows: The rows of Elements + :type rows: List[List[Element]] + :return: Used for chaining + :rtype: (Frame) + """ + + for row in rows: + try: + iter(row) + except TypeError: + PopupError('Error creating Frame layout', + 'Your row is not an iterable (e.g. a list)', + 'Instead of a list, the type found was {}'.format(type(row)), + 'The offensive row = ', + row, + 'This item will be stripped from your layout', keep_on_top=True, image=_random_error_emoji()) + continue + self.AddRow(*row) + return self + + def _GetElementAtLocation(self, location): + """ + Not user callable. Used to find the Element at a row, col position within the layout + + :param location: (row, column) position of the element to find in layout + :type location: (int, int) + :return: (Element) The element found at the location + :rtype: (Element) + """ + + (row_num, col_num) = location + row = self.Rows[row_num] + element = row[col_num] + return element + + def update(self, value=None, visible=None): + """ + Changes some of the settings for the Frame Element. Must call `Window.Read` or `Window.Finalize` prior + + Changes will not be visible in your window until you call window.read or window.refresh. + + If you change visibility, your element may MOVE. If you want it to remain stationary, use the "layout helper" + function "pin" to ensure your element is "pinned" to that location in your layout so that it returns there + when made visible. + + :param value: New text value to show on frame + :type value: (Any) + :param visible: control visibility of element + :type visible: (bool) + """ + if not self._widget_was_created(): # if widget hasn't been created yet, then don't allow + return + + if self._this_elements_window_closed(): + _error_popup_with_traceback('Error in Frame.update - The window was closed') + return + + if visible is False: + self._pack_forget_save_settings() + # self.TKFrame.pack_forget() + elif visible is True: + self._pack_restore_settings() + # self.TKFrame.pack(padx=self.pad_used[0], pady=self.pad_used[1]) + if value is not None: + self.TKFrame.config(text=str(value)) + if visible is not None: + self._visible = visible + + AddRow = add_row + Layout = layout + Update = update + + +Fr = Frame + + +# ---------------------------------------------------------------------- # +# Vertical Separator # +# ---------------------------------------------------------------------- # +class VerticalSeparator(Element): + """ + Vertical Separator Element draws a vertical line at the given location. It will span 1 "row". Usually paired with + Column Element if extra height is needed + """ + + def __init__(self, color=None, pad=None, p=None, key=None, k=None): + """ + :param color: Color of the line. Defaults to theme's text color. Can be name or #RRGGBB format + :type color: (str) + :param pad: Amount of padding to put around element in pixels (left/right, top/bottom) or ((left, right), (top, bottom)) or an int. If an int, then it's converted into a tuple (int, int) + :type pad: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param p: Same as pad parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, pad will be used + :type p: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param key: Value that uniquely identifies this element from all other elements. Used when Finding an element or in return values. Must be unique to the window + :type key: str | int | tuple | object + :param k: Same as the Key. You can use either k or key. Which ever is set will be used. + :type k: str | int | tuple | object + """ + key = key if key is not None else k + pad = pad if pad is not None else p + self.expand_x = None + self.expand_y = None + self.Orientation = 'vertical' # for now only vertical works + self.color = color if color is not None else theme_text_color() + super().__init__(ELEM_TYPE_SEPARATOR, pad=pad, key=key) + + +VSeperator = VerticalSeparator +VSeparator = VerticalSeparator +VSep = VerticalSeparator + + +# ---------------------------------------------------------------------- # +# Horizontal Separator # +# ---------------------------------------------------------------------- # +class HorizontalSeparator(Element): + """ + Horizontal Separator Element draws a Horizontal line at the given location. + """ + + def __init__(self, color=None, pad=None, p=None, key=None, k=None): + """ + :param color: Color of the line. Defaults to theme's text color. Can be name or #RRGGBB format + :type color: (str) + :param pad: Amount of padding to put around element in pixels (left/right, top/bottom) or ((left, right), (top, bottom)) or an int. If an int, then it's converted into a tuple (int, int) + :type pad: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param p: Same as pad parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, pad will be used + :type p: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param key: Value that uniquely identifies this element from all other elements. Used when Finding an element or in return values. Must be unique to the window + :type key: str | int | tuple | object + :param k: Same as the Key. You can use either k or key. Which ever is set will be used. + :type k: str | int | tuple | object + """ + + self.Orientation = 'horizontal' # for now only vertical works + self.color = color if color is not None else theme_text_color() + self.expand_x = True + self.expand_y = None + key = key if key is not None else k + pad = pad if pad is not None else p + + super().__init__(ELEM_TYPE_SEPARATOR, pad=pad, key=key) + + +HSeparator = HorizontalSeparator +HSep = HorizontalSeparator + + +# ---------------------------------------------------------------------- # +# Sizegrip # +# ---------------------------------------------------------------------- # +class Sizegrip(Element): + """ + Sizegrip element will be added to the bottom right corner of your window. + It should be placed on the last row of your window along with any other elements on that row. + The color will match the theme's background color. + """ + + def __init__(self, background_color=None, pad=None, p=(0,0), key=None, k=None): + """ + Sizegrip Element + :param background_color: color to use for the background of the grip + :type background_color: str + :param pad: Amount of padding to put around element in pixels (left/right, top/bottom) or ((left, right), (top, bottom)) or an int. If an int, then it's converted into a tuple (int, int) + :type pad: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param p: Same as pad parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, pad will be used + :type p: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param key: Value that uniquely identifies this element from all other elements. Used when Finding an element or in return values. Must be unique to the window + :type key: str | int | tuple | object + :param k: Same as the Key. You can use either k or key. Which ever is set will be used. + :type k: str | int | tuple | object + """ + + bg = background_color if background_color is not None else theme_background_color() + pad = pad if pad is not None else p + key = key if key is not None else k + + + super().__init__(ELEM_TYPE_SIZEGRIP, background_color=bg,key=key, pad=pad) + + +SGrip = Sizegrip + + +# ---------------------------------------------------------------------- # +# Tab # +# ---------------------------------------------------------------------- # +class Tab(Element): + """ + Tab Element is another "Container" element that holds a layout and displays a tab with text. Used with TabGroup only + Tabs are never placed directly into a layout. They are always "Contained" in a TabGroup layout + """ + + def __init__(self, title, layout, title_color=None, background_color=None, font=None, pad=None, p=None, disabled=False, + border_width=None, key=None, k=None, tooltip=None, right_click_menu=None, expand_x=False, expand_y=False, visible=True, element_justification='left', image_source=None, image_subsample=None, image_zoom=None, metadata=None): + """ + :param title: text to show on the tab + :type title: (str) + :param layout: The element layout that will be shown in the tab + :type layout: List[List[Element]] + :param title_color: color of the tab text (note not currently working on tkinter) + :type title_color: (str) + :param background_color: color of background of the entire layout + :type background_color: (str) + :param font: NOT USED in the tkinter port + :type font: (str or (str, int[, str]) or None) + :param pad: Amount of padding to put around element in pixels (left/right, top/bottom) or ((left, right), (top, bottom)) or an int. If an int, then it's converted into a tuple (int, int) + :type pad: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param p: Same as pad parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, pad will be used + :type p: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param disabled: If True button will be created disabled + :type disabled: (bool) + :param border_width: NOT USED in tkinter port + :type border_width: (int) + :param key: Value that uniquely identifies this element from all other elements. Used when Finding an element or in return values. Must be unique to the window + :type key: str | int | tuple | object + :param k: Same as the Key. You can use either k or key. Which ever is set will be used. + :type k: str | int | tuple | object + :param tooltip: text, that will appear when mouse hovers over the element + :type tooltip: (str) + :param right_click_menu: A list of lists of Menu items to show when this element is right clicked. See user docs for exact format. + :type right_click_menu: List[List[ List[str] | str ]] + :param expand_x: If True the element will automatically expand in the X direction to fill available space + :type expand_x: (bool) + :param expand_y: If True the element will automatically expand in the Y direction to fill available space + :type expand_y: (bool) + :param visible: set visibility state of the element + :type visible: (bool) + :param element_justification: All elements inside the Tab will have this justification 'left', 'right', 'center' are valid values + :type element_justification: (str) + :param image_source: A filename or a base64 bytes of an image to place on the Tab + :type image_source: str | bytes | None + :param image_subsample: amount to reduce the size of the image. Divides the size by this number. 2=1/2, 3=1/3, 4=1/4, etc + :type image_subsample: (int) + :param image_zoom: amount to increase the size of the image. 2=twice size, 3=3 times, etc + :type image_zoom: (int) + :param metadata: User metadata that can be set to ANYTHING + :type metadata: (Any) + """ + + filename = data = None + if image_source is not None: + if isinstance(image_source, bytes): + data = image_source + elif isinstance(image_source, str): + filename = image_source + else: + warnings.warn('Image element - source is not a valid type: {}'.format(type(image_source)), UserWarning) + + self.Filename = filename + self.Data = data + self.ImageSubsample = image_subsample + self.zoom = int(image_zoom) if image_zoom is not None else None + self.UseDictionary = False + self.ReturnValues = None + self.ReturnValuesList = [] + self.ReturnValuesDictionary = {} + self.DictionaryKeyCounter = 0 + self.ParentWindow = None + self.Rows = [] + self.TKFrame = None + self.Widget = None # type: tk.Frame + self.Title = title + self.BorderWidth = border_width + self.Disabled = disabled + self.ParentNotebook = None + self.TabID = None + self.BackgroundColor = background_color if background_color is not None else DEFAULT_BACKGROUND_COLOR + self.RightClickMenu = right_click_menu + self.ContainerElemementNumber = Window._GetAContainerNumber() + self.ElementJustification = element_justification + key = key if key is not None else k + pad = pad if pad is not None else p + self.expand_x = expand_x + self.expand_y = expand_y + + self.Layout(layout) + + super().__init__(ELEM_TYPE_TAB, background_color=background_color, text_color=title_color, font=font, pad=pad, key=key, tooltip=tooltip, + visible=visible, metadata=metadata) + return + + def add_row(self, *args): + """ + Not recommended use call. Used to add rows of Elements to the Frame Element. + + :param *args: The list of elements for this row + :type *args: List[Element] + """ + NumRows = len(self.Rows) # number of existing rows is our row number + CurrentRowNumber = NumRows # this row's number + CurrentRow = [] # start with a blank row and build up + # ------------------------- Add the elements to a row ------------------------- # + for i, element in enumerate(args): # Loop through list of elements and add them to the row + if type(element) == list: + popup_error_with_traceback('Error creating Tab layout', + 'Layout has a LIST instead of an ELEMENT', + 'This sometimes means you have a badly placed ]', + 'The offensive list is:', + element, + 'This list will be stripped from your layout') + continue + elif callable(element) and not isinstance(element, Element): + popup_error_with_traceback('Error creating Tab layout', + 'Layout has a FUNCTION instead of an ELEMENT', + 'This likely means you are missing () from your layout', + 'The offensive list is:', + element, + 'This item will be stripped from your layout') + continue + if element.ParentContainer is not None: + warnings.warn( + '*** YOU ARE ATTEMPTING TO REUSE AN ELEMENT IN YOUR LAYOUT! Once placed in a layout, an element cannot be used in another layout. ***', + UserWarning) + popup_error_with_traceback('Error creating Tab layout', + 'The layout specified has already been used', + 'You MUST start witha "clean", unused layout every time you create a window', + 'The offensive Element = ', + element, + 'and has a key = ', element.Key, + 'This item will be stripped from your layout', + 'Hint - try printing your layout and matching the IDs "print(layout)"') + continue + element.Position = (CurrentRowNumber, i) + element.ParentContainer = self + CurrentRow.append(element) + if element.Key is not None: + self.UseDictionary = True + # ------------------------- Append the row to list of Rows ------------------------- # + self.Rows.append(CurrentRow) + + def layout(self, rows): + """ + Not user callable. Use layout parameter instead. Creates the layout using the supplied rows of Elements + + :param rows: List[List[Element]] The list of rows + :type rows: List[List[Element]] + :return: (Tab) used for chaining + :rtype: + """ + + for row in rows: + try: + iter(row) + except TypeError: + PopupError('Error creating Tab layout', + 'Your row is not an iterable (e.g. a list)', + 'Instead of a list, the type found was {}'.format(type(row)), + 'The offensive row = ', + row, + 'This item will be stripped from your layout', keep_on_top=True, image=_random_error_emoji()) + continue + self.AddRow(*row) + return self + + def update(self, title=None, disabled=None, visible=None): + """ + Changes some of the settings for the Tab Element. Must call `Window.Read` or `Window.Finalize` prior + + Changes will not be visible in your window until you call window.read or window.refresh. + + If you change visibility, your element may MOVE. If you want it to remain stationary, use the "layout helper" + function "pin" to ensure your element is "pinned" to that location in your layout so that it returns there + when made visible. + + :param title: tab title + :type title: (str) + :param disabled: disable or enable state of the element + :type disabled: (bool) + :param visible: control visibility of element + :type visible: (bool) + """ + if not self._widget_was_created(): # if widget hasn't been created yet, then don't allow + return + + if self._this_elements_window_closed(): + _error_popup_with_traceback('Error in Tab.update - The window was closed') + return + + + state = 'normal' + if disabled is not None: + self.Disabled = disabled + if disabled: + state = 'disabled' + if visible is False: + state = 'hidden' + if visible is not None: + self._visible = visible + + self.ParentNotebook.tab(self.TabID, state=state) + + if title is not None: + self.Title = str(title) + self.ParentNotebook.tab(self.TabID, text=self.Title) + # self.ParentNotebook.tab(self.ContainerElemementNumber-1, text=self.Title) + + # if visible is False: + # self.ParentNotebook.pack_forget() + # elif visible is True: + # self.ParentNotebook.pack() + return self + + def _GetElementAtLocation(self, location): + """ + Not user callable. Used to find the Element at a row, col position within the layout + + :param location: (row, column) position of the element to find in layout + :type location: (int, int) + :return: The element found at the location + :rtype: (Element) + """ + + (row_num, col_num) = location + row = self.Rows[row_num] + element = row[col_num] + return element + + def select(self): + """ + Create a tkinter event that mimics user clicking on a tab. Must have called window.Finalize / Read first! + + """ + # Use a try in case the window has been destoyed + try: + self.ParentNotebook.select(self.TabID) + except Exception as e: + print('Exception Selecting Tab {}'.format(e)) + + AddRow = add_row + Layout = layout + Select = select + Update = update + + +# ---------------------------------------------------------------------- # +# TabGroup # +# ---------------------------------------------------------------------- # +class TabGroup(Element): + """ + TabGroup Element groups together your tabs into the group of tabs you see displayed in your window + """ + + def __init__(self, layout, tab_location=None, title_color=None, tab_background_color=None, selected_title_color=None, selected_background_color=None, + background_color=None, focus_color=None, font=None, change_submits=False, enable_events=False, pad=None, p=None, border_width=None, tab_border_width=None, theme=None, key=None, k=None, + size=(None, None), s=(None, None), tooltip=None, right_click_menu=None, expand_x=False, expand_y=False, visible=True, metadata=None): + """ + :param layout: Layout of Tabs. Different than normal layouts. ALL Tabs should be on first row + :type layout: List[List[Tab]] + :param tab_location: location that tabs will be displayed. Choices are left, right, top, bottom, lefttop, leftbottom, righttop, rightbottom, bottomleft, bottomright, topleft, topright + :type tab_location: (str) + :param title_color: color of text on tabs + :type title_color: (str) + :param tab_background_color: color of all tabs that are not selected + :type tab_background_color: (str) + :param selected_title_color: color of tab text when it is selected + :type selected_title_color: (str) + :param selected_background_color: color of tab when it is selected + :type selected_background_color: (str) + :param background_color: color of background area that tabs are located on + :type background_color: (str) + :param focus_color: color of focus indicator on the tabs + :type focus_color: (str) + :param font: specifies the font family, size, etc. Tuple or Single string format 'name size styles'. Styles: italic * roman bold normal underline overstrike + :type font: (str or (str, int[, str]) or None) + :param change_submits: * DEPRICATED DO NOT USE. Use `enable_events` instead + :type change_submits: (bool) + :param enable_events: If True then switching tabs will generate an Event + :type enable_events: (bool) + :param pad: Amount of padding to put around element in pixels (left/right, top/bottom) or ((left, right), (top, bottom)) or an int. If an int, then it's converted into a tuple (int, int) + :type pad: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param p: Same as pad parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, pad will be used + :type p: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param border_width: width of border around element in pixels + :type border_width: (int) + :param tab_border_width: width of border around the tabs + :type tab_border_width: (int) + :param theme: DEPRICATED - You can only specify themes using set options or when window is created. It's not possible to do it on an element basis + :type theme: (enum) + :param key: Value that uniquely identifies this element from all other elements. Used when Finding an element or in return values. Must be unique to the window + :type key: str | int | tuple | object + :param k: Same as the Key. You can use either k or key. Which ever is set will be used. + :type k: str | int | tuple | object + :param size: (width, height) w=pixels-wide, h=pixels-high. Either item in tuple can be None to indicate use the computed value and set only 1 direction + :type size: (int|None, int|None) + :param s: Same as size parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, size will be used + :type s: (int|None, int|None) + :param tooltip: text, that will appear when mouse hovers over the element + :type tooltip: (str) + :param right_click_menu: A list of lists of Menu items to show when this element is right clicked. See user docs for exact format. + :type right_click_menu: List[List[ List[str] | str ]] + :param expand_x: If True the element will automatically expand in the X direction to fill available space + :type expand_x: (bool) + :param expand_y: If True the element will automatically expand in the Y direction to fill available space + :type expand_y: (bool) + :param visible: DEPRECATED - Should you need to control visiblity for the TabGroup as a whole, place it into a Column element + :type visible: (bool) + :param metadata: User metadata that can be set to ANYTHING + :type metadata: (Any) + """ + + + self.UseDictionary = False + self.ReturnValues = None + self.ReturnValuesList = [] + self.ReturnValuesDictionary = {} + self.DictionaryKeyCounter = 0 + self.ParentWindow = None + self.SelectedTitleColor = selected_title_color if selected_title_color is not None else LOOK_AND_FEEL_TABLE[CURRENT_LOOK_AND_FEEL]['TEXT'] + self.SelectedBackgroundColor = selected_background_color if selected_background_color is not None else LOOK_AND_FEEL_TABLE[CURRENT_LOOK_AND_FEEL][ + 'BACKGROUND'] + title_color = title_color if title_color is not None else LOOK_AND_FEEL_TABLE[CURRENT_LOOK_AND_FEEL]['TEXT_INPUT'] + self.TabBackgroundColor = tab_background_color if tab_background_color is not None else LOOK_AND_FEEL_TABLE[CURRENT_LOOK_AND_FEEL]['INPUT'] + self.Rows = [] + self.TKNotebook = None # type: ttk.Notebook + self.Widget = None # type: ttk.Notebook + self.tab_index_to_key = {} # has a list of the tabs in the notebook and their associated key + self.TabCount = 0 + self.BorderWidth = border_width + self.BackgroundColor = background_color if background_color is not None else DEFAULT_BACKGROUND_COLOR + self.ChangeSubmits = change_submits or enable_events + self.TabLocation = tab_location + self.ElementJustification = 'left' + self.RightClickMenu = right_click_menu + self.TabBorderWidth = tab_border_width + self.FocusColor = focus_color + + key = key if key is not None else k + sz = size if size != (None, None) else s + pad = pad if pad is not None else p + self.expand_x = expand_x + self.expand_y = expand_y + + self.Layout(layout) + + super().__init__(ELEM_TYPE_TAB_GROUP, size=sz, background_color=background_color, text_color=title_color, font=font, + pad=pad, key=key, tooltip=tooltip, visible=visible, metadata=metadata) + return + + def add_row(self, *args): + """ + Not recommended user call. Used to add rows of Elements to the Frame Element. + + :param *args: The list of elements for this row + :type *args: List[Element] + """ + + NumRows = len(self.Rows) # number of existing rows is our row number + CurrentRowNumber = NumRows # this row's number + CurrentRow = [] # start with a blank row and build up + # ------------------------- Add the elements to a row ------------------------- # + for i, element in enumerate(args): # Loop through list of elements and add them to the row + if type(element) == list: + PopupError('Error creating Tab layout', + 'Layout has a LIST instead of an ELEMENT', + 'This sometimes means you have a badly placed ]', + 'The offensive list is:', + element, + 'This list will be stripped from your layout', keep_on_top=True, image=_random_error_emoji() + ) + continue + elif callable(element) and not isinstance(element, Element): + PopupError('Error creating Tab layout', + 'Layout has a FUNCTION instead of an ELEMENT', + 'This likely means you are missing () from your layout', + 'The offensive list is:', + element, + 'This item will be stripped from your layout', keep_on_top=True, image=_random_error_emoji()) + continue + if element.ParentContainer is not None: + warnings.warn( + '*** YOU ARE ATTEMPTING TO REUSE AN ELEMENT IN YOUR LAYOUT! Once placed in a layout, an element cannot be used in another layout. ***', + UserWarning) + PopupError('Error creating Tab layout', + 'The layout specified has already been used', + 'You MUST start witha "clean", unused layout every time you create a window', + 'The offensive Element = ', + element, + 'and has a key = ', element.Key, + 'This item will be stripped from your layout', + 'Hint - try printing your layout and matching the IDs "print(layout)"', keep_on_top=True, image=_random_error_emoji()) + continue + element.Position = (CurrentRowNumber, i) + element.ParentContainer = self + CurrentRow.append(element) + if element.Key is not None: + self.UseDictionary = True + # ------------------------- Append the row to list of Rows ------------------------- # + self.Rows.append(CurrentRow) + + def layout(self, rows): + """ + Can use like the Window.Layout method, but it's better to use the layout parameter when creating + + :param rows: The rows of Elements + :type rows: List[List[Element]] + :return: Used for chaining + :rtype: (Frame) + """ + for row in rows: + try: + iter(row) + except TypeError: + PopupError('Error creating Tab layout', + 'Your row is not an iterable (e.g. a list)', + 'Instead of a list, the type found was {}'.format(type(row)), + 'The offensive row = ', + row, + 'This item will be stripped from your layout', keep_on_top=True, image=_random_error_emoji()) + continue + self.AddRow(*row) + return self + + def _GetElementAtLocation(self, location): + """ + Not user callable. Used to find the Element at a row, col position within the layout + + :param location: (row, column) position of the element to find in layout + :type location: (int, int) + :return: The element found at the location + :rtype: (Element) + """ + + (row_num, col_num) = location + row = self.Rows[row_num] + element = row[col_num] + return element + + def find_key_from_tab_name(self, tab_name): + """ + Searches through the layout to find the key that matches the text on the tab. Implies names should be unique + + :param tab_name: name of a tab + :type tab_name: str + :return: Returns the key or None if no key found + :rtype: key | None + """ + for row in self.Rows: + for element in row: + if element.Title == tab_name: + return element.Key + return None + + + def find_currently_active_tab_key(self): + """ + Returns the key for the currently active tab in this TabGroup + :return: Returns the key or None of no key found + :rtype: key | None + """ + try: + current_index = self.TKNotebook.index('current') + key = self.tab_index_to_key.get(current_index, None) + except: + key = None + + return key + + def get(self): + """ + Returns the current value for the Tab Group, which will be the currently selected tab's KEY or the text on + the tab if no key is defined. Returns None if an error occurs. + Note that this is exactly the same data that would be returned from a call to Window.read. Are you sure you + are using this method correctly? + + :return: The key of the currently selected tab or None if there is an error + :rtype: Any | None + """ + + try: + current_index = self.TKNotebook.index('current') + key = self.tab_index_to_key.get(current_index, None) + except: + key = None + + return key + + def add_tab(self, tab_element): + """ + Add a new tab to an existing TabGroup + This call was written so that tabs can be added at runtime as your user performs operations. + Your Window should already be created and finalized. + + :param tab_element: A Tab Element that has a layout in it + :type tab_element: Tab + """ + + self.add_row(tab_element) + tab_element.TKFrame = tab_element.Widget = tk.Frame(self.TKNotebook) + form = self.ParentForm + form._BuildKeyDictForWindow(form, tab_element, form.AllKeysDict) + form.AllKeysDict[tab_element.Key] = tab_element + # Pack the tab's layout into the tab. NOTE - This does NOT pack the Tab itself... for that see below... + PackFormIntoFrame(tab_element, tab_element.TKFrame, self.ParentForm) + + # - This is below - Perform the same operation that is performed when a Tab is packed into the window. + # If there's an image in the tab, then do the imagey-stuff + # ------------------- start of imagey-stuff ------------------- + try: + if tab_element.Filename is not None: + photo = tk.PhotoImage(file=tab_element.Filename) + elif tab_element.Data is not None: + photo = tk.PhotoImage(data=tab_element.Data) + else: + photo = None + + if tab_element.ImageSubsample and photo is not None: + photo = photo.subsample(tab_element.ImageSubsample) + # print('*ERROR laying out form.... Image Element has no image specified*') + except Exception as e: + photo = None + _error_popup_with_traceback('Your Window has an Tab Element with an IMAGE problem', + 'The traceback will show you the Window with the problem layout', + 'Look in this Window\'s layout for an Image tab_element that has a key of {}'.format(tab_element.Key), + 'The error occuring is:', e) + + tab_element.photo = photo + # add the label + if photo is not None: + width, height = photo.width(), photo.height() + tab_element.tktext_label = tk.Label(tab_element.ParentRowFrame, image=photo, width=width, height=height, bd=0) + else: + tab_element.tktext_label = tk.Label(tab_element.ParentRowFrame, bd=0) + # ------------------- end of imagey-stuff ------------------- + + state = 'normal' + if tab_element.Disabled: + state = 'disabled' + if tab_element.visible is False: + state = 'hidden' + if photo is not None: + self.TKNotebook.add(tab_element.TKFrame, text=tab_element.Title, compound=tk.LEFT, state=state, image=photo) + else: + self.TKNotebook.add(tab_element.TKFrame, text=tab_element.Title, state=state) + tab_element.ParentNotebook = self.TKNotebook + tab_element.TabID = self.TabCount + tab_element.ParentForm = self.ParentForm + self.TabCount += 1 + if tab_element.BackgroundColor != COLOR_SYSTEM_DEFAULT and tab_element.BackgroundColor is not None: + tab_element.TKFrame.configure(background=tab_element.BackgroundColor, highlightbackground=tab_element.BackgroundColor, + highlightcolor=tab_element.BackgroundColor) + if tab_element.BorderWidth is not None: + tab_element.TKFrame.configure(borderwidth=tab_element.BorderWidth) + if tab_element.Tooltip is not None: + tab_element.TooltipObject = ToolTip(tab_element.TKFrame, text=tab_element.Tooltip, timeout=DEFAULT_TOOLTIP_TIME) + _add_right_click_menu(tab_element, form) + + + def update(self, visible=None): + """ + Enables changing the visibility + + :param visible: control visibility of element + :type visible: (bool) + """ + if not self._widget_was_created(): # if widget hasn't been created yet, then don't allow + return + + if self._this_elements_window_closed(): + _error_popup_with_traceback('Error in TabGroup.update - The window was closed') + return + + if visible is False: + self._pack_forget_save_settings() + elif visible is True: + self._pack_restore_settings() + + if visible is not None: + self._visible = visible + + + + + AddRow = add_row + FindKeyFromTabName = find_key_from_tab_name + Get = get + Layout = layout + + +# ---------------------------------------------------------------------- # +# Slider # +# ---------------------------------------------------------------------- # +class Slider(Element): + """ + A slider, horizontal or vertical + """ + + def __init__(self, range=(None, None), default_value=None, resolution=None, tick_interval=None, orientation=None, + disable_number_display=False, border_width=None, relief=None, change_submits=False, + enable_events=False, disabled=False, size=(None, None), s=(None, None), font=None, background_color=None, + text_color=None, trough_color=None, key=None, k=None, pad=None, p=None, expand_x=False, expand_y=False, tooltip=None, visible=True, metadata=None): + """ + :param range: slider's range (min value, max value) + :type range: (int, int) | Tuple[float, float] + :param default_value: starting value for the slider + :type default_value: int | float + :param resolution: the smallest amount the slider can be moved + :type resolution: int | float + :param tick_interval: how often a visible tick should be shown next to slider + :type tick_interval: int | float + :param orientation: 'horizontal' or 'vertical' ('h' or 'v' also work) + :type orientation: (str) + :param disable_number_display: if True no number will be displayed by the Slider Element + :type disable_number_display: (bool) + :param border_width: width of border around element in pixels + :type border_width: (int) + :param relief: relief style. Use constants - RELIEF_RAISED RELIEF_SUNKEN RELIEF_FLAT RELIEF_RIDGE RELIEF_GROOVE RELIEF_SOLID + :type relief: str | None + :param change_submits: * DEPRICATED DO NOT USE. Use `enable_events` instead + :type change_submits: (bool) + :param enable_events: If True then moving the slider will generate an Event + :type enable_events: (bool) + :param disabled: set disable state for element + :type disabled: (bool) + :param size: (l=length chars/rows, w=width pixels) + :type size: (int, int) + :param s: Same as size parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, size will be used + :type s: (int, int) | (None, None) + :param font: specifies the font family, size, etc. Tuple or Single string format 'name size styles'. Styles: italic * roman bold normal underline overstrike + :type font: (str or (str, int[, str]) or None) + :param background_color: color of slider's background + :type background_color: (str) + :param text_color: color of the slider's text + :type text_color: (str) + :param trough_color: color of the slider's trough + :type trough_color: (str) + :param key: Value that uniquely identifies this element from all other elements. Used when Finding an element or in return values. Must be unique to the window + :type key: str | int | tuple | object + :param k: Same as the Key. You can use either k or key. Which ever is set will be used. + :type k: str | int | tuple | object + :param pad: Amount of padding to put around element in pixels (left/right, top/bottom) or ((left, right), (top, bottom)) or an int. If an int, then it's converted into a tuple (int, int) + :type pad: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param p: Same as pad parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, pad will be used + :type p: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param expand_x: If True the element will automatically expand in the X direction to fill available space + :type expand_x: (bool) + :param expand_y: If True the element will automatically expand in the Y direction to fill available space + :type expand_y: (bool) + :param tooltip: text, that will appear when mouse hovers over the element + :type tooltip: (str) + :param visible: set visibility state of the element + :type visible: (bool) + :param metadata: User metadata that can be set to ANYTHING + :type metadata: (Any) + """ + + self.TKScale = self.Widget = None # type: tk.Scale + self.Range = (1, 10) if range == (None, None) else range + self.DefaultValue = self.Range[0] if default_value is None else default_value + self.Orientation = orientation if orientation else DEFAULT_SLIDER_ORIENTATION + self.BorderWidth = border_width if border_width else DEFAULT_SLIDER_BORDER_WIDTH + self.Relief = relief if relief else DEFAULT_SLIDER_RELIEF + self.Resolution = 1 if resolution is None else resolution + self.ChangeSubmits = change_submits or enable_events + self.Disabled = disabled + self.TickInterval = tick_interval + self.DisableNumericDisplay = disable_number_display + self.TroughColor = trough_color or DEFAULT_SCROLLBAR_COLOR + sz = size if size != (None, None) else s + temp_size = sz + if temp_size == (None, None): + temp_size = (20, 20) if self.Orientation.startswith('h') else (8, 20) + key = key if key is not None else k + pad = pad if pad is not None else p + self.expand_x = expand_x + self.expand_y = expand_y + + super().__init__(ELEM_TYPE_INPUT_SLIDER, size=temp_size, font=font, background_color=background_color, + text_color=text_color, key=key, pad=pad, tooltip=tooltip, visible=visible, metadata=metadata) + return + + def update(self, value=None, range=(None, None), disabled=None, visible=None): + """ + Changes some of the settings for the Slider Element. Must call `Window.Read` or `Window.Finalize` prior + + Changes will not be visible in your window until you call window.read or window.refresh. + + If you change visibility, your element may MOVE. If you want it to remain stationary, use the "layout helper" + function "pin" to ensure your element is "pinned" to that location in your layout so that it returns there + when made visible. + + :param value: sets current slider value + :type value: int | float + :param range: Sets a new range for slider + :type range: (int, int) | Tuple[float, float + :param disabled: disable or enable state of the element + :type disabled: (bool) + :param visible: control visibility of element + :type visible: (bool) + """ + if not self._widget_was_created(): # if widget hasn't been created yet, then don't allow + return + + if self._this_elements_window_closed(): + _error_popup_with_traceback('Error in Slider.update - The window was closed') + return + + + if range != (None, None): + self.TKScale.config(from_=range[0], to_=range[1]) + if value is not None: + try: + self.TKIntVar.set(value) + except: + pass + self.DefaultValue = value + if disabled is True: + self.TKScale['state'] = 'disabled' + elif disabled is False: + self.TKScale['state'] = 'normal' + self.Disabled = disabled if disabled is not None else self.Disabled + + if visible is False: + self._pack_forget_save_settings() + elif visible is True: + self._pack_restore_settings() + + if visible is not None: + self._visible = visible + + + def _SliderChangedHandler(self, event): + """ + Not user callable. Callback function for when slider is moved. + + :param event: (event) the event data provided by tkinter. Unknown format. Not used. + :type event: + """ + + if self.Key is not None: + self.ParentForm.LastButtonClicked = self.Key + else: + self.ParentForm.LastButtonClicked = '' + self.ParentForm.FormRemainedOpen = True + # if self.ParentForm.CurrentlyRunningMainloop: + # self.ParentForm.TKroot.quit() # kick the users out of the mainloop + _exit_mainloop(self.ParentForm) + + Update = update + + +Sl = Slider + + +# ---------------------------------------------------------------------- # +# TkFixedFrame (Used by Column) # +# ---------------------------------------------------------------------- # +class TkFixedFrame(tk.Frame): + """ + A tkinter frame that is used with Column Elements that do not have a scrollbar + """ + + def __init__(self, master, **kwargs): + """ + :param master: The parent widget + :type master: (tk.Widget) + :param **kwargs: The keyword args + :type **kwargs: + """ + tk.Frame.__init__(self, master, **kwargs) + + self.canvas = tk.Canvas(self) + + self.canvas.pack(side="left", fill="both", expand=True) + + # reset the view + self.canvas.xview_moveto(0) + self.canvas.yview_moveto(0) + + # create a frame inside the canvas which will be scrolled with it + self.TKFrame = tk.Frame(self.canvas, **kwargs) + self.frame_id = self.canvas.create_window(0, 0, window=self.TKFrame, anchor="nw") + self.canvas.config(borderwidth=0, highlightthickness=0) + self.TKFrame.config(borderwidth=0, highlightthickness=0) + self.config(borderwidth=0, highlightthickness=0) + + +# ---------------------------------------------------------------------- # +# TkScrollableFrame (Used by Column) # +# ---------------------------------------------------------------------- # +class TkScrollableFrame(tk.Frame): + """ + A frame with one or two scrollbars. Used to make Columns with scrollbars + """ + + def __init__(self, master, vertical_only, element, window, **kwargs): + """ + :param master: The parent widget + :type master: (tk.Widget) + :param vertical_only: if True the only a vertical scrollbar will be shown + :type vertical_only: (bool) + :param element: The element containing this object + :type element: (Column) + """ + tk.Frame.__init__(self, master, **kwargs) + # create a canvas object and a vertical scrollbar for scrolling it + + self.canvas = tk.Canvas(self) + element.Widget = self.canvas + # Okay, we're gonna make a list. Containing the y-min, x-min, y-max, and x-max of the frame + element.element_frame = self + _make_ttk_scrollbar(element, 'v', window) + # element.vsb = tk.Scrollbar(self, orient=tk.VERTICAL) + element.vsb.pack(side='right', fill="y", expand="false") + + if not vertical_only: + _make_ttk_scrollbar(element, 'h', window) + # self.hscrollbar = tk.Scrollbar(self, orient=tk.HORIZONTAL) + element.hsb.pack(side='bottom', fill="x", expand="false") + self.canvas.config(xscrollcommand=element.hsb.set) + # self.canvas = tk.Canvas(self, ) + # else: + # self.canvas = tk.Canvas(self) + + self.canvas.config(yscrollcommand=element.vsb.set) + self.canvas.pack(side="left", fill="both", expand=True) + element.vsb.config(command=self.canvas.yview) + if not vertical_only: + element.hsb.config(command=self.canvas.xview) + + # reset the view + self.canvas.xview_moveto(0) + self.canvas.yview_moveto(0) + + # create a frame inside the canvas which will be scrolled with it + self.TKFrame = tk.Frame(self.canvas, **kwargs) + self.frame_id = self.canvas.create_window(0, 0, window=self.TKFrame, anchor="nw") + self.canvas.config(borderwidth=0, highlightthickness=0) + self.TKFrame.config(borderwidth=0, highlightthickness=0) + self.config(borderwidth=0, highlightthickness=0) + + # Canvas can be: master, canvas, TKFrame + + # Chr0nic + + # self.unhookMouseWheel(None) + # self.TKFrame.bind("", self.hookMouseWheel) + # self.TKFrame.bind("", self.unhookMouseWheel) + # self.bind('', self.set_scrollregion) + + + self.unhookMouseWheel(None) + self.canvas.bind("", self.hookMouseWheel) + self.canvas.bind("", self.unhookMouseWheel) + self.bind('', self.set_scrollregion) + + + # Chr0nic + def hookMouseWheel(self, e): + # print("enter") + VarHolder.canvas_holder = self.canvas + self.canvas.bind_all('<4>', self.yscroll, add='+') + self.canvas.bind_all('<5>', self.yscroll, add='+') + self.canvas.bind_all("", self.yscroll, add='+') + self.canvas.bind_all("", self.xscroll, add='+') + + # Chr0nic + def unhookMouseWheel(self, e): + # print("leave") + VarHolder.canvas_holder = None + self.canvas.unbind_all('<4>') + self.canvas.unbind_all('<5>') + self.canvas.unbind_all("") + self.canvas.unbind_all("") + + def resize_frame(self, e): + self.canvas.itemconfig(self.frame_id, height=e.height, width=e.width) + + def yscroll(self, event): + if self.canvas.yview() == (0.0, 1.0): + return + if event.num == 5 or event.delta < 0: + self.canvas.yview_scroll(1, "unit") + elif event.num == 4 or event.delta > 0: + self.canvas.yview_scroll(-1, "unit") + + def xscroll(self, event): + if event.num == 5 or event.delta < 0: + self.canvas.xview_scroll(1, "unit") + elif event.num == 4 or event.delta > 0: + self.canvas.xview_scroll(-1, "unit") + + def bind_mouse_scroll(self, parent, mode): + # ~~ Windows only + parent.bind("", mode) + # ~~ Unix only + parent.bind("", mode) + parent.bind("", mode) + + def set_scrollregion(self, event=None): + """ Set the scroll region on the canvas """ + self.canvas.configure(scrollregion=self.canvas.bbox('all')) + + +# ---------------------------------------------------------------------- # +# Column # +# ---------------------------------------------------------------------- # +class Column(Element): + """ + A container element that is used to create a layout within your window's layout + """ + + def __init__(self, layout, background_color=None, size=(None, None), s=(None, None), size_subsample_width=1, size_subsample_height=2, pad=None, p=None, scrollable=False, + vertical_scroll_only=False, right_click_menu=None, key=None, k=None, visible=True, justification=None, element_justification=None, + vertical_alignment=None, grab=None, expand_x=None, expand_y=None, metadata=None, + sbar_trough_color=None, sbar_background_color=None, sbar_arrow_color=None, sbar_width=None, sbar_arrow_width=None, + sbar_frame_color=None, sbar_relief=None): + """ + :param layout: Layout that will be shown in the Column container + :type layout: List[List[Element]] + :param background_color: color of background of entire Column + :type background_color: (str) + :param size: (width, height) size in pixels (doesn't work quite right, sometimes only 1 dimension is set by tkinter. Use a Sizer Element to help set sizes + :type size: (int | None, int | None) + :param s: Same as size parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, size will be used + :type s: (int | None, int | None) + :param size_subsample_width: Determines the size of a scrollable column width based on 1/size_subsample * required size. 1 = match the contents exactly, 2 = 1/2 contents size, 3 = 1/3. Can be a fraction to make larger than required. + :type size_subsample_width: (float) + :param size_subsample_height: Determines the size of a scrollable height based on 1/size_subsample * required size. 1 = match the contents exactly, 2 = 1/2 contents size, 3 = 1/3. Can be a fraction to make larger than required.. + :type size_subsample_height: (float) + :param pad: Amount of padding to put around element in pixels (left/right, top/bottom) or ((left, right), (top, bottom)) or an int. If an int, then it's converted into a tuple (int, int) + :type pad: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param p: Same as pad parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, pad will be used + :type p: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param scrollable: if True then scrollbars will be added to the column. If you update the contents of a scrollable column, be sure and call Column.contents_changed also + :type scrollable: (bool) + :param vertical_scroll_only: if True then no horizontal scrollbar will be shown if a scrollable column + :type vertical_scroll_only: (bool) + :param right_click_menu: A list of lists of Menu items to show when this element is right clicked. See user docs for exact format. + :type right_click_menu: List[List[ List[str] | str ]] + :param key: Value that uniquely identifies this element from all other elements. Used when Finding an element or in return values. Must be unique to the window + :type key: str | int | tuple | object + :param k: Same as the Key. You can use either k or key. Which ever is set will be used. + :type k: str | int | tuple | object + :param visible: set visibility state of the element + :type visible: (bool) + :param justification: set justification for the Column itself. Note entire row containing the Column will be affected + :type justification: (str) + :param element_justification: All elements inside the Column will have this justification 'left', 'right', 'center' are valid values + :type element_justification: (str) + :param vertical_alignment: Place the column at the 'top', 'center', 'bottom' of the row (can also use t,c,r). Defaults to no setting (tkinter decides) + :type vertical_alignment: (str) + :param grab: If True can grab this element and move the window around. Default is False + :type grab: (bool) + :param expand_x: If True the column will automatically expand in the X direction to fill available space + :type expand_x: (bool) + :param expand_y: If True the column will automatically expand in the Y direction to fill available space + :type expand_y: (bool) + :param metadata: User metadata that can be set to ANYTHING + :type metadata: (Any) + :param sbar_trough_color: Scrollbar color of the trough + :type sbar_trough_color: (str) + :param sbar_background_color: Scrollbar color of the background of the arrow buttons at the ends AND the color of the "thumb" (the thing you grab and slide). Switches to arrow color when mouse is over + :type sbar_background_color: (str) + :param sbar_arrow_color: Scrollbar color of the arrow at the ends of the scrollbar (it looks like a button). Switches to background color when mouse is over + :type sbar_arrow_color: (str) + :param sbar_width: Scrollbar width in pixels + :type sbar_width: (int) + :param sbar_arrow_width: Scrollbar width of the arrow on the scrollbar. It will potentially impact the overall width of the scrollbar + :type sbar_arrow_width: (int) + :param sbar_frame_color: Scrollbar Color of frame around scrollbar (available only on some ttk themes) + :type sbar_frame_color: (str) + :param sbar_relief: Scrollbar relief that will be used for the "thumb" of the scrollbar (the thing you grab that slides). Should be a constant that is defined at starting with "RELIEF_" - RELIEF_RAISED, RELIEF_SUNKEN, RELIEF_FLAT, RELIEF_RIDGE, RELIEF_GROOVE, RELIEF_SOLID + :type sbar_relief: (str) + """ + + + self.UseDictionary = False + self.ReturnValues = None + self.ReturnValuesList = [] + self.ReturnValuesDictionary = {} + self.DictionaryKeyCounter = 0 + self.ParentWindow = None + self.ParentPanedWindow = None + self.Rows = [] + self.TKFrame = None + self.TKColFrame = None # type: tk.Frame + self.Scrollable = scrollable + self.VerticalScrollOnly = vertical_scroll_only + + self.RightClickMenu = right_click_menu + bg = background_color if background_color is not None else DEFAULT_BACKGROUND_COLOR + self.ContainerElemementNumber = Window._GetAContainerNumber() + self.ElementJustification = element_justification + self.Justification = justification + self.VerticalAlignment = vertical_alignment + key = key if key is not None else k + self.Grab = grab + self.expand_x = expand_x + self.expand_y = expand_y + self.Layout(layout) + sz = size if size != (None, None) else s + pad = pad if pad is not None else p + self.size_subsample_width = size_subsample_width + self.size_subsample_height = size_subsample_height + + super().__init__(ELEM_TYPE_COLUMN, background_color=bg, size=sz, pad=pad, key=key, visible=visible, metadata=metadata, + sbar_trough_color=sbar_trough_color, sbar_background_color=sbar_background_color, sbar_arrow_color=sbar_arrow_color, sbar_width=sbar_width, sbar_arrow_width=sbar_arrow_width, sbar_frame_color=sbar_frame_color, sbar_relief=sbar_relief) + return + + def add_row(self, *args): + """ + Not recommended user call. Used to add rows of Elements to the Column Element. + + :param *args: The list of elements for this row + :type *args: List[Element] + """ + + NumRows = len(self.Rows) # number of existing rows is our row number + CurrentRowNumber = NumRows # this row's number + CurrentRow = [] # start with a blank row and build up + # ------------------------- Add the elements to a row ------------------------- # + for i, element in enumerate(args): # Loop through list of elements and add them to the row + if type(element) == list: + PopupError('Error creating Column layout', + 'Layout has a LIST instead of an ELEMENT', + 'This sometimes means you have a badly placed ]', + 'The offensive list is:', + element, + 'This list will be stripped from your layout', keep_on_top=True, image=_random_error_emoji() + ) + continue + elif callable(element) and not isinstance(element, Element): + PopupError('Error creating Column layout', + 'Layout has a FUNCTION instead of an ELEMENT', + 'This likely means you are missing () from your layout', + 'The offensive list is:', + element, + 'This item will be stripped from your layout', keep_on_top=True, image=_random_error_emoji()) + continue + if element.ParentContainer is not None: + warnings.warn( + '*** YOU ARE ATTEMPTING TO REUSE AN ELEMENT IN YOUR LAYOUT! Once placed in a layout, an element cannot be used in another layout. ***', + UserWarning) + PopupError('Error creating Column layout', + 'The layout specified has already been used', + 'You MUST start witha "clean", unused layout every time you create a window', + 'The offensive Element = ', + element, + 'and has a key = ', element.Key, + 'This item will be stripped from your layout', + 'Hint - try printing your layout and matching the IDs "print(layout)"', keep_on_top=True, image=_random_error_emoji()) + continue + element.Position = (CurrentRowNumber, i) + element.ParentContainer = self + CurrentRow.append(element) + if element.Key is not None: + self.UseDictionary = True + # ------------------------- Append the row to list of Rows ------------------------- # + self.Rows.append(CurrentRow) + + def layout(self, rows): + """ + Can use like the Window.Layout method, but it's better to use the layout parameter when creating + + :param rows: The rows of Elements + :type rows: List[List[Element]] + :return: Used for chaining + :rtype: (Column) + """ + + for row in rows: + try: + iter(row) + except TypeError: + PopupError('Error creating Column layout', + 'Your row is not an iterable (e.g. a list)', + 'Instead of a list, the type found was {}'.format(type(row)), + 'The offensive row = ', + row, + 'This item will be stripped from your layout', keep_on_top=True, image=_random_error_emoji()) + continue + self.AddRow(*row) + return self + + def _GetElementAtLocation(self, location): + """ + Not user callable. Used to find the Element at a row, col position within the layout + + :param location: (row, column) position of the element to find in layout + :type location: (int, int) + :return: The element found at the location + :rtype: (Element) + """ + + (row_num, col_num) = location + row = self.Rows[row_num] + element = row[col_num] + return element + + def update(self, visible=None): + """ + Changes some of the settings for the Column Element. Must call `Window.Read` or `Window.Finalize` prior + + Changes will not be visible in your window until you call window.read or window.refresh. + + If you change visibility, your element may MOVE. If you want it to remain stationary, use the "layout helper" + function "pin" to ensure your element is "pinned" to that location in your layout so that it returns there + when made visible. + + :param visible: control visibility of element + :type visible: (bool) + """ + if not self._widget_was_created(): # if widget hasn't been created yet, then don't allow + return + + if self._this_elements_window_closed(): + _error_popup_with_traceback('Error in Column.update - The window was closed') + return + + if self.expand_x and self.expand_y: + expand = tk.BOTH + elif self.expand_x: + expand = tk.X + elif self.expand_y: + expand = tk.Y + else: + expand = None + + if visible is False: + if self.TKColFrame: + self._pack_forget_save_settings() + # self.TKColFrame.pack_forget() + if self.ParentPanedWindow: + self.ParentPanedWindow.remove(self.TKColFrame) + elif visible is True: + if self.TKColFrame: + self._pack_restore_settings() + # self.TKColFrame.pack(padx=self.pad_used[0], pady=self.pad_used[1], fill=expand) + if self.ParentPanedWindow: + self.ParentPanedWindow.add(self.TKColFrame) + if visible is not None: + self._visible = visible + + def contents_changed(self): + """ + When a scrollable column has part of its layout changed by making elements visible or invisible or the + layout is extended for the Column, then this method needs to be called so that the new scroll area + is computed to match the new contents. + """ + self.TKColFrame.canvas.config(scrollregion=self.TKColFrame.canvas.bbox('all')) + + AddRow = add_row + Layout = layout + Update = update + + +Col = Column + + +# ---------------------------------------------------------------------- # +# Pane # +# ---------------------------------------------------------------------- # +class Pane(Element): + """ + A sliding Pane that is unique to tkinter. Uses Columns to create individual panes + """ + + def __init__(self, pane_list, background_color=None, size=(None, None), s=(None, None), pad=None, p=None, orientation='vertical', + show_handle=True, relief=RELIEF_RAISED, handle_size=None, border_width=None, key=None, k=None, expand_x=None, expand_y=None, visible=True, metadata=None): + """ + :param pane_list: Must be a list of Column Elements. Each Column supplied becomes one pane that's shown + :type pane_list: List[Column] | Tuple[Column] + :param background_color: color of background + :type background_color: (str) + :param size: (width, height) w=characters-wide, h=rows-high How much room to reserve for the Pane + :type size: (int, int) + :param s: Same as size parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, size will be used + :type s: (int, int) | (None, None) + :param pad: Amount of padding to put around element in pixels (left/right, top/bottom) or ((left, right), (top, bottom)) or an int. If an int, then it's converted into a tuple (int, int) + :type pad: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param p: Same as pad parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, pad will be used + :type p: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param orientation: 'horizontal' or 'vertical' or ('h' or 'v'). Direction the Pane should slide + :type orientation: (str) + :param show_handle: if True, the handle is drawn that makes it easier to grab and slide + :type show_handle: (bool) + :param relief: relief style. Values are same as other elements that use relief values. RELIEF_RAISED RELIEF_SUNKEN RELIEF_FLAT RELIEF_RIDGE RELIEF_GROOVE RELIEF_SOLID + :type relief: (enum) + :param handle_size: Size of the handle in pixels + :type handle_size: (int) + :param border_width: width of border around element in pixels + :type border_width: (int) + :param key: Value that uniquely identifies this element from all other elements. Used when Finding an element or in return values. Must be unique to the window + :type key: str | int | tuple | object + :param k: Same as the Key. You can use either k or key. Which ever is set will be used. + :type k: str | int | tuple | object + :param expand_x: If True the column will automatically expand in the X direction to fill available space + :type expand_x: (bool) + :param expand_y: If True the column will automatically expand in the Y direction to fill available space + :type expand_y: (bool) + :param visible: set visibility state of the element + :type visible: (bool) + :param metadata: User metadata that can be set to ANYTHING + :type metadata: (Any) + """ + + + self.UseDictionary = False + self.ReturnValues = None + self.ReturnValuesList = [] + self.ReturnValuesDictionary = {} + self.DictionaryKeyCounter = 0 + self.ParentWindow = None + self.Rows = [] + self.TKFrame = None + self.PanedWindow = None + self.Orientation = orientation + self.PaneList = pane_list + self.ShowHandle = show_handle + self.Relief = relief + self.HandleSize = handle_size or 8 + self.BorderDepth = border_width + bg = background_color if background_color is not None else DEFAULT_BACKGROUND_COLOR + + self.Rows = [pane_list] + key = key if key is not None else k + sz = size if size != (None, None) else s + pad = pad if pad is not None else p + self.expand_x = expand_x + self.expand_y = expand_y + + super().__init__(ELEM_TYPE_PANE, background_color=bg, size=sz, pad=pad, key=key, visible=visible, metadata=metadata) + return + + def update(self, visible=None): + """ + Changes some of the settings for the Pane Element. Must call `Window.Read` or `Window.Finalize` prior + + Changes will not be visible in your window until you call window.read or window.refresh. + + If you change visibility, your element may MOVE. If you want it to remain stationary, use the "layout helper" + function "pin" to ensure your element is "pinned" to that location in your layout so that it returns there + when made visible. + + :param visible: control visibility of element + :type visible: (bool) + """ + if not self._widget_was_created(): # if widget hasn't been created yet, then don't allow + return + + if self._this_elements_window_closed(): + _error_popup_with_traceback('Error in Pane.update - The window was closed') + return + + if visible is False: + self._pack_forget_save_settings() + elif visible is True: + self._pack_restore_settings() + + if visible is not None: + self._visible = visible + + Update = update + + +# ---------------------------------------------------------------------- # +# Calendar # +# ---------------------------------------------------------------------- # +class TKCalendar(ttk.Frame): + """ + This code was shamelessly lifted from moshekaplan's repository - moshekaplan/tkinter_components + NONE of this code is user callable. Stay away! + """ + # XXX ToDo: cget and configure + + datetime = calendar.datetime.datetime + timedelta = calendar.datetime.timedelta + + def __init__(self, master=None, target_element=None, close_when_chosen=True, default_date=(None, None, None), **kw): + """WIDGET-SPECIFIC OPTIONS: locale, firstweekday, year, month, selectbackground, selectforeground """ + self._TargetElement = target_element + default_mon, default_day, default_year = default_date + # remove custom options from kw before initializating ttk.Frame + fwday = kw.pop('firstweekday', calendar.MONDAY) + year = kw.pop('year', default_year or self.datetime.now().year) + month = kw.pop('month', default_mon or self.datetime.now().month) + locale = kw.pop('locale', None) + sel_bg = kw.pop('selectbackground', '#ecffc4') + sel_fg = kw.pop('selectforeground', '#05640e') + self.format = kw.pop('format') + if self.format is None: + self.format = '%Y-%m-%d %H:%M:%S' + + self._date = self.datetime(year, month, default_day or 1) + self._selection = None # no date selected + self._master = master + self.close_when_chosen = close_when_chosen + ttk.Frame.__init__(self, master, **kw) + + # instantiate proper calendar class + if locale is None: + self._cal = calendar.TextCalendar(fwday) + else: + self._cal = calendar.LocaleTextCalendar(fwday, locale) + + self.__setup_styles() # creates custom styles + self.__place_widgets() # pack/grid used widgets + self.__config_calendar() # adjust calendar columns and setup tags + # configure a canvas, and proper bindings, for selecting dates + self.__setup_selection(sel_bg, sel_fg) + + # store items ids, used for insertion later + self._items = [self._calendar.insert('', 'end', values='') + for _ in range(6)] + # insert dates in the currently empty calendar + self._build_calendar() + + def __setitem__(self, item, value): + if item in ('year', 'month'): + raise AttributeError("attribute '%s' is not writeable" % item) + elif item == 'selectbackground': + self._canvas['background'] = value + elif item == 'selectforeground': + self._canvas.itemconfigure(self._canvas.text, item=value) + else: + ttk.Frame.__setitem__(self, item, value) + + def __getitem__(self, item): + if item in ('year', 'month'): + return getattr(self._date, item) + elif item == 'selectbackground': + return self._canvas['background'] + elif item == 'selectforeground': + return self._canvas.itemcget(self._canvas.text, 'fill') + else: + r = ttk.tclobjs_to_py({item: ttk.Frame.__getitem__(self, item)}) + return r[item] + + def __setup_styles(self): + # custom ttk styles + style = ttk.Style(self.master) + arrow_layout = lambda dir: ( + [('Button.focus', {'children': [('Button.%sarrow' % dir, None)]})] + ) + style.layout('L.TButton', arrow_layout('left')) + style.layout('R.TButton', arrow_layout('right')) + + def __place_widgets(self): + # header frame and its widgets + hframe = ttk.Frame(self) + lbtn = ttk.Button(hframe, style='L.TButton', command=self._prev_month) + rbtn = ttk.Button(hframe, style='R.TButton', command=self._next_month) + self._header = ttk.Label(hframe, width=15, anchor='center') + # the calendar + self._calendar = ttk.Treeview(self, show='', selectmode='none', height=7) + + # pack the widgets + hframe.pack(in_=self, side='top', pady=4, anchor='center') + lbtn.grid(in_=hframe) + self._header.grid(in_=hframe, column=1, row=0, padx=12) + rbtn.grid(in_=hframe, column=2, row=0) + self._calendar.pack(in_=self, expand=1, fill='both', side='bottom') + + def __config_calendar(self): + cols = self._cal.formatweekheader(3).split() + self._calendar['columns'] = cols + self._calendar.tag_configure('header', background='grey90') + self._calendar.insert('', 'end', values=cols, tag='header') + # adjust its columns width + font = tkinter.font.Font() + maxwidth = max(font.measure(col) for col in cols) + for col in cols: + self._calendar.column(col, width=maxwidth, minwidth=maxwidth, + anchor='e') + + def __setup_selection(self, sel_bg, sel_fg): + self._font = tkinter.font.Font() + self._canvas = canvas = tk.Canvas(self._calendar, + background=sel_bg, borderwidth=0, highlightthickness=0) + canvas.text = canvas.create_text(0, 0, fill=sel_fg, anchor='w') + + canvas.bind('', lambda evt: canvas.place_forget()) + self._calendar.bind('', lambda evt: canvas.place_forget()) + self._calendar.bind('', self._pressed) + + def __minsize(self, evt): + width, height = self._calendar.master.geometry().split('x') + height = height[:height.index('+')] + self._calendar.master.minsize(width, height) + + def _build_calendar(self): + year, month = self._date.year, self._date.month + + # update header text (Month, YEAR) + header = self._cal.formatmonthname(year, month, 0) + self._header['text'] = header.title() + + # update calendar shown dates + cal = self._cal.monthdayscalendar(year, month) + for indx, item in enumerate(self._items): + week = cal[indx] if indx < len(cal) else [] + fmt_week = [('%02d' % day) if day else '' for day in week] + self._calendar.item(item, values=fmt_week) + + def _show_selection(self, text, bbox): + """ Configure canvas for a new selection. """ + x, y, width, height = bbox + + textw = self._font.measure(text) + + canvas = self._canvas + canvas.configure(width=width, height=height) + canvas.coords(canvas.text, width - textw, height / 2 - 1) + canvas.itemconfigure(canvas.text, text=text) + canvas.place(in_=self._calendar, x=x, y=y) + + # Callbacks + + def _pressed(self, evt): + """ Clicked somewhere in the calendar. """ + x, y, widget = evt.x, evt.y, evt.widget + item = widget.identify_row(y) + column = widget.identify_column(x) + + if not column or not item in self._items: + # clicked in the weekdays row or just outside the columns + return + + item_values = widget.item(item)['values'] + if not len(item_values): # row is empty for this month + return + + text = item_values[int(column[1]) - 1] + if not text: # date is empty + return + + bbox = widget.bbox(item, column) + if not bbox: # calendar not visible yet + return + + # update and then show selection + text = '%02d' % text + self._selection = (text, item, column) + self._show_selection(text, bbox) + year, month = self._date.year, self._date.month + now = self.datetime.now() + try: + self._TargetElement.Update( + self.datetime(year, month, int(self._selection[0]), now.hour, now.minute, now.second).strftime( + self.format)) + if self._TargetElement.ChangeSubmits: + self._TargetElement.ParentForm.LastButtonClicked = self._TargetElement.Key + self._TargetElement.ParentForm.FormRemainedOpen = True + self._TargetElement.ParentForm.TKroot.quit() # kick the users out of the mainloop + except: + pass + if self.close_when_chosen: + self._master.destroy() + + def _prev_month(self): + """Updated calendar to show the previous month.""" + self._canvas.place_forget() + + self._date = self._date - self.timedelta(days=1) + self._date = self.datetime(self._date.year, self._date.month, 1) + self._build_calendar() # reconstuct calendar + + def _next_month(self): + """Update calendar to show the next month.""" + self._canvas.place_forget() + + year, month = self._date.year, self._date.month + self._date = self._date + self.timedelta( + days=calendar.monthrange(year, month)[1] + 1) + self._date = self.datetime(self._date.year, self._date.month, 1) + self._build_calendar() # reconstruct calendar + + # Properties + + @property + def selection(self): + if not self._selection: + return None + + year, month = self._date.year, self._date.month + return self.datetime(year, month, int(self._selection[0])) + + +# ---------------------------------------------------------------------- # +# Menu # +# ---------------------------------------------------------------------- # +class Menu(Element): + """ + Menu Element is the Element that provides a Menu Bar that goes across the top of the window, just below titlebar. + Here is an example layout. The "&" are shortcut keys ALT+key. + Is a List of - "Item String" + List + Where Item String is what will be displayed on the Menubar itself. + The List that follows the item represents the items that are shown then Menu item is clicked + Notice how an "entry" in a mennu can be a list which means it branches out and shows another menu, etc. (resursive) + menu_def = [['&File', ['!&Open', '&Save::savekey', '---', '&Properties', 'E&xit']], + ['!&Edit', ['!&Paste', ['Special', 'Normal', ], 'Undo'], ], + ['&Debugger', ['Popout', 'Launch Debugger']], + ['&Toolbar', ['Command &1', 'Command &2', 'Command &3', 'Command &4']], + ['&Help', '&About...'], ] + Important Note! The colors, font, look of the Menubar itself cannot be changed, only the menus shown AFTER clicking the menubar + can be changed. If you want to change the style/colors the Menubar, then you will have to use the MenubarCustom element. + Finally, "keys" can be added to entries so make them unique. The "Save" entry has a key associated with it. You + can see it has a "::" which signifies the beginning of a key. The user will not see the key portion when the + menu is shown. The key portion is returned as part of the event. + """ + + def __init__(self, menu_definition, background_color=None, text_color=None, disabled_text_color=None, size=(None, None), s=(None, None), tearoff=False, + font=None, pad=None, p=None, key=None, k=None, visible=True, metadata=None): + """ + :param menu_definition: The Menu definition specified using lists (docs explain the format) + :type menu_definition: List[List[Tuple[str, List[str]]] + :param background_color: color of the background of menus, NOT the Menubar + :type background_color: (str) + :param text_color: text color for menus, NOT the Menubar. Can be in #RRGGBB format or a color name "black". + :type text_color: (str) + :param disabled_text_color: color to use for text when item in submenu, not the menubar itself, is disabled. Can be in #RRGGBB format or a color name "black" + :type disabled_text_color: (str) + :param size: Not used in the tkinter port + :type size: (int, int) + :param s: Same as size parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, size will be used + :type s: (int, int) | (None, None) + :param tearoff: if True, then can tear the menu off from the window ans use as a floating window. Very cool effect + :type tearoff: (bool) + :param pad: Amount of padding to put around element in pixels (left/right, top/bottom) or ((left, right), (top, bottom)) or an int. If an int, then it's converted into a tuple (int, int) + :type pad: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param p: Same as pad parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, pad will be used + :type p: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param font: specifies the font family, size, etc. of submenus. Does NOT apply to the Menubar itself. Tuple or Single string format 'name size styles'. Styles: italic * roman bold normal underline overstrike + :type font: (str or (str, int[, str]) or None) + :param key: Value that uniquely identifies this element from all other elements. Used when Finding an element or in return values. Must be unique to the window + :type key: str | int | tuple | object + :param k: Same as the Key. You can use either k or key. Which ever is set will be used. + :type k: str | int | tuple | object + :param visible: set visibility state of the element + :type visible: (bool) + :param metadata: User metadata that can be set to ANYTHING + :type metadata: (Any) + """ + + self.BackgroundColor = background_color if background_color is not None else theme_input_background_color() + self.TextColor = text_color if text_color is not None else theme_input_text_color() + + self.DisabledTextColor = disabled_text_color if disabled_text_color is not None else COLOR_SYSTEM_DEFAULT + self.MenuDefinition = copy.deepcopy(menu_definition) + self.Widget = self.TKMenu = None # type: tk.Menu + self.MenuItemChosen = None + key = key if key is not None else k + sz = size if size != (None, None) else s + pad = pad if pad is not None else p + + super().__init__(ELEM_TYPE_MENUBAR, background_color=self.BackgroundColor, text_color=self.TextColor, size=sz, pad=pad, key=key, visible=visible, + font=font, metadata=metadata) + # super().__init__(ELEM_TYPE_MENUBAR, background_color=COLOR_SYSTEM_DEFAULT, text_color=COLOR_SYSTEM_DEFAULT, size=sz, pad=pad, key=key, visible=visible, font=None, metadata=metadata) + + self.Tearoff = tearoff + + return + + def _MenuItemChosenCallback(self, item_chosen): # Menu Menu Item Chosen Callback + """ + Not user callable. Called when some end-point on the menu (an item) has been clicked. Send the information back to the application as an event. Before event can be sent + + :param item_chosen: the text that was clicked on / chosen from the menu + :type item_chosen: (str) + """ + # print('IN MENU ITEM CALLBACK', item_chosen) + self.MenuItemChosen = item_chosen + self.ParentForm.LastButtonClicked = item_chosen + self.ParentForm.FormRemainedOpen = True + # if self.ParentForm.CurrentlyRunningMainloop: + # self.ParentForm.TKroot.quit() # kick the users out of the mainloop + _exit_mainloop(self.ParentForm) + + def update(self, menu_definition=None, visible=None): + """ + Update a menubar - can change the menu definition and visibility. The entire menu has to be specified + + Changes will not be visible in your window until you call window.read or window.refresh. + + If you change visibility, your element may MOVE. If you want it to remain stationary, use the "layout helper" + function "pin" to ensure your element is "pinned" to that location in your layout so that it returns there + when made visible. + + :param menu_definition: The menu definition list + :type menu_definition: List[List[Tuple[str, List[str]]] + :param visible: control visibility of element + :type visible: (bool) + """ + if not self._widget_was_created(): # if widget hasn't been created yet, then don't allow + return + + if self._this_elements_window_closed(): + _error_popup_with_traceback('Error in Menu.update - The window was closed') + return + + if menu_definition is not None: + self.MenuDefinition = copy.deepcopy(menu_definition) + if self.TKMenu is None: # if no menu exists, make one + self.TKMenu = tk.Menu(self.ParentForm.TKroot, tearoff=self.Tearoff, tearoffcommand=self._tearoff_menu_callback) # create the menubar + menubar = self.TKMenu + # Delete all the menu items (assuming 10000 should be a high enough number to cover them all) + menubar.delete(0, 10000) + self.Widget = self.TKMenu # same the new menu so user can access to extend PySimpleGUI + for menu_entry in self.MenuDefinition: + baritem = tk.Menu(menubar, tearoff=self.Tearoff, tearoffcommand=self._tearoff_menu_callback) + + if self.BackgroundColor not in (COLOR_SYSTEM_DEFAULT, None): + baritem.config(bg=self.BackgroundColor) + if self.TextColor not in (COLOR_SYSTEM_DEFAULT, None): + baritem.config(fg=self.TextColor) + if self.DisabledTextColor not in (COLOR_SYSTEM_DEFAULT, None): + baritem.config(disabledforeground=self.DisabledTextColor) + if self.Font is not None: + baritem.config(font=self.Font) + + if self.Font is not None: + baritem.config(font=self.Font) + pos = menu_entry[0].find(MENU_SHORTCUT_CHARACTER) + # print(pos) + if pos != -1: + if pos == 0 or menu_entry[0][pos - len(MENU_SHORTCUT_CHARACTER)] != "\\": + menu_entry[0] = menu_entry[0][:pos] + menu_entry[0][pos + len(MENU_SHORTCUT_CHARACTER):] + if menu_entry[0][0] == MENU_DISABLED_CHARACTER: + menubar.add_cascade(label=menu_entry[0][len(MENU_DISABLED_CHARACTER):], menu=baritem, underline=pos) + menubar.entryconfig(menu_entry[0][len(MENU_DISABLED_CHARACTER):], state='disabled') + else: + menubar.add_cascade(label=menu_entry[0], menu=baritem, underline=pos) + + if len(menu_entry) > 1: + AddMenuItem(baritem, menu_entry[1], self) + + if visible is False: + self.ParentForm.TKroot.configure(menu=[]) # this will cause the menubar to disappear + elif self.TKMenu is not None: + self.ParentForm.TKroot.configure(menu=self.TKMenu) + if visible is not None: + self._visible = visible + + Update = update + + +MenuBar = Menu # another name for Menu to make it clear it's the Menu Bar +Menubar = Menu # another name for Menu to make it clear it's the Menu Bar + + +# ---------------------------------------------------------------------- # +# Table # +# ---------------------------------------------------------------------- # +class Table(Element): + + def __init__(self, values, headings=None, visible_column_map=None, col_widths=None, cols_justification=None, def_col_width=10, + auto_size_columns=True, max_col_width=20, select_mode=None, display_row_numbers=False, starting_row_number=0, num_rows=None, + row_height=None, font=None, justification='right', text_color=None, background_color=None, + alternating_row_color=None, selected_row_colors=(None, None), header_text_color=None, header_background_color=None, header_font=None, header_border_width=None, header_relief=None, + row_colors=None, vertical_scroll_only=True, hide_vertical_scroll=False, border_width=None, + sbar_trough_color=None, sbar_background_color=None, sbar_arrow_color=None, sbar_width=None, sbar_arrow_width=None, sbar_frame_color=None, sbar_relief=None, + size=(None, None), s=(None, None), change_submits=False, enable_events=False, enable_click_events=False, right_click_selects=False, bind_return_key=False, pad=None, p=None, + key=None, k=None, tooltip=None, right_click_menu=None, expand_x=False, expand_y=False, visible=True, metadata=None): + """ + :param values: Your table data represented as a 2-dimensions table... a list of rows, with each row representing a row in your table. + :type values: List[List[str | int | float]] + :param headings: The headings to show on the top line + :type headings: List[str] + :param visible_column_map: One entry for each column. False indicates the column is not shown + :type visible_column_map: List[bool] + :param col_widths: Number of characters that each column will occupy + :type col_widths: List[int] + :param cols_justification: Justification for EACH column. Is a list of strings with the value 'l', 'r', 'c' that indicates how the column will be justified. Either no columns should be set, or have to have one for every colun + :type cols_justification: List[str] or Tuple[str] or None + :param def_col_width: Default column width in characters + :type def_col_width: (int) + :param auto_size_columns: if True columns will be sized automatically + :type auto_size_columns: (bool) + :param max_col_width: Maximum width for all columns in characters + :type max_col_width: (int) + :param select_mode: Select Mode. Valid values start with "TABLE_SELECT_MODE_". Valid values are: TABLE_SELECT_MODE_NONE TABLE_SELECT_MODE_BROWSE TABLE_SELECT_MODE_EXTENDED + :type select_mode: (enum) + :param display_row_numbers: if True, the first column of the table will be the row # + :type display_row_numbers: (bool) + :param starting_row_number: The row number to use for the first row. All following rows will be based on this starting value. Default is 0. + :type starting_row_number: (int) + :param num_rows: The number of rows of the table to display at a time + :type num_rows: (int) + :param row_height: height of a single row in pixels + :type row_height: (int) + :param font: specifies the font family, size, etc. Tuple or Single string format 'name size styles'. Styles: italic * roman bold normal underline overstrike + :type font: (str or (str, int[, str]) or None) + :param justification: 'left', 'right', 'center' are valid choices + :type justification: (str) + :param text_color: color of the text + :type text_color: (str) + :param background_color: color of background + :type background_color: (str) + :param alternating_row_color: if set then every other row will have this color in the background. + :type alternating_row_color: (str) + :param selected_row_colors: Sets the text color and background color for a selected row. Same format as button colors - tuple ('red', 'yellow') or string 'red on yellow'. Defaults to theme's button color + :type selected_row_colors: str or (str, str) + :param header_text_color: sets the text color for the header + :type header_text_color: (str) + :param header_background_color: sets the background color for the header + :type header_background_color: (str) + :param header_font: specifies the font family, size, etc. Tuple or Single string format 'name size styles'. Styles: italic * roman bold normal underline overstrike + :type header_font: (str or (str, int[, str]) or None) + :param header_border_width: Border width for the header portion + :type header_border_width: (int | None) + :param header_relief: Relief style for the header. Values are same as other elements that use relief. RELIEF_RAISED RELIEF_SUNKEN RELIEF_FLAT RELIEF_RIDGE RELIEF_GROOVE RELIEF_SOLID + :type header_relief: (str | None) + :param row_colors: list of tuples of (row, background color) OR (row, foreground color, background color). Sets the colors of listed rows to the color(s) provided (note the optional foreground color) + :type row_colors: List[Tuple[int, str] | Tuple[Int, str, str]] + :param vertical_scroll_only: if True only the vertical scrollbar will be visible + :type vertical_scroll_only: (bool) + :param hide_vertical_scroll: if True vertical scrollbar will be hidden + :type hide_vertical_scroll: (bool) + :param border_width: Border width/depth in pixels + :type border_width: (int) + :param sbar_trough_color: Scrollbar color of the trough + :type sbar_trough_color: (str) + :param sbar_background_color: Scrollbar color of the background of the arrow buttons at the ends AND the color of the "thumb" (the thing you grab and slide). Switches to arrow color when mouse is over + :type sbar_background_color: (str) + :param sbar_arrow_color: Scrollbar color of the arrow at the ends of the scrollbar (it looks like a button). Switches to background color when mouse is over + :type sbar_arrow_color: (str) + :param sbar_width: Scrollbar width in pixels + :type sbar_width: (int) + :param sbar_arrow_width: Scrollbar width of the arrow on the scrollbar. It will potentially impact the overall width of the scrollbar + :type sbar_arrow_width: (int) + :param sbar_frame_color: Scrollbar Color of frame around scrollbar (available only on some ttk themes) + :type sbar_frame_color: (str) + :param sbar_relief: Scrollbar relief that will be used for the "thumb" of the scrollbar (the thing you grab that slides). Should be a constant that is defined at starting with "RELIEF_" - RELIEF_RAISED, RELIEF_SUNKEN, RELIEF_FLAT, RELIEF_RIDGE, RELIEF_GROOVE, RELIEF_SOLID + :type sbar_relief: (str) + :param size: DO NOT USE! Use num_rows instead + :type size: (int, int) + :param change_submits: DO NOT USE. Only listed for backwards compat - Use enable_events instead + :type change_submits: (bool) + :param enable_events: Turns on the element specific events. Table events happen when row is clicked + :type enable_events: (bool) + :param enable_click_events: Turns on the element click events that will give you (row, col) click data when the table is clicked + :type enable_click_events: (bool) + :param right_click_selects: If True, then right clicking a row will select that row if multiple rows are not currently selected + :type right_click_selects: (bool) + :param bind_return_key: if True, pressing return key will cause event coming from Table, ALSO a left button double click will generate an event if this parameter is True + :type bind_return_key: (bool) + :param pad: Amount of padding to put around element in pixels (left/right, top/bottom) or ((left, right), (top, bottom)) or an int. If an int, then it's converted into a tuple (int, int) + :type pad: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param p: Same as pad parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, pad will be used + :type p: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param key: Used with window.find_element and with return values to uniquely identify this element to uniquely identify this element + :type key: str | int | tuple | object + :param k: Same as the Key. You can use either k or key. Which ever is set will be used. + :type k: str | int | tuple | object + :param tooltip: text, that will appear when mouse hovers over the element + :type tooltip: (str) + :param right_click_menu: A list of lists of Menu items to show when this element is right clicked. See user docs for exact format. + :type right_click_menu: List[List[ List[str] | str ]] + :param expand_x: If True the element will automatically expand in the X direction to fill available space + :type expand_x: (bool) + :param expand_y: If True the element will automatically expand in the Y direction to fill available space + :type expand_y: (bool) + :param visible: set visibility state of the element + :type visible: (bool) + :param metadata: User metadata that can be set to ANYTHING + :type metadata: (Any) + """ + + + self.Values = values + self.ColumnHeadings = headings + self.ColumnsToDisplay = visible_column_map + self.ColumnWidths = col_widths + self.cols_justification = cols_justification + self.MaxColumnWidth = max_col_width + self.DefaultColumnWidth = def_col_width + self.AutoSizeColumns = auto_size_columns + self.BackgroundColor = background_color if background_color is not None else DEFAULT_BACKGROUND_COLOR + self.TextColor = text_color + self.HeaderTextColor = header_text_color if header_text_color is not None else LOOK_AND_FEEL_TABLE[CURRENT_LOOK_AND_FEEL]['TEXT_INPUT'] + self.HeaderBackgroundColor = header_background_color if header_background_color is not None else LOOK_AND_FEEL_TABLE[CURRENT_LOOK_AND_FEEL]['INPUT'] + self.HeaderFont = header_font + self.Justification = justification + self.InitialState = None + self.SelectMode = select_mode + self.DisplayRowNumbers = display_row_numbers + self.NumRows = num_rows if num_rows is not None else size[1] + self.RowHeight = row_height + self.Widget = self.TKTreeview = None # type: ttk.Treeview + self.AlternatingRowColor = alternating_row_color + self.VerticalScrollOnly = vertical_scroll_only + self.HideVerticalScroll = hide_vertical_scroll + self.SelectedRows = [] + self.ChangeSubmits = change_submits or enable_events + self.BindReturnKey = bind_return_key + self.StartingRowNumber = starting_row_number # When displaying row numbers, where to start + self.RowHeaderText = 'Row' + self.enable_click_events = enable_click_events + self.right_click_selects = right_click_selects + self.last_clicked_position = (None, None) + self.HeaderBorderWidth = header_border_width + self.BorderWidth = border_width + self.HeaderRelief = header_relief + self.table_ttk_style_name = None # the ttk style name for the Table itself + if selected_row_colors == (None, None): + # selected_row_colors = DEFAULT_TABLE_AND_TREE_SELECTED_ROW_COLORS + selected_row_colors = theme_button_color() + else: + try: + if isinstance(selected_row_colors, str): + selected_row_colors = selected_row_colors.split(' on ') + except Exception as e: + print('* Table Element Warning * you messed up with color formatting of Selected Row Color', e) + self.SelectedRowColors = selected_row_colors + + self.RightClickMenu = right_click_menu + self.RowColors = row_colors + self.tree_ids = [] # ids returned when inserting items into table - will use to delete colors + key = key if key is not None else k + sz = size if size != (None, None) else s + pad = pad if pad is not None else p + self.expand_x = expand_x + self.expand_y = expand_y + + super().__init__(ELEM_TYPE_TABLE, text_color=text_color, background_color=background_color, font=font, + size=sz, pad=pad, key=key, tooltip=tooltip, visible=visible, metadata=metadata, + sbar_trough_color=sbar_trough_color, sbar_background_color=sbar_background_color, sbar_arrow_color=sbar_arrow_color, sbar_width=sbar_width, sbar_arrow_width=sbar_arrow_width, sbar_frame_color=sbar_frame_color, sbar_relief=sbar_relief) + return + + def update(self, values=None, num_rows=None, visible=None, select_rows=None, alternating_row_color=None, row_colors=None): + """ + Changes some of the settings for the Table Element. Must call `Window.Read` or `Window.Finalize` prior + + Changes will not be visible in your window until you call window.read or window.refresh. + + If you change visibility, your element may MOVE. If you want it to remain stationary, use the "layout helper" + function "pin" to ensure your element is "pinned" to that location in your layout so that it returns there + when made visible. + + :param values: A new 2-dimensional table to show + :type values: List[List[str | int | float]] + :param num_rows: How many rows to display at a time + :type num_rows: (int) + :param visible: if True then will be visible + :type visible: (bool) + :param select_rows: List of rows to select as if user did + :type select_rows: List[int] + :param alternating_row_color: the color to make every other row + :type alternating_row_color: (str) + :param row_colors: list of tuples of (row, background color) OR (row, foreground color, background color). Changes the colors of listed rows to the color(s) provided (note the optional foreground color) + :type row_colors: List[Tuple[int, str] | Tuple[Int, str, str]] + """ + if not self._widget_was_created(): # if widget hasn't been created yet, then don't allow + return + + if self._this_elements_window_closed(): + _error_popup_with_traceback('Error in Table.update - The window was closed') + return + + if values is not None: + for id in self.tree_ids: + self.TKTreeview.item(id, tags=()) + if self.BackgroundColor is not None and self.BackgroundColor != COLOR_SYSTEM_DEFAULT: + self.TKTreeview.tag_configure(id, background=self.BackgroundColor) + else: + self.TKTreeview.tag_configure(id, background='#FFFFFF', foreground='#000000') + if self.TextColor is not None and self.TextColor != COLOR_SYSTEM_DEFAULT: + self.TKTreeview.tag_configure(id, foreground=self.TextColor) + else: + self.TKTreeview.tag_configure(id, foreground='#000000') + + children = self.TKTreeview.get_children() + for i in children: + self.TKTreeview.detach(i) + self.TKTreeview.delete(i) + children = self.TKTreeview.get_children() + + self.tree_ids = [] + for i, value in enumerate(values): + if self.DisplayRowNumbers: + value = [i + self.StartingRowNumber] + value + id = self.TKTreeview.insert('', 'end', text=value, iid=i + 1, values=value, tag=i) + if self.BackgroundColor is not None and self.BackgroundColor != COLOR_SYSTEM_DEFAULT: + self.TKTreeview.tag_configure(id, background=self.BackgroundColor) + else: + self.TKTreeview.tag_configure(id, background='#FFFFFF') + self.tree_ids.append(id) + self.Values = values + self.SelectedRows = [] + if visible is False: + self._pack_forget_save_settings(self.element_frame) + elif visible is True: + self._pack_restore_settings(self.element_frame) + + if num_rows is not None: + self.TKTreeview.config(height=num_rows) + if select_rows is not None: + rows_to_select = [i + 1 for i in select_rows] + self.TKTreeview.selection_set(rows_to_select) + + if alternating_row_color is not None: # alternating colors + self.AlternatingRowColor = alternating_row_color + + if self.AlternatingRowColor is not None: + for row in range(0, len(self.Values), 2): + self.TKTreeview.tag_configure(row, background=self.AlternatingRowColor) + if row_colors is not None: # individual row colors + self.RowColors = row_colors + for row_def in self.RowColors: + if len(row_def) == 2: # only background is specified + self.TKTreeview.tag_configure(row_def[0], background=row_def[1]) + else: + self.TKTreeview.tag_configure(row_def[0], background=row_def[2], foreground=row_def[1]) + if visible is not None: + self._visible = visible + + def _treeview_selected(self, event): + """ + Not user callable. Callback function that is called when something is selected from Table. + Stores the selected rows in Element as they are being selected. If events enabled, then returns from Read + + :param event: event information from tkinter + :type event: (unknown) + """ + # print('**-- in treeview selected --**') + selections = self.TKTreeview.selection() + self.SelectedRows = [int(x) - 1 for x in selections] + if self.ChangeSubmits: + if self.Key is not None: + self.ParentForm.LastButtonClicked = self.Key + else: + self.ParentForm.LastButtonClicked = '' + self.ParentForm.FormRemainedOpen = True + # if self.ParentForm.CurrentlyRunningMainloop: + # self.ParentForm.TKroot.quit() + _exit_mainloop(self.ParentForm) + + def _treeview_double_click(self, event): + """ + Not user callable. Callback function that is called when something is selected from Table. + Stores the selected rows in Element as they are being selected. If events enabled, then returns from Read + + :param event: event information from tkinter + :type event: (unknown) + """ + selections = self.TKTreeview.selection() + self.SelectedRows = [int(x) - 1 for x in selections] + if self.BindReturnKey: # Signifies BOTH a return key AND a double click + if self.Key is not None: + self.ParentForm.LastButtonClicked = self.Key + else: + self.ParentForm.LastButtonClicked = '' + self.ParentForm.FormRemainedOpen = True + # if self.ParentForm.CurrentlyRunningMainloop: + # self.ParentForm.TKroot.quit() + _exit_mainloop(self.ParentForm) + + + def _table_clicked(self, event): + """ + Not user callable. Callback function that is called a click happens on a table. + Stores the selected rows in Element as they are being selected. If events enabled, then returns from Read + + :param event: event information from tkinter + :type event: (unknown) + """ + if not self._widget_was_created(): # if widget hasn't been created yet, then don't allow + return + # popup(obj_to_string_single_obj(event)) + try: + region = self.Widget.identify('region', event.x, event.y) + if region == 'heading': + row = -1 + elif region == 'cell': + row = int(self.Widget.identify_row(event.y))-1 + elif region == 'separator': + row = None + else: + row = None + col_identified = self.Widget.identify_column(event.x) + if col_identified: # Sometimes tkinter returns a value of '' which would cause an error if cast to an int + column = int(self.Widget.identify_column(event.x)[1:])-1-int(self.DisplayRowNumbers is True) + else: + column = None + except Exception as e: + warnings.warn('Error getting table click data for table with key= {}\nError: {}'.format(self.Key, e), UserWarning) + if not SUPPRESS_ERROR_POPUPS: + _error_popup_with_traceback('Unable to complete operation getting the clicked event for table with key {}'.format(self.Key), _create_error_message(), e, 'Event data:', obj_to_string_single_obj(event)) + row = column = None + + self.last_clicked_position = (row, column) + + # update the rows being selected if appropriate + self.ParentForm.TKroot.update() + # self.TKTreeview.() + selections = self.TKTreeview.selection() + if self.right_click_selects and len(selections) <= 1: + if (event.num == 3 and not running_mac()) or (event.num == 2 and running_mac()): + if row != -1 and row is not None: + selections = [row+1] + self.TKTreeview.selection_set(selections) + # print(selections) + self.SelectedRows = [int(x) - 1 for x in selections] + # print('The new selected rows = ', self.SelectedRows, 'selections =', selections) + if self.enable_click_events is True: + if self.Key is not None: + self.ParentForm.LastButtonClicked = (self.Key, TABLE_CLICKED_INDICATOR, (row, column)) + else: + self.ParentForm.LastButtonClicked = '' + self.ParentForm.FormRemainedOpen = True + _exit_mainloop(self.ParentForm) + + + + def get(self): + """ + Get the selected rows using tktiner's selection method. Returns a list of the selected rows. + + :return: a list of the index of the selected rows (a list of ints) + :rtype: List[int] + """ + + selections = self.TKTreeview.selection() + selected_rows = [int(x) - 1 for x in selections] + return selected_rows + + def get_last_clicked_position(self): + """ + Returns a tuple with the row and column of the cell that was last clicked. + Headers will have a row == -1 and the Row Number Column (if present) will have a column == -1 + :return: The (row,col) position of the last cell clicked in the table + :rtype: (int | None, int | None) + """ + return self.last_clicked_position + + + + + Update = update + Get = get + + +# ---------------------------------------------------------------------- # +# Tree # +# ---------------------------------------------------------------------- # +class Tree(Element): + """ + Tree Element - Presents data in a tree-like manner, much like a file/folder browser. Uses the TreeData class + to hold the user's data and pass to the element for display. + """ + + def __init__(self, data=None, headings=None, visible_column_map=None, col_widths=None, col0_width=10, col0_heading='', + def_col_width=10, auto_size_columns=True, max_col_width=20, select_mode=None, show_expanded=False, + change_submits=False, enable_events=False, click_toggles_select=None, font=None, justification='right', text_color=None, border_width=None, + background_color=None, selected_row_colors=(None, None), header_text_color=None, header_background_color=None, header_font=None, header_border_width=None, header_relief=None, num_rows=None, + sbar_trough_color=None, sbar_background_color=None, sbar_arrow_color=None, sbar_width=None, sbar_arrow_width=None, sbar_frame_color=None, sbar_relief=None, + row_height=None, vertical_scroll_only=True, hide_vertical_scroll=False, pad=None, p=None, key=None, k=None, tooltip=None, + right_click_menu=None, expand_x=False, expand_y=False, visible=True, metadata=None): + """ + :param data: The data represented using a PySimpleGUI provided TreeData class + :type data: (TreeData) + :param headings: List of individual headings for each column + :type headings: List[str] + :param visible_column_map: Determines if a column should be visible. If left empty, all columns will be shown + :type visible_column_map: List[bool] + :param col_widths: List of column widths so that individual column widths can be controlled + :type col_widths: List[int] + :param col0_width: Size of Column 0 which is where the row numbers will be optionally shown + :type col0_width: (int) + :param col0_heading: Text to be shown in the header for the left-most column + :type col0_heading: (str) + :param def_col_width: default column width + :type def_col_width: (int) + :param auto_size_columns: if True, the size of a column is determined using the contents of the column + :type auto_size_columns: (bool) + :param max_col_width: the maximum size a column can be + :type max_col_width: (int) + :param select_mode: Use same values as found on Table Element. Valid values include: TABLE_SELECT_MODE_NONE TABLE_SELECT_MODE_BROWSE TABLE_SELECT_MODE_EXTENDED + :type select_mode: (enum) + :param show_expanded: if True then the tree will be initially shown with all nodes completely expanded + :type show_expanded: (bool) + :param change_submits: DO NOT USE. Only listed for backwards compat - Use enable_events instead + :type change_submits: (bool) + :param enable_events: Turns on the element specific events. Tree events happen when row is clicked + :type enable_events: (bool) + :param click_toggles_select: If True then clicking a row will cause the selection for that row to toggle between selected and deselected + :type click_toggles_select: (bool) + :param font: specifies the font family, size, etc. Tuple or Single string format 'name size styles'. Styles: italic * roman bold normal underline overstrike + :type font: (str or (str, int[, str]) or None) + :param justification: 'left', 'right', 'center' are valid choices + :type justification: (str) + :param text_color: color of the text + :type text_color: (str) + :param border_width: Border width/depth in pixels + :type border_width: (int) + :param background_color: color of background + :type background_color: (str) + :param selected_row_colors: Sets the text color and background color for a selected row. Same format as button colors - tuple ('red', 'yellow') or string 'red on yellow'. Defaults to theme's button color + :type selected_row_colors: str or (str, str) + :param header_text_color: sets the text color for the header + :type header_text_color: (str) + :param header_background_color: sets the background color for the header + :type header_background_color: (str) + :param header_font: specifies the font family, size, etc. Tuple or Single string format 'name size styles'. Styles: italic * roman bold normal underline overstrike + :type header_font: (str or (str, int[, str]) or None) + :param header_border_width: Border width for the header portion + :type header_border_width: (int | None) + :param header_relief: Relief style for the header. Values are same as other elements that use relief. RELIEF_RAISED RELIEF_SUNKEN RELIEF_FLAT RELIEF_RIDGE RELIEF_GROOVE RELIEF_SOLID + :type header_relief: (str | None) + :param num_rows: The number of rows of the table to display at a time + :type num_rows: (int) + :param row_height: height of a single row in pixels + :type row_height: (int) + :param vertical_scroll_only: if True only the vertical scrollbar will be visible + :type vertical_scroll_only: (bool) + :param hide_vertical_scroll: if True vertical scrollbar will be hidden + :type hide_vertical_scroll: (bool) + :param sbar_trough_color: Scrollbar color of the trough + :type sbar_trough_color: (str) + :param sbar_background_color: Scrollbar color of the background of the arrow buttons at the ends AND the color of the "thumb" (the thing you grab and slide). Switches to arrow color when mouse is over + :type sbar_background_color: (str) + :param sbar_arrow_color: Scrollbar color of the arrow at the ends of the scrollbar (it looks like a button). Switches to background color when mouse is over + :type sbar_arrow_color: (str) + :param sbar_width: Scrollbar width in pixels + :type sbar_width: (int) + :param sbar_arrow_width: Scrollbar width of the arrow on the scrollbar. It will potentially impact the overall width of the scrollbar + :type sbar_arrow_width: (int) + :param sbar_frame_color: Scrollbar Color of frame around scrollbar (available only on some ttk themes) + :type sbar_frame_color: (str) + :param sbar_relief: Scrollbar relief that will be used for the "thumb" of the scrollbar (the thing you grab that slides). Should be a constant that is defined at starting with "RELIEF_" - RELIEF_RAISED, RELIEF_SUNKEN, RELIEF_FLAT, RELIEF_RIDGE, RELIEF_GROOVE, RELIEF_SOLID + :type sbar_relief: (str) + :param pad: Amount of padding to put around element in pixels (left/right, top/bottom) or ((left, right), (top, bottom)) or an int. If an int, then it's converted into a tuple (int, int) + :type pad: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param p: Same as pad parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, pad will be used + :type p: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param key: Used with window.find_element and with return values to uniquely identify this element to uniquely identify this element + :type key: str | int | tuple | object + :param k: Same as the Key. You can use either k or key. Which ever is set will be used. + :type k: str | int | tuple | object + :param tooltip: text, that will appear when mouse hovers over the element + :type tooltip: (str) + :param right_click_menu: A list of lists of Menu items to show when this element is right clicked. See user docs for exact format. + :type right_click_menu: List[List[str] | str]] + :param expand_x: If True the element will automatically expand in the X direction to fill available space + :type expand_x: (bool) + :param expand_y: If True the element will automatically expand in the Y direction to fill available space + :type expand_y: (bool) + :param visible: set visibility state of the element + :type visible: (bool) + :param metadata: User metadata that can be set to ANYTHING + :type metadata: (Any) + """ + + self.image_dict = {} + + self.TreeData = data + self.ColumnHeadings = headings + self.ColumnsToDisplay = visible_column_map + self.ColumnWidths = col_widths + self.MaxColumnWidth = max_col_width + self.DefaultColumnWidth = def_col_width + self.AutoSizeColumns = auto_size_columns + self.BackgroundColor = background_color if background_color is not None else DEFAULT_BACKGROUND_COLOR + self.TextColor = text_color + self.HeaderTextColor = header_text_color if header_text_color is not None else LOOK_AND_FEEL_TABLE[CURRENT_LOOK_AND_FEEL]['TEXT_INPUT'] + self.HeaderBackgroundColor = header_background_color if header_background_color is not None else LOOK_AND_FEEL_TABLE[CURRENT_LOOK_AND_FEEL]['INPUT'] + self.HeaderBorderWidth = header_border_width + self.BorderWidth = border_width + self.HeaderRelief = header_relief + self.click_toggles_select = click_toggles_select + if selected_row_colors == (None, None): + # selected_row_colors = DEFAULT_TABLE_AND_TREE_SELECTED_ROW_COLORS + selected_row_colors = theme_button_color() + else: + try: + if isinstance(selected_row_colors, str): + selected_row_colors = selected_row_colors.split(' on ') + except Exception as e: + print('* Table Element Warning * you messed up with color formatting of Selected Row Color', e) + self.SelectedRowColors = selected_row_colors + + self.HeaderFont = header_font + self.Justification = justification + self.InitialState = None + self.SelectMode = select_mode + self.ShowExpanded = show_expanded + self.NumRows = num_rows + self.Col0Width = col0_width + self.col0_heading = col0_heading + self.TKTreeview = None # type: ttk.Treeview + self.element_frame = None # type: tk.Frame + self.VerticalScrollOnly = vertical_scroll_only + self.HideVerticalScroll = hide_vertical_scroll + self.SelectedRows = [] + self.ChangeSubmits = change_submits or enable_events + self.RightClickMenu = right_click_menu + self.RowHeight = row_height + self.IconList = {} + self.IdToKey = {'': ''} + self.KeyToID = {'': ''} + key = key if key is not None else k + pad = pad if pad is not None else p + self.expand_x = expand_x + self.expand_y = expand_y + + super().__init__(ELEM_TYPE_TREE, text_color=text_color, background_color=background_color, font=font, pad=pad, key=key, tooltip=tooltip, + visible=visible, metadata=metadata, + sbar_trough_color=sbar_trough_color, sbar_background_color=sbar_background_color, sbar_arrow_color=sbar_arrow_color, sbar_width=sbar_width, sbar_arrow_width=sbar_arrow_width, sbar_frame_color=sbar_frame_color, sbar_relief=sbar_relief) + return + + def _treeview_selected(self, event): + """ + Not a user function. Callback function that happens when an item is selected from the tree. In this + method, it saves away the reported selections so they can be properly returned. + + :param event: An event parameter passed in by tkinter. Not used + :type event: (Any) + """ + + selections = self.TKTreeview.selection() + selected_rows = [self.IdToKey[x] for x in selections] + if self.click_toggles_select: + if set(self.SelectedRows) == set(selected_rows): + for item in selections: + self.TKTreeview.selection_remove(item) + selections = [] + self.SelectedRows = [self.IdToKey[x] for x in selections] + + if self.ChangeSubmits: + MyForm = self.ParentForm + if self.Key is not None: + self.ParentForm.LastButtonClicked = self.Key + else: + self.ParentForm.LastButtonClicked = '' + self.ParentForm.FormRemainedOpen = True + # if self.ParentForm.CurrentlyRunningMainloop: + # self.ParentForm.TKroot.quit() + _exit_mainloop(self.ParentForm) + + + def add_treeview_data(self, node): + """ + Not a user function. Recursive method that inserts tree data into the tkinter treeview widget. + + :param node: The node to insert. Will insert all nodes from starting point downward, recursively + :type node: (TreeData) + """ + if node.key != '': + if node.icon: + try: + if node.icon not in self.image_dict: + if type(node.icon) is bytes: + photo = tk.PhotoImage(data=node.icon) + else: + photo = tk.PhotoImage(file=node.icon) + self.image_dict[node.icon] = photo + else: + photo = self.image_dict.get(node.icon) + + node.photo = photo + id = self.TKTreeview.insert(self.KeyToID[node.parent], 'end', iid=None, text=node.text, + values=node.values, open=self.ShowExpanded, image=node.photo) + self.IdToKey[id] = node.key + self.KeyToID[node.key] = id + except: + self.photo = None + else: + id = self.TKTreeview.insert(self.KeyToID[node.parent], 'end', iid=None, text=node.text, + values=node.values, open=self.ShowExpanded) + self.IdToKey[id] = node.key + self.KeyToID[node.key] = id + + for node in node.children: + self.add_treeview_data(node) + + def update(self, values=None, key=None, value=None, text=None, icon=None, visible=None): + """ + Changes some of the settings for the Tree Element. Must call `Window.Read` or `Window.Finalize` prior + + Changes will not be visible in your window until you call window.read or window.refresh. + + If you change visibility, your element may MOVE. If you want it to remain stationary, use the "layout helper" + function "pin" to ensure your element is "pinned" to that location in your layout so that it returns there + when made visible. + + :param values: Representation of the tree + :type values: (TreeData) + :param key: identifies a particular item in tree to update + :type key: str | int | tuple | object + :param value: sets the node identified by key to a particular value + :type value: (Any) + :param text: sets the node identified by key to this string + :type text: (str) + :param icon: can be either a base64 icon or a filename for the icon + :type icon: bytes | str + :param visible: control visibility of element + :type visible: (bool) + """ + if not self._widget_was_created(): # if widget hasn't been created yet, then don't allow + return + + if self._this_elements_window_closed(): + _error_popup_with_traceback('Error in Tree.update - The window was closed') + return + + if values is not None: + children = self.TKTreeview.get_children() + for i in children: + self.TKTreeview.detach(i) + self.TKTreeview.delete(i) + children = self.TKTreeview.get_children() + self.TreeData = values + self.IdToKey = {'': ''} + self.KeyToID = {'': ''} + self.add_treeview_data(self.TreeData.root_node) + self.SelectedRows = [] + if key is not None: + for id in self.IdToKey.keys(): + if key == self.IdToKey[id]: + break + else: + id = None + print('** Key not found **') + else: + id = None + if id: + # item = self.TKTreeview.item(id) + if value is not None: + self.TKTreeview.item(id, values=value) + if text is not None: + self.TKTreeview.item(id, text=text) + if icon is not None: + try: + if type(icon) is bytes: + photo = tk.PhotoImage(data=icon) + else: + photo = tk.PhotoImage(file=icon) + self.TKTreeview.item(id, image=photo) + self.IconList[key] = photo # save so that it's not deleted (save reference) + except: + pass + # item = self.TKTreeview.item(id) + if visible is False: + self._pack_forget_save_settings(self.element_frame) + elif visible is True: + self._pack_restore_settings(self.element_frame) + + if visible is not None: + self._visible = visible + + return self + + Update = update + + +class TreeData(object): + """ + Class that user fills in to represent their tree data. It's a very simple tree representation with a root "Node" + with possibly one or more children "Nodes". Each Node contains a key, text to display, list of values to display + and an icon. The entire tree is built using a single method, Insert. Nothing else is required to make the tree. + """ + + class Node(object): + """ + Contains information about the individual node in the tree + """ + + def __init__(self, parent, key, text, values, icon=None): + """ + Represents a node within the TreeData class + + :param parent: The parent Node + :type parent: (TreeData.Node) + :param key: Used to uniquely identify this node + :type key: str | int | tuple | object + :param text: The text that is displayed at this node's location + :type text: (str) + :param values: The list of values that are displayed at this node + :type values: List[Any] + :param icon: just a icon + :type icon: str | bytes + """ + + self.parent = parent # type: TreeData.Node + self.children = [] # type: List[TreeData.Node] + self.key = key # type: str + self.text = text # type: str + self.values = values # type: List[Any] + self.icon = icon # type: str | bytes + + def _Add(self, node): + self.children.append(node) + + def __init__(self): + """ + Instantiate the object, initializes the Tree Data, creates a root node for you + """ + self.tree_dict = {} # type: Dict[str, TreeData.Node] + self.root_node = self.Node("", "", 'root', [], None) # The root node + self.tree_dict[""] = self.root_node # Start the tree out with the root node + + def _AddNode(self, key, node): + """ + Adds a node to tree dictionary (not user callable) + + :param key: Uniquely identifies this Node + :type key: (str) + :param node: Node being added + :type node: (TreeData.Node) + """ + self.tree_dict[key] = node + + def insert(self, parent, key, text, values, icon=None): + """ + Inserts a node into the tree. This is how user builds their tree, by Inserting Nodes + This is the ONLY user callable method in the TreeData class + + :param parent: the parent Node + :type parent: (Node) + :param key: Used to uniquely identify this node + :type key: str | int | tuple | object + :param text: The text that is displayed at this node's location + :type text: (str) + :param values: The list of values that are displayed at this node + :type values: List[Any] + :param icon: icon + :type icon: str | bytes + """ + + node = self.Node(parent, key, text, values, icon) + self.tree_dict[key] = node + parent_node = self.tree_dict[parent] + parent_node._Add(node) + + def __repr__(self): + """ + Converts the TreeData into a printable version, nicely formatted + + :return: (str) A formatted, text version of the TreeData + :rtype: + """ + return self._NodeStr(self.root_node, 1) + + def _NodeStr(self, node, level): + """ + Does the magic of converting the TreeData into a nicely formatted string version + + :param node: The node to begin printing the tree + :type node: (TreeData.Node) + :param level: The indentation level for string formatting + :type level: (int) + """ + return '\n'.join( + [str(node.key) + ' : ' + str(node.text) + ' [ ' + ', '.join([str(v) for v in node.values]) +' ]'] + + [' ' * 4 * level + self._NodeStr(child, level + 1) for child in node.children]) + + Insert = insert + + +# ---------------------------------------------------------------------- # +# Error Element # +# ---------------------------------------------------------------------- # +class ErrorElement(Element): + """ + A "dummy Element" that is returned when there are error conditions, like trying to find an element that's invalid + """ + + def __init__(self, key=None, metadata=None): + """ + :param key: Used with window.find_element and with return values to uniquely identify this element + :type key: + """ + self.Key = key + + super().__init__(ELEM_TYPE_ERROR, key=key, metadata=metadata) + + def update(self, silent_on_error=True, *args, **kwargs): + """ + Update method for the Error Element, an element that should not be directly used by developer + + :param silent_on_error: if False, then a Popup window will be shown + :type silent_on_error: (bool) + :param *args: meant to "soak up" any normal parameters passed in + :type *args: (Any) + :param **kwargs: meant to "soak up" any keyword parameters that were passed in + :type **kwargs: (Any) + :return: returns 'self' so call can be chained + :rtype: (ErrorElement) + """ + print('** Your update is being ignored because you supplied a bad key earlier **') + return self + + def get(self): + """ + One of the method names found in other Elements. Used here to return an error string in case it's called + + :return: A warning text string. + :rtype: (str) + """ + return 'This is NOT a valid Element!\nSTOP trying to do things with it or I will have to crash at some point!' + + Get = get + Update = update + + +# ---------------------------------------------------------------------- # +# Stretch Element # +# ---------------------------------------------------------------------- # +# This is for source code compatibility with tkinter version. No tkinter equivalent but you can fake it using a Text element that expands in the X direction +def Push(background_color=None): + """ + Acts like a Stretch element found in the Qt port. + Used in a Horizontal fashion. Placing one on each side of an element will enter the element. + Place one to the left and the element to the right will be right justified. See VStretch for vertical type + :param background_color: color of background may be needed because of how this is implemented + :type background_color: (str) + :return: (Text) + """ + return Text(font='_ 1', background_color=background_color, pad=(0,0), expand_x=True) + +P = Push +Stretch = Push + +def VPush(background_color=None): + """ + Acts like a Stretch element found in the Qt port. + Used in a Vertical fashion. + :param background_color: color of background may be needed because of how this is implemented + :type background_color: (str) + :return: (Text) + """ + return Text(font='_ 1', background_color=background_color, pad=(0,0), expand_y=True) + + +VStretch = VPush +VP = VPush + + + + +# ------------------------------------------------------------------------- # +# _TimerPeriodic CLASS # +# ------------------------------------------------------------------------- # + +class _TimerPeriodic: + id_counter = 1 + # Dictionary containing the active timers. Format is {id : _TimerPeriodic object} + active_timers = {} #type: dict[int:_TimerPeriodic] + + def __init__(self, window, frequency_ms, key=EVENT_TIMER, repeating=True): + """ + :param window: The window to send events to + :type window: Window + :param frequency_ms: How often to send events in milliseconds + :type frequency_ms: int + :param repeating: If True then the timer will run, repeatedly sending events, until stopped + :type repeating: bool + """ + self.window = window + self.frequency_ms = frequency_ms + self.repeating = repeating + self.key = key + self.id = _TimerPeriodic.id_counter + _TimerPeriodic.id_counter += 1 + self.start() + + + @classmethod + def stop_timer_with_id(cls, timer_id): + """ + Not user callable! + :return: A simple counter that makes each container element unique + :rtype: + """ + timer = cls.active_timers.get(timer_id, None) + if timer is not None: + timer.stop() + + + @classmethod + def stop_all_timers_for_window(cls, window): + """ + Stops all timers for a given window + :param window: The window to stop timers for + :type window: Window + """ + for timer in _TimerPeriodic.active_timers.values(): + if timer.window == window: + timer.running = False + + @classmethod + def get_all_timers_for_window(cls, window): + """ + Returns a list of timer IDs for a given window + :param window: The window to find timers for + :type window: Window + :return: List of timer IDs for the window + :rtype: List[int] + """ + timers = [] + for timer in _TimerPeriodic.active_timers.values(): + if timer.window == window: + timers.append(timer.id) + + return timers + + + + def timer_thread(self): + """ + The thread that sends events to the window. Runs either once or in a loop until timer is stopped + """ + + if not self.running: # if timer has been cancelled, abort + del _TimerPeriodic.active_timers[self.id] + return + while True: + time.sleep(self.frequency_ms/1000) + if not self.running: # if timer has been cancelled, abort + del _TimerPeriodic.active_timers[self.id] + return + self.window.write_event_value(self.key, self.id) + + if not self.repeating: # if timer does not repeat, then exit thread + del _TimerPeriodic.active_timers[self.id] + return + + + def start(self): + """ + Starts a timer by starting a timer thread + Adds timer to the list of active timers + """ + self.running = True + self.thread = threading.Thread(target=self.timer_thread, daemon=True) + self.thread.start() + _TimerPeriodic.active_timers[self.id] = self + + + def stop(self): + """ + Stops a timer + """ + self.running = False + + + + + +# ------------------------------------------------------------------------- # +# Window CLASS # +# ------------------------------------------------------------------------- # +class Window: + """ + Represents a single Window + """ + NumOpenWindows = 0 + _user_defined_icon = None + hidden_master_root = None # type: tk.Tk + _animated_popup_dict = {} # type: Dict + _active_windows = {} # type: Dict[Window, tk.Tk()] + _move_all_windows = False # if one window moved, they will move + _window_that_exited = None # type: Window + _root_running_mainloop = None # type: tk.Tk() # (may be the hidden root or a window's root) + _timeout_key = None + _TKAfterID = None # timer that is used to run reads with timeouts + _window_running_mainloop = None # The window that is running the mainloop + _container_element_counter = 0 # used to get a number of Container Elements (Frame, Column, Tab) + _read_call_from_debugger = False + _timeout_0_counter = 0 # when timeout=0 then go through each window one at a time + _counter_for_ttk_widgets = 0 + _floating_debug_window_build_needed = False + _main_debug_window_build_needed = False + # rereouted stdout info. List of tuples (window, element, previous destination) + _rerouted_stdout_stack = [] # type: List[Tuple[Window, Element]] + _rerouted_stderr_stack = [] # type: List[Tuple[Window, Element]] + _original_stdout = None + _original_stderr = None + _watermark = None + _watermark_temp_forced = False + _watermark_user_text = '' + + def __init__(self, title, layout=None, default_element_size=None, + default_button_element_size=(None, None), + auto_size_text=None, auto_size_buttons=None, location=(None, None), relative_location=(None, None), size=(None, None), + element_padding=None, margins=(None, None), button_color=None, font=None, + progress_bar_color=(None, None), background_color=None, border_depth=None, auto_close=False, + auto_close_duration=DEFAULT_AUTOCLOSE_TIME, icon=None, force_toplevel=False, + alpha_channel=None, return_keyboard_events=False, use_default_focus=True, text_justification=None, + no_titlebar=False, grab_anywhere=False, grab_anywhere_using_control=True, keep_on_top=None, resizable=False, disable_close=False, + disable_minimize=False, right_click_menu=None, transparent_color=None, debugger_enabled=True, + right_click_menu_background_color=None, right_click_menu_text_color=None, right_click_menu_disabled_text_color=None, right_click_menu_selected_colors=(None, None), + right_click_menu_font=None, right_click_menu_tearoff=False, + finalize=False, element_justification='left', ttk_theme=None, use_ttk_buttons=None, modal=False, enable_close_attempted_event=False, enable_window_config_events=False, + titlebar_background_color=None, titlebar_text_color=None, titlebar_font=None, titlebar_icon=None, + use_custom_titlebar=None, scaling=None, + sbar_trough_color=None, sbar_background_color=None, sbar_arrow_color=None, sbar_width=None, sbar_arrow_width=None, sbar_frame_color=None, sbar_relief=None, watermark=None, + metadata=None): + """ + :param title: The title that will be displayed in the Titlebar and on the Taskbar + :type title: (str) + :param layout: The layout for the window. Can also be specified in the Layout method + :type layout: List[List[Element]] | Tuple[Tuple[Element]] + :param default_element_size: size in characters (wide) and rows (high) for all elements in this window + :type default_element_size: (int, int) - (width, height) + :param default_button_element_size: (width, height) size in characters (wide) and rows (high) for all Button elements in this window + :type default_button_element_size: (int, int) + :param auto_size_text: True if Elements in Window should be sized to exactly fir the length of text + :type auto_size_text: (bool) + :param auto_size_buttons: True if Buttons in this Window should be sized to exactly fit the text on this. + :type auto_size_buttons: (bool) + :param relative_location: (x,y) location relative to the default location of the window, in pixels. Normally the window centers. This location is relative to the location the window would be created. Note they can be negative. + :type relative_location: (int, int) + :param location: (x,y) location, in pixels, to locate the upper left corner of the window on the screen. Default is to center on screen. None will not set any location meaning the OS will decide + :type location: (int, int) or (None, None) or None + :param size: (width, height) size in pixels for this window. Normally the window is autosized to fit contents, not set to an absolute size by the user. Try not to set this value. You risk, the contents being cut off, etc. Let the layout determine the window size instead + :type size: (int, int) + :param element_padding: Default amount of padding to put around elements in window (left/right, top/bottom) or ((left, right), (top, bottom)), or an int. If an int, then it's converted into a tuple (int, int) + :type element_padding: (int, int) or ((int, int),(int,int)) or int + :param margins: (left/right, top/bottom) Amount of pixels to leave inside the window's frame around the edges before your elements are shown. + :type margins: (int, int) + :param button_color: Default button colors for all buttons in the window + :type button_color: (str, str) | str + :param font: specifies the font family, size, etc. Tuple or Single string format 'name size styles'. Styles: italic * roman bold normal underline overstrike + :type font: (str or (str, int[, str]) or None) + :param progress_bar_color: (bar color, background color) Sets the default colors for all progress bars in the window + :type progress_bar_color: (str, str) + :param background_color: color of background + :type background_color: (str) + :param border_depth: Default border depth (width) for all elements in the window + :type border_depth: (int) + :param auto_close: If True, the window will automatically close itself + :type auto_close: (bool) + :param auto_close_duration: Number of seconds to wait before closing the window + :type auto_close_duration: (int) + :param icon: Can be either a filename or Base64 value. For Windows if filename, it MUST be ICO format. For Linux, must NOT be ICO. Most portable is to use a Base64 of a PNG file. This works universally across all OS's + :type icon: (str | bytes) + :param force_toplevel: If True will cause this window to skip the normal use of a hidden master window + :type force_toplevel: (bool) + :param alpha_channel: Sets the opacity of the window. 0 = invisible 1 = completely visible. Values bewteen 0 & 1 will produce semi-transparent windows in SOME environments (The Raspberry Pi always has this value at 1 and cannot change. + :type alpha_channel: (float) + :param return_keyboard_events: if True key presses on the keyboard will be returned as Events from Read calls + :type return_keyboard_events: (bool) + :param use_default_focus: If True will use the default focus algorithm to set the focus to the "Correct" element + :type use_default_focus: (bool) + :param text_justification: Default text justification for all Text Elements in window + :type text_justification: 'left' | 'right' | 'center' + :param no_titlebar: If true, no titlebar nor frame will be shown on window. This means you cannot minimize the window and it will not show up on the taskbar + :type no_titlebar: (bool) + :param grab_anywhere: If True can use mouse to click and drag to move the window. Almost every location of the window will work except input fields on some systems + :type grab_anywhere: (bool) + :param grab_anywhere_using_control: If True can use CONTROL key + left mouse mouse to click and drag to move the window. DEFAULT is TRUE. Unlike normal grab anywhere, it works on all elements. + :type grab_anywhere_using_control: (bool) + :param keep_on_top: If True, window will be created on top of all other windows on screen. It can be bumped down if another window created with this parm + :type keep_on_top: (bool) + :param resizable: If True, allows the user to resize the window. Note the not all Elements will change size or location when resizing. + :type resizable: (bool) + :param disable_close: If True, the X button in the top right corner of the window will no work. Use with caution and always give a way out toyour users + :type disable_close: (bool) + :param disable_minimize: if True the user won't be able to minimize window. Good for taking over entire screen and staying that way. + :type disable_minimize: (bool) + :param right_click_menu: A list of lists of Menu items to show when this element is right clicked. See user docs for exact format. + :type right_click_menu: List[List[ List[str] | str ]] + :param transparent_color: Any portion of the window that has this color will be completely transparent. You can even click through these spots to the window under this window. + :type transparent_color: (str) + :param debugger_enabled: If True then the internal debugger will be enabled + :type debugger_enabled: (bool) + :param right_click_menu_background_color: Background color for right click menus + :type right_click_menu_background_color: (str) + :param right_click_menu_text_color: Text color for right click menus + :type right_click_menu_text_color: (str) + :param right_click_menu_disabled_text_color: Text color for disabled right click menu items + :type right_click_menu_disabled_text_color: (str) + :param right_click_menu_selected_colors: Text AND background colors for a selected item. Can be a Tuple OR a color string. simplified-button-color-string "foreground on background". Can be a single color if want to set only the background. Normally a tuple, but can be a simplified-dual-color-string "foreground on background". Can be a single color if want to set only the background. + :type right_click_menu_selected_colors: (str, str) | str | Tuple + :param right_click_menu_font: Font for right click menus + :type right_click_menu_font: (str or (str, int[, str]) or None) + :param right_click_menu_tearoff: If True then all right click menus can be torn off + :type right_click_menu_tearoff: bool + :param finalize: If True then the Finalize method will be called. Use this rather than chaining .Finalize for cleaner code + :type finalize: (bool) + :param element_justification: All elements in the Window itself will have this justification 'left', 'right', 'center' are valid values + :type element_justification: (str) + :param ttk_theme: Set the tkinter ttk "theme" of the window. Default = DEFAULT_TTK_THEME. Sets all ttk widgets to this theme as their default + :type ttk_theme: (str) + :param use_ttk_buttons: Affects all buttons in window. True = use ttk buttons. False = do not use ttk buttons. None = use ttk buttons only if on a Mac + :type use_ttk_buttons: (bool) + :param modal: If True then this window will be the only window a user can interact with until it is closed + :type modal: (bool) + :param enable_close_attempted_event: If True then the window will not close when "X" clicked. Instead an event WINDOW_CLOSE_ATTEMPTED_EVENT if returned from window.read + :type enable_close_attempted_event: (bool) + :param enable_window_config_events: If True then window configuration events (resizing or moving the window) will return WINDOW_CONFIG_EVENT from window.read. Note you will get several when Window is created. + :type enable_window_config_events: (bool) + :param titlebar_background_color: If custom titlebar indicated by use_custom_titlebar, then use this as background color + :type titlebar_background_color: (str | None) + :param titlebar_text_color: If custom titlebar indicated by use_custom_titlebar, then use this as text color + :type titlebar_text_color: (str | None) + :param titlebar_font: If custom titlebar indicated by use_custom_titlebar, then use this as title font + :type titlebar_font: (str or (str, int[, str]) or None) + :param titlebar_icon: If custom titlebar indicated by use_custom_titlebar, then use this as the icon (file or base64 bytes) + :type titlebar_icon: (bytes | str) + :param use_custom_titlebar: If True, then a custom titlebar will be used instead of the normal titlebar + :type use_custom_titlebar: bool + :param scaling: Apply scaling to the elements in the window. Can be set on a global basis using set_options + :type scaling: float + :param sbar_trough_color: Scrollbar color of the trough + :type sbar_trough_color: (str) + :param sbar_background_color: Scrollbar color of the background of the arrow buttons at the ends AND the color of the "thumb" (the thing you grab and slide). Switches to arrow color when mouse is over + :type sbar_background_color: (str) + :param sbar_arrow_color: Scrollbar color of the arrow at the ends of the scrollbar (it looks like a button). Switches to background color when mouse is over + :type sbar_arrow_color: (str) + :param sbar_width: Scrollbar width in pixels + :type sbar_width: (int) + :param sbar_arrow_width: Scrollbar width of the arrow on the scrollbar. It will potentially impact the overall width of the scrollbar + :type sbar_arrow_width: (int) + :param sbar_frame_color: Scrollbar Color of frame around scrollbar (available only on some ttk themes) + :type sbar_frame_color: (str) + :param sbar_relief: Scrollbar relief that will be used for the "thumb" of the scrollbar (the thing you grab that slides). Should be a constant that is defined at starting with "RELIEF_" - RELIEF_RAISED, RELIEF_SUNKEN, RELIEF_FLAT, RELIEF_RIDGE, RELIEF_GROOVE, RELIEF_SOLID + :type sbar_relief: (str) + :param watermark: If True, then turns on watermarking temporarily for ALL windows created from this point forward. See global settings doc for more info + :type watermark: bool + :param metadata: User metadata that can be set to ANYTHING + :type metadata: (Any) + """ + + + self._metadata = None # type: Any + self.AutoSizeText = auto_size_text if auto_size_text is not None else DEFAULT_AUTOSIZE_TEXT + self.AutoSizeButtons = auto_size_buttons if auto_size_buttons is not None else DEFAULT_AUTOSIZE_BUTTONS + self.Title = str(title) + self.Rows = [] # a list of ELEMENTS for this row + self.DefaultElementSize = default_element_size if default_element_size is not None else DEFAULT_ELEMENT_SIZE + self.DefaultButtonElementSize = default_button_element_size if default_button_element_size != ( + None, None) else DEFAULT_BUTTON_ELEMENT_SIZE + if DEFAULT_WINDOW_LOCATION != (None, None) and location == (None, None): + self.Location = DEFAULT_WINDOW_LOCATION + else: + self.Location = location + self.RelativeLoction = relative_location + self.ButtonColor = button_color_to_tuple(button_color) + self.BackgroundColor = background_color if background_color else DEFAULT_BACKGROUND_COLOR + self.ParentWindow = None + self.Font = font if font else DEFAULT_FONT + self.RadioDict = {} + self.BorderDepth = border_depth + if icon: + self.WindowIcon = icon + elif Window._user_defined_icon is not None: + self.WindowIcon = Window._user_defined_icon + else: + self.WindowIcon = DEFAULT_WINDOW_ICON + self.AutoClose = auto_close + self.NonBlocking = False + self.TKroot = None # type: tk.Tk + self.TKrootDestroyed = False + self.CurrentlyRunningMainloop = False + self.FormRemainedOpen = False + self.TKAfterID = None + self.ProgressBarColor = progress_bar_color + self.AutoCloseDuration = auto_close_duration + self.RootNeedsDestroying = False + self.Shown = False + self.ReturnValues = None + self.ReturnValuesList = [] + self.ReturnValuesDictionary = {} + self.DictionaryKeyCounter = 0 + self.LastButtonClicked = None + self.LastButtonClickedWasRealtime = False + self.UseDictionary = False + self.UseDefaultFocus = use_default_focus + self.ReturnKeyboardEvents = return_keyboard_events + self.LastKeyboardEvent = None + self.TextJustification = text_justification + self.NoTitleBar = no_titlebar + self.Grab = grab_anywhere + self.GrabAnywhere = grab_anywhere + self.GrabAnywhereUsingControlKey = grab_anywhere_using_control + if keep_on_top is None and DEFAULT_KEEP_ON_TOP is not None: + keep_on_top = DEFAULT_KEEP_ON_TOP + elif keep_on_top is None: + keep_on_top = False + self.KeepOnTop = keep_on_top + self.ForceTopLevel = force_toplevel + self.Resizable = resizable + self._AlphaChannel = alpha_channel if alpha_channel is not None else DEFAULT_ALPHA_CHANNEL + self.Timeout = None + self.TimeoutKey = TIMEOUT_KEY + self.TimerCancelled = False + self.DisableClose = disable_close + self.DisableMinimize = disable_minimize + self._Hidden = False + self._Size = size + self.XFound = False + if element_padding is not None: + if isinstance(element_padding, int): + element_padding = (element_padding, element_padding) + + if element_padding is None: + self.ElementPadding = DEFAULT_ELEMENT_PADDING + else: + self.ElementPadding = element_padding + self.RightClickMenu = right_click_menu + self.Margins = margins if margins != (None, None) else DEFAULT_MARGINS + self.ContainerElemementNumber = Window._GetAContainerNumber() + # The dictionary containing all elements and keys for the window + # The keys are the keys for the elements and the values are the elements themselves. + self.AllKeysDict = {} + self.TransparentColor = transparent_color + self.UniqueKeyCounter = 0 + self.DebuggerEnabled = debugger_enabled + self.WasClosed = False + self.ElementJustification = element_justification + self.FocusSet = False + self.metadata = metadata + self.TtkTheme = ttk_theme or DEFAULT_TTK_THEME + self.UseTtkButtons = use_ttk_buttons if use_ttk_buttons is not None else USE_TTK_BUTTONS + self.user_bind_dict = {} # Used when user defines a tkinter binding using bind method - convert bind string to key modifier + self.user_bind_event = None # Used when user defines a tkinter binding using bind method - event data from tkinter + self.modal = modal + self.thread_queue = None # type: queue.Queue + self.thread_lock = None # type: threading.Lock + self.thread_timer = None # type: tk.Misc + self.thread_strvar = None # type: tk.StringVar + self.read_closed_window_count = 0 + self.config_last_size = (None, None) + self.config_last_location = (None, None) + self.starting_window_position = (None, None) + self.not_completed_initial_movement = True + self.config_count = 0 + self.saw_00 = False + self.maximized = False + self.right_click_menu_background_color = right_click_menu_background_color if right_click_menu_background_color is not None else theme_input_background_color() + self.right_click_menu_text_color = right_click_menu_text_color if right_click_menu_text_color is not None else theme_input_text_color() + self.right_click_menu_disabled_text_color = right_click_menu_disabled_text_color if right_click_menu_disabled_text_color is not None else COLOR_SYSTEM_DEFAULT + self.right_click_menu_font = right_click_menu_font if right_click_menu_font is not None else self.Font + self.right_click_menu_tearoff = right_click_menu_tearoff + self.auto_close_timer_needs_starting = False + self.finalize_in_progress = False + self.close_destroys_window = not enable_close_attempted_event if enable_close_attempted_event is not None else None + self.enable_window_config_events = enable_window_config_events + self.override_custom_titlebar = False + self.use_custom_titlebar = use_custom_titlebar or theme_use_custom_titlebar() + self.titlebar_background_color = titlebar_background_color + self.titlebar_text_color = titlebar_text_color + self.titlebar_font = titlebar_font + self.titlebar_icon = titlebar_icon + self.right_click_menu_selected_colors = _simplified_dual_color_to_tuple(right_click_menu_selected_colors, + (self.right_click_menu_background_color, self.right_click_menu_text_color)) + self.TKRightClickMenu = None + self._grab_anywhere_ignore_these_list = [] + self._grab_anywhere_include_these_list = [] + self._has_custom_titlebar = use_custom_titlebar + self._mousex = self._mousey = 0 + self._startx = self._starty = 0 + self.scaling = scaling if scaling is not None else DEFAULT_SCALING + if self.use_custom_titlebar: + self.Margins = (0, 0) + self.NoTitleBar = True + self._mouse_offset_x = self._mouse_offset_y = 0 + + if watermark is True: + Window._watermark_temp_forced = True + _global_settings_get_watermark_info() + elif watermark is False: + Window._watermark = None + Window._watermark_temp_forced = False + + + self.ttk_part_overrides = TTKPartOverrides(sbar_trough_color=sbar_trough_color, sbar_background_color=sbar_background_color, sbar_arrow_color=sbar_arrow_color, sbar_width=sbar_width, sbar_arrow_width=sbar_arrow_width, sbar_frame_color=sbar_frame_color, sbar_relief=sbar_relief) + + if no_titlebar is True: + self.override_custom_titlebar = True + + if layout is not None and type(layout) not in (list, tuple): + warnings.warn('Your layout is not a list or tuple... this is not good!') + + if layout is not None: + self.Layout(layout) + if finalize: + self.Finalize() + + if CURRENT_LOOK_AND_FEEL == 'Default': + print("Window will be a boring gray. Try removing the theme call entirely\n", + "You will get the default theme or the one set in global settings\n" + "If you seriously want this gray window and no more nagging, add theme('DefaultNoMoreNagging') or theme('Gray Gray Gray') for completely gray/System Defaults") + + + @classmethod + def _GetAContainerNumber(cls): + """ + Not user callable! + :return: A simple counter that makes each container element unique + :rtype: + """ + cls._container_element_counter += 1 + return cls._container_element_counter + + @classmethod + def _IncrementOpenCount(self): + """ + Not user callable! Increments the number of open windows + Note - there is a bug where this count easily gets out of sync. Issue has been opened already. No ill effects + """ + self.NumOpenWindows += 1 + # print('+++++ INCREMENTING Num Open Windows = {} ---'.format(Window.NumOpenWindows)) + + @classmethod + def _DecrementOpenCount(self): + """ + Not user callable! Decrements the number of open windows + """ + self.NumOpenWindows -= 1 * (self.NumOpenWindows != 0) # decrement if not 0 + # print('----- DECREMENTING Num Open Windows = {} ---'.format(Window.NumOpenWindows)) + + @classmethod + def get_screen_size(self): + """ + This is a "Class Method" meaning you call it by writing: width, height = Window.get_screen_size() + Returns the size of the "screen" as determined by tkinter. This can vary depending on your operating system and the number of monitors installed on your system. For Windows, the primary monitor's size is returns. On some multi-monitored Linux systems, the monitors are combined and the total size is reported as if one screen. + + :return: Size of the screen in pixels as determined by tkinter + :rtype: (int, int) + """ + root = _get_hidden_master_root() + screen_width = root.winfo_screenwidth() + screen_height = root.winfo_screenheight() + return screen_width, screen_height + + @property + def metadata(self): + """ + Metadata is available for all windows. You can set to any value. + :return: the current metadata value + :rtype: (Any) + """ + return self._metadata + + @metadata.setter + def metadata(self, value): + """ + Metadata is available for all windows. You can set to any value. + :param value: Anything you want it to be + :type value: (Any) + """ + self._metadata = value + + # ------------------------- Add ONE Row to Form ------------------------- # + def add_row(self, *args): + """ + Adds a single row of elements to a window's self.Rows variables. + Generally speaking this is NOT how users should be building Window layouts. + Users, create a single layout (a list of lists) and pass as a parameter to Window object, or call Window.Layout(layout) + + :param *args: List[Elements] + :type *args: + """ + NumRows = len(self.Rows) # number of existing rows is our row number + CurrentRowNumber = NumRows # this row's number + CurrentRow = [] # start with a blank row and build up + # ------------------------- Add the elements to a row ------------------------- # + for i, element in enumerate(args): # Loop through list of elements and add them to the row + + if isinstance(element, tuple) or isinstance(element, list): + self.add_row(*element) + continue + _error_popup_with_traceback('Error creating Window layout', + 'Layout has a LIST instead of an ELEMENT', + 'This sometimes means you have a badly placed ]', + 'The offensive list is:', + element, + 'This list will be stripped from your layout' + ) + continue + elif callable(element) and not isinstance(element, Element): + _error_popup_with_traceback('Error creating Window layout', + 'Layout has a FUNCTION instead of an ELEMENT', + 'This likely means you are missing () from your layout', + 'The offensive list is:', + element, + 'This item will be stripped from your layout') + continue + if element.ParentContainer is not None: + warnings.warn( + '*** YOU ARE ATTEMPTING TO REUSE AN ELEMENT IN YOUR LAYOUT! Once placed in a layout, an element cannot be used in another layout. ***', + UserWarning) + _error_popup_with_traceback('Error detected in layout - Contains an element that has already been used.', + 'You have attempted to reuse an element in your layout.', + "The layout specified has an element that's already been used.", + 'You MUST start with a "clean", unused layout every time you create a window', + 'The offensive Element = ', + element, + 'and has a key = ', element.Key, + 'This item will be stripped from your layout', + 'Hint - try printing your layout and matching the IDs "print(layout)"') + continue + element.Position = (CurrentRowNumber, i) + element.ParentContainer = self + CurrentRow.append(element) + # if this element is a titlebar, then automatically set the window margins to (0,0) and turn off normal titlebar + if element.metadata == TITLEBAR_METADATA_MARKER: + self.Margins = (0, 0) + self.NoTitleBar = True + # ------------------------- Append the row to list of Rows ------------------------- # + self.Rows.append(CurrentRow) + + # ------------------------- Add Multiple Rows to Form ------------------------- # + def add_rows(self, rows): + """ + Loops through a list of lists of elements and adds each row, list, to the layout. + This is NOT the best way to go about creating a window. Sending the entire layout at one time and passing + it as a parameter to the Window call is better. + + :param rows: A list of a list of elements + :type rows: List[List[Elements]] + """ + for row in rows: + try: + iter(row) + except TypeError: + _error_popup_with_traceback('Error Creating Window Layout', 'Error creating Window layout', + 'Your row is not an iterable (e.g. a list)', + 'Instead of a list, the type found was {}'.format(type(row)), + 'The offensive row = ', + row, + 'This item will be stripped from your layout') + continue + self.add_row(*row) + # if _optional_window_data(self) is not None: + # self.add_row(_optional_window_data(self)) + if Window._watermark is not None: + self.add_row(Window._watermark(self)) + + + + def layout(self, rows): + """ + Second of two preferred ways of telling a Window what its layout is. The other way is to pass the layout as + a parameter to Window object. The parameter method is the currently preferred method. This call to Layout + has been removed from examples contained in documents and in the Demo Programs. Trying to remove this call + from history and replace with sending as a parameter to Window. + + :param rows: Your entire layout + :type rows: List[List[Elements]] + :return: self so that you can chain method calls + :rtype: (Window) + """ + if self.use_custom_titlebar and not self.override_custom_titlebar: + if self.titlebar_icon is not None: + icon = self.titlebar_icon + elif CUSTOM_TITLEBAR_ICON is not None: + icon = CUSTOM_TITLEBAR_ICON + elif self.titlebar_icon is not None: + icon = self.titlebar_icon + elif self.WindowIcon == DEFAULT_WINDOW_ICON: + icon = DEFAULT_BASE64_ICON_16_BY_16 + else: + icon = None + + new_rows = [[Titlebar(title=self.Title, icon=icon, text_color=self.titlebar_text_color, background_color=self.titlebar_background_color, + font=self.titlebar_font)]] + rows + else: + new_rows = rows + self.add_rows(new_rows) + self._BuildKeyDict() + + if self._has_custom_titlebar_element(): + self.Margins = (0, 0) + self.NoTitleBar = True + self._has_custom_titlebar = True + return self + + def extend_layout(self, container, rows): + """ + Adds new rows to an existing container element inside of this window + If the container is a scrollable Column, you need to also call the contents_changed() method + + :param container: The container Element the layout will be placed inside of + :type container: Frame | Column | Tab + :param rows: The layout to be added + :type rows: (List[List[Element]]) + :return: (Window) self so could be chained + :rtype: (Window) + """ + column = Column(rows, pad=(0, 0), background_color=container.BackgroundColor) + if self == container: + frame = self.TKroot + elif isinstance(container.Widget, TkScrollableFrame): + frame = container.Widget.TKFrame + else: + frame = container.Widget + PackFormIntoFrame(column, frame, self) + # sg.PackFormIntoFrame(col, window.TKroot, window) + self.AddRow(column) + self.AllKeysDict = self._BuildKeyDictForWindow(self, column, self.AllKeysDict) + return self + + def LayoutAndRead(self, rows, non_blocking=False): + """ + Deprecated!! Now your layout your window's rows (layout) and then separately call Read. + + :param rows: The layout of the window + :type rows: List[List[Element]] + :param non_blocking: if True the Read call will not block + :type non_blocking: (bool) + """ + _error_popup_with_traceback('LayoutAndRead Depricated', 'Wow! You have been using PySimpleGUI for a very long time.', + 'The Window.LayoutAndRead call is no longer supported') + + raise DeprecationWarning( + 'LayoutAndRead is no longer supported... change your call window.Layout(layout).Read()\nor window(title, layout).Read()') + # self.AddRows(rows) + # self._Show(non_blocking=non_blocking) + # return self.ReturnValues + + def LayoutAndShow(self, rows): + """ + Deprecated - do not use any longer. Layout your window and then call Read. Or can add a Finalize call before the Read + """ + raise DeprecationWarning('LayoutAndShow is no longer supported... ') + + def _Show(self, non_blocking=False): + """ + NOT TO BE CALLED BY USERS. INTERNAL ONLY! + It's this method that first shows the window to the user, collects results + + :param non_blocking: if True, this is a non-blocking call + :type non_blocking: (bool) + :return: Tuple[Any, Dict] The event, values turple that is returned from Read calls + :rtype: + """ + self.Shown = True + # Compute num rows & num cols (it'll come in handy debugging) + self.NumRows = len(self.Rows) + self.NumCols = max(len(row) for row in self.Rows) + self.NonBlocking = non_blocking + + # Search through entire form to see if any elements set the focus + # if not, then will set the focus to the first input element + found_focus = False + for row in self.Rows: + for element in row: + try: + if element.Focus: + found_focus = True + except: + pass + try: + if element.Key is not None: + self.UseDictionary = True + except: + pass + + if not found_focus and self.UseDefaultFocus: + self.UseDefaultFocus = True + else: + self.UseDefaultFocus = False + # -=-=-=-=-=-=-=-=- RUN the GUI -=-=-=-=-=-=-=-=- ## + StartupTK(self) + # If a button or keyboard event happened but no results have been built, build the results + if self.LastKeyboardEvent is not None or self.LastButtonClicked is not None: + return _BuildResults(self, False, self) + return self.ReturnValues + + # ------------------------- SetIcon - set the window's fav icon ------------------------- # + def set_icon(self, icon=None, pngbase64=None): + """ + Changes the icon that is shown on the title bar and on the task bar. + NOTE - The file type is IMPORTANT and depends on the OS! + Can pass in: + * filename which must be a .ICO icon file for windows, PNG file for Linux + * bytes object + * BASE64 encoded file held in a variable + + :param icon: Filename or bytes object + :type icon: (str) + :param pngbase64: Base64 encoded image + :type pngbase64: (bytes) + """ + if type(icon) is bytes or pngbase64 is not None: + wicon = tkinter.PhotoImage(data=icon if icon is not None else pngbase64) + try: + self.TKroot.tk.call('wm', 'iconphoto', self.TKroot._w, wicon) + except: + wicon = tkinter.PhotoImage(data=DEFAULT_BASE64_ICON) + try: + self.TKroot.tk.call('wm', 'iconphoto', self.TKroot._w, wicon) + except: + pass + self.WindowIcon = wicon + return + + wicon = icon + try: + self.TKroot.iconbitmap(icon) + except: + try: + wicon = tkinter.PhotoImage(file=icon) + self.TKroot.tk.call('wm', 'iconphoto', self.TKroot._w, wicon) + except: + try: + wicon = tkinter.PhotoImage(data=DEFAULT_BASE64_ICON) + try: + self.TKroot.tk.call('wm', 'iconphoto', self.TKroot._w, wicon) + except: + pass + except: + pass + self.WindowIcon = wicon + + def _GetElementAtLocation(self, location): + """ + Given a (row, col) location in a layout, return the element located at that position + + :param location: (int, int) Return the element located at (row, col) in layout + :type location: + :return: (Element) The Element located at that position in this window + :rtype: + """ + + (row_num, col_num) = location + row = self.Rows[row_num] + element = row[col_num] + return element + + def _GetDefaultElementSize(self): + """ + Returns the default elementSize + + :return: (width, height) of the default element size + :rtype: (int, int) + """ + + return self.DefaultElementSize + + def _AutoCloseAlarmCallback(self): + """ + Function that's called by tkinter when autoclode timer expires. Closes the window + + """ + try: + window = self + if window: + if window.NonBlocking: + self.Close() + else: + window._Close() + self.TKroot.quit() + self.RootNeedsDestroying = True + except: + pass + + def _TimeoutAlarmCallback(self): + """ + Read Timeout Alarm callback. Will kick a mainloop call out of the tkinter event loop and cause it to return + """ + # first, get the results table built + # modify the Results table in the parent FlexForm object + # print('TIMEOUT CALLBACK') + if self.TimerCancelled: + # print('** timer was cancelled **') + return + self.LastButtonClicked = self.TimeoutKey + self.FormRemainedOpen = True + self.TKroot.quit() # kick the users out of the mainloop + + def _calendar_chooser_button_clicked(self, elem): + """ + + :param elem: + :type elem: + :return: + :rtype: + """ + target_element, strvar, should_submit_window = elem._find_target() + + if elem.calendar_default_date_M_D_Y == (None, None, None): + now = datetime.datetime.now() + cur_month, cur_day, cur_year = now.month, now.day, now.year + else: + cur_month, cur_day, cur_year = elem.calendar_default_date_M_D_Y + + date_chosen = popup_get_date(start_mon=cur_month, start_day=cur_day, start_year=cur_year, close_when_chosen=elem.calendar_close_when_chosen, + no_titlebar=elem.calendar_no_titlebar, begin_at_sunday_plus=elem.calendar_begin_at_sunday_plus, + locale=elem.calendar_locale, location=elem.calendar_location, month_names=elem.calendar_month_names, + day_abbreviations=elem.calendar_day_abbreviations, title=elem.calendar_title) + if date_chosen is not None: + month, day, year = date_chosen + now = datetime.datetime.now() + hour, minute, second = now.hour, now.minute, now.second + try: + date_string = calendar.datetime.datetime(year, month, day, hour, minute, second).strftime(elem.calendar_format) + except Exception as e: + print('Bad format string in calendar chooser button', e) + date_string = 'Bad format string' + + if target_element is not None and target_element != elem: + target_element.update(date_string) + elif target_element == elem: + elem.calendar_selection = date_string + + strvar.set(date_string) + elem.TKStringVar.set(date_string) + if should_submit_window: + self.LastButtonClicked = target_element.Key + results = _BuildResults(self, False, self) + else: + should_submit_window = False + return should_submit_window + + # @_timeit_summary + def read(self, timeout=None, timeout_key=TIMEOUT_KEY, close=False): + """ + THE biggest deal method in the Window class! This is how you get all of your data from your Window. + Pass in a timeout (in milliseconds) to wait for a maximum of timeout milliseconds. Will return timeout_key + if no other GUI events happen first. + + :param timeout: Milliseconds to wait until the Read will return IF no other GUI events happen first + :type timeout: (int) + :param timeout_key: The value that will be returned from the call if the timer expired + :type timeout_key: (Any) + :param close: if True the window will be closed prior to returning + :type close: (bool) + :return: (event, values) + :rtype: Tuple[(Any), Dict[Any, Any], List[Any], None] + """ + + if Window._floating_debug_window_build_needed is True: + Window._floating_debug_window_build_needed = False + _Debugger.debugger._build_floating_window() + + if Window._main_debug_window_build_needed is True: + Window._main_debug_window_build_needed = False + _Debugger.debugger._build_main_debugger_window() + + # ensure called only 1 time through a single read cycle + if not Window._read_call_from_debugger: + _refresh_debugger() + + # if the user has not added timeout and a debug window is open, then set a timeout for them so the debugger continuously refreshes + if _debugger_window_is_open() and not Window._read_call_from_debugger: + if timeout is None or timeout > 3000: + timeout = 200 + + + while True: + Window._root_running_mainloop = self.TKroot + results = self._read(timeout=timeout, timeout_key=timeout_key) + if results is not None: + if results[0] == DEFAULT_WINDOW_SNAPSHOT_KEY: + self.save_window_screenshot_to_disk() + popup_quick_message('Saved window screenshot to disk', background_color='#1c1e23', text_color='white', keep_on_top=True, font='_ 30') + continue + # Post processing for Calendar Chooser Button + try: + if results[0] == timeout_key: # if a timeout, then not a calendar button + break + elem = self.find_element(results[0], silent_on_error=True) # get the element that caused the event + if elem.Type == ELEM_TYPE_BUTTON: + if elem.BType == BUTTON_TYPE_CALENDAR_CHOOSER: + if self._calendar_chooser_button_clicked(elem): # returns True if should break out + # results[0] = self.LastButtonClicked + results = self.ReturnValues + break + else: + continue + break + except: + break # wasn't a calendar button for sure + + + if close: + self.close() + + return results + + # @_timeit + def _read(self, timeout=None, timeout_key=TIMEOUT_KEY): + """ + THE biggest deal method in the Window class! This is how you get all of your data from your Window. + Pass in a timeout (in milliseconds) to wait for a maximum of timeout milliseconds. Will return timeout_key + if no other GUI events happen first. + + :param timeout: Milliseconds to wait until the Read will return IF no other GUI events happen first + :type timeout: (int) + :param timeout_key: The value that will be returned from the call if the timer expired + :type timeout_key: (Any) + :return: (event, values) (event or timeout_key or None, Dictionary of values or List of values from all elements in the Window) + :rtype: Tuple[(Any), Dict[Any, Any], List[Any], None] + """ + + # if there are events in the thread event queue, then return those events before doing anything else. + if self._queued_thread_event_available(): + self.ReturnValues = results = _BuildResults(self, False, self) + return results + + if self.finalize_in_progress and self.auto_close_timer_needs_starting: + self._start_autoclose_timer() + self.auto_close_timer_needs_starting = False + + timeout = int(timeout) if timeout is not None else None + if timeout == 0: # timeout of zero runs the old readnonblocking + event, values = self._ReadNonBlocking() + if event is None: + event = timeout_key + if values is None: + event = None + return event, values # make event None if values was None and return + # Read with a timeout + self.Timeout = timeout + self.TimeoutKey = timeout_key + self.NonBlocking = False + if self.TKrootDestroyed: + self.read_closed_window_count += 1 + if self.read_closed_window_count > 100: + popup_error_with_traceback('Trying to read a closed window', 'You have tried 100 times to read a closed window.', 'You need to add a check for event == WIN_CLOSED',) + return None, None + if not self.Shown: + self._Show() + else: + # if already have a button waiting, the return previously built results + if self.LastButtonClicked is not None and not self.LastButtonClickedWasRealtime: + results = _BuildResults(self, False, self) + self.LastButtonClicked = None + return results + InitializeResults(self) + + if self._queued_thread_event_available(): + self.ReturnValues = results = _BuildResults(self, False, self) + return results + + # if the last button clicked was realtime, emulate a read non-blocking + # the idea is to quickly return realtime buttons without any blocks until released + if self.LastButtonClickedWasRealtime: + # clear the realtime flag if the element is not a button element (for example a graph element that is dragging) + if self.AllKeysDict.get(self.LastButtonClicked, None): + if self.AllKeysDict.get(self.LastButtonClicked).Type != ELEM_TYPE_BUTTON: + self.LastButtonClickedWasRealtime = False # stops from generating events until something changes + else: # it is possible for the key to not be in the dicitonary because it has a modifier. If so, then clear the realtime button flag + self.LastButtonClickedWasRealtime = False # stops from generating events until something changes + + try: + rc = self.TKroot.update() + except: + self.TKrootDestroyed = True + Window._DecrementOpenCount() + # _my_windows.Decrement() + # print('ROOT Destroyed') + results = _BuildResults(self, False, self) + if results[0] != None and results[0] != timeout_key: + return results + else: + pass + + # else: + # print("** REALTIME PROBLEM FOUND **", results) + + if self.RootNeedsDestroying: + # print('*** DESTROYING really late***') + try: + self.TKroot.destroy() + except: + pass + # _my_windows.Decrement() + self.LastButtonClicked = None + return None, None + + # normal read blocking code.... + if timeout != None: + self.TimerCancelled = False + self.TKAfterID = self.TKroot.after(timeout, self._TimeoutAlarmCallback) + self.CurrentlyRunningMainloop = True + # self.TKroot.protocol("WM_DESTROY_WINDOW", self._OnClosingCallback) + # self.TKroot.protocol("WM_DELETE_WINDOW", self._OnClosingCallback) + Window._window_running_mainloop = self + try: + Window._root_running_mainloop.mainloop() + except: + print('**** EXITING ****') + exit(-1) + # print('Out main') + self.CurrentlyRunningMainloop = False + # if self.LastButtonClicked != TIMEOUT_KEY: + try: + self.TKroot.after_cancel(self.TKAfterID) + del self.TKAfterID + except: + pass + # print('** tkafter cancel failed **') + self.TimerCancelled = True + if self.RootNeedsDestroying: + # print('*** DESTROYING LATE ***') + try: + self.TKroot.destroy() + except: + pass + Window._DecrementOpenCount() + # _my_windows.Decrement() + self.LastButtonClicked = None + return None, None + # if form was closed with X + if self.LastButtonClicked is None and self.LastKeyboardEvent is None and self.ReturnValues[0] is None: + Window._DecrementOpenCount() + # _my_windows.Decrement() + # Determine return values + if self.LastKeyboardEvent is not None or self.LastButtonClicked is not None: + results = _BuildResults(self, False, self) + if not self.LastButtonClickedWasRealtime: + self.LastButtonClicked = None + return results + else: + if self._queued_thread_event_available(): + self.ReturnValues = results = _BuildResults(self, False, self) + return results + if not self.XFound and self.Timeout != 0 and self.Timeout is not None and self.ReturnValues[ + 0] is None: # Special Qt case because returning for no reason so fake timeout + self.ReturnValues = self.TimeoutKey, self.ReturnValues[1] # fake a timeout + elif not self.XFound and self.ReturnValues[0] is None: # Return a timeout event... can happen when autoclose used on another window + # print("*** Faking timeout ***") + self.ReturnValues = self.TimeoutKey, self.ReturnValues[1] # fake a timeout + return self.ReturnValues + + def _ReadNonBlocking(self): + """ + Should be NEVER called directly by the user. The user can call Window.read(timeout=0) to get same effect + + :return: (event, values). (event or timeout_key or None, Dictionary of values or List of values from all elements in the Window) + :rtype: Tuple[(Any), Dict[Any, Any] | List[Any] | None] + """ + if self.TKrootDestroyed: + try: + self.TKroot.quit() + self.TKroot.destroy() + except: + pass + # print('DESTROY FAILED') + return None, None + if not self.Shown: + self._Show(non_blocking=True) + try: + rc = self.TKroot.update() + except: + self.TKrootDestroyed = True + Window._DecrementOpenCount() + # _my_windows.Decrement() + # print("read failed") + # return None, None + if self.RootNeedsDestroying: + # print('*** DESTROYING LATE ***', self.ReturnValues) + self.TKroot.destroy() + Window._DecrementOpenCount() + # _my_windows.Decrement() + self.Values = None + self.LastButtonClicked = None + return None, None + return _BuildResults(self, False, self) + + def _start_autoclose_timer(self): + duration = DEFAULT_AUTOCLOSE_TIME if self.AutoCloseDuration is None else self.AutoCloseDuration + self.TKAfterID = self.TKroot.after(int(duration * 1000), self._AutoCloseAlarmCallback) + + def finalize(self): + """ + Use this method to cause your layout to built into a real tkinter window. In reality this method is like + Read(timeout=0). It doesn't block and uses your layout to create tkinter widgets to represent the elements. + Lots of action! + + :return: Returns 'self' so that method "Chaining" can happen (read up about it as it's very cool!) + :rtype: (Window) + """ + + if self.TKrootDestroyed: + return self + self.finalize_in_progress = True + + self.Read(timeout=1) + + if self.AutoClose: + self.auto_close_timer_needs_starting = True + # add the window to the list of active windows + Window._active_windows[self] = Window.hidden_master_root + return self + # OLD CODE FOLLOWS + if not self.Shown: + self._Show(non_blocking=True) + try: + rc = self.TKroot.update() + except: + self.TKrootDestroyed = True + Window._DecrementOpenCount() + print('** Finalize failed **') + # _my_windows.Decrement() + # return None, None + return self + + def refresh(self): + """ + Refreshes the window by calling tkroot.update(). Can sometimes get away with a refresh instead of a Read. + Use this call when you want something to appear in your Window immediately (as soon as this function is called). + If you change an element in a window, your change will not be visible until the next call to Window.read + or a call to Window.refresh() + + :return: `self` so that method calls can be easily "chained" + :rtype: (Window) + """ + + if self.TKrootDestroyed: + return self + try: + rc = self.TKroot.update() + except: + pass + return self + + def fill(self, values_dict): + """ + Fill in elements that are input fields with data based on a 'values dictionary' + + :param values_dict: pairs + :type values_dict: (Dict[Any, Any]) - {Element_key : value} + :return: returns self so can be chained with other methods + :rtype: (Window) + """ + + FillFormWithValues(self, values_dict) + return self + + def _find_closest_key(self, search_key): + if not isinstance(search_key, str): + search_key = str(search_key) + matches = difflib.get_close_matches(search_key, [str(k) for k in self.AllKeysDict.keys()]) + if not len(matches): + return None + for k in self.AllKeysDict.keys(): + if matches[0] == str(k): + return k + return matches[0] if len(matches) else None + + def FindElement(self, key, silent_on_error=False): + """ + ** Warning ** This call will eventually be depricated. ** + + It is suggested that you modify your code to use the recommended window[key] lookup or the PEP8 compliant window.find_element(key) + + For now, you'll only see a message printed and the call will continue to funcation as before. + + :param key: Used with window.find_element and with return values to uniquely identify this element + :type key: str | int | tuple | object + :param silent_on_error: If True do not display popup nor print warning of key errors + :type silent_on_error: (bool) + :return: Return value can be: the Element that matches the supplied key if found; an Error Element if silent_on_error is False; None if silent_on_error True; + :rtype: Element | Error Element | None + """ + + warnings.warn('Use of FindElement is not recommended.\nEither switch to the recommended window[key] format\nor the PEP8 compliant find_element', + UserWarning) + print('** Warning - FindElement should not be used to look up elements. window[key] or window.find_element are recommended. **') + + return self.find_element(key, silent_on_error=silent_on_error) + + def find_element(self, key, silent_on_error=False, supress_guessing=None, supress_raise=None): + """ + Find element object associated with the provided key. + THIS METHOD IS NO LONGER NEEDED to be called by the user + + You can perform the same operation by writing this statement: + element = window[key] + + You can drop the entire "find_element" function name and use [ ] instead. + + However, if you wish to perform a lookup without error checking, and don't have error popups turned + off globally, you'll need to make this call so that you can disable error checks on this call. + + find_element is typically used in combination with a call to element's update method (or any other element method!): + window[key].update(new_value) + + Versus the "old way" + window.FindElement(key).Update(new_value) + + This call can be abbreviated to any of these: + find_element = FindElement == Element == Find + With find_element being the PEP8 compliant call that should be used. + + Rememeber that this call will return None if no match is found which may cause your code to crash if not + checked for. + + :param key: Used with window.find_element and with return values to uniquely identify this element + :type key: str | int | tuple | object + :param silent_on_error: If True do not display popup nor print warning of key errors + :type silent_on_error: (bool) + :param supress_guessing: Override for the global key guessing setting. + :type supress_guessing: (bool | None) + :param supress_raise: Override for the global setting that determines if a key error should raise an exception + :type supress_raise: (bool | None) + :return: Return value can be: the Element that matches the supplied key if found; an Error Element if silent_on_error is False; None if silent_on_error True + :rtype: Element | ErrorElement | None + """ + + key_error = False + closest_key = None + supress_guessing = supress_guessing if supress_guessing is not None else SUPPRESS_KEY_GUESSING + supress_raise = supress_raise if supress_raise is not None else SUPPRESS_RAISE_KEY_ERRORS + try: + element = self.AllKeysDict[key] + except KeyError: + key_error = True + closest_key = self._find_closest_key(key) + if not silent_on_error: + print('** Error looking up your element using the key: ', key, 'The closest matching key: ', closest_key) + _error_popup_with_traceback('Key Error', 'Problem finding your key ' + str(key), 'Closest match = ' + str(closest_key), emoji=EMOJI_BASE64_KEY) + element = ErrorElement(key=key) + else: + element = None + if not supress_raise: + raise KeyError(key) + + if key_error: + if not supress_guessing and closest_key is not None: + element = self.AllKeysDict[closest_key] + + return element + + Element = find_element # Shortcut function + Find = find_element # Shortcut function, most likely not used by many people. + Elem = find_element # NEW for 2019! More laziness... Another shortcut + + def find_element_with_focus(self): + """ + Returns the Element that currently has focus as reported by tkinter. If no element is found None is returned! + :return: An Element if one has been found with focus or None if no element found + :rtype: Element | None + """ + element = _FindElementWithFocusInSubForm(self) + return element + + + def widget_to_element(self, widget): + """ + Returns the element that matches a supplied tkinter widget. + If no matching element is found, then None is returned. + + + :return: Element that uses the specified widget + :rtype: Element | None + """ + if self.AllKeysDict is None or len(self.AllKeysDict) == 0: + return None + for key, element in self.AllKeysDict.items(): + if element.Widget == widget: + return element + return None + + + def _BuildKeyDict(self): + """ + Used internally only! Not user callable + Builds a dictionary containing all elements with keys for this window. + """ + dict = {} + self.AllKeysDict = self._BuildKeyDictForWindow(self, self, dict) + + def _BuildKeyDictForWindow(self, top_window, window, key_dict): + """ + Loop through all Rows and all Container Elements for this window and create the keys for all of them. + Note that the calls are recursive as all pathes must be walked + + :param top_window: The highest level of the window + :type top_window: (Window) + :param window: The "sub-window" (container element) to be searched + :type window: Column | Frame | TabGroup | Pane | Tab + :param key_dict: The dictionary as it currently stands.... used as part of recursive call + :type key_dict: + :return: (dict) Dictionary filled with all keys in the window + :rtype: + """ + for row_num, row in enumerate(window.Rows): + for col_num, element in enumerate(row): + if element.Type == ELEM_TYPE_COLUMN: + key_dict = self._BuildKeyDictForWindow(top_window, element, key_dict) + if element.Type == ELEM_TYPE_FRAME: + key_dict = self._BuildKeyDictForWindow(top_window, element, key_dict) + if element.Type == ELEM_TYPE_TAB_GROUP: + key_dict = self._BuildKeyDictForWindow(top_window, element, key_dict) + if element.Type == ELEM_TYPE_PANE: + key_dict = self._BuildKeyDictForWindow(top_window, element, key_dict) + if element.Type == ELEM_TYPE_TAB: + key_dict = self._BuildKeyDictForWindow(top_window, element, key_dict) + if element.Key is None: # if no key has been assigned.... create one for input elements + if element.Type == ELEM_TYPE_BUTTON: + element.Key = element.ButtonText + elif element.Type == ELEM_TYPE_TAB: + element.Key = element.Title + if element.Type in (ELEM_TYPE_MENUBAR, ELEM_TYPE_BUTTONMENU, + ELEM_TYPE_INPUT_SLIDER, ELEM_TYPE_GRAPH, ELEM_TYPE_IMAGE, + ELEM_TYPE_INPUT_CHECKBOX, ELEM_TYPE_INPUT_LISTBOX, ELEM_TYPE_INPUT_COMBO, + ELEM_TYPE_INPUT_MULTILINE, ELEM_TYPE_INPUT_OPTION_MENU, ELEM_TYPE_INPUT_SPIN, + ELEM_TYPE_INPUT_RADIO, ELEM_TYPE_INPUT_TEXT, ELEM_TYPE_PROGRESS_BAR, + ELEM_TYPE_TABLE, ELEM_TYPE_TREE, + ELEM_TYPE_TAB_GROUP, ELEM_TYPE_SEPARATOR): + element.Key = top_window.DictionaryKeyCounter + top_window.DictionaryKeyCounter += 1 + if element.Key is not None: + if element.Key in key_dict.keys(): + if element.Type == ELEM_TYPE_BUTTON and WARN_DUPLICATE_BUTTON_KEY_ERRORS: # for Buttons see if should complain + warnings.warn('*** Duplicate key found in your layout {} ***'.format(element.Key), UserWarning) + warnings.warn('*** Replaced new key with {} ***'.format(str(element.Key) + str(self.UniqueKeyCounter))) + if not SUPPRESS_ERROR_POPUPS: + _error_popup_with_traceback('Duplicate key found in your layout', 'Dupliate key: {}'.format(element.Key), + 'Is being replaced with: {}'.format(str(element.Key) + str(self.UniqueKeyCounter)), + 'The line of code above shows you which layout, but does not tell you exactly where the element was defined', + 'The element type is {}'.format(element.Type)) + element.Key = str(element.Key) + str(self.UniqueKeyCounter) + self.UniqueKeyCounter += 1 + key_dict[element.Key] = element + return key_dict + + def element_list(self): + """ + Returns a list of all elements in the window + + :return: List of all elements in the window and container elements in the window + :rtype: List[Element] + """ + return self._build_element_list() + + def _build_element_list(self): + """ + Used internally only! Not user callable + Builds a dictionary containing all elements with keys for this window. + """ + elem_list = [] + elem_list = self._build_element_list_for_form(self, self, elem_list) + return elem_list + + def _build_element_list_for_form(self, top_window, window, elem_list): + """ + Loop through all Rows and all Container Elements for this window and create a list + Note that the calls are recursive as all pathes must be walked + + :param top_window: The highest level of the window + :type top_window: (Window) + :param window: The "sub-window" (container element) to be searched + :type window: Column | Frame | TabGroup | Pane | Tab + :param elem_list: The element list as it currently stands.... used as part of recursive call + :type elem_list: ??? + :return: List of all elements in this sub-window + :rtype: List[Element] + """ + for row_num, row in enumerate(window.Rows): + for col_num, element in enumerate(row): + elem_list.append(element) + if element.Type in (ELEM_TYPE_COLUMN, ELEM_TYPE_FRAME, ELEM_TYPE_TAB_GROUP, ELEM_TYPE_PANE, ELEM_TYPE_TAB): + elem_list = self._build_element_list_for_form(top_window, element, elem_list) + return elem_list + + def save_to_disk(self, filename): + """ + Saves the values contained in each of the input areas of the form. Basically saves what would be returned from a call to Read. It takes these results and saves them to disk using pickle. + Note that every element in your layout that is to be saved must have a key assigned to it. + + :param filename: Filename to save the values to in pickled form + :type filename: str + """ + try: + event, values = _BuildResults(self, False, self) + remove_these = [] + for key in values: + if self.Element(key).Type == ELEM_TYPE_BUTTON: + remove_these.append(key) + for key in remove_these: + del values[key] + with open(filename, 'wb') as sf: + pickle.dump(values, sf) + except: + print('*** Error saving Window contents to disk ***') + + def load_from_disk(self, filename): + """ + Restore values from a previous call to SaveToDisk which saves the returned values dictionary in Pickle format + + :param filename: Pickle Filename to load + :type filename: (str) + """ + try: + with open(filename, 'rb') as df: + self.Fill(pickle.load(df)) + except: + print('*** Error loading form to disk ***') + + def get_screen_dimensions(self): + """ + Get the screen dimensions. NOTE - you must have a window already open for this to work (blame tkinter not me) + + :return: Tuple containing width and height of screen in pixels + :rtype: Tuple[None, None] | Tuple[width, height] + """ + + if self.TKrootDestroyed or self.TKroot is None: + return Window.get_screen_size() + screen_width = self.TKroot.winfo_screenwidth() # get window info to move to middle of screen + screen_height = self.TKroot.winfo_screenheight() + return screen_width, screen_height + + def move(self, x, y): + """ + Move the upper left corner of this window to the x,y coordinates provided + :param x: x coordinate in pixels + :type x: (int) + :param y: y coordinate in pixels + :type y: (int) + """ + try: + self.TKroot.geometry("+%s+%s" % (x, y)) + self.config_last_location = (int(x), (int(y))) + + except: + pass + + + def move_to_center(self): + """ + Recenter your window after it's been moved or the size changed. + + This is a conveinence method. There are no tkinter calls involved, only pure PySimpleGUI API calls. + """ + if not self._is_window_created('tried Window.move_to_center'): + return + screen_width, screen_height = self.get_screen_dimensions() + win_width, win_height = self.size + x, y = (screen_width - win_width)//2, (screen_height - win_height)//2 + self.move(x, y) + + + + def minimize(self): + """ + Minimize this window to the task bar + """ + if not self._is_window_created('tried Window.minimize'): + return + if self.use_custom_titlebar is True: + self._custom_titlebar_minimize() + else: + self.TKroot.iconify() + self.maximized = False + + + def maximize(self): + """ + Maximize the window. This is done differently on a windows system versus a linux or mac one. For non-Windows + the root attribute '-fullscreen' is set to True. For Windows the "root" state is changed to "zoomed" + The reason for the difference is the title bar is removed in some cases when using fullscreen option + """ + + if not self._is_window_created('tried Window.maximize'): + return + if not running_linux(): + self.TKroot.state('zoomed') + else: + self.TKroot.attributes('-fullscreen', True) + # this method removes the titlebar too + # self.TKroot.attributes('-fullscreen', True) + self.maximized = True + + def normal(self): + """ + Restore a window to a non-maximized state. Does different things depending on platform. See Maximize for more. + """ + if not self._is_window_created('tried Window.normal'): + return + if self.use_custom_titlebar: + self._custom_titlebar_restore() + else: + if self.TKroot.state() == 'iconic': + self.TKroot.deiconify() + else: + if not running_linux(): + self.TKroot.state('normal') + else: + self.TKroot.attributes('-fullscreen', False) + self.maximized = False + + + def _StartMoveUsingControlKey(self, event): + """ + Used by "Grab Anywhere" style windows. This function is bound to mouse-down. It marks the beginning of a drag. + :param event: event information passed in by tkinter. Contains x,y position of mouse + :type event: (event) + """ + self._start_move_save_offset(event) + return + + + def _StartMoveGrabAnywhere(self, event): + + + """ + Used by "Grab Anywhere" style windows. This function is bound to mouse-down. It marks the beginning of a drag. + :param event: event information passed in by tkinter. Contains x,y position of mouse + :type event: (event) + """ + if (isinstance(event.widget, GRAB_ANYWHERE_IGNORE_THESE_WIDGETS) or event.widget in self._grab_anywhere_ignore_these_list) and event.widget not in self._grab_anywhere_include_these_list: + # print('Found widget to ignore in grab anywhere...') + return + self._start_move_save_offset(event) + + def _StartMove(self, event): + self._start_move_save_offset(event) + return + + def _StopMove(self, event): + """ + Used by "Grab Anywhere" style windows. This function is bound to mouse-up. It marks the ending of a drag. + Sets the position of the window to this final x,y coordinates + :param event: event information passed in by tkinter. Contains x,y position of mouse + :type event: (event) + """ + return + + def _start_move_save_offset(self, event): + self._mousex = event.x + event.widget.winfo_rootx() + self._mousey = event.y + event.widget.winfo_rooty() + geometry = self.TKroot.geometry() + location = geometry[geometry.find('+') + 1:].split('+') + self._startx = int(location[0]) + self._starty = int(location[1]) + self._mouse_offset_x = self._mousex - self._startx + self._mouse_offset_y = self._mousey - self._starty + # ------ Move All Windows code ------ + if Window._move_all_windows: + # print('Moving all') + for win in Window._active_windows: + if win == self: + continue + geometry = win.TKroot.geometry() + location = geometry[geometry.find('+') + 1:].split('+') + _startx = int(location[0]) + _starty = int(location[1]) + win._mouse_offset_x = event.x_root - _startx + win._mouse_offset_y = event.y_root - _starty + + + def _OnMotionUsingControlKey(self, event): + self._OnMotion(event) + + + def _OnMotionGrabAnywhere(self, event): + + """ + Used by "Grab Anywhere" style windows. This function is bound to mouse motion. It actually moves the window + :param event: event information passed in by tkinter. Contains x,y position of mouse + :type event: (event) + """ + if (isinstance(event.widget, GRAB_ANYWHERE_IGNORE_THESE_WIDGETS) or event.widget in self._grab_anywhere_ignore_these_list) and event.widget not in self._grab_anywhere_include_these_list: + # print('Found widget to ignore in grab anywhere...') + return + + self._OnMotion(event) + + + def _OnMotion(self, event): + + self.TKroot.geometry(f"+{event.x_root-self._mouse_offset_x}+{event.y_root-self._mouse_offset_y}") + # print(f"+{event.x_root}+{event.y_root}") + # ------ Move All Windows code ------ + try: + if Window._move_all_windows: + for win in Window._active_windows: + if win == self: + continue + win.TKroot.geometry(f"+{event.x_root-win._mouse_offset_x}+{event.y_root-win._mouse_offset_y}") + except Exception as e: + print('on motion error', e) + + def _focus_callback(self, event): + print('Focus event = {} window = {}'.format(event, self.Title)) + + def _config_callback(self, event): + """ + Called when a config event happens for the window + + :param event: From tkinter and is not used + :type event: Any + """ + self.LastButtonClicked = WINDOW_CONFIG_EVENT + self.FormRemainedOpen = True + self.user_bind_event = event + _exit_mainloop(self) + + + def _move_callback(self, event): + """ + Called when a control + arrow key is pressed. + This is a built-in window positioning key sequence + + :param event: From tkinter and is not used + :type event: Any + """ + if not self._is_window_created('Tried to move window using arrow keys'): + return + x,y = self.current_location() + if event.keysym == 'Up': + self.move(x, y-1) + elif event.keysym == 'Down': + self.move(x, y+1) + elif event.keysym == 'Left': + self.move(x-1, y) + elif event.keysym == 'Right': + self.move(x+1, y) + + """ + def _config_callback(self, event): + new_x = event.x + new_y = event.y + + + if self.not_completed_initial_movement: + if self.starting_window_position != (new_x, new_y): + return + self.not_completed_initial_movement = False + return + + if not self.saw_00: + if new_x == 0 and new_y == 0: + self.saw_00 = True + + # self.config_count += 1 + # if self.config_count < 40: + # return + + print('Move LOGIC') + + if self.config_last_size != (event.width, event.height): + self.config_last_size = (event.width, event.height) + + if self.config_last_location[0] != new_x or self.config_last_location[1] != new_y: + if self.config_last_location == (None, None): + self.config_last_location = (new_x, new_y) + return + + deltax = self.config_last_location[0] - event.x + deltay = self.config_last_location[1] - event.y + if deltax == 0 and deltay == 0: + print('not moving so returning') + return + if Window._move_all_windows: + print('checking all windows') + for window in Window._active_windows: + if window == self: + continue + x = window.TKroot.winfo_x() + deltax + y = window.TKroot.winfo_y() + deltay + # window.TKroot.geometry("+%s+%s" % (x, y)) # this is what really moves the window + # window.config_last_location = (x,y) + """ + + def _KeyboardCallback(self, event): + """ + Window keyboard callback. Called by tkinter. Will kick user out of the tkinter event loop. Should only be + called if user has requested window level keyboard events + + :param event: object provided by tkinter that contains the key information + :type event: (event) + """ + self.LastButtonClicked = None + self.FormRemainedOpen = True + if event.char != '': + self.LastKeyboardEvent = event.char + else: + self.LastKeyboardEvent = str(event.keysym) + ':' + str(event.keycode) + # if not self.NonBlocking: + # _BuildResults(self, False, self) + _exit_mainloop(self) + + def _MouseWheelCallback(self, event): + """ + Called by tkinter when a mouse wheel event has happened. Only called if keyboard events for the window + have been enabled + + :param event: object sent in by tkinter that has the wheel direction + :type event: (event) + """ + self.LastButtonClicked = None + self.FormRemainedOpen = True + self.LastKeyboardEvent = 'MouseWheel:Down' if event.delta < 0 or event.num == 5 else 'MouseWheel:Up' + # if not self.NonBlocking: + # _BuildResults(self, False, self) + _exit_mainloop(self) + + def _Close(self, without_event=False): + """ + The internal close call that does the real work of building. This method basically sets up for closing + but doesn't destroy the window like the User's version of Close does + + :parm without_event: if True, then do not cause an event to be generated, "silently" close the window + :type without_event: (bool) + """ + + try: + self.TKroot.update() + except: + pass + + if not self.NonBlocking or not without_event: + _BuildResults(self, False, self) + if self.TKrootDestroyed: + return + self.TKrootDestroyed = True + self.RootNeedsDestroying = True + return + + def close(self): + """ + Closes window. Users can safely call even if window has been destroyed. Should always call when done with + a window so that resources are properly freed up within your thread. + """ + + try: + del Window._active_windows[self] # will only be in the list if window was explicitly finalized + except: + pass + + try: + self.TKroot.update() # On Linux must call update if the user closed with X or else won't actually close the window + except: + pass + + self._restore_stdout() + self._restore_stderr() + + _TimerPeriodic.stop_all_timers_for_window(self) + + if self.TKrootDestroyed: + return + try: + self.TKroot.destroy() + self.TKroot.update() + Window._DecrementOpenCount() + except: + pass + # if down to 1 window, try and destroy the hidden window, if there is one + # if Window.NumOpenWindows == 1: + # try: + # Window.hidden_master_root.destroy() + # Window.NumOpenWindows = 0 # if no hidden window, then this won't execute + # except: + # pass + self.TKrootDestroyed = True + + # Free up anything that was held in the layout and the root variables + self.Rows = None + self.TKroot = None + + + + + def is_closed(self, quick_check=None): + """ + Returns True is the window is maybe closed. Can be difficult to tell sometimes + NOTE - the call to TKroot.update was taking over 500 ms sometimes so added a flag to bypass the lengthy call. + :param quick_quick: If True, then don't use the root.update call, only check the flags + :type quick_check: bool + :return: True if the window was closed or destroyed + :rtype: (bool) + """ + + if self.TKrootDestroyed or self.TKroot is None: + return True + + # if performing a quick check only, then skip calling tkinter for performance reasons + if quick_check is True: + return False + + # see if can do an update... if not, then it's been destroyed + try: + rc = self.TKroot.update() + except: + return True + return False + + + # IT FINALLY WORKED! 29-Oct-2018 was the first time this damned thing got called + def _OnClosingCallback(self): + """ + Internally used method ONLY. Not sure callable. tkinter calls this when the window is closed by clicking X + """ + # global _my_windows + # print('Got closing callback', self.DisableClose) + if self.DisableClose: + return + if self.CurrentlyRunningMainloop: # quit if this is the current mainloop, otherwise don't quit! + _exit_mainloop(self) + if self.close_destroys_window: + self.TKroot.destroy() # destroy this window + self.TKrootDestroyed = True + self.XFound = True + else: + self.LastButtonClicked = WINDOW_CLOSE_ATTEMPTED_EVENT + elif Window._root_running_mainloop == Window.hidden_master_root: + _exit_mainloop(self) + else: + if self.close_destroys_window: + self.TKroot.destroy() # destroy this window + self.XFound = True + else: + self.LastButtonClicked = WINDOW_CLOSE_ATTEMPTED_EVENT + if self.close_destroys_window: + self.RootNeedsDestroying = True + self._restore_stdout() + self._restore_stderr() + + def disable(self): + """ + Disables window from taking any input from the user + """ + if not self._is_window_created('tried Window.disable'): + return + self.TKroot.attributes('-disabled', 1) + # self.TKroot.grab_set_global() + + def enable(self): + """ + Re-enables window to take user input after having it be Disabled previously + """ + if not self._is_window_created('tried Window.enable'): + return + self.TKroot.attributes('-disabled', 0) + # self.TKroot.grab_release() + + def hide(self): + """ + Hides the window from the screen and the task bar + """ + if not self._is_window_created('tried Window.hide'): + return + self._Hidden = True + self.TKroot.withdraw() + + def un_hide(self): + """ + Used to bring back a window that was previously hidden using the Hide method + """ + if not self._is_window_created('tried Window.un_hide'): + return + if self._Hidden: + self.TKroot.deiconify() + self._Hidden = False + + def is_hidden(self): + """ + Returns True if the window is currently hidden + :return: Returns True if the window is currently hidden + :rtype: bool + """ + return self._Hidden + + def disappear(self): + """ + Causes a window to "disappear" from the screen, but remain on the taskbar. It does this by turning the alpha + channel to 0. NOTE that on some platforms alpha is not supported. The window will remain showing on these + platforms. The Raspberry Pi for example does not have an alpha setting + """ + if not self._is_window_created('tried Window.disappear'): + return + self.TKroot.attributes('-alpha', 0) + + def reappear(self): + """ + Causes a window previously made to "Disappear" (using that method). Does this by restoring the alpha channel + """ + if not self._is_window_created('tried Window.reappear'): + return + self.TKroot.attributes('-alpha', 255) + + def set_alpha(self, alpha): + """ + Sets the Alpha Channel for a window. Values are between 0 and 1 where 0 is completely transparent + + :param alpha: 0 to 1. 0 is completely transparent. 1 is completely visible and solid (can't see through) + :type alpha: (float) + """ + if not self._is_window_created('tried Window.set_alpha'): + return + self._AlphaChannel = alpha + self.TKroot.attributes('-alpha', alpha) + + @property + def alpha_channel(self): + """ + A property that changes the current alpha channel value (internal value) + :return: the current alpha channel setting according to self, not read directly from tkinter + :rtype: (float) + """ + return self._AlphaChannel + + @alpha_channel.setter + def alpha_channel(self, alpha): + """ + The setter method for this "property". + Planning on depricating so that a Set call is always used by users. This is more in line with the SDK + :param alpha: 0 to 1. 0 is completely transparent. 1 is completely visible and solid (can't see through) + :type alpha: (float) + """ + if not self._is_window_created('tried Window.alpha_channel'): + return + self._AlphaChannel = alpha + self.TKroot.attributes('-alpha', alpha) + + def bring_to_front(self): + """ + Brings this window to the top of all other windows (perhaps may not be brought before a window made to "stay + on top") + """ + if not self._is_window_created('tried Window.bring_to_front'): + return + if running_windows(): + try: + self.TKroot.wm_attributes("-topmost", 0) + self.TKroot.wm_attributes("-topmost", 1) + if not self.KeepOnTop: + self.TKroot.wm_attributes("-topmost", 0) + except Exception as e: + warnings.warn('Problem in Window.bring_to_front' + str(e), UserWarning) + else: + try: + self.TKroot.lift() + except: + pass + + def send_to_back(self): + """ + Pushes this window to the bottom of the stack of windows. It is the opposite of BringToFront + """ + if not self._is_window_created('tried Window.send_to_back'): + return + try: + self.TKroot.lower() + except: + pass + + + def keep_on_top_set(self): + """ + Sets keep_on_top after a window has been created. Effect is the same + as if the window was created with this set. The Window is also brought + to the front + """ + if not self._is_window_created('tried Window.keep_on_top_set'): + return + self.KeepOnTop = True + self.bring_to_front() + try: + self.TKroot.wm_attributes("-topmost", 1) + except Exception as e: + warnings.warn('Problem in Window.keep_on_top_set trying to set wm_attributes topmost' + str(e), UserWarning) + + + def keep_on_top_clear(self): + """ + Clears keep_on_top after a window has been created. Effect is the same + as if the window was created with this set. + """ + if not self._is_window_created('tried Window.keep_on_top_clear'): + return + self.KeepOnTop = False + try: + self.TKroot.wm_attributes("-topmost", 0) + except Exception as e: + warnings.warn('Problem in Window.keep_on_top_clear trying to clear wm_attributes topmost' + str(e), UserWarning) + + + + def current_location(self, more_accurate=False, without_titlebar=False): + """ + Get the current location of the window's top left corner. + Sometimes, depending on the environment, the value returned does not include the titlebar,etc + A new option, more_accurate, can be used to get the theoretical upper leftmost corner of the window. + The titlebar and menubar are crated by the OS. It gets really confusing when running in a webpage (repl, trinket) + Thus, the values can appear top be "off" due to the sometimes unpredictable way the location is calculated. + If without_titlebar is set then the location of the root x,y is used which should not include the titlebar but + may be OS dependent. + + :param more_accurate: If True, will use the window's geometry to get the topmost location with titlebar, menubar taken into account + :type more_accurate: (bool) + :param without_titlebar: If True, return location of top left of main window area without the titlebar (may be OS dependent?) + :type without_titlebar: (bool) + :return: The x and y location in tuple form (x,y) + :rtype: Tuple[(int | None), (int | None)] + """ + + + if not self._is_window_created('tried Window.current_location'): + return (None, None) + try: + if without_titlebar is True: + x, y = self.TKroot.winfo_rootx(), self.TKroot.winfo_rooty() + elif more_accurate: + geometry = self.TKroot.geometry() + location = geometry[geometry.find('+') + 1:].split('+') + x, y = int(location[0]), int(location[1]) + else: + x, y = int(self.TKroot.winfo_x()), int(self.TKroot.winfo_y()) + except Exception as e: + warnings.warn('Error in Window.current_location. Trouble getting x,y location\n' + str(e), UserWarning) + x, y = (None, None) + return (x,y) + + + def current_size_accurate(self): + """ + Get the current location of the window based on tkinter's geometry setting + + :return: The x and y size in tuple form (x,y) + :rtype: Tuple[(int | None), (int | None)] + """ + + if not self._is_window_created('tried Window.current_location'): + return (None, None) + try: + geometry = self.TKroot.geometry() + geometry_tuple = geometry.split('+') + window_size = geometry_tuple[0].split('x') + x, y = int(window_size[0]), int(window_size[1]) + except Exception as e: + warnings.warn('Error in Window.current_size_accurate. Trouble getting x,y size\n{} {}'.format(geometry, geometry_tuple) + str(e), UserWarning) + x, y = (None, None) + return (x,y) + + @property + def size(self): + """ + Return the current size of the window in pixels + + :return: (width, height) of the window + :rtype: Tuple[(int), (int)] or Tuple[None, None] + """ + if not self._is_window_created('Tried to use Window.size property'): + return (None, None) + win_width = self.TKroot.winfo_width() + win_height = self.TKroot.winfo_height() + return win_width, win_height + + @size.setter + def size(self, size): + """ + Changes the size of the window, if possible + + :param size: (width, height) of the desired window size + :type size: (int, int) + """ + try: + self.TKroot.geometry("%sx%s" % (size[0], size[1])) + self.TKroot.update_idletasks() + except: + pass + + + def set_size(self, size): + """ + Changes the size of the window, if possible. You can also use the Window.size prooerty + to set/get the size. + + :param size: (width, height) of the desired window size + :type size: (int, int) + """ + if not self._is_window_created('Tried to change the size of the window prior to creation.'): + return + try: + self.TKroot.geometry("%sx%s" % (size[0], size[1])) + self.TKroot.update_idletasks() + except: + pass + + + + def set_min_size(self, size): + """ + Changes the minimum size of the window. Note Window must be read or finalized first. + + :param size: (width, height) tuple (int, int) of the desired window size in pixels + :type size: (int, int) + """ + if not self._is_window_created('tried Window.set_min_size'): + return + self.TKroot.minsize(size[0], size[1]) + self.TKroot.update_idletasks() + + + def set_resizable(self, x_axis_enable, y_axis_enable): + """ + Changes if a window can be resized in either the X or the Y direction. + Note Window must be read or finalized first. + + :param x_axis_enable: If True, the window can be changed in the X-axis direction. If False, it cannot + :type x_axis_enable: (bool) + :param y_axis_enable: If True, the window can be changed in the Y-axis direction. If False, it cannot + :type y_axis_enable: (bool) + """ + + if not self._is_window_created('tried Window.set_resixable'): + return + try: + self.TKroot.resizable(x_axis_enable, y_axis_enable) + except Exception as e: + _error_popup_with_traceback('Window.set_resizable - tkinter reported error', e) + + def visibility_changed(self): + """ + When making an element in a column or someplace that has a scrollbar, then you'll want to call this function + prior to the column's contents_changed() method. + """ + self.refresh() + + def set_transparent_color(self, color): + """ + Set the color that will be transparent in your window. Areas with this color will be SEE THROUGH. + + :param color: Color string that defines the transparent color + :type color: (str) + """ + if not self._is_window_created('tried Window.set_transparent_color'): + return + try: + self.TKroot.attributes('-transparentcolor', color) + self.TransparentColor = color + except: + print('Transparent color not supported on this platform (windows only)') + + def mouse_location(self): + """ + Return the (x,y) location of the mouse relative to the entire screen. It's the same location that + you would use to create a window, popup, etc. + + :return: The location of the mouse pointer + :rtype: (int, int) + """ + if not self._is_window_created('tried Window.mouse_location'): + return (0,0) + + return (self.TKroot.winfo_pointerx(), self.TKroot.winfo_pointery()) + + def grab_any_where_on(self): + """ + Turns on Grab Anywhere functionality AFTER a window has been created. Don't try on a window that's not yet + been Finalized or Read. + """ + if not self._is_window_created('tried Window.grab_any_where_on'): + return + self.TKroot.bind("", self._StartMoveGrabAnywhere) + self.TKroot.bind("", self._StopMove) + self.TKroot.bind("", self._OnMotionGrabAnywhere) + + def grab_any_where_off(self): + """ + Turns off Grab Anywhere functionality AFTER a window has been created. Don't try on a window that's not yet + been Finalized or Read. + """ + if not self._is_window_created('tried Window.grab_any_where_off'): + return + self.TKroot.unbind("") + self.TKroot.unbind("") + self.TKroot.unbind("") + + def _user_bind_callback(self, bind_string, event, propagate=True): + """ + Used when user binds a tkinter event directly to an element + + :param bind_string: The event that was bound so can lookup the key modifier + :type bind_string: (str) + :param event: Event data passed in by tkinter (not used) + :type event: + :param propagate: If True then tkinter will be told to propagate the event + :type propagate: (bool) + """ + # print('bind callback', bind_string, event) + key = self.user_bind_dict.get(bind_string, '') + self.user_bind_event = event + if key is not None: + self.LastButtonClicked = key + else: + self.LastButtonClicked = bind_string + self.FormRemainedOpen = True + # if self.CurrentlyRunningMainloop: + # self.TKroot.quit() + _exit_mainloop(self) + return 'break' if propagate is not True else None + + + def bind(self, bind_string, key, propagate=True): + """ + Used to add tkinter events to a Window. + The tkinter specific data is in the Window's member variable user_bind_event + :param bind_string: The string tkinter expected in its bind function + :type bind_string: (str) + :param key: The event that will be generated when the tkinter event occurs + :type key: str | int | tuple | object + :param propagate: If True then tkinter will be told to propagate the event + :type propagate: (bool) + """ + if not self._is_window_created('tried Window.bind'): + return + try: + self.TKroot.bind(bind_string, lambda evt: self._user_bind_callback(bind_string, evt, propagate)) + except Exception as e: + self.TKroot.unbind_all(bind_string) + return + # _error_popup_with_traceback('Window.bind error', e) + self.user_bind_dict[bind_string] = key + + + def unbind(self, bind_string): + """ + Used to remove tkinter events to a Window. + This implementation removes ALL of the binds of the bind_string from the Window. If there + are multiple binds for the Window itself, they will all be removed. This can be extended later if there + is a need. + :param bind_string: The string tkinter expected in its bind function + :type bind_string: (str) + """ + if not self._is_window_created('tried Window.unbind'): + return + self.TKroot.unbind(bind_string) + + + + def _callback_main_debugger_window_create_keystroke(self, event): + """ + Called when user presses the key that creates the main debugger window + March 2022 - now causes the user reads to return timeout events automatically + :param event: (event) not used. Passed in event info + :type event: + """ + Window._main_debug_window_build_needed = True + # exit the event loop in a way that resembles a timeout occurring + self.LastButtonClicked = self.TimeoutKey + self.FormRemainedOpen = True + self.TKroot.quit() # kick the users out of the mainloop + + def _callback_popout_window_create_keystroke(self, event): + """ + Called when user presses the key that creates the floating debugger window + March 2022 - now causes the user reads to return timeout events automatically + :param event: (event) not used. Passed in event info + :type event: + """ + Window._floating_debug_window_build_needed = True + # exit the event loop in a way that resembles a timeout occurring + self.LastButtonClicked = self.TimeoutKey + self.FormRemainedOpen = True + self.TKroot.quit() # kick the users out of the mainloop + + def enable_debugger(self): + """ + Enables the internal debugger. By default, the debugger IS enabled + """ + if not self._is_window_created('tried Window.enable_debugger'): + return + self.TKroot.bind('', self._callback_main_debugger_window_create_keystroke) + self.TKroot.bind('', self._callback_popout_window_create_keystroke) + self.DebuggerEnabled = True + + def disable_debugger(self): + """ + Disable the internal debugger. By default the debugger is ENABLED + """ + if not self._is_window_created('tried Window.disable_debugger'): + return + self.TKroot.unbind("") + self.TKroot.unbind("") + self.DebuggerEnabled = False + + def set_title(self, title): + """ + Change the title of the window + + :param title: The string to set the title to + :type title: (str) + """ + if not self._is_window_created('tried Window.set_title'): + return + if self._has_custom_titlebar: + try: # just in case something goes badly, don't crash + self.find_element(TITLEBAR_TEXT_KEY).update(title) + except: + pass + # even with custom titlebar, set the main window's title too so it'll match when minimized + self.TKroot.wm_title(str(title)) + + def make_modal(self): + """ + Makes a window into a "Modal Window" + This means user will not be able to interact with other windows until this one is closed + + NOTE - Sorry Mac users - you can't have modal windows.... lobby your tkinter Mac devs + """ + if not self._is_window_created('tried Window.make_modal'): + return + + if running_mac() and ENABLE_MAC_MODAL_DISABLE_PATCH: + return + + # if modal windows have been disabled globally + if not DEFAULT_MODAL_WINDOWS_ENABLED and not DEFAULT_MODAL_WINDOWS_FORCED: + # if not DEFAULT_MODAL_WINDOWS_ENABLED: + return + + try: + self.TKroot.transient() + self.TKroot.grab_set() + self.TKroot.focus_force() + except Exception as e: + print('Exception trying to make modal', e) + + def force_focus(self): + """ + Forces this window to take focus + """ + if not self._is_window_created('tried Window.force_focus'): + return + self.TKroot.focus_force() + + def was_closed(self): + """ + Returns True if the window was closed + + :return: True if the window is closed + :rtype: bool + """ + return self.TKrootDestroyed + + def set_cursor(self, cursor): + """ + Sets the cursor for the window. + If you do not want any mouse pointer, then use the string "none" + + :param cursor: The tkinter cursor name + :type cursor: (str) + """ + + if not self._is_window_created('tried Window.set_cursor'): + return + try: + self.TKroot.config(cursor=cursor) + except Exception as e: + print('Warning bad cursor specified ', cursor) + print(e) + + def ding(self, display_number=0): + """ + Make a "bell" sound. A capability provided by tkinter. Your window needs to be finalized prior to calling. + Ring a display's bell is the tkinter description of the call. + :param display_number: Passed to tkinter's bell method as parameter "displayof". + :type display_number: int + """ + if not self._is_window_created('tried Window.ding'): + return + try: + self.TKroot.bell(display_number) + except Exception as e: + if not SUPPRESS_ERROR_POPUPS: + _error_popup_with_traceback('Window.ding() - tkinter reported error from bell() call', e) + + def _window_tkvar_changed_callback(self, *args): + """ + Internal callback function for when the thread + + :param event: Information from tkinter about the callback + :type event: + + """ + # print('Thread callback info', threading.current_thread()) + # print(event) + # trace_details = traceback.format_stack() + # print(''.join(trace_details)) + # self.thread_lock.acquire() + # if self.thread_timer: + # self.TKroot.after_cancel(id=self.thread_timer) + # self.thread_timer = None + # self.thread_lock.release() + + if self._queued_thread_event_available(): + self.FormRemainedOpen = True + _exit_mainloop(self) + + def _create_thread_queue(self): + """ + Creates the queue used by threads to communicate with this window + """ + + if self.thread_queue is None: + self.thread_queue = queue.Queue() + + if self.thread_lock is None: + self.thread_lock = threading.Lock() + + if self.thread_strvar is None: + self.thread_strvar = tk.StringVar() + self.thread_strvar.trace('w', self._window_tkvar_changed_callback) + + def write_event_value(self, key, value): + """ + Adds a key & value tuple to the queue that is used by threads to communicate with the window + + :param key: The key that will be returned as the event when reading the window + :type key: Any + :param value: The value that will be in the values dictionary + :type value: Any + """ + + if self.thread_queue is None: + print('*** Warning Window.write_event_value - no thread queue found ***') + return + # self.thread_lock.acquire() # first lock the critical section + self.thread_queue.put(item=(key, value)) + self.TKroot.tk.willdispatch() # brilliant bit of code provided by Giuliano who I owe a million thank yous! + self.thread_strvar.set('new item') + + # self.thread_queue.put(item=(key, value)) + # self.thread_strvar.set('new item') + # March 28 2021 - finally found a solution! It needs a little more work and a lock + # if no timer is running, then one should be started + # if self.thread_timer is None: + # print('Starting a timer') + # self.thread_timer = self.TKroot.after(1, self._window_tkvar_changed_callback) + # self.thread_lock.release() + + def _queued_thread_event_read(self): + if self.thread_queue is None: + return None + + try: # see if something has been posted to Queue + message = self.thread_queue.get_nowait() + except queue.Empty: # get_nowait() will get exception when Queue is empty + return None + + return message + + def _queued_thread_event_available(self): + + if self.thread_queue is None: + return False + # self.thread_lock.acquire() + qsize = self.thread_queue.qsize() + if qsize == 0: + self.thread_timer = None + # self.thread_lock.release() + return qsize != 0 + + + + def _RightClickMenuCallback(self, event): + """ + When a right click menu is specified for an entire window, then this callback catches right clicks + that happen to the window itself, when there are no elements that are in that area. + + The only portion that is not currently covered correctly is the row frame itself. There will still + be parts of the window, at the moment, that don't respond to a right click. It's getting there, bit + by bit. + + Callback function that's called when a right click happens. Shows right click menu as result. + + :param event: information provided by tkinter about the event including x,y location of click + :type event: + """ + # if there are widgets under the mouse, then see if it's the root only. If not, then let the widget (element) show their menu instead + x, y = self.TKroot.winfo_pointerxy() + widget = self.TKroot.winfo_containing(x, y) + if widget != self.TKroot: + return + self.TKRightClickMenu.tk_popup(event.x_root, event.y_root, 0) + self.TKRightClickMenu.grab_release() + + + def save_window_screenshot_to_disk(self, filename=None): + """ + Saves an image of the PySimpleGUI window provided into the filename provided + + :param filename: Optional filename to save screenshot to. If not included, the User Settinds are used to get the filename + :return: A PIL ImageGrab object that can be saved or manipulated + :rtype: (PIL.ImageGrab | None) + """ + global pil_import_attempted, pil_imported, PIL, ImageGrab, Image + + if not pil_import_attempted: + try: + import PIL as PIL + from PIL import ImageGrab + from PIL import Image + pil_imported = True + pil_import_attempted = True + except: + pil_imported = False + pil_import_attempted = True + print('FAILED TO IMPORT PIL!') + return None + try: + # Get location of window to save + pos = self.current_location() + # Add a little to the X direction if window has a titlebar + if not self.NoTitleBar: + pos = (pos[0]+7, pos[1]) + # Get size of wiondow + size = self.current_size_accurate() + # Get size of the titlebar + titlebar_height = self.TKroot.winfo_rooty() - self.TKroot.winfo_y() + # Add titlebar to size of window so that titlebar and window will be saved + size = (size[0], size[1] + titlebar_height) + if not self.NoTitleBar: + size_adjustment = (2,1) + else: + size_adjustment = (0,0) + # Make the "Bounding rectangle" used by PLK to do the screen grap "operation + rect = (pos[0], pos[1], pos[0] + size[0]+size_adjustment[0], pos[1] + size[1]+size_adjustment[1]) + # Grab the image + grab = ImageGrab.grab(bbox=rect) + # Save the grabbed image to disk + except Exception as e: + # print(e) + popup_error_with_traceback('Screen capture failure', 'Error happened while trying to save screencapture', e) + + return None + # return grab + if filename is None: + folder = pysimplegui_user_settings.get('-screenshots folder-', '') + filename = pysimplegui_user_settings.get('-screenshots filename-', '') + full_filename = os.path.join(folder, filename) + else: + full_filename = filename + if full_filename: + try: + grab.save(full_filename) + except Exception as e: + popup_error_with_traceback('Screen capture failure', 'Error happened while trying to save screencapture', e) + else: + popup_error_with_traceback('Screen capture failure', 'You have attempted a screen capture but have not set up a good filename to save to') + return grab + + + def perform_long_operation(self, func, end_key=None): + """ + Call your function that will take a long time to execute. When it's complete, send an event + specified by the end_key. + + Starts a thread on your behalf. + + This is a way for you to "ease into" threading without learning the details of threading. + Your function will run, and when it returns 2 things will happen: + 1. The value you provide for end_key will be returned to you when you call window.read() + 2. If your function returns a value, then the value returned will also be included in your windows.read call in the values dictionary + + IMPORTANT - This method uses THREADS... this means you CANNOT make any PySimpleGUI calls from + the function you provide with the exception of one function, Window.write_event_value. + + :param func: A lambda or a function name with no parms + :type func: Any + :param end_key: Optional key that will be generated when the function returns + :type end_key: (Any | None) + :return: The id of the thread + :rtype: threading.Thread + """ + + thread = threading.Thread(target=_long_func_thread, args=(self, end_key, func), daemon=True) + thread.start() + return thread + + @property + def key_dict(self): + """ + Returns a dictionary with all keys and their corresponding elements + { key : Element } + :return: Dictionary of keys and elements + :rtype: Dict[Any, Element] + """ + return self.AllKeysDict + + + def key_is_good(self, key): + """ + Checks to see if this is a good key for this window + If there's an element with the key provided, then True is returned + :param key: The key to check + :type key: str | int | tuple | object + :return: True if key is an element in this window + :rtype: bool + """ + if key in self.key_dict: + return True + return False + + def get_scaling(self): + """ + Returns the current scaling value set for this window + + :return: Scaling according to tkinter. Returns DEFAULT_SCALING if error + :rtype: float + """ + + if not self._is_window_created('Tried Window.set_scaling'): + return DEFAULT_SCALING + try: + scaling = self.TKroot.tk.call('tk', 'scaling') + except Exception as e: + if not SUPPRESS_ERROR_POPUPS: + _error_popup_with_traceback('Window.get_scaling() - tkinter reported error', e) + scaling = DEFAULT_SCALING + + return scaling + + + def _custom_titlebar_restore_callback(self, event): + self._custom_titlebar_restore() + + + def _custom_titlebar_restore(self): + if running_linux(): + # if self._skip_first_restore_callback: + # self._skip_first_restore_callback = False + # return + self.TKroot.unbind('') + self.TKroot.deiconify() + + # self.ParentForm.TKroot.wm_overrideredirect(True) + self.TKroot.wm_attributes("-type", 'dock') + + else: + self.TKroot.unbind('') + self.TKroot.wm_overrideredirect(True) + if self.TKroot.state() == 'iconic': + self.TKroot.deiconify() + else: + if not running_linux(): + self.TKroot.state('normal') + else: + self.TKroot.attributes('-fullscreen', False) + self.maximized = False + + + def _custom_titlebar_minimize(self): + if running_linux(): + self.TKroot.wm_attributes("-type", "normal") + # self.ParentForm.TKroot.state('icon') + # return + # self.ParentForm.maximize() + self.TKroot.wm_overrideredirect(False) + # self.ParentForm.minimize() + # self.ParentForm.TKroot.wm_overrideredirect(False) + self.TKroot.iconify() + # self._skip_first_restore_callback = True + self.TKroot.bind('', self._custom_titlebar_restore_callback) + else: + self.TKroot.wm_overrideredirect(False) + self.TKroot.iconify() + self.TKroot.bind('', self._custom_titlebar_restore_callback) + + + def _custom_titlebar_callback(self, key): + """ + One of the Custom Titlbar buttons was clicked + :param key: + :return: + """ + if key == TITLEBAR_MINIMIZE_KEY: + if not self.DisableMinimize: + self._custom_titlebar_minimize() + elif key == TITLEBAR_MAXIMIZE_KEY: + if self.Resizable: + if self.maximized: + self.normal() + else: + self.maximize() + elif key == TITLEBAR_CLOSE_KEY: + if not self.DisableClose: + self._OnClosingCallback() + + + def timer_start(self, frequency_ms, key=EVENT_TIMER, repeating=True): + """ + Starts a timer that gnerates Timer Events. The default is to repeat the timer events until timer is stopped. + You can provide your own key or a default key will be used. The default key is defined + with the constants EVENT_TIMER or TIMER_KEY. They both equal the same value. + The values dictionary will contain the timer ID that is returned from this function. + + :param frequency_ms: How often to generate timer events in milliseconds + :type frequency_ms: int + :param key: Key to be returned as the timer event + :type key: str | int | tuple | object + :param repeating: If True then repeat timer events until timer is explicitly stopped + :type repeating: bool + :return: Timer ID for the timer + :rtype: int + """ + timer = _TimerPeriodic(self, frequency_ms=frequency_ms, key=key, repeating=repeating) + return timer.id + + + def timer_stop(self, timer_id): + """ + Stops a timer with a given ID + + :param timer_id: Timer ID of timer to stop + :type timer_id: int + :return: + """ + _TimerPeriodic.stop_timer_with_id(timer_id) + + + def timer_stop_all(self): + """ + Stops all timers for THIS window + """ + _TimerPeriodic.stop_all_timers_for_window(self) + + + def timer_get_active_timers(self): + """ + Returns a list of currently active timers for a window + :return: List of timers for the window + :rtype: List[int] + """ + return _TimerPeriodic.get_all_timers_for_window(self) + + + @classmethod + def _restore_stdout(cls): + for item in cls._rerouted_stdout_stack: + (window, element) = item # type: (Window, Element) + if not window.is_closed(): + sys.stdout = element + break + cls._rerouted_stdout_stack = [item for item in cls._rerouted_stdout_stack if not item[0].is_closed()] + if len(cls._rerouted_stdout_stack) == 0 and cls._original_stdout is not None: + sys.stdout = cls._original_stdout + # print('Restored stdout... new stack:', [item[0].Title for item in cls._rerouted_stdout_stack ]) + + + @classmethod + def _restore_stderr(cls): + for item in cls._rerouted_stderr_stack: + (window, element) = item # type: (Window, Element) + if not window.is_closed(): + sys.stderr = element + break + cls._rerouted_stderr_stack = [item for item in cls._rerouted_stderr_stack if not item[0].is_closed()] + if len(cls._rerouted_stderr_stack) == 0 and cls._original_stderr is not None: + sys.stderr = cls._original_stderr + # print('Restored stderr... new stack:', [item[0].Title for item in cls._rerouted_stderr_stack ]) + + + + + + # def __enter__(self): + # """ + # WAS used with context managers which are no longer needed nor advised. It is here for legacy support and + # am afraid of removing right now + # :return: (window) + # :rtype: + # """ + # return self + + def __getitem__(self, key): + """ + Returns Element that matches the passed in key. + This is "called" by writing code as thus: + window['element key'].update + + :param key: The key to find + :type key: str | int | tuple | object + :return: The element found + :rtype: Element | Input | Combo | OptionMenu | Listbox | Radio | Checkbox | Spin | Multiline | Text | StatusBar | Output | Button | ButtonMenu | ProgressBar | Image | Canvas | Graph | Frame | VerticalSeparator | HorizontalSeparator | Tab | TabGroup | Slider | Column | Pane | Menu | Table | Tree | ErrorElement | None + """ + + return self.find_element(key) + + def __call__(self, *args, **kwargs): + """ + Call window.read but without having to type it out. + window() == window.read() + window(timeout=50) == window.read(timeout=50) + + :return: The famous event, values that read returns. + :rtype: Tuple[Any, Dict[Any, Any]] + """ + return self.read(*args, **kwargs) + + def _is_window_created(self, additional_message=''): + msg = str(additional_message) + if self.TKroot is None: + warnings.warn( + 'You cannot perform operations on a Window until it is read or finalized. Adding a "finalize=True" parameter to your Window creation will fix this. ' + msg, + UserWarning) + if not SUPPRESS_ERROR_POPUPS: + _error_popup_with_traceback('You cannot perform operations on a Window until it is read or finalized.', + 'Adding a "finalize=True" parameter to your Window creation will likely fix this', msg) + return False + return True + + def _has_custom_titlebar_element(self): + for elem in self.AllKeysDict.values(): + if elem.Key in (TITLEBAR_MAXIMIZE_KEY, TITLEBAR_CLOSE_KEY, TITLEBAR_IMAGE_KEY): + return True + if elem.metadata == TITLEBAR_METADATA_MARKER: + return True + return False + + AddRow = add_row + AddRows = add_rows + AlphaChannel = alpha_channel + BringToFront = bring_to_front + Close = close + CurrentLocation = current_location + Disable = disable + DisableDebugger = disable_debugger + Disappear = disappear + Enable = enable + EnableDebugger = enable_debugger + Fill = fill + Finalize = finalize + # FindElement = find_element + FindElementWithFocus = find_element_with_focus + GetScreenDimensions = get_screen_dimensions + GrabAnyWhereOff = grab_any_where_off + GrabAnyWhereOn = grab_any_where_on + Hide = hide + Layout = layout + LoadFromDisk = load_from_disk + Maximize = maximize + Minimize = minimize + Move = move + Normal = normal + Read = read + Reappear = reappear + Refresh = refresh + SaveToDisk = save_to_disk + SendToBack = send_to_back + SetAlpha = set_alpha + SetIcon = set_icon + SetTransparentColor = set_transparent_color + Size = size + UnHide = un_hide + VisibilityChanged = visibility_changed + CloseNonBlocking = close + CloseNonBlockingForm = close + start_thread = perform_long_operation + # + # def __exit__(self, *a): + # """ + # WAS used with context managers which are no longer needed nor advised. It is here for legacy support and + # am afraid of removing right now + # :param *a: (?) Not sure what's passed in. + # :type *a: + # :return: Always returns False which was needed for context manager to work + # :rtype: + # """ + # self.__del__() + # return False + # + # def __del__(self): + # # print('DELETING WINDOW') + # for row in self.Rows: + # for element in row: + # element.__del__() + + +# -------------------------------- PEP8-ify the Window Class USER Interfaces -------------------------------- # + + +FlexForm = Window + + +def _long_func_thread(window, end_key, original_func): + """ + Used to run long operations on the user's behalf. Called by the window object + + :param window: The window that will get the event + :type window: (Window) + :param end_key: The event that will be sent when function returns. If None then no event will be sent when exiting thread + :type end_key: (Any|None) + :param original_func: The user's function that is called. Can be a function with no arguments or a lambda experession + :type original_func: (Any) + """ + + return_value = original_func() + if end_key is not None: + window.write_event_value(end_key, return_value) + + +def _exit_mainloop(exiting_window): + if exiting_window == Window._window_running_mainloop or Window._root_running_mainloop == Window.hidden_master_root: + Window._window_that_exited = exiting_window + if Window._root_running_mainloop is not None: + Window._root_running_mainloop.quit() + # print('** Exited window mainloop **') + + +def _timeout_alarm_callback_hidden(): + """ + Read Timeout Alarm callback. Will kick a mainloop call out of the tkinter event loop and cause it to return + """ + + del Window._TKAfterID + + # first, get the results table built + # modify the Results table in the parent FlexForm object + # print('TIMEOUT CALLBACK') + Window._root_running_mainloop.quit() # kick the users out of the mainloop + + # Get window that caused return + Window._window_that_exited = None + + +def read_all_windows(timeout=None, timeout_key=TIMEOUT_KEY): + """ + Reads all windows that are "active" when the call is made. "Active" means that it's been finalized or read. + If a window has not been finalized then it will not be considered an "active window" + + If any of the active windows returns a value then the window and its event and values + are returned. + + If no windows are open, then the value (None, WIN_CLOSED, None) will be returned + Since WIN_CLOSED is None, it means (None, None, None) is what's returned when no windows remain opened + + :param timeout: Time in milliseconds to delay before a returning a timeout event + :type timeout: (int) + :param timeout_key: Key to return when a timeout happens. Defaults to the standard TIMEOUT_KEY + :type timeout_key: (Any) + :return: A tuple with the (Window, event, values dictionary/list) + :rtype: (Window, Any, Dict | List) + """ + + if len(Window._active_windows) == 0: + return None, WIN_CLOSED, None + + # first see if any queued events are waiting for any of the windows + for window in Window._active_windows.keys(): + if window._queued_thread_event_available(): + _BuildResults(window, False, window) + event, values = window.ReturnValues + return window, event, values + + Window._root_running_mainloop = Window.hidden_master_root + Window._timeout_key = timeout_key + + if timeout == 0: + window = list(Window._active_windows.keys())[Window._timeout_0_counter] + event, values = window._ReadNonBlocking() + if event is None: + event = timeout_key + if values is None: + event = None + Window._timeout_0_counter = (Window._timeout_0_counter + 1) % len(Window._active_windows) + return window, event, values + + Window._timeout_0_counter = 0 # reset value if not reading with timeout 0 so ready next time needed + + # setup timeout timer + if timeout != None: + try: + Window.hidden_master_root.after_cancel(Window._TKAfterID) + del Window._TKAfterID + except: + pass + + Window._TKAfterID = Window.hidden_master_root.after(timeout, _timeout_alarm_callback_hidden) + + # ------------ Call Mainloop ------------ + Window._root_running_mainloop.mainloop() + + try: + Window.hidden_master_root.after_cancel(Window._TKAfterID) + del Window._TKAfterID + except: + pass + # print('** tkafter cancel failed **') + + # Get window that caused return + + window = Window._window_that_exited + + if window is None: + return None, timeout_key, None + + if window.XFound: + event, values = None, None + window.close() + try: + del Window._active_windows[window] + except: + pass + # print('Error deleting window, but OK') + else: + _BuildResults(window, False, window) + event, values = window.ReturnValues + + return window, event, values + +# MP""""""`MM dP +# M mmmmm..M 88 +# M. `YM dP dP .d8888b. d8888P .d8888b. 88d8b.d8b. +# MMMMMMM. M 88 88 Y8ooooo. 88 88ooood8 88'`88'`88 +# M. .MMM' M 88. .88 88 88 88. ... 88 88 88 +# Mb. .dM `8888P88 `88888P' dP `88888P' dP dP dP +# MMMMMMMMMMM .88 +# d8888P +# M""""""""M +# Mmmm mmmM +# MMMM MMMM 88d888b. .d8888b. dP dP +# MMMM MMMM 88' `88 88' `88 88 88 +# MMMM MMMM 88 88. .88 88. .88 +# MMMM MMMM dP `88888P8 `8888P88 +# MMMMMMMMMM .88 +# d8888P + +# ------------------------------------------------------------------------- # +# SystemTray - class for implementing a psyeudo tray # +# ------------------------------------------------------------------------- # + +# -------------------------------- System Tray Begins Here -------------------------------- # +# Feb 2020 - Just starting on this so code commented out for now. Basing on PySimpleGUIQt's implementation / call format + + +# ------------------------------------------------------------------- +# fade in/out info and default window alpha +SYSTEM_TRAY_WIN_MARGINS = 160, 60 # from right edge of screen, from bottom of screen +SYSTEM_TRAY_MESSAGE_MAX_LINE_LENGTH = 50 +# colors +SYSTEM_TRAY_MESSAGE_WIN_COLOR = "#282828" +SYSTEM_TRAY_MESSAGE_TEXT_COLOR = "#ffffff" + +SYSTEM_TRAY_MESSAGE_DISPLAY_DURATION_IN_MILLISECONDS = 3000 # how long to display the window +SYSTEM_TRAY_MESSAGE_FADE_IN_DURATION = 1000 # how long to fade in / fade out the window + +EVENT_SYSTEM_TRAY_ICON_DOUBLE_CLICKED = '__DOUBLE_CLICKED__' +EVENT_SYSTEM_TRAY_ICON_ACTIVATED = '__ACTIVATED__' +EVENT_SYSTEM_TRAY_MESSAGE_CLICKED = '__MESSAGE_CLICKED__' + +# Base64 Images to use as icons in the window +_tray_icon_error = b'iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAMAAABEpIrGAAAAA3NCSVQICAjb4U/gAAAACXBIWXMAAADlAAAA5QGP5Zs8AAAAGXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAAAIpQTFRF////20lt30Bg30pg4FJc409g4FBe4E9f4U9f4U9g4U9f4E9g31Bf4E9f4E9f4E9f4E9f4E9f4FFh4Vdm4lhn42Bv5GNx5W575nJ/6HqH6HyI6YCM6YGM6YGN6oaR8Kev9MPI9cbM9snO9s3R+Nfb+dzg+d/i++vt/O7v/fb3/vj5//z8//7+////KofnuQAAABF0Uk5TAAcIGBktSYSXmMHI2uPy8/XVqDFbAAAA8UlEQVQ4y4VT15LCMBBTQkgPYem9d9D//x4P2I7vILN68kj2WtsAhyDO8rKuyzyLA3wjSnvi0Eujf3KY9OUP+kno651CvlB0Gr1byQ9UXff+py5SmRhhIS0oPj4SaUUCAJHxP9+tLb/ezU0uEYDUsCc+l5/T8smTIVMgsPXZkvepiMj0Tm5txQLENu7gSF7HIuMreRxYNkbmHI0u5Hk4PJOXkSMz5I3nyY08HMjbpOFylF5WswdJPmYeVaL28968yNfGZ2r9gvqFalJNUy2UWmq1Wa7di/3Kxl3tF1671YHRR04dWn3s9cXRV09f3vb1fwPD7z9j1WgeRgAAAABJRU5ErkJggg==' +_tray_icon_success = b'iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAMAAABEpIrGAAAAA3NCSVQICAjb4U/gAAAACXBIWXMAAAEKAAABCgEWpLzLAAAAGXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAAAHJQTFRF////ZsxmbbZJYL9gZrtVar9VZsJcbMRYaMZVasFYaL9XbMFbasRZaMFZacRXa8NYasFaasJaasFZasJaasNZasNYasJYasJZasJZasJZasJZasJZasJYasJZasJZasJZasJZasJaasJZasJZasJZasJZ2IAizQAAACV0Uk5TAAUHCA8YGRobHSwtPEJJUVtghJeYrbDByNjZ2tvj6vLz9fb3/CyrN0oAAADnSURBVDjLjZPbWoUgFIQnbNPBIgNKiwwo5v1fsQvMvUXI5oqPf4DFOgCrhLKjC8GNVgnsJY3nKm9kgTsduVHU3SU/TdxpOp15P7OiuV/PVzk5L3d0ExuachyaTWkAkLFtiBKAqZHPh/yuAYSv8R7XE0l6AVXnwBNJUsE2+GMOzWL8k3OEW7a/q5wOIS9e7t5qnGExvF5Bvlc4w/LEM4Abt+d0S5BpAHD7seMcf7+ZHfclp10TlYZc2y2nOqc6OwruxUWx0rDjNJtyp6HkUW4bJn0VWdf/a7nDpj1u++PBOR694+Ftj/8PKNdnDLn/V8YAAAAASUVORK5CYII=' +_tray_icon_halt = b'iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAMAAABEpIrGAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAMAUExURQAAANswNuMPDO8HBO8FCe0HCu4IBu4IB+oLDeoLDu8JC+wKCu4JDO4LDOwKEe4OEO4OEeUQDewQDe0QDucVEuYcG+ccHOsQFuwWHe4fH/EGAvMEBfMFBvAHBPMGBfEGBvYCAfYDAvcDA/cDBPcDBfUDBvYEAPYEAfYEAvYEA/QGAPQGAfQGAvYEBPUEBvYFB/QGBPQGBfQHB/EFCvIHCPMHCfIHC/IFDfMHDPQGCPQGCfQGCvEIBPIIBfAIB/UIB/QICPYICfoBAPoBAfoBAvsBA/kCAPkCAfkCAvkCA/oBBPkCBPkCBfkCBvgCB/gEAPkEAfgEAvkEA/gGAfkGAvkEBPgEBfkEBv0AAP0AAfwAAvwAA/wCAPwCAfwCAvwCA/wABP0ABfwCBfwEAPwFA/ASD/ESFPAUEvAUE/EXFvAdH+kbIOobIeofIfEfIOcmKOohIukgJOggJesiKuwiKewoLe0tLO0oMOQ3OO43Oew4OfAhIPAhIfAiIPEiI+dDRe9ES+lQTOdSWupSUOhTUehSV+hUVu1QUO1RUe1SV+tTWe5SWOxXWOpYV+pZWelYXexaW+xaXO9aX+lZYeNhYOxjZ+lna+psbOttbehsbupscepucuxtcuxucep3fet7e+p/ffB6gOmKiu2Iie2Sk+2Qle2QluySlOyTleuYmvKFivCOjgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIxGNZsAAAEAdFJOU////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////wBT9wclAAAACXBIWXMAAA7DAAAOwwHHb6hkAAACVElEQVQ4T22S93PTMBhADQdl791SSsuRARTKKHsn+STZBptAi6zIacous+w9yyxl7z1T1h8ptHLhrrzLD5+/987R2XZElZ/39tZsbGg42NdvF4pqcGMs4XEcozAB/oQeu6wGr5fkAZcKOUIIRgQXR723wgaXt/NSgcwlO1r3oARkATfhbmNMMCnlMZdz5J8RN9fVhglS5JA/pJUOJiYXoShCkz/flheDvpzlBCBmya5KcDG1sMSB+r/VQtG+YoFXlwN0Us4yeBXujPmWCOqNlVwX5zHntLH5iQ420YiqX9pqTZFSCrBGBc+InBUDAsbwLRlMC40fGJT8YLRwfnhY3v6/AUtDc9m5z0tRJBOAvHUaFchdY6+zDzEghHv1tUnrNCaIOw84Q2WQmkeO/Xopj1xFBREFr8ZZjuRhA++PEB+t05ggwBucpbH8i/n5C1ZU0EEEmRZnSMxoIYcarKigA0Cb1zpHAyZnGj21xqICAA9dcvo4UgEdZ41FBZSTzEOn30f6QeE3Vhl0gLN+2RGDzZPMHLHKoAO3MFy+ix4sDxFlvMXfrdNgFezy7qrXPaaJg0u27j5nneKrCjJ4pf4e3m4DVMcjNNNKxWnpo6jtnfnkunExB4GbuGKk5FNanpB1nJCjCsThJPAAJ8lVdSF5sSrklM2ZqmYdiC40G7Dfnhp57ZsQz6c3hylEO6ZoZQJxqiVgbhoQK3T6AIgU4rbjxthAPF6NAwAOAcS+ixlp/WBFJRDi0fj2RtcjWRwif8Qdu/w3EKLcu3/YslnrZzwo24UQQvwFCrp/iM1NnHwAAAAASUVORK5CYII=' +_tray_icon_notallowed = b'iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAMAAABEpIrGAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAMAUExURQAAAPcICPcLC/cMDPcQEPcSEvcXF/cYGPcaGvcbG/ccHPgxMfgyMvg0NPg5Ofg6Ovg7O/hBQfhCQvlFRflGRvljY/pkZPplZfpnZ/p2dgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMgEwNYAAAEAdFJOU////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////wBT9wclAAAACXBIWXMAAA7DAAAOwwHHb6hkAAABE0lEQVQ4T4WT65bDIAiExWbbtN0m3Uua+P4P6g4jGtN4NvNL4DuCCC5WWobe++uwmEmtwNxJUTebcwWCt5jJBwsYcKf3NE4hTOOJxj1FEnBTz4NH6qH2jUcCGr/QLLpkQgHe/6VWJXVqFgBB4yI/KVCkBCoFgPrPHw0CWbwCL8RibBFwzQDQH62/QeAtHQBeADUIDbkF/UnmnkB1ixtERrN3xCgyuF5kMntHTCJXh2vyv+wIdMhvgTeCQJ0C2hBMgSKfZlM1wSLXZ5oqgs8sjSpaCQ2VVlfKhLU6fdZGSvyWz9JMb+NE4jt/Nwfm0yJZSkBpYDg7TcJGrjm0Z7jK0B6P/fHiHK8e9Pp/eSmuf1+vf4x/ralnCN9IrncAAAAASUVORK5CYII=' +_tray_icon_stop = b'iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAMAAABEpIrGAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAMAUExURQAAANsAANsBAdsCAtwEBNwFBdwHB9wICNwLC90MDN0NDd0PD90REd0SEt4TE94UFN4WFt4XF94ZGeAjI+AlJeEnJ+EpKeEqKuErK+EsLOEuLuIvL+IyMuIzM+M1NeM2NuM3N+M6OuM8POQ9PeQ+PuQ/P+RAQOVISOVJSeVKSuZLS+ZOTuZQUOZRUedSUudVVehbW+lhYeljY+poaOtvb+twcOtxcetzc+t0dOx3d+x4eOx6eu19fe1+fu2AgO2Cgu6EhO6Ghu6Hh+6IiO6Jie+Kiu+Li++MjO+Nje+Oju+QkPCUlPCVlfKgoPKkpPKlpfKmpvOrq/SurvSxsfSysvW4uPW6uvW7u/W8vPa9vfa+vvbAwPbCwvfExPfFxffGxvfHx/fIyPfJyffKyvjLy/jNzfjQ0PjR0fnS0vnU1PnY2Pvg4Pvi4vvj4/vl5fvm5vzo6Pzr6/3u7v3v7/3x8f3z8/309P719f729v739/74+P75+f76+v77+//8/P/9/f/+/v///wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPHCyoUAAAEAdFJOU////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////wBT9wclAAAACXBIWXMAAA7DAAAOwwHHb6hkAAABnUlEQVQ4T33S50PTQBgG8D6lzLbsIUv2kD0FFWTvPWTvISDIUBGV1ecvj+8luZTR9P1wSe755XK5O4+hK4gn5bc7DcMBz/InQoMXeVjY4FXuCAtEyLUwQcTcFgq45JYQ4JqbwhMtV8IjeUJDjQ+5paqCyG9srEsGgoUlpeXpIjxA1nfyi2+Jqmo7Q9JeV+ODerpvBQTM8/ySzQ3t+xxoL7h7nJve5jd85M7wJq9McHaT8o6TwBTfIIfHQGzoAZ/YiSTSq8D5dSDQVqFADrJ5KFMLPaKLHQiQMQoscClezdgCB4CXD/jM90izR8g85UaKA3YAn4AejhV189acA5LX+DVOg00gnvfoVX/BRQsgbplNGqzLusgIffx1tDchiyRgdRbVHNdgRRZHQD9H1asm+PMzYyYMtoBU/sYgRxxgrmGtBRL/cnf5RL4zzCEHZF2QE14LoOWf6B9vMcJBG/iBxKo8dVtYnyStv6yuUq7FLfmqTzbLEOFest1GNGEemCjCPnKuwjm0LsLMbRBJWLkGr4WdO+Cl0HkYPBc6N4z//HcQqVwcOuIAAAAASUVORK5CYII=' +_tray_icon_exclamation = b'iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAMAAABEpIrGAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAMAUExURQAAAN0zM900NN01Nd02Nt03N944ON45Od46Ot47O98/P99BQd9CQt9DQ+FPT+JSUuJTU+JUVOJVVeJWVuNbW+ReXuVjY+Zra+dxceh4eOl7e+l8fOl+ful/f+qBgeqCguqDg+qFheuJieuLi+yPj+yQkO2Wlu+cnO+hofGqqvGtrfre3vrf3/ri4vvn5/75+f76+v/+/v///wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMQ8SQkAAAEAdFJOU////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////wBT9wclAAAACXBIWXMAAA7DAAAOwwHHb6hkAAABJElEQVQ4T4WS63KCMBBGsyBai62X0otY0aq90ZZa3v/dtpvsJwTijOfXt7tnILOJYY9tNonjNCtQOlqhuKKG0RrNVjgkmIHBHgMId+h7zHSiwg2a9FNVVYScupETmjkd67o+CWpYwft+R6CpCgeUlq5AOyf45+8JsRUKFI6eQLkI3n5CIREBUekLxGaLpATCymRISiAszARJCYSxiZGUQKDLQoqgnPnFhUPOTWeRoZD3FvVZlmVHkE2OEM9iV71GVoZDBGUpAg9QWN5/jx+Ilsi9hz0q4VHOWD+hEF70yc1QEr1a4Q0F0S3eJDfLuv8T4QEFXduZE1rj+et7g6hzCDxF08N+X4DAu+6lUSTnc5wE5tx73ckSTV8QVoux3N88Rykw/wP3i+vwPKk17AAAAABJRU5ErkJggg==' +_tray_icon_none = None + +SYSTEM_TRAY_MESSAGE_ICON_INFORMATION = _tray_icon_success +SYSTEM_TRAY_MESSAGE_ICON_WARNING = _tray_icon_exclamation +SYSTEM_TRAY_MESSAGE_ICON_CRITICAL = _tray_icon_stop +SYSTEM_TRAY_MESSAGE_ICON_NOICON = _tray_icon_none + + +# ------------------------------------------------------------------------- # +# Tray CLASS # +# ------------------------------------------------------------------------- # +class SystemTray: + """ + A "Simulated System Tray" that duplicates the API calls available to PySimpleGUIWx and PySimpleGUIQt users. + + All of the functionality works. The icon is displayed ABOVE the system tray rather than inside of it. + """ + + def __init__(self, menu=None, filename=None, data=None, data_base64=None, tooltip=None, metadata=None): + """ + SystemTray - create an icon in the system tray + :param menu: Menu definition. Example - ['UNUSED', ['My', 'Simple', '---', 'Menu', 'Exit']] + :type menu: List[List[List[str] or str]] + :param filename: filename for icon + :type filename: (str) + :param data: in-ram image for icon (same as data_base64 parm) + :type data: (bytes) + :param data_base64: base-64 data for icon + :type data_base64: (bytes) + :param tooltip: tooltip string + :type tooltip: (str) + :param metadata: User metadata that can be set to ANYTHING + :type metadata: (Any) + """ + self._metadata = None + self.Menu = menu + self.TrayIcon = None + self.Shown = False + self.MenuItemChosen = TIMEOUT_KEY + self.metadata = metadata + self.last_message_event = None + + screen_size = Window.get_screen_size() + + if filename: + image_elem = Image(filename=filename, background_color='red', enable_events=True, tooltip=tooltip, key='-IMAGE-') + elif data_base64: + image_elem = Image(data=data_base64, background_color='red', enable_events=True, tooltip=tooltip, key='-IMAGE-') + elif data: + image_elem = Image(data=data, background_color='red', enable_events=True, tooltip=tooltip, key='-IMAGE-') + else: + image_elem = Image(background_color='red', enable_events=True, tooltip=tooltip, key='-IMAGE-') + layout = [ + [image_elem], + ] + self.window = Window('Window Title', layout, element_padding=(0, 0), margins=(0, 0), grab_anywhere=True, no_titlebar=True, transparent_color='red', + keep_on_top=True, right_click_menu=menu, location=(screen_size[0] - 100, screen_size[1] - 100), finalize=True) + + self.window['-IMAGE-'].bind('', '+DOUBLE_CLICK') + + @property + def metadata(self): + """ + Metadata is an SystemTray property that you can use at any time to hold any value + :return: the current metadata value + :rtype: (Any) + """ + return self._metadata + + @metadata.setter + def metadata(self, value): + """ + Metadata is an SystemTray property that you can use at any time to hold any value + :param value: Anything you want it to be + :type value: (Any) + """ + self._metadata = value + + def read(self, timeout=None): + """ + Reads the context menu + :param timeout: Optional. Any value other than None indicates a non-blocking read + :type timeout: + :return: + :rtype: + """ + if self.last_message_event != TIMEOUT_KEY and self.last_message_event is not None: + event = self.last_message_event + self.last_message_event = None + return event + event, values = self.window.read(timeout=timeout) + if event.endswith('DOUBLE_CLICK'): + return EVENT_SYSTEM_TRAY_ICON_DOUBLE_CLICKED + elif event == '-IMAGE-': + return EVENT_SYSTEM_TRAY_ICON_ACTIVATED + + return event + + def hide(self): + """ + Hides the icon + """ + self.window.hide() + + def un_hide(self): + """ + Restores a previously hidden icon + """ + self.window.un_hide() + + def show_message(self, title, message, filename=None, data=None, data_base64=None, messageicon=None, + time=(SYSTEM_TRAY_MESSAGE_FADE_IN_DURATION, SYSTEM_TRAY_MESSAGE_DISPLAY_DURATION_IN_MILLISECONDS)): + """ + Shows a balloon above icon in system tray + :param title: Title shown in balloon + :type title: str + :param message: Message to be displayed + :type message: str + :param filename: Optional icon filename + :type filename: str + :param data: Optional in-ram icon + :type data: b'' + :param data_base64: Optional base64 icon + :type data_base64: b'' + :param time: Amount of time to display message in milliseconds. If tuple, first item is fade in/out duration + :type time: int | (int, int) + :return: The event that happened during the display such as user clicked on message + :rtype: Any + """ + + if isinstance(time, tuple): + fade_duration, display_duration = time + else: + fade_duration = SYSTEM_TRAY_MESSAGE_FADE_IN_DURATION + display_duration = time + + user_icon = data_base64 or filename or data or messageicon + + event = self.notify(title, message, icon=user_icon, fade_in_duration=fade_duration, display_duration_in_ms=display_duration) + self.last_message_event = event + return event + + def close(self): + """ + Close the system tray window + """ + self.window.close() + + def update(self, menu=None, tooltip=None, filename=None, data=None, data_base64=None, ): + """ + Updates the menu, tooltip or icon + :param menu: menu defintion + :type menu: ??? + :param tooltip: string representing tooltip + :type tooltip: ??? + :param filename: icon filename + :type filename: ??? + :param data: icon raw image + :type data: ??? + :param data_base64: icon base 64 image + :type data_base64: ??? + """ + # Menu + if menu is not None: + top_menu = tk.Menu(self.window.TKroot, tearoff=False) + AddMenuItem(top_menu, menu[1], self.window['-IMAGE-']) + self.window['-IMAGE-'].TKRightClickMenu = top_menu + + if filename: + self.window['-IMAGE-'].update(filename=filename) + elif data_base64: + self.window['-IMAGE-'].update(data=data_base64) + elif data: + self.window['-IMAGE-'].update(data=data) + + if tooltip: + self.window['-IMAGE-'].set_tooltip(tooltip) + + @classmethod + def notify(cls, title, message, icon=_tray_icon_success, display_duration_in_ms=SYSTEM_TRAY_MESSAGE_DISPLAY_DURATION_IN_MILLISECONDS, + fade_in_duration=SYSTEM_TRAY_MESSAGE_FADE_IN_DURATION, alpha=0.9, location=None): + """ + Displays a "notification window", usually in the bottom right corner of your display. Has an icon, a title, and a message + The window will slowly fade in and out if desired. Clicking on the window will cause it to move through the end the current "phase". For example, if the window was fading in and it was clicked, then it would immediately stop fading in and instead be fully visible. It's a way for the user to quickly dismiss the window. + :param title: Text to be shown at the top of the window in a larger font + :type title: (str) + :param message: Text message that makes up the majority of the window + :type message: (str) + :param icon: A base64 encoded PNG/GIF image or PNG/GIF filename that will be displayed in the window + :type icon: bytes | str + :param display_duration_in_ms: Number of milliseconds to show the window + :type display_duration_in_ms: (int) + :param fade_in_duration: Number of milliseconds to fade window in and out + :type fade_in_duration: (int) + :param alpha: Alpha channel. 0 - invisible 1 - fully visible + :type alpha: (float) + :param location: Location on the screen to display the window + :type location: (int, int) + :return: (int) reason for returning + :rtype: (int) + """ + + messages = message.split('\n') + full_msg = '' + for m in messages: + m_wrap = textwrap.fill(m, SYSTEM_TRAY_MESSAGE_MAX_LINE_LENGTH) + full_msg += m_wrap + '\n' + message = full_msg[:-1] + + win_msg_lines = message.count("\n") + 1 + max_line = max(message.split('\n')) + + screen_res_x, screen_res_y = Window.get_screen_size() + win_margin = SYSTEM_TRAY_WIN_MARGINS # distance from screen edges + win_width, win_height = 364, 66 + (14.8 * win_msg_lines) + + layout = [[Graph(canvas_size=(win_width, win_height), graph_bottom_left=(0, win_height), graph_top_right=(win_width, 0), key="-GRAPH-", + background_color=SYSTEM_TRAY_MESSAGE_WIN_COLOR, enable_events=True)]] + + win_location = location if location is not None else (screen_res_x - win_width - win_margin[0], screen_res_y - win_height - win_margin[1]) + window = Window(title, layout, background_color=SYSTEM_TRAY_MESSAGE_WIN_COLOR, no_titlebar=True, + location=win_location, keep_on_top=True, alpha_channel=0, margins=(0, 0), element_padding=(0, 0), grab_anywhere=True, finalize=True) + + window["-GRAPH-"].draw_rectangle((win_width, win_height), (-win_width, -win_height), fill_color=SYSTEM_TRAY_MESSAGE_WIN_COLOR, + line_color=SYSTEM_TRAY_MESSAGE_WIN_COLOR) + if type(icon) is bytes: + window["-GRAPH-"].draw_image(data=icon, location=(20, 20)) + elif icon is not None: + window["-GRAPH-"].draw_image(filename=icon, location=(20, 20)) + window["-GRAPH-"].draw_text(title, location=(64, 20), color=SYSTEM_TRAY_MESSAGE_TEXT_COLOR, font=("Helvetica", 12, "bold"), + text_location=TEXT_LOCATION_TOP_LEFT) + window["-GRAPH-"].draw_text(message, location=(64, 44), color=SYSTEM_TRAY_MESSAGE_TEXT_COLOR, font=("Helvetica", 9), + text_location=TEXT_LOCATION_TOP_LEFT) + window["-GRAPH-"].set_cursor('hand2') + + if fade_in_duration: + for i in range(1, int(alpha * 100)): # fade in + window.set_alpha(i / 100) + event, values = window.read(timeout=fade_in_duration // 100) + if event != TIMEOUT_KEY: + window.set_alpha(1) + break + if event != TIMEOUT_KEY: + window.close() + return EVENT_SYSTEM_TRAY_MESSAGE_CLICKED if event == '-GRAPH-' else event + event, values = window(timeout=display_duration_in_ms) + if event == TIMEOUT_KEY: + for i in range(int(alpha * 100), 1, -1): # fade out + window.set_alpha(i / 100) + event, values = window.read(timeout=fade_in_duration // 100) + if event != TIMEOUT_KEY: + break + else: + window.set_alpha(alpha) + event, values = window(timeout=display_duration_in_ms) + window.close() + + return EVENT_SYSTEM_TRAY_MESSAGE_CLICKED if event == '-GRAPH-' else event + + Close = close + Hide = hide + Read = read + ShowMessage = show_message + UnHide = un_hide + Update = update + + +# ################################################################################ +# ################################################################################ +# END OF ELEMENT DEFINITIONS +# ################################################################################ +# ################################################################################ + + +# =========================================================================== # +# Button Lazy Functions so the caller doesn't have to define a bunch of stuff # +# =========================================================================== # + +# ------------------------- A fake Element... the Pad Element ------------------------- # +def Sizer(h_pixels=0, v_pixels=0): + """ + "Pushes" out the size of whatever it is placed inside of. This includes Columns, Frames, Tabs and Windows + + :param h_pixels: number of horizontal pixels + :type h_pixels: (int) + :param v_pixels: number of vertical pixels + :type v_pixels: (int) + :return: (Canvas) A canvas element that has a pad setting set according to parameters + :rtype: (Canvas) + """ + + return Canvas(size=(0, 0), pad=((h_pixels, 0), (v_pixels, 0))) + +def pin(elem, vertical_alignment=None, shrink=True, expand_x=None, expand_y=None): + """ + Pin's an element provided into a layout so that when it's made invisible and visible again, it will + be in the correct place. Otherwise it will be placed at the end of its containing window/column. + + The element you want to pin is the element that you'll be making visibile/invisible. + + The pin helper function also causes containers to shrink to fit the contents correct after something inside + has changed visiblity. Note that setting a hardcoded size on your window can impact this ability to shrink. + + :param elem: the element to put into the layout + :type elem: Element + :param vertical_alignment: Aligns elements vertically. 'top', 'center', 'bottom'. Can be shortened to 't', 'c', 'b' + :type vertical_alignment: str | None + :param shrink: If True, then the space will shrink down to a single pixel when hidden. False leaves the area large and blank + :type shrink: bool + :param expand_x: If True/False the value will be passed to the Column Elements used to make this feature + :type expand_x: (bool) + :param expand_y: If True/False the value will be passed to the Column Elements used to make this feature + :type expand_y: (bool) + :return: A column element containing the provided element + :rtype: Column + """ + if shrink: + # return Column([[elem, Canvas(size=(0, 0),background_color=elem.BackgroundColor, pad=(0, 0))]], pad=(0, 0), vertical_alignment=vertical_alignment, expand_x=expand_x, expand_y=expand_y) + return Column([[elem, Column([[]],pad=(0,0))]], pad=(0, 0), vertical_alignment=vertical_alignment, expand_x=expand_x, expand_y=expand_y) + else: + return Column([[elem]], pad=(0, 0), vertical_alignment=vertical_alignment, expand_x=expand_x, expand_y=expand_y) + + +def vtop(elem_or_row, expand_x=None, expand_y=None, background_color=None): + """ + Align an element or a row of elements to the top of the row that contains it + + :param elem_or_row: the element or row of elements + :type elem_or_row: Element | List[Element] | Tuple[Element] + :param expand_x: If True/False the value will be passed to the Column Elements used to make this feature + :type expand_x: (bool) + :param expand_y: If True/False the value will be passed to the Column Elements used to make this feature + :type expand_y: (bool) + :param background_color: Background color for container that is used by vtop to do the alignment + :type background_color: str | None + :return: A column element containing the provided element aligned to the top or list of elements (a row) + :rtype: Column | List[Column] + """ + + + if isinstance(elem_or_row, list) or isinstance(elem_or_row, tuple): + return [Column([[e]], pad=(0, 0), vertical_alignment='top', expand_x=expand_x, expand_y=expand_y, background_color=background_color) for e in elem_or_row] + + return Column([[elem_or_row]], pad=(0, 0), vertical_alignment='top', expand_x=expand_x, expand_y=expand_y, background_color=background_color) + + +def vcenter(elem_or_row, expand_x=None, expand_y=None, background_color=None): + """ + Align an element or a row of elements to the center of the row that contains it + + :param elem_or_row: the element or row of elements + :type elem_or_row: Element | List[Element] | Tuple[Element] + :param expand_x: If True/False the value will be passed to the Column Elements used to make this feature + :type expand_x: (bool) + :param expand_y: If True/False the value will be passed to the Column Elements used to make this feature + :type expand_y: (bool) + :param background_color: Background color for container that is used by vcenter to do the alignment + :type background_color: str | None + :return: A column element containing the provided element aligned to the center or list of elements (a row) + :rtype: Column | List[Column] + """ + + if isinstance(elem_or_row, list) or isinstance(elem_or_row, tuple): + return [Column([[e]], pad=(0, 0), vertical_alignment='center',expand_x=expand_x, expand_y=expand_y, background_color=background_color) for e in elem_or_row] + + return Column([[elem_or_row]], pad=(0, 0), vertical_alignment='center', expand_x=expand_x, expand_y=expand_y,background_color=background_color) + + +def vbottom(elem_or_row, expand_x=None, expand_y=None, background_color=None): + """ + Align an element or a row of elements to the bottom of the row that contains it + + :param elem_or_row: the element or row of elements + :type elem_or_row: Element | List[Element] | Tuple[Element] + :param expand_x: If True/False the value will be passed to the Column Elements used to make this feature + :type expand_x: (bool) + :param expand_y: If True/False the value will be passed to the Column Elements used to make this feature + :type expand_y: (bool) + :param background_color: Background color for container that is used by vcenter to do the alignment + :type background_color: str | None + :return: A column element containing the provided element aligned to the bottom or list of elements (a row) + :rtype: Column | List[Column] + """ + + + if isinstance(elem_or_row, list) or isinstance(elem_or_row, tuple): + return [Column([[e]], pad=(0, 0), vertical_alignment='bottom', expand_x=expand_x, expand_y=expand_y, background_color=background_color) for e in elem_or_row] + + return Column([[elem_or_row]], pad=(0, 0), vertical_alignment='bottom', expand_x=expand_x, expand_y=expand_y,background_color=background_color) + + +def Titlebar(title='', icon=None, text_color=None, background_color=None, font=None, key=None, k=None): + """ + A custom titlebar that replaces the OS provided titlebar, thus giving you control + the is not possible using the OS provided titlebar such as the color. + + NOTE LINUX USERS - at the moment the minimize function is not yet working. Windows users + should have no problem and it should function as a normal window would. + + This titlebar is created from a row of elements that is then encapsulated into a + one Column element which is what this Titlebar function returns to you. + + A custom titlebar removes the margins from your window. If you want the remainder + of your Window to have margins, place the layout after the Titlebar into a Column and + set the pad of that Column to the dimensions you would like your margins to have. + + The Titlebar is a COLUMN element. You can thus call the update method for the column and + perform operations such as making the column visible/invisible + + :param icon: Can be either a filename or Base64 byte string of a PNG or GIF. This is used in an Image element to create the titlebar + :type icon: str or bytes or None + :param title: The "title" to show in the titlebar + :type title: str + :param text_color: Text color for titlebar + :type text_color: str | None + :param background_color: Background color for titlebar + :type background_color: str | None + :param font: Font to be used for the text and the symbols + :type font: (str or (str, int[, str]) or None) + :param key: Identifies an Element. Should be UNIQUE to this window. + :type key: str | int | tuple | object | None + :param k: Exactly the same as key. Choose one of them to use + :type k: str | int | tuple | object | None + :return: A single Column element that has eveything in 1 element + :rtype: Column + """ + bc = background_color or CUSTOM_TITLEBAR_BACKGROUND_COLOR or theme_button_color()[1] + tc = text_color or CUSTOM_TITLEBAR_TEXT_COLOR or theme_button_color()[0] + font = font or CUSTOM_TITLEBAR_FONT or ('Helvetica', 12) + key = k or key + + if isinstance(icon, bytes): + icon_and_text_portion = [Image(data=icon, background_color=bc, key=TITLEBAR_IMAGE_KEY)] + elif icon == TITLEBAR_DO_NOT_USE_AN_ICON: + icon_and_text_portion = [] + elif icon is not None: + icon_and_text_portion = [Image(filename=icon, background_color=bc, key=TITLEBAR_IMAGE_KEY)] + elif CUSTOM_TITLEBAR_ICON is not None: + if isinstance(CUSTOM_TITLEBAR_ICON, bytes): + icon_and_text_portion = [Image(data=CUSTOM_TITLEBAR_ICON, background_color=bc, key=TITLEBAR_IMAGE_KEY)] + else: + icon_and_text_portion = [Image(filename=CUSTOM_TITLEBAR_ICON, background_color=bc, key=TITLEBAR_IMAGE_KEY)] + else: + icon_and_text_portion = [Image(data=DEFAULT_BASE64_ICON_16_BY_16, background_color=bc, key=TITLEBAR_IMAGE_KEY)] + + icon_and_text_portion += [T(title, text_color=tc, background_color=bc, font=font, grab=True, key=TITLEBAR_TEXT_KEY)] + + return Column([[Column([icon_and_text_portion], pad=(0, 0), background_color=bc), + Column([[T(SYMBOL_TITLEBAR_MINIMIZE, text_color=tc, background_color=bc, enable_events=True, font=font, key=TITLEBAR_MINIMIZE_KEY), + Text(SYMBOL_TITLEBAR_MAXIMIZE, text_color=tc, background_color=bc, enable_events=True, font=font, key=TITLEBAR_MAXIMIZE_KEY), + Text(SYMBOL_TITLEBAR_CLOSE, text_color=tc, background_color=bc, font=font, enable_events=True, key=TITLEBAR_CLOSE_KEY), ]], + element_justification='r', expand_x=True, grab=True, pad=(0, 0), background_color=bc)]], expand_x=True, grab=True, + background_color=bc, pad=(0, 0), metadata=TITLEBAR_METADATA_MARKER, key=key) + + +def MenubarCustom(menu_definition, disabled_text_color=None, bar_font=None, font=None, tearoff=False, pad=0, p=None, background_color=None, text_color=None, + bar_background_color=None, bar_text_color=None, key=None, k=None): + """ + A custom Menubar that replaces the OS provided Menubar + + Why? + Two reasons - 1. they look great (see custom titlebar) 2. if you have a custom titlebar, then you have to use a custom menubar if you want a menubar + + :param menu_definition: The Menu definition specified using lists (docs explain the format) + :type menu_definition: List[List[Tuple[str, List[str]]] + :param disabled_text_color: color to use for text when item is disabled. Can be in #RRGGBB format or a color name "black" + :type disabled_text_color: (str) + :param bar_font: specifies the font family, size to be used for the chars in the bar itself + :type bar_font: (str or (str, int[, str]) or None) + :param font: specifies the font family, size to be used for the menu items + :type font: (str or (str, int[, str]) or None) + :param tearoff: if True, then can tear the menu off from the window ans use as a floating window. Very cool effect + :type tearoff: (bool) + :param pad: Amount of padding to put around element in pixels (left/right, top/bottom) or ((left, right), (top, bottom)) or an int. If an int, then it's converted into a tuple (int, int). TIP - 0 will make flush with titlebar + :type pad: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param p: Same as pad parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, pad will be used + :type p: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param background_color: color to use for background of the menus that are displayed after making a section. Can be in #RRGGBB format or a color name "black". Defaults to the color of the bar text + :type background_color: (str) + :param text_color: color to use for the text of the many items in the displayed menus. Can be in #RRGGBB format or a color name "black". Defaults to the bar background + :type text_color: (str) + :param bar_background_color: color to use for the menubar. Can be in #RRGGBB format or a color name "black". Defaults to theme's button text color + :type bar_background_color: (str) + :param bar_text_color: color to use for the menu items text when item is disabled. Can be in #RRGGBB format or a color name "black". Defaults to theme's button background color + :type bar_text_color: (str) + :param key: Value that uniquely identifies this element from all other elements. Used when Finding an element or in return values. Must be unique to the window + :type key: str | int | tuple | object + :param k: Same as the Key. You can use either k or key. Which ever is set will be used. + :type k: str | int | tuple | object + :returns: A Column element that has a series of ButtonMenu elements + :rtype: Column + """ + + bar_bg = bar_background_color if bar_background_color is not None else theme_button_color()[0] + bar_text = bar_text_color if bar_text_color is not None else theme_button_color()[1] + menu_bg = background_color if background_color is not None else bar_text + menu_text = text_color if text_color is not None else bar_bg + pad = pad if pad is not None else p + + row = [] + for menu in menu_definition: + text = menu[0] + if MENU_SHORTCUT_CHARACTER in text: + text = text.replace(MENU_SHORTCUT_CHARACTER, '') + if text.startswith(MENU_DISABLED_CHARACTER): + disabled = True + text = text[len(MENU_DISABLED_CHARACTER):] + else: + disabled = False + + button_menu = ButtonMenu(text, menu, border_width=0, button_color=(bar_text, bar_bg), key=text, pad=(0, 0), disabled=disabled, font=bar_font, + item_font=font, disabled_text_color=disabled_text_color, text_color=menu_text, background_color=menu_bg, tearoff=tearoff) + button_menu.part_of_custom_menubar = True + button_menu.custom_menubar_key = key if key is not None else k + row += [button_menu] + return Column([row], pad=pad, background_color=bar_bg, expand_x=True, key=key if key is not None else k) + + +# ------------------------- FOLDER BROWSE Element lazy function ------------------------- # +def FolderBrowse(button_text='Browse', target=(ThisRow, -1), initial_folder=None, tooltip=None, size=(None, None), s=(None, None), + auto_size_button=None, button_color=None, disabled=False, change_submits=False, enable_events=False, + font=None, pad=None, p=None, key=None, k=None, visible=True, metadata=None, expand_x=False, expand_y=False): + """ + :param button_text: text in the button (Default value = 'Browse') + :type button_text: (str) + :param target: target for the button (Default value = (ThisRow, -1)) + :type target: str | (int, int) + :param initial_folder: starting path for folders and files + :type initial_folder: (str) + :param tooltip: text, that will appear when mouse hovers over the element + :type tooltip: (str) + :param size: (w,h) w=characters-wide, h=rows-high + :type size: (int, int) + :param s: Same as size parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, size will be used + :type s: (int, int) | (None, None) | int + :param auto_size_button: True if button size is determined by button text + :type auto_size_button: (bool) + :param button_color: button color (foreground, background) + :type button_color: (str, str) | str + :param disabled: set disable state for element (Default = False) + :type disabled: (bool) + :param change_submits: If True, pressing Enter key submits window (Default = False) + :type enable_events: (bool) + :param enable_events: Turns on the element specific events.(Default = False) + :type enable_events: (bool) + :param font: specifies the font family, size, etc. Tuple or Single string format 'name size styles'. Styles: italic * roman bold normal underline overstrike + :type font: (str or (str, int[, str]) or None) + :param pad: Amount of padding to put around element in pixels (left/right, top/bottom) or ((left, right), (top, bottom)) or an int. If an int, then it's converted into a tuple (int, int) + :type pad: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param p: Same as pad parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, pad will be used + :type p: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param key: Used with window.find_element and with return values to uniquely identify this element + :type key: str | int | tuple | object + :param k: Same as the Key. You can use either k or key. Which ever is set will be used. + :type k: str | int | tuple | object + :param visible: set initial visibility state of the Button + :type visible: (bool) + :param metadata: Anything you want to store along with this button + :type metadata: (Any) + :param expand_x: If True Element will expand in the Horizontal directions + :type expand_x: (bool) + :param expand_y: If True Element will expand in the Vertical directions + :type expand_y: (bool) + :return: The Button created + :rtype: (Button) + """ + + return Button(button_text=button_text, button_type=BUTTON_TYPE_BROWSE_FOLDER, target=target, + initial_folder=initial_folder, tooltip=tooltip, size=size, s=s, auto_size_button=auto_size_button, + disabled=disabled, button_color=button_color, change_submits=change_submits, + enable_events=enable_events, font=font, pad=pad, p=p, key=key, k=k, visible=visible, metadata=metadata, expand_x=expand_x, expand_y=expand_y) + + +# ------------------------- FILE BROWSE Element lazy function ------------------------- # +def FileBrowse(button_text='Browse', target=(ThisRow, -1), file_types=FILE_TYPES_ALL_FILES, initial_folder=None, + tooltip=None, size=(None, None), s=(None, None), auto_size_button=None, button_color=None, change_submits=False, + enable_events=False, font=None, disabled=False, + pad=None, p=None, key=None, k=None, visible=True, metadata=None, expand_x=False, expand_y=False): + """ + + :param button_text: text in the button (Default value = 'Browse') + :type button_text: (str) + :param target: key or (row,col) target for the button (Default value = (ThisRow, -1)) + :type target: str | (int, int) + :param file_types: filter file types Default value = (("ALL Files", "*.* *"),). + :type file_types: Tuple[(str, str), ...] + :param initial_folder: starting path for folders and files + :type initial_folder: + :param tooltip: text, that will appear when mouse hovers over the element + :type tooltip: (str) + :param size: (w,h) w=characters-wide, h=rows-high + :type size: (int, int) + :param s: Same as size parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, size will be used + :type s: (int, int) | (None, None) | int + :param auto_size_button: True if button size is determined by button text + :type auto_size_button: (bool) + :param button_color: button color (foreground, background) + :type button_color: (str, str) | str + :param change_submits: If True, pressing Enter key submits window (Default = False) + :type change_submits: (bool) + :param enable_events: Turns on the element specific events.(Default = False) + :type enable_events: (bool) + :param font: specifies the font family, size, etc. Tuple or Single string format 'name size styles'. Styles: italic * roman bold normal underline overstrike + :type font: (str or (str, int[, str]) or None) + :param disabled: set disable state for element (Default = False) + :type disabled: (bool) + :param pad: Amount of padding to put around element in pixels (left/right, top/bottom) or ((left, right), (top, bottom)) or an int. If an int, then it's converted into a tuple (int, int) + :type pad: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param p: Same as pad parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, pad will be used + :type p: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param key: key for uniquely identify this element (for window.find_element) + :type key: str | int | tuple | object + :param k: Same as the Key. You can use either k or key. Which ever is set will be used. + :type k: str | int | tuple | object + :param visible: set initial visibility state of the Button + :type visible: (bool) + :param metadata: Anything you want to store along with this button + :type metadata: (Any) + :param expand_x: If True Element will expand in the Horizontal directions + :type expand_x: (bool) + :param expand_y: If True Element will expand in the Vertical directions + :type expand_y: (bool) + :return: returns a button + :rtype: (Button) + """ + return Button(button_text=button_text, button_type=BUTTON_TYPE_BROWSE_FILE, target=target, file_types=file_types, + initial_folder=initial_folder, tooltip=tooltip, size=size, s=s, auto_size_button=auto_size_button, + change_submits=change_submits, enable_events=enable_events, disabled=disabled, + button_color=button_color, font=font, pad=pad, p=p, key=key, k=k, visible=visible, metadata=metadata, expand_x=expand_x, expand_y=expand_y) + + +# ------------------------- FILES BROWSE Element (Multiple file selection) lazy function ------------------------- # +def FilesBrowse(button_text='Browse', target=(ThisRow, -1), file_types=FILE_TYPES_ALL_FILES, disabled=False, + initial_folder=None, tooltip=None, size=(None, None), s=(None, None), auto_size_button=None, button_color=None, + change_submits=False, enable_events=False, + font=None, pad=None, p=None, key=None, k=None, visible=True, files_delimiter=BROWSE_FILES_DELIMITER, metadata=None, expand_x=False, expand_y=False): + """ + Allows browsing of multiple files. File list is returned as a single list with the delimiter defined using the files_delimiter parameter. + + :param button_text: text in the button (Default value = 'Browse') + :type button_text: (str) + :param target: key or (row,col) target for the button (Default value = (ThisRow, -1)) + :type target: str | (int, int) + :param file_types: Default value = (("ALL Files", "*.* *"),). + :type file_types: Tuple[(str, str), ...] + :param disabled: set disable state for element (Default = False) + :type disabled: (bool) + :param initial_folder: starting path for folders and files + :type initial_folder: (str) + :param tooltip: text, that will appear when mouse hovers over the element + :type tooltip: (str) + :param size: (w,h) w=characters-wide, h=rows-high + :type size: (int, int) + :param s: Same as size parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, size will be used + :type s: (int, int) | (None, None) | int + :param auto_size_button: True if button size is determined by button text + :type auto_size_button: (bool) + :param button_color: button color (foreground, background) + :type button_color: (str, str) | str + :param change_submits: If True, pressing Enter key submits window (Default = False) + :type change_submits: (bool) + :param enable_events: Turns on the element specific events.(Default = False) + :type enable_events: (bool) + :param font: specifies the font family, size, etc. Tuple or Single string format 'name size styles'. Styles: italic * roman bold normal underline overstrike + :type font: (str or (str, int[, str]) or None) + :param pad: Amount of padding to put around element in pixels (left/right, top/bottom) or ((left, right), (top, bottom)) or an int. If an int, then it's converted into a tuple (int, int) + :type pad: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param p: Same as pad parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, pad will be used + :type p: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param key: key for uniquely identify this element (for window.find_element) + :type key: str | int | tuple | object + :param k: Same as the Key. You can use either k or key. Which ever is set will be used. + :type k: str | int | tuple | object + :param visible: set initial visibility state of the Button + :type visible: (bool) + :param files_delimiter: String to place between files when multiple files are selected. Normally a ; + :type files_delimiter: str + :param metadata: Anything you want to store along with this button + :type metadata: (Any) + :param expand_x: If True Element will expand in the Horizontal directions + :type expand_x: (bool) + :param expand_y: If True Element will expand in the Vertical directions + :type expand_y: (bool) + :return: returns a button + :rtype: (Button) + """ + button = Button(button_text=button_text, button_type=BUTTON_TYPE_BROWSE_FILES, target=target, file_types=file_types, + initial_folder=initial_folder, change_submits=change_submits, enable_events=enable_events, + tooltip=tooltip, size=size, s=s, auto_size_button=auto_size_button, + disabled=disabled, button_color=button_color, font=font, pad=pad, p=p, key=key, k=k, visible=visible, metadata=metadata, expand_x=expand_x, expand_y=expand_y) + button._files_delimiter = files_delimiter + return button + + +# ------------------------- FILE BROWSE Element lazy function ------------------------- # +def FileSaveAs(button_text='Save As...', target=(ThisRow, -1), file_types=FILE_TYPES_ALL_FILES, initial_folder=None, + default_extension='', disabled=False, tooltip=None, size=(None, None), s=(None, None), auto_size_button=None, button_color=None, + change_submits=False, enable_events=False, font=None, + pad=None, p=None, key=None, k=None, visible=True, metadata=None, expand_x=False, expand_y=False): + """ + + :param button_text: text in the button (Default value = 'Save As...') + :type button_text: (str) + :param target: key or (row,col) target for the button (Default value = (ThisRow, -1)) + :type target: str | (int, int) + :param file_types: Default value = (("ALL Files", "*.* *"),). + :type file_types: Tuple[(str, str), ...] + :param default_extension: If no extension entered by user, add this to filename (only used in saveas dialogs) + :type default_extension: (str) + :param initial_folder: starting path for folders and files + :type initial_folder: (str) + :param disabled: set disable state for element (Default = False) + :type disabled: (bool) + :param tooltip: text, that will appear when mouse hovers over the element + :type tooltip: (str) + :param size: (w,h) w=characters-wide, h=rows-high + :type size: (int, int) + :param s: Same as size parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, size will be used + :type s: (int, int) | (None, None) | int + :param auto_size_button: True if button size is determined by button text + :type auto_size_button: (bool) + :param button_color: button color (foreground, background) + :type button_color: (str, str) | str + :param change_submits: If True, pressing Enter key submits window (Default = False) + :type change_submits: (bool) + :param enable_events: Turns on the element specific events.(Default = False) + :type enable_events: (bool) + :param font: specifies the font family, size, etc. Tuple or Single string format 'name size styles'. Styles: italic * roman bold normal underline overstrike + :type font: (str or (str, int[, str]) or None) + :param pad: Amount of padding to put around element in pixels (left/right, top/bottom) or ((left, right), (top, bottom)) or an int. If an int, then it's converted into a tuple (int, int) + :type pad: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param p: Same as pad parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, pad will be used + :type p: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param key: key for uniquely identify this element (for window.find_element) + :type key: str | int | tuple | object + :param k: Same as the Key. You can use either k or key. Which ever is set will be used. + :type k: str | int | tuple | object + :param visible: set initial visibility state of the Button + :type visible: (bool) + :param metadata: Anything you want to store along with this button + :type metadata: (Any) + :param expand_x: If True Element will expand in the Horizontal directions + :type expand_x: (bool) + :param expand_y: If True Element will expand in the Vertical directions + :type expand_y: (bool) :return: returns a button + :rtype: (Button) + """ + return Button(button_text=button_text, button_type=BUTTON_TYPE_SAVEAS_FILE, target=target, file_types=file_types, + initial_folder=initial_folder, default_extension=default_extension, tooltip=tooltip, size=size, s=s, disabled=disabled, + auto_size_button=auto_size_button, button_color=button_color, change_submits=change_submits, + enable_events=enable_events, font=font, pad=pad, p=p, key=key, k=k, visible=visible, metadata=metadata, expand_x=expand_x, expand_y=expand_y) + + +# ------------------------- SAVE AS Element lazy function ------------------------- # +def SaveAs(button_text='Save As...', target=(ThisRow, -1), file_types=FILE_TYPES_ALL_FILES, initial_folder=None, default_extension='', + disabled=False, tooltip=None, size=(None, None), s=(None, None), auto_size_button=None, button_color=None, + change_submits=False, enable_events=False, font=None, + pad=None, p=None, key=None, k=None, visible=True, metadata=None, expand_x=False, expand_y=False): + """ + + :param button_text: text in the button (Default value = 'Save As...') + :type button_text: (str) + :param target: key or (row,col) target for the button (Default value = (ThisRow, -1)) + :type target: str | (int, int) + :param file_types: Default value = (("ALL Files", "*.* *"),). + :type file_types: Tuple[(str, str), ...] + :param default_extension: If no extension entered by user, add this to filename (only used in saveas dialogs) + :type default_extension: (str) + :param initial_folder: starting path for folders and files + :type initial_folder: (str) + :param disabled: set disable state for element (Default = False) + :type disabled: (bool) + :param tooltip: text, that will appear when mouse hovers over the element + :type tooltip: (str) + :param size: (w,h) w=characters-wide, h=rows-high + :type size: (int, int) + :param s: Same as size parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, size will be used + :type s: (int, int) | (None, None) | int + :param auto_size_button: True if button size is determined by button text + :type auto_size_button: (bool) + :param button_color: button color (foreground, background) + :type button_color: (str, str) or str + :param change_submits: If True, pressing Enter key submits window (Default = False) + :type change_submits: (bool) + :param enable_events: Turns on the element specific events.(Default = False) + :type enable_events: (bool) + :param font: specifies the font family, size, etc. Tuple or Single string format 'name size styles'. Styles: italic * roman bold normal underline overstrike + :type font: (str or (str, int[, str]) or None) + :param pad: Amount of padding to put around element in pixels (left/right, top/bottom) or ((left, right), (top, bottom)) or an int. If an int, then it's converted into a tuple (int, int) + :type pad: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param p: Same as pad parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, pad will be used + :type p: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int :param key: key for uniquely identify this element (for window.find_element) + :type key: str | int | tuple | object + :param k: Same as the Key. You can use either k or key. Which ever is set will be used. + :type k: str | int | tuple | object + :param visible: set initial visibility state of the Button + :type visible: (bool) + :param metadata: Anything you want to store along with this button + :type metadata: (Any) + :param expand_x: If True Element will expand in the Horizontal directions + :type expand_x: (bool) + :param expand_y: If True Element will expand in the Vertical directions + :type expand_y: (bool) + :return: returns a button + :rtype: (Button) + """ + return Button(button_text=button_text, button_type=BUTTON_TYPE_SAVEAS_FILE, target=target, file_types=file_types, + initial_folder=initial_folder, default_extension=default_extension, tooltip=tooltip, size=size, s=s, disabled=disabled, + auto_size_button=auto_size_button, button_color=button_color, change_submits=change_submits, + enable_events=enable_events, font=font, pad=pad, p=p, key=key, k=k, visible=visible, metadata=metadata, expand_x=expand_x, expand_y=expand_y) + + +# ------------------------- SAVE BUTTON Element lazy function ------------------------- # +def Save(button_text='Save', size=(None, None), s=(None, None), auto_size_button=None, button_color=None, bind_return_key=True, + disabled=False, tooltip=None, font=None, focus=False, pad=None, p=None, key=None, k=None, visible=True, metadata=None, expand_x=False, expand_y=False): + """ + + :param button_text: text in the button (Default value = 'Save') + :type button_text: (str) + :param size: (w,h) w=characters-wide, h=rows-high + :type size: (int, int) + :param s: Same as size parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, size will be used + :type s: (int, int) | (None, None) | int + :param auto_size_button: True if button size is determined by button text + :type auto_size_button: (bool) + :param button_color: button color (foreground, background) + :type button_color: (str, str) | str + :param bind_return_key: (Default = True) If True, this button will appear to be clicked when return key is pressed in other elements such as Input and elements with return key options + :type bind_return_key: (bool) + :param disabled: set disable state for element (Default = False) + :type disabled: (bool) + :param tooltip: text, that will appear when mouse hovers over the element + :type tooltip: (str) + :param font: specifies the font family, size, etc. Tuple or Single string format 'name size styles'. Styles: italic * roman bold normal underline overstrike + :type font: (str or (str, int[, str]) or None) + :param focus: if focus should be set to this + :type focus: idk_yetReally + :param pad: Amount of padding to put around element in pixels (left/right, top/bottom) or ((left, right), (top, bottom)) or an int. If an int, then it's converted into a tuple (int, int) + :type pad: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param p: Same as pad parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, pad will be used + :type p: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param key: key for uniquely identify this element (for window.find_element) + :type key: str | int | tuple | object + :param k: Same as the Key. You can use either k or key. Which ever is set will be used. + :type k: str | int | tuple | object + :param visible: set initial visibility state of the Button + :type visible: (bool) + :param metadata: Anything you want to store along with this button + :type metadata: (Any) + :param expand_x: If True Element will expand in the Horizontal directions + :type expand_x: (bool) + :param expand_y: If True Element will expand in the Vertical directions + :type expand_y: (bool) + :return: returns a button + :rtype: (Button) + """ + return Button(button_text=button_text, button_type=BUTTON_TYPE_READ_FORM, tooltip=tooltip, size=size, s=s, + auto_size_button=auto_size_button, button_color=button_color, font=font, disabled=disabled, + bind_return_key=bind_return_key, focus=focus, pad=pad, p=p, key=key, k=k, visible=visible, metadata=metadata, expand_x=expand_x, expand_y=expand_y) + + +# ------------------------- SUBMIT BUTTON Element lazy function ------------------------- # +def Submit(button_text='Submit', size=(None, None), s=(None, None), auto_size_button=None, button_color=None, disabled=False, + bind_return_key=True, tooltip=None, font=None, focus=False, pad=None, p=None, key=None, k=None, visible=True, metadata=None, expand_x=False, expand_y=False): + """ + + :param button_text: text in the button (Default value = 'Submit') + :type button_text: (str) + :param size: (w,h) w=characters-wide, h=rows-high + :type size: (int, int) + :param s: Same as size parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, size will be used + :type s: (int, int) | (None, None) | int + :param auto_size_button: True if button size is determined by button text + :type auto_size_button: (bool) + :param button_color: button color (foreground, background) + :type button_color: (str, str) | str + :param disabled: set disable state for element (Default = False) + :type disabled: (bool) + :param bind_return_key: (Default = True) If True, this button will appear to be clicked when return key is pressed in other elements such as Input and elements with return key options + :type bind_return_key: (bool) + :param tooltip: text, that will appear when mouse hovers over the element + :type tooltip: (str) + :param font: specifies the font family, size, etc. Tuple or Single string format 'name size styles'. Styles: italic * roman bold normal underline overstrike + :type font: (str or (str, int[, str]) or None) + :param focus: if focus should be set to this + :type focus: idk_yetReally + :param pad: Amount of padding to put around element in pixels (left/right, top/bottom) or ((left, right), (top, bottom)) or an int. If an int, then it's converted into a tuple (int, int) + :type pad: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param p: Same as pad parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, pad will be used + :type p: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param key: key for uniquely identify this element (for window.find_element) + :type key: str | int | tuple | object + :param k: Same as the Key. You can use either k or key. Which ever is set will be used. + :type k: str | int | tuple | object + :param visible: set initial visibility state of the Button + :type visible: (bool) + :param metadata: Anything you want to store along with this button + :type metadata: (Any) + :param expand_x: If True Element will expand in the Horizontal directions + :type expand_x: (bool) + :param expand_y: If True Element will expand in the Vertical directions + :type expand_y: (bool) + :return: returns a button + :rtype: (Button) + """ + return Button(button_text=button_text, button_type=BUTTON_TYPE_READ_FORM, tooltip=tooltip, size=size, s=s, + auto_size_button=auto_size_button, button_color=button_color, font=font, disabled=disabled, + bind_return_key=bind_return_key, focus=focus, pad=pad, p=p, key=key, k=k, visible=visible, metadata=metadata, expand_x=expand_x, expand_y=expand_y) + + +# ------------------------- OPEN BUTTON Element lazy function ------------------------- # +# ------------------------- OPEN BUTTON Element lazy function ------------------------- # +def Open(button_text='Open', size=(None, None), s=(None, None), auto_size_button=None, button_color=None, disabled=False, + bind_return_key=True, tooltip=None, font=None, focus=False, pad=None, p=None, key=None, k=None, visible=True, metadata=None, expand_x=False, expand_y=False): + """ + + :param button_text: text in the button (Default value = 'Open') + :type button_text: (str) + :param size: (w,h) w=characters-wide, h=rows-high + :type size: (int, int) + :param s: Same as size parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, size will be used + :type s: (int, int) | (None, None) | int + :param auto_size_button: True if button size is determined by button text + :type auto_size_button: (bool) + :param button_color: button color (foreground, background) + :type button_color: (str, str) | str + :param disabled: set disable state for element (Default = False) + :type disabled: (bool) + :param bind_return_key: (Default = True) If True, this button will appear to be clicked when return key is pressed in other elements such as Input and elements with return key options + :type bind_return_key: (bool) + :param tooltip: text, that will appear when mouse hovers over the element + :type tooltip: (str) + :param font: specifies the font family, size, etc. Tuple or Single string format 'name size styles'. Styles: italic * roman bold normal underline overstrike + :type font: (str or (str, int[, str]) or None) + :param focus: if focus should be set to this + :type focus: idk_yetReally + :param pad: Amount of padding to put around element in pixels (left/right, top/bottom) or ((left, right), (top, bottom)) or an int. If an int, then it's converted into a tuple (int, int) + :type pad: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param p: Same as pad parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, pad will be used + :type p: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param key: key for uniquely identify this element (for window.find_element) + :type key: str | int | tuple | object + :param k: Same as the Key. You can use either k or key. Which ever is set will be used. + :type k: str | int | tuple | object + :param visible: set initial visibility state of the Button + :type visible: (bool) + :param metadata: Anything you want to store along with this button + :type metadata: (Any) + :param expand_x: If True Element will expand in the Horizontal directions + :type expand_x: (bool) + :param expand_y: If True Element will expand in the Vertical directions + :type expand_y: (bool) + :return: returns a button + :rtype: (Button) + """ + return Button(button_text=button_text, button_type=BUTTON_TYPE_READ_FORM, tooltip=tooltip, size=size, s=s, + auto_size_button=auto_size_button, button_color=button_color, font=font, disabled=disabled, + bind_return_key=bind_return_key, focus=focus, pad=pad, p=p, key=key, k=k, visible=visible, metadata=metadata, expand_x=expand_x, expand_y=expand_y) + + +# ------------------------- OK BUTTON Element lazy function ------------------------- # +def OK(button_text='OK', size=(None, None), s=(None, None), auto_size_button=None, button_color=None, disabled=False, + bind_return_key=True, tooltip=None, font=None, focus=False, pad=None, p=None, key=None, k=None, visible=True, metadata=None, expand_x=False, expand_y=False): + """ + + :param button_text: text in the button (Default value = 'OK') + :type button_text: (str) + :param size: (w,h) w=characters-wide, h=rows-high + :type size: (int, int) + :param s: Same as size parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, size will be used + :type s: (int, int) | (None, None) | int + :param auto_size_button: True if button size is determined by button text + :type auto_size_button: (bool) + :param button_color: button color (foreground, background) + :type button_color: (str, str) | str + :param disabled: set disable state for element (Default = False) + :type disabled: (bool) + :param bind_return_key: (Default = True) If True, this button will appear to be clicked when return key is pressed in other elements such as Input and elements with return key options + :type bind_return_key: (bool) + :param tooltip: text, that will appear when mouse hovers over the element + :type tooltip: (str) + :param font: specifies the font family, size, etc. Tuple or Single string format 'name size styles'. Styles: italic * roman bold normal underline overstrike + :type font: (str or (str, int[, str]) or None) + :param focus: if focus should be set to this + :type focus: idk_yetReally + :param pad: Amount of padding to put around element in pixels (left/right, top/bottom) or ((left, right), (top, bottom)) or an int. If an int, then it's converted into a tuple (int, int) + :type pad: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param p: Same as pad parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, pad will be used + :type p: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param key: key for uniquely identify this element (for window.find_element) + :type key: str | int | tuple | object + :param k: Same as the Key. You can use either k or key. Which ever is set will be used. + :type k: str | int | tuple | object + :param visible: set initial visibility state of the Button + :type visible: (bool) + :param metadata: Anything you want to store along with this button + :type metadata: (Any) + :param expand_x: If True Element will expand in the Horizontal directions + :type expand_x: (bool) + :param expand_y: If True Element will expand in the Vertical directions + :type expand_y: (bool) + :return: returns a button + :rtype: (Button) + """ + return Button(button_text=button_text, button_type=BUTTON_TYPE_READ_FORM, tooltip=tooltip, size=size, s=s, + auto_size_button=auto_size_button, button_color=button_color, font=font, disabled=disabled, + bind_return_key=bind_return_key, focus=focus, pad=pad, p=p, key=key, k=k, visible=visible, metadata=metadata, expand_x=expand_x, expand_y=expand_y) + + +# ------------------------- YES BUTTON Element lazy function ------------------------- # +def Ok(button_text='Ok', size=(None, None), s=(None, None), auto_size_button=None, button_color=None, disabled=False, + bind_return_key=True, tooltip=None, font=None, focus=False, pad=None, p=None, key=None, k=None, visible=True, metadata=None, expand_x=False, expand_y=False): + """ + + :param button_text: text in the button (Default value = 'Ok') + :type button_text: (str) + :param size: (w,h) w=characters-wide, h=rows-high + :type size: (int, int) + :param s: Same as size parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, size will be used + :type s: (int, int) | (None, None) | int + :param auto_size_button: True if button size is determined by button text + :type auto_size_button: (bool) + :param button_color: button color (foreground, background) + :type button_color: (str, str) | str + :param disabled: set disable state for element (Default = False) + :type disabled: (bool) + :param bind_return_key: (Default = True) If True, this button will appear to be clicked when return key is pressed in other elements such as Input and elements with return key options + :type bind_return_key: (bool) + :param tooltip: text, that will appear when mouse hovers over the element + :type tooltip: (str) + :param font: specifies the font family, size, etc. Tuple or Single string format 'name size styles'. Styles: italic * roman bold normal underline overstrike + :type font: (str or (str, int[, str]) or None) + :param focus: if focus should be set to this + :type focus: idk_yetReally + :param pad: Amount of padding to put around element in pixels (left/right, top/bottom) or ((left, right), (top, bottom)) or an int. If an int, then it's converted into a tuple (int, int) + :type pad: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param p: Same as pad parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, pad will be used + :type p: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param key: key for uniquely identify this element (for window.find_element) + :type key: str | int | tuple | object + :param k: Same as the Key. You can use either k or key. Which ever is set will be used. + :type k: str | int | tuple | object + :param visible: set initial visibility state of the Button + :type visible: (bool) + :param metadata: Anything you want to store along with this button + :type metadata: (Any) + :param expand_x: If True Element will expand in the Horizontal directions + :type expand_x: (bool) + :param expand_y: If True Element will expand in the Vertical directions + :type expand_y: (bool) + :return: returns a button + :rtype: (Button) + """ + return Button(button_text=button_text, button_type=BUTTON_TYPE_READ_FORM, tooltip=tooltip, size=size, s=s, + auto_size_button=auto_size_button, button_color=button_color, font=font, disabled=disabled, + bind_return_key=bind_return_key, focus=focus, pad=pad, p=p, key=key, k=k, visible=visible, metadata=metadata, expand_x=expand_x, expand_y=expand_y) + + +# ------------------------- CANCEL BUTTON Element lazy function ------------------------- # +def Cancel(button_text='Cancel', size=(None, None), s=(None, None), auto_size_button=None, button_color=None, disabled=False, + tooltip=None, font=None, bind_return_key=False, focus=False, pad=None, p=None, key=None, k=None, visible=True, metadata=None, expand_x=False, expand_y=False): + """ + + :param button_text: text in the button (Default value = 'Cancel') + :type button_text: (str) + :param size: (w,h) w=characters-wide, h=rows-high + :type size: (int, int) + :param s: Same as size parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, size will be used + :type s: (int, int) | (None, None) | int + :param auto_size_button: True if button size is determined by button text + :type auto_size_button: (bool) + :param button_color: button color (foreground, background) + :type button_color: (str, str) | str + :param disabled: set disable state for element (Default = False) + :type disabled: (bool) + :param tooltip: text, that will appear when mouse hovers over the element + :type tooltip: (str) + :param font: specifies the font family, size, etc. Tuple or Single string format 'name size styles'. Styles: italic * roman bold normal underline overstrike + :type font: (str or (str, int[, str]) or None) + :param bind_return_key: (Default = False) If True, this button will appear to be clicked when return key is pressed in other elements such as Input and elements with return key options + :type bind_return_key: (bool) + :param focus: if focus should be set to this + :type focus: + :param pad: Amount of padding to put around element in pixels (left/right, top/bottom) or ((left, right), (top, bottom)) or an int. If an int, then it's converted into a tuple (int, int) + :type pad: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param p: Same as pad parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, pad will be used + :type p: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param key: key for uniquely identify this element (for window.find_element) + :type key: str | int | tuple | object + :param k: Same as the Key. You can use either k or key. Which ever is set will be used. + :type k: str | int | tuple | object + :param visible: set initial visibility state of the Button + :type visible: (bool) + :param metadata: Anything you want to store along with this button + :type metadata: (Any) + :param expand_x: If True Element will expand in the Horizontal directions + :type expand_x: (bool) + :param expand_y: If True Element will expand in the Vertical directions + :type expand_y: (bool) + :return: returns a button + :rtype: (Button) + """ + return Button(button_text=button_text, button_type=BUTTON_TYPE_READ_FORM, tooltip=tooltip, size=size, s=s, + auto_size_button=auto_size_button, button_color=button_color, font=font, disabled=disabled, + bind_return_key=bind_return_key, focus=focus, pad=pad, p=p, key=key, k=k, visible=visible, metadata=metadata, expand_x=expand_x, expand_y=expand_y) + + +# ------------------------- QUIT BUTTON Element lazy function ------------------------- # +def Quit(button_text='Quit', size=(None, None), s=(None, None), auto_size_button=None, button_color=None, disabled=False, tooltip=None, + font=None, bind_return_key=False, focus=False, pad=None, p=None, key=None, k=None, visible=True, metadata=None, expand_x=False, expand_y=False): + """ + + :param button_text: text in the button (Default value = 'Quit') + :type button_text: (str) + :param size: (w,h) w=characters-wide, h=rows-high + :type size: (int, int) + :param s: Same as size parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, size will be used + :type s: (int, int) | (None, None) | int + :param auto_size_button: True if button size is determined by button text + :type auto_size_button: (bool) + :param button_color: button color (foreground, background) + :type button_color: (str, str) | str + :param disabled: set disable state for element (Default = False) + :type disabled: (bool) + :param tooltip: text, that will appear when mouse hovers over the element + :type tooltip: (str) + :param font: specifies the font family, size, etc. Tuple or Single string format 'name size styles'. Styles: italic * roman bold normal underline overstrike + :type font: (str or (str, int[, str]) or None) + :param bind_return_key: (Default = False) If True, this button will appear to be clicked when return key is pressed in other elements such as Input and elements with return key options + :type bind_return_key: (bool) + :param focus: if focus should be set to this + :type focus: (bool) + :param pad: Amount of padding to put around element in pixels (left/right, top/bottom) or ((left, right), (top, bottom)) or an int. If an int, then it's converted into a tuple (int, int) + :type pad: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param p: Same as pad parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, pad will be used + :type p: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param key: key for uniquely identify this element (for window.find_element) + :type key: str | int | tuple | object + :param k: Same as the Key. You can use either k or key. Which ever is set will be used. + :type k: str | int | tuple | object + :param visible: set initial visibility state of the Button + :type visible: (bool) + :param metadata: Anything you want to store along with this button + :type metadata: (Any) + :param expand_x: If True Element will expand in the Horizontal directions + :type expand_x: (bool) + :param expand_y: If True Element will expand in the Vertical directions + :type expand_y: (bool) + :return: returns a button + :rtype: (Button) + """ + return Button(button_text=button_text, button_type=BUTTON_TYPE_READ_FORM, tooltip=tooltip, size=size, s=s, + auto_size_button=auto_size_button, button_color=button_color, font=font, disabled=disabled, + bind_return_key=bind_return_key, focus=focus, pad=pad, p=p, key=key, k=k, visible=visible, metadata=metadata, expand_x=expand_x, expand_y=expand_y) + + +# ------------------------- Exit BUTTON Element lazy function ------------------------- # +def Exit(button_text='Exit', size=(None, None), s=(None, None), auto_size_button=None, button_color=None, disabled=False, tooltip=None, + font=None, bind_return_key=False, focus=False, pad=None, p=None, key=None, k=None, visible=True, metadata=None, expand_x=False, expand_y=False): + """ + + :param button_text: text in the button (Default value = 'Exit') + :type button_text: (str) + :param size: (w,h) w=characters-wide, h=rows-high + :type size: (int, int) + :param s: Same as size parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, size will be used + :type s: (int, int) | (None, None) | int + :param auto_size_button: True if button size is determined by button text + :type auto_size_button: (bool) + :param button_color: button color (foreground, background) + :type button_color: (str, str) | str + :param disabled: set disable state for element (Default = False) + :type disabled: (bool) + :param tooltip: text, that will appear when mouse hovers over the element + :type tooltip: (str) + :param font: specifies the font family, size, etc. Tuple or Single string format 'name size styles'. Styles: italic * roman bold normal underline overstrike + :type font: (str or (str, int[, str]) or None) + :param bind_return_key: (Default = False) If True, this button will appear to be clicked when return key is pressed in other elements such as Input and elements with return key options + :type bind_return_key: (bool) + :param focus: if focus should be set to this + :type focus: + :param pad: Amount of padding to put around element in pixels (left/right, top/bottom) or ((left, right), (top, bottom)) or an int. If an int, then it's converted into a tuple (int, int) + :type pad: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param p: Same as pad parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, pad will be used + :type p: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param key: key for uniquely identify this element (for window.find_element) + :type key: str | int | tuple | object + :param k: Same as the Key. You can use either k or key. Which ever is set will be used. + :type k: str | int | tuple | object + :param visible: set initial visibility state of the Button + :type visible: (bool) + :param metadata: Anything you want to store along with this button + :type metadata: (Any) + :param expand_x: If True Element will expand in the Horizontal directions + :type expand_x: (bool) + :param expand_y: If True Element will expand in the Vertical directions + :type expand_y: (bool) + :return: returns a button + :rtype: (Button) + """ + return Button(button_text=button_text, button_type=BUTTON_TYPE_READ_FORM, tooltip=tooltip, size=size, s=s, + auto_size_button=auto_size_button, button_color=button_color, font=font, disabled=disabled, + bind_return_key=bind_return_key, focus=focus, pad=pad, p=p, key=key, k=k, visible=visible, metadata=metadata, expand_x=expand_x, expand_y=expand_y) + + +# ------------------------- YES BUTTON Element lazy function ------------------------- # +def Yes(button_text='Yes', size=(None, None), s=(None, None), auto_size_button=None, button_color=None, disabled=False, tooltip=None, + font=None, bind_return_key=True, focus=False, pad=None, p=None, key=None, k=None, visible=True, metadata=None, expand_x=False, expand_y=False): + """ + + :param button_text: text in the button (Default value = 'Yes') + :type button_text: (str) + :param size: (w,h) w=characters-wide, h=rows-high + :type size: (int, int) + :param s: Same as size parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, size will be used + :type s: (int, int) | (None, None) | int + :param auto_size_button: True if button size is determined by button text + :type auto_size_button: (bool) + :param button_color: button color (foreground, background) + :type button_color: (str, str) | str + :param disabled: set disable state for element (Default = False) + :type disabled: (bool) + :param tooltip: text, that will appear when mouse hovers over the element + :type tooltip: (str) + :param font: specifies the font family, size, etc. Tuple or Single string format 'name size styles'. Styles: italic * roman bold normal underline overstrike + :type font: (str or (str, int[, str]) or None) + :param bind_return_key: (Default = True) If True, this button will appear to be clicked when return key is pressed in other elements such as Input and elements with return key options + :type bind_return_key: (bool) + :param focus: if focus should be set to this + :type focus: + :param pad: Amount of padding to put around element in pixels (left/right, top/bottom) or ((left, right), (top, bottom)) or an int. If an int, then it's converted into a tuple (int, int) + :type pad: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param p: Same as pad parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, pad will be used + :type p: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param key: key for uniquely identify this element (for window.find_element) + :type key: str | int | tuple | object + :param k: Same as the Key. You can use either k or key. Which ever is set will be used. + :type k: str | int | tuple | object + :param visible: set initial visibility state of the Button + :type visible: (bool) + :param metadata: Anything you want to store along with this button + :type metadata: (Any) + :param expand_x: If True Element will expand in the Horizontal directions + :type expand_x: (bool) + :param expand_y: If True Element will expand in the Vertical directions + :type expand_y: (bool) + :return: returns a button + :rtype: (Button) + """ + return Button(button_text=button_text, button_type=BUTTON_TYPE_READ_FORM, tooltip=tooltip, size=size, s=s, + auto_size_button=auto_size_button, button_color=button_color, font=font, disabled=disabled, + bind_return_key=bind_return_key, focus=focus, pad=pad, p=p, key=key, k=k, visible=visible, metadata=metadata, expand_x=expand_x, expand_y=expand_y) + + +# ------------------------- NO BUTTON Element lazy function ------------------------- # +def No(button_text='No', size=(None, None), s=(None, None), auto_size_button=None, button_color=None, disabled=False, tooltip=None, + font=None, bind_return_key=False, focus=False, pad=None, p=None, key=None, k=None, visible=True, metadata=None, expand_x=False, expand_y=False): + """ + + :param button_text: text in the button (Default value = 'No') + :type button_text: (str) + :param size: (w,h) w=characters-wide, h=rows-high + :type size: (int, int) + :param s: Same as size parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, size will be used + :type s: (int, int) | (None, None) | int + :param auto_size_button: True if button size is determined by button text + :type auto_size_button: (bool) + :param button_color: button color (foreground, background) + :type button_color: (str, str) | str + :param disabled: set disable state for element (Default = False) + :type disabled: (bool) + :param tooltip: text, that will appear when mouse hovers over the element + :type tooltip: (str) + :param font: specifies the font family, size, etc. Tuple or Single string format 'name size styles'. Styles: italic * roman bold normal underline overstrike + :type font: (str or (str, int[, str]) or None) + :param bind_return_key: (Default = False) If True, then the return key will cause a the Listbox to generate an event + :type bind_return_key: (bool) + :param focus: if focus should be set to this + :type focus: + :param pad: Amount of padding to put around element in pixels (left/right, top/bottom) or ((left, right), (top, bottom)) or an int. If an int, then it's converted into a tuple (int, int) + :type pad: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param p: Same as pad parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, pad will be used + :type p: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param key: key for uniquely identify this element (for window.find_element) + :type key: str | int | tuple | object + :param k: Same as the Key. You can use either k or key. Which ever is set will be used. + :type k: str | int | tuple | object + :param visible: set initial visibility state of the Button + :type visible: (bool) + :param metadata: Anything you want to store along with this button + :type metadata: (Any) + :param expand_x: If True Element will expand in the Horizontal directions + :type expand_x: (bool) + :param expand_y: If True Element will expand in the Vertical directions + :type expand_y: (bool) + :return: returns a button + :rtype: (Button) + """ + return Button(button_text=button_text, button_type=BUTTON_TYPE_READ_FORM, tooltip=tooltip, size=size, s=s, + auto_size_button=auto_size_button, button_color=button_color, font=font, disabled=disabled, + bind_return_key=bind_return_key, focus=focus, pad=pad, p=p, key=key, k=k, visible=visible, metadata=metadata, expand_x=expand_x, expand_y=expand_y) + + +# ------------------------- NO BUTTON Element lazy function ------------------------- # +def Help(button_text='Help', size=(None, None), s=(None, None), auto_size_button=None, button_color=None, disabled=False, font=None, + tooltip=None, bind_return_key=False, focus=False, pad=None, p=None, key=None, k=None, visible=True, metadata=None, expand_x=False, expand_y=False): + """ + + :param button_text: text in the button (Default value = 'Help') + :type button_text: (str) + :param size: (w,h) w=characters-wide, h=rows-high + :type size: (int, int) + :param s: Same as size parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, size will be used + :type s: (int, int) | (None, None) | int + :param auto_size_button: True if button size is determined by button text + :type auto_size_button: (bool) + :param button_color: button color (foreground, background) + :type button_color: (str, str) | str + :param disabled: set disable state for element (Default = False) + :type disabled: (bool) + :param font: specifies the font family, size, etc. Tuple or Single string format 'name size styles'. Styles: italic * roman bold normal underline overstrike + :type font: (str or (str, int[, str]) or None) + :param tooltip: text, that will appear when mouse hovers over the element + :type tooltip: (str) + :param bind_return_key: (Default = False) If True, this button will appear to be clicked when return key is pressed in other elements such as Input and elements with return key options + :type bind_return_key: (bool) + :param focus: if focus should be set to this + :type focus: + :param pad: Amount of padding to put around element in pixels (left/right, top/bottom) or ((left, right), (top, bottom)) or an int. If an int, then it's converted into a tuple (int, int) + :type pad: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param p: Same as pad parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, pad will be used + :type p: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param key: key for uniquely identify this element (for window.find_element) + :type key: str | int | tuple | object + :param k: Same as the Key. You can use either k or key. Which ever is set will be used. + :type k: str | int | tuple | object + :param visible: set initial visibility state of the Button + :type visible: (bool) + :param metadata: Anything you want to store along with this button + :type metadata: (Any) + :param expand_x: If True Element will expand in the Horizontal directions + :type expand_x: (bool) + :param expand_y: If True Element will expand in the Vertical directions + :type expand_y: (bool) + :return: returns a button + :rtype: (Button) + """ + return Button(button_text=button_text, button_type=BUTTON_TYPE_READ_FORM, tooltip=tooltip, size=size, s=s, + auto_size_button=auto_size_button, button_color=button_color, font=font, disabled=disabled, + bind_return_key=bind_return_key, focus=focus, pad=pad, p=p, key=key, k=k, visible=visible, metadata=metadata, expand_x=expand_x, expand_y=expand_y) + + +# ------------------------- NO BUTTON Element lazy function ------------------------- # +def Debug(button_text='', size=(None, None), s=(None, None), auto_size_button=None, button_color=None, disabled=False, font=None, + tooltip=None, bind_return_key=False, focus=False, pad=None, p=None, key=None, k=None, visible=True, metadata=None, expand_x=False, expand_y=False): + """ + This Button has been changed in how it works!! + Your button has been replaced with a normal button that has the PySimpleGUI Debugger buggon logo on it. + In your event loop, you will need to check for the event of this button and then call: + show_debugger_popout_window() + :param button_text: text in the button (Default value = '') + :type button_text: (str) + :param size: (w,h) w=characters-wide, h=rows-high + :type size: (int, int) + :param s: Same as size parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, size will be used + :type s: (int, int) | (None, None) | int + :param auto_size_button: True if button size is determined by button text + :type auto_size_button: (bool) + :param button_color: button color (foreground, background) + :type button_color: (str, str) | str + :param disabled: set disable state for element (Default = False) + :type disabled: (bool) + :param font: specifies the font family, size, etc. Tuple or Single string format 'name size styles'. Styles: italic * roman bold normal underline overstrike + :type font: (str or (str, int[, str]) or None) + :param tooltip: text, that will appear when mouse hovers over the element + :type tooltip: (str) + :param bind_return_key: (Default = False) If True, this button will appear to be clicked when return key is pressed in other elements such as Input and elements with return key options + :type bind_return_key: (bool) + :param focus: if focus should be set to this + :type focus: + :param pad: Amount of padding to put around element in pixels (left/right, top/bottom) or ((left, right), (top, bottom)) or an int. If an int, then it's converted into a tuple (int, int) + :type pad: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param p: Same as pad parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, pad will be used + :type p: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param key: key for uniquely identify this element (for window.find_element) + :type key: str | int | tuple | object + :param k: Same as the Key. You can use either k or key. Which ever is set will be used. + :type k: str | int | tuple | object + :param visible: set initial visibility state of the Button + :type visible: (bool) + :param metadata: Anything you want to store along with this button + :type metadata: (Any) + :param expand_x: If True Element will expand in the Horizontal directions + :type expand_x: (bool) + :param expand_y: If True Element will expand in the Vertical directions + :type expand_y: (bool) + :return: returns a button + :rtype: (Button) + """ + + + user_key = key if key is not None else k if k is not None else button_text + + return Button(button_text='', button_type=BUTTON_TYPE_READ_FORM, tooltip=tooltip, size=size, s=s, + auto_size_button=auto_size_button, button_color=theme_button_color(), font=font, disabled=disabled, + bind_return_key=bind_return_key, focus=focus, pad=pad, p=p, key=user_key, k=k, visible=visible, image_data=PSG_DEBUGGER_LOGO, + image_subsample=2, border_width=0, metadata=metadata, expand_x=expand_x, expand_y=expand_y) + + +# ------------------------- GENERIC BUTTON Element lazy function ------------------------- # +def SimpleButton(button_text, image_filename=None, image_data=None, image_size=(None, None), image_subsample=None, + border_width=None, tooltip=None, size=(None, None), s=(None, None), auto_size_button=None, button_color=None, + font=None, bind_return_key=False, disabled=False, focus=False, pad=None, p=None, key=None, k=None, metadata=None, expand_x=False, expand_y=False): + """ + DEPIRCATED + + This Button should not be used. + + :param button_text: text in the button + :type button_text: (str) + :param image_filename: image filename if there is a button image + :type image_filename: image filename if there is a button image + :param image_data: in-RAM image to be displayed on button + :type image_data: in-RAM image to be displayed on button + :param image_size: image size (O.K.) + :type image_size: (Default = (None)) + :param image_subsample: amount to reduce the size of the image + :type image_subsample: amount to reduce the size of the image + :param tooltip: text, that will appear when mouse hovers over the element + :type tooltip: (str) + :param size: (w,h) w=characters-wide, h=rows-high + :type size: (int, int) + :param s: Same as size parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, size will be used + :type s: (int, int) | (None, None) | int + :param auto_size_button: True if button size is determined by button text + :type auto_size_button: (bool) + :param button_color: button color (foreground, background) + :type button_color: (str, str) | str + :param font: specifies the font family, size, etc. Tuple or Single string format 'name size styles'. Styles: italic * roman bold normal underline overstrike + :type font: (str or (str, int[, str]) or None) + :param bind_return_key: (Default = False) If True, this button will appear to be clicked when return key is pressed in other elements such as Input and elements with return key options + :type bind_return_key: (bool) + :param disabled: set disable state for element (Default = False) + :type disabled: (bool) + :param focus: if focus should be set to this + :type focus: idk_yetReally + :param pad: Amount of padding to put around element in pixels (left/right, top/bottom) or ((left, right), (top, bottom)) or an int. If an int, then it's converted into a tuple (int, int) + :type pad: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param p: Same as pad parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, pad will be used + :type p: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param key: key for uniquely identify this element (for window.find_element) + :type key: str | int | tuple | object + :param k: Same as the Key. You can use either k or key. Which ever is set will be used. + :type k: str | int | tuple | object + :param metadata: Anything you want to store along with this button + :type metadata: (Any) + :param expand_x: If True Element will expand in the Horizontal directions + :type expand_x: (bool) + :param expand_y: If True Element will expand in the Vertical directions + :type expand_y: (bool) + :return: returns a button + :rtype: (Button) + """ + return Button(button_text=button_text, button_type=BUTTON_TYPE_CLOSES_WIN, image_filename=image_filename, + image_data=image_data, image_size=image_size, image_subsample=image_subsample, + border_width=border_width, tooltip=tooltip, disabled=disabled, size=size, s=s, + auto_size_button=auto_size_button, button_color=button_color, font=font, + bind_return_key=bind_return_key, focus=focus, pad=pad, p=p, key=key, k=k, metadata=metadata, expand_x=expand_x, expand_y=expand_y) + + +# ------------------------- CLOSE BUTTON Element lazy function ------------------------- # +def CloseButton(button_text, image_filename=None, image_data=None, image_size=(None, None), image_subsample=None, + border_width=None, tooltip=None, size=(None, None), s=(None, None), auto_size_button=None, button_color=None, font=None, + bind_return_key=False, disabled=False, focus=False, pad=None, p=None, key=None, k=None, metadata=None, expand_x=False, expand_y=False): + """ + DEPRICATED + + This button should not be used. Instead explicitly close your windows by calling window.close() or by using + the close parameter in window.read + + :param button_text: text in the button + :type button_text: (str) + :param image_filename: image filename if there is a button image + :type image_filename: image filename if there is a button image + :param image_data: in-RAM image to be displayed on button + :type image_data: in-RAM image to be displayed on button + :param image_size: image size (O.K.) + :type image_size: (Default = (None)) + :param image_subsample: amount to reduce the size of the image + :type image_subsample: amount to reduce the size of the image + :param tooltip: text, that will appear when mouse hovers over the element + :type tooltip: (str) + :param size: (w,h) w=characters-wide, h=rows-high + :type size: (int, int) + :param s: Same as size parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, size will be used + :type s: (int, int) | (None, None) | int + :param auto_size_button: True if button size is determined by button text + :type auto_size_button: (bool) + :param button_color: button color (foreground, background) + :type button_color: (str, str) | str + :param font: specifies the font family, size, etc. Tuple or Single string format 'name size styles'. Styles: italic * roman bold normal underline overstrike + :type font: (str or (str, int[, str]) or None) + :param bind_return_key: (Default = False) If True, this button will appear to be clicked when return key is pressed in other elements such as Input and elements with return key options + :type bind_return_key: (bool) + :param disabled: set disable state for element (Default = False) + :type disabled: (bool) + :param focus: if focus should be set to this + :type focus: idk_yetReally + :param pad: Amount of padding to put around element in pixels (left/right, top/bottom) or ((left, right), (top, bottom)) or an int. If an int, then it's converted into a tuple (int, int) + :type pad: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param p: Same as pad parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, pad will be used + :type p: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param key: key for uniquely identify this element (for window.find_element) + :type key: str | int | tuple | object + :param k: Same as the Key. You can use either k or key. Which ever is set will be used. + :type k: str | int | tuple | object + :param metadata: Anything you want to store along with this button + :type metadata: (Any) + :param expand_x: If True Element will expand in the Horizontal directions + :type expand_x: (bool) + :param expand_y: If True Element will expand in the Vertical directions + :type expand_y: (bool) + :return: returns a button + :rtype: (Button) + """ + return Button(button_text=button_text, button_type=BUTTON_TYPE_CLOSES_WIN, image_filename=image_filename, + image_data=image_data, image_size=image_size, image_subsample=image_subsample, + border_width=border_width, tooltip=tooltip, disabled=disabled, size=size, s=s, + auto_size_button=auto_size_button, button_color=button_color, font=font, + bind_return_key=bind_return_key, focus=focus, pad=pad, p=p, key=key, k=k, metadata=metadata, expand_x=expand_x, expand_y=expand_y) + + +CButton = CloseButton + + +# ------------------------- GENERIC BUTTON Element lazy function ------------------------- # +def ReadButton(button_text, image_filename=None, image_data=None, image_size=(None, None), image_subsample=None, + border_width=None, tooltip=None, size=(None, None), s=(None, None), auto_size_button=None, button_color=None, font=None, + bind_return_key=False, disabled=False, focus=False, pad=None, p=None, key=None, k=None, metadata=None, expand_x=False, expand_y=False): + """ + :param button_text: text in the button + :type button_text: (str) + :param image_filename: image filename if there is a button image + :type image_filename: image filename if there is a button image + :param image_data: in-RAM image to be displayed on button + :type image_data: in-RAM image to be displayed on button + :param image_size: image size (O.K.) + :type image_size: (Default = (None)) + :param image_subsample: amount to reduce the size of the image + :type image_subsample: amount to reduce the size of the image + :param tooltip: text, that will appear when mouse hovers over the element + :type tooltip: (str) + :param size: (w,h) w=characters-wide, h=rows-high + :type size: (int, int) + :param s: Same as size parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, size will be used + :type s: (int, int) | (None, None) | int + :param auto_size_button: True if button size is determined by button text + :type auto_size_button: (bool) + :param button_color: button color (foreground, background) + :type button_color: (str, str) | str + :param font: specifies the font family, size, etc. Tuple or Single string format 'name size styles'. Styles: italic * roman bold normal underline overstrike + :type font: (str or (str, int[, str]) or None) + :param bind_return_key: (Default = False) If True, this button will appear to be clicked when return key is pressed in other elements such as Input and elements with return key options + :type bind_return_key: (bool) + :param disabled: set disable state for element (Default = False) + :type disabled: (bool) + :param focus: if focus should be set to this + :type focus: idk_yetReally + :param pad: Amount of padding to put around element in pixels (left/right, top/bottom) or ((left, right), (top, bottom)) or an int. If an int, then it's converted into a tuple (int, int) + :type pad: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param p: Same as pad parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, pad will be used + :type p: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param key: key for uniquely identify this element (for window.find_element) + :type key: str | int | tuple | object + :param k: Same as the Key. You can use either k or key. Which ever is set will be used. + :type k: str | int | tuple | object + :param border_width: width of border around element + :type border_width: (int) + :param metadata: Anything you want to store along with this button + :type metadata: (Any) + :param expand_x: If True Element will expand in the Horizontal directions + :type expand_x: (bool) + :param expand_y: If True Element will expand in the Vertical directions + :type expand_y: (bool) + :return: Button created + :rtype: (Button) + """ + + return Button(button_text=button_text, button_type=BUTTON_TYPE_READ_FORM, image_filename=image_filename, + image_data=image_data, image_size=image_size, image_subsample=image_subsample, + border_width=border_width, tooltip=tooltip, size=size, s=s, disabled=disabled, + auto_size_button=auto_size_button, button_color=button_color, font=font, + bind_return_key=bind_return_key, focus=focus, pad=pad, p=p, key=key, k=k, metadata=metadata, expand_x=expand_x, expand_y=expand_y) + + +ReadFormButton = ReadButton +RButton = ReadFormButton + + +# ------------------------- Realtime BUTTON Element lazy function ------------------------- # +def RealtimeButton(button_text, image_filename=None, image_data=None, image_size=(None, None), image_subsample=None, + border_width=None, tooltip=None, size=(None, None), s=(None, None), auto_size_button=None, button_color=None, + font=None, disabled=False, bind_return_key=False, focus=False, pad=None, p=None, key=None, k=None, visible=True, metadata=None, expand_x=False, expand_y=False): + """ + + :param button_text: text in the button + :type button_text: (str) + :param image_filename: image filename if there is a button image + :type image_filename: image filename if there is a button image + :param image_data: in-RAM image to be displayed on button + :type image_data: in-RAM image to be displayed on button + :param image_size: image size (O.K.) + :type image_size: (Default = (None)) + :param image_subsample: amount to reduce the size of the image + :type image_subsample: amount to reduce the size of the image + :param border_width: width of border around element + :type border_width: (int) + :param tooltip: text, that will appear when mouse hovers over the element + :type tooltip: (str) + :param size: (w,h) w=characters-wide, h=rows-high + :type size: (int, int) + :param s: Same as size parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, size will be used + :type s: (int, int) | (None, None) | int + :param auto_size_button: True if button size is determined by button text + :type auto_size_button: (bool) + :param button_color: button color (foreground, background) + :type button_color: (str, str) | str + :param font: specifies the font family, size, etc. Tuple or Single string format 'name size styles'. Styles: italic * roman bold normal underline overstrike + :type font: (str or (str, int[, str]) or None) + :param disabled: set disable state for element (Default = False) + :type disabled: (bool) + :param bind_return_key: (Default = False) If True, this button will appear to be clicked when return key is pressed in other elements such as Input and elements with return key options + :type bind_return_key: (bool) + :param focus: if focus should be set to this + :type focus: (bool) + :param pad: Amount of padding to put around element in pixels (left/right, top/bottom) or ((left, right), (top, bottom)) or an int. If an int, then it's converted into a tuple (int, int) + :type pad: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param p: Same as pad parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, pad will be used + :type p: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param key: key for uniquely identify this element (for window.find_element) + :type key: str | int | tuple | object + :param k: Same as the Key. You can use either k or key. Which ever is set will be used. + :type k: str | int | tuple | object + :param visible: set initial visibility state of the Button + :type visible: (bool) + :param metadata: Anything you want to store along with this button + :type metadata: (Any) + :param expand_x: If True Element will expand in the Horizontal directions + :type expand_x: (bool) + :param expand_y: If True Element will expand in the Vertical directions + :type expand_y: (bool) + :return: Button created + :rtype: (Button) + """ + return Button(button_text=button_text, button_type=BUTTON_TYPE_REALTIME, image_filename=image_filename, + image_data=image_data, image_size=image_size, image_subsample=image_subsample, + border_width=border_width, tooltip=tooltip, disabled=disabled, size=size, s=s, + auto_size_button=auto_size_button, button_color=button_color, font=font, + bind_return_key=bind_return_key, focus=focus, pad=pad, p=p, key=key, k=k, visible=visible, metadata=metadata, expand_x=expand_x, expand_y=expand_y) + + +# ------------------------- Dummy BUTTON Element lazy function ------------------------- # +def DummyButton(button_text, image_filename=None, image_data=None, image_size=(None, None), image_subsample=None, + border_width=None, tooltip=None, size=(None, None), s=(None, None), auto_size_button=None, button_color=None, font=None, + disabled=False, bind_return_key=False, focus=False, pad=None, p=None, key=None, k=None, visible=True, metadata=None, expand_x=False, expand_y=False): + """ + This is a special type of Button. + + It will close the window but NOT send an event that the window has been closed. + + It's used in conjunction with non-blocking windows to silently close them. They are used to + implement the non-blocking popup windows. They're also found in some Demo Programs, so look there for proper use. + + :param button_text: text in the button + :type button_text: (str) + :param image_filename: image filename if there is a button image + :type image_filename: image filename if there is a button image + :param image_data: in-RAM image to be displayed on button + :type image_data: in-RAM image to be displayed on button + :param image_size: image size (O.K.) + :type image_size: (Default = (None)) + :param image_subsample: amount to reduce the size of the image + :type image_subsample: amount to reduce the size of the image + :param border_width: width of border around element + :type border_width: (int) + :param tooltip: text, that will appear when mouse hovers over the element + :type tooltip: (str) + :param size: (w,h) w=characters-wide, h=rows-high + :type size: (int, int) + :param s: Same as size parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, size will be used + :type s: (int, int) | (None, None) | int + :param auto_size_button: True if button size is determined by button text + :type auto_size_button: (bool) + :param button_color: button color (foreground, background) + :type button_color: (str, str) | str + :param font: specifies the font family, size, etc. Tuple or Single string format 'name size styles'. Styles: italic * roman bold normal underline overstrike + :type font: (str or (str, int[, str]) or None) + :param disabled: set disable state for element (Default = False) + :type disabled: (bool) + :param bind_return_key: (Default = False) If True, this button will appear to be clicked when return key is pressed in other elements such as Input and elements with return key options + :type bind_return_key: (bool) + :param focus: if focus should be set to this + :type focus: (bool) + :param pad: Amount of padding to put around element in pixels (left/right, top/bottom) or ((left, right), (top, bottom)) or an int. If an int, then it's converted into a tuple (int, int) + :type pad: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param p: Same as pad parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, pad will be used + :type p: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param key: key for uniquely identify this element (for window.find_element) + :type key: str | int | tuple | object + :param k: Same as the Key. You can use either k or key. Which ever is set will be used. + :type k: str | int | tuple | object + :param visible: set initial visibility state of the Button + :type visible: (bool) + :param metadata: Anything you want to store along with this button + :type metadata: (Any) + :param expand_x: If True Element will expand in the Horizontal directions + :type expand_x: (bool) + :param expand_y: If True Element will expand in the Vertical directions + :type expand_y: (bool) + :return: returns a button + :rtype: (Button) + """ + return Button(button_text=button_text, button_type=BUTTON_TYPE_CLOSES_WIN_ONLY, image_filename=image_filename, + image_data=image_data, image_size=image_size, image_subsample=image_subsample, + border_width=border_width, tooltip=tooltip, size=size, s=s, auto_size_button=auto_size_button, + button_color=button_color, font=font, disabled=disabled, bind_return_key=bind_return_key, focus=focus, + pad=pad, p=p, key=key, k=k, visible=visible, metadata=metadata, expand_x=expand_x, expand_y=expand_y) + + +# ------------------------- Calendar Chooser Button lazy function ------------------------- # +def CalendarButton(button_text, target=(ThisRow, -1), close_when_date_chosen=True, default_date_m_d_y=(None, None, None), + image_filename=None, image_data=None, image_size=(None, None), + image_subsample=None, tooltip=None, border_width=None, size=(None, None), s=(None, None), auto_size_button=None, + button_color=None, disabled=False, font=None, bind_return_key=False, focus=False, pad=None, p=None, enable_events=None, + key=None, k=None, visible=True, locale=None, format='%Y-%m-%d %H:%M:%S', begin_at_sunday_plus=0, month_names=None, day_abbreviations=None, + title='Choose Date', + no_titlebar=True, location=(None, None), metadata=None, expand_x=False, expand_y=False): + """ + Button that will show a calendar chooser window. Fills in the target element with result + + :param button_text: text in the button + :type button_text: (str) + :param target: Key or "coordinate" (see docs) of target element + :type target: (int, int) | Any + :param close_when_date_chosen: (Default = True) + :type close_when_date_chosen: bool + :param default_date_m_d_y: Beginning date to show + :type default_date_m_d_y: (int, int or None, int) + :param image_filename: image filename if there is a button image + :type image_filename: image filename if there is a button image + :param image_data: in-RAM image to be displayed on button + :type image_data: in-RAM image to be displayed on button + :param image_size: image size (O.K.) + :type image_size: (Default = (None)) + :param image_subsample: amount to reduce the size of the image + :type image_subsample: amount to reduce the size of the image + :param tooltip: text, that will appear when mouse hovers over the element + :type tooltip: (str) + :param border_width: width of border around element + :type border_width: width of border around element + :param size: (w,h) w=characters-wide, h=rows-high + :type size: (int, int) + :param s: Same as size parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, size will be used + :type s: (int, int) | (None, None) | int + :param auto_size_button: True if button size is determined by button text + :type auto_size_button: (bool) + :param button_color: button color (foreground, background) + :type button_color: (str, str) | str + :param disabled: set disable state for element (Default = False) + :type disabled: (bool) + :param font: specifies the font family, size, etc. Tuple or Single string format 'name size styles'. Styles: italic * roman bold normal underline overstrike + :type font: (str or (str, int[, str]) or None) + :param bind_return_key: (Default = False) If True, this button will appear to be clicked when return key is pressed in other elements such as Input and elements with return key options + :type bind_return_key: bool + :param focus: if focus should be set to this + :type focus: bool + :param pad: Amount of padding to put around element in pixels (left/right, top/bottom) or ((left, right), (top, bottom)) or an int. If an int, then it's converted into a tuple (int, int) + :type pad: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param p: Same as pad parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, pad will be used + :type p: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param key: key for uniquely identify this element (for window.find_element) + :type key: str | int | tuple | object + :param k: Same as the Key. You can use either k or key. Which ever is set will be used. + :type k: str | int | tuple | object + :param locale: defines the locale used to get day names + :type locale: str + :param format: formats result using this strftime format + :type format: str + :param begin_at_sunday_plus: Determines the left-most day in the display. 0=sunday, 1=monday, etc + :type begin_at_sunday_plus: (int) + :param month_names: optional list of month names to use (should be 12 items) + :type month_names: List[str] + :param day_abbreviations: optional list of abbreviations to display as the day of week + :type day_abbreviations: List[str] + :param title: Title shown on the date chooser window + :type title: (str) + :param no_titlebar: if True no titlebar will be shown on the date chooser window + :type no_titlebar: bool + :param location: Location on the screen (x,y) to show the calendar popup window + :type location: (int, int) + :param visible: set initial visibility state of the Button + :type visible: (bool) + :param metadata: Anything you want to store along with this button + :type metadata: (Any) + :param expand_x: If True Element will expand in the Horizontal directions + :type expand_x: (bool) + :param expand_y: If True Element will expand in the Vertical directions + :type expand_y: (bool) + :return: returns a button + :rtype: (Button) + """ + button = Button(button_text=button_text, button_type=BUTTON_TYPE_CALENDAR_CHOOSER, target=target, + image_filename=image_filename, image_data=image_data, image_size=image_size, + image_subsample=image_subsample, border_width=border_width, tooltip=tooltip, size=size, s=s, + auto_size_button=auto_size_button, button_color=button_color, font=font, disabled=disabled, enable_events=enable_events, + bind_return_key=bind_return_key, focus=focus, pad=pad, p=p, key=key, k=k, visible=visible, metadata=metadata, expand_x=expand_x, expand_y=expand_y) + button.calendar_close_when_chosen = close_when_date_chosen + button.calendar_default_date_M_D_Y = default_date_m_d_y + button.calendar_locale = locale + button.calendar_format = format + button.calendar_no_titlebar = no_titlebar + button.calendar_location = location + button.calendar_begin_at_sunday_plus = begin_at_sunday_plus + button.calendar_month_names = month_names + button.calendar_day_abbreviations = day_abbreviations + button.calendar_title = title + + return button + + +# ------------------------- Calendar Chooser Button lazy function ------------------------- # +def ColorChooserButton(button_text, target=(ThisRow, -1), image_filename=None, image_data=None, image_size=(None, None), + image_subsample=None, tooltip=None, border_width=None, size=(None, None), s=(None, None), auto_size_button=None, + button_color=None, disabled=False, font=None, bind_return_key=False, focus=False, pad=None, p=None, + key=None, k=None, default_color=None, visible=True, metadata=None, expand_x=False, expand_y=False): + """ + + :param button_text: text in the button + :type button_text: (str) + :param target: key or (row,col) target for the button. Note that -1 for column means 1 element to the left of this one. The constant ThisRow is used to indicate the current row. The Button itself is a valid target for some types of button + :type target: str | (int, int) + :type image_filename: (str) + :param image_filename: image filename if there is a button image. GIFs and PNGs only. + :type image_filename: (str) + :param image_data: Raw or Base64 representation of the image to put on button. Choose either filename or data + :type image_data: bytes | str + :param image_size: Size of the image in pixels (width, height) + :type image_size: (int, int) + :param image_subsample: amount to reduce the size of the image. Divides the size by this number. 2=1/2, 3=1/3, 4=1/4, etc + :type image_subsample: (int) + :param tooltip: text, that will appear when mouse hovers over the element + :type tooltip: (str) + :param border_width: width of border around element + :type border_width: (int) + :param size: (w,h) w=characters-wide, h=rows-high + :type size: (int, int) + :param s: Same as size parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, size will be used + :type s: (int, int) | (None, None) | int + :param auto_size_button: True if button size is determined by button text + :type auto_size_button: (bool) + :param button_color: button color (foreground, background) + :type button_color: (str, str) | str + :param disabled: set disable state for element (Default = False) + :type disabled: (bool) + :param font: specifies the font family, size, etc. Tuple or Single string format 'name size styles'. Styles: italic * roman bold normal underline overstrike + :type font: (str or (str, int[, str]) or None) + :param bind_return_key: (Default = False) If True, this button will appear to be clicked when return key is pressed in other elements such as Input and elements with return key options + :type bind_return_key: (bool) + :param focus: Determines if initial focus should go to this element. + :type focus: (bool) + :param pad: Amount of padding to put around element in pixels (left/right, top/bottom) or ((left, right), (top, bottom)) or an int. If an int, then it's converted into a tuple (int, int) + :type pad: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param p: Same as pad parameter. It's an alias. If EITHER of them are set, then the one that's set will be used. If BOTH are set, pad will be used + :type p: (int, int) or ((int, int),(int,int)) or (int,(int,int)) or ((int, int),int) | int + :param key: key for uniquely identify this element (for window.find_element) + :type key: str | int | tuple | object + :param k: Same as the Key. You can use either k or key. Which ever is set will be used. + :type k: str | int | tuple | object + :param default_color: Color to be sent to tkinter to use as the default color + :type default_color: str + :param visible: set initial visibility state of the Button + :type visible: (bool) + :param metadata: User metadata that can be set to ANYTHING + :type metadata: (Any) + :param expand_x: If True Element will expand in the Horizontal directions + :type expand_x: (bool) + :param expand_y: If True Element will expand in the Vertical directions + :type expand_y: (bool) + :return: returns a button + :rtype: (Button) + """ + button = Button(button_text=button_text, button_type=BUTTON_TYPE_COLOR_CHOOSER, target=target, + image_filename=image_filename, image_data=image_data, image_size=image_size, + image_subsample=image_subsample, border_width=border_width, tooltip=tooltip, size=size, s=s, + auto_size_button=auto_size_button, button_color=button_color, font=font, disabled=disabled, + bind_return_key=bind_return_key, focus=focus, pad=pad, p=p, key=key, k=k, visible=visible, metadata=metadata, expand_x=expand_x, expand_y=expand_y) + button.default_color = default_color + return button + +##################################### ----- BUTTON Functions ------ ################################################## + +def button_color_to_tuple(color_tuple_or_string, default=(None, None)): + """ + Convert a color tuple or color string into 2 components and returns them as a tuple + (Text Color, Button Background Color) + If None is passed in as the first parameter, then the theme's button color is + returned + + :param color_tuple_or_string: Button color - tuple or a simplied color string with word "on" between color + :type color_tuple_or_string: str | (str, str) + :param default: The 2 colors to use if there is a problem. Otherwise defaults to the theme's button color + :type default: (str, str) + :return: (str | (str, str) + :rtype: str | (str, str) + """ + if default == (None, None): + color_tuple = _simplified_dual_color_to_tuple(color_tuple_or_string, default=theme_button_color()) + elif color_tuple_or_string == COLOR_SYSTEM_DEFAULT: + color_tuple = (COLOR_SYSTEM_DEFAULT, COLOR_SYSTEM_DEFAULT) + else: + color_tuple = _simplified_dual_color_to_tuple(color_tuple_or_string, default=default) + + return color_tuple + + +def _simplified_dual_color_to_tuple(color_tuple_or_string, default=(None, None)): + """ + Convert a color tuple or color string into 2 components and returns them as a tuple + (Text Color, Button Background Color) + If None is passed in as the first parameter, theme_ + + :param color_tuple_or_string: Button color - tuple or a simplied color string with word "on" between color + :type color_tuple_or_string: str | (str, str} | (None, None) + :param default: The 2 colors to use if there is a problem. Otherwise defaults to the theme's button color + :type default: (str, str) + :return: (str | (str, str) + :rtype: str | (str, str) + """ + if color_tuple_or_string is None or color_tuple_or_string == (None, None): + color_tuple_or_string = default + if color_tuple_or_string == COLOR_SYSTEM_DEFAULT: + return (COLOR_SYSTEM_DEFAULT, COLOR_SYSTEM_DEFAULT) + text_color = background_color = COLOR_SYSTEM_DEFAULT + try: + if isinstance(color_tuple_or_string, (tuple, list)): + if len(color_tuple_or_string) >= 2: + text_color = color_tuple_or_string[0] or default[0] + background_color = color_tuple_or_string[1] or default[1] + elif len(color_tuple_or_string) == 1: + background_color = color_tuple_or_string[0] or default[1] + elif isinstance(color_tuple_or_string, str): + color_tuple_or_string = color_tuple_or_string.lower() + split_colors = color_tuple_or_string.split(' on ') + if len(split_colors) >= 2: + text_color = split_colors[0].strip() or default[0] + background_color = split_colors[1].strip() or default[1] + elif len(split_colors) == 1: + split_colors = color_tuple_or_string.split('on') + if len(split_colors) == 1: + text_color, background_color = default[0], split_colors[0].strip() + else: + split_colors = split_colors[0].strip(), split_colors[1].strip() + text_color = split_colors[0] or default[0] + background_color = split_colors[1] or default[1] + # text_color, background_color = color_tuple_or_string, default[1] + else: + text_color, background_color = default + else: + if not SUPPRESS_ERROR_POPUPS: + _error_popup_with_traceback('** Badly formatted dual-color... not a tuple nor string **', color_tuple_or_string) + else: + print('** Badly formatted dual-color... not a tuple nor string **', color_tuple_or_string) + text_color, background_color = default + except Exception as e: + if not SUPPRESS_ERROR_POPUPS: + _error_popup_with_traceback('** Badly formatted button color **', color_tuple_or_string, e) + else: + print('** Badly formatted button color... not a tuple nor string **', color_tuple_or_string, e) + text_color, background_color = default + if isinstance(text_color, int): + text_color = "#%06X" % text_color + if isinstance(background_color, int): + background_color = "#%06X" % background_color + # print('converted button color', color_tuple_or_string, 'to', (text_color, background_color)) + + return (text_color, background_color) + + +##################################### ----- RESULTS ------ ################################################## + +def AddToReturnDictionary(form, element, value): + form.ReturnValuesDictionary[element.Key] = value + # if element.Key is None: + # form.ReturnValuesDictionary[form.DictionaryKeyCounter] = value + # element.Key = form.DictionaryKeyCounter + # form.DictionaryKeyCounter += 1 + # else: + # form.ReturnValuesDictionary[element.Key] = value + + +def AddToReturnList(form, value): + form.ReturnValuesList.append(value) + + +# ----------------------------------------------------------------------------# +# ------- FUNCTION InitializeResults. Sets up form results matrix --------# +def InitializeResults(form): + _BuildResults(form, True, form) + return + + +# ===== Radio Button RadVar encoding and decoding =====# +# ===== The value is simply the row * 1000 + col =====# +def DecodeRadioRowCol(RadValue): + container = RadValue // 100000 + row = RadValue // 1000 + col = RadValue % 1000 + return container, row, col + + +def EncodeRadioRowCol(container, row, col): + RadValue = container * 100000 + row * 1000 + col + return RadValue + + +# ------- FUNCTION BuildResults. Form exiting so build the results to pass back ------- # +# format of return values is +# (Button Pressed, input_values) +def _BuildResults(form, initialize_only, top_level_form): + # Results for elements are: + # TEXT - Nothing + # INPUT - Read value from TK + # Button - Button Text and position as a Tuple + + # Get the initialized results so we don't have to rebuild + # form.DictionaryKeyCounter = 0 + form.ReturnValuesDictionary = {} + form.ReturnValuesList = [] + _BuildResultsForSubform(form, initialize_only, top_level_form) + if not top_level_form.LastButtonClickedWasRealtime: + top_level_form.LastButtonClicked = None + return form.ReturnValues + + +def _BuildResultsForSubform(form, initialize_only, top_level_form): + event = top_level_form.LastButtonClicked + for row_num, row in enumerate(form.Rows): + for col_num, element in enumerate(row): + if element.Key is not None and WRITE_ONLY_KEY in str(element.Key): + continue + value = None + if element.Type == ELEM_TYPE_COLUMN: + element.DictionaryKeyCounter = top_level_form.DictionaryKeyCounter + element.ReturnValuesList = [] + element.ReturnValuesDictionary = {} + _BuildResultsForSubform(element, initialize_only, top_level_form) + for item in element.ReturnValuesList: + AddToReturnList(top_level_form, item) + if element.UseDictionary: + top_level_form.UseDictionary = True + if element.ReturnValues[0] is not None: # if a button was clicked + event = element.ReturnValues[0] + + if element.Type == ELEM_TYPE_FRAME: + element.DictionaryKeyCounter = top_level_form.DictionaryKeyCounter + element.ReturnValuesList = [] + element.ReturnValuesDictionary = {} + _BuildResultsForSubform(element, initialize_only, top_level_form) + for item in element.ReturnValuesList: + AddToReturnList(top_level_form, item) + if element.UseDictionary: + top_level_form.UseDictionary = True + if element.ReturnValues[0] is not None: # if a button was clicked + event = element.ReturnValues[0] + + if element.Type == ELEM_TYPE_PANE: + element.DictionaryKeyCounter = top_level_form.DictionaryKeyCounter + element.ReturnValuesList = [] + element.ReturnValuesDictionary = {} + _BuildResultsForSubform(element, initialize_only, top_level_form) + for item in element.ReturnValuesList: + AddToReturnList(top_level_form, item) + if element.UseDictionary: + top_level_form.UseDictionary = True + if element.ReturnValues[0] is not None: # if a button was clicked + event = element.ReturnValues[0] + + if element.Type == ELEM_TYPE_TAB_GROUP: + element.DictionaryKeyCounter = top_level_form.DictionaryKeyCounter + element.ReturnValuesList = [] + element.ReturnValuesDictionary = {} + _BuildResultsForSubform(element, initialize_only, top_level_form) + for item in element.ReturnValuesList: + AddToReturnList(top_level_form, item) + if element.UseDictionary: + top_level_form.UseDictionary = True + if element.ReturnValues[0] is not None: # if a button was clicked + event = element.ReturnValues[0] + + if element.Type == ELEM_TYPE_TAB: + element.DictionaryKeyCounter = top_level_form.DictionaryKeyCounter + element.ReturnValuesList = [] + element.ReturnValuesDictionary = {} + _BuildResultsForSubform(element, initialize_only, top_level_form) + for item in element.ReturnValuesList: + AddToReturnList(top_level_form, item) + if element.UseDictionary: + top_level_form.UseDictionary = True + if element.ReturnValues[0] is not None: # if a button was clicked + event = element.ReturnValues[0] + + if not initialize_only: + if element.Type == ELEM_TYPE_INPUT_TEXT: + try: + value = element.TKStringVar.get() + except: + value = '' + if not top_level_form.NonBlocking and not element.do_not_clear and not top_level_form.ReturnKeyboardEvents: + element.TKStringVar.set('') + elif element.Type == ELEM_TYPE_INPUT_CHECKBOX: + value = element.TKIntVar.get() + value = (value != 0) + elif element.Type == ELEM_TYPE_INPUT_RADIO: + RadVar = element.TKIntVar.get() + this_rowcol = EncodeRadioRowCol(form.ContainerElemementNumber, row_num, col_num) + # this_rowcol = element.EncodedRadioValue # could use the saved one + value = RadVar == this_rowcol + elif element.Type == ELEM_TYPE_BUTTON: + if top_level_form.LastButtonClicked == element.Key: + event = top_level_form.LastButtonClicked + if element.BType != BUTTON_TYPE_REALTIME: # Do not clear realtime buttons + top_level_form.LastButtonClicked = None + if element.BType == BUTTON_TYPE_CALENDAR_CHOOSER: + # value = None + value = element.calendar_selection + else: + try: + value = element.TKStringVar.get() + except: + value = None + elif element.Type == ELEM_TYPE_INPUT_COMBO: + element = element # type: Combo + # value = element.TKStringVar.get() + try: + if element.TKCombo.current() == -1: # if the current value was not in the original list + value = element.TKCombo.get() + else: + value = element.Values[element.TKCombo.current()] # get value from original list given index + except: + value = '*Exception occurred*' + elif element.Type == ELEM_TYPE_INPUT_OPTION_MENU: + value = element.TKStringVar.get() + elif element.Type == ELEM_TYPE_INPUT_LISTBOX: + try: + items = element.TKListbox.curselection() + value = [element.Values[int(item)] for item in items] + except Exception as e: + value = '' + elif element.Type == ELEM_TYPE_INPUT_SPIN: + try: + value = element.TKStringVar.get() + for v in element.Values: + if str(v) == value: + value = v + break + except: + value = 0 + elif element.Type == ELEM_TYPE_INPUT_SLIDER: + try: + value = float(element.TKScale.get()) + except: + value = 0 + elif element.Type == ELEM_TYPE_INPUT_MULTILINE: + if element.WriteOnly: # if marked as "write only" when created, then don't include with the values being returned + continue + try: + value = element.TKText.get(1.0, tk.END) + if element.rstrip: + value = value.rstrip() + if not top_level_form.NonBlocking and not element.do_not_clear and not top_level_form.ReturnKeyboardEvents: + element.TKText.delete('1.0', tk.END) + except: + value = None + elif element.Type == ELEM_TYPE_TAB_GROUP: + try: + value = element.TKNotebook.tab(element.TKNotebook.index('current'))['text'] + tab_key = element.find_currently_active_tab_key() + # tab_key = element.FindKeyFromTabName(value) + if tab_key is not None: + value = tab_key + except: + value = None + elif element.Type == ELEM_TYPE_TABLE: + value = element.SelectedRows + elif element.Type == ELEM_TYPE_TREE: + value = element.SelectedRows + elif element.Type == ELEM_TYPE_GRAPH: + value = element.ClickPosition + elif element.Type == ELEM_TYPE_MENUBAR: + if element.MenuItemChosen is not None: + event = top_level_form.LastButtonClicked = element.MenuItemChosen + value = element.MenuItemChosen + element.MenuItemChosen = None + elif element.Type == ELEM_TYPE_BUTTONMENU: + element = element # type: ButtonMenu + value = element.MenuItemChosen + if element.part_of_custom_menubar: + if element.MenuItemChosen is not None: + value = event = element.MenuItemChosen + top_level_form.LastButtonClicked = element.MenuItemChosen + if element.custom_menubar_key is not None: + top_level_form.ReturnValuesDictionary[element.custom_menubar_key] = value + element.MenuItemChosen = None + else: + if element.custom_menubar_key not in top_level_form.ReturnValuesDictionary: + top_level_form.ReturnValuesDictionary[element.custom_menubar_key] = None + value = None + + # if element.MenuItemChosen is not None: + # button_pressed_text = top_level_form.LastButtonClicked = element.MenuItemChosen + # value = element.MenuItemChosen + # element.MenuItemChosen = None + else: + value = None + + # if an input type element, update the results + if element.Type not in ( + ELEM_TYPE_BUTTON, ELEM_TYPE_TEXT, ELEM_TYPE_IMAGE, ELEM_TYPE_OUTPUT, ELEM_TYPE_PROGRESS_BAR, ELEM_TYPE_COLUMN, ELEM_TYPE_FRAME, ELEM_TYPE_SEPARATOR, + ELEM_TYPE_TAB): + if not (element.Type == ELEM_TYPE_BUTTONMENU and element.part_of_custom_menubar): + AddToReturnList(form, value) + AddToReturnDictionary(top_level_form, element, value) + elif (element.Type == ELEM_TYPE_BUTTON and + element.BType == BUTTON_TYPE_COLOR_CHOOSER and + element.Target == (None, None)) or \ + (element.Type == ELEM_TYPE_BUTTON + and element.Key is not None and + (element.BType in (BUTTON_TYPE_SAVEAS_FILE, BUTTON_TYPE_BROWSE_FILE, BUTTON_TYPE_BROWSE_FILES, + BUTTON_TYPE_BROWSE_FOLDER, BUTTON_TYPE_CALENDAR_CHOOSER))): + AddToReturnList(form, value) + AddToReturnDictionary(top_level_form, element, value) + + # if this is a column, then will fail so need to wrap with try + try: + if form.ReturnKeyboardEvents and form.LastKeyboardEvent is not None: + event = form.LastKeyboardEvent + form.LastKeyboardEvent = None + except: + pass + + try: + form.ReturnValuesDictionary.pop(None, None) # clean up dictionary include None was included + except: + pass + + # if no event was found + if not initialize_only and event is None and form == top_level_form: + queued_event_value = form._queued_thread_event_read() + if queued_event_value is not None: + event, value = queued_event_value + AddToReturnList(form, value) + form.ReturnValuesDictionary[event] = value + + if not form.UseDictionary: + form.ReturnValues = event, form.ReturnValuesList + else: + form.ReturnValues = event, form.ReturnValuesDictionary + + return form.ReturnValues + + +def fill_form_with_values(window, values_dict): + """ + Fills a window with values provided in a values dictionary { element_key : new_value } + + :param window: The window object to fill + :type window: (Window) + :param values_dict: A dictionary with element keys as key and value is values parm for Update call + :type values_dict: (Dict[Any, Any]) + :return: None + :rtype: None + """ + + for element_key in values_dict: + try: + window.AllKeysDict[element_key].Update(values_dict[element_key]) + except Exception as e: + print('Problem filling form. Perhaps bad key? This is a suspected bad key: {}'.format(element_key)) + + +def _FindElementWithFocusInSubForm(form): + """ + Searches through a "sub-form" (can be a window or container) for the current element with focus + + :param form: a Window, Column, Frame, or TabGroup (container elements) + :type form: container elements + :return: Element + :rtype: Element | None + """ + for row_num, row in enumerate(form.Rows): + for col_num, element in enumerate(row): + if element.Type == ELEM_TYPE_COLUMN: + matching_elem = _FindElementWithFocusInSubForm(element) + if matching_elem is not None: + return matching_elem + elif element.Type == ELEM_TYPE_FRAME: + matching_elem = _FindElementWithFocusInSubForm(element) + if matching_elem is not None: + return matching_elem + elif element.Type == ELEM_TYPE_TAB_GROUP: + matching_elem = _FindElementWithFocusInSubForm(element) + if matching_elem is not None: + return matching_elem + elif element.Type == ELEM_TYPE_TAB: + matching_elem = _FindElementWithFocusInSubForm(element) + if matching_elem is not None: + return matching_elem + elif element.Type == ELEM_TYPE_PANE: + matching_elem = _FindElementWithFocusInSubForm(element) + if matching_elem is not None: + return matching_elem + elif element.Type == ELEM_TYPE_INPUT_TEXT: + if element.TKEntry is not None: + if element.TKEntry is element.TKEntry.focus_get(): + return element + elif element.Type == ELEM_TYPE_INPUT_MULTILINE: + if element.TKText is not None: + if element.TKText is element.TKText.focus_get(): + return element + elif element.Type == ELEM_TYPE_BUTTON: + if element.TKButton is not None: + if element.TKButton is element.TKButton.focus_get(): + return element + else: # The "Catch All" - if type isn't one of the above, try generic element.Widget + try: + if element.Widget is not None: + if element.Widget is element.Widget.focus_get(): + return element + except: + return None + + return None + + +def AddMenuItem(top_menu, sub_menu_info, element, is_sub_menu=False, skip=False, right_click_menu=False): + """ + Only to be used internally. Not user callable + :param top_menu: ??? + :type top_menu: ??? + :param sub_menu_info: ??? + :type sub_menu_info: + :param element: ??? + :type element: idk_yetReally + :param is_sub_menu: (Default = False) + :type is_sub_menu: (bool) + :param skip: (Default = False) + :type skip: (bool) + + """ + return_val = None + if type(sub_menu_info) is str: + if not is_sub_menu and not skip: + pos = sub_menu_info.find(MENU_SHORTCUT_CHARACTER) + if pos != -1: + if pos < len(MENU_SHORTCUT_CHARACTER) or sub_menu_info[pos - len(MENU_SHORTCUT_CHARACTER)] != "\\": + sub_menu_info = sub_menu_info[:pos] + sub_menu_info[pos + len(MENU_SHORTCUT_CHARACTER):] + if sub_menu_info == '---': + top_menu.add('separator') + else: + try: + item_without_key = sub_menu_info[:sub_menu_info.index(MENU_KEY_SEPARATOR)] + except: + item_without_key = sub_menu_info + + if item_without_key[0] == MENU_DISABLED_CHARACTER: + top_menu.add_command(label=item_without_key[len(MENU_DISABLED_CHARACTER):], underline=pos - 1, + command=lambda: element._MenuItemChosenCallback(sub_menu_info)) + top_menu.entryconfig(item_without_key[len(MENU_DISABLED_CHARACTER):], state='disabled') + else: + top_menu.add_command(label=item_without_key, underline=pos, + command=lambda: element._MenuItemChosenCallback(sub_menu_info)) + else: + i = 0 + while i < (len(sub_menu_info)): + item = sub_menu_info[i] + if i != len(sub_menu_info) - 1: + if type(sub_menu_info[i + 1]) == list: + new_menu = tk.Menu(top_menu, tearoff=element.Tearoff) + # if a right click menu, then get styling from the top-level window + if right_click_menu: + window = element.ParentForm + if window.right_click_menu_background_color not in (COLOR_SYSTEM_DEFAULT, None): + new_menu.config(bg=window.right_click_menu_background_color) + new_menu.config(activeforeground=window.right_click_menu_background_color) + if window.right_click_menu_text_color not in (COLOR_SYSTEM_DEFAULT, None): + new_menu.config(fg=window.right_click_menu_text_color) + new_menu.config(activebackground=window.right_click_menu_text_color) + if window.right_click_menu_disabled_text_color not in (COLOR_SYSTEM_DEFAULT, None): + new_menu.config(disabledforeground=window.right_click_menu_disabled_text_color) + if window.right_click_menu_font is not None: + new_menu.config(font=window.right_click_menu_font) + else: + if element.Font is not None: + new_menu.config(font=element.Font) + if element.BackgroundColor not in (COLOR_SYSTEM_DEFAULT, None): + new_menu.config(bg=element.BackgroundColor) + new_menu.config(activeforeground=element.BackgroundColor) + if element.TextColor not in (COLOR_SYSTEM_DEFAULT, None): + new_menu.config(fg=element.TextColor) + new_menu.config(activebackground=element.TextColor) + if element.DisabledTextColor not in (COLOR_SYSTEM_DEFAULT, None): + new_menu.config(disabledforeground=element.DisabledTextColor) + if element.ItemFont is not None: + new_menu.config(font=element.ItemFont) + return_val = new_menu + pos = sub_menu_info[i].find(MENU_SHORTCUT_CHARACTER) + if pos != -1: + if pos < len(MENU_SHORTCUT_CHARACTER) or sub_menu_info[i][pos - len(MENU_SHORTCUT_CHARACTER)] != "\\": + sub_menu_info[i] = sub_menu_info[i][:pos] + sub_menu_info[i][pos + len(MENU_SHORTCUT_CHARACTER):] + if sub_menu_info[i][0] == MENU_DISABLED_CHARACTER: + top_menu.add_cascade(label=sub_menu_info[i][len(MENU_DISABLED_CHARACTER):], menu=new_menu, + underline=pos, state='disabled') + else: + top_menu.add_cascade(label=sub_menu_info[i], menu=new_menu, underline=pos) + AddMenuItem(new_menu, sub_menu_info[i + 1], element, is_sub_menu=True, right_click_menu=right_click_menu) + i += 1 # skip the next one + else: + AddMenuItem(top_menu, item, element, right_click_menu=right_click_menu) + else: + AddMenuItem(top_menu, item, element, right_click_menu=right_click_menu) + i += 1 + return return_val + + +# 888 888 d8b 888 +# 888 888 Y8P 888 +# 888 888 888 +# 888888 888 888 888 88888b. 888888 .d88b. 888d888 +# 888 888 .88P 888 888 "88b 888 d8P Y8b 888P" +# 888 888888K 888 888 888 888 88888888 888 +# Y88b. 888 "88b 888 888 888 Y88b. Y8b. 888 +# "Y888 888 888 888 888 888 "Y888 "Y8888 888 + +# My crappy tkinter code starts here. (search for "crappy" to get here quickly... that's the purpose if you hadn't caught on + +""" + ) + ( + , + ___)\ + (_____) + (_______) + +""" + + +# Chr0nic || This is probably *very* bad practice. But it works. Simple, but it works... +class VarHolder(object): + canvas_holder = None + + def __init__(self): + self.canvas_holder = None + + +# Also, to get to the point in the code where each element's widget is created, look for element + "p lacement" (without the space) + + +# ======================== TK CODE STARTS HERE ========================================= # +def _fixed_map(style, style_name, option, highlight_colors=(None, None)): + # Fix for setting text colour for Tkinter 8.6.9 + # From: https://core.tcl.tk/tk/info/509cafafae + + # default_map = [elm for elm in style.map("Treeview", query_opt=option) if '!' not in elm[0]] + # custom_map = [elm for elm in style.map(style_name, query_opt=option) if '!' not in elm[0]] + default_map = [elm for elm in style.map("Treeview", query_opt=option) if '!' not in elm[0] and 'selected' not in elm[0]] + custom_map = [elm for elm in style.map(style_name, query_opt=option) if '!' not in elm[0] and 'selected' not in elm[0]] + if option == 'background': + custom_map.append(('selected', highlight_colors[1] if highlight_colors[1] is not None else ALTERNATE_TABLE_AND_TREE_SELECTED_ROW_COLORS[1])) + elif option == 'foreground': + custom_map.append(('selected', highlight_colors[0] if highlight_colors[0] is not None else ALTERNATE_TABLE_AND_TREE_SELECTED_ROW_COLORS[0])) + + new_map = custom_map + default_map + return new_map + # + # new_map = [elm for elm in style.map(style_name, query_opt=option) if elm[:2] != ('!disabled', '!selected')] + # + # if option == 'background': + # new_map.append(('selected', highlight_colors[1] if highlight_colors[1] is not None else ALTERNATE_TABLE_AND_TREE_SELECTED_ROW_COLORS[1])) + # elif option == 'foreground': + # new_map.append(('selected', highlight_colors[0] if highlight_colors[0] is not None else ALTERNATE_TABLE_AND_TREE_SELECTED_ROW_COLORS[0])) + # return new_map + # + +def _add_right_click_menu(element, toplevel_form): + if element.RightClickMenu == MENU_RIGHT_CLICK_DISABLED: + return + if element.RightClickMenu or toplevel_form.RightClickMenu: + menu = element.RightClickMenu or toplevel_form.RightClickMenu + top_menu = tk.Menu(toplevel_form.TKroot, tearoff=toplevel_form.right_click_menu_tearoff, tearoffcommand=element._tearoff_menu_callback) + + if toplevel_form.right_click_menu_background_color not in (COLOR_SYSTEM_DEFAULT, None): + top_menu.config(bg=toplevel_form.right_click_menu_background_color) + if toplevel_form.right_click_menu_text_color not in (COLOR_SYSTEM_DEFAULT, None): + top_menu.config(fg=toplevel_form.right_click_menu_text_color) + if toplevel_form.right_click_menu_disabled_text_color not in (COLOR_SYSTEM_DEFAULT, None): + top_menu.config(disabledforeground=toplevel_form.right_click_menu_disabled_text_color) + if toplevel_form.right_click_menu_font is not None: + top_menu.config(font=toplevel_form.right_click_menu_font) + + if toplevel_form.right_click_menu_selected_colors[0] not in (COLOR_SYSTEM_DEFAULT, None): + top_menu.config(activeforeground=toplevel_form.right_click_menu_selected_colors[0]) + if toplevel_form.right_click_menu_selected_colors[1] not in (COLOR_SYSTEM_DEFAULT, None): + top_menu.config(activebackground=toplevel_form.right_click_menu_selected_colors[1]) + AddMenuItem(top_menu, menu[1], element, right_click_menu=True) + element.TKRightClickMenu = top_menu + if (running_mac()): + element.Widget.bind('', element._RightClickMenuCallback) + else: + element.Widget.bind('', element._RightClickMenuCallback) + + +def _change_ttk_theme(style, theme_name): + global ttk_theme_in_use + if theme_name not in style.theme_names(): + _error_popup_with_traceback('You are trying to use TTK theme "{}"'.format(theme_name), + 'This is not legal for your system', + 'The valid themes to choose from are: {}'.format(', '.join(style.theme_names()))) + return False + + style.theme_use(theme_name) + ttk_theme_in_use = theme_name + return True + +# class Stylist: +# """ +# A class to help get information about ttk styles +# """ +# @staticmethod +# def get_elements(layout): +# """Return a list of elements contained in the style""" +# elements = [] +# element = layout[0][0] +# elements.append(element) +# sublayout = layout[0][1] +# +# if 'children' in sublayout: +# child_elements = Stylist.get_elements(sublayout['children']) +# elements.extend(child_elements) +# return elements +# +# @staticmethod +# def get_options(ttkstyle, theme=None): +# style = ttk.Style() +# if theme is not None: +# style.theme_use(theme) +# layout = style.layout(ttkstyle) +# elements = Stylist.get_elements(layout) +# options = [] +# for e in elements: +# _opts = style.element_options(e) +# if _opts: +# options.extend(list(_opts)) +# return list(set(options)) +# +# @staticmethod +# def create_style(base_style: str, theme=None, **kwargs): +# style = ttk.Style() +# if theme is not None: +# style.theme_use(theme) +# style_id = uuid4() +# ttkstyle = '{}.{}'.format(style_id, base_style) +# style.configure(ttkstyle, **kwargs) +# return ttkstyle + +def _make_ttk_style_name(base_style, element, primary_style=False): + Window._counter_for_ttk_widgets += 1 + style_name = str(Window._counter_for_ttk_widgets) + '___' + str(element.Key) + base_style + if primary_style: + element.ttk_style_name = style_name + return style_name + + +def _make_ttk_scrollbar(element, orientation, window): + """ + Creates a ttk scrollbar for elements as they are being added to the layout + + :param element: The element + :type element: (Element) + :param orientation: The orientation vertical ('v') or horizontal ('h') + :type orientation: (str) + :param window: The window containing the scrollbar + :type window: (Window) + """ + + style = ttk.Style() + _change_ttk_theme(style, window.TtkTheme) + if orientation[0].lower() == 'v': + orient = 'vertical' + style_name = _make_ttk_style_name('.Vertical.TScrollbar', element) + # style_name_thumb = _make_ttk_style_name('.Vertical.TScrollbar.thumb', element) + element.vsb_style = style + element.vsb = ttk.Scrollbar(element.element_frame, orient=orient, command=element.Widget.yview, style=style_name) + element.vsb_style_name = style_name + else: + orient = 'horizontal' + style_name = _make_ttk_style_name('.Horizontal.TScrollbar', element) + element.hsb_style = style + element.hsb = ttk.Scrollbar(element.element_frame, orient=orient, command=element.Widget.xview, style=style_name) + element.hsb_style_name = style_name + + + # ------------------ Get the colors using heirarchy of element, window, options, settings ------------------ + # Trough Color + if element.ttk_part_overrides.sbar_trough_color is not None: + trough_color = element.ttk_part_overrides.sbar_trough_color + elif window.ttk_part_overrides.sbar_trough_color is not None: + trough_color = window.ttk_part_overrides.sbar_trough_color + elif ttk_part_overrides_from_options.sbar_trough_color is not None: + trough_color = ttk_part_overrides_from_options.sbar_trough_color + else: + trough_color = element.scroll_trough_color + # Relief + if element.ttk_part_overrides.sbar_relief is not None: + scroll_relief = element.ttk_part_overrides.sbar_relief + elif window.ttk_part_overrides.sbar_relief is not None: + scroll_relief = window.ttk_part_overrides.sbar_relief + elif ttk_part_overrides_from_options.sbar_relief is not None: + scroll_relief = ttk_part_overrides_from_options.sbar_relief + else: + scroll_relief = element.scroll_relief + # Frame Color + if element.ttk_part_overrides.sbar_frame_color is not None: + frame_color = element.ttk_part_overrides.sbar_frame_color + elif window.ttk_part_overrides.sbar_frame_color is not None: + frame_color = window.ttk_part_overrides.sbar_frame_color + elif ttk_part_overrides_from_options.sbar_frame_color is not None: + frame_color = ttk_part_overrides_from_options.sbar_frame_color + else: + frame_color = element.scroll_frame_color + # Background Color + if element.ttk_part_overrides.sbar_background_color is not None: + background_color = element.ttk_part_overrides.sbar_background_color + elif window.ttk_part_overrides.sbar_background_color is not None: + background_color = window.ttk_part_overrides.sbar_background_color + elif ttk_part_overrides_from_options.sbar_background_color is not None: + background_color = ttk_part_overrides_from_options.sbar_background_color + else: + background_color = element.scroll_background_color + # Arrow Color + if element.ttk_part_overrides.sbar_arrow_color is not None: + arrow_color = element.ttk_part_overrides.sbar_arrow_color + elif window.ttk_part_overrides.sbar_arrow_color is not None: + arrow_color = window.ttk_part_overrides.sbar_arrow_color + elif ttk_part_overrides_from_options.sbar_arrow_color is not None: + arrow_color = ttk_part_overrides_from_options.sbar_arrow_color + else: + arrow_color = element.scroll_arrow_color + # Arrow Width + if element.ttk_part_overrides.sbar_arrow_width is not None: + arrow_width = element.ttk_part_overrides.sbar_arrow_width + elif window.ttk_part_overrides.sbar_arrow_width is not None: + arrow_width = window.ttk_part_overrides.sbar_arrow_width + elif ttk_part_overrides_from_options.sbar_arrow_width is not None: + arrow_width = ttk_part_overrides_from_options.sbar_arrow_width + else: + arrow_width = element.scroll_arrow_width + # Scroll Width + if element.ttk_part_overrides.sbar_width is not None: + scroll_width = element.ttk_part_overrides.sbar_width + elif window.ttk_part_overrides.sbar_width is not None: + scroll_width = window.ttk_part_overrides.sbar_width + elif ttk_part_overrides_from_options.sbar_width is not None: + scroll_width = ttk_part_overrides_from_options.sbar_width + else: + scroll_width = element.scroll_width + + + if trough_color not in (None, COLOR_SYSTEM_DEFAULT): + style.configure(style_name, troughcolor=trough_color) + + if frame_color not in (None, COLOR_SYSTEM_DEFAULT): + style.configure(style_name, framecolor=frame_color) + if frame_color not in (None, COLOR_SYSTEM_DEFAULT): + style.configure(style_name, bordercolor=frame_color) + + if (background_color not in (None, COLOR_SYSTEM_DEFAULT)) and \ + (arrow_color not in (None, COLOR_SYSTEM_DEFAULT)): + style.map(style_name, background=[("selected", background_color), ('active', arrow_color), ('background', background_color), ('!focus', background_color)]) + if (background_color not in (None, COLOR_SYSTEM_DEFAULT)) and \ + (arrow_color not in (None, COLOR_SYSTEM_DEFAULT)): + style.map(style_name, arrowcolor=[("selected", arrow_color), ('active', background_color), ('background', background_color),('!focus', arrow_color)]) + + if scroll_width not in (None, COLOR_SYSTEM_DEFAULT): + style.configure(style_name, width=scroll_width) + if arrow_width not in (None, COLOR_SYSTEM_DEFAULT): + style.configure(style_name, arrowsize=arrow_width) + + if scroll_relief not in (None, COLOR_SYSTEM_DEFAULT): + style.configure(style_name, relief=scroll_relief) + +# if __name__ == '__main__': +# root = tk.Tk() +# +# # find out what options are available for the theme and widget style +# options = Stylist.get_options('TFrame', 'default') +# print('The options for this style and theme are', options) +# +# # create a new style +# frame_style = Stylist.create_style('TFrame', 'alt', relief=tk.RAISED, borderwidth=1) +# +# # apply the new style +# ttk.Frame(style=frame_style, width=100, height=100).pack(padx=10, pady=10) +# +# root.mainloop() + +# @_timeit +def PackFormIntoFrame(form, containing_frame, toplevel_form): + """ + + :param form: a window class + :type form: (Window) + :param containing_frame: ??? + :type containing_frame: ??? + :param toplevel_form: ??? + :type toplevel_form: (Window) + + """ + + # Old bindings + def yscroll_old(event): + try: + if event.num == 5 or event.delta < 0: + VarHolder.canvas_holder.yview_scroll(1, "unit") + elif event.num == 4 or event.delta > 0: + VarHolder.canvas_holder.yview_scroll(-1, "unit") + except: + pass + + def xscroll_old(event): + try: + if event.num == 5 or event.delta < 0: + VarHolder.canvas_holder.xview_scroll(1, "unit") + elif event.num == 4 or event.delta > 0: + VarHolder.canvas_holder.xview_scroll(-1, "unit") + except: + pass + + # Chr0nic + def testMouseHook2(em): + combo = em.TKCombo + combo.unbind_class("TCombobox", "") + combo.unbind_class("TCombobox", "") + combo.unbind_class("TCombobox", "") + containing_frame.unbind_all('<4>') + containing_frame.unbind_all('<5>') + containing_frame.unbind_all("") + containing_frame.unbind_all("") + + # Chr0nic + def testMouseUnhook2(em): + containing_frame.bind_all('<4>', yscroll_old, add="+") + containing_frame.bind_all('<5>', yscroll_old, add="+") + containing_frame.bind_all("", yscroll_old, add="+") + containing_frame.bind_all("", xscroll_old, add="+") + + # Chr0nic + def testMouseHook(em): + containing_frame.unbind_all('<4>') + containing_frame.unbind_all('<5>') + containing_frame.unbind_all("") + containing_frame.unbind_all("") + + # Chr0nic + def testMouseUnhook(em): + containing_frame.bind_all('<4>', yscroll_old, add="+") + containing_frame.bind_all('<5>', yscroll_old, add="+") + containing_frame.bind_all("", yscroll_old, add="+") + containing_frame.bind_all("", xscroll_old, add="+") + + def _char_width_in_pixels(font): + return tkinter.font.Font(font=font).measure('A') # single character width + + def _char_height_in_pixels(font): + return tkinter.font.Font(font=font).metrics('linespace') + + def _string_width_in_pixels(font, string): + return tkinter.font.Font(font=font).measure(string) # single character width + + + + + # def _valid_theme(style, theme_name): + # if theme_name in style.theme_names(): + # return True + # _error_popup_with_traceback('Your Window has an invalid ttk theme specified', + # 'The traceback will show you the Window with the problem layout', + # '** Invalid ttk theme specified {} **'.format(theme_name), + # '\nValid choices include: {}'.format(style.theme_names())) + # + # # print('** Invalid ttk theme specified {} **'.format(theme_name), + # # '\nValid choices include: {}'.format(style.theme_names())) + # return False + + + + def _add_grab(element): + + try: + if form.Grab is True or element.Grab is True: + # if something already about to the button, then don't do the grab stuff + if '' not in element.Widget.bind(): + element.Widget.bind("", toplevel_form._StartMoveGrabAnywhere) + element.Widget.bind("", toplevel_form._StopMove) + element.Widget.bind("", toplevel_form._OnMotionGrabAnywhere) + element.ParentRowFrame.bind("", toplevel_form._StartMoveGrabAnywhere) + element.ParentRowFrame.bind("", toplevel_form._StopMove) + element.ParentRowFrame.bind("", toplevel_form._OnMotionGrabAnywhere) + if element.Type == ELEM_TYPE_COLUMN: + element.TKColFrame.canvas.bind("", toplevel_form._StartMoveGrabAnywhere) + element.TKColFrame.canvas.bind("", toplevel_form._StopMove) + element.TKColFrame.canvas.bind("", toplevel_form._OnMotionGrabAnywhere) + except Exception as e: + pass + # print(e) + + def _add_right_click_menu_and_grab(element): + if element.RightClickMenu == MENU_RIGHT_CLICK_DISABLED: + return + if element.Type == ELEM_TYPE_TAB_GROUP: # unless everything disabled, then need to always set a right click menu for tabgroups + if toplevel_form.RightClickMenu == MENU_RIGHT_CLICK_DISABLED: + return + menu = _MENU_RIGHT_CLICK_TABGROUP_DEFAULT + else: + menu = element.RightClickMenu or form.RightClickMenu or toplevel_form.RightClickMenu + + if menu: + top_menu = tk.Menu(toplevel_form.TKroot, tearoff=toplevel_form.right_click_menu_tearoff, tearoffcommand=element._tearoff_menu_callback) + + if toplevel_form.right_click_menu_background_color not in (COLOR_SYSTEM_DEFAULT, None): + top_menu.config(bg=toplevel_form.right_click_menu_background_color) + if toplevel_form.right_click_menu_text_color not in (COLOR_SYSTEM_DEFAULT, None): + top_menu.config(fg=toplevel_form.right_click_menu_text_color) + if toplevel_form.right_click_menu_disabled_text_color not in (COLOR_SYSTEM_DEFAULT, None): + top_menu.config(disabledforeground=toplevel_form.right_click_menu_disabled_text_color) + if toplevel_form.right_click_menu_font is not None: + top_menu.config(font=toplevel_form.right_click_menu_font) + + if toplevel_form.right_click_menu_selected_colors[0] not in (COLOR_SYSTEM_DEFAULT, None): + top_menu.config(activeforeground=toplevel_form.right_click_menu_selected_colors[0]) + if toplevel_form.right_click_menu_selected_colors[1] not in (COLOR_SYSTEM_DEFAULT, None): + top_menu.config(activebackground=toplevel_form.right_click_menu_selected_colors[1]) + AddMenuItem(top_menu, menu[1], element, right_click_menu=True) + element.TKRightClickMenu = top_menu + if toplevel_form.RightClickMenu: # if the top level has a right click menu, then setup a callback for the Window itself + if toplevel_form.TKRightClickMenu is None: + toplevel_form.TKRightClickMenu = top_menu + if (running_mac()): + toplevel_form.TKroot.bind('', toplevel_form._RightClickMenuCallback) + else: + toplevel_form.TKroot.bind('', toplevel_form._RightClickMenuCallback) + if (running_mac()): + element.Widget.bind('', element._RightClickMenuCallback) + else: + element.Widget.bind('', element._RightClickMenuCallback) + try: + if element.Type == ELEM_TYPE_COLUMN: + element.TKColFrame.canvas.bind('', element._RightClickMenuCallback) + except: + pass + _add_grab(element) + + + def _add_expansion(element, row_should_expand, row_fill_direction): + expand = True + if element.expand_x and element.expand_y: + fill = tk.BOTH + row_fill_direction = tk.BOTH + row_should_expand = True + elif element.expand_x: + fill = tk.X + row_fill_direction = tk.X if row_fill_direction == tk.NONE else tk.BOTH if row_fill_direction == tk.Y else tk.X + elif element.expand_y: + fill = tk.Y + row_fill_direction = tk.Y if row_fill_direction == tk.NONE else tk.BOTH if row_fill_direction == tk.X else tk.Y + row_should_expand = True + else: + fill = tk.NONE + expand = False + return expand, fill, row_should_expand, row_fill_direction + + tclversion_detailed = tkinter.Tcl().eval('info patchlevel') + + + # --------------------------------------------------------------------------- # + # **************** Use FlexForm to build the tkinter window ********** ----- # + # Building is done row by row. # + # WARNING - You can't use print in this function. If the user has rerouted # + # stdout then there will be an error saying the window isn't finalized # + # --------------------------------------------------------------------------- # + ######################### LOOP THROUGH ROWS ######################### + # *********** ------- Loop through ROWS ------- ***********# + for row_num, flex_row in enumerate(form.Rows): + ######################### LOOP THROUGH ELEMENTS ON ROW ######################### + # *********** ------- Loop through ELEMENTS ------- ***********# + # *********** Make TK Row ***********# + tk_row_frame = tk.Frame(containing_frame) + row_should_expand = False + row_fill_direction = tk.NONE + + if form.ElementJustification is not None: + row_justify = form.ElementJustification + else: + row_justify = 'l' + + for col_num, element in enumerate(flex_row): + element.ParentRowFrame = tk_row_frame + element.element_frame = None # for elements that have a scrollbar too + element.ParentForm = toplevel_form # save the button's parent form object + if toplevel_form.Font and (element.Font == DEFAULT_FONT or element.Font is None): + font = toplevel_form.Font + elif element.Font is not None: + font = element.Font + else: + font = DEFAULT_FONT + # ------- Determine Auto-Size setting on a cascading basis ------- # + if element.AutoSizeText is not None: # if element overide + auto_size_text = element.AutoSizeText + elif toplevel_form.AutoSizeText is not None: # if form override + auto_size_text = toplevel_form.AutoSizeText + else: + auto_size_text = DEFAULT_AUTOSIZE_TEXT + element_type = element.Type + # Set foreground color + text_color = element.TextColor + elementpad = element.Pad if element.Pad is not None else toplevel_form.ElementPadding + # element.pad_used = elementpad # store the value used back into the element + # Determine Element size + element_size = element.Size + if (element_size == (None, None) and element_type not in ( + ELEM_TYPE_BUTTON, ELEM_TYPE_BUTTONMENU)): # user did not specify a size + element_size = toplevel_form.DefaultElementSize + elif (element_size == (None, None) and element_type in (ELEM_TYPE_BUTTON, ELEM_TYPE_BUTTONMENU)): + element_size = toplevel_form.DefaultButtonElementSize + else: + auto_size_text = False # if user has specified a size then it shouldn't autosize + + border_depth = toplevel_form.BorderDepth if toplevel_form.BorderDepth is not None else DEFAULT_BORDER_WIDTH + try: + if element.BorderWidth is not None: + border_depth = element.BorderWidth + except: + pass + + # ------------------------- COLUMN placement element ------------------------- # + if element_type == ELEM_TYPE_COLUMN: + element = element # type: Column + # ----------------------- SCROLLABLE Column ---------------------- + if element.Scrollable: + element.Widget = element.TKColFrame = TkScrollableFrame(tk_row_frame, element.VerticalScrollOnly, element, toplevel_form) # do not use yet! not working + PackFormIntoFrame(element, element.TKColFrame.TKFrame, toplevel_form) + element.TKColFrame.TKFrame.update() + if element.Size == (None, None): # if no size specified, use column width x column height/2 + element.TKColFrame.canvas.config(width=element.TKColFrame.TKFrame.winfo_reqwidth() // element.size_subsample_width, + height=element.TKColFrame.TKFrame.winfo_reqheight() // element.size_subsample_height) + else: + element.TKColFrame.canvas.config(width=element.TKColFrame.TKFrame.winfo_reqwidth() // element.size_subsample_width, + height=element.TKColFrame.TKFrame.winfo_reqheight() // element.size_subsample_height) + if None not in (element.Size[0], element.Size[1]): + element.TKColFrame.canvas.config(width=element.Size[0], height=element.Size[1]) + elif element.Size[1] is not None: + element.TKColFrame.canvas.config(height=element.Size[1]) + elif element.Size[0] is not None: + element.TKColFrame.canvas.config(width=element.Size[0]) + if not element.BackgroundColor in (None, COLOR_SYSTEM_DEFAULT): + element.TKColFrame.canvas.config(background=element.BackgroundColor) + element.TKColFrame.TKFrame.config(background=element.BackgroundColor, borderwidth=0, highlightthickness=0) + element.TKColFrame.config(background=element.BackgroundColor, borderwidth=0, + highlightthickness=0) + # ----------------------- PLAIN Column ---------------------- + else: + if element.Size != (None, None): + element.Widget = element.TKColFrame = TkFixedFrame(tk_row_frame) + PackFormIntoFrame(element, element.TKColFrame.TKFrame, toplevel_form) + element.TKColFrame.TKFrame.update() + if None not in (element.Size[0], element.Size[1]): + element.TKColFrame.canvas.config(width=element.Size[0], height=element.Size[1]) + elif element.Size[1] is not None: + element.TKColFrame.canvas.config(height=element.Size[1]) + elif element.Size[0] is not None: + element.TKColFrame.canvas.config(width=element.Size[0]) + if not element.BackgroundColor in (None, COLOR_SYSTEM_DEFAULT): + element.TKColFrame.canvas.config(background=element.BackgroundColor) + element.TKColFrame.TKFrame.config(background=element.BackgroundColor, borderwidth=0, highlightthickness=0) + else: + element.Widget = element.TKColFrame = tk.Frame(tk_row_frame) + PackFormIntoFrame(element, element.TKColFrame, toplevel_form) + if element.BackgroundColor not in (None, COLOR_SYSTEM_DEFAULT): + element.TKColFrame.config(background=element.BackgroundColor, borderwidth=0, highlightthickness=0) + + if element.Justification is None: + pass + elif element.Justification.lower().startswith('l'): + row_justify = 'l' + elif element.Justification.lower().startswith('c'): + row_justify = 'c' + elif element.Justification.lower().startswith('r'): + row_justify = 'r' + + # anchor=tk.NW + # side = tk.LEFT + # row_justify = element.Justification + + # element.Widget = element.TKColFrame + + expand = True + if element.expand_x and element.expand_y: + fill = tk.BOTH + row_fill_direction = tk.BOTH + row_should_expand = True + elif element.expand_x: + fill = tk.X + row_fill_direction = tk.X + elif element.expand_y: + fill = tk.Y + row_fill_direction = tk.Y + row_should_expand = True + else: + fill = tk.NONE + expand = False + + if element.VerticalAlignment is not None: + anchor = tk.CENTER # Default to center if a bad choice is made + + if element.VerticalAlignment.lower().startswith('t'): + anchor = tk.N + if element.VerticalAlignment.lower().startswith('c'): + anchor = tk.CENTER + if element.VerticalAlignment.lower().startswith('b'): + anchor = tk.S + element.TKColFrame.pack(side=tk.LEFT, anchor=anchor, padx=elementpad[0], pady=elementpad[1], expand=expand, fill=fill) + else: + element.TKColFrame.pack(side=tk.LEFT, padx=elementpad[0], pady=elementpad[1], expand=expand, fill=fill) + + # element.TKColFrame.pack(side=side, padx=elementpad[0], pady=elementpad[1], expand=True, fill='both') + if element.visible is False: + element._pack_forget_save_settings() + # element.TKColFrame.pack_forget() + + _add_right_click_menu_and_grab(element) + # if element.Grab: + # element._grab_anywhere_on() + # row_should_expand = True + # ------------------------- Pane placement element ------------------------- # + if element_type == ELEM_TYPE_PANE: + bd = element.BorderDepth if element.BorderDepth is not None else border_depth + element.PanedWindow = element.Widget = tk.PanedWindow(tk_row_frame, + orient=tk.VERTICAL if element.Orientation.startswith( + 'v') else tk.HORIZONTAL, + borderwidth=bd, + bd=bd, + ) + if element.Relief is not None: + element.PanedWindow.configure(relief=element.Relief) + element.PanedWindow.configure(handlesize=element.HandleSize) + if element.ShowHandle: + element.PanedWindow.config(showhandle=True) + if element.Size != (None, None): + element.PanedWindow.config(width=element.Size[0], height=element.Size[1]) + if element.BackgroundColor is not None and element.BackgroundColor != COLOR_SYSTEM_DEFAULT: + element.PanedWindow.configure(background=element.BackgroundColor) + for pane in element.PaneList: + pane.Widget = pane.TKColFrame = tk.Frame(element.PanedWindow) + pane.ParentPanedWindow = element.PanedWindow + PackFormIntoFrame(pane, pane.TKColFrame, toplevel_form) + if pane.visible: + element.PanedWindow.add(pane.TKColFrame) + if pane.BackgroundColor != COLOR_SYSTEM_DEFAULT and pane.BackgroundColor is not None: + pane.TKColFrame.configure(background=pane.BackgroundColor, + highlightbackground=pane.BackgroundColor, + highlightcolor=pane.BackgroundColor) + expand, fill, row_should_expand, row_fill_direction = _add_expansion(element, row_should_expand, row_fill_direction) + element.PanedWindow.pack(side=tk.LEFT, padx=elementpad[0], pady=elementpad[1], expand=expand, fill=fill) + # element.PanedWindow.pack(side=tk.LEFT, padx=elementpad[0], pady=elementpad[1], expand=True, fill='both') + if element.visible is False: + element._pack_forget_save_settings() + # element.PanedWindow.pack_forget() + # ------------------------- TEXT placement element ------------------------- # + elif element_type == ELEM_TYPE_TEXT: + # auto_size_text = element.AutoSizeText + element = element # type: Text + display_text = element.DisplayText # text to display + if auto_size_text is False: + width, height = element_size + else: + width, height = None, None + # lines = display_text.split('\n') + # max_line_len = max([len(l) for l in lines]) + # num_lines = len(lines) + # if max_line_len > element_size[0]: # if text exceeds element size, the will have to wrap + # width = element_size[0] + # else: + # width = max_line_len + # height = num_lines + # ---===--- LABEL widget create and place --- # + element = element # type: Text + bd = element.BorderWidth if element.BorderWidth is not None else border_depth + stringvar = tk.StringVar() + element.TKStringVar = stringvar + stringvar.set(str(display_text)) + if auto_size_text: + width = 0 + if element.Justification is not None: + justification = element.Justification + elif toplevel_form.TextJustification is not None: + justification = toplevel_form.TextJustification + else: + justification = DEFAULT_TEXT_JUSTIFICATION + justify = tk.LEFT if justification.startswith('l') else tk.CENTER if justification.startswith('c') else tk.RIGHT + anchor = tk.NW if justification.startswith('l') else tk.N if justification.startswith('c') else tk.NE + tktext_label = element.Widget = tk.Label(tk_row_frame, textvariable=stringvar, width=width, + height=height, justify=justify, bd=bd, font=font) + # Set wrap-length for text (in PIXELS) == PAIN IN THE ASS + wraplen = tktext_label.winfo_reqwidth() # width of widget in Pixels + if auto_size_text or (not auto_size_text and height == 1): # if just 1 line high, ensure no wrap happens + wraplen = 0 + tktext_label.configure(anchor=anchor, wraplen=wraplen) # set wrap to width of widget + if element.Relief is not None: + tktext_label.configure(relief=element.Relief) + if element.BackgroundColor is not None and element.BackgroundColor != COLOR_SYSTEM_DEFAULT: + tktext_label.configure(background=element.BackgroundColor) + if element.TextColor != COLOR_SYSTEM_DEFAULT and element.TextColor is not None: + tktext_label.configure(fg=element.TextColor) + expand, fill, row_should_expand, row_fill_direction = _add_expansion(element, row_should_expand, row_fill_direction) + tktext_label.pack(side=tk.LEFT, padx=elementpad[0], pady=elementpad[1], expand=expand, fill=fill) + if element.visible is False: + element._pack_forget_save_settings() + # tktext_label.pack_forget() + element.TKText = tktext_label + if element.ClickSubmits: + tktext_label.bind('', element._TextClickedHandler) + if element.Tooltip is not None: + element.TooltipObject = ToolTip(element.TKText, text=element.Tooltip, timeout=DEFAULT_TOOLTIP_TIME) + _add_right_click_menu_and_grab(element) + if element.Grab: + element._grab_anywhere_on() + # ------------------------- BUTTON placement element non-ttk version ------------------------- # + elif (element_type == ELEM_TYPE_BUTTON and element.UseTtkButtons is False) or \ + (element_type == ELEM_TYPE_BUTTON and element.UseTtkButtons is not True and toplevel_form.UseTtkButtons is not True): + element = element # type: Button + element.UseTtkButtons = False # indicate that ttk button was not used + stringvar = tk.StringVar() + element.TKStringVar = stringvar + element.Location = (row_num, col_num) + btext = element.ButtonText + btype = element.BType + if element.AutoSizeButton is not None: + auto_size = element.AutoSizeButton + else: + auto_size = toplevel_form.AutoSizeButtons + if auto_size is False or element.Size[0] is not None: + width, height = element_size + else: + width = 0 + height = toplevel_form.DefaultButtonElementSize[1] + if element.ButtonColor != (None, None) and element.ButtonColor != DEFAULT_BUTTON_COLOR: + bc = element.ButtonColor + elif toplevel_form.ButtonColor != (None, None) and toplevel_form.ButtonColor != DEFAULT_BUTTON_COLOR: + bc = toplevel_form.ButtonColor + else: + bc = DEFAULT_BUTTON_COLOR + + bd = element.BorderWidth + pos = -1 + if DEFAULT_USE_BUTTON_SHORTCUTS is True: + pos = btext.find(MENU_SHORTCUT_CHARACTER) + if pos != -1: + if pos < len(MENU_SHORTCUT_CHARACTER) or btext[pos - len(MENU_SHORTCUT_CHARACTER)] != "\\": + btext = btext[:pos] + btext[pos + len(MENU_SHORTCUT_CHARACTER):] + else: + btext = btext.replace('\\'+MENU_SHORTCUT_CHARACTER, MENU_SHORTCUT_CHARACTER) + pos = -1 + tkbutton = element.Widget = tk.Button(tk_row_frame, text=btext, width=width, height=height, justify=tk.CENTER, bd=bd, font=font) + if pos != -1: + tkbutton.config(underline=pos) + try: + if btype != BUTTON_TYPE_REALTIME: + tkbutton.config( command=element.ButtonCallBack) + + else: + tkbutton.bind('', element.ButtonReleaseCallBack) + tkbutton.bind('', element.ButtonPressCallBack) + if bc != (None, None) and COLOR_SYSTEM_DEFAULT not in bc: + tkbutton.config(foreground=bc[0], background=bc[1]) + else: + if bc[0] != COLOR_SYSTEM_DEFAULT: + tkbutton.config(foreground=bc[0]) + if bc[1] != COLOR_SYSTEM_DEFAULT: + tkbutton.config(background=bc[1]) + except Exception as e: + _error_popup_with_traceback('Button has a problem....', + 'The traceback information will not show the line in your layout with the problem, but it does tell you which window.', + 'Error {}'.format(e), + # 'Button Text: {}'.format(btext), + # 'Button key: {}'.format(element.Key), + # 'Color string: {}'.format(bc), + "Parent Window's Title: {}".format(toplevel_form.Title)) + + if bd == 0 and not running_mac(): + tkbutton.config(relief=tk.FLAT) + + element.TKButton = tkbutton # not used yet but save the TK button in case + if elementpad[0] == 0 or elementpad[1] == 0: + tkbutton.config(highlightthickness=0) + + ## -------------- TK Button With Image -------------- ## + if element.ImageFilename: # if button has an image on it + tkbutton.config(highlightthickness=0) + try: + photo = tk.PhotoImage(file=element.ImageFilename) + if element.ImageSubsample: + photo = photo.subsample(element.ImageSubsample) + if element.zoom: + photo = photo.zoom(element.zoom) + if element.ImageSize != (None, None): + width, height = element.ImageSize + else: + width, height = photo.width(), photo.height() + except Exception as e: + _error_popup_with_traceback('Button Element error {}'.format(e), 'Image filename: {}'.format(element.ImageFilename), + 'NOTE - file format must be PNG or GIF!', + 'Button element key: {}'.format(element.Key), + "Parent Window's Title: {}".format(toplevel_form.Title)) + tkbutton.config(image=photo, compound=tk.CENTER, width=width, height=height) + tkbutton.image = photo + if element.ImageData: # if button has an image on it + tkbutton.config(highlightthickness=0) + try: + photo = tk.PhotoImage(data=element.ImageData) + if element.ImageSubsample: + photo = photo.subsample(element.ImageSubsample) + if element.zoom: + photo = photo.zoom(element.zoom) + if element.ImageSize != (None, None): + width, height = element.ImageSize + else: + width, height = photo.width(), photo.height() + tkbutton.config(image=photo, compound=tk.CENTER, width=width, height=height) + tkbutton.image = photo + except Exception as e: + _error_popup_with_traceback('Button Element error {}'.format(e), + 'Problem using BASE64 Image data Image Susample', + 'Buton element key: {}'.format(element.Key), + "Parent Window's Title: {}".format(toplevel_form.Title)) + + if width != 0: + wraplen = width * _char_width_in_pixels(font) + tkbutton.configure(wraplength=wraplen) # set wrap to width of widget + expand, fill, row_should_expand, row_fill_direction = _add_expansion(element, row_should_expand, row_fill_direction) + + tkbutton.pack(side=tk.LEFT, padx=elementpad[0], pady=elementpad[1], expand=expand, fill=fill) + if element.visible is False: + element._pack_forget_save_settings() + # tkbutton.pack_forget() + if element.BindReturnKey: + element.TKButton.bind('', element._ReturnKeyHandler) + if element.Focus is True or (toplevel_form.UseDefaultFocus and not toplevel_form.FocusSet): + toplevel_form.FocusSet = True + element.TKButton.bind('', element._ReturnKeyHandler) + element.TKButton.focus_set() + toplevel_form.TKroot.focus_force() + if element.Disabled is True: + element.TKButton['state'] = 'disabled' + if element.DisabledButtonColor != (None, None) and element.DisabledButtonColor != (COLOR_SYSTEM_DEFAULT, COLOR_SYSTEM_DEFAULT): + if element.DisabledButtonColor[0] not in (None, COLOR_SYSTEM_DEFAULT): + element.TKButton['disabledforeground'] = element.DisabledButtonColor[0] + if element.MouseOverColors[1] not in (COLOR_SYSTEM_DEFAULT, None): + tkbutton.config(activebackground=element.MouseOverColors[1]) + if element.MouseOverColors[0] not in (COLOR_SYSTEM_DEFAULT, None): + tkbutton.config(activeforeground=element.MouseOverColors[0]) + + if element.Tooltip is not None: + element.TooltipObject = ToolTip(element.TKButton, text=element.Tooltip, + timeout=DEFAULT_TOOLTIP_TIME) + try: + if element.HighlightColors[1] != COLOR_SYSTEM_DEFAULT: + tkbutton.config(highlightbackground=element.HighlightColors[1]) + if element.HighlightColors[0] != COLOR_SYSTEM_DEFAULT: + tkbutton.config(highlightcolor=element.HighlightColors[0]) + except Exception as e: + _error_popup_with_traceback('Button Element error {}'.format(e), + 'Button element key: {}'.format(element.Key), + 'Button text: {}'.format(btext), + 'Has a bad highlight color {}'.format(element.HighlightColors), + "Parent Window's Title: {}".format(toplevel_form.Title)) + # print('Button with text: ', btext, 'has a bad highlight color', element.HighlightColors) + _add_right_click_menu_and_grab(element) + + # ------------------------- BUTTON placement element ttk version ------------------------- # + elif element_type == ELEM_TYPE_BUTTON: + element = element # type: Button + element.UseTtkButtons = True # indicate that ttk button was used + stringvar = tk.StringVar() + element.TKStringVar = stringvar + element.Location = (row_num, col_num) + btext = element.ButtonText + pos = -1 + if DEFAULT_USE_BUTTON_SHORTCUTS is True: + pos = btext.find(MENU_SHORTCUT_CHARACTER) + if pos != -1: + if pos < len(MENU_SHORTCUT_CHARACTER) or btext[pos - len(MENU_SHORTCUT_CHARACTER)] != "\\": + btext = btext[:pos] + btext[pos + len(MENU_SHORTCUT_CHARACTER):] + else: + btext = btext.replace('\\'+MENU_SHORTCUT_CHARACTER, MENU_SHORTCUT_CHARACTER) + pos = -1 + btype = element.BType + if element.AutoSizeButton is not None: + auto_size = element.AutoSizeButton + else: + auto_size = toplevel_form.AutoSizeButtons + if auto_size is False or element.Size[0] is not None: + width, height = element_size + else: + width = 0 + height = toplevel_form.DefaultButtonElementSize[1] + if element.ButtonColor != (None, None) and element.ButtonColor != COLOR_SYSTEM_DEFAULT: + bc = element.ButtonColor + elif toplevel_form.ButtonColor != (None, None) and toplevel_form.ButtonColor != COLOR_SYSTEM_DEFAULT: + bc = toplevel_form.ButtonColor + else: + bc = DEFAULT_BUTTON_COLOR + bd = element.BorderWidth + tkbutton = element.Widget = ttk.Button(tk_row_frame, text=btext, width=width) + if pos != -1: + tkbutton.config(underline=pos) + if btype != BUTTON_TYPE_REALTIME: + tkbutton.config(command=element.ButtonCallBack) + else: + tkbutton.bind('', element.ButtonReleaseCallBack) + tkbutton.bind('', element.ButtonPressCallBack) + style_name = _make_ttk_style_name('.TButton', element, primary_style=True) + button_style = ttk.Style() + element.ttk_style = button_style + _change_ttk_theme(button_style, toplevel_form.TtkTheme) + button_style.configure(style_name, font=font) + + if bc != (None, None) and COLOR_SYSTEM_DEFAULT not in bc: + button_style.configure(style_name, foreground=bc[0], background=bc[1]) + elif bc[0] != COLOR_SYSTEM_DEFAULT: + button_style.configure(style_name, foreground=bc[0]) + elif bc[1] != COLOR_SYSTEM_DEFAULT: + button_style.configure(style_name, background=bc[1]) + + if bd == 0 and not running_mac(): + button_style.configure(style_name, relief=tk.FLAT) + button_style.configure(style_name, borderwidth=0) + else: + button_style.configure(style_name, borderwidth=bd) + button_style.configure(style_name, justify=tk.CENTER) + + if element.MouseOverColors[1] not in (COLOR_SYSTEM_DEFAULT, None): + button_style.map(style_name, background=[('active', element.MouseOverColors[1])]) + if element.MouseOverColors[0] not in (COLOR_SYSTEM_DEFAULT, None): + button_style.map(style_name, foreground=[('active', element.MouseOverColors[0])]) + + if element.DisabledButtonColor[0] not in (COLOR_SYSTEM_DEFAULT, None): + button_style.map(style_name, foreground=[('disabled', element.DisabledButtonColor[0])]) + if element.DisabledButtonColor[1] not in (COLOR_SYSTEM_DEFAULT, None): + button_style.map(style_name, background=[('disabled', element.DisabledButtonColor[1])]) + + if height > 1: + button_style.configure(style_name, padding=height * _char_height_in_pixels(font)) # should this be height instead? + if width != 0: + wraplen = width * _char_width_in_pixels(font) # width of widget in Pixels + button_style.configure(style_name, wraplength=wraplen) # set wrap to width of widget + + ## -------------- TTK Button With Image -------------- ## + if element.ImageFilename: # if button has an image on it + button_style.configure(style_name, borderwidth=0) + # tkbutton.configure(highlightthickness=0) + photo = tk.PhotoImage(file=element.ImageFilename) + if element.ImageSubsample: + photo = photo.subsample(element.ImageSubsample) + if element.zoom: + photo = photo.zoom(element.zoom) + if element.ImageSize != (None, None): + width, height = element.ImageSize + else: + width, height = photo.width(), photo.height() + button_style.configure(style_name, image=photo, compound=tk.CENTER, width=width, height=height) + tkbutton.image = photo + if element.ImageData: # if button has an image on it + # tkbutton.configure(highlightthickness=0) + button_style.configure(style_name, borderwidth=0) + + photo = tk.PhotoImage(data=element.ImageData) + if element.ImageSubsample: + photo = photo.subsample(element.ImageSubsample) + if element.zoom: + photo = photo.zoom(element.zoom) + if element.ImageSize != (None, None): + width, height = element.ImageSize + else: + width, height = photo.width(), photo.height() + button_style.configure(style_name, image=photo, compound=tk.CENTER, width=width, height=height) + # tkbutton.configure(image=photo, compound=tk.CENTER, width=width, height=height) + tkbutton.image = photo + + element.TKButton = tkbutton # not used yet but save the TK button in case + expand, fill, row_should_expand, row_fill_direction = _add_expansion(element, row_should_expand, row_fill_direction) + tkbutton.pack(side=tk.LEFT, padx=elementpad[0], pady=elementpad[1], expand=expand, fill=fill) + if element.visible is False: + element._pack_forget_save_settings() + # tkbutton.pack_forget() + if element.BindReturnKey: + element.TKButton.bind('', element._ReturnKeyHandler) + if element.Focus is True or (toplevel_form.UseDefaultFocus and not toplevel_form.FocusSet): + toplevel_form.FocusSet = True + element.TKButton.bind('', element._ReturnKeyHandler) + element.TKButton.focus_set() + toplevel_form.TKroot.focus_force() + if element.Disabled is True: + element.TKButton['state'] = 'disabled' + + tkbutton.configure(style=style_name) # IMPORTANT! Apply the style to the button! + _add_right_click_menu_and_grab(element) + + if element.Tooltip is not None: + element.TooltipObject = ToolTip(element.TKButton, text=element.Tooltip, + timeout=DEFAULT_TOOLTIP_TIME) + # ------------------------- BUTTONMENU placement element ------------------------- # + elif element_type == ELEM_TYPE_BUTTONMENU: + element = element # type: ButtonMenu + element.Location = (row_num, col_num) + btext = element.ButtonText + if element.AutoSizeButton is not None: + auto_size = element.AutoSizeButton + else: + auto_size = toplevel_form.AutoSizeButtons + if auto_size is False or element.Size[0] is not None: + width, height = element_size + else: + width = 0 + height = toplevel_form.DefaultButtonElementSize[1] + if element.ButtonColor != (None, None) and element.ButtonColor != DEFAULT_BUTTON_COLOR: + bc = element.ButtonColor + elif toplevel_form.ButtonColor != (None, None) and toplevel_form.ButtonColor != DEFAULT_BUTTON_COLOR: + bc = toplevel_form.ButtonColor + else: + bc = DEFAULT_BUTTON_COLOR + bd = element.BorderWidth + if element.ItemFont is None: + element.ItemFont = font + tkbutton = element.Widget = tk.Menubutton(tk_row_frame, text=btext, width=width, height=height, justify=tk.LEFT, bd=bd, font=font) + element.TKButtonMenu = tkbutton + if bc != (None, None) and bc != COLOR_SYSTEM_DEFAULT and bc[1] != COLOR_SYSTEM_DEFAULT: + tkbutton.config(foreground=bc[0], background=bc[1]) + tkbutton.config(activebackground=bc[0]) + tkbutton.config(activeforeground=bc[1]) + elif bc[0] != COLOR_SYSTEM_DEFAULT: + tkbutton.config(foreground=bc[0]) + tkbutton.config(activebackground=bc[0]) + if bd == 0 and not running_mac(): + tkbutton.config(relief=RELIEF_FLAT) + elif bd != 0: + tkbutton.config(relief=RELIEF_RAISED) + + element.TKButton = tkbutton # not used yet but save the TK button in case + wraplen = tkbutton.winfo_reqwidth() # width of widget in Pixels + if element.ImageFilename: # if button has an image on it + photo = tk.PhotoImage(file=element.ImageFilename) + if element.ImageSubsample: + photo = photo.subsample(element.ImageSubsample) + if element.zoom: + photo = photo.zoom(element.zoom) + if element.ImageSize != (None, None): + width, height = element.ImageSize + else: + width, height = photo.width(), photo.height() + tkbutton.config(image=photo, compound=tk.CENTER, width=width, height=height) + tkbutton.image = photo + if element.ImageData: # if button has an image on it + photo = tk.PhotoImage(data=element.ImageData) + if element.ImageSubsample: + photo = photo.subsample(element.ImageSubsample) + if element.zoom: + photo = photo.zoom(element.zoom) + if element.ImageSize != (None, None): + width, height = element.ImageSize + else: + width, height = photo.width(), photo.height() + tkbutton.config(image=photo, compound=tk.CENTER, width=width, height=height) + tkbutton.image = photo + if width != 0: + tkbutton.configure(wraplength=wraplen + 10) # set wrap to width of widget + expand, fill, row_should_expand, row_fill_direction = _add_expansion(element, row_should_expand, row_fill_direction) + tkbutton.pack(side=tk.LEFT, padx=elementpad[0], pady=elementpad[1], expand=expand, fill=fill) + + menu_def = element.MenuDefinition + + element.TKMenu = top_menu = tk.Menu(tkbutton, tearoff=element.Tearoff, font=element.ItemFont, tearoffcommand=element._tearoff_menu_callback) + + if element.BackgroundColor not in (COLOR_SYSTEM_DEFAULT, None): + top_menu.config(bg=element.BackgroundColor) + top_menu.config(activeforeground=element.BackgroundColor) + if element.TextColor not in (COLOR_SYSTEM_DEFAULT, None): + top_menu.config(fg=element.TextColor) + top_menu.config(activebackground=element.TextColor) + if element.DisabledTextColor not in (COLOR_SYSTEM_DEFAULT, None): + top_menu.config(disabledforeground=element.DisabledTextColor) + if element.ItemFont is not None: + top_menu.config(font=element.ItemFont) + + AddMenuItem(top_menu, menu_def[1], element) + if elementpad[0] == 0 or elementpad[1] == 0: + tkbutton.config(highlightthickness=0) + tkbutton.configure(menu=top_menu) + element.TKMenu = top_menu + if element.visible is False: + element._pack_forget_save_settings() + # tkbutton.pack_forget() + if element.Disabled == True: + element.TKButton['state'] = 'disabled' + if element.Tooltip is not None: + element.TooltipObject = ToolTip(element.TKButton, text=element.Tooltip, timeout=DEFAULT_TOOLTIP_TIME) + + # ------------------------- INPUT placement element ------------------------- # + elif element_type == ELEM_TYPE_INPUT_TEXT: + element = element # type: InputText + default_text = element.DefaultText + element.TKStringVar = tk.StringVar() + element.TKStringVar.set(default_text) + show = element.PasswordCharacter if element.PasswordCharacter else "" + bd = border_depth + if element.Justification is not None: + justification = element.Justification + else: + justification = DEFAULT_TEXT_JUSTIFICATION + justify = tk.LEFT if justification.startswith('l') else tk.CENTER if justification.startswith('c') else tk.RIGHT + # anchor = tk.NW if justification == 'left' else tk.N if justification == 'center' else tk.NE + element.TKEntry = element.Widget = tk.Entry(tk_row_frame, width=element_size[0], + textvariable=element.TKStringVar, bd=bd, + font=font, show=show, justify=justify) + if element.ChangeSubmits: + element.TKEntry.bind('', element._KeyboardHandler) + element.TKEntry.bind('', element._ReturnKeyHandler) + + + if element.BackgroundColor not in (None, COLOR_SYSTEM_DEFAULT): + element.TKEntry.configure(background=element.BackgroundColor, selectforeground=element.BackgroundColor) + + if text_color not in (None, COLOR_SYSTEM_DEFAULT): + element.TKEntry.configure(fg=text_color, selectbackground=text_color) + element.TKEntry.config(insertbackground=text_color) + if element.selected_background_color not in (None, COLOR_SYSTEM_DEFAULT): + element.TKEntry.configure(selectbackground=element.selected_background_color) + if element.selected_text_color not in (None, COLOR_SYSTEM_DEFAULT): + element.TKEntry.configure(selectforeground=element.selected_text_color) + if element.disabled_readonly_background_color not in (None, COLOR_SYSTEM_DEFAULT): + element.TKEntry.config(readonlybackground=element.disabled_readonly_background_color) + if element.disabled_readonly_text_color not in (None, COLOR_SYSTEM_DEFAULT) and element.Disabled: + element.TKEntry.config(fg=element.disabled_readonly_text_color) + + element.Widget.config(highlightthickness=0) + # element.pack_keywords = {'side':tk.LEFT, 'padx':elementpad[0], 'pady':elementpad[1], 'expand':False, 'fill':tk.NONE } + # element.TKEntry.pack(**element.pack_keywords) + expand, fill, row_should_expand, row_fill_direction = _add_expansion(element, row_should_expand, row_fill_direction) + element.TKEntry.pack(side=tk.LEFT, padx=elementpad[0], pady=elementpad[1], expand=expand, fill=fill) + if element.visible is False: + element._pack_forget_save_settings() + # element.TKEntry.pack_forget() + if element.Focus is True or (toplevel_form.UseDefaultFocus and not toplevel_form.FocusSet): + toplevel_form.FocusSet = True + element.TKEntry.focus_set() + if element.Disabled: + element.TKEntry['state'] = 'readonly' if element.UseReadonlyForDisable else 'disabled' + if element.ReadOnly: + element.TKEntry['state'] = 'readonly' + + if element.Tooltip is not None: + element.TooltipObject = ToolTip(element.TKEntry, text=element.Tooltip, timeout=DEFAULT_TOOLTIP_TIME) + _add_right_click_menu_and_grab(element) + + # row_should_expand = True + + # ------------------------- COMBO placement element ------------------------- # + elif element_type == ELEM_TYPE_INPUT_COMBO: + element = element # type: Combo + max_line_len = max([len(str(l)) for l in element.Values]) if len(element.Values) else 0 + if auto_size_text is False: + width = element_size[0] + else: + width = max_line_len + 1 + element.TKStringVar = tk.StringVar() + style_name = _make_ttk_style_name('.TCombobox', element, primary_style=True) + combostyle = ttk.Style() + element.ttk_style = combostyle + _change_ttk_theme(combostyle, toplevel_form.TtkTheme) + + # Creates a unique name for each field element(Sure there is a better way to do this) + # unique_field = _make_ttk_style_name('.TCombobox.field', element) + + + # Set individual widget options + try: + if element.TextColor not in (None, COLOR_SYSTEM_DEFAULT): + combostyle.configure(style_name, foreground=element.TextColor) + combostyle.configure(style_name, selectbackground=element.TextColor) + combostyle.configure(style_name, insertcolor=element.TextColor) + combostyle.map(style_name, fieldforeground=[('readonly', element.TextColor)]) + if element.BackgroundColor not in (None, COLOR_SYSTEM_DEFAULT): + combostyle.configure(style_name, selectforeground=element.BackgroundColor) + combostyle.map(style_name, fieldbackground=[('readonly', element.BackgroundColor)]) + combostyle.configure(style_name, fieldbackground=element.BackgroundColor) + + if element.button_arrow_color not in (None, COLOR_SYSTEM_DEFAULT): + combostyle.configure(style_name, arrowcolor=element.button_arrow_color) + if element.button_background_color not in (None, COLOR_SYSTEM_DEFAULT): + combostyle.configure(style_name, background=element.button_background_color) + if element.Readonly is True: + if element.TextColor not in (None, COLOR_SYSTEM_DEFAULT): + combostyle.configure(style_name, selectforeground=element.TextColor) + if element.BackgroundColor not in (None, COLOR_SYSTEM_DEFAULT): + combostyle.configure(style_name, selectbackground=element.BackgroundColor) + + + except Exception as e: + _error_popup_with_traceback('Combo Element error {}'.format(e), + 'Combo element key: {}'.format(element.Key), + 'One of your colors is bad. Check the text, background, button background and button arrow colors', + "Parent Window's Title: {}".format(toplevel_form.Title)) + + # Strange code that is needed to set the font for the drop-down list + element._dropdown_newfont = tkinter.font.Font(font=font) + tk_row_frame.option_add("*TCombobox*Listbox*Font", element._dropdown_newfont) + + element.TKCombo = element.Widget = ttk.Combobox(tk_row_frame, width=width, textvariable=element.TKStringVar, font=font, style=style_name) + + # make tcl call to deal with colors for the drop-down formatting + try: + if element.BackgroundColor not in (None, COLOR_SYSTEM_DEFAULT) and \ + element.TextColor not in (None, COLOR_SYSTEM_DEFAULT): + element.Widget.tk.eval( + '[ttk::combobox::PopdownWindow {}].f.l configure -foreground {} -background {} -selectforeground {} -selectbackground {}'.format(element.Widget, element.TextColor, element.BackgroundColor, element.BackgroundColor, element.TextColor)) + except Exception as e: + pass # going to let this one slide + + # Chr0nic + element.TKCombo.bind("", lambda event, em=element: testMouseHook2(em)) + element.TKCombo.bind("", lambda event, em=element: testMouseUnhook2(em)) + + if toplevel_form.UseDefaultFocus and not toplevel_form.FocusSet: + toplevel_form.FocusSet = True + element.TKCombo.focus_set() + + if element.Size[1] != 1 and element.Size[1] is not None: + element.TKCombo.configure(height=element.Size[1]) + element.TKCombo['values'] = element.Values + expand, fill, row_should_expand, row_fill_direction = _add_expansion(element, row_should_expand, row_fill_direction) + element.TKCombo.pack(side=tk.LEFT, padx=elementpad[0], pady=elementpad[1], expand=expand, fill=fill) + if element.visible is False: + element._pack_forget_save_settings() + # element.TKCombo.pack_forget() + if element.DefaultValue is not None: + element.TKCombo.set(element.DefaultValue) + # for i, v in enumerate(element.Values): + # if v == element.DefaultValue: + # element.TKCombo.current(i) + # break + # elif element.Values: + # element.TKCombo.current(0) + if element.ChangeSubmits: + element.TKCombo.bind('<>', element._ComboboxSelectHandler) + if element.BindReturnKey: + element.TKCombo.bind('', element._ComboboxSelectHandler) + if element.enable_per_char_events: + element.TKCombo.bind('', element._KeyboardHandler) + if element.Readonly: + element.TKCombo['state'] = 'readonly' + if element.Disabled is True: # note overrides readonly if disabled + element.TKCombo['state'] = 'disabled' + if element.Tooltip is not None: + element.TooltipObject = ToolTip(element.TKCombo, text=element.Tooltip, timeout=DEFAULT_TOOLTIP_TIME) + _add_right_click_menu_and_grab(element) + + # ------------------------- OPTIONMENU placement Element (Like ComboBox but different) element ------------------------- # + elif element_type == ELEM_TYPE_INPUT_OPTION_MENU: + max_line_len = max([len(str(l)) for l in element.Values]) + if auto_size_text is False: + width = element_size[0] + else: + width = max_line_len + element.TKStringVar = tk.StringVar() + if element.DefaultValue: + element.TKStringVar.set(element.DefaultValue) + element.TKOptionMenu = element.Widget = tk.OptionMenu(tk_row_frame, element.TKStringVar, *element.Values) + element.TKOptionMenu.config(highlightthickness=0, font=font, width=width) + element.TKOptionMenu['menu'].config(font=font) + element.TKOptionMenu.config(borderwidth=border_depth) + if element.BackgroundColor is not None and element.BackgroundColor != COLOR_SYSTEM_DEFAULT: + element.TKOptionMenu.configure(background=element.BackgroundColor) + element.TKOptionMenu['menu'].config(background=element.BackgroundColor) + if element.TextColor != COLOR_SYSTEM_DEFAULT and element.TextColor is not None: + element.TKOptionMenu.configure(fg=element.TextColor) + element.TKOptionMenu['menu'].config(fg=element.TextColor) + expand, fill, row_should_expand, row_fill_direction = _add_expansion(element, row_should_expand, row_fill_direction) + element.TKOptionMenu.pack(side=tk.LEFT, padx=elementpad[0], pady=elementpad[1], expand=expand, fill=fill) + if element.visible is False: + element._pack_forget_save_settings() + # element.TKOptionMenu.pack_forget() + if element.Disabled == True: + element.TKOptionMenu['state'] = 'disabled' + if element.Tooltip is not None: + element.TooltipObject = ToolTip(element.TKOptionMenu, text=element.Tooltip, + timeout=DEFAULT_TOOLTIP_TIME) + # ------------------------- LISTBOX placement element ------------------------- # + elif element_type == ELEM_TYPE_INPUT_LISTBOX: + element = element # type: Listbox + max_line_len = max([len(str(l)) for l in element.Values]) if len(element.Values) else 0 + if auto_size_text is False: + width = element_size[0] + else: + width = max_line_len + element_frame = tk.Frame(tk_row_frame) + element.element_frame = element_frame + + justification = tk.LEFT + if element.justification is not None: + if element.justification.startswith('l'): + justification = tk.LEFT + elif element.justification.startswith('r'): + justification = tk.RIGHT + elif element.justification.startswith('c'): + justification = tk.CENTER + + element.TKStringVar = tk.StringVar() + element.TKListbox = element.Widget = tk.Listbox(element_frame, height=element_size[1], width=width, + selectmode=element.SelectMode, font=font, exportselection=False) + # On OLD versions of tkinter the justify option isn't available + try: + element.Widget.config(justify=justification) + except: + pass + + element.Widget.config(highlightthickness=0) + for index, item in enumerate(element.Values): + element.TKListbox.insert(tk.END, item) + if element.DefaultValues is not None and item in element.DefaultValues: + element.TKListbox.selection_set(index) + if element.BackgroundColor is not None and element.BackgroundColor != COLOR_SYSTEM_DEFAULT: + element.TKListbox.configure(background=element.BackgroundColor) + if element.HighlightBackgroundColor is not None and element.HighlightBackgroundColor != COLOR_SYSTEM_DEFAULT: + element.TKListbox.config(selectbackground=element.HighlightBackgroundColor) + if text_color is not None and text_color != COLOR_SYSTEM_DEFAULT: + element.TKListbox.configure(fg=text_color) + if element.HighlightTextColor is not None and element.HighlightTextColor != COLOR_SYSTEM_DEFAULT: + element.TKListbox.config(selectforeground=element.HighlightTextColor) + if element.ChangeSubmits: + element.TKListbox.bind('<>', element._ListboxSelectHandler) + + if not element.NoScrollbar: + _make_ttk_scrollbar(element, 'v', toplevel_form) + element.Widget.configure(yscrollcommand=element.vsb.set) + element.vsb.pack(side=tk.RIGHT, fill='y') + + # Horizontal scrollbar + if element.HorizontalScroll: + _make_ttk_scrollbar(element, 'h', toplevel_form) + element.hsb.pack(side=tk.BOTTOM, fill='x') + element.Widget.configure(xscrollcommand=element.hsb.set) + + if not element.NoScrollbar or element.HorizontalScroll: + # Chr0nic + element.Widget.bind("", lambda event, em=element: testMouseHook(em)) + element.Widget.bind("", lambda event, em=element: testMouseUnhook(em)) + + # else: + # element.TKText.config(wrap='word') + + # if not element.NoScrollbar: + # # Vertical scrollbar + # element.vsb = tk.Scrollbar(element_frame, orient="vertical", command=element.TKListbox.yview) + # element.TKListbox.configure(yscrollcommand=element.vsb.set) + # element.vsb.pack(side=tk.RIGHT, fill='y') + + # Horizontal scrollbar + # if element.HorizontalScroll: + # hscrollbar = tk.Scrollbar(element_frame, orient=tk.HORIZONTAL) + # hscrollbar.pack(side=tk.BOTTOM, fill='x') + # hscrollbar.config(command=element.Widget.xview) + # element.Widget.configure(xscrollcommand=hscrollbar.set) + # element.hsb = hscrollbar + # + # # Chr0nic + # element.TKListbox.bind("", lambda event, em=element: testMouseHook(em)) + # element.TKListbox.bind("", lambda event, em=element: testMouseUnhook(em)) + # + # + + + + expand, fill, row_should_expand, row_fill_direction = _add_expansion(element, row_should_expand, row_fill_direction) + element_frame.pack(side=tk.LEFT, padx=elementpad[0], pady=elementpad[1], fill=fill, expand=expand) + element.TKListbox.pack(side=tk.LEFT, fill=fill, expand=expand) + if element.visible is False: + element._pack_forget_save_settings(alternate_widget=element_frame) + # element_frame.pack_forget() + if element.BindReturnKey: + element.TKListbox.bind('', element._ListboxSelectHandler) + element.TKListbox.bind('', element._ListboxSelectHandler) + if element.Disabled is True: + element.TKListbox['state'] = 'disabled' + if element.Tooltip is not None: + element.TooltipObject = ToolTip(element.TKListbox, text=element.Tooltip, + timeout=DEFAULT_TOOLTIP_TIME) + _add_right_click_menu_and_grab(element) + # ------------------------- MULTILINE placement element ------------------------- # + elif element_type == ELEM_TYPE_INPUT_MULTILINE: + element = element # type: Multiline + width, height = element_size + bd = element.BorderWidth + element.element_frame = element_frame = tk.Frame(tk_row_frame) + + # if element.no_scrollbar: + element.TKText = element.Widget = tk.Text(element_frame, width=width, height=height, bd=bd, font=font, relief=RELIEF_SUNKEN) + # else: + # element.TKText = element.Widget = tk.scrolledtext.ScrolledText(element_frame, width=width, height=height, bd=bd, font=font, relief=RELIEF_SUNKEN) + + if not element.no_scrollbar: + _make_ttk_scrollbar(element, 'v', toplevel_form) + + element.Widget.configure(yscrollcommand=element.vsb.set) + element.vsb.pack(side=tk.RIGHT, fill='y') + + # Horizontal scrollbar + if element.HorizontalScroll: + element.TKText.config(wrap='none') + _make_ttk_scrollbar(element, 'h', toplevel_form) + element.hsb.pack(side=tk.BOTTOM, fill='x') + element.Widget.configure(xscrollcommand=element.hsb.set) + else: + element.TKText.config(wrap='word') + + if element.wrap_lines is True: + element.TKText.config(wrap='word') + elif element.wrap_lines is False: + element.TKText.config(wrap='none') + + if not element.no_scrollbar or element.HorizontalScroll: + # Chr0nic + element.TKText.bind("", lambda event, em=element: testMouseHook(em)) + element.TKText.bind("", lambda event, em=element: testMouseUnhook(em)) + + if element.DefaultText: + element.TKText.insert(1.0, element.DefaultText) # set the default text + element.TKText.config(highlightthickness=0) + if text_color is not None and text_color != COLOR_SYSTEM_DEFAULT: + element.TKText.configure(fg=text_color, selectbackground=text_color) + element.TKText.config(insertbackground=text_color) + if element.BackgroundColor is not None and element.BackgroundColor != COLOR_SYSTEM_DEFAULT: + element.TKText.configure(background=element.BackgroundColor, selectforeground=element.BackgroundColor) + if element.selected_background_color not in (None, COLOR_SYSTEM_DEFAULT): + element.TKText.configure(selectbackground=element.selected_background_color) + if element.selected_text_color not in (None, COLOR_SYSTEM_DEFAULT): + element.TKText.configure(selectforeground=element.selected_text_color) + element.TKText.tag_configure("center", justify='center') + element.TKText.tag_configure("left", justify='left') + element.TKText.tag_configure("right", justify='right') + + if element.Justification.startswith('l'): + element.TKText.tag_add("left", 1.0, "end") + element.justification_tag = 'left' + elif element.Justification.startswith('r'): + element.TKText.tag_add("right", 1.0, "end") + element.justification_tag = 'right' + elif element.Justification.startswith('c'): + element.TKText.tag_add("center", 1.0, "end") + element.justification_tag = 'center' + # if DEFAULT_SCROLLBAR_COLOR not in (None, COLOR_SYSTEM_DEFAULT): # only works on Linux so not including it + # element.TKText.vbar.config(troughcolor=DEFAULT_SCROLLBAR_COLOR) + expand, fill, row_should_expand, row_fill_direction = _add_expansion(element, row_should_expand, row_fill_direction) + + element.element_frame.pack(side=tk.LEFT, padx=elementpad[0], pady=elementpad[1], fill=fill, expand=expand) + element.Widget.pack(side=tk.LEFT, fill=fill, expand=expand) + + if element.visible is False: + element._pack_forget_save_settings(alternate_widget=element_frame) + # element.element_frame.pack_forget() + else: + # Chr0nic + element.TKText.bind("", lambda event, em=element: testMouseHook(em)) + element.TKText.bind("", lambda event, em=element: testMouseUnhook(em)) + if element.ChangeSubmits: + element.TKText.bind('', element._KeyboardHandler) + if element.EnterSubmits: + element.TKText.bind('', element._ReturnKeyHandler) + if element.Focus is True or (toplevel_form.UseDefaultFocus and not toplevel_form.FocusSet): + toplevel_form.FocusSet = True + element.TKText.focus_set() + + + if element.Disabled is True: + element.TKText['state'] = 'disabled' + if element.Tooltip is not None: + element.TooltipObject = ToolTip(element.TKText, text=element.Tooltip, timeout=DEFAULT_TOOLTIP_TIME) + + if element.reroute_cprint: + cprint_set_output_destination(toplevel_form, element.Key) + + _add_right_click_menu_and_grab(element) + + if element.reroute_stdout: + element.reroute_stdout_to_here() + if element.reroute_stderr: + element.reroute_stderr_to_here() + + # row_should_expand = True + # ------------------------- CHECKBOX pleacement element ------------------------- # + elif element_type == ELEM_TYPE_INPUT_CHECKBOX: + element = element # type: Checkbox + width = 0 if auto_size_text else element_size[0] + default_value = element.InitialState + element.TKIntVar = tk.IntVar() + element.TKIntVar.set(default_value if default_value is not None else 0) + + element.TKCheckbutton = element.Widget = tk.Checkbutton(tk_row_frame, anchor=tk.NW, + text=element.Text, width=width, + variable=element.TKIntVar, bd=border_depth, + font=font) + if element.ChangeSubmits: + element.TKCheckbutton.configure(command=element._CheckboxHandler) + if element.Disabled: + element.TKCheckbutton.configure(state='disable') + if element.BackgroundColor is not None and element.BackgroundColor != COLOR_SYSTEM_DEFAULT: + element.TKCheckbutton.configure(background=element.BackgroundColor) + element.TKCheckbutton.configure(selectcolor=element.CheckboxBackgroundColor) # The background of the checkbox + element.TKCheckbutton.configure(activebackground=element.BackgroundColor) + if text_color is not None and text_color != COLOR_SYSTEM_DEFAULT: + element.TKCheckbutton.configure(fg=text_color) + element.TKCheckbutton.configure(activeforeground=element.TextColor) + + element.Widget.configure(highlightthickness=element.highlight_thickness) + if element.BackgroundColor != COLOR_SYSTEM_DEFAULT: + element.TKCheckbutton.config(highlightbackground=element.BackgroundColor) + if element.TextColor != COLOR_SYSTEM_DEFAULT: + element.TKCheckbutton.config(highlightcolor=element.TextColor) + expand, fill, row_should_expand, row_fill_direction = _add_expansion(element, row_should_expand, row_fill_direction) + element.TKCheckbutton.pack(side=tk.LEFT, padx=elementpad[0], pady=elementpad[1], expand=expand, fill=fill) + if element.visible is False: + element._pack_forget_save_settings() + # element.TKCheckbutton.pack_forget() + if element.Tooltip is not None: + element.TooltipObject = ToolTip(element.TKCheckbutton, text=element.Tooltip, + timeout=DEFAULT_TOOLTIP_TIME) + _add_right_click_menu_and_grab(element) + + # ------------------------- PROGRESS placement element ------------------------- # + elif element_type == ELEM_TYPE_PROGRESS_BAR: + element = element # type: ProgressBar + if element.size_px != (None, None): + progress_length, progress_width = element.size_px + else: + width = element_size[0] + fnt = tkinter.font.Font() + char_width = fnt.measure('A') # single character width + progress_length = width * char_width + progress_width = element_size[1] + direction = element.Orientation + if element.BarColor != (None, None): # if element has a bar color, use it + bar_color = element.BarColor + else: + bar_color = DEFAULT_PROGRESS_BAR_COLOR + if element.Orientation.lower().startswith('h'): + base_style_name = ".Horizontal.TProgressbar" + else: + base_style_name = ".Vertical.TProgressbar" + style_name = _make_ttk_style_name(base_style_name, element, primary_style=True) + element.TKProgressBar = TKProgressBar(tk_row_frame, element.MaxValue, progress_length, progress_width, + orientation=direction, BarColor=bar_color, + border_width=element.BorderWidth, relief=element.Relief, + ttk_theme=toplevel_form.TtkTheme, key=element.Key, style_name=style_name) + element.Widget = element.TKProgressBar.TKProgressBarForReal + expand, fill, row_should_expand, row_fill_direction = _add_expansion(element, row_should_expand, row_fill_direction) + element.TKProgressBar.TKProgressBarForReal.pack(side=tk.LEFT, padx=elementpad[0], pady=elementpad[1], expand=expand, fill=fill) + if element.visible is False: + element._pack_forget_save_settings(alternate_widget=element.TKProgressBar.TKProgressBarForReal) + # element.TKProgressBar.TKProgressBarForReal.pack_forget() + _add_right_click_menu_and_grab(element) + + # ------------------------- RADIO placement element ------------------------- # + elif element_type == ELEM_TYPE_INPUT_RADIO: + element = element # type: Radio + width = 0 if auto_size_text else element_size[0] + default_value = element.InitialState + ID = element.GroupID + # see if ID has already been placed + value = EncodeRadioRowCol(form.ContainerElemementNumber, row_num, + col_num) # value to set intvar to if this radio is selected + element.EncodedRadioValue = value + if ID in toplevel_form.RadioDict: + RadVar = toplevel_form.RadioDict[ID] + else: + RadVar = tk.IntVar() + toplevel_form.RadioDict[ID] = RadVar + element.TKIntVar = RadVar # store the RadVar in Radio object + if default_value: # if this radio is the one selected, set RadVar to match + element.TKIntVar.set(value) + element.TKRadio = element.Widget = tk.Radiobutton(tk_row_frame, anchor=tk.NW, text=element.Text, + width=width, variable=element.TKIntVar, value=value, + bd=border_depth, font=font) + if element.ChangeSubmits: + element.TKRadio.configure(command=element._RadioHandler) + if not element.BackgroundColor in (None, COLOR_SYSTEM_DEFAULT): + element.TKRadio.configure(background=element.BackgroundColor) + element.TKRadio.configure(selectcolor=element.CircleBackgroundColor) + element.TKRadio.configure(activebackground=element.BackgroundColor) + if text_color is not None and text_color != COLOR_SYSTEM_DEFAULT: + element.TKRadio.configure(fg=text_color) + element.TKRadio.configure(activeforeground=text_color) + + element.Widget.configure(highlightthickness=1) + if element.BackgroundColor != COLOR_SYSTEM_DEFAULT: + element.TKRadio.config(highlightbackground=element.BackgroundColor) + if element.TextColor != COLOR_SYSTEM_DEFAULT: + element.TKRadio.config(highlightcolor=element.TextColor) + + if element.Disabled: + element.TKRadio['state'] = 'disabled' + expand, fill, row_should_expand, row_fill_direction = _add_expansion(element, row_should_expand, row_fill_direction) + element.TKRadio.pack(side=tk.LEFT, padx=elementpad[0], pady=elementpad[1], expand=expand, fill=fill) + if element.visible is False: + element._pack_forget_save_settings() + # element.TKRadio.pack_forget() + if element.Tooltip is not None: + element.TooltipObject = ToolTip(element.TKRadio, text=element.Tooltip, timeout=DEFAULT_TOOLTIP_TIME) + _add_right_click_menu_and_grab(element) + + # ------------------------- SPIN placement element ------------------------- # + elif element_type == ELEM_TYPE_INPUT_SPIN: + element = element # type: Spin + width, height = element_size + width = 0 if auto_size_text else element_size[0] + element.TKStringVar = tk.StringVar() + element.TKSpinBox = element.Widget = tk.Spinbox(tk_row_frame, values=element.Values, textvariable=element.TKStringVar, width=width, bd=border_depth) + if element.DefaultValue is not None: + element.TKStringVar.set(element.DefaultValue) + element.TKSpinBox.configure(font=font) # set wrap to width of widget + if element.BackgroundColor is not None and element.BackgroundColor != COLOR_SYSTEM_DEFAULT: + element.TKSpinBox.configure(background=element.BackgroundColor) + element.TKSpinBox.configure(buttonbackground=element.BackgroundColor) + if text_color not in (None, COLOR_SYSTEM_DEFAULT): + element.TKSpinBox.configure(fg=text_color) + element.TKSpinBox.config(insertbackground=text_color) + element.Widget.config(highlightthickness=0) + if element.wrap is True: + element.Widget.configure(wrap=True) + expand, fill, row_should_expand, row_fill_direction = _add_expansion(element, row_should_expand, row_fill_direction) + element.TKSpinBox.pack(side=tk.LEFT, padx=elementpad[0], pady=elementpad[1], expand=expand, fill=fill) + if element.visible is False: + element._pack_forget_save_settings() + # element.TKSpinBox.pack_forget() + if element.ChangeSubmits: + element.TKSpinBox.configure(command=element._SpinboxSelectHandler) + # element.TKSpinBox.bind('', element._SpinChangedHandler) + # element.TKSpinBox.bind('', element._SpinChangedHandler) + # element.TKSpinBox.bind('', element._SpinChangedHandler) + if element.Readonly: + element.TKSpinBox['state'] = 'readonly' + if element.Disabled is True: # note overrides readonly if disabled + element.TKSpinBox['state'] = 'disabled' + if element.Tooltip is not None: + element.TooltipObject = ToolTip(element.TKSpinBox, text=element.Tooltip, timeout=DEFAULT_TOOLTIP_TIME) + if element.BindReturnKey: + element.TKSpinBox.bind('', element._SpinboxSelectHandler) + _add_right_click_menu_and_grab(element) + # ------------------------- IMAGE placement element ------------------------- # + elif element_type == ELEM_TYPE_IMAGE: + element = element # type: Image + try: + if element.Filename is not None: + photo = tk.PhotoImage(file=element.Filename) + elif element.Data is not None: + photo = tk.PhotoImage(data=element.Data) + else: + photo = None + + if photo is not None: + if element.ImageSubsample: + photo = photo.subsample(element.ImageSubsample) + if element.zoom: + photo = photo.zoom(element.zoom) + # print('*ERROR laying out form.... Image Element has no image specified*') + except Exception as e: + photo = None + _error_popup_with_traceback('Your Window has an Image Element with a problem', + 'The traceback will show you the Window with the problem layout', + 'Look in this Window\'s layout for an Image element that has a key of {}'.format(element.Key), + 'The error occuring is:', e) + + element.tktext_label = element.Widget = tk.Label(tk_row_frame, bd=0) + + if photo is not None: + if element_size == (None, None) or element_size is None or element_size == toplevel_form.DefaultElementSize: + width, height = photo.width(), photo.height() + else: + width, height = element_size + element.tktext_label.config(image=photo, width=width, height=height) + + + if not element.BackgroundColor in (None, COLOR_SYSTEM_DEFAULT): + element.tktext_label.config(background=element.BackgroundColor) + + element.tktext_label.image = photo + # tktext_label.configure(anchor=tk.NW, image=photo) + expand, fill, row_should_expand, row_fill_direction = _add_expansion(element, row_should_expand, row_fill_direction) + element.tktext_label.pack(side=tk.LEFT, padx=elementpad[0], pady=elementpad[1], expand=expand, fill=fill) + + if element.visible is False: + element._pack_forget_save_settings() + # element.tktext_label.pack_forget() + if element.Tooltip is not None: + element.TooltipObject = ToolTip(element.tktext_label, text=element.Tooltip, + timeout=DEFAULT_TOOLTIP_TIME) + if element.EnableEvents and element.tktext_label is not None: + element.tktext_label.bind('', element._ClickHandler) + + _add_right_click_menu_and_grab(element) + + # ------------------------- Canvas placement element ------------------------- # + elif element_type == ELEM_TYPE_CANVAS: + element = element # type: Canvas + width, height = element_size + if element._TKCanvas is None: + element._TKCanvas = tk.Canvas(tk_row_frame, width=width, height=height, bd=border_depth) + else: + element._TKCanvas.master = tk_row_frame + element.Widget = element._TKCanvas + + if element.BackgroundColor is not None and element.BackgroundColor != COLOR_SYSTEM_DEFAULT: + element._TKCanvas.configure(background=element.BackgroundColor, highlightthickness=0) + expand, fill, row_should_expand, row_fill_direction = _add_expansion(element, row_should_expand, row_fill_direction) + element._TKCanvas.pack(side=tk.LEFT, padx=elementpad[0], pady=elementpad[1], expand=expand, fill=fill) + if element.visible is False: + element._pack_forget_save_settings() + # element._TKCanvas.pack_forget() + if element.Tooltip is not None: + element.TooltipObject = ToolTip(element._TKCanvas, text=element.Tooltip, + timeout=DEFAULT_TOOLTIP_TIME) + _add_right_click_menu_and_grab(element) + + # ------------------------- Graph placement element ------------------------- # + elif element_type == ELEM_TYPE_GRAPH: + element = element # type: Graph + width, height = element_size + # I don't know why TWO canvases were being defined, on inside the other. Was it so entire canvas can move? + # if element._TKCanvas is None: + # element._TKCanvas = tk.Canvas(tk_row_frame, width=width, height=height, bd=border_depth) + # else: + # element._TKCanvas.master = tk_row_frame + element._TKCanvas2 = element.Widget = tk.Canvas(tk_row_frame, width=width, height=height, + bd=border_depth) + expand, fill, row_should_expand, row_fill_direction = _add_expansion(element, row_should_expand, row_fill_direction) + element._TKCanvas2.pack(side=tk.LEFT, expand=expand, fill=fill) + element._TKCanvas2.addtag_all('mytag') + if element.BackgroundColor is not None and element.BackgroundColor != COLOR_SYSTEM_DEFAULT: + element._TKCanvas2.configure(background=element.BackgroundColor, highlightthickness=0) + # element._TKCanvas.configure(background=element.BackgroundColor, highlightthickness=0) + element._TKCanvas2.pack(side=tk.LEFT, padx=elementpad[0], pady=elementpad[1], expand=expand, fill=fill) + if element.visible is False: + element._pack_forget_save_settings() + # element._TKCanvas2.pack_forget() + if element.Tooltip is not None: + element.TooltipObject = ToolTip(element._TKCanvas2, text=element.Tooltip, + timeout=DEFAULT_TOOLTIP_TIME) + if element.ChangeSubmits: + element._TKCanvas2.bind('', element.ButtonReleaseCallBack) + element._TKCanvas2.bind('', element.ButtonPressCallBack) + if element.DragSubmits: + element._TKCanvas2.bind('', element.MotionCallBack) + _add_right_click_menu_and_grab(element) + # ------------------------- MENU placement element ------------------------- # + elif element_type == ELEM_TYPE_MENUBAR: + element = element # type: MenuBar + menu_def = element.MenuDefinition + element.TKMenu = element.Widget = tk.Menu(toplevel_form.TKroot, tearoff=element.Tearoff, + tearoffcommand=element._tearoff_menu_callback) # create the menubar + menubar = element.TKMenu + if font is not None: # if a font is used, make sure it's saved in the element + element.Font = font + for menu_entry in menu_def: + baritem = tk.Menu(menubar, tearoff=element.Tearoff, tearoffcommand=element._tearoff_menu_callback) + if element.BackgroundColor not in (COLOR_SYSTEM_DEFAULT, None): + baritem.config(bg=element.BackgroundColor) + baritem.config(activeforeground=element.BackgroundColor) + if element.TextColor not in (COLOR_SYSTEM_DEFAULT, None): + baritem.config(fg=element.TextColor) + baritem.config(activebackground=element.TextColor) + if element.DisabledTextColor not in (COLOR_SYSTEM_DEFAULT, None): + baritem.config(disabledforeground=element.DisabledTextColor) + if font is not None: + baritem.config(font=font) + pos = menu_entry[0].find(MENU_SHORTCUT_CHARACTER) + # print(pos) + if pos != -1: + if pos == 0 or menu_entry[0][pos - len(MENU_SHORTCUT_CHARACTER)] != "\\": + menu_entry[0] = menu_entry[0][:pos] + menu_entry[0][pos + 1:] + if menu_entry[0][0] == MENU_DISABLED_CHARACTER: + menubar.add_cascade(label=menu_entry[0][len(MENU_DISABLED_CHARACTER):], menu=baritem, + underline=pos - 1) + menubar.entryconfig(menu_entry[0][len(MENU_DISABLED_CHARACTER):], state='disabled') + else: + menubar.add_cascade(label=menu_entry[0], menu=baritem, underline=pos) + + if len(menu_entry) > 1: + AddMenuItem(baritem, menu_entry[1], element) + toplevel_form.TKroot.configure(menu=element.TKMenu) + # ------------------------- Frame placement element ------------------------- # + elif element_type == ELEM_TYPE_FRAME: + element = element # type: Frame + labeled_frame = element.Widget = tk.LabelFrame(tk_row_frame, text=element.Title, relief=element.Relief) + element.TKFrame = labeled_frame + PackFormIntoFrame(element, labeled_frame, toplevel_form) + expand, fill, row_should_expand, row_fill_direction = _add_expansion(element, row_should_expand, row_fill_direction) + if element.VerticalAlignment is not None: + anchor = tk.CENTER # Default to center if a bad choice is made + if element.VerticalAlignment.lower().startswith('t'): + anchor = tk.N + if element.VerticalAlignment.lower().startswith('c'): + anchor = tk.CENTER + if element.VerticalAlignment.lower().startswith('b'): + anchor = tk.S + labeled_frame.pack(side=tk.LEFT, anchor=anchor, padx=elementpad[0], pady=elementpad[1], expand=expand, fill=fill) + else: + labeled_frame.pack(side=tk.LEFT, padx=elementpad[0], pady=elementpad[1], expand=expand, fill=fill) + + if element.Size != (None, None): + labeled_frame.config(width=element.Size[0], height=element.Size[1]) + labeled_frame.pack_propagate(0) + if not element.visible: + element._pack_forget_save_settings() + # labeled_frame.pack_forget() + if element.BackgroundColor != COLOR_SYSTEM_DEFAULT and element.BackgroundColor is not None: + labeled_frame.configure(background=element.BackgroundColor, + highlightbackground=element.BackgroundColor, + highlightcolor=element.BackgroundColor) + if element.TextColor != COLOR_SYSTEM_DEFAULT and element.TextColor is not None: + labeled_frame.configure(foreground=element.TextColor) + if font is not None: + labeled_frame.configure(font=font) + if element.TitleLocation is not None: + labeled_frame.configure(labelanchor=element.TitleLocation) + if element.BorderWidth is not None: + labeled_frame.configure(borderwidth=element.BorderWidth) + if element.Tooltip is not None: + element.TooltipObject = ToolTip(labeled_frame, text=element.Tooltip, timeout=DEFAULT_TOOLTIP_TIME) + _add_right_click_menu_and_grab(element) + # row_should_expand=True + # ------------------------- Tab placement element ------------------------- # + elif element_type == ELEM_TYPE_TAB: + element = element # type: Tab + form = form # type: TabGroup + element.TKFrame = element.Widget = tk.Frame(form.TKNotebook) + PackFormIntoFrame(element, element.TKFrame, toplevel_form) + state = 'normal' + if element.Disabled: + state = 'disabled' + if element.visible is False: + state = 'hidden' + # this code will add an image to the tab. Use it when adding the image on a tab enhancement + try: + if element.Filename is not None: + photo = tk.PhotoImage(file=element.Filename) + elif element.Data is not None: + photo = tk.PhotoImage(data=element.Data) + else: + photo = None + + if element.ImageSubsample and photo is not None: + photo = photo.subsample(element.ImageSubsample) + if element.zoom and photo is not None: + photo = photo.zoom(element.zoom) + # print('*ERROR laying out form.... Image Element has no image specified*') + except Exception as e: + photo = None + _error_popup_with_traceback('Your Window has an Tab Element with an IMAGE problem', + 'The traceback will show you the Window with the problem layout', + 'Look in this Window\'s layout for an Image element that has a key of {}'.format(element.Key), + 'The error occuring is:', e) + + element.photo = photo + if photo is not None: + if element_size == (None, None) or element_size is None or element_size == toplevel_form.DefaultElementSize: + width, height = photo.width(), photo.height() + else: + width, height = element_size + element.tktext_label = tk.Label(tk_row_frame, image=photo, width=width, height=height, bd=0) + else: + element.tktext_label = tk.Label(tk_row_frame, bd=0) + if photo is not None: + form.TKNotebook.add(element.TKFrame, text=element.Title, compound=tk.LEFT, state=state,image=photo) + + # element.photo_image = tk.PhotoImage(data=DEFAULT_BASE64_ICON) + # form.TKNotebook.add(element.TKFrame, text=element.Title, compound=tk.LEFT, state=state,image = element.photo_image) + + + form.TKNotebook.add(element.TKFrame, text=element.Title, state=state) + # July 28 2022 removing the expansion and pack as a test + # expand, fill, row_should_expand, row_fill_direction = _add_expansion(element, row_should_expand, row_fill_direction) + # form.TKNotebook.pack(side=tk.LEFT, padx=elementpad[0], pady=elementpad[1], fill=fill, expand=expand) + + element.ParentNotebook = form.TKNotebook + element.TabID = form.TabCount + form.tab_index_to_key[element.TabID] = element.key # has a list of the tabs in the notebook and their associated key + form.TabCount += 1 + if element.BackgroundColor not in (COLOR_SYSTEM_DEFAULT, None): + element.TKFrame.configure(background=element.BackgroundColor, highlightbackground=element.BackgroundColor, highlightcolor=element.BackgroundColor) + + # if element.BorderWidth is not None: + # element.TKFrame.configure(borderwidth=element.BorderWidth) + if element.Tooltip is not None: + element.TooltipObject = ToolTip(element.TKFrame, text=element.Tooltip, timeout=DEFAULT_TOOLTIP_TIME) + _add_right_click_menu_and_grab(element) + # row_should_expand = True + # ------------------------- TabGroup placement element ------------------------- # + elif element_type == ELEM_TYPE_TAB_GROUP: + element = element # type: TabGroup + # custom_style = str(element.Key) + 'customtab.TNotebook' + custom_style = _make_ttk_style_name('.TNotebook', element, primary_style=True) + style = ttk.Style() + _change_ttk_theme(style, toplevel_form.TtkTheme) + + if element.TabLocation is not None: + position_dict = {'left': 'w', 'right': 'e', 'top': 'n', 'bottom': 's', 'lefttop': 'wn', + 'leftbottom': 'ws', 'righttop': 'en', 'rightbottom': 'es', 'bottomleft': 'sw', + 'bottomright': 'se', 'topleft': 'nw', 'topright': 'ne'} + try: + tab_position = position_dict[element.TabLocation] + except: + tab_position = position_dict['top'] + style.configure(custom_style, tabposition=tab_position) + + if element.BackgroundColor is not None and element.BackgroundColor != COLOR_SYSTEM_DEFAULT: + style.configure(custom_style, background=element.BackgroundColor) + + # FINALLY the proper styling to get tab colors! + if element.SelectedTitleColor is not None and element.SelectedTitleColor != COLOR_SYSTEM_DEFAULT: + style.map(custom_style + '.Tab', foreground=[("selected", element.SelectedTitleColor)]) + if element.SelectedBackgroundColor is not None and element.SelectedBackgroundColor != COLOR_SYSTEM_DEFAULT: + style.map(custom_style + '.Tab', background=[("selected", element.SelectedBackgroundColor)]) + if element.TabBackgroundColor is not None and element.TabBackgroundColor != COLOR_SYSTEM_DEFAULT: + style.configure(custom_style + '.Tab', background=element.TabBackgroundColor) + if element.TextColor is not None and element.TextColor != COLOR_SYSTEM_DEFAULT: + style.configure(custom_style + '.Tab', foreground=element.TextColor) + if element.BorderWidth is not None: + style.configure(custom_style, borderwidth=element.BorderWidth) + if element.TabBorderWidth is not None: + style.configure(custom_style + '.Tab', borderwidth=element.TabBorderWidth) # if ever want to get rid of border around the TABS themselves + if element.FocusColor not in (None, COLOR_SYSTEM_DEFAULT): + style.configure(custom_style + '.Tab', focuscolor=element.FocusColor) + + style.configure(custom_style + '.Tab', font=font) + element.Style = style + element.StyleName = custom_style + element.TKNotebook = element.Widget = ttk.Notebook(tk_row_frame, style=custom_style) + + PackFormIntoFrame(element, toplevel_form.TKroot, toplevel_form) + + expand, fill, row_should_expand, row_fill_direction = _add_expansion(element, row_should_expand, row_fill_direction) + element.TKNotebook.pack(anchor=tk.SW, side=tk.LEFT, padx=elementpad[0], pady=elementpad[1], fill=fill, expand=expand) + + if element.ChangeSubmits: + element.TKNotebook.bind('<>', element._TabGroupSelectHandler) + if element.Tooltip is not None: + element.TooltipObject = ToolTip(element.TKNotebook, text=element.Tooltip, timeout=DEFAULT_TOOLTIP_TIME) + if element.Size != (None, None): + element.TKNotebook.configure(width=element.Size[0], height=element.Size[1]) + _add_right_click_menu_and_grab(element) + if element.visible is False: + element._pack_forget_save_settings() + # row_should_expand = True + # ------------------- SLIDER placement element ------------------------- # + elif element_type == ELEM_TYPE_INPUT_SLIDER: + element = element # type: Slider + slider_length = element_size[0] * _char_width_in_pixels(font) + slider_width = element_size[1] + element.TKIntVar = tk.IntVar() + element.TKIntVar.set(element.DefaultValue) + if element.Orientation.startswith('v'): + range_from = element.Range[1] + range_to = element.Range[0] + slider_length += DEFAULT_MARGINS[1] * (element_size[0] * 2) # add in the padding + else: + range_from = element.Range[0] + range_to = element.Range[1] + tkscale = element.Widget = tk.Scale(tk_row_frame, orient=element.Orientation, + variable=element.TKIntVar, + from_=range_from, to_=range_to, resolution=element.Resolution, + length=slider_length, width=slider_width, + bd=element.BorderWidth, + relief=element.Relief, font=font, + tickinterval=element.TickInterval) + tkscale.config(highlightthickness=0) + if element.ChangeSubmits: + tkscale.config(command=element._SliderChangedHandler) + if element.BackgroundColor not in (None, COLOR_SYSTEM_DEFAULT): + tkscale.configure(background=element.BackgroundColor) + if element.TroughColor != COLOR_SYSTEM_DEFAULT: + tkscale.config(troughcolor=element.TroughColor) + if element.DisableNumericDisplay: + tkscale.config(showvalue=0) + if text_color not in (None, COLOR_SYSTEM_DEFAULT): + tkscale.configure(fg=text_color) + expand, fill, row_should_expand, row_fill_direction = _add_expansion(element, row_should_expand, row_fill_direction) + tkscale.pack(side=tk.LEFT, padx=elementpad[0], pady=elementpad[1], expand=expand, fill=fill) + if element.visible is False: + element._pack_forget_save_settings() + # tkscale.pack_forget() + element.TKScale = tkscale + if element.Disabled == True: + element.TKScale['state'] = 'disabled' + if element.Tooltip is not None: + element.TooltipObject = ToolTip(element.TKScale, text=element.Tooltip, timeout=DEFAULT_TOOLTIP_TIME) + _add_right_click_menu_and_grab(element) + + # ------------------------- TABLE placement element ------------------------- # + elif element_type == ELEM_TYPE_TABLE: + element = element # type: Table + element.element_frame = frame = tk.Frame(tk_row_frame) + element.table_frame = frame + height = element.NumRows + if element.Justification.startswith('l'): + anchor = tk.W + elif element.Justification.startswith('r'): + anchor = tk.E + else: + anchor = tk.CENTER + column_widths = {} + # create column width list + for row in element.Values: + for i, col in enumerate(row): + col_width = min(len(str(col)), element.MaxColumnWidth) + try: + if col_width > column_widths[i]: + column_widths[i] = col_width + except: + column_widths[i] = col_width + + if element.ColumnsToDisplay is None: + displaycolumns = element.ColumnHeadings if element.ColumnHeadings is not None else element.Values[0] + else: + displaycolumns = [] + for i, should_display in enumerate(element.ColumnsToDisplay): + if should_display: + if element.ColumnHeadings is not None: + displaycolumns.append(element.ColumnHeadings[i]) + else: + displaycolumns.append(str(i)) + + column_headings = element.ColumnHeadings if element.ColumnHeadings is not None else displaycolumns + if element.DisplayRowNumbers: # if display row number, tack on the numbers to front of columns + displaycolumns = [element.RowHeaderText, ] + displaycolumns + if column_headings is not None: + column_headings = [element.RowHeaderText, ] + element.ColumnHeadings + else: + column_headings = [element.RowHeaderText, ] + displaycolumns + element.TKTreeview = element.Widget = ttk.Treeview(frame, columns=column_headings, + displaycolumns=displaycolumns, show='headings', + height=height, + selectmode=element.SelectMode, ) + treeview = element.TKTreeview + if element.DisplayRowNumbers: + treeview.heading(element.RowHeaderText, text=element.RowHeaderText) # make a dummy heading + row_number_header_width =_string_width_in_pixels(element.HeaderFont, element.RowHeaderText) + 10 + row_number_width = _string_width_in_pixels(font, str(len(element.Values))) + 10 + row_number_width = max(row_number_header_width, row_number_width) + treeview.column(element.RowHeaderText, width=row_number_width, minwidth=10, anchor=anchor, stretch=0) + + headings = element.ColumnHeadings if element.ColumnHeadings is not None else element.Values[0] + for i, heading in enumerate(headings): + # heading = str(heading) + treeview.heading(heading, text=heading) + if element.AutoSizeColumns: + col_width = column_widths.get(i, len(heading)) # in case more headings than there are columns of data + width = max(col_width * _char_width_in_pixels(font), len(heading)*_char_width_in_pixels(element.HeaderFont)) + else: + try: + width = element.ColumnWidths[i] * _char_width_in_pixels(font) + except: + width = element.DefaultColumnWidth * _char_width_in_pixels(font) + if element.cols_justification is not None: + try: + if element.cols_justification[i].startswith('l'): + col_anchor = tk.W + elif element.cols_justification[i].startswith('r'): + col_anchor = tk.E + elif element.cols_justification[i].startswith('c'): + col_anchor = tk.CENTER + else: + col_anchor = anchor + + except: # likely didn't specify enough entries (must be one per col) + col_anchor = anchor + else: + col_anchor = anchor + treeview.column(heading, width=width, minwidth=10, anchor=col_anchor, stretch=element.expand_x) + # Insert values into the tree + for i, value in enumerate(element.Values): + if element.DisplayRowNumbers: + value = [i + element.StartingRowNumber] + value + id = treeview.insert('', 'end', text=value, iid=i + 1, values=value, tag=i) + element.tree_ids.append(id) + if element.AlternatingRowColor not in (None, COLOR_SYSTEM_DEFAULT): # alternating colors + for row in range(0, len(element.Values), 2): + treeview.tag_configure(row, background=element.AlternatingRowColor) + if element.RowColors is not None: # individual row colors + for row_def in element.RowColors: + if len(row_def) == 2: # only background is specified + treeview.tag_configure(row_def[0], background=row_def[1]) + else: + treeview.tag_configure(row_def[0], background=row_def[2], foreground=row_def[1]) + # ------ Do Styling of Colors ----- + # style_name = str(element.Key) + 'customtable.Treeview' + style_name = _make_ttk_style_name( '.Treeview', element, primary_style=True) + element.table_ttk_style_name = style_name + table_style = ttk.Style() + element.ttk_style = table_style + + _change_ttk_theme(table_style, toplevel_form.TtkTheme) + + if element.BackgroundColor is not None and element.BackgroundColor != COLOR_SYSTEM_DEFAULT: + table_style.configure(style_name, background=element.BackgroundColor, fieldbackground=element.BackgroundColor, ) + if element.SelectedRowColors[1] is not None: + table_style.map(style_name, background=_fixed_map(table_style, style_name, 'background', element.SelectedRowColors)) + if element.TextColor is not None and element.TextColor != COLOR_SYSTEM_DEFAULT: + table_style.configure(style_name, foreground=element.TextColor) + if element.SelectedRowColors[0] is not None: + table_style.map(style_name, foreground=_fixed_map(table_style, style_name, 'foreground', element.SelectedRowColors)) + if element.RowHeight is not None: + table_style.configure(style_name, rowheight=element.RowHeight) + else: + table_style.configure(style_name, rowheight=_char_height_in_pixels(font)) + if element.HeaderTextColor is not None and element.HeaderTextColor != COLOR_SYSTEM_DEFAULT: + table_style.configure(style_name + '.Heading', foreground=element.HeaderTextColor) + if element.HeaderBackgroundColor is not None and element.HeaderBackgroundColor != COLOR_SYSTEM_DEFAULT: + table_style.configure(style_name + '.Heading', background=element.HeaderBackgroundColor) + if element.HeaderFont is not None: + table_style.configure(style_name + '.Heading', font=element.HeaderFont) + else: + table_style.configure(style_name + '.Heading', font=font) + if element.HeaderBorderWidth is not None: + table_style.configure(style_name + '.Heading', borderwidth=element.HeaderBorderWidth) + if element.HeaderRelief is not None: + table_style.configure(style_name + '.Heading', relief=element.HeaderRelief) + table_style.configure(style_name, font=font) + if element.BorderWidth is not None: + table_style.configure(style_name, borderwidth=element.BorderWidth) + + if element.HeaderBackgroundColor not in (None, COLOR_SYSTEM_DEFAULT) and element.HeaderTextColor not in (None, COLOR_SYSTEM_DEFAULT): + table_style.map(style_name + ".Heading", background=[('pressed', '!focus', element.HeaderBackgroundColor), + ('active', element.HeaderTextColor),]) + table_style.map(style_name + ".Heading", foreground=[('pressed', '!focus', element.HeaderTextColor), + ('active', element.HeaderBackgroundColor),]) + + treeview.configure(style=style_name) + # scrollable_frame.pack(side=tk.LEFT, padx=elementpad[0], pady=elementpad[1], expand=True, fill='both') + if element.enable_click_events is True: + treeview.bind('', element._table_clicked) + if element.right_click_selects: + if running_mac(): + treeview.bind('', element._table_clicked) + else: + treeview.bind('', element._table_clicked) + treeview.bind("<>", element._treeview_selected) + if element.BindReturnKey: + treeview.bind('', element._treeview_double_click) + treeview.bind('', element._treeview_double_click) + + + + + if not element.HideVerticalScroll: + _make_ttk_scrollbar(element, 'v', toplevel_form) + + element.Widget.configure(yscrollcommand=element.vsb.set) + element.vsb.pack(side=tk.RIGHT, fill='y') + + # Horizontal scrollbar + if not element.VerticalScrollOnly: + # element.Widget.config(wrap='none') + _make_ttk_scrollbar(element, 'h', toplevel_form) + element.hsb.pack(side=tk.BOTTOM, fill='x') + element.Widget.configure(xscrollcommand=element.hsb.set) + + if not element.HideVerticalScroll or not element.VerticalScrollOnly: + # Chr0nic + element.Widget.bind("", lambda event, em=element: testMouseHook(em)) + element.Widget.bind("", lambda event, em=element: testMouseUnhook(em)) + + + + # if not element.HideVerticalScroll: + # scrollbar = tk.Scrollbar(frame) + # scrollbar.pack(side=tk.RIGHT, fill='y') + # scrollbar.config(command=treeview.yview) + # treeview.configure(yscrollcommand=scrollbar.set) + + # if not element.VerticalScrollOnly: + # hscrollbar = tk.Scrollbar(frame, orient=tk.HORIZONTAL) + # hscrollbar.pack(side=tk.BOTTOM, fill='x') + # hscrollbar.config(command=treeview.xview) + # treeview.configure(xscrollcommand=hscrollbar.set) + + + + + + + expand, fill, row_should_expand, row_fill_direction = _add_expansion(element, row_should_expand, row_fill_direction) + element.TKTreeview.pack(side=tk.LEFT, padx=0, pady=0, expand=expand, fill=fill) + frame.pack(side=tk.LEFT, padx=elementpad[0], pady=elementpad[1], expand=expand, fill=fill) + if element.visible is False: + element._pack_forget_save_settings(alternate_widget=element.element_frame) # seems like it should be the frame if following other elements conventions + # element.TKTreeview.pack_forget() + if element.Tooltip is not None: + element.TooltipObject = ToolTip(element.TKTreeview, text=element.Tooltip, + timeout=DEFAULT_TOOLTIP_TIME) + _add_right_click_menu_and_grab(element) + + if tclversion_detailed == '8.6.9' and ENABLE_TREEVIEW_869_PATCH: + # print('*** tk version 8.6.9 detected.... patching ttk treeview code ***') + table_style.map(style_name, + foreground=_fixed_map(table_style, style_name, 'foreground', element.SelectedRowColors), + background=_fixed_map(table_style, style_name, 'background', element.SelectedRowColors)) + # ------------------------- Tree placement element ------------------------- # + elif element_type == ELEM_TYPE_TREE: + element = element # type: Tree + element.element_frame = element_frame = tk.Frame(tk_row_frame) + + height = element.NumRows + if element.Justification.startswith('l'): # justification + anchor = tk.W + elif element.Justification.startswith('r'): + anchor = tk.E + else: + anchor = tk.CENTER + + if element.ColumnsToDisplay is None: # Which cols to display + displaycolumns = element.ColumnHeadings + else: + displaycolumns = [] + for i, should_display in enumerate(element.ColumnsToDisplay): + if should_display: + displaycolumns.append(element.ColumnHeadings[i]) + column_headings = element.ColumnHeadings + # ------------- GET THE TREEVIEW WIDGET ------------- + element.TKTreeview = element.Widget = ttk.Treeview(element_frame, columns=column_headings, + displaycolumns=displaycolumns, + show='tree headings' if column_headings is not None else 'tree', + height=height, + selectmode=element.SelectMode) + treeview = element.TKTreeview + max_widths = {} + for key, node in element.TreeData.tree_dict.items(): + for i, value in enumerate(node.values): + max_width = max_widths.get(i, 0) + if len(str(value)) > max_width: + max_widths[i] = len(str(value)) + + if element.ColumnHeadings is not None: + for i, heading in enumerate(element.ColumnHeadings): # Configure cols + headings + treeview.heading(heading, text=heading) + if element.AutoSizeColumns: + max_width = max_widths.get(i, 0) + max_width = max(max_width, len(heading)) + width = min(element.MaxColumnWidth, max_width+1) + else: + try: + width = element.ColumnWidths[i] + except: + width = element.DefaultColumnWidth + treeview.column(heading, width=width * _char_width_in_pixels(font) + 10, anchor=anchor) + + def add_treeview_data(node): + """ + + :param node: + :type node: + + """ + if node.key != '': + if node.icon: + if node.icon not in element.image_dict: + if type(node.icon) is bytes: + photo = tk.PhotoImage(data=node.icon) + else: + photo = tk.PhotoImage(file=node.icon) + element.image_dict[node.icon] = photo + else: + photo = element.image_dict.get(node.icon) + + node.photo = photo + try: + id = treeview.insert(element.KeyToID[node.parent], 'end', iid=None, text=node.text, values=node.values, open=element.ShowExpanded, image=node.photo) + element.IdToKey[id] = node.key + element.KeyToID[node.key] = id + except Exception as e: + print('Error inserting image into tree', e) + else: + id = treeview.insert(element.KeyToID[node.parent], 'end', iid=None, text=node.text, values=node.values, open=element.ShowExpanded) + element.IdToKey[id] = node.key + element.KeyToID[node.key] = id + + for node in node.children: + add_treeview_data(node) + + add_treeview_data(element.TreeData.root_node) + treeview.column('#0', width=element.Col0Width * _char_width_in_pixels(font), anchor=tk.W) + treeview.heading('#0', text=element.col0_heading) + + # ----- configure colors ----- + # style_name = str(element.Key) + '.Treeview' + style_name = _make_ttk_style_name('.Treeview', element, primary_style=True) + tree_style = ttk.Style() + _change_ttk_theme(tree_style, toplevel_form.TtkTheme) + + if element.BackgroundColor is not None and element.BackgroundColor != COLOR_SYSTEM_DEFAULT: + tree_style.configure(style_name, background=element.BackgroundColor, fieldbackground=element.BackgroundColor) + if element.SelectedRowColors[1] is not None: + tree_style.map(style_name, background=_fixed_map(tree_style, style_name, 'background', element.SelectedRowColors)) + if element.TextColor is not None and element.TextColor != COLOR_SYSTEM_DEFAULT: + tree_style.configure(style_name, foreground=element.TextColor) + if element.SelectedRowColors[0] is not None: + tree_style.map(style_name, foreground=_fixed_map(tree_style, style_name, 'foreground', element.SelectedRowColors)) + if element.HeaderTextColor is not None and element.HeaderTextColor != COLOR_SYSTEM_DEFAULT: + tree_style.configure(style_name + '.Heading', foreground=element.HeaderTextColor) + if element.HeaderBackgroundColor is not None and element.HeaderBackgroundColor != COLOR_SYSTEM_DEFAULT: + tree_style.configure(style_name + '.Heading', background=element.HeaderBackgroundColor) + if element.HeaderFont is not None: + tree_style.configure(style_name + '.Heading', font=element.HeaderFont) + else: + tree_style.configure(style_name + '.Heading', font=font) + if element.HeaderBorderWidth is not None: + tree_style.configure(style_name + '.Heading', borderwidth=element.HeaderBorderWidth) + if element.HeaderRelief is not None: + tree_style.configure(style_name + '.Heading', relief=element.HeaderRelief) + tree_style.configure(style_name, font=font) + if element.RowHeight: + tree_style.configure(style_name, rowheight=element.RowHeight) + else: + tree_style.configure(style_name, rowheight=_char_height_in_pixels(font)) + if element.BorderWidth is not None: + tree_style.configure(style_name, borderwidth=element.BorderWidth) + + treeview.configure(style=style_name) # IMPORTANT! Be sure and set the style name for this widget + + + + if not element.HideVerticalScroll: + _make_ttk_scrollbar(element, 'v', toplevel_form) + + element.Widget.configure(yscrollcommand=element.vsb.set) + element.vsb.pack(side=tk.RIGHT, fill='y') + + # Horizontal scrollbar + if not element.VerticalScrollOnly: + # element.Widget.config(wrap='none') + _make_ttk_scrollbar(element, 'h', toplevel_form) + element.hsb.pack(side=tk.BOTTOM, fill='x') + element.Widget.configure(xscrollcommand=element.hsb.set) + + if not element.HideVerticalScroll or not element.VerticalScrollOnly: + # Chr0nic + element.Widget.bind("", lambda event, em=element: testMouseHook(em)) + element.Widget.bind("", lambda event, em=element: testMouseUnhook(em)) + + + # Horizontal scrollbar + # if not element.VerticalScrollOnly: + # element.TKText.config(wrap='none') + # _make_ttk_scrollbar(element, 'h') + # element.hsb.pack(side=tk.BOTTOM, fill='x') + # element.Widget.configure(xscrollcommand=element.hsb.set) + + # if not element.HideVerticalScroll or not element.VerticalScrollOnly: + # Chr0nic + # element.Widget.bind("", lambda event, em=element: testMouseHook(em)) + # element.Widget.bind("", lambda event, em=element: testMouseUnhook(em)) + + + + + + # element.scrollbar = scrollbar = tk.Scrollbar(element_frame) + # scrollbar.pack(side=tk.RIGHT, fill='y') + # scrollbar.config(command=treeview.yview) + # treeview.configure(yscrollcommand=scrollbar.set) + + + expand, fill, row_should_expand, row_fill_direction = _add_expansion(element, row_should_expand, row_fill_direction) + element.TKTreeview.pack(side=tk.LEFT, padx=0, pady=0, expand=expand, fill=fill) + element_frame.pack(side=tk.LEFT, padx=elementpad[0], pady=elementpad[1], expand=expand, fill=fill) + if element.visible is False: + element._pack_forget_save_settings(alternate_widget=element.element_frame) # seems like it should be the frame if following other elements conventions + # element.TKTreeview.pack_forget() + treeview.bind("<>", element._treeview_selected) + if element.Tooltip is not None: # tooltip + element.TooltipObject = ToolTip(element.TKTreeview, text=element.Tooltip, + timeout=DEFAULT_TOOLTIP_TIME) + _add_right_click_menu_and_grab(element) + + if tclversion_detailed == '8.6.9' and ENABLE_TREEVIEW_869_PATCH: + # print('*** tk version 8.6.9 detected.... patching ttk treeview code ***') + tree_style.map(style_name, + foreground=_fixed_map(tree_style, style_name, 'foreground', element.SelectedRowColors), + background=_fixed_map(tree_style, style_name, 'background', element.SelectedRowColors)) + + # ------------------------- Separator placement element ------------------------- # + elif element_type == ELEM_TYPE_SEPARATOR: + element = element # type: VerticalSeparator + # style_name = str(element.Key) + "Line.TSeparator" + style_name = _make_ttk_style_name(".Line.TSeparator", element, primary_style=True) + style = ttk.Style() + + _change_ttk_theme(style, toplevel_form.TtkTheme) + + if element.color not in (None, COLOR_SYSTEM_DEFAULT): + style.configure(style_name, background=element.color) + separator = element.Widget = ttk.Separator(tk_row_frame, orient=element.Orientation, ) + + expand, fill, row_should_expand, row_fill_direction = _add_expansion(element, row_should_expand, row_fill_direction) + + if element.Orientation.startswith('h'): + separator.pack(side=tk.LEFT, padx=elementpad[0], pady=elementpad[1], fill=tk.X, expand=True) + else: + separator.pack(side=tk.LEFT, padx=elementpad[0], pady=elementpad[1], fill=tk.Y, expand=False) + element.Widget.configure(style=style_name) # IMPORTANT! Apply the style + # ------------------------- SizeGrip placement element ------------------------- # + elif element_type == ELEM_TYPE_SIZEGRIP: + element = element # type: Sizegrip + style_name = "Sizegrip.TSizegrip" + style = ttk.Style() + + _change_ttk_theme(style, toplevel_form.TtkTheme) + + size_grip = element.Widget = ttk.Sizegrip(tk_row_frame) + toplevel_form.sizegrip_widget = size_grip + # if no size is specified, then use the background color for the window + if element.BackgroundColor != COLOR_SYSTEM_DEFAULT: + style.configure(style_name, background=element.BackgroundColor) + else: + style.configure(style_name, background=toplevel_form.TKroot['bg']) + size_grip.configure(style=style_name) + + size_grip.pack(side=tk.BOTTOM, anchor='se', padx=elementpad[0], pady=elementpad[1], fill=tk.X, expand=True) + # tricky part of sizegrip... it shouldn't cause the row to expand, but should expand and should add X axis if + # not already filling in that direction. Otherwise, leaves things alone! + # row_should_expand = True + row_fill_direction = tk.BOTH if row_fill_direction in (tk.Y, tk.BOTH) else tk.X + # ------------------------- StatusBar placement element ------------------------- # + elif element_type == ELEM_TYPE_STATUSBAR: + # auto_size_text = element.AutoSizeText + display_text = element.DisplayText # text to display + if auto_size_text is False: + width, height = element_size + else: + lines = display_text.split('\n') + max_line_len = max([len(l) for l in lines]) + num_lines = len(lines) + if max_line_len > element_size[0]: # if text exceeds element size, the will have to wrap + width = element_size[0] + else: + width = max_line_len + height = num_lines + # ---===--- LABEL widget create and place --- # + stringvar = tk.StringVar() + element.TKStringVar = stringvar + stringvar.set(display_text) + if auto_size_text: + width = 0 + if element.Justification is not None: + justification = element.Justification + elif toplevel_form.TextJustification is not None: + justification = toplevel_form.TextJustification + else: + justification = DEFAULT_TEXT_JUSTIFICATION + justify = tk.LEFT if justification.startswith('l') else tk.CENTER if justification.startswith('c') else tk.RIGHT + anchor = tk.NW if justification.startswith('l') else tk.N if justification.startswith('c') else tk.NE + # tktext_label = tk.Label(tk_row_frame, textvariable=stringvar, width=width, height=height, + # justify=justify, bd=border_depth, font=font) + tktext_label = element.Widget = tk.Label(tk_row_frame, textvariable=stringvar, width=width, + height=height, + justify=justify, bd=border_depth, font=font) + # Set wrap-length for text (in PIXELS) == PAIN IN THE ASS + wraplen = tktext_label.winfo_reqwidth() + 40 # width of widget in Pixels + if not auto_size_text and height == 1: + wraplen = 0 + # print("wraplen, width, height", wraplen, width, height) + tktext_label.configure(anchor=anchor, wraplen=wraplen) # set wrap to width of widget + if element.Relief is not None: + tktext_label.configure(relief=element.Relief) + if element.BackgroundColor is not None and element.BackgroundColor != COLOR_SYSTEM_DEFAULT: + tktext_label.configure(background=element.BackgroundColor) + if element.TextColor != COLOR_SYSTEM_DEFAULT and element.TextColor is not None: + tktext_label.configure(fg=element.TextColor) + tktext_label.pack(side=tk.LEFT, padx=elementpad[0], pady=elementpad[1], fill=tk.X, expand=True) + row_fill_direction = tk.X + if element.visible is False: + element._pack_forget_save_settings() + # tktext_label.pack_forget() + element.TKText = tktext_label + if element.ClickSubmits: + tktext_label.bind('', element._TextClickedHandler) + if element.Tooltip is not None: + element.TooltipObject = ToolTip(element.TKText, text=element.Tooltip, timeout=DEFAULT_TOOLTIP_TIME) + _add_right_click_menu_and_grab(element) + + # ............................DONE WITH ROW pack the row of widgets ..........................# + # done with row, pack the row of widgets + # tk_row_frame.grid(row=row_num+2, sticky=tk.NW, padx=DEFAULT_MARGINS[0]) + + anchor = 'nw' + + if row_justify.lower().startswith('c'): + anchor = 'n' + side = tk.LEFT + elif row_justify.lower().startswith('r'): + anchor = 'ne' + side = tk.RIGHT + elif row_justify.lower().startswith('l'): + anchor = 'nw' + side = tk.LEFT + # elif toplevel_form.ElementJustification.lower().startswith('c'): + # anchor = 'n' + # side = tk.TOP + # elif toplevel_form.ElementJustification.lower().startswith('r'): + # anchor = 'ne' + # side = tk.TOP + # else: + # anchor = 'nw' + # side = tk.TOP + + # row_should_expand = False + + # if form.RightClickMenu: + # menu = form.RightClickMenu + # top_menu = tk.Menu(toplevel_form.TKroot, tearoff=False) + # AddMenuItem(top_menu, menu[1], form) + # tk_row_frame.bind('', form._RightClickMenuCallback) + + tk_row_frame.pack(side=tk.TOP, anchor=anchor, padx=0, pady=0, expand=row_should_expand, fill=row_fill_direction) + if form.BackgroundColor is not None and form.BackgroundColor != COLOR_SYSTEM_DEFAULT: + tk_row_frame.configure(background=form.BackgroundColor) + + return + + +def _get_hidden_master_root(): + """ + Creates the hidden master root window. This window is never visible and represents the overall "application" + """ + + # if one is already made, then skip making another + if Window.hidden_master_root is None: + Window._IncrementOpenCount() + Window.hidden_master_root = tk.Tk() + Window.hidden_master_root.attributes('-alpha', 0) # HIDE this window really really really + # if not running_mac(): + try: + Window.hidden_master_root.wm_overrideredirect(True) + except Exception as e: + if not running_mac(): + print('* Error performing wm_overrideredirect while hiding the hidden master root*', e) + Window.hidden_master_root.withdraw() + return Window.hidden_master_root + + +def _no_titlebar_setup(window): + """ + Does the operations required to turn off the titlebar for the window. + The Raspberry Pi required the settings to be make after the window's creation. + Calling twice seems to have had better overall results so that's what's currently done. + The MAC has been the problem with this feature. It's been a chronic problem on the Mac. + :param window: window to turn off the titlebar if indicated in the settings + :type window: Window + """ + try: + if window.NoTitleBar: + if running_linux(): + # window.TKroot.wm_attributes("-type", 'splash') + window.TKroot.wm_attributes("-type", 'dock') + else: + window.TKroot.wm_overrideredirect(True) + # Special case for Mac. Need to clear flag again if not tkinter version 8.6.10+ + # Previously restricted patch to only certain tkinter versions. Now use the patch setting exclusively regardless of tk ver + # if running_mac() and ENABLE_MAC_NOTITLEBAR_PATCH and (sum([int(i) for i in tclversion_detailed.split('.')]) < 24): + # if running_mac() and ENABLE_MAC_NOTITLEBAR_PATCH: + if _mac_should_apply_notitlebar_patch(): + print('* Applying Mac no_titlebar patch *') + window.TKroot.wm_overrideredirect(False) + except Exception as e: + warnings.warn('** Problem setting no titlebar {} **'.format(e), UserWarning) + + +def _convert_window_to_tk(window): + """ + + :type window: (Window) + + """ + master = window.TKroot + master.title(window.Title) + InitializeResults(window) + + + PackFormIntoFrame(window, master, window) + + window.TKroot.configure(padx=window.Margins[0], pady=window.Margins[1]) + + + # ....................................... DONE creating and laying out window ..........................# + if window._Size != (None, None): + master.geometry("%sx%s" % (window._Size[0], window._Size[1])) + screen_width = master.winfo_screenwidth() # get window info to move to middle of screen + screen_height = master.winfo_screenheight() + if window.Location is not None: + if window.Location != (None, None): + x, y = window.Location + elif DEFAULT_WINDOW_LOCATION != (None, None): + x, y = DEFAULT_WINDOW_LOCATION + else: + master.update_idletasks() # don't forget to do updates or values are bad + win_width = master.winfo_width() + win_height = master.winfo_height() + x = screen_width / 2 - win_width / 2 + y = screen_height / 2 - win_height / 2 + if y + win_height > screen_height: + y = screen_height - win_height + if x + win_width > screen_width: + x = screen_width - win_width + + if window.RelativeLoction != (None, None): + x += window.RelativeLoction[0] + y += window.RelativeLoction[1] + + move_string = '+%i+%i' % (int(x), int(y)) + master.geometry(move_string) + window.config_last_location = (int(x), (int(y))) + window.TKroot.x = int(x) + window.TKroot.y = int(y) + window.starting_window_position = (int(x), (int(y))) + master.update_idletasks() # don't forget + master.geometry(move_string) + master.update_idletasks() # don't forget + else: + master.update_idletasks() + x, y = int(master.winfo_x()), int(master.winfo_y()) + window.config_last_location = x,y + window.TKroot.x = x + window.TKroot.y = y + window.starting_window_position = x,y + _no_titlebar_setup(window) + + return + + +# ----====----====----====----====----==== STARTUP TK ====----====----====----====----====----# +def StartupTK(window): + """ + NOT user callable + Creates the window (for real) lays out all the elements, etc. It's a HUGE set of things it does. It's the basic + "porting layer" that will change depending on the GUI framework PySimpleGUI is running on top of. + + :param window: you window object + :type window: (Window) + + """ + window = window # type: Window + # global _my_windows + # ow = _my_windows.NumOpenWindows + ow = Window.NumOpenWindows + # print('Starting TK open Windows = {}'.format(ow)) + if ENABLE_TK_WINDOWS: + root = tk.Tk() + elif not ow and not window.ForceTopLevel: + # if first window being created, make a throwaway, hidden master root. This stops one user + # window from becoming the child of another user window. All windows are children of this hidden window + _get_hidden_master_root() + root = tk.Toplevel(class_=window.Title) + else: + root = tk.Toplevel(class_=window.Title) + if window.DebuggerEnabled: + root.bind('', window._callback_main_debugger_window_create_keystroke) + root.bind('', window._callback_popout_window_create_keystroke) + + + # If location is None, then there's no need to hide the window. Let it build where it is going to end up being. + if DEFAULT_HIDE_WINDOW_WHEN_CREATING is True and window.Location is not None: + try: + if not running_mac() or \ + (running_mac() and not window.NoTitleBar) or \ + (running_mac() and window.NoTitleBar and not _mac_should_apply_notitlebar_patch()): + + root.attributes('-alpha', 0) # hide window while building it. makes for smoother 'paint' + except Exception as e: + print('*** Exception setting alpha channel to zero while creating window ***', e) + + + if window.BackgroundColor is not None and window.BackgroundColor != COLOR_SYSTEM_DEFAULT: + root.configure(background=window.BackgroundColor) + Window._IncrementOpenCount() + + window.TKroot = root + + window._create_thread_queue() + + # for the Raspberry Pi. Need to set the attributes here, prior to the building of the window + # so going ahead and doing it for all platforms, in addition to doing it after the window is packed + # 2023-April - this call seems to be causing problems on MacOS 13.2.1 Ventura. Input elements become non-responsive + # if this call is made here and at the end of building the window + if not running_mac(): + _no_titlebar_setup(window) + + if not window.Resizable: + root.resizable(False, False) + + if window.DisableMinimize: + root.attributes("-toolwindow", 1) + + if window.KeepOnTop: + root.wm_attributes("-topmost", 1) + + if window.TransparentColor is not None: + window.SetTransparentColor(window.TransparentColor) + + if window.scaling is not None: + root.tk.call('tk', 'scaling', window.scaling) + + + # root.protocol("WM_DELETE_WINDOW", MyFlexForm.DestroyedCallback()) + # root.bind('', MyFlexForm.DestroyedCallback()) + _convert_window_to_tk(window) + + # Make moveable window + if (window.GrabAnywhere is not False and not ( + window.NonBlocking and window.GrabAnywhere is not True)): + if not (ENABLE_MAC_DISABLE_GRAB_ANYWHERE_WITH_TITLEBAR and running_mac() and not window.NoTitleBar): + root.bind("", window._StartMoveGrabAnywhere) + root.bind("", window._StopMove) + root.bind("", window._OnMotionGrabAnywhere) + if (window.GrabAnywhereUsingControlKey is not False and not ( + window.NonBlocking and window.GrabAnywhereUsingControlKey is not True)): + root.bind("", window._StartMoveUsingControlKey) + root.bind("", window._StopMove) + root.bind("", window._OnMotionUsingControlKey) + # also enable movement using Control + Arrow key + root.bind("", window._move_callback) + root.bind("", window._move_callback) + root.bind("", window._move_callback) + root.bind("", window._move_callback) + + window.set_icon(window.WindowIcon) + try: + alpha_channel = 1 if window.AlphaChannel is None else window.AlphaChannel + root.attributes('-alpha', alpha_channel) # Make window visible again + except Exception as e: + print('**** Error setting Alpha Channel to {} after window was created ****'.format(alpha_channel), e) + # pass + + if window.ReturnKeyboardEvents and not window.NonBlocking: + root.bind("", window._KeyboardCallback) + root.bind("", window._MouseWheelCallback) + root.bind("", window._MouseWheelCallback) + root.bind("", window._MouseWheelCallback) + elif window.ReturnKeyboardEvents: + root.bind("", window._KeyboardCallback) + root.bind("", window._MouseWheelCallback) + root.bind("", window._MouseWheelCallback) + root.bind("", window._MouseWheelCallback) + + DEFAULT_WINDOW_SNAPSHOT_KEY_CODE = main_global_get_screen_snapshot_symcode() + + if DEFAULT_WINDOW_SNAPSHOT_KEY_CODE: + # print('**** BINDING THE SNAPSHOT!', DEFAULT_WINDOW_SNAPSHOT_KEY_CODE, DEFAULT_WINDOW_SNAPSHOT_KEY) + window.bind(DEFAULT_WINDOW_SNAPSHOT_KEY_CODE, DEFAULT_WINDOW_SNAPSHOT_KEY, propagate=False) + # window.bind('', DEFAULT_WINDOW_SNAPSHOT_KEY, ) + + if window.NoTitleBar: + window.TKroot.focus_force() + + if window.AutoClose: + # if the window is being finalized, then don't start the autoclose timer + if not window.finalize_in_progress: + window._start_autoclose_timer() + # duration = DEFAULT_AUTOCLOSE_TIME if window.AutoCloseDuration is None else window.AutoCloseDuration + # window.TKAfterID = root.after(int(duration * 1000), window._AutoCloseAlarmCallback) + + if window.Timeout != None: + window.TKAfterID = root.after(int(window.Timeout), window._TimeoutAlarmCallback) + if window.NonBlocking: + window.TKroot.protocol("WM_DESTROY_WINDOW", window._OnClosingCallback) + window.TKroot.protocol("WM_DELETE_WINDOW", window._OnClosingCallback) + + else: # it's a blocking form + # print('..... CALLING MainLoop') + window.CurrentlyRunningMainloop = True + window.TKroot.protocol("WM_DESTROY_WINDOW", window._OnClosingCallback) + window.TKroot.protocol("WM_DELETE_WINDOW", window._OnClosingCallback) + + if window.modal or DEFAULT_MODAL_WINDOWS_FORCED: + window.make_modal() + + if window.enable_window_config_events: + window.TKroot.bind("", window._config_callback) + + # ----------------------------------- tkinter mainloop call ----------------------------------- + Window._window_running_mainloop = window + Window._root_running_mainloop = window.TKroot + window.TKroot.mainloop() + window.CurrentlyRunningMainloop = False + window.TimerCancelled = True + # print('..... BACK from MainLoop') + if not window.FormRemainedOpen: + Window._DecrementOpenCount() + # _my_windows.Decrement() + if window.RootNeedsDestroying: + try: + window.TKroot.destroy() + except: + pass + window.RootNeedsDestroying = False + return + + +def _set_icon_for_tkinter_window(root, icon=None, pngbase64=None): + """ + At the moment, this function is only used by the get_filename or folder with the no_window option set. + Changes the icon that is shown on the title bar and on the task bar. + NOTE - The file type is IMPORTANT and depends on the OS! + Can pass in: + * filename which must be a .ICO icon file for windows, PNG file for Linux + * bytes object + * BASE64 encoded file held in a variable + + :param root: The window being modified + :type root: (tk.Tk or tk.TopLevel) + :param icon: Filename or bytes object + :type icon: (str | bytes) + :param pngbase64: Base64 encoded image + :type pngbase64: (bytes) + """ + + if type(icon) is bytes or pngbase64 is not None: + wicon = tkinter.PhotoImage(data=icon if icon is not None else pngbase64) + try: + root.tk.call('wm', 'iconphoto', root._w, wicon) + except: + wicon = tkinter.PhotoImage(data=DEFAULT_BASE64_ICON) + try: + root.tk.call('wm', 'iconphoto', root._w, wicon) + except Exception as e: + print('Set icon exception', e) + pass + return + + wicon = icon + try: + root.iconbitmap(icon) + except Exception as e: + try: + wicon = tkinter.PhotoImage(file=icon) + root.tk.call('wm', 'iconphoto', root._w, wicon) + except Exception as e: + try: + wicon = tkinter.PhotoImage(data=DEFAULT_BASE64_ICON) + try: + root.tk.call('wm', 'iconphoto', root._w, wicon) + except Exception as e: + print('Set icon exception', e) + pass + except: + print('Set icon exception', e) + pass + + +# ==============================_GetNumLinesNeeded ==# +# Helper function for determining how to wrap text # +# ===================================================# +def _GetNumLinesNeeded(text, max_line_width): + if max_line_width == 0: + return 1 + lines = text.split('\n') + num_lines = len(lines) # number of original lines of text + max_line_len = max([len(l) for l in lines]) # longest line + lines_used = [] + for L in lines: + lines_used.append(len(L) // max_line_width + (len(L) % max_line_width > 0)) # fancy math to round up + total_lines_needed = sum(lines_used) + return total_lines_needed + + +# ============================== PROGRESS METER ========================================== # + +def convert_args_to_single_string(*args): + """ + + :param *args: + :type *args: + + """ + max_line_total, width_used, total_lines, = 0, 0, 0 + single_line_message = '' + # loop through args and built a SINGLE string from them + for message in args: + # fancy code to check if string and convert if not is not need. Just always convert to string :-) + # if not isinstance(message, str): message = str(message) + message = str(message) + longest_line_len = max([len(l) for l in message.split('\n')]) + width_used = max(longest_line_len, width_used) + max_line_total = max(max_line_total, width_used) + lines_needed = _GetNumLinesNeeded(message, width_used) + total_lines += lines_needed + single_line_message += message + '\n' + return single_line_message, width_used, total_lines + + +METER_REASON_CANCELLED = 'cancelled' +METER_REASON_CLOSED = 'closed' +METER_REASON_REACHED_MAX = 'finished' +METER_OK = True +METER_STOPPED = False + + +class _QuickMeter(object): + active_meters = {} + exit_reasons = {} + + def __init__(self, title, current_value, max_value, key, *args, orientation='v', bar_color=(None, None), button_color=(None, None), + size=DEFAULT_PROGRESS_BAR_SIZE, border_width=None, grab_anywhere=False, no_titlebar=False, keep_on_top=None, no_button=False): + """ + + :param title: text to display in element + :type title: (str) + :param current_value: current value + :type current_value: (int) + :param max_value: max value of progress meter + :type max_value: (int) + :param key: Used with window.find_element and with return values to uniquely identify this element + :type key: str | int | tuple | object + :param *args: stuff to output + :type *args: (Any) + :param orientation: 'horizontal' or 'vertical' ('h' or 'v' work) (Default value = 'vertical' / 'v') + :type orientation: (str) + :param bar_color: The 2 colors that make up a progress bar. Either a tuple of 2 strings or a string. Tuple - (bar, background). A string with 1 color changes the background of the bar only. A string with 2 colors separated by "on" like "red on blue" specifies a red bar on a blue background. + :type bar_color: (str, str) or str + :param button_color: button color (foreground, background) + :type button_color: (str, str) | str + :param size: (w,h) w=characters-wide, h=rows-high (Default value = DEFAULT_PROGRESS_BAR_SIZE) + :type size: (int, int) + :param border_width: width of border around element + :type border_width: (int) + :param grab_anywhere: If True: can grab anywhere to move the window (Default = False) + :type grab_anywhere: (bool) + :param no_titlebar: If True: window will be created without a titlebar + :type no_titlebar: (bool) + :param keep_on_top: If True the window will remain above all current windows + :type keep_on_top: (bool) + :param no_button: If True: window will be created without a cancel button + :type no_button: (bool) + """ + self.start_time = datetime.datetime.utcnow() + self.key = key + self.orientation = orientation + self.bar_color = bar_color + self.size = size + self.grab_anywhere = grab_anywhere + self.button_color = button_color + self.border_width = border_width + self.no_titlebar = no_titlebar + self.title = title + self.current_value = current_value + self.max_value = max_value + self.close_reason = None + self.keep_on_top = keep_on_top + self.no_button = no_button + self.window = self.BuildWindow(*args) + + def BuildWindow(self, *args): + layout = [] + if self.orientation.lower().startswith('h'): + col = [] + col += [[T(''.join(map(lambda x: str(x) + '\n', args)), + key='-OPTMSG-')]] ### convert all *args into one string that can be updated + col += [[T('', size=(30, 10), key='-STATS-')], + [ProgressBar(max_value=self.max_value, orientation='h', key='-PROG-', size=self.size, + bar_color=self.bar_color)]] + if not self.no_button: + col += [[Cancel(button_color=self.button_color), Stretch()]] + layout = [Column(col)] + else: + col = [[ProgressBar(max_value=self.max_value, orientation='v', key='-PROG-', size=self.size, + bar_color=self.bar_color)]] + col2 = [] + col2 += [[T(''.join(map(lambda x: str(x) + '\n', args)), + key='-OPTMSG-')]] ### convert all *args into one string that can be updated + col2 += [[T('', size=(30, 10), key='-STATS-')]] + if not self.no_button: + col2 += [[Cancel(button_color=self.button_color), Stretch()]] + + layout = [Column(col), Column(col2)] + self.window = Window(self.title, grab_anywhere=self.grab_anywhere, border_depth=self.border_width, no_titlebar=self.no_titlebar, disable_close=True, keep_on_top=self.keep_on_top) + self.window.Layout([layout]).Finalize() + + return self.window + + def UpdateMeter(self, current_value, max_value, *args): ### support for *args when updating + + self.current_value = current_value + self.max_value = max_value + self.window.Element('-PROG-').UpdateBar(self.current_value, self.max_value) + self.window.Element('-STATS-').Update('\n'.join(self.ComputeProgressStats())) + self.window.Element('-OPTMSG-').Update( + value=''.join(map(lambda x: str(x) + '\n', args))) ### update the string with the args + event, values = self.window.read(timeout=0) + if event in ('Cancel', None) or current_value >= max_value: + exit_reason = METER_REASON_CANCELLED if event in ('Cancel', None) else METER_REASON_REACHED_MAX if current_value >= max_value else METER_STOPPED + self.window.close() + del (_QuickMeter.active_meters[self.key]) + _QuickMeter.exit_reasons[self.key] = exit_reason + return _QuickMeter.exit_reasons[self.key] + return METER_OK + + def ComputeProgressStats(self): + utc = datetime.datetime.utcnow() + time_delta = utc - self.start_time + total_seconds = time_delta.total_seconds() + if not total_seconds: + total_seconds = 1 + try: + time_per_item = total_seconds / self.current_value + except: + time_per_item = 1 + seconds_remaining = (self.max_value - self.current_value) * time_per_item + time_remaining = str(datetime.timedelta(seconds=seconds_remaining)) + time_remaining_short = (time_remaining).split(".")[0] + time_delta_short = str(time_delta).split(".")[0] + total_time = time_delta + datetime.timedelta(seconds=seconds_remaining) + total_time_short = str(total_time).split(".")[0] + self.stat_messages = [ + '{} of {}'.format(self.current_value, self.max_value), + '{} %'.format(100 * self.current_value // self.max_value), + '', + ' {:6.2f} Iterations per Second'.format(self.current_value / total_seconds), + ' {:6.2f} Seconds per Iteration'.format(total_seconds / (self.current_value if self.current_value else 1)), + '', + '{} Elapsed Time'.format(time_delta_short), + '{} Time Remaining'.format(time_remaining_short), + '{} Estimated Total Time'.format(total_time_short)] + return self.stat_messages + + +def one_line_progress_meter(title, current_value, max_value, *args, key='OK for 1 meter', orientation='v', bar_color=(None, None), button_color=None, size=DEFAULT_PROGRESS_BAR_SIZE, border_width=None, grab_anywhere=False, no_titlebar=False, keep_on_top=None, no_button=False): + """ + :param title: text to display in titlebar of window + :type title: (str) + :param current_value: current value + :type current_value: (int) + :param max_value: max value of progress meter + :type max_value: (int) + :param *args: stuff to output as text in the window along with the meter + :type *args: (Any) + :param key: Used to differentiate between multiple meters. Used to cancel meter early. Now optional as there is a default value for single meters + :type key: str | int | tuple | object + :param orientation: 'horizontal' or 'vertical' ('h' or 'v' work) (Default value = 'vertical' / 'v') + :type orientation: (str) + :param bar_color: The 2 colors that make up a progress bar. Either a tuple of 2 strings or a string. Tuple - (bar, background). A string with 1 color changes the background of the bar only. A string with 2 colors separated by "on" like "red on blue" specifies a red bar on a blue background. + :type bar_color: (str, str) or str + :param button_color: button color (foreground, background) + :type button_color: (str, str) | str + :param size: (w,h) w=characters-wide, h=rows-high (Default value = DEFAULT_PROGRESS_BAR_SIZE) + :type size: (int, int) + :param border_width: width of border around element + :type border_width: (int) + :param grab_anywhere: If True: can grab anywhere to move the window (Default = False) + :type grab_anywhere: (bool) + :param no_titlebar: If True: no titlebar will be shown on the window + :type no_titlebar: (bool) + :param keep_on_top: If True the window will remain above all current windows + :type keep_on_top: (bool) + :param no_button: If True: window will be created without a cancel button + :type no_button: (bool) + :return: True if updated successfully. False if user closed the meter with the X or Cancel button + :rtype: (bool) + """ + if key not in _QuickMeter.active_meters: + meter = _QuickMeter(title, current_value, max_value, key, *args, orientation=orientation, bar_color=bar_color, button_color=button_color, size=size, border_width=border_width, grab_anywhere=grab_anywhere, no_titlebar=no_titlebar, keep_on_top=keep_on_top, no_button=no_button) + _QuickMeter.active_meters[key] = meter + _QuickMeter.exit_reasons[key] = None + + else: + meter = _QuickMeter.active_meters[key] + + rc = meter.UpdateMeter(current_value, max_value, *args) ### pass the *args to to UpdateMeter function + OneLineProgressMeter.exit_reasons = getattr(OneLineProgressMeter, 'exit_reasons', _QuickMeter.exit_reasons) + exit_reason = OneLineProgressMeter.exit_reasons.get(key) + return METER_OK if exit_reason in (None, METER_REASON_REACHED_MAX) else METER_STOPPED + + +def one_line_progress_meter_cancel(key='OK for 1 meter'): + """ + Cancels and closes a previously created One Line Progress Meter window + + :param key: Key used when meter was created + :type key: (Any) + :return: None + :rtype: None + """ + try: + meter = _QuickMeter.active_meters[key] + meter.window.Close() + del (_QuickMeter.active_meters[key]) + _QuickMeter.exit_reasons[key] = METER_REASON_CANCELLED + except: # meter is already deleted + return + + +def get_complimentary_hex(color): + """ + :param color: color string, like "#RRGGBB" + :type color: (str) + :return: color string, like "#RRGGBB" + :rtype: (str) + """ + + # strip the # from the beginning + color = color[1:] + # convert the string into hex + color = int(color, 16) + # invert the three bytes + # as good as substracting each of RGB component by 255(FF) + comp_color = 0xFFFFFF ^ color + # convert the color back to hex by prefixing a # + comp_color = "#%06X" % comp_color + return comp_color + + +# ======================== EasyPrint =====# +# ===================================================# +class _DebugWin(): + debug_window = None + + def __init__(self, size=(None, None), location=(None, None), relative_location=(None, None), font=None, no_titlebar=False, no_button=False, + grab_anywhere=False, keep_on_top=None, do_not_reroute_stdout=True, echo_stdout=False, resizable=True, blocking=False): + """ + + :param size: (w,h) w=characters-wide, h=rows-high + :type size: (int, int) + :param location: Location of upper left corner of the window + :type location: (int, int) + :param relative_location: (x,y) location relative to the default location of the window, in pixels. Normally the window centers. This location is relative to the location the window would be created. Note they can be negative. + :type relative_location: (int, int) + :param font: specifies the font family, size, etc. Tuple or Single string format 'name size styles'. Styles: italic * roman bold normal underline overstrike + :type font: (str or (str, int[, str]) or None) + :param no_titlebar: If True no titlebar will be shown + :type no_titlebar: (bool) + :param no_button: show button + :type no_button: (bool) + :param grab_anywhere: If True: can grab anywhere to move the window (Default = False) + :type grab_anywhere: (bool) + :param location: Location of upper left corner of the window + :type location: (int, int) + :param do_not_reroute_stdout: bool value + :type do_not_reroute_stdout: (bool) + :param echo_stdout: If True stdout is sent to both the console and the debug window + :type echo_stdout: (bool) + :param resizable: if True, makes the window resizble + :type resizable: (bool) + :param blocking: if True, makes the window block instead of returning immediately + :type blocking: (bool) + """ + + # Show a form that's a running counter + self.size = size + self.location = location + self.relative_location = relative_location + self.font = font + self.no_titlebar = no_titlebar + self.no_button = no_button + self.grab_anywhere = grab_anywhere + self.keep_on_top = keep_on_top + self.do_not_reroute_stdout = do_not_reroute_stdout + self.echo_stdout = echo_stdout + self.resizable = resizable + self.blocking = blocking + + win_size = size if size != (None, None) else DEFAULT_DEBUG_WINDOW_SIZE + self.output_element = Multiline(size=win_size, autoscroll=True, auto_refresh=True, reroute_stdout=False if do_not_reroute_stdout else True, echo_stdout_stderr=self.echo_stdout, reroute_stderr=False if do_not_reroute_stdout else True, expand_x=True, expand_y=True, key='-MULTILINE-') + if no_button: + self.layout = [[self.output_element]] + else: + if blocking: + self.quit_button = Button('Quit', key='Quit') + else: + self.quit_button = DummyButton('Quit', key='Quit') + self.layout = [[self.output_element], + [pin(self.quit_button), pin(B('Pause', key='-PAUSE-')), Stretch()]] + + self.layout[-1] += [Sizegrip()] + + self.window = Window('Debug Window', self.layout, no_titlebar=no_titlebar, auto_size_text=True, location=location, relative_location=relative_location, + font=font or ('Courier New', 10), grab_anywhere=grab_anywhere, keep_on_top=keep_on_top, finalize=True, resizable=resizable) + return + + def reopen_window(self): + if self.window is None or (self.window is not None and self.window.is_closed()): + self.__init__(size=self.size, location=self.location, relative_location=self.relative_location, font=self.font, no_titlebar=self.no_titlebar, + no_button=self.no_button, grab_anywhere=self.grab_anywhere, keep_on_top=self.keep_on_top, + do_not_reroute_stdout=self.do_not_reroute_stdout, resizable=self.resizable, echo_stdout=self.echo_stdout) + + + def Print(self, *args, end=None, sep=None, text_color=None, background_color=None, erase_all=False, font=None, blocking=None): + global SUPPRESS_WIDGET_NOT_FINALIZED_WARNINGS + suppress = SUPPRESS_WIDGET_NOT_FINALIZED_WARNINGS + SUPPRESS_WIDGET_NOT_FINALIZED_WARNINGS = True + sepchar = sep if sep is not None else ' ' + endchar = end if end is not None else '\n' + self.reopen_window() # if needed, open the window again + + timeout = 0 if not blocking else None + if erase_all: + self.output_element.update('') + + if self.do_not_reroute_stdout: + end_str = str(end) if end is not None else '\n' + sep_str = str(sep) if sep is not None else ' ' + + outstring = '' + num_args = len(args) + for i, arg in enumerate(args): + outstring += str(arg) + if i != num_args - 1: + outstring += sep_str + outstring += end_str + try: + self.output_element.update(outstring, append=True, text_color_for_value=text_color, background_color_for_value=background_color, font_for_value=font) + except: + self.window=None + self.reopen_window() + self.output_element.update(outstring, append=True, text_color_for_value=text_color, background_color_for_value=background_color, font_for_value=font) + + else: + print(*args, sep=sepchar, end=endchar) + # This is tricky....changing the button type depending on the blocking parm. If blocking, then the "Quit" button should become a normal button + if blocking and not self.no_button: + self.quit_button.BType = BUTTON_TYPE_READ_FORM + try: # The window may be closed by user at any time, so have to protect + self.quit_button.update(text='Click to continue...') + except: + self.window = None + elif not self.no_button: + self.quit_button.BType = BUTTON_TYPE_CLOSES_WIN_ONLY + try: # The window may be closed by user at any time, so have to protect + self.quit_button.update(text='Quit') + except: + self.window = None + + try: # The window may be closed by user at any time, so have to protect + if blocking and not self.no_button: + self.window['-PAUSE-'].update(visible=False) + elif not self.no_button: + self.window['-PAUSE-'].update(visible=True) + except: + self.window = None + + self.reopen_window() # if needed, open the window again + + paused = None + while True: + event, values = self.window.read(timeout=timeout) + + if event == WIN_CLOSED: + self.Close() + break + elif blocking and event == 'Quit': + break + elif not paused and event == TIMEOUT_EVENT and not blocking: + break + elif event == '-PAUSE-': + if blocking or self.no_button: # if blocking or shouldn't have been a button event, ignore the pause button entirely + continue + if paused: + self.window['-PAUSE-'].update(text='Pause') + self.quit_button.update(visible=True) + break + paused = True + self.window['-PAUSE-'].update(text='Resume') + self.quit_button.update(visible=False) + timeout = None + + SUPPRESS_WIDGET_NOT_FINALIZED_WARNINGS = suppress + + def Close(self): + if self.window.XFound: # increment the number of open windows to get around a bug with debug windows + Window._IncrementOpenCount() + self.window.close() + self.window = None + + +def easy_print(*args, size=(None, None), end=None, sep=None, location=(None, None), relative_location=(None, None), font=None, no_titlebar=False, + no_button=False, grab_anywhere=False, keep_on_top=None, do_not_reroute_stdout=True, echo_stdout=False, text_color=None, background_color=None, colors=None, c=None, erase_all=False, resizable=True, blocking=None, wait=None): + """ + Works like a "print" statement but with windowing options. Routes output to the "Debug Window" + + In addition to the normal text and background colors, you can use a "colors" tuple/string + The "colors" or "c" parameter defines both the text and background in a single parm. + It can be a tuple or a single single. Both text and background colors need to be specified + colors -(str, str) or str. A combined text/background color definition in a single parameter + c - (str, str) - Colors tuple has format (foreground, backgrouned) + c - str - can also be a string of the format "foreground on background" ("white on red") + + :param *args: stuff to output + :type *args: (Any) + :param size: (w,h) w=characters-wide, h=rows-high + :type size: (int, int) + :param end: end character + :type end: (str) + :param sep: separator character + :type sep: (str) + :param location: Location of upper left corner of the window + :type location: (int, int) + :param relative_location: (x,y) location relative to the default location of the window, in pixels. Normally the window centers. This location is relative to the location the window would be created. Note they can be negative. + :type relative_location: (int, int) + :param font: specifies the font family, size, etc. Tuple or Single string format 'name size styles'. Styles: italic * roman bold normal underline overstrike + :type font: (str or (str, int[, str]) or None) + :param no_titlebar: If True no titlebar will be shown + :type no_titlebar: (bool) + :param no_button: don't show button + :type no_button: (bool) + :param grab_anywhere: If True: can grab anywhere to move the window (Default = False) + :type grab_anywhere: (bool) + :param background_color: color of background + :type background_color: (str) + :param text_color: color of the text + :type text_color: (str) + :param keep_on_top: If True the window will remain above all current windows + :type keep_on_top: (bool) + :param location: Location of upper left corner of the window + :type location: (int, int) + :param do_not_reroute_stdout: do not reroute stdout and stderr. If False, both stdout and stderr will reroute to here + :type do_not_reroute_stdout: (bool) + :param echo_stdout: If True stdout is sent to both the console and the debug window + :type echo_stdout: (bool) + :param colors: Either a tuple or a string that has both the text and background colors + :type colors: (str) or (str, str) + :param c: Either a tuple or a string that has both the text and background colors + :type c: (str) or (str, str) + :param resizable: if True, the user can resize the debug window. Default is True + :type resizable: (bool) + :param erase_all: If True when erase the output before printing + :type erase_all: (bool) + :param blocking: if True, makes the window block instead of returning immediately. The "Quit" button changers to "More" + :type blocking: (bool | None) + :param wait: Same as the "blocking" parm. It's an alias. if True, makes the window block instead of returning immediately. The "Quit" button changes to "Click to Continue..." + :type wait: (bool | None) + :return: + :rtype: + """ + + blocking = blocking or wait + if _DebugWin.debug_window is None: + _DebugWin.debug_window = _DebugWin(size=size, location=location, relative_location=relative_location, font=font, no_titlebar=no_titlebar, + no_button=no_button, grab_anywhere=grab_anywhere, keep_on_top=keep_on_top, + do_not_reroute_stdout=do_not_reroute_stdout, echo_stdout=echo_stdout, resizable=resizable, blocking=blocking) + txt_color, bg_color = _parse_colors_parm(c or colors) + _DebugWin.debug_window.Print(*args, end=end, sep=sep, text_color=text_color or txt_color, background_color=background_color or bg_color, + erase_all=erase_all, font=font, blocking=blocking) + + +def easy_print_close(): + """ + Close a previously opened EasyPrint window + + :return: + :rtype: + """ + if _DebugWin.debug_window is not None: + _DebugWin.debug_window.Close() + _DebugWin.debug_window = None + + +# d8b 888 +# Y8P 888 +# 888 +# .d8888b 88888b. 888d888 888 88888b. 888888 +# d88P" 888 "88b 888P" 888 888 "88b 888 +# 888 888 888 888 888 888 888 888 +# Y88b. 888 d88P 888 888 888 888 Y88b. +# "Y8888P 88888P" 888 888 888 888 "Y888 +# 888 +# 888 +# 888 + + +CPRINT_DESTINATION_WINDOW = None +CPRINT_DESTINATION_MULTILINE_ELMENT_KEY = None + + +def cprint_set_output_destination(window, multiline_key): + """ + Sets up the color print (cprint) output destination + :param window: The window that the cprint call will route the output to + :type window: (Window) + :param multiline_key: Key for the Multiline Element where output will be sent + :type multiline_key: (Any) + :return: None + :rtype: None + """ + + global CPRINT_DESTINATION_WINDOW, CPRINT_DESTINATION_MULTILINE_ELMENT_KEY + + CPRINT_DESTINATION_WINDOW = window + CPRINT_DESTINATION_MULTILINE_ELMENT_KEY = multiline_key + + +def cprint(*args, end=None, sep=' ', text_color=None, font=None, t=None, background_color=None, b=None, colors=None, c=None, window=None, key=None, + justification=None, autoscroll=True, erase_all=False): + """ + Color print to a multiline element in a window of your choice. + Must have EITHER called cprint_set_output_destination prior to making this call so that the + window and element key can be saved and used here to route the output, OR used the window + and key parameters to the cprint function to specicy these items. + + args is a variable number of things you want to print. + + end - The end char to use just like print uses + sep - The separation character like print uses + text_color - The color of the text + key - overrides the previously defined Multiline key + window - overrides the previously defined window to output to + background_color - The color of the background + colors -(str, str) or str. A combined text/background color definition in a single parameter + + There are also "aliases" for text_color, background_color and colors (t, b, c) + t - An alias for color of the text (makes for shorter calls) + b - An alias for the background_color parameter + c - (str, str) - "shorthand" way of specifying color. (foreground, backgrouned) + c - str - can also be a string of the format "foreground on background" ("white on red") + + With the aliases it's possible to write the same print but in more compact ways: + cprint('This will print white text on red background', c=('white', 'red')) + cprint('This will print white text on red background', c='white on red') + cprint('This will print white text on red background', text_color='white', background_color='red') + cprint('This will print white text on red background', t='white', b='red') + + :param *args: stuff to output + :type *args: (Any) + :param text_color: Color of the text + :type text_color: (str) + :param font: specifies the font family, size, etc. Tuple or Single string format 'name size styles'. Styles: italic * roman bold normal underline overstrike for the value being updated + :type font: (str or (str, int[, str]) or None) + :param background_color: The background color of the line + :type background_color: (str) + :param colors: Either a tuple or a string that has both the text and background colors "text on background" or just the text color + :type colors: (str) or (str, str) + :param t: Color of the text + :type t: (str) + :param b: The background color of the line + :type b: (str) + :param c: Either a tuple or a string. Same as the color parm + :type c: (str) or (str, str) + :param end: end character + :type end: (str) + :param sep: separator character + :type sep: (str) + :param key: key of multiline to output to (if you want to override the one previously set) + :type key: (Any) + :param window: Window containing the multiline to output to (if you want to override the one previously set) + :type window: (Window) + :param justification: text justification. left, right, center. Can use single characters l, r, c. Sets only for this value, not entire element + :type justification: (str) + :param autoscroll: If True the contents of the element will automatically scroll as more data added to the end + :type autoscroll: (bool) + :param erase_all If True the contents of the element will be cleared before printing happens + :type erase_all (bool) + """ + + destination_key = CPRINT_DESTINATION_MULTILINE_ELMENT_KEY if key is None else key + destination_window = window or CPRINT_DESTINATION_WINDOW + + if (destination_window is None and window is None) or (destination_key is None and key is None): + print('** Warning ** Attempting to perform a cprint without a valid window & key', + 'Will instead print on Console', + 'You can specify window and key in this cprint call, or set ahead of time using cprint_set_output_destination') + print(*args) + return + + kw_text_color = text_color or t + kw_background_color = background_color or b + dual_color = colors or c + try: + if isinstance(dual_color, tuple): + kw_text_color = dual_color[0] + kw_background_color = dual_color[1] + elif isinstance(dual_color, str): + if ' on ' in dual_color: # if has "on" in the string, then have both text and background + kw_text_color = dual_color.split(' on ')[0] + kw_background_color = dual_color.split(' on ')[1] + else: # if no "on" then assume the color string is just the text color + kw_text_color = dual_color + except Exception as e: + print('* cprint warning * you messed up with color formatting', e) + + mline = destination_window.find_element(destination_key, silent_on_error=True) # type: Multiline + try: + # mline = destination_window[destination_key] # type: Multiline + if erase_all is True: + mline.update('') + if end is None: + mline.print(*args, text_color=kw_text_color, background_color=kw_background_color, end='', sep=sep, justification=justification, font=font, + autoscroll=autoscroll) + mline.print('', justification=justification, autoscroll=autoscroll) + else: + mline.print(*args, text_color=kw_text_color, background_color=kw_background_color, end=end, sep=sep, justification=justification, font=font, + autoscroll=autoscroll) + except Exception as e: + print('** cprint error trying to print to the multiline. Printing to console instead **', e) + print(*args, end=end, sep=sep) + + +# ------------------------------------------------------------------------------------------------ # +# A print-like call that can be used to output to a multiline element as if it's an Output element # +# ------------------------------------------------------------------------------------------------ # + +def _print_to_element(multiline_element, *args, end=None, sep=None, text_color=None, background_color=None, autoscroll=None, justification=None, font=None): + """ + Print like Python normally prints except route the output to a multiline element and also add colors if desired + + :param multiline_element: The multiline element to be output to + :type multiline_element: (Multiline) + :param args: The arguments to print + :type args: List[Any] + :param end: The end char to use just like print uses + :type end: (str) + :param sep: The separation character like print uses + :type sep: (str) + :param text_color: color of the text + :type text_color: (str) + :param background_color: The background color of the line + :type background_color: (str) + :param autoscroll: If True (the default), the element will scroll to bottom after updating + :type autoscroll: (bool) + :param font: specifies the font family, size, etc. Tuple or Single string format 'name size styles'. Styles: italic * roman bold normal underline overstrike for the value being updated + :type font: str | (str, int) + """ + end_str = str(end) if end is not None else '\n' + sep_str = str(sep) if sep is not None else ' ' + + outstring = '' + num_args = len(args) + for i, arg in enumerate(args): + outstring += str(arg) + if i != num_args - 1: + outstring += sep_str + outstring += end_str + + multiline_element.update(outstring, append=True, text_color_for_value=text_color, background_color_for_value=background_color, autoscroll=autoscroll, + justification=justification, font_for_value=font) + + try: # if the element is set to autorefresh, then refresh the parent window + if multiline_element.AutoRefresh: + multiline_element.ParentForm.refresh() + except: + pass + + +def _parse_colors_parm(colors): + """ + Parse a colors parameter into its separate colors. + Some functions accept a dual colors string/tuple. + This function parses the parameter into the component colors + + :param colors: Either a tuple or a string that has both the text and background colors + :type colors: (str) or (str, str) + :return: tuple with the individual text and background colors + :rtype: (str, str) + """ + if colors is None: + return None, None + dual_color = colors + kw_text_color = kw_background_color = None + try: + if isinstance(dual_color, tuple): + kw_text_color = dual_color[0] + kw_background_color = dual_color[1] + elif isinstance(dual_color, str): + if ' on ' in dual_color: # if has "on" in the string, then have both text and background + kw_text_color = dual_color.split(' on ')[0] + kw_background_color = dual_color.split(' on ')[1] + else: # if no "on" then assume the color string is just the text color + kw_text_color = dual_color + except Exception as e: + print('* warning * you messed up with color formatting', e) + + return kw_text_color, kw_background_color + + +# ============================== set_global_icon ====# +# Sets the icon to be used by default # +# ===================================================# +def set_global_icon(icon): + """ + Sets the icon which will be used any time a window is created if an icon is not provided when the + window is created. + + :param icon: Either a Base64 byte string or a filename + :type icon: bytes | str + """ + + Window._user_defined_icon = icon + + +# ============================== set_options ========# +# Sets the icon to be used by default # +# ===================================================# +def set_options(icon=None, button_color=None, element_size=(None, None), button_element_size=(None, None), + margins=(None, None), + element_padding=(None, None), auto_size_text=None, auto_size_buttons=None, font=None, border_width=None, + slider_border_width=None, slider_relief=None, slider_orientation=None, + autoclose_time=None, message_box_line_width=None, + progress_meter_border_depth=None, progress_meter_style=None, + progress_meter_relief=None, progress_meter_color=None, progress_meter_size=None, + text_justification=None, background_color=None, element_background_color=None, + text_element_background_color=None, input_elements_background_color=None, input_text_color=None, + scrollbar_color=None, text_color=None, element_text_color=None, debug_win_size=(None, None), + window_location=(None, None), error_button_color=(None, None), tooltip_time=None, tooltip_font=None, use_ttk_buttons=None, ttk_theme=None, + suppress_error_popups=None, suppress_raise_key_errors=None, suppress_key_guessing=None,warn_button_key_duplicates=False, enable_treeview_869_patch=None, + enable_mac_notitlebar_patch=None, use_custom_titlebar=None, titlebar_background_color=None, titlebar_text_color=None, titlebar_font=None, + titlebar_icon=None, user_settings_path=None, pysimplegui_settings_path=None, pysimplegui_settings_filename=None, keep_on_top=None, dpi_awareness=None, scaling=None, disable_modal_windows=None, force_modal_windows=None, tooltip_offset=(None, None), + sbar_trough_color=None, sbar_background_color=None, sbar_arrow_color=None, sbar_width=None, sbar_arrow_width=None, sbar_frame_color=None, sbar_relief=None, alpha_channel=None, + hide_window_when_creating=None, use_button_shortcuts=None, watermark_text=None): + """ + :param icon: Can be either a filename or Base64 value. For Windows if filename, it MUST be ICO format. For Linux, must NOT be ICO. Most portable is to use a Base64 of a PNG file. This works universally across all OS's + :type icon: bytes | str + :param button_color: Color of the button (text, background) + :type button_color: (str, str) | str + :param element_size: element size (width, height) in characters + :type element_size: (int, int) + :param button_element_size: Size of button + :type button_element_size: (int, int) + :param margins: (left/right, top/bottom) tkinter margins around outsize. Amount of pixels to leave inside the window's frame around the edges before your elements are shown. + :type margins: (int, int) + :param element_padding: Default amount of padding to put around elements in window (left/right, top/bottom) or ((left, right), (top, bottom)) + :type element_padding: (int, int) or ((int, int),(int,int)) + :param auto_size_text: True if the Widget should be shrunk to exactly fit the number of chars to show + :type auto_size_text: bool + :param auto_size_buttons: True if Buttons in this Window should be sized to exactly fit the text on this. + :type auto_size_buttons: (bool) + :param font: specifies the font family, size, etc. Tuple or Single string format 'name size styles'. Styles: italic * roman bold normal underline overstrike + :type font: (str or (str, int[, str]) or None) + :param border_width: width of border around element + :type border_width: (int) + :param slider_border_width: Width of the border around sliders + :type slider_border_width: (int) + :param slider_relief: Type of relief to use for sliders + :type slider_relief: (str) + :param slider_orientation: ??? + :type slider_orientation: ??? + :param autoclose_time: ??? + :type autoclose_time: ??? + :param message_box_line_width: ??? + :type message_box_line_width: ??? + :param progress_meter_border_depth: ??? + :type progress_meter_border_depth: ??? + :param progress_meter_style: You can no longer set a progress bar style. All ttk styles must be the same for the window + :type progress_meter_style: ??? + :param progress_meter_relief: + :type progress_meter_relief: ??? + :param progress_meter_color: ??? + :type progress_meter_color: ??? + :param progress_meter_size: ??? + :type progress_meter_size: ??? + :param text_justification: Default text justification for all Text Elements in window + :type text_justification: 'left' | 'right' | 'center' + :param background_color: color of background + :type background_color: (str) + :param element_background_color: element background color + :type element_background_color: (str) + :param text_element_background_color: text element background color + :type text_element_background_color: (str) + :param input_elements_background_color: Default color to use for the background of input elements + :type input_elements_background_color: (str) + :param input_text_color: Default color to use for the text for Input elements + :type input_text_color: (str) + :param scrollbar_color: Default color to use for the slider trough + :type scrollbar_color: (str) + :param text_color: color of the text + :type text_color: (str) + :param element_text_color: Default color to use for Text elements + :type element_text_color: (str) + :param debug_win_size: window size + :type debug_win_size: (int, int) + :param window_location: Default location to place windows. Not setting will center windows on the display + :type window_location: (int, int) | None + :param error_button_color: (Default = (None)) + :type error_button_color: ??? + :param tooltip_time: time in milliseconds to wait before showing a tooltip. Default is 400ms + :type tooltip_time: (int) + :param tooltip_font: font to use for all tooltips + :type tooltip_font: str or Tuple[str, int] or Tuple[str, int, str] + :param use_ttk_buttons: if True will cause all buttons to be ttk buttons + :type use_ttk_buttons: (bool) + :param ttk_theme: Theme to use with ttk widgets. Choices (on Windows) include - 'default', 'winnative', 'clam', 'alt', 'classic', 'vista', 'xpnative' + :type ttk_theme: (str) + :param suppress_error_popups: If True then error popups will not be shown if generated internally to PySimpleGUI + :type suppress_error_popups: (bool) + :param suppress_raise_key_errors: If True then key errors won't be raised (you'll still get popup error) + :type suppress_raise_key_errors: (bool) + :param suppress_key_guessing: If True then key errors won't try and find closest matches for you + :type suppress_key_guessing: (bool) + :param warn_button_key_duplicates: If True then duplicate Button Keys generate warnings (not recommended as they're expected) + :type warn_button_key_duplicates: (bool) + :param enable_treeview_869_patch: If True, then will use the treeview color patch for tk 8.6.9 + :type enable_treeview_869_patch: (bool) + :param enable_mac_notitlebar_patch: If True then Windows with no titlebar use an alternative technique when tkinter version < 8.6.10 + :type enable_mac_notitlebar_patch: (bool) + :param use_custom_titlebar: If True then a custom titlebar is used instead of the normal system titlebar + :type use_custom_titlebar: (bool) + :param titlebar_background_color: If custom titlebar indicated by use_custom_titlebar, then use this as background color + :type titlebar_background_color: str | None + :param titlebar_text_color: If custom titlebar indicated by use_custom_titlebar, then use this as text color + :type titlebar_text_color: str | None + :param titlebar_font: If custom titlebar indicated by use_custom_titlebar, then use this as title font + :type titlebar_font: (str or (str, int[, str]) or None) | None + :param titlebar_icon: If custom titlebar indicated by use_custom_titlebar, then use this as the icon (file or base64 bytes) + :type titlebar_icon: bytes | str + :param user_settings_path: default path for user_settings API calls. Expanded with os.path.expanduser so can contain ~ to represent user + :type user_settings_path: (str) + :param pysimplegui_settings_path: default path for the global PySimpleGUI user_settings + :type pysimplegui_settings_path: (str) + :param pysimplegui_settings_filename: default filename for the global PySimpleGUI user_settings + :type pysimplegui_settings_filename: (str) + :param keep_on_top: If True then all windows will automatically be set to keep_on_top=True + :type keep_on_top: (bool) + :param dpi_awareness: If True then will turn on DPI awareness (Windows only at the moment) + :type dpi_awareness: (bool) + :param scaling: Sets the default scaling for all windows including popups, etc. + :type scaling: (float) + :param disable_modal_windows: If True then all windows, including popups, will not be modal windows (unless they've been set to FORCED using another option) + :type disable_modal_windows: (bool) + :param force_modal_windows: If True then all windows will be modal (the disable option will be ignored... all windows will be forced to be modal) + :type force_modal_windows: (bool) + :param tooltip_offset: Offset to use for tooltips as a tuple. These values will be added to the mouse location when the widget was entered. + :type tooltip_offset: ((None, None) | (int, int)) + :param sbar_trough_color: Scrollbar color of the trough + :type sbar_trough_color: (str) + :param sbar_background_color: Scrollbar color of the background of the arrow buttons at the ends AND the color of the "thumb" (the thing you grab and slide). Switches to arrow color when mouse is over + :type sbar_background_color: (str) + :param sbar_arrow_color: Scrollbar color of the arrow at the ends of the scrollbar (it looks like a button). Switches to background color when mouse is over + :type sbar_arrow_color: (str) + :param sbar_width: Scrollbar width in pixels + :type sbar_width: (int) + :param sbar_arrow_width: Scrollbar width of the arrow on the scrollbar. It will potentially impact the overall width of the scrollbar + :type sbar_arrow_width: (int) + :param sbar_frame_color: Scrollbar Color of frame around scrollbar (available only on some ttk themes) + :type sbar_frame_color: (str) + :param sbar_relief: Scrollbar relief that will be used for the "thumb" of the scrollbar (the thing you grab that slides). Should be a constant that is defined at starting with "RELIEF_" - RELIEF_RAISED, RELIEF_SUNKEN, RELIEF_FLAT, RELIEF_RIDGE, RELIEF_GROOVE, RELIEF_SOLID + :type sbar_relief: (str) + :param alpha_channel: Default alpha channel to be used on all windows + :type alpha_channel: (float) + :param hide_window_when_creating: If True then alpha will be set to 0 while a window is made and moved to location indicated + :type hide_window_when_creating: (bool) + :param use_button_shortcuts: If True then Shortcut Char will be used with Buttons + :type use_button_shortcuts: (bool) + :param watermark_text: Set the text that will be used if a window is watermarked + :type watermark_text: (str) + :return: None + :rtype: None + """ + + global DEFAULT_ELEMENT_SIZE + global DEFAULT_BUTTON_ELEMENT_SIZE + global DEFAULT_MARGINS # Margins for each LEFT/RIGHT margin is first term + global DEFAULT_ELEMENT_PADDING # Padding between elements (row, col) in pixels + global DEFAULT_AUTOSIZE_TEXT + global DEFAULT_AUTOSIZE_BUTTONS + global DEFAULT_FONT + global DEFAULT_BORDER_WIDTH + global DEFAULT_AUTOCLOSE_TIME + global DEFAULT_BUTTON_COLOR + global MESSAGE_BOX_LINE_WIDTH + global DEFAULT_PROGRESS_BAR_BORDER_WIDTH + global DEFAULT_PROGRESS_BAR_STYLE + global DEFAULT_PROGRESS_BAR_RELIEF + global DEFAULT_PROGRESS_BAR_COLOR + global DEFAULT_PROGRESS_BAR_SIZE + global DEFAULT_TEXT_JUSTIFICATION + global DEFAULT_DEBUG_WINDOW_SIZE + global DEFAULT_SLIDER_BORDER_WIDTH + global DEFAULT_SLIDER_RELIEF + global DEFAULT_SLIDER_ORIENTATION + global DEFAULT_BACKGROUND_COLOR + global DEFAULT_INPUT_ELEMENTS_COLOR + global DEFAULT_ELEMENT_BACKGROUND_COLOR + global DEFAULT_TEXT_ELEMENT_BACKGROUND_COLOR + global DEFAULT_SCROLLBAR_COLOR + global DEFAULT_TEXT_COLOR + global DEFAULT_WINDOW_LOCATION + global DEFAULT_ELEMENT_TEXT_COLOR + global DEFAULT_INPUT_TEXT_COLOR + global DEFAULT_TOOLTIP_TIME + global DEFAULT_ERROR_BUTTON_COLOR + global DEFAULT_TTK_THEME + global USE_TTK_BUTTONS + global TOOLTIP_FONT + global SUPPRESS_ERROR_POPUPS + global SUPPRESS_RAISE_KEY_ERRORS + global SUPPRESS_KEY_GUESSING + global WARN_DUPLICATE_BUTTON_KEY_ERRORS + global ENABLE_TREEVIEW_869_PATCH + global ENABLE_MAC_NOTITLEBAR_PATCH + global USE_CUSTOM_TITLEBAR + global CUSTOM_TITLEBAR_BACKGROUND_COLOR + global CUSTOM_TITLEBAR_TEXT_COLOR + global CUSTOM_TITLEBAR_ICON + global CUSTOM_TITLEBAR_FONT + global DEFAULT_USER_SETTINGS_PATH + global DEFAULT_USER_SETTINGS_PYSIMPLEGUI_PATH + global DEFAULT_USER_SETTINGS_PYSIMPLEGUI_FILENAME + global DEFAULT_KEEP_ON_TOP + global DEFAULT_SCALING + global DEFAULT_MODAL_WINDOWS_ENABLED + global DEFAULT_MODAL_WINDOWS_FORCED + global DEFAULT_TOOLTIP_OFFSET + global DEFAULT_ALPHA_CHANNEL + global _pysimplegui_user_settings + global ttk_part_overrides_from_options + global DEFAULT_HIDE_WINDOW_WHEN_CREATING + global DEFAULT_USE_BUTTON_SHORTCUTS + # global _my_windows + + if icon: + Window._user_defined_icon = icon + # _my_windows._user_defined_icon = icon + + if button_color != None: + if button_color == COLOR_SYSTEM_DEFAULT: + DEFAULT_BUTTON_COLOR = (COLOR_SYSTEM_DEFAULT, COLOR_SYSTEM_DEFAULT) + else: + DEFAULT_BUTTON_COLOR = button_color + + if element_size != (None, None): + DEFAULT_ELEMENT_SIZE = element_size + + if button_element_size != (None, None): + DEFAULT_BUTTON_ELEMENT_SIZE = button_element_size + + if margins != (None, None): + DEFAULT_MARGINS = margins + + if element_padding != (None, None): + DEFAULT_ELEMENT_PADDING = element_padding + + if auto_size_text != None: + DEFAULT_AUTOSIZE_TEXT = auto_size_text + + if auto_size_buttons != None: + DEFAULT_AUTOSIZE_BUTTONS = auto_size_buttons + + if font != None: + DEFAULT_FONT = font + + if border_width != None: + DEFAULT_BORDER_WIDTH = border_width + + if autoclose_time != None: + DEFAULT_AUTOCLOSE_TIME = autoclose_time + + if message_box_line_width != None: + MESSAGE_BOX_LINE_WIDTH = message_box_line_width + + if progress_meter_border_depth != None: + DEFAULT_PROGRESS_BAR_BORDER_WIDTH = progress_meter_border_depth + + if progress_meter_style != None: + warnings.warn('You can no longer set a progress bar style. All ttk styles must be the same for the window', UserWarning) + # DEFAULT_PROGRESS_BAR_STYLE = progress_meter_style + + if progress_meter_relief != None: + DEFAULT_PROGRESS_BAR_RELIEF = progress_meter_relief + + if progress_meter_color != None: + DEFAULT_PROGRESS_BAR_COLOR = progress_meter_color + + if progress_meter_size != None: + DEFAULT_PROGRESS_BAR_SIZE = progress_meter_size + + if slider_border_width != None: + DEFAULT_SLIDER_BORDER_WIDTH = slider_border_width + + if slider_orientation != None: + DEFAULT_SLIDER_ORIENTATION = slider_orientation + + if slider_relief != None: + DEFAULT_SLIDER_RELIEF = slider_relief + + if text_justification != None: + DEFAULT_TEXT_JUSTIFICATION = text_justification + + if background_color != None: + DEFAULT_BACKGROUND_COLOR = background_color + + if text_element_background_color != None: + DEFAULT_TEXT_ELEMENT_BACKGROUND_COLOR = text_element_background_color + + if input_elements_background_color != None: + DEFAULT_INPUT_ELEMENTS_COLOR = input_elements_background_color + + if element_background_color != None: + DEFAULT_ELEMENT_BACKGROUND_COLOR = element_background_color + + if window_location != (None, None): + DEFAULT_WINDOW_LOCATION = window_location + + if debug_win_size != (None, None): + DEFAULT_DEBUG_WINDOW_SIZE = debug_win_size + + if text_color != None: + DEFAULT_TEXT_COLOR = text_color + + if scrollbar_color != None: + DEFAULT_SCROLLBAR_COLOR = scrollbar_color + + if element_text_color != None: + DEFAULT_ELEMENT_TEXT_COLOR = element_text_color + + if input_text_color is not None: + DEFAULT_INPUT_TEXT_COLOR = input_text_color + + if tooltip_time is not None: + DEFAULT_TOOLTIP_TIME = tooltip_time + + if error_button_color != (None, None): + DEFAULT_ERROR_BUTTON_COLOR = error_button_color + + if ttk_theme is not None: + DEFAULT_TTK_THEME = ttk_theme + + if use_ttk_buttons is not None: + USE_TTK_BUTTONS = use_ttk_buttons + + if tooltip_font is not None: + TOOLTIP_FONT = tooltip_font + + if suppress_error_popups is not None: + SUPPRESS_ERROR_POPUPS = suppress_error_popups + + if suppress_raise_key_errors is not None: + SUPPRESS_RAISE_KEY_ERRORS = suppress_raise_key_errors + + if suppress_key_guessing is not None: + SUPPRESS_KEY_GUESSING = suppress_key_guessing + + if warn_button_key_duplicates is not None: + WARN_DUPLICATE_BUTTON_KEY_ERRORS = warn_button_key_duplicates + + if enable_treeview_869_patch is not None: + ENABLE_TREEVIEW_869_PATCH = enable_treeview_869_patch + + if enable_mac_notitlebar_patch is not None: + ENABLE_MAC_NOTITLEBAR_PATCH = enable_mac_notitlebar_patch + + if use_custom_titlebar is not None: + USE_CUSTOM_TITLEBAR = use_custom_titlebar + + if titlebar_background_color is not None: + CUSTOM_TITLEBAR_BACKGROUND_COLOR = titlebar_background_color + + if titlebar_text_color is not None: + CUSTOM_TITLEBAR_TEXT_COLOR = titlebar_text_color + + if titlebar_font is not None: + CUSTOM_TITLEBAR_FONT = titlebar_font + + if titlebar_icon is not None: + CUSTOM_TITLEBAR_ICON = titlebar_icon + + if user_settings_path is not None: + DEFAULT_USER_SETTINGS_PATH = user_settings_path + + if pysimplegui_settings_path is not None: + DEFAULT_USER_SETTINGS_PYSIMPLEGUI_PATH = pysimplegui_settings_path + + if pysimplegui_settings_filename is not None: + DEFAULT_USER_SETTINGS_PYSIMPLEGUI_FILENAME = pysimplegui_settings_filename + + if pysimplegui_settings_filename is not None or pysimplegui_settings_filename is not None: + _pysimplegui_user_settings = UserSettings(filename=DEFAULT_USER_SETTINGS_PYSIMPLEGUI_FILENAME, + path=DEFAULT_USER_SETTINGS_PYSIMPLEGUI_PATH) + + if keep_on_top is not None: + DEFAULT_KEEP_ON_TOP = keep_on_top + + if dpi_awareness is True: + if running_windows(): + if platform.release() == "7": + ctypes.windll.user32.SetProcessDPIAware() + elif platform.release() == "8" or platform.release() == "10": + ctypes.windll.shcore.SetProcessDpiAwareness(1) + + if scaling is not None: + DEFAULT_SCALING = scaling + + if disable_modal_windows is not None: + DEFAULT_MODAL_WINDOWS_ENABLED = not disable_modal_windows + + if force_modal_windows is not None: + DEFAULT_MODAL_WINDOWS_FORCED = force_modal_windows + + if tooltip_offset != (None, None): + DEFAULT_TOOLTIP_OFFSET = tooltip_offset + + + if alpha_channel is not None: + DEFAULT_ALPHA_CHANNEL = alpha_channel + + # ---------------- ttk scrollbar section ---------------- + if sbar_background_color is not None: + ttk_part_overrides_from_options.sbar_background_color = sbar_background_color + + if sbar_trough_color is not None: + ttk_part_overrides_from_options.sbar_trough_color = sbar_trough_color + + if sbar_arrow_color is not None: + ttk_part_overrides_from_options.sbar_arrow_color = sbar_arrow_color + + if sbar_frame_color is not None: + ttk_part_overrides_from_options.sbar_frame_color = sbar_frame_color + + if sbar_relief is not None: + ttk_part_overrides_from_options.sbar_relief = sbar_relief + + if sbar_arrow_width is not None: + ttk_part_overrides_from_options.sbar_arrow_width = sbar_arrow_width + + if sbar_width is not None: + ttk_part_overrides_from_options.sbar_width = sbar_width + + if hide_window_when_creating is not None: + DEFAULT_HIDE_WINDOW_WHEN_CREATING = hide_window_when_creating + + if use_button_shortcuts is not None: + DEFAULT_USE_BUTTON_SHORTCUTS = use_button_shortcuts + + if watermark_text is not None: + Window._watermark_user_text = watermark_text + + return True + + +# ----------------------------------------------------------------- # + +# .########.##.....##.########.##.....##.########..######. +# ....##....##.....##.##.......###...###.##.......##....## +# ....##....##.....##.##.......####.####.##.......##...... +# ....##....#########.######...##.###.##.######....######. +# ....##....##.....##.##.......##.....##.##.............## +# ....##....##.....##.##.......##.....##.##.......##....## +# ....##....##.....##.########.##.....##.########..######. + +# ----------------------------------------------------------------- # + +# The official Theme code + +#################### ChangeLookAndFeel ####################### +# Predefined settings that will change the colors and styles # +# of the elements. # +############################################################## +LOOK_AND_FEEL_TABLE = { + "SystemDefault": {"BACKGROUND": COLOR_SYSTEM_DEFAULT, "TEXT": COLOR_SYSTEM_DEFAULT, "INPUT": COLOR_SYSTEM_DEFAULT, "TEXT_INPUT": COLOR_SYSTEM_DEFAULT, + "SCROLL": COLOR_SYSTEM_DEFAULT, "BUTTON": OFFICIAL_PYSIMPLEGUI_BUTTON_COLOR, "PROGRESS": COLOR_SYSTEM_DEFAULT, "BORDER": 1, + "SLIDER_DEPTH": 1, "PROGRESS_DEPTH": 0, }, + "SystemDefaultForReal": {"BACKGROUND": COLOR_SYSTEM_DEFAULT, "TEXT": COLOR_SYSTEM_DEFAULT, "INPUT": COLOR_SYSTEM_DEFAULT, + "TEXT_INPUT": COLOR_SYSTEM_DEFAULT, "SCROLL": COLOR_SYSTEM_DEFAULT, "BUTTON": COLOR_SYSTEM_DEFAULT, + "PROGRESS": COLOR_SYSTEM_DEFAULT, "BORDER": 1, "SLIDER_DEPTH": 1, "PROGRESS_DEPTH": 0, }, + "SystemDefault1": {"BACKGROUND": COLOR_SYSTEM_DEFAULT, "TEXT": COLOR_SYSTEM_DEFAULT, "INPUT": COLOR_SYSTEM_DEFAULT, "TEXT_INPUT": COLOR_SYSTEM_DEFAULT, + "SCROLL": COLOR_SYSTEM_DEFAULT, "BUTTON": COLOR_SYSTEM_DEFAULT, "PROGRESS": COLOR_SYSTEM_DEFAULT, "BORDER": 1, "SLIDER_DEPTH": 1, + "PROGRESS_DEPTH": 0, }, + "Material1": {"BACKGROUND": "#E3F2FD", "TEXT": "#000000", "INPUT": "#86A8FF", "TEXT_INPUT": "#000000", "SCROLL": "#86A8FF", + "BUTTON": ("#FFFFFF", "#5079D3"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 0, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "ACCENT1": "#FF0266", "ACCENT2": "#FF5C93", "ACCENT3": "#C5003C", }, + "Material2": {"BACKGROUND": "#FAFAFA", "TEXT": "#000000", "INPUT": "#004EA1", "TEXT_INPUT": "#FFFFFF", "SCROLL": "#5EA7FF", + "BUTTON": ("#FFFFFF", "#0079D3"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 0, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "ACCENT1": "#FF0266", "ACCENT2": "#FF5C93", "ACCENT3": "#C5003C", }, + "Reddit": {"BACKGROUND": "#ffffff", "TEXT": "#1a1a1b", "INPUT": "#dae0e6", "TEXT_INPUT": "#222222", "SCROLL": "#a5a4a4", "BUTTON": ("#FFFFFF", "#0079d3"), + "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, "ACCENT1": "#ff5414", "ACCENT2": "#33a8ff", + "ACCENT3": "#dbf0ff", }, + "Topanga": {"BACKGROUND": "#282923", "TEXT": "#E7DB74", "INPUT": "#393a32", "TEXT_INPUT": "#E7C855", "SCROLL": "#E7C855", "BUTTON": ("#E7C855", "#284B5A"), + "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, "ACCENT1": "#c15226", "ACCENT2": "#7a4d5f", + "ACCENT3": "#889743", }, + "GreenTan": {"BACKGROUND": "#9FB8AD", "TEXT": '#000000', "INPUT": "#F7F3EC", "TEXT_INPUT": "#000000", "SCROLL": "#F7F3EC", "BUTTON": ("#FFFFFF", "#475841"), + "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, }, + "Dark": {"BACKGROUND": "#404040", "TEXT": "#FFFFFF", "INPUT": "#4D4D4D", "TEXT_INPUT": "#FFFFFF", "SCROLL": "#707070", "BUTTON": ("#FFFFFF", "#004F00"), + "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, }, + "LightGreen": {"BACKGROUND": "#B7CECE", "TEXT": "#000000", "INPUT": "#FDFFF7", "TEXT_INPUT": "#000000", "SCROLL": "#FDFFF7", + "BUTTON": ("#FFFFFF", "#658268"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "ACCENT1": "#76506d", + "ACCENT2": "#5148f1", "ACCENT3": "#0a1c84", "PROGRESS_DEPTH": 0, }, + "Dark2": {"BACKGROUND": "#404040", "TEXT": "#FFFFFF", "INPUT": "#FFFFFF", "TEXT_INPUT": "#000000", "SCROLL": "#707070", "BUTTON": ("#FFFFFF", "#004F00"), + "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, }, + "Black": {"BACKGROUND": "#000000", "TEXT": "#FFFFFF", "INPUT": "#4D4D4D", "TEXT_INPUT": "#FFFFFF", "SCROLL": "#707070", "BUTTON": ("#000000", "#FFFFFF"), + "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, }, + "Black2": {"BACKGROUND": "#000000", "TEXT": "#FFFFFF", "INPUT": "#000000", "TEXT_INPUT": "#FFFFFF", "SCROLL": "#FFFFFF", "BUTTON": ("#000000", "#FFFFFF"), + "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, }, + "Tan": {"BACKGROUND": "#fdf6e3", "TEXT": "#268bd1", "INPUT": "#eee8d5", "TEXT_INPUT": "#6c71c3", "SCROLL": "#eee8d5", "BUTTON": ("#FFFFFF", "#063542"), + "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, }, + "TanBlue": {"BACKGROUND": "#e5dece", "TEXT": "#063289", "INPUT": "#f9f8f4", "TEXT_INPUT": "#242834", "SCROLL": "#eee8d5", "BUTTON": ("#FFFFFF", "#063289"), + "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, }, + "DarkTanBlue": {"BACKGROUND": "#242834", "TEXT": "#dfe6f8", "INPUT": "#97755c", "TEXT_INPUT": "#FFFFFF", "SCROLL": "#a9afbb", + "BUTTON": ("#FFFFFF", "#063289"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, }, + "DarkAmber": {"BACKGROUND": "#2c2825", "TEXT": "#fdcb52", "INPUT": "#705e52", "TEXT_INPUT": "#fdcb52", "SCROLL": "#705e52", + "BUTTON": ("#000000", "#fdcb52"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, }, + "DarkBlue": {"BACKGROUND": "#1a2835", "TEXT": "#d1ecff", "INPUT": "#335267", "TEXT_INPUT": "#acc2d0", "SCROLL": "#1b6497", "BUTTON": ("#000000", "#fafaf8"), + "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, }, + "Reds": {"BACKGROUND": "#280001", "TEXT": "#FFFFFF", "INPUT": "#d8d584", "TEXT_INPUT": "#000000", "SCROLL": "#763e00", "BUTTON": ("#000000", "#daad28"), + "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, }, + "Green": {"BACKGROUND": "#82a459", "TEXT": "#000000", "INPUT": "#d8d584", "TEXT_INPUT": "#000000", "SCROLL": "#e3ecf3", "BUTTON": ("#FFFFFF", "#517239"), + "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, }, + "BluePurple": {"BACKGROUND": "#A5CADD", "TEXT": "#6E266E", "INPUT": "#E0F5FF", "TEXT_INPUT": "#000000", "SCROLL": "#E0F5FF", + "BUTTON": ("#FFFFFF", "#303952"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, }, + "Purple": {"BACKGROUND": "#B0AAC2", "TEXT": "#000000", "INPUT": "#F2EFE8", "SCROLL": "#F2EFE8", "TEXT_INPUT": "#000000", "BUTTON": ("#000000", "#C2D4D8"), + "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, }, + "BlueMono": {"BACKGROUND": "#AAB6D3", "TEXT": "#000000", "INPUT": "#F1F4FC", "SCROLL": "#F1F4FC", "TEXT_INPUT": "#000000", "BUTTON": ("#FFFFFF", "#7186C7"), + "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, }, + "GreenMono": {"BACKGROUND": "#A8C1B4", "TEXT": "#000000", "INPUT": "#DDE0DE", "SCROLL": "#E3E3E3", "TEXT_INPUT": "#000000", + "BUTTON": ("#FFFFFF", "#6D9F85"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, }, + "BrownBlue": {"BACKGROUND": "#64778d", "TEXT": "#FFFFFF", "INPUT": "#f0f3f7", "SCROLL": "#A6B2BE", "TEXT_INPUT": "#000000", + "BUTTON": ("#FFFFFF", "#283b5b"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, }, + "BrightColors": {"BACKGROUND": "#b4ffb4", "TEXT": "#000000", "INPUT": "#ffff64", "SCROLL": "#ffb482", "TEXT_INPUT": "#000000", + "BUTTON": ("#000000", "#ffa0dc"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, }, + "NeutralBlue": {"BACKGROUND": "#92aa9d", "TEXT": "#000000", "INPUT": "#fcfff6", "SCROLL": "#fcfff6", "TEXT_INPUT": "#000000", + "BUTTON": ("#000000", "#d0dbbd"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, }, + "Kayak": {"BACKGROUND": "#a7ad7f", "TEXT": "#000000", "INPUT": "#e6d3a8", "SCROLL": "#e6d3a8", "TEXT_INPUT": "#000000", "BUTTON": ("#FFFFFF", "#5d907d"), + "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, }, + "SandyBeach": {"BACKGROUND": "#efeccb", "TEXT": "#012f2f", "INPUT": "#e6d3a8", "SCROLL": "#e6d3a8", "TEXT_INPUT": "#012f2f", + "BUTTON": ("#FFFFFF", "#046380"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, }, + "TealMono": {"BACKGROUND": "#a8cfdd", "TEXT": "#000000", "INPUT": "#dfedf2", "SCROLL": "#dfedf2", "TEXT_INPUT": "#000000", "BUTTON": ("#FFFFFF", "#183440"), + "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, }, + "Default": {"BACKGROUND": COLOR_SYSTEM_DEFAULT, "TEXT": COLOR_SYSTEM_DEFAULT, "INPUT": COLOR_SYSTEM_DEFAULT, "TEXT_INPUT": COLOR_SYSTEM_DEFAULT, + "SCROLL": COLOR_SYSTEM_DEFAULT, "BUTTON": OFFICIAL_PYSIMPLEGUI_BUTTON_COLOR, "PROGRESS": COLOR_SYSTEM_DEFAULT, "BORDER": 1, "SLIDER_DEPTH": 1, + "PROGRESS_DEPTH": 0, }, + "Default1": {"BACKGROUND": COLOR_SYSTEM_DEFAULT, "TEXT": COLOR_SYSTEM_DEFAULT, "INPUT": COLOR_SYSTEM_DEFAULT, "TEXT_INPUT": COLOR_SYSTEM_DEFAULT, + "SCROLL": COLOR_SYSTEM_DEFAULT, "BUTTON": COLOR_SYSTEM_DEFAULT, "PROGRESS": COLOR_SYSTEM_DEFAULT, "BORDER": 1, "SLIDER_DEPTH": 1, + "PROGRESS_DEPTH": 0, }, + "DefaultNoMoreNagging": {"BACKGROUND": COLOR_SYSTEM_DEFAULT, "TEXT": COLOR_SYSTEM_DEFAULT, "INPUT": COLOR_SYSTEM_DEFAULT, + "TEXT_INPUT": COLOR_SYSTEM_DEFAULT, "SCROLL": COLOR_SYSTEM_DEFAULT, "BUTTON": OFFICIAL_PYSIMPLEGUI_BUTTON_COLOR, + "PROGRESS": COLOR_SYSTEM_DEFAULT, "BORDER": 1, "SLIDER_DEPTH": 1, "PROGRESS_DEPTH": 0, }, + "GrayGrayGray": {"BACKGROUND": COLOR_SYSTEM_DEFAULT, "TEXT": COLOR_SYSTEM_DEFAULT, "INPUT": COLOR_SYSTEM_DEFAULT, "TEXT_INPUT": COLOR_SYSTEM_DEFAULT, + "SCROLL": COLOR_SYSTEM_DEFAULT, "BUTTON": COLOR_SYSTEM_DEFAULT, "PROGRESS": COLOR_SYSTEM_DEFAULT, "BORDER": 1, "SLIDER_DEPTH": 1, + "PROGRESS_DEPTH": 0, }, + "LightBlue": {"BACKGROUND": "#E3F2FD", "TEXT": "#000000", "INPUT": "#86A8FF", "TEXT_INPUT": "#000000", "SCROLL": "#86A8FF", + "BUTTON": ("#FFFFFF", "#5079D3"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 0, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "ACCENT1": "#FF0266", "ACCENT2": "#FF5C93", "ACCENT3": "#C5003C", }, + "LightGrey": {"BACKGROUND": "#FAFAFA", "TEXT": "#000000", "INPUT": "#004EA1", "TEXT_INPUT": "#FFFFFF", "SCROLL": "#5EA7FF", + "BUTTON": ("#FFFFFF", "#0079D3"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 0, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "ACCENT1": "#FF0266", "ACCENT2": "#FF5C93", "ACCENT3": "#C5003C", }, + "LightGrey1": {"BACKGROUND": "#ffffff", "TEXT": "#1a1a1b", "INPUT": "#dae0e6", "TEXT_INPUT": "#222222", "SCROLL": "#a5a4a4", + "BUTTON": ("#FFFFFF", "#0079d3"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "ACCENT1": "#ff5414", "ACCENT2": "#33a8ff", "ACCENT3": "#dbf0ff", }, + "DarkBrown": {"BACKGROUND": "#282923", "TEXT": "#E7DB74", "INPUT": "#393a32", "TEXT_INPUT": "#E7C855", "SCROLL": "#E7C855", + "BUTTON": ("#E7C855", "#284B5A"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "ACCENT1": "#c15226", "ACCENT2": "#7a4d5f", "ACCENT3": "#889743", }, + "LightGreen1": {"BACKGROUND": "#9FB8AD", "TEXT": "#000000", "INPUT": "#F7F3EC", "TEXT_INPUT": "#000000", "SCROLL": "#F7F3EC", + "BUTTON": ("#FFFFFF", "#475841"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, }, + "DarkGrey": {"BACKGROUND": "#404040", "TEXT": "#FFFFFF", "INPUT": "#4D4D4D", "TEXT_INPUT": "#FFFFFF", "SCROLL": "#707070", "BUTTON": ("#FFFFFF", "#004F00"), + "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, }, + "LightGreen2": {"BACKGROUND": "#B7CECE", "TEXT": "#000000", "INPUT": "#FDFFF7", "TEXT_INPUT": "#000000", "SCROLL": "#FDFFF7", + "BUTTON": ("#FFFFFF", "#658268"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "ACCENT1": "#76506d", + "ACCENT2": "#5148f1", "ACCENT3": "#0a1c84", "PROGRESS_DEPTH": 0, }, + "DarkGrey1": {"BACKGROUND": "#404040", "TEXT": "#FFFFFF", "INPUT": "#FFFFFF", "TEXT_INPUT": "#000000", "SCROLL": "#707070", + "BUTTON": ("#FFFFFF", "#004F00"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, }, + "DarkBlack": {"BACKGROUND": "#000000", "TEXT": "#FFFFFF", "INPUT": "#4D4D4D", "TEXT_INPUT": "#FFFFFF", "SCROLL": "#707070", + "BUTTON": ("#000000", "#FFFFFF"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, }, + "LightBrown": {"BACKGROUND": "#fdf6e3", "TEXT": "#268bd1", "INPUT": "#eee8d5", "TEXT_INPUT": "#6c71c3", "SCROLL": "#eee8d5", + "BUTTON": ("#FFFFFF", "#063542"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, }, + "LightBrown1": {"BACKGROUND": "#e5dece", "TEXT": "#063289", "INPUT": "#f9f8f4", "TEXT_INPUT": "#242834", "SCROLL": "#eee8d5", + "BUTTON": ("#FFFFFF", "#063289"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, }, + "DarkBlue1": {"BACKGROUND": "#242834", "TEXT": "#dfe6f8", "INPUT": "#97755c", "TEXT_INPUT": "#FFFFFF", "SCROLL": "#a9afbb", + "BUTTON": ("#FFFFFF", "#063289"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, }, + "DarkBrown1": {"BACKGROUND": "#2c2825", "TEXT": "#fdcb52", "INPUT": "#705e52", "TEXT_INPUT": "#fdcb52", "SCROLL": "#705e52", + "BUTTON": ("#000000", "#fdcb52"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, }, + "DarkBlue2": {"BACKGROUND": "#1a2835", "TEXT": "#d1ecff", "INPUT": "#335267", "TEXT_INPUT": "#acc2d0", "SCROLL": "#1b6497", + "BUTTON": ("#000000", "#fafaf8"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, }, + "DarkBrown2": {"BACKGROUND": "#280001", "TEXT": "#FFFFFF", "INPUT": "#d8d584", "TEXT_INPUT": "#000000", "SCROLL": "#763e00", + "BUTTON": ("#000000", "#daad28"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, }, + "DarkGreen": {"BACKGROUND": "#82a459", "TEXT": "#000000", "INPUT": "#d8d584", "TEXT_INPUT": "#000000", "SCROLL": "#e3ecf3", + "BUTTON": ("#FFFFFF", "#517239"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, }, + "LightBlue1": {"BACKGROUND": "#A5CADD", "TEXT": "#6E266E", "INPUT": "#E0F5FF", "TEXT_INPUT": "#000000", "SCROLL": "#E0F5FF", + "BUTTON": ("#FFFFFF", "#303952"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, }, + "LightPurple": {"BACKGROUND": "#B0AAC2", "TEXT": "#000000", "INPUT": "#F2EFE8", "SCROLL": "#F2EFE8", "TEXT_INPUT": "#000000", + "BUTTON": ("#000000", "#C2D4D8"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, }, + "LightBlue2": {"BACKGROUND": "#AAB6D3", "TEXT": "#000000", "INPUT": "#F1F4FC", "SCROLL": "#F1F4FC", "TEXT_INPUT": "#000000", + "BUTTON": ("#FFFFFF", "#7186C7"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, }, + "LightGreen3": {"BACKGROUND": "#A8C1B4", "TEXT": "#000000", "INPUT": "#DDE0DE", "SCROLL": "#E3E3E3", "TEXT_INPUT": "#000000", + "BUTTON": ("#FFFFFF", "#6D9F85"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, }, + "DarkBlue3": {"BACKGROUND": "#64778d", "TEXT": "#FFFFFF", "INPUT": "#f0f3f7", "SCROLL": "#A6B2BE", "TEXT_INPUT": "#000000", + "BUTTON": ("#FFFFFF", "#283b5b"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, }, + "LightGreen4": {"BACKGROUND": "#b4ffb4", "TEXT": "#000000", "INPUT": "#ffff64", "SCROLL": "#ffb482", "TEXT_INPUT": "#000000", + "BUTTON": ("#000000", "#ffa0dc"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, }, + "LightGreen5": {"BACKGROUND": "#92aa9d", "TEXT": "#000000", "INPUT": "#fcfff6", "SCROLL": "#fcfff6", "TEXT_INPUT": "#000000", + "BUTTON": ("#000000", "#d0dbbd"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, }, + "LightBrown2": {"BACKGROUND": "#a7ad7f", "TEXT": "#000000", "INPUT": "#e6d3a8", "SCROLL": "#e6d3a8", "TEXT_INPUT": "#000000", + "BUTTON": ("#FFFFFF", "#5d907d"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, }, + "LightBrown3": {"BACKGROUND": "#efeccb", "TEXT": "#012f2f", "INPUT": "#e6d3a8", "SCROLL": "#e6d3a8", "TEXT_INPUT": "#012f2f", + "BUTTON": ("#FFFFFF", "#046380"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, }, + "LightBlue3": {"BACKGROUND": "#a8cfdd", "TEXT": "#000000", "INPUT": "#dfedf2", "SCROLL": "#dfedf2", "TEXT_INPUT": "#000000", + "BUTTON": ("#FFFFFF", "#183440"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, }, + "LightBrown4": {"BACKGROUND": "#d7c79e", "TEXT": "#a35638", "INPUT": "#9dab86", "TEXT_INPUT": "#000000", "SCROLL": "#a35638", + "BUTTON": ("#FFFFFF", "#a35638"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "COLOR_LIST": ["#a35638", "#9dab86", "#e08f62", "#d7c79e"], }, + "DarkTeal": {"BACKGROUND": "#003f5c", "TEXT": "#fb5b5a", "INPUT": "#bc4873", "TEXT_INPUT": "#FFFFFF", "SCROLL": "#bc4873", "BUTTON": ("#FFFFFF", "#fb5b5a"), + "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "COLOR_LIST": ["#003f5c", "#472b62", "#bc4873", "#fb5b5a"], }, + "DarkPurple": {"BACKGROUND": "#472b62", "TEXT": "#fb5b5a", "INPUT": "#bc4873", "TEXT_INPUT": "#FFFFFF", "SCROLL": "#bc4873", + "BUTTON": ("#FFFFFF", "#472b62"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "COLOR_LIST": ["#003f5c", "#472b62", "#bc4873", "#fb5b5a"], }, + "LightGreen6": {"BACKGROUND": "#eafbea", "TEXT": "#1f6650", "INPUT": "#6f9a8d", "TEXT_INPUT": "#FFFFFF", "SCROLL": "#1f6650", + "BUTTON": ("#FFFFFF", "#1f6650"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "COLOR_LIST": ["#1f6650", "#6f9a8d", "#ea5e5e", "#eafbea"], }, + "DarkGrey2": {"BACKGROUND": "#2b2b28", "TEXT": "#f8f8f8", "INPUT": "#f1d6ab", "TEXT_INPUT": "#000000", "SCROLL": "#f1d6ab", + "BUTTON": ("#2b2b28", "#e3b04b"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "COLOR_LIST": ["#2b2b28", "#e3b04b", "#f1d6ab", "#f8f8f8"], }, + "LightBrown6": {"BACKGROUND": "#f9b282", "TEXT": "#8f4426", "INPUT": "#de6b35", "TEXT_INPUT": "#FFFFFF", "SCROLL": "#8f4426", + "BUTTON": ("#FFFFFF", "#8f4426"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "COLOR_LIST": ["#8f4426", "#de6b35", "#64ccda", "#f9b282"], }, + "DarkTeal1": {"BACKGROUND": "#396362", "TEXT": "#ffe7d1", "INPUT": "#f6c89f", "TEXT_INPUT": "#000000", "SCROLL": "#f6c89f", + "BUTTON": ("#ffe7d1", "#4b8e8d"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "COLOR_LIST": ["#396362", "#4b8e8d", "#f6c89f", "#ffe7d1"], }, + "LightBrown7": {"BACKGROUND": "#f6c89f", "TEXT": "#396362", "INPUT": "#4b8e8d", "TEXT_INPUT": "#FFFFFF", "SCROLL": "#396362", + "BUTTON": ("#FFFFFF", "#396362"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "COLOR_LIST": ["#396362", "#4b8e8d", "#f6c89f", "#ffe7d1"], }, + "DarkPurple1": {"BACKGROUND": "#0c093c", "TEXT": "#fad6d6", "INPUT": "#eea5f6", "TEXT_INPUT": "#000000", "SCROLL": "#eea5f6", + "BUTTON": ("#FFFFFF", "#df42d1"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "COLOR_LIST": ["#0c093c", "#df42d1", "#eea5f6", "#fad6d6"], }, + "DarkGrey3": {"BACKGROUND": "#211717", "TEXT": "#dfddc7", "INPUT": "#f58b54", "TEXT_INPUT": "#000000", "SCROLL": "#f58b54", + "BUTTON": ("#dfddc7", "#a34a28"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "COLOR_LIST": ["#211717", "#a34a28", "#f58b54", "#dfddc7"], }, + "LightBrown8": {"BACKGROUND": "#dfddc7", "TEXT": "#211717", "INPUT": "#a34a28", "TEXT_INPUT": "#dfddc7", "SCROLL": "#211717", + "BUTTON": ("#dfddc7", "#a34a28"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "COLOR_LIST": ["#211717", "#a34a28", "#f58b54", "#dfddc7"], }, + "DarkBlue4": {"BACKGROUND": "#494ca2", "TEXT": "#e3e7f1", "INPUT": "#c6cbef", "TEXT_INPUT": "#000000", "SCROLL": "#c6cbef", + "BUTTON": ("#FFFFFF", "#8186d5"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "COLOR_LIST": ["#494ca2", "#8186d5", "#c6cbef", "#e3e7f1"], }, + "LightBlue4": {"BACKGROUND": "#5c94bd", "TEXT": "#470938", "INPUT": "#1a3e59", "TEXT_INPUT": "#FFFFFF", "SCROLL": "#470938", + "BUTTON": ("#FFFFFF", "#470938"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "COLOR_LIST": ["#470938", "#1a3e59", "#5c94bd", "#f2d6eb"], }, + "DarkTeal2": {"BACKGROUND": "#394a6d", "TEXT": "#c0ffb3", "INPUT": "#52de97", "TEXT_INPUT": "#000000", "SCROLL": "#52de97", + "BUTTON": ("#c0ffb3", "#394a6d"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "COLOR_LIST": ["#394a6d", "#3c9d9b", "#52de97", "#c0ffb3"], }, + "DarkTeal3": {"BACKGROUND": "#3c9d9b", "TEXT": "#c0ffb3", "INPUT": "#52de97", "TEXT_INPUT": "#000000", "SCROLL": "#52de97", + "BUTTON": ("#c0ffb3", "#394a6d"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "COLOR_LIST": ["#394a6d", "#3c9d9b", "#52de97", "#c0ffb3"], }, + "DarkPurple5": {"BACKGROUND": "#730068", "TEXT": "#f6f078", "INPUT": "#01d28e", "TEXT_INPUT": "#000000", "SCROLL": "#01d28e", + "BUTTON": ("#f6f078", "#730068"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "COLOR_LIST": ["#730068", "#434982", "#01d28e", "#f6f078"], }, + "DarkPurple2": {"BACKGROUND": "#202060", "TEXT": "#b030b0", "INPUT": "#602080", "TEXT_INPUT": "#FFFFFF", "SCROLL": "#602080", + "BUTTON": ("#FFFFFF", "#202040"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "COLOR_LIST": ["#202040", "#202060", "#602080", "#b030b0"], }, + "DarkBlue5": {"BACKGROUND": "#000272", "TEXT": "#ff6363", "INPUT": "#a32f80", "TEXT_INPUT": "#FFFFFF", "SCROLL": "#a32f80", + "BUTTON": ("#FFFFFF", "#341677"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "COLOR_LIST": ["#000272", "#341677", "#a32f80", "#ff6363"], }, + "LightGrey2": {"BACKGROUND": "#f6f6f6", "TEXT": "#420000", "INPUT": "#d4d7dd", "TEXT_INPUT": "#420000", "SCROLL": "#420000", + "BUTTON": ("#420000", "#d4d7dd"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "COLOR_LIST": ["#420000", "#d4d7dd", "#eae9e9", "#f6f6f6"], }, + "LightGrey3": {"BACKGROUND": "#eae9e9", "TEXT": "#420000", "INPUT": "#d4d7dd", "TEXT_INPUT": "#420000", "SCROLL": "#420000", + "BUTTON": ("#420000", "#d4d7dd"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "COLOR_LIST": ["#420000", "#d4d7dd", "#eae9e9", "#f6f6f6"], }, + "DarkBlue6": {"BACKGROUND": "#01024e", "TEXT": "#ff6464", "INPUT": "#8b4367", "TEXT_INPUT": "#FFFFFF", "SCROLL": "#8b4367", + "BUTTON": ("#FFFFFF", "#543864"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "COLOR_LIST": ["#01024e", "#543864", "#8b4367", "#ff6464"], }, + "DarkBlue7": {"BACKGROUND": "#241663", "TEXT": "#eae7af", "INPUT": "#a72693", "TEXT_INPUT": "#eae7af", "SCROLL": "#a72693", + "BUTTON": ("#eae7af", "#160f30"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "COLOR_LIST": ["#160f30", "#241663", "#a72693", "#eae7af"], }, + "LightBrown9": {"BACKGROUND": "#f6d365", "TEXT": "#3a1f5d", "INPUT": "#c83660", "TEXT_INPUT": "#f6d365", "SCROLL": "#3a1f5d", + "BUTTON": ("#f6d365", "#c83660"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "COLOR_LIST": ["#3a1f5d", "#c83660", "#e15249", "#f6d365"], }, + "DarkPurple3": {"BACKGROUND": "#6e2142", "TEXT": "#ffd692", "INPUT": "#e16363", "TEXT_INPUT": "#ffd692", "SCROLL": "#e16363", + "BUTTON": ("#ffd692", "#943855"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "COLOR_LIST": ["#6e2142", "#943855", "#e16363", "#ffd692"], }, + "LightBrown10": {"BACKGROUND": "#ffd692", "TEXT": "#6e2142", "INPUT": "#943855", "TEXT_INPUT": "#FFFFFF", "SCROLL": "#6e2142", + "BUTTON": ("#FFFFFF", "#6e2142"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "COLOR_LIST": ["#6e2142", "#943855", "#e16363", "#ffd692"], }, + "DarkPurple4": {"BACKGROUND": "#200f21", "TEXT": "#f638dc", "INPUT": "#5a3d5c", "TEXT_INPUT": "#FFFFFF", "SCROLL": "#5a3d5c", + "BUTTON": ("#FFFFFF", "#382039"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "COLOR_LIST": ["#200f21", "#382039", "#5a3d5c", "#f638dc"], }, + "LightBlue5": {"BACKGROUND": "#b2fcff", "TEXT": "#3e64ff", "INPUT": "#5edfff", "TEXT_INPUT": "#000000", "SCROLL": "#3e64ff", + "BUTTON": ("#FFFFFF", "#3e64ff"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "COLOR_LIST": ["#3e64ff", "#5edfff", "#b2fcff", "#ecfcff"], }, + "DarkTeal4": {"BACKGROUND": "#464159", "TEXT": "#c7f0db", "INPUT": "#8bbabb", "TEXT_INPUT": "#000000", "SCROLL": "#8bbabb", + "BUTTON": ("#FFFFFF", "#6c7b95"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "COLOR_LIST": ["#464159", "#6c7b95", "#8bbabb", "#c7f0db"], }, + "LightTeal": {"BACKGROUND": "#c7f0db", "TEXT": "#464159", "INPUT": "#6c7b95", "TEXT_INPUT": "#FFFFFF", "SCROLL": "#464159", + "BUTTON": ("#FFFFFF", "#464159"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "COLOR_LIST": ["#464159", "#6c7b95", "#8bbabb", "#c7f0db"], }, + "DarkTeal5": {"BACKGROUND": "#8bbabb", "TEXT": "#464159", "INPUT": "#6c7b95", "TEXT_INPUT": "#FFFFFF", "SCROLL": "#464159", + "BUTTON": ("#c7f0db", "#6c7b95"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "COLOR_LIST": ["#464159", "#6c7b95", "#8bbabb", "#c7f0db"], }, + "LightGrey4": {"BACKGROUND": "#faf5ef", "TEXT": "#672f2f", "INPUT": "#99b19c", "TEXT_INPUT": "#672f2f", "SCROLL": "#672f2f", + "BUTTON": ("#672f2f", "#99b19c"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "COLOR_LIST": ["#672f2f", "#99b19c", "#d7d1c9", "#faf5ef"], }, + "LightGreen7": {"BACKGROUND": "#99b19c", "TEXT": "#faf5ef", "INPUT": "#d7d1c9", "TEXT_INPUT": "#000000", "SCROLL": "#d7d1c9", + "BUTTON": ("#FFFFFF", "#99b19c"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "COLOR_LIST": ["#672f2f", "#99b19c", "#d7d1c9", "#faf5ef"], }, + "LightGrey5": {"BACKGROUND": "#d7d1c9", "TEXT": "#672f2f", "INPUT": "#99b19c", "TEXT_INPUT": "#672f2f", "SCROLL": "#672f2f", + "BUTTON": ("#FFFFFF", "#672f2f"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "COLOR_LIST": ["#672f2f", "#99b19c", "#d7d1c9", "#faf5ef"], }, + "DarkBrown3": {"BACKGROUND": "#a0855b", "TEXT": "#f9f6f2", "INPUT": "#f1d6ab", "TEXT_INPUT": "#000000", "SCROLL": "#f1d6ab", + "BUTTON": ("#FFFFFF", "#38470b"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "COLOR_LIST": ["#38470b", "#a0855b", "#f1d6ab", "#f9f6f2"], }, + "LightBrown11": {"BACKGROUND": "#f1d6ab", "TEXT": "#38470b", "INPUT": "#a0855b", "TEXT_INPUT": "#FFFFFF", "SCROLL": "#38470b", + "BUTTON": ("#f9f6f2", "#a0855b"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "COLOR_LIST": ["#38470b", "#a0855b", "#f1d6ab", "#f9f6f2"], }, + "DarkRed": {"BACKGROUND": "#83142c", "TEXT": "#f9d276", "INPUT": "#ad1d45", "TEXT_INPUT": "#FFFFFF", "SCROLL": "#ad1d45", "BUTTON": ("#f9d276", "#ad1d45"), + "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "COLOR_LIST": ["#44000d", "#83142c", "#ad1d45", "#f9d276"], }, + "DarkTeal6": {"BACKGROUND": "#204969", "TEXT": "#fff7f7", "INPUT": "#dadada", "TEXT_INPUT": "#000000", "SCROLL": "#dadada", + "BUTTON": ("#000000", "#fff7f7"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "COLOR_LIST": ["#204969", "#08ffc8", "#dadada", "#fff7f7"], }, + "DarkBrown4": {"BACKGROUND": "#252525", "TEXT": "#ff0000", "INPUT": "#af0404", "TEXT_INPUT": "#FFFFFF", "SCROLL": "#af0404", + "BUTTON": ("#FFFFFF", "#252525"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "COLOR_LIST": ["#252525", "#414141", "#af0404", "#ff0000"], }, + "LightYellow": {"BACKGROUND": "#f4ff61", "TEXT": "#27aa80", "INPUT": "#32ff6a", "TEXT_INPUT": "#000000", "SCROLL": "#27aa80", + "BUTTON": ("#f4ff61", "#27aa80"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "COLOR_LIST": ["#27aa80", "#32ff6a", "#a8ff3e", "#f4ff61"], }, + "DarkGreen1": {"BACKGROUND": "#2b580c", "TEXT": "#fdef96", "INPUT": "#f7b71d", "TEXT_INPUT": "#000000", "SCROLL": "#f7b71d", + "BUTTON": ("#fdef96", "#2b580c"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "COLOR_LIST": ["#2b580c", "#afa939", "#f7b71d", "#fdef96"], }, + "LightGreen8": {"BACKGROUND": "#c8dad3", "TEXT": "#63707e", "INPUT": "#93b5b3", "TEXT_INPUT": "#000000", "SCROLL": "#63707e", + "BUTTON": ("#FFFFFF", "#63707e"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "COLOR_LIST": ["#63707e", "#93b5b3", "#c8dad3", "#f2f6f5"], }, + "DarkTeal7": {"BACKGROUND": "#248ea9", "TEXT": "#fafdcb", "INPUT": "#aee7e8", "TEXT_INPUT": "#000000", "SCROLL": "#aee7e8", + "BUTTON": ("#000000", "#fafdcb"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "COLOR_LIST": ["#248ea9", "#28c3d4", "#aee7e8", "#fafdcb"], }, + "DarkBlue8": {"BACKGROUND": "#454d66", "TEXT": "#d9d872", "INPUT": "#58b368", "TEXT_INPUT": "#000000", "SCROLL": "#58b368", + "BUTTON": ("#000000", "#009975"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "COLOR_LIST": ["#009975", "#454d66", "#58b368", "#d9d872"], }, + "DarkBlue9": {"BACKGROUND": "#263859", "TEXT": "#ff6768", "INPUT": "#6b778d", "TEXT_INPUT": "#FFFFFF", "SCROLL": "#6b778d", + "BUTTON": ("#ff6768", "#263859"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "COLOR_LIST": ["#17223b", "#263859", "#6b778d", "#ff6768"], }, + "DarkBlue10": {"BACKGROUND": "#0028ff", "TEXT": "#f1f4df", "INPUT": "#10eaf0", "TEXT_INPUT": "#000000", "SCROLL": "#10eaf0", + "BUTTON": ("#f1f4df", "#24009c"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "COLOR_LIST": ["#24009c", "#0028ff", "#10eaf0", "#f1f4df"], }, + "DarkBlue11": {"BACKGROUND": "#6384b3", "TEXT": "#e6f0b6", "INPUT": "#b8e9c0", "TEXT_INPUT": "#000000", "SCROLL": "#b8e9c0", + "BUTTON": ("#e6f0b6", "#684949"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "COLOR_LIST": ["#684949", "#6384b3", "#b8e9c0", "#e6f0b6"], }, + "DarkTeal8": {"BACKGROUND": "#71a0a5", "TEXT": "#212121", "INPUT": "#665c84", "TEXT_INPUT": "#FFFFFF", "SCROLL": "#212121", + "BUTTON": ("#fab95b", "#665c84"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "COLOR_LIST": ["#212121", "#665c84", "#71a0a5", "#fab95b"], }, + "DarkRed1": {"BACKGROUND": "#c10000", "TEXT": "#eeeeee", "INPUT": "#dedede", "TEXT_INPUT": "#000000", "SCROLL": "#dedede", "BUTTON": ("#c10000", "#eeeeee"), + "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "COLOR_LIST": ["#c10000", "#ff4949", "#dedede", "#eeeeee"], }, + "LightBrown5": {"BACKGROUND": "#fff591", "TEXT": "#e41749", "INPUT": "#f5587b", "TEXT_INPUT": "#000000", "SCROLL": "#e41749", + "BUTTON": ("#fff591", "#e41749"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "COLOR_LIST": ["#e41749", "#f5587b", "#ff8a5c", "#fff591"], }, + "LightGreen9": {"BACKGROUND": "#f1edb3", "TEXT": "#3b503d", "INPUT": "#4a746e", "TEXT_INPUT": "#f1edb3", "SCROLL": "#3b503d", + "BUTTON": ("#f1edb3", "#3b503d"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "COLOR_LIST": ["#3b503d", "#4a746e", "#c8cf94", "#f1edb3"], "DESCRIPTION": ["Green", "Turquoise", "Yellow"], }, + "DarkGreen2": {"BACKGROUND": "#3b503d", "TEXT": "#f1edb3", "INPUT": "#c8cf94", "TEXT_INPUT": "#000000", "SCROLL": "#c8cf94", + "BUTTON": ("#f1edb3", "#3b503d"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "COLOR_LIST": ["#3b503d", "#4a746e", "#c8cf94", "#f1edb3"], "DESCRIPTION": ["Green", "Turquoise", "Yellow"], }, + "LightGray1": {"BACKGROUND": "#f2f2f2", "TEXT": "#222831", "INPUT": "#393e46", "TEXT_INPUT": "#FFFFFF", "SCROLL": "#222831", + "BUTTON": ("#f2f2f2", "#222831"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "COLOR_LIST": ["#222831", "#393e46", "#f96d00", "#f2f2f2"], "DESCRIPTION": ["#000000", "Grey", "Orange", "Grey", "Autumn"], }, + "DarkGrey4": {"BACKGROUND": "#52524e", "TEXT": "#e9e9e5", "INPUT": "#d4d6c8", "TEXT_INPUT": "#000000", "SCROLL": "#d4d6c8", + "BUTTON": ("#FFFFFF", "#9a9b94"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "COLOR_LIST": ["#52524e", "#9a9b94", "#d4d6c8", "#e9e9e5"], "DESCRIPTION": ["Grey", "Pastel", "Winter"], }, + "DarkBlue12": {"BACKGROUND": "#324e7b", "TEXT": "#f8f8f8", "INPUT": "#86a6df", "TEXT_INPUT": "#000000", "SCROLL": "#86a6df", + "BUTTON": ("#FFFFFF", "#5068a9"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "COLOR_LIST": ["#324e7b", "#5068a9", "#86a6df", "#f8f8f8"], "DESCRIPTION": ["Blue", "Grey", "Cold", "Winter"], }, + "DarkPurple6": {"BACKGROUND": "#070739", "TEXT": "#e1e099", "INPUT": "#c327ab", "TEXT_INPUT": "#e1e099", "SCROLL": "#c327ab", + "BUTTON": ("#e1e099", "#521477"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "COLOR_LIST": ["#070739", "#521477", "#c327ab", "#e1e099"], "DESCRIPTION": ["#000000", "Purple", "Yellow", "Dark"], }, + "DarkPurple7": {"BACKGROUND": "#191930", "TEXT": "#B1B7C5", "INPUT": "#232B5C", "TEXT_INPUT": "#D0E3E7", "SCROLL": "#B1B7C5", + "BUTTON": ("#272D38", "#B1B7C5"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, }, + "DarkBlue13": {"BACKGROUND": "#203562", "TEXT": "#e3e8f8", "INPUT": "#c0c5cd", "TEXT_INPUT": "#000000", "SCROLL": "#c0c5cd", + "BUTTON": ("#FFFFFF", "#3e588f"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "COLOR_LIST": ["#203562", "#3e588f", "#c0c5cd", "#e3e8f8"], "DESCRIPTION": ["Blue", "Grey", "Wedding", "Cold"], }, + "DarkBrown5": {"BACKGROUND": "#3c1b1f", "TEXT": "#f6e1b5", "INPUT": "#e2bf81", "TEXT_INPUT": "#000000", "SCROLL": "#e2bf81", + "BUTTON": ("#3c1b1f", "#f6e1b5"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "COLOR_LIST": ["#3c1b1f", "#b21e4b", "#e2bf81", "#f6e1b5"], "DESCRIPTION": ["Brown", "Red", "Yellow", "Warm"], }, + "DarkGreen3": {"BACKGROUND": "#062121", "TEXT": "#eeeeee", "INPUT": "#e4dcad", "TEXT_INPUT": "#000000", "SCROLL": "#e4dcad", + "BUTTON": ("#eeeeee", "#181810"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "COLOR_LIST": ["#062121", "#181810", "#e4dcad", "#eeeeee"], "DESCRIPTION": ["#000000", "#000000", "Brown", "Grey"], }, + "DarkBlack1": {"BACKGROUND": "#181810", "TEXT": "#eeeeee", "INPUT": "#e4dcad", "TEXT_INPUT": "#000000", "SCROLL": "#e4dcad", + "BUTTON": ("#FFFFFF", "#062121"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "COLOR_LIST": ["#062121", "#181810", "#e4dcad", "#eeeeee"], "DESCRIPTION": ["#000000", "#000000", "Brown", "Grey"], }, + "DarkGrey5": {"BACKGROUND": "#343434", "TEXT": "#f3f3f3", "INPUT": "#e9dcbe", "TEXT_INPUT": "#000000", "SCROLL": "#e9dcbe", + "BUTTON": ("#FFFFFF", "#8e8b82"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "COLOR_LIST": ["#343434", "#8e8b82", "#e9dcbe", "#f3f3f3"], "DESCRIPTION": ["Grey", "Brown"], }, + "LightBrown12": {"BACKGROUND": "#8e8b82", "TEXT": "#f3f3f3", "INPUT": "#e9dcbe", "TEXT_INPUT": "#000000", "SCROLL": "#e9dcbe", + "BUTTON": ("#f3f3f3", "#8e8b82"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "COLOR_LIST": ["#343434", "#8e8b82", "#e9dcbe", "#f3f3f3"], "DESCRIPTION": ["Grey", "Brown"], }, + "DarkTeal9": {"BACKGROUND": "#13445a", "TEXT": "#fef4e8", "INPUT": "#446878", "TEXT_INPUT": "#FFFFFF", "SCROLL": "#446878", + "BUTTON": ("#fef4e8", "#446878"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "COLOR_LIST": ["#13445a", "#970747", "#446878", "#fef4e8"], "DESCRIPTION": ["Red", "Grey", "Blue", "Wedding", "Retro"], }, + "DarkBlue14": {"BACKGROUND": "#21273d", "TEXT": "#f1f6f8", "INPUT": "#b9d4f1", "TEXT_INPUT": "#000000", "SCROLL": "#b9d4f1", + "BUTTON": ("#FFFFFF", "#6a759b"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "COLOR_LIST": ["#21273d", "#6a759b", "#b9d4f1", "#f1f6f8"], "DESCRIPTION": ["Blue", "#000000", "Grey", "Cold", "Winter"], }, + "LightBlue6": {"BACKGROUND": "#f1f6f8", "TEXT": "#21273d", "INPUT": "#6a759b", "TEXT_INPUT": "#FFFFFF", "SCROLL": "#21273d", + "BUTTON": ("#f1f6f8", "#6a759b"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "COLOR_LIST": ["#21273d", "#6a759b", "#b9d4f1", "#f1f6f8"], "DESCRIPTION": ["Blue", "#000000", "Grey", "Cold", "Winter"], }, + "DarkGreen4": {"BACKGROUND": "#044343", "TEXT": "#e4e4e4", "INPUT": "#045757", "TEXT_INPUT": "#e4e4e4", "SCROLL": "#045757", + "BUTTON": ("#e4e4e4", "#045757"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "COLOR_LIST": ["#222222", "#044343", "#045757", "#e4e4e4"], "DESCRIPTION": ["#000000", "Turquoise", "Grey", "Dark"], }, + "DarkGreen5": {"BACKGROUND": "#1b4b36", "TEXT": "#e0e7f1", "INPUT": "#aebd77", "TEXT_INPUT": "#000000", "SCROLL": "#aebd77", + "BUTTON": ("#FFFFFF", "#538f6a"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "COLOR_LIST": ["#1b4b36", "#538f6a", "#aebd77", "#e0e7f1"], "DESCRIPTION": ["Green", "Grey"], }, + "DarkTeal10": {"BACKGROUND": "#0d3446", "TEXT": "#d8dfe2", "INPUT": "#71adb5", "TEXT_INPUT": "#000000", "SCROLL": "#71adb5", + "BUTTON": ("#FFFFFF", "#176d81"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "COLOR_LIST": ["#0d3446", "#176d81", "#71adb5", "#d8dfe2"], "DESCRIPTION": ["Grey", "Turquoise", "Winter", "Cold"], }, + "DarkGrey6": {"BACKGROUND": "#3e3e3e", "TEXT": "#ededed", "INPUT": "#68868c", "TEXT_INPUT": "#ededed", "SCROLL": "#68868c", + "BUTTON": ("#FFFFFF", "#405559"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "COLOR_LIST": ["#3e3e3e", "#405559", "#68868c", "#ededed"], "DESCRIPTION": ["Grey", "Turquoise", "Winter"], }, + "DarkTeal11": {"BACKGROUND": "#405559", "TEXT": "#ededed", "INPUT": "#68868c", "TEXT_INPUT": "#ededed", "SCROLL": "#68868c", + "BUTTON": ("#ededed", "#68868c"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "COLOR_LIST": ["#3e3e3e", "#405559", "#68868c", "#ededed"], "DESCRIPTION": ["Grey", "Turquoise", "Winter"], }, + "LightBlue7": {"BACKGROUND": "#9ed0e0", "TEXT": "#19483f", "INPUT": "#5c868e", "TEXT_INPUT": "#FFFFFF", "SCROLL": "#19483f", + "BUTTON": ("#FFFFFF", "#19483f"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "COLOR_LIST": ["#19483f", "#5c868e", "#ff6a38", "#9ed0e0"], "DESCRIPTION": ["Orange", "Blue", "Turquoise"], }, + "LightGreen10": {"BACKGROUND": "#d8ebb5", "TEXT": "#205d67", "INPUT": "#639a67", "TEXT_INPUT": "#FFFFFF", "SCROLL": "#205d67", + "BUTTON": ("#d8ebb5", "#205d67"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "COLOR_LIST": ["#205d67", "#639a67", "#d9bf77", "#d8ebb5"], "DESCRIPTION": ["Blue", "Green", "Brown", "Vintage"], }, + "DarkBlue15": {"BACKGROUND": "#151680", "TEXT": "#f1fea4", "INPUT": "#375fc0", "TEXT_INPUT": "#f1fea4", "SCROLL": "#375fc0", + "BUTTON": ("#f1fea4", "#1c44ac"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "COLOR_LIST": ["#151680", "#1c44ac", "#375fc0", "#f1fea4"], "DESCRIPTION": ["Blue", "Yellow", "Cold"], }, + "DarkBlue16": {"BACKGROUND": "#1c44ac", "TEXT": "#f1fea4", "INPUT": "#375fc0", "TEXT_INPUT": "#f1fea4", "SCROLL": "#375fc0", + "BUTTON": ("#f1fea4", "#151680"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "COLOR_LIST": ["#151680", "#1c44ac", "#375fc0", "#f1fea4"], "DESCRIPTION": ["Blue", "Yellow", "Cold"], }, + "DarkTeal12": {"BACKGROUND": "#004a7c", "TEXT": "#fafafa", "INPUT": "#e8f1f5", "TEXT_INPUT": "#000000", "SCROLL": "#e8f1f5", + "BUTTON": ("#fafafa", "#005691"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "COLOR_LIST": ["#004a7c", "#005691", "#e8f1f5", "#fafafa"], "DESCRIPTION": ["Grey", "Blue", "Cold", "Winter"], }, + "LightBrown13": {"BACKGROUND": "#ebf5ee", "TEXT": "#921224", "INPUT": "#bdc6b8", "TEXT_INPUT": "#921224", "SCROLL": "#921224", + "BUTTON": ("#FFFFFF", "#921224"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "COLOR_LIST": ["#921224", "#bdc6b8", "#bce0da", "#ebf5ee"], "DESCRIPTION": ["Red", "Blue", "Grey", "Vintage", "Wedding"], }, + "DarkBlue17": {"BACKGROUND": "#21294c", "TEXT": "#f9f2d7", "INPUT": "#f2dea8", "TEXT_INPUT": "#000000", "SCROLL": "#f2dea8", + "BUTTON": ("#f9f2d7", "#141829"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "COLOR_LIST": ["#141829", "#21294c", "#f2dea8", "#f9f2d7"], "DESCRIPTION": ["#000000", "Blue", "Yellow"], }, + "DarkBlue18": {"BACKGROUND": "#0c1825", "TEXT": "#d1d7dd", "INPUT": "#001c35", "TEXT_INPUT": "#d1d7dd", "SCROLL": "#00438e", + "BUTTON": ("#75b7ff", "#001c35"), "PROGRESS": ('#0074ff', '#75b7ff'), "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "COLOR_LIST": ["#141829", "#21294c", "#f2dea8", "#f9f2d7"], "DESCRIPTION": ["#000000", "Blue", "Yellow"], }, + "DarkBrown6": {"BACKGROUND": "#785e4d", "TEXT": "#f2eee3", "INPUT": "#baaf92", "TEXT_INPUT": "#000000", "SCROLL": "#baaf92", + "BUTTON": ("#FFFFFF", "#785e4d"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "COLOR_LIST": ["#785e4d", "#ff8426", "#baaf92", "#f2eee3"], "DESCRIPTION": ["Grey", "Brown", "Orange", "Autumn"], }, + "DarkGreen6": {"BACKGROUND": "#5c715e", "TEXT": "#f2f9f1", "INPUT": "#ddeedf", "TEXT_INPUT": "#000000", "SCROLL": "#ddeedf", + "BUTTON": ("#f2f9f1", "#5c715e"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "COLOR_LIST": ["#5c715e", "#b6cdbd", "#ddeedf", "#f2f9f1"], "DESCRIPTION": ["Grey", "Green", "Vintage"], }, + "DarkGreen7": {"BACKGROUND": "#0C231E", "TEXT": "#efbe1c", "INPUT": "#153C33", "TEXT_INPUT": "#efbe1c", "SCROLL": "#153C33", + "BUTTON": ("#efbe1c", "#153C33"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, }, + "DarkGrey7": {"BACKGROUND": "#4b586e", "TEXT": "#dddddd", "INPUT": "#574e6d", "TEXT_INPUT": "#dddddd", "SCROLL": "#574e6d", + "BUTTON": ("#dddddd", "#43405d"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "COLOR_LIST": ["#43405d", "#4b586e", "#574e6d", "#dddddd"], "DESCRIPTION": ["Grey", "Winter", "Cold"], }, + "DarkRed2": {"BACKGROUND": "#ab1212", "TEXT": "#f6e4b5", "INPUT": "#cd3131", "TEXT_INPUT": "#f6e4b5", "SCROLL": "#cd3131", "BUTTON": ("#f6e4b5", "#ab1212"), + "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "COLOR_LIST": ["#ab1212", "#1fad9f", "#cd3131", "#f6e4b5"], "DESCRIPTION": ["Turquoise", "Red", "Yellow"], }, + "LightGrey6": {"BACKGROUND": "#e3e3e3", "TEXT": "#233142", "INPUT": "#455d7a", "TEXT_INPUT": "#e3e3e3", "SCROLL": "#233142", + "BUTTON": ("#e3e3e3", "#455d7a"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, + "COLOR_LIST": ["#233142", "#455d7a", "#f95959", "#e3e3e3"], "DESCRIPTION": ["#000000", "Blue", "Red", "Grey"], }, + "HotDogStand": {"BACKGROUND": "red", "TEXT": "yellow", "INPUT": "yellow", "TEXT_INPUT": "#000000", "SCROLL": "yellow", "BUTTON": ("red", "yellow"), + "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, }, + "DarkGrey8": {"BACKGROUND": "#19232D", "TEXT": "#ffffff", "INPUT": "#32414B", "TEXT_INPUT": "#ffffff", "SCROLL": "#505F69", + "BUTTON": ("#ffffff", "#32414B"), "PROGRESS": ("#505F69", "#32414B"), "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, }, + "DarkGrey9": {"BACKGROUND": "#36393F", "TEXT": "#DCDDDE", "INPUT": "#40444B", "TEXT_INPUT": "#ffffff", "SCROLL": "#202225", + "BUTTON": ("#202225", "#B9BBBE"), "PROGRESS": ("#202225", "#40444B"), "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, }, + "DarkGrey10": {"BACKGROUND": "#1c1e23", "TEXT": "#cccdcf", "INPUT": "#272a31", "TEXT_INPUT": "#8b9fde", "SCROLL": "#313641", + "BUTTON": ("#f5f5f6", "#2e3d5a"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, }, + "DarkGrey11": {"BACKGROUND": "#1c1e23", "TEXT": "#cccdcf", "INPUT": "#313641", "TEXT_INPUT": "#cccdcf", "SCROLL": "#313641", + "BUTTON": ("#f5f5f6", "#313641"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, }, + "DarkGrey12": {"BACKGROUND": "#1c1e23", "TEXT": "#8b9fde", "INPUT": "#313641", "TEXT_INPUT": "#8b9fde", "SCROLL": "#313641", + "BUTTON": ("#cccdcf", "#2e3d5a"), "PROGRESS": DEFAULT_PROGRESS_BAR_COMPUTE, "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, }, + "DarkGrey13": {"BACKGROUND": "#1c1e23", "TEXT": "#cccdcf", "INPUT": "#272a31", "TEXT_INPUT": "#cccdcf", "SCROLL": "#313641", + "BUTTON": ("#8b9fde", "#313641"), "PROGRESS": ("#cccdcf", "#272a31"), "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, }, + "DarkGrey14": {"BACKGROUND": "#24292e", "TEXT": "#fafbfc", "INPUT": "#1d2125", "TEXT_INPUT": "#fafbfc", "SCROLL": "#1d2125", + "BUTTON": ("#fafbfc", "#155398"), "PROGRESS": ("#155398", "#1d2125"), "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, }, + "DarkGrey15": {'BACKGROUND': '#121212', 'TEXT': '#dddddd', 'INPUT': '#1e1e1e', 'TEXT_INPUT': '#69b1ef', 'SCROLL': '#272727', + 'BUTTON': ('#69b1ef', '#2e2e2e'), 'PROGRESS': ('#69b1ef', '#2e2e2e'), 'BORDER': 1, 'SLIDER_DEPTH': 0, 'PROGRESS_DEPTH': 0,}, + "DarkGrey16": {'BACKGROUND': '#353535', 'TEXT': '#ffffff', 'INPUT': '#191919', 'TEXT_INPUT': '#ffffff', 'SCROLL': '#454545', + 'BUTTON': ('#ffffff', '#454545'), 'PROGRESS': ('#757575', '#454545'), 'BORDER': 1, 'SLIDER_DEPTH': 0, 'PROGRESS_DEPTH': 0,}, + "DarkBrown7": {"BACKGROUND": "#2c2417", "TEXT": "#baa379", "INPUT": "#baa379", "TEXT_INPUT": "#000000", "SCROLL": "#392e1c", + "BUTTON": ("#000000", "#baa379"), "PROGRESS": ("#baa379", "#453923"), "BORDER": 1, "SLIDER_DEPTH": 1, "PROGRESS_DEPTH": 0, }, + "Python": {"BACKGROUND": "#3d7aab", "TEXT": "#ffde56", "INPUT": "#295273", "TEXT_INPUT": "#ffde56", "SCROLL": "#295273", + "BUTTON": ("#ffde56", "#295273"), "PROGRESS": ("#ffde56", "#295273"), "BORDER": 1, "SLIDER_DEPTH": 1, "PROGRESS_DEPTH": 0, }, + "PythonPlus": {"BACKGROUND": "#001d3c", "TEXT": "#ffffff", "INPUT": "#015bbb", "TEXT_INPUT": "#fed500", "SCROLL": "#015bbb", + "BUTTON": ("#fed500", "#015bbb"), "PROGRESS": ("#015bbb", "#fed500"), "BORDER": 1, "SLIDER_DEPTH": 1, "PROGRESS_DEPTH": 0, }, + "NeonBlue1": {"BACKGROUND": "#000000", "TEXT": "#ffffff", "INPUT": "#000000", "TEXT_INPUT": "#33ccff", "SCROLL": "#33ccff", + "BUTTON": ("#33ccff", "#000000"), "PROGRESS": ("#33ccff", "#ffffff"), "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, }, + "NeonGreen1": {"BACKGROUND": "#000000", "TEXT": "#ffffff", "INPUT": "#000000", "TEXT_INPUT": "#96ff7b", "SCROLL": "#96ff7b", + "BUTTON": ("#96ff7b", "#000000"), "PROGRESS": ("#96ff7b", "#ffffff"), "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, }, + "NeonYellow1": {"BACKGROUND": "#000000", "TEXT": "#ffffff", "INPUT": "#000000", "TEXT_INPUT": "#ffcf40", "SCROLL": "#ffcf40", + "BUTTON": ("#ffcf40", "#000000"), "PROGRESS": ("#ffcf40", "#ffffff"), "BORDER": 1, "SLIDER_DEPTH": 0, "PROGRESS_DEPTH": 0, }, +} + + + +def list_of_look_and_feel_values(): + """ + Get a list of the valid values to pass into your call to change_look_and_feel + + :return: list of valid string values + :rtype: List[str] + """ + + return sorted(list(LOOK_AND_FEEL_TABLE.keys())) + + +def theme(new_theme=None): + """ + Sets / Gets the current Theme. If none is specified then returns the current theme. + This call replaces the ChangeLookAndFeel / change_look_and_feel call which only sets the theme. + + :param new_theme: the new theme name to use + :type new_theme: (str) + :return: the currently selected theme + :rtype: (str) + """ + global TRANSPARENT_BUTTON + + if new_theme is not None: + change_look_and_feel(new_theme) + TRANSPARENT_BUTTON = (theme_background_color(), theme_background_color()) + return CURRENT_LOOK_AND_FEEL + + +def theme_background_color(color=None): + """ + Sets/Returns the background color currently in use + Used for Windows and containers (Column, Frame, Tab) and tables + + :param color: new background color to use (optional) + :type color: (str) + :return: color string of the background color currently in use + :rtype: (str) + """ + if color is not None: + set_options(background_color=color) + return DEFAULT_BACKGROUND_COLOR + + +# This "constant" is misleading but rather than remove and break programs, will try this method instead +TRANSPARENT_BUTTON = (theme_background_color(), theme_background_color()) # replaces an older version that had hardcoded numbers + + +def theme_element_background_color(color=None): + """ + Sets/Returns the background color currently in use for all elements except containers + + :return: (str) - color string of the element background color currently in use + :rtype: (str) + """ + if color is not None: + set_options(element_background_color=color) + return DEFAULT_ELEMENT_BACKGROUND_COLOR + + +def theme_text_color(color=None): + """ + Sets/Returns the text color currently in use + + :return: (str) - color string of the text color currently in use + :rtype: (str) + """ + if color is not None: + set_options(text_color=color) + return DEFAULT_TEXT_COLOR + + +def theme_text_element_background_color(color=None): + """ + Sets/Returns the background color for text elements + + :return: (str) - color string of the text background color currently in use + :rtype: (str) + """ + if color is not None: + set_options(text_element_background_color=color) + return DEFAULT_TEXT_ELEMENT_BACKGROUND_COLOR + + +def theme_input_background_color(color=None): + """ + Sets/Returns the input element background color currently in use + + :return: (str) - color string of the input element background color currently in use + :rtype: (str) + """ + if color is not None: + set_options(input_elements_background_color=color) + return DEFAULT_INPUT_ELEMENTS_COLOR + + +def theme_input_text_color(color=None): + """ + Sets/Returns the input element entry color (not the text but the thing that's displaying the text) + + :return: (str) - color string of the input element color currently in use + :rtype: (str) + """ + if color is not None: + set_options(input_text_color=color) + return DEFAULT_INPUT_TEXT_COLOR + + +def theme_button_color(color=None): + """ + Sets/Returns the button color currently in use + + :return: (str, str) - TUPLE with color strings of the button color currently in use (button text color, button background color) + :rtype: (str, str) + """ + if color is not None: + if color == COLOR_SYSTEM_DEFAULT: + color_tuple = (COLOR_SYSTEM_DEFAULT, COLOR_SYSTEM_DEFAULT) + else: + color_tuple = button_color_to_tuple(color, (None, None)) + if color_tuple == (None, None): + if not SUPPRESS_ERROR_POPUPS: + popup_error('theme_button_color - bad color string passed in', color) + else: + print('** Badly formatted button color... not a tuple nor string **', color) + set_options(button_color=color) # go ahead and try with their string + else: + set_options(button_color=color_tuple) + return DEFAULT_BUTTON_COLOR + + +def theme_button_color_background(): + """ + Returns the button color background currently in use. Note this function simple calls the theme_button_color + function and splits apart the tuple + + :return: color string of the button color background currently in use + :rtype: (str) + """ + return theme_button_color()[1] + + +def theme_button_color_text(): + """ + Returns the button color text currently in use. Note this function simple calls the theme_button_color + function and splits apart the tuple + + :return: color string of the button color text currently in use + :rtype: (str) + """ + return theme_button_color()[0] + + +def theme_progress_bar_color(color=None): + """ + Sets/Returns the progress bar colors by the current color theme + + :return: (str, str) - TUPLE with color strings of the ProgressBar color currently in use(button text color, button background color) + :rtype: (str, str) + """ + if color is not None: + set_options(progress_meter_color=color) + return DEFAULT_PROGRESS_BAR_COLOR + + +def theme_slider_color(color=None): + """ + Sets/Returns the slider color (used for sliders) + + :return: color string of the slider color currently in use + :rtype: (str) + """ + if color is not None: + set_options(scrollbar_color=color) + return DEFAULT_SCROLLBAR_COLOR + + +def theme_border_width(border_width=None): + """ + Sets/Returns the border width currently in use + Used by non ttk elements at the moment + + :return: border width currently in use + :rtype: (int) + """ + if border_width is not None: + set_options(border_width=border_width) + return DEFAULT_BORDER_WIDTH + + +def theme_slider_border_width(border_width=None): + """ + Sets/Returns the slider border width currently in use + + :return: border width currently in use for sliders + :rtype: (int) + """ + if border_width is not None: + set_options(slider_border_width=border_width) + return DEFAULT_SLIDER_BORDER_WIDTH + + +def theme_progress_bar_border_width(border_width=None): + """ + Sets/Returns the progress meter border width currently in use + + :return: border width currently in use for progress meters + :rtype: (int) + """ + if border_width is not None: + set_options(progress_meter_border_depth=border_width) + return DEFAULT_PROGRESS_BAR_BORDER_WIDTH + + +def theme_element_text_color(color=None): + """ + Sets/Returns the text color used by elements that have text as part of their display (Tables, Trees and Sliders) + + :return: color string currently in use + :rtype: (str) + """ + if color is not None: + set_options(element_text_color=color) + return DEFAULT_ELEMENT_TEXT_COLOR + + +def theme_list(): + """ + Returns a sorted list of the currently available color themes + + :return: A sorted list of the currently available color themes + :rtype: List[str] + """ + return list_of_look_and_feel_values() + + +def theme_add_new(new_theme_name, new_theme_dict): + """ + Add a new theme to the dictionary of themes + + :param new_theme_name: text to display in element + :type new_theme_name: (str) + :param new_theme_dict: text to display in element + :type new_theme_dict: (dict) + """ + global LOOK_AND_FEEL_TABLE + try: + LOOK_AND_FEEL_TABLE[new_theme_name] = new_theme_dict + except Exception as e: + print('Exception during adding new theme {}'.format(e)) + + +def theme_use_custom_titlebar(): + """ + Returns True if a custom titlebar will be / should be used. + The setting is in the Global Settings window and can be overwridden + using set_options call + + :return: True if a custom titlebar / custom menubar should be used + :rtype: (bool) + """ + if USE_CUSTOM_TITLEBAR is False: + return False + + return USE_CUSTOM_TITLEBAR or pysimplegui_user_settings.get('-custom titlebar-', False) + + +def theme_global(new_theme=None): + """ + Sets / Gets the global PySimpleGUI Theme. If none is specified then returns the global theme from user settings. + Note the theme must be a standard, built-in PySimpleGUI theme... not a user-created theme. + + :param new_theme: the new theme name to use + :type new_theme: (str) + :return: the currently selected theme + :rtype: (str) + """ + if new_theme is not None: + if new_theme not in theme_list(): + popup_error_with_traceback('Cannot use custom themes with theme_global call', + 'Your request to use theme {} cannot be performed.'.format(new_theme), + 'The PySimpleGUI Global User Settings are meant for PySimpleGUI standard items, not user config items', + 'You can use any of the many built-in themes instead or use your own UserSettings file to store your custom theme') + return pysimplegui_user_settings.get('-theme-', CURRENT_LOOK_AND_FEEL) + pysimplegui_user_settings.set('-theme-', new_theme) + theme(new_theme) + return new_theme + else: + return pysimplegui_user_settings.get('-theme-', CURRENT_LOOK_AND_FEEL) + + +def theme_previewer(columns=12, scrollable=False, scroll_area_size=(None, None), search_string=None, location=(None, None)): + """ + Displays a "Quick Reference Window" showing all of the different Look and Feel settings that are available. + They are sorted alphabetically. The legacy color names are mixed in, but otherwise they are sorted into Dark and Light halves + + :param columns: The number of themes to display per row + :type columns: int + :param scrollable: If True then scrollbars will be added + :type scrollable: bool + :param scroll_area_size: Size of the scrollable area (The Column Element used to make scrollable) + :type scroll_area_size: (int, int) + :param search_string: If specified then only themes containing this string will be shown + :type search_string: str + :param location: Location on the screen to place the window. Defaults to the center like all windows + :type location: (int, int) + """ + + current_theme = theme() + + # Show a "splash" type message so the user doesn't give up waiting + popup_quick_message('Hang on for a moment, this will take a bit to create....', keep_on_top=True, background_color='red', text_color='#FFFFFF', + auto_close=True, non_blocking=True) + + web = False + + win_bg = 'black' + + def sample_layout(): + return [[Text('Text element'), InputText('Input data here', size=(10, 1))], + [Button('Ok'), Button('Disabled', disabled=True), Slider((1, 10), orientation='h', size=(5, 15))]] + + names = list_of_look_and_feel_values() + names.sort() + if search_string not in (None, ''): + names = [name for name in names if search_string.lower().replace(" ", "") in name.lower().replace(" ", "")] + + if search_string not in (None, ''): + layout = [[Text('Themes containing "{}"'.format(search_string), font='Default 18', background_color=win_bg)]] + else: + layout = [[Text('List of all themes', font='Default 18', background_color=win_bg)]] + + col_layout = [] + row = [] + for count, theme_name in enumerate(names): + theme(theme_name) + if not count % columns: + col_layout += [row] + row = [] + row += [Frame(theme_name, sample_layout() if not web else [[T(theme_name)]] + sample_layout(), pad=(2, 2))] + if row: + col_layout += [row] + + layout += [[Column(col_layout, scrollable=scrollable, size=scroll_area_size, pad=(0, 0), background_color=win_bg, key='-COL-')]] + window = Window('Preview of Themes', layout, background_color=win_bg, resizable=True, location=location, keep_on_top=True, finalize=True, modal=True) + window['-COL-'].expand(True, True, True) # needed so that col will expand with the window + window.read(close=True) + theme(current_theme) + + +preview_all_look_and_feel_themes = theme_previewer + + +def _theme_preview_window_swatches(): + # Begin the layout with a header + layout = [[Text('Themes as color swatches', text_color='white', background_color='black', font='Default 25')], + [Text('Tooltip and right click a color to get the value', text_color='white', background_color='black', font='Default 15')], + [Text('Left click a color to copy to clipboard', text_color='white', background_color='black', font='Default 15')]] + layout = [[Column(layout, element_justification='c', background_color='black')]] + # Create the pain part, the rows of Text with color swatches + for i, theme_name in enumerate(theme_list()): + theme(theme_name) + colors = [theme_background_color(), theme_text_color(), theme_input_background_color(), + theme_input_text_color()] + if theme_button_color() != COLOR_SYSTEM_DEFAULT: + colors.append(theme_button_color()[0]) + colors.append(theme_button_color()[1]) + colors = list(set(colors)) # de-duplicate items + row = [T(theme(), background_color='black', text_color='white', size=(20, 1), justification='r')] + for color in colors: + if color != COLOR_SYSTEM_DEFAULT: + row.append(T(SYMBOL_SQUARE, text_color=color, background_color='black', pad=(0, 0), font='DEFAUlT 20', right_click_menu=['Nothing', [color]], + tooltip=color, enable_events=True, key=(i, color))) + layout += [row] + # place layout inside of a Column so that it's scrollable + layout = [[Column(layout, size=(500, 900), scrollable=True, vertical_scroll_only=True, background_color='black')]] + # finish the layout by adding an exit button + layout += [[B('Exit')]] + + # create and return Window that uses the layout + return Window('Theme Color Swatches', layout, background_color='black', finalize=True, keep_on_top=True) + + +def theme_previewer_swatches(): + """ + Display themes in a window as color swatches. + Click on a color swatch to see the hex value printed on the console. + If you hover over a color or right click it you'll also see the hext value. + """ + current_theme = theme() + popup_quick_message('This is going to take a minute...', text_color='white', background_color='red', font='Default 20', keep_on_top=True) + window = _theme_preview_window_swatches() + theme(OFFICIAL_PYSIMPLEGUI_THEME) + # col_height = window.get_screen_size()[1]-200 + # if window.size[1] > 100: + # window.size = (window.size[0], col_height) + # window.move(window.get_screen_size()[0] // 2 - window.size[0] // 2, 0) + + while True: # Event Loop + event, values = window.read() + if event == WIN_CLOSED or event == 'Exit': + break + if isinstance(event, tuple): # someone clicked a swatch + chosen_color = event[1] + else: + if event[0] == '#': # someone right clicked + chosen_color = event + else: + chosen_color = '' + print('Copied to clipboard color = ', chosen_color) + clipboard_set(chosen_color) + # window.TKroot.clipboard_clear() + # window.TKroot.clipboard_append(chosen_color) + window.close() + theme(current_theme) + + +def change_look_and_feel(index, force=False): + """ + Change the "color scheme" of all future PySimpleGUI Windows. + The scheme are string names that specify a group of colors. Background colors, text colors, button colors. + There are 13 different color settings that are changed at one time using a single call to ChangeLookAndFeel + The look and feel table itself has these indexes into the dictionary LOOK_AND_FEEL_TABLE. + The original list was (prior to a major rework and renaming)... these names still work... + In Nov 2019 a new Theme Formula was devised to make choosing a theme easier: + The "Formula" is: + ["Dark" or "Light"] Color Number + Colors can be Blue Brown Grey Green Purple Red Teal Yellow Black + The number will vary for each pair. There are more DarkGrey entries than there are LightYellow for example. + Default = The default settings (only button color is different than system default) + Default1 = The full system default including the button (everything's gray... how sad... don't be all gray... please....) + :param index: the name of the index into the Look and Feel table (does not have to be exact, can be "fuzzy") + :type index: (str) + :param force: no longer used + :type force: (bool) + :return: None + :rtype: None + """ + global CURRENT_LOOK_AND_FEEL + + # if running_mac() and not force: + # print('*** Changing look and feel is not supported on Mac platform ***') + # return + + requested_theme_name = index + theme_names_list = list_of_look_and_feel_values() + # normalize available l&f values by setting all to lower case + lf_values_lowercase = [item.lower() for item in theme_names_list] + # option 1 + opt1 = requested_theme_name.replace(' ', '').lower() + # option 3 is option 1 with gray replaced with grey + opt3 = opt1.replace('gray', 'grey') + # option 2 (reverse lookup) + optx = requested_theme_name.lower().split(' ') + optx.reverse() + opt2 = ''.join(optx) + + # search for valid l&f name + if requested_theme_name in theme_names_list: + ix = theme_names_list.index(requested_theme_name) + elif opt1 in lf_values_lowercase: + ix = lf_values_lowercase.index(opt1) + elif opt2 in lf_values_lowercase: + ix = lf_values_lowercase.index(opt2) + elif opt3 in lf_values_lowercase: + ix = lf_values_lowercase.index(opt3) + else: + ix = random.randint(0, len(lf_values_lowercase) - 1) + print('** Warning - {} Theme is not a valid theme. Change your theme call. **'.format(index)) + print('valid values are', list_of_look_and_feel_values()) + print('Instead, please enjoy a random Theme named {}'.format(list_of_look_and_feel_values()[ix])) + + selection = theme_names_list[ix] + CURRENT_LOOK_AND_FEEL = selection + try: + colors = LOOK_AND_FEEL_TABLE[selection] + + # Color the progress bar using button background and input colors...unless they're the same + if colors['PROGRESS'] != COLOR_SYSTEM_DEFAULT: + if colors['PROGRESS'] == DEFAULT_PROGRESS_BAR_COMPUTE: + if colors['BUTTON'][1] != colors['INPUT'] and colors['BUTTON'][1] != colors['BACKGROUND']: + colors['PROGRESS'] = colors['BUTTON'][1], colors['INPUT'] + else: # if the same, then use text input on top of input color + colors['PROGRESS'] = (colors['TEXT_INPUT'], colors['INPUT']) + else: + colors['PROGRESS'] = DEFAULT_PROGRESS_BAR_COLOR_OFFICIAL + # call to change all the colors + SetOptions(background_color=colors['BACKGROUND'], + text_element_background_color=colors['BACKGROUND'], + element_background_color=colors['BACKGROUND'], + text_color=colors['TEXT'], + input_elements_background_color=colors['INPUT'], + # button_color=colors['BUTTON'] if not running_mac() else None, + button_color=colors['BUTTON'], + progress_meter_color=colors['PROGRESS'], + border_width=colors['BORDER'], + slider_border_width=colors['SLIDER_DEPTH'], + progress_meter_border_depth=colors['PROGRESS_DEPTH'], + scrollbar_color=(colors['SCROLL']), + element_text_color=colors['TEXT'], + input_text_color=colors['TEXT_INPUT']) + except: # most likely an index out of range + print('** Warning - Theme value not valid. Change your theme call. **') + print('valid values are', list_of_look_and_feel_values()) + + +# ------------------------ Color processing functions ------------------------ + +def _hex_to_hsl(hex): + r, g, b = _hex_to_rgb(hex) + return _rgb_to_hsl(r, g, b) + + +def _hex_to_rgb(hex): + hex = hex.lstrip('#') + hlen = len(hex) + return tuple(int(hex[i:i + hlen // 3], 16) for i in range(0, hlen, hlen // 3)) + + +def _rgb_to_hsl(r, g, b): + r = float(r) + g = float(g) + b = float(b) + high = max(r, g, b) + low = min(r, g, b) + h, s, v = ((high + low) / 2,) * 3 + if high == low: + h = s = 0.0 + else: + d = high - low + l = (high + low) / 2 + s = d / (2 - high - low) if l > 0.5 else d / (high + low) + h = { + r: (g - b) / d + (6 if g < b else 0), + g: (b - r) / d + 2, + b: (r - g) / d + 4, + }[high] + h /= 6 + return h, s, v + + +def _hsl_to_rgb(h, s, l): + def hue_to_rgb(p, q, t): + t += 1 if t < 0 else 0 + t -= 1 if t > 1 else 0 + if t < 1 / 6: return p + (q - p) * 6 * t + if t < 1 / 2: return q + if t < 2 / 3: p + (q - p) * (2 / 3 - t) * 6 + return p + + if s == 0: + r, g, b = l, l, l + else: + q = l * (1 + s) if l < 0.5 else l + s - l * s + p = 2 * l - q + r = hue_to_rgb(p, q, h + 1 / 3) + g = hue_to_rgb(p, q, h) + b = hue_to_rgb(p, q, h - 1 / 3) + + return r, g, b + + +def _hsv_to_hsl(h, s, v): + l = 0.5 * v * (2 - s) + s = v * s / (1 - fabs(2 * l - 1)) + return h, s, l + + +def _hsl_to_hsv(h, s, l): + v = (2 * l + s * (1 - fabs(2 * l - 1))) / 2 + s = 2 * (v - l) / v + return h, s, v + + +# Converts an object's contents into a nice printable string. Great for dumping debug data +def obj_to_string_single_obj(obj): + """ + Dumps an Object's values as a formatted string. Very nicely done. Great way to display an object's member variables in human form + Returns only the top-most object's variables instead of drilling down to dispolay more + :param obj: The object to display + :type obj: (Any) + :return: Formatted output of the object's values + :rtype: (str) + """ + if obj is None: + return 'None' + return str(obj.__class__) + '\n' + '\n'.join( + (repr(item) + ' = ' + repr(obj.__dict__[item]) for item in sorted(obj.__dict__))) + + +def obj_to_string(obj, extra=' '): + """ + Dumps an Object's values as a formatted string. Very nicely done. Great way to display an object's member variables in human form + :param obj: The object to display + :type obj: (Any) + :param extra: extra stuff (Default value = ' ') + :type extra: (str) + :return: Formatted output of the object's values + :rtype: (str) + """ + if obj is None: + return 'None' + return str(obj.__class__) + '\n' + '\n'.join( + (extra + (str(item) + ' = ' + + (ObjToString(obj.__dict__[item], extra + ' ') if hasattr(obj.__dict__[item], '__dict__') else str( + obj.__dict__[item]))) + for item in sorted(obj.__dict__))) + + +# ...######..##.......####.########..########...#######.....###....########..########. +# ..##....##.##........##..##.....##.##.....##.##.....##...##.##...##.....##.##.....## +# ..##.......##........##..##.....##.##.....##.##.....##..##...##..##.....##.##.....## +# ..##.......##........##..########..########..##.....##.##.....##.########..##.....## +# ..##.......##........##..##........##.....##.##.....##.#########.##...##...##.....## +# ..##....##.##........##..##........##.....##.##.....##.##.....##.##....##..##.....## +# ...######..########.####.##........########...#######..##.....##.##.....##.########. +# ................................................................................ +# ..########.##.....##.##....##..######..########.####..#######..##....##..######. +# ..##.......##.....##.###...##.##....##....##.....##..##.....##.###...##.##....## +# ..##.......##.....##.####..##.##..........##.....##..##.....##.####..##.##...... +# ..######...##.....##.##.##.##.##..........##.....##..##.....##.##.##.##..######. +# ..##.......##.....##.##..####.##..........##.....##..##.....##.##..####.......## +# ..##.......##.....##.##...###.##....##....##.....##..##.....##.##...###.##....## +# ..##........#######..##....##..######.....##....####..#######..##....##..######. + +def clipboard_set(new_value): + """ + Sets the clipboard to a specific value. + IMPORTANT NOTE - Your PySimpleGUI application needs to remain running until you've pasted + your clipboard. This is a tkinter limitation. A workaround was found for Windows, but you still + need to stay running for Linux systems. + + :param new_value: value to set the clipboard to. Will be converted to a string + :type new_value: (str | bytes) + """ + root = _get_hidden_master_root() + root.clipboard_clear() + root.clipboard_append(str(new_value)) + root.update() + +def clipboard_get(): + """ + Gets the clipboard current value. + + :return: The current value of the clipboard + :rtype: (str) + """ + root = _get_hidden_master_root() + + try: + value = root.clipboard_get() + except: + value = '' + root.update() + return value + + +# MM"""""""`YM +# MM mmmmm M +# M' .M .d8888b. 88d888b. dP dP 88d888b. .d8888b. +# MM MMMMMMMM 88' `88 88' `88 88 88 88' `88 Y8ooooo. +# MM MMMMMMMM 88. .88 88. .88 88. .88 88. .88 88 +# MM MMMMMMMM `88888P' 88Y888P' `88888P' 88Y888P' `88888P' +# MMMMMMMMMMMM 88 88 +# dP dP +# ------------------------------------------------------------------------------------------------------------------ # +# ===================================== Upper PySimpleGUI ======================================================== # +# ------------------------------------------------------------------------------------------------------------------ # +# ----------------------------------- The mighty Popup! ------------------------------------------------------------ # + +def popup(*args, title=None, button_color=None, background_color=None, text_color=None, button_type=POPUP_BUTTONS_OK, auto_close=False, + auto_close_duration=None, custom_text=(None, None), non_blocking=False, icon=None, line_width=None, font=None, no_titlebar=False, grab_anywhere=False, + keep_on_top=None, location=(None, None), relative_location=(None, None), any_key_closes=False, image=None, modal=True, button_justification=None, drop_whitespace=True): + """ + Popup - Display a popup Window with as many parms as you wish to include. This is the GUI equivalent of the + "print" statement. It's also great for "pausing" your program's flow until the user can read some error messages. + + If this popup doesn't have the features you want, then you can easily make your own. Popups can be accomplished in 1 line of code: + choice, _ = sg.Window('Continue?', [[sg.T('Do you want to continue?')], [sg.Yes(s=10), sg.No(s=10)]], disable_close=True).read(close=True) + + + :param *args: Variable number of your arguments. Load up the call with stuff to see! + :type *args: (Any) + :param title: Optional title for the window. If none provided, the first arg will be used instead. + :type title: (str) + :param button_color: Color of the buttons shown (text color, button color) + :type button_color: (str, str) | str + :param background_color: Window's background color + :type background_color: (str) + :param text_color: text color + :type text_color: (str) + :param button_type: NOT USER SET! Determines which pre-defined buttons will be shown (Default value = POPUP_BUTTONS_OK). There are many Popup functions and they call Popup, changing this parameter to get the desired effect. + :type button_type: (int) + :param auto_close: If True the window will automatically close + :type auto_close: (bool) + :param auto_close_duration: time in seconds to keep window open before closing it automatically + :type auto_close_duration: (int) + :param custom_text: A string or pair of strings that contain the text to display on the buttons + :type custom_text: (str, str) | str + :param non_blocking: If True then will immediately return from the function without waiting for the user's input. + :type non_blocking: (bool) + :param icon: icon to display on the window. Same format as a Window call + :type icon: str | bytes + :param line_width: Width of lines in characters. Defaults to MESSAGE_BOX_LINE_WIDTH + :type line_width: (int) + :param font: specifies the font family, size, etc. Tuple or Single string format 'name size styles'. Styles: italic * roman bold normal underline overstrike + :type font: str | Tuple[font_name, size, modifiers] + :param no_titlebar: If True will not show the frame around the window and the titlebar across the top + :type no_titlebar: (bool) + :param grab_anywhere: If True can grab anywhere to move the window. If no_titlebar is True, grab_anywhere should likely be enabled too + :type grab_anywhere: (bool) + :param location: Location on screen to display the top left corner of window. Defaults to window centered on screen + :type location: (int, int) + :param relative_location: (x,y) location relative to the default location of the window, in pixels. Normally the window centers. This location is relative to the location the window would be created. Note they can be negative. + :type relative_location: (int, int) + :param keep_on_top: If True the window will remain above all current windows + :type keep_on_top: (bool) + :param any_key_closes: If True then will turn on return_keyboard_events for the window which will cause window to close as soon as any key is pressed. Normally the return key only will close the window. Default is false. + :type any_key_closes: (bool) + :param image: Image to include at the top of the popup window + :type image: (str) or (bytes) + :param modal: If True then makes the popup will behave like a Modal window... all other windows are non-operational until this one is closed. Default = True + :type modal: bool + :param right_justify_buttons: If True then the buttons will be "pushed" to the right side of the Window + :type right_justify_buttons: bool + :param button_justification: Speficies if buttons should be left, right or centered. Default is left justified + :type button_justification: str + :param drop_whitespace: Controls is whitespace should be removed when wrapping text. Parameter is passed to textwrap.fill. Default is to drop whitespace (so popup remains backward compatible) + :type drop_whitespace: bool + :return: Returns text of the button that was pressed. None will be returned if user closed window with X + :rtype: str | None + """ + + + + if not args: + args_to_print = [''] + else: + args_to_print = args + if line_width != None: + local_line_width = line_width + else: + local_line_width = MESSAGE_BOX_LINE_WIDTH + _title = title if title is not None else args_to_print[0] + + layout = [[]] + max_line_total, total_lines = 0, 0 + if image is not None: + if isinstance(image, str): + layout += [[Image(filename=image)]] + else: + layout += [[Image(data=image)]] + + for message in args_to_print: + # fancy code to check if string and convert if not is not need. Just always convert to string :-) + # if not isinstance(message, str): message = str(message) + message = str(message) + if message.count('\n'): # if there are line breaks, then wrap each segment separately + # message_wrapped = message # used to just do this, but now breaking into smaller pieces + message_wrapped = '' + msg_list = message.split('\n') # break into segments that will each be wrapped + message_wrapped = '\n'.join([textwrap.fill(msg, local_line_width) for msg in msg_list]) + else: + message_wrapped = textwrap.fill(message, local_line_width, drop_whitespace=drop_whitespace) + message_wrapped_lines = message_wrapped.count('\n') + 1 + longest_line_len = max([len(l) for l in message.split('\n')]) + width_used = min(longest_line_len, local_line_width) + max_line_total = max(max_line_total, width_used) + # height = _GetNumLinesNeeded(message, width_used) + height = message_wrapped_lines + layout += [[ + Text(message_wrapped, auto_size_text=True, text_color=text_color, background_color=background_color)]] + total_lines += height + + if non_blocking: + PopupButton = DummyButton # important to use or else button will close other windows too! + else: + PopupButton = Button + # show either an OK or Yes/No depending on paramater + if custom_text != (None, None): + if type(custom_text) is not tuple: + layout += [[PopupButton(custom_text, size=(len(custom_text), 1), button_color=button_color, focus=True, + bind_return_key=True)]] + elif custom_text[1] is None: + layout += [[ + PopupButton(custom_text[0], size=(len(custom_text[0]), 1), button_color=button_color, focus=True, + bind_return_key=True)]] + else: + layout += [[PopupButton(custom_text[0], button_color=button_color, focus=True, bind_return_key=True, + size=(len(custom_text[0]), 1)), + PopupButton(custom_text[1], button_color=button_color, size=(len(custom_text[1]), 1))]] + elif button_type == POPUP_BUTTONS_YES_NO: + layout += [[PopupButton('Yes', button_color=button_color, focus=True, bind_return_key=True, + size=(5, 1)), PopupButton('No', button_color=button_color, size=(5, 1))]] + elif button_type == POPUP_BUTTONS_CANCELLED: + layout += [[ + PopupButton('Cancelled', button_color=button_color, focus=True, bind_return_key=True )]] + elif button_type == POPUP_BUTTONS_ERROR: + layout += [[PopupButton('Error', size=(6, 1), button_color=button_color, focus=True, bind_return_key=True)]] + elif button_type == POPUP_BUTTONS_OK_CANCEL: + layout += [[PopupButton('OK', size=(6, 1), button_color=button_color, focus=True, bind_return_key=True), + PopupButton('Cancel', size=(6, 1), button_color=button_color)]] + elif button_type == POPUP_BUTTONS_NO_BUTTONS: + pass + else: + layout += [[PopupButton('OK', size=(5, 1), button_color=button_color, focus=True, bind_return_key=True,)]] + if button_justification is not None: + justification = button_justification.lower()[0] + if justification == 'r': + layout[-1] = [Push()] + layout[-1] + elif justification == 'c': + layout[-1] = [Push()] + layout[-1] + [Push()] + + + window = Window(_title, layout, auto_size_text=True, background_color=background_color, button_color=button_color, + auto_close=auto_close, auto_close_duration=auto_close_duration, icon=icon, font=font, + no_titlebar=no_titlebar, grab_anywhere=grab_anywhere, keep_on_top=keep_on_top, location=location, relative_location=relative_location, return_keyboard_events=any_key_closes, + modal=modal) + + + if non_blocking: + button, values = window.read(timeout=0) + else: + button, values = window.read() + window.close() + del window + + return button + + +# ============================== MsgBox============# +# Lazy function. Same as calling Popup with parms # +# This function WILL Disappear perhaps today # +# ==================================================# +# MsgBox is the legacy call and should not be used any longer +def MsgBox(*args): + """ + Do not call this anymore it will raise exception. Use Popups instead + :param *args: + :type *args: + + """ + raise DeprecationWarning('MsgBox is no longer supported... change your call to Popup') + + +# ======================== Scrolled Text Box =====# +# ===================================================# +def popup_scrolled(*args, title=None, button_color=None, background_color=None, text_color=None, yes_no=False, no_buttons=False, button_justification='l', auto_close=False, auto_close_duration=None, size=(None, None), location=(None, None), relative_location=(None, None), non_blocking=False, no_titlebar=False, grab_anywhere=False, keep_on_top=None, font=None, image=None, icon=None, modal=True, no_sizegrip=False): + """ + Show a scrolled Popup window containing the user's text that was supplied. Use with as many items to print as you + want, just like a print statement. + + :param *args: Variable number of items to display + :type *args: (Any) + :param title: Title to display in the window. + :type title: (str) + :param button_color: button color (foreground, background) + :type button_color: (str, str) | str + :param yes_no: If True, displays Yes and No buttons instead of Ok + :type yes_no: (bool) + :param no_buttons: If True, no buttons will be shown. User will have to close using the "X" + :type no_buttons: (bool) + :param button_justification: How buttons should be arranged. l, c, r for Left, Center or Right justified + :type button_justification: (str) + :param auto_close: if True window will close itself + :type auto_close: (bool) + :param auto_close_duration: Older versions only accept int. Time in seconds until window will close + :type auto_close_duration: int | float + :param size: (w,h) w=characters-wide, h=rows-high + :type size: (int, int) + :param location: Location on the screen to place the upper left corner of the window + :type location: (int, int) + :param relative_location: (x,y) location relative to the default location of the window, in pixels. Normally the window centers. This location is relative to the location the window would be created. Note they can be negative. + :type relative_location: (int, int) + :param non_blocking: if True the call will immediately return rather than waiting on user input + :type non_blocking: (bool) + :param background_color: color of background + :type background_color: (str) + :param text_color: color of the text + :type text_color: (str) + :param no_titlebar: If True no titlebar will be shown + :type no_titlebar: (bool) + :param grab_anywhere: If True, than can grab anywhere to move the window (Default = False) + :type grab_anywhere: (bool) + :param keep_on_top: If True the window will remain above all current windows + :type keep_on_top: (bool) + :param font: specifies the font family, size, etc. Tuple or Single string format 'name size styles'. Styles: italic * roman bold normal underline overstrike + :type font: (str or (str, int[, str]) or None) + :param image: Image to include at the top of the popup window + :type image: (str) or (bytes) + :param icon: filename or base64 string to be used for the window's icon + :type icon: bytes | str + :param modal: If True then makes the popup will behave like a Modal window... all other windows are non-operational until this one is closed. Default = True + :type modal: bool + :param no_sizegrip: If True no Sizegrip will be shown when there is no titlebar. It's only shown if there is no titlebar + :type no_sizegrip: (bool) + :return: Returns text of the button that was pressed. None will be returned if user closed window with X + :rtype: str | None | TIMEOUT_KEY + """ + + if not args: return + width, height = size + width = width if width else MESSAGE_BOX_LINE_WIDTH + + layout = [[]] + + if image is not None: + if isinstance(image, str): + layout += [[Image(filename=image)]] + else: + layout += [[Image(data=image)]] + max_line_total, max_line_width, total_lines, height_computed = 0, 0, 0, 0 + complete_output = '' + for message in args: + # fancy code to check if string and convert if not is not need. Just always convert to string :-) + # if not isinstance(message, str): message = str(message) + message = str(message) + longest_line_len = max([len(l) for l in message.split('\n')]) + width_used = min(longest_line_len, width) + max_line_total = max(max_line_total, width_used) + max_line_width = width + lines_needed = _GetNumLinesNeeded(message, width_used) + height_computed += lines_needed + 1 + complete_output += message + '\n' + total_lines += lines_needed + height_computed = MAX_SCROLLED_TEXT_BOX_HEIGHT if height_computed > MAX_SCROLLED_TEXT_BOX_HEIGHT else height_computed + if height: + height_computed = height + layout += [[Multiline(complete_output, size=(max_line_width, height_computed), background_color=background_color, text_color=text_color, expand_x=True, + expand_y=True, k='-MLINE-')]] + # show either an OK or Yes/No depending on paramater + button = DummyButton if non_blocking else Button + + if yes_no: + buttons = [button('Yes'), button('No')] + elif no_buttons is not True: + buttons = [button('OK', size=(5, 1), button_color=button_color)] + else: + buttons = None + + if buttons is not None: + if button_justification.startswith('l'): + layout += [buttons] + elif button_justification.startswith('c'): + layout += [[Push()] + buttons + [Push()]] + else: + layout += [[Push()] + buttons] + + if no_sizegrip is not True: + layout[-1] += [Sizegrip()] + + window = Window(title or args[0], layout, auto_size_text=True, button_color=button_color, auto_close=auto_close, + auto_close_duration=auto_close_duration, location=location, relative_location=relative_location, resizable=True, font=font, background_color=background_color, + no_titlebar=no_titlebar, grab_anywhere=grab_anywhere, keep_on_top=keep_on_top, modal=modal, icon=icon) + if non_blocking: + button, values = window.read(timeout=0) + else: + button, values = window.read() + window.close() + del window + return button + + +# ============================== sprint ======# +# Is identical to the Scrolled Text Box # +# Provides a crude 'print' mechanism but in a # +# GUI environment # +# This is in addition to the Print function # +# which routes output to a "Debug Window" # +# ============================================# + + +# --------------------------- popup_no_buttons --------------------------- +def popup_no_buttons(*args, title=None, background_color=None, text_color=None, auto_close=False, + auto_close_duration=None, non_blocking=False, icon=None, line_width=None, font=None, + no_titlebar=False, grab_anywhere=False, keep_on_top=None, location=(None, None), relative_location=(None, None), image=None, modal=True): + """Show a Popup but without any buttons + + :param *args: Variable number of items to display + :type *args: (Any) + :param title: Title to display in the window. + :type title: (str) + :param background_color: color of background + :type background_color: (str) + :param text_color: color of the text + :type text_color: (str) + :param auto_close: if True window will close itself + :type auto_close: (bool) + :param auto_close_duration: Older versions only accept int. Time in seconds until window will close + :type auto_close_duration: int | float + :param non_blocking: If True then will immediately return from the function without waiting for the user's input. (Default = False) + :type non_blocking: (bool) + :param icon: filename or base64 string to be used for the window's icon + :type icon: bytes | str + :param line_width: Width of lines in characters + :type line_width: (int) + :param font: specifies the font family, size, etc. Tuple or Single string format 'name size styles'. Styles: italic * roman bold normal underline overstrike + :type font: (str or (str, int[, str]) or None) + :param no_titlebar: If True no titlebar will be shown + :type no_titlebar: (bool) + :param grab_anywhere: If True, than can grab anywhere to move the window (Default = False) + :type grab_anywhere: (bool) + :param location: Location of upper left corner of the window + :type location: (int, int) + :param relative_location: (x,y) location relative to the default location of the window, in pixels. Normally the window centers. This location is relative to the location the window would be created. Note they can be negative. + :type relative_location: (int, int) + :param image: Image to include at the top of the popup window + :type image: (str) or (bytes) + :param modal: If True then makes the popup will behave like a Modal window... all other windows are non-operational until this one is closed. Default = True + :type modal: bool + :return: Returns text of the button that was pressed. None will be returned if user closed window with X + :rtype: str | None | TIMEOUT_KEY """ + Popup(*args, title=title, background_color=background_color, text_color=text_color, + button_type=POPUP_BUTTONS_NO_BUTTONS, + auto_close=auto_close, auto_close_duration=auto_close_duration, non_blocking=non_blocking, icon=icon, + line_width=line_width, + font=font, no_titlebar=no_titlebar, grab_anywhere=grab_anywhere, keep_on_top=keep_on_top, location=location, relative_location=relative_location, image=image, modal=modal) + + +# --------------------------- popup_non_blocking --------------------------- +def popup_non_blocking(*args, title=None, button_type=POPUP_BUTTONS_OK, button_color=None, background_color=None, + text_color=None, auto_close=False, auto_close_duration=None, non_blocking=True, icon=None, + line_width=None, font=None, no_titlebar=False, grab_anywhere=False, keep_on_top=None, + location=(None, None), relative_location=(None, None), image=None, modal=False): + """ + Show Popup window and immediately return (does not block) + + :param *args: Variable number of items to display + :type *args: (Any) + :param title: Title to display in the window. + :type title: (str) + :param button_type: Determines which pre-defined buttons will be shown (Default value = POPUP_BUTTONS_OK). + :type button_type: (int) + :param button_color: button color (foreground, background) + :type button_color: (str, str) | str + :param background_color: color of background + :type background_color: (str) + :param text_color: color of the text + :type text_color: (str) + :param auto_close: if True window will close itself + :type auto_close: (bool) + :param auto_close_duration: Older versions only accept int. Time in seconds until window will close + :type auto_close_duration: int | float + :param non_blocking: if True the call will immediately return rather than waiting on user input + :type non_blocking: (bool) + :param icon: filename or base64 string to be used for the window's icon + :type icon: bytes | str + :param line_width: Width of lines in characters + :type line_width: (int) + :param font: specifies the font family, size, etc. Tuple or Single string format 'name size styles'. Styles: italic * roman bold normal underline overstrike + :type font: (str or (str, int[, str]) or None) + :param no_titlebar: If True no titlebar will be shown + :type no_titlebar: (bool) + :param grab_anywhere: If True: can grab anywhere to move the window (Default = False) + :type grab_anywhere: (bool) + :param location: Location of upper left corner of the window + :type location: (int, int) + :param relative_location: (x,y) location relative to the default location of the window, in pixels. Normally the window centers. This location is relative to the location the window would be created. Note they can be negative. + :type relative_location: (int, int) + :param image: Image to include at the top of the popup window + :type image: (str) or (bytes) + :param modal: If True then makes the popup will behave like a Modal window... all other windows are non-operational until this one is closed. Default = False + :type modal: bool + :return: Reason for popup closing + :rtype: str | None + """ + + return popup(*args, title=title, button_color=button_color, background_color=background_color, text_color=text_color, + button_type=button_type, + auto_close=auto_close, auto_close_duration=auto_close_duration, non_blocking=non_blocking, icon=icon, + line_width=line_width, + font=font, no_titlebar=no_titlebar, grab_anywhere=grab_anywhere, keep_on_top=keep_on_top, location=location, relative_location=relative_location, image=image, modal=modal) + + +# --------------------------- popup_quick - a NonBlocking, Self-closing Popup --------------------------- +def popup_quick(*args, title=None, button_type=POPUP_BUTTONS_OK, button_color=None, background_color=None, + text_color=None, auto_close=True, auto_close_duration=2, non_blocking=True, icon=None, line_width=None, + font=None, no_titlebar=False, grab_anywhere=False, keep_on_top=None, location=(None, None), relative_location=(None, None), image=None, modal=False): + """ + Show Popup box that doesn't block and closes itself + + :param *args: Variable number of items to display + :type *args: (Any) + :param title: Title to display in the window. + :type title: (str) + :param button_type: Determines which pre-defined buttons will be shown (Default value = POPUP_BUTTONS_OK). + :type button_type: (int) + :param button_color: button color (foreground, background) + :type button_color: (str, str) | str + :param background_color: color of background + :type background_color: (str) + :param text_color: color of the text + :type text_color: (str) + :param auto_close: if True window will close itself + :type auto_close: (bool) + :param auto_close_duration: Older versions only accept int. Time in seconds until window will close + :type auto_close_duration: int | float + :param non_blocking: if True the call will immediately return rather than waiting on user input + :type non_blocking: (bool) + :param icon: filename or base64 string to be used for the window's icon + :type icon: bytes | str + :param line_width: Width of lines in characters + :type line_width: (int) + :param font: specifies the font family, size, etc. Tuple or Single string format 'name size styles'. Styles: italic * roman bold normal underline overstrike + :type font: (str or (str, int[, str]) or None) + :param no_titlebar: If True no titlebar will be shown + :type no_titlebar: (bool) + :param grab_anywhere: If True: can grab anywhere to move the window (Default = False) + :type grab_anywhere: (bool) + :param keep_on_top: If True the window will remain above all current windows + :type keep_on_top: (bool) + :param location: Location of upper left corner of the window + :type location: (int, int) + :param relative_location: (x,y) location relative to the default location of the window, in pixels. Normally the window centers. This location is relative to the location the window would be created. Note they can be negative. + :type relative_location: (int, int) + :param image: Image to include at the top of the popup window + :type image: (str) or (bytes) + :param modal: If True then makes the popup will behave like a Modal window... all other windows are non-operational until this one is closed. Default = False + :type modal: bool + :return: Returns text of the button that was pressed. None will be returned if user closed window with X + :rtype: str | None | TIMEOUT_KEY + """ + + return popup(*args, title=title, button_color=button_color, background_color=background_color, text_color=text_color, + button_type=button_type, + auto_close=auto_close, auto_close_duration=auto_close_duration, non_blocking=non_blocking, icon=icon, + line_width=line_width, + font=font, no_titlebar=no_titlebar, grab_anywhere=grab_anywhere, keep_on_top=keep_on_top, location=location, relative_location=relative_location, image=image, modal=modal) + + +# --------------------------- popup_quick_message - a NonBlocking, Self-closing Popup with no titlebar and no buttons --------------------------- +def popup_quick_message(*args, title=None, button_type=POPUP_BUTTONS_NO_BUTTONS, button_color=None, background_color=None, + text_color=None, auto_close=True, auto_close_duration=2, non_blocking=True, icon=None, line_width=None, + font=None, no_titlebar=True, grab_anywhere=False, keep_on_top=True, location=(None, None), relative_location=(None, None), image=None, modal=False): + """ + Show Popup window with no titlebar, doesn't block, and auto closes itself. + + :param *args: Variable number of items to display + :type *args: (Any) + :param title: Title to display in the window. + :type title: (str) + :param button_type: Determines which pre-defined buttons will be shown (Default value = POPUP_BUTTONS_OK). + :type button_type: (int) + :param button_color: button color (foreground, background) + :type button_color: (str, str) | str + :param keep_on_top: If True the window will remain above all current windows + :type keep_on_top: (bool) + :param background_color: color of background + :type background_color: (str) + :param text_color: color of the text + :type text_color: (str) + :param auto_close: if True window will close itself + :type auto_close: (bool) + :param auto_close_duration: Older versions only accept int. Time in seconds until window will close + :type auto_close_duration: int | float + :param non_blocking: if True the call will immediately return rather than waiting on user input + :type non_blocking: (bool) + :param icon: filename or base64 string to be used for the window's icon + :type icon: bytes | str + :param line_width: Width of lines in characters + :type line_width: (int) + :param font: specifies the font family, size, etc. Tuple or Single string format 'name size styles'. Styles: italic * roman bold normal underline overstrike + :type font: (str or (str, int[, str]) or None) + :param no_titlebar: If True no titlebar will be shown + :type no_titlebar: (bool) + :param grab_anywhere: If True: can grab anywhere to move the window (Default = False) + :type grab_anywhere: (bool) + :param location: Location of upper left corner of the window + :type location: (int, int) + :param relative_location: (x,y) location relative to the default location of the window, in pixels. Normally the window centers. This location is relative to the location the window would be created. Note they can be negative. + :type relative_location: (int, int) + :param image: Image to include at the top of the popup window + :type image: (str) or (bytes) + :param modal: If True then makes the popup will behave like a Modal window... all other windows are non-operational until this one is closed. Default = False + :type modal: bool + :return: Returns text of the button that was pressed. None will be returned if user closed window with X + :rtype: str | None | TIMEOUT_KEY + """ + return popup(*args, title=title, button_color=button_color, background_color=background_color, text_color=text_color, + button_type=button_type, + auto_close=auto_close, auto_close_duration=auto_close_duration, non_blocking=non_blocking, icon=icon, + line_width=line_width, + font=font, no_titlebar=no_titlebar, grab_anywhere=grab_anywhere, keep_on_top=keep_on_top, location=location, relative_location=relative_location, image=image, modal=modal) + + +# --------------------------- PopupNoTitlebar --------------------------- +def popup_no_titlebar(*args, title=None, button_type=POPUP_BUTTONS_OK, button_color=None, background_color=None, + text_color=None, auto_close=False, auto_close_duration=None, non_blocking=False, icon=None, + line_width=None, font=None, grab_anywhere=True, keep_on_top=None, location=(None, None), relative_location=(None, None), image=None, modal=True): + """ + Display a Popup without a titlebar. Enables grab anywhere so you can move it + + :param *args: Variable number of items to display + :type *args: (Any) + :param title: Title to display in the window. + :type title: (str) + :param button_type: Determines which pre-defined buttons will be shown (Default value = POPUP_BUTTONS_OK). + :type button_type: (int) + :param button_color: button color (foreground, background) + :type button_color: (str, str) | str + :param background_color: color of background + :type background_color: (str) + :param text_color: color of the text + :type text_color: (str) + :param auto_close: if True window will close itself + :type auto_close: (bool) + :param auto_close_duration: Older versions only accept int. Time in seconds until window will close + :type auto_close_duration: int | float + :param non_blocking: if True the call will immediately return rather than waiting on user input + :type non_blocking: (bool) + :param icon: filename or base64 string to be used for the window's icon + :type icon: bytes | str + :param line_width: Width of lines in characters + :type line_width: (int) + :param font: specifies the font family, size, etc. Tuple or Single string format 'name size styles'. Styles: italic * roman bold normal underline overstrike + :type font: (str or (str, int[, str]) or None) + :param grab_anywhere: If True: can grab anywhere to move the window (Default = False) + :type grab_anywhere: (bool) + :param keep_on_top: If True the window will remain above all current windows + :type keep_on_top: (bool) + :param location: Location of upper left corner of the window + :type location: (int, int) + :param relative_location: (x,y) location relative to the default location of the window, in pixels. Normally the window centers. This location is relative to the location the window would be created. Note they can be negative. + :type relative_location: (int, int) + :param image: Image to include at the top of the popup window + :type image: (str) or (bytes) + :param modal: If True then makes the popup will behave like a Modal window... all other windows are non-operational until this one is closed. Default = True + :type modal: bool + :return: Returns text of the button that was pressed. None will be returned if user closed window with X + :rtype: str | None | TIMEOUT_KEY + """ + return popup(*args, title=title, button_color=button_color, background_color=background_color, text_color=text_color, + button_type=button_type, + auto_close=auto_close, auto_close_duration=auto_close_duration, non_blocking=non_blocking, icon=icon, + line_width=line_width, + font=font, no_titlebar=True, grab_anywhere=grab_anywhere, keep_on_top=keep_on_top, location=location, relative_location=relative_location, image=image, modal=modal) + + +# --------------------------- PopupAutoClose --------------------------- +def popup_auto_close(*args, title=None, button_type=POPUP_BUTTONS_OK, button_color=None, background_color=None, text_color=None, + auto_close=True, auto_close_duration=None, non_blocking=False, icon=None, + line_width=None, font=None, no_titlebar=False, grab_anywhere=False, keep_on_top=None, + location=(None, None), relative_location=(None, None), image=None, modal=True): + """Popup that closes itself after some time period + + :param *args: Variable number of items to display + :type *args: (Any) + :param title: Title to display in the window. + :type title: (str) + :param button_type: Determines which pre-defined buttons will be shown (Default value = POPUP_BUTTONS_OK). + :type button_type: (int) + :param button_color: button color (foreground, background) + :type button_color: (str, str) | str + :param background_color: color of background + :type background_color: (str) + :param text_color: color of the text + :type text_color: (str) + :param auto_close: if True window will close itself + :type auto_close: (bool) + :param auto_close_duration: Older versions only accept int. Time in seconds until window will close + :type auto_close_duration: int | float + :param non_blocking: if True the call will immediately return rather than waiting on user input + :type non_blocking: (bool) + :param icon: filename or base64 string to be used for the window's icon + :type icon: bytes | str + :param line_width: Width of lines in characters + :type line_width: (int) + :param font: specifies the font family, size, etc. Tuple or Single string format 'name size styles'. Styles: italic * roman bold normal underline overstrike + :type font: (str or (str, int[, str]) or None) + :param no_titlebar: If True no titlebar will be shown + :type no_titlebar: (bool) + :param grab_anywhere: If True: can grab anywhere to move the window (Default = False) + :type grab_anywhere: (bool) + :param keep_on_top: If True the window will remain above all current windows + :type keep_on_top: (bool) + :param location: Location of upper left corner of the window + :type location: (int, int) + :param relative_location: (x,y) location relative to the default location of the window, in pixels. Normally the window centers. This location is relative to the location the window would be created. Note they can be negative. + :type relative_location: (int, int) + :param image: Image to include at the top of the popup window + :type image: (str) or (bytes) + :param modal: If True then makes the popup will behave like a Modal window... all other windows are non-operational until this one is closed. Default = True + :type modal: bool + :return: Returns text of the button that was pressed. None will be returned if user closed window with X + :rtype: str | None | TIMEOUT_KEY + """ + + return popup(*args, title=title, button_color=button_color, background_color=background_color, text_color=text_color, + button_type=button_type, + auto_close=auto_close, auto_close_duration=auto_close_duration, non_blocking=non_blocking, icon=icon, + line_width=line_width, + font=font, no_titlebar=no_titlebar, grab_anywhere=grab_anywhere, keep_on_top=keep_on_top, location=location, relative_location=relative_location, image=image, modal=modal) + + +# --------------------------- popup_error --------------------------- +def popup_error(*args, title=None, button_color=(None, None), background_color=None, text_color=None, auto_close=False, + auto_close_duration=None, non_blocking=False, icon=None, line_width=None, font=None, + no_titlebar=False, grab_anywhere=False, keep_on_top=None, location=(None, None), relative_location=(None, None), image=None, modal=True): + """ + Popup with colored button and 'Error' as button text + + :param *args: Variable number of items to display + :type *args: (Any) + :param title: Title to display in the window. + :type title: (str) + :param button_color: button color (foreground, background) + :type button_color: (str, str) | str + :param background_color: color of background + :type background_color: (str) + :param text_color: color of the text + :type text_color: (str) + :param auto_close: if True window will close itself + :type auto_close: (bool) + :param auto_close_duration: Older versions only accept int. Time in seconds until window will close + :type auto_close_duration: int | float + :param non_blocking: if True the call will immediately return rather than waiting on user input + :type non_blocking: (bool) + :param icon: filename or base64 string to be used for the window's icon + :type icon: bytes | str + :param line_width: Width of lines in characters + :type line_width: (int) + :param font: specifies the font family, size, etc. Tuple or Single string format 'name size styles'. Styles: italic * roman bold normal underline overstrike + :type font: (str or (str, int[, str]) or None) + :param no_titlebar: If True no titlebar will be shown + :type no_titlebar: (bool) + :param grab_anywhere: If True: can grab anywhere to move the window (Default = False) + :type grab_anywhere: (bool) + :param keep_on_top: If True the window will remain above all current windows + :type keep_on_top: (bool) + :param location: Location of upper left corner of the window + :type location: (int, int) + :param relative_location: (x,y) location relative to the default location of the window, in pixels. Normally the window centers. This location is relative to the location the window would be created. Note they can be negative. + :type relative_location: (int, int) + :param image: Image to include at the top of the popup window + :type image: (str) or (bytes) + :param modal: If True then makes the popup will behave like a Modal window... all other windows are non-operational until this one is closed. Default = True + :type modal: bool + :return: Returns text of the button that was pressed. None will be returned if user closed window with X + :rtype: str | None | TIMEOUT_KEY + """ + tbutton_color = DEFAULT_ERROR_BUTTON_COLOR if button_color == (None, None) else button_color + return popup(*args, title=title, button_type=POPUP_BUTTONS_ERROR, background_color=background_color, text_color=text_color, + non_blocking=non_blocking, icon=icon, line_width=line_width, button_color=tbutton_color, + auto_close=auto_close, + auto_close_duration=auto_close_duration, font=font, no_titlebar=no_titlebar, grab_anywhere=grab_anywhere, + keep_on_top=keep_on_top, location=location, relative_location=relative_location, image=image, modal=modal) + + +# --------------------------- popup_cancel --------------------------- +def popup_cancel(*args, title=None, button_color=None, background_color=None, text_color=None, auto_close=False, + auto_close_duration=None, non_blocking=False, icon=None, line_width=None, font=None, + no_titlebar=False, grab_anywhere=False, keep_on_top=None, location=(None, None), relative_location=(None, None), image=None, modal=True): + """ + Display Popup with "cancelled" button text + + :param *args: Variable number of items to display + :type *args: (Any) + :param title: Title to display in the window. + :type title: (str) + :param button_color: button color (foreground, background) + :type button_color: (str, str) | str + :param background_color: color of background + :type background_color: (str) + :param text_color: color of the text + :type text_color: (str) + :param auto_close: if True window will close itself + :type auto_close: (bool) + :param auto_close_duration: Older versions only accept int. Time in seconds until window will close + :type auto_close_duration: int | float + :param non_blocking: if True the call will immediately return rather than waiting on user input + :type non_blocking: (bool) + :param icon: filename or base64 string to be used for the window's icon + :type icon: bytes | str + :param line_width: Width of lines in characters + :type line_width: (int) + :param font: specifies the font family, size, etc. Tuple or Single string format 'name size styles'. Styles: italic * roman bold normal underline overstrike + :type font: (str or (str, int[, str]) or None) + :param no_titlebar: If True no titlebar will be shown + :type no_titlebar: (bool) + :param grab_anywhere: If True: can grab anywhere to move the window (Default = False) + :type grab_anywhere: (bool) + :param keep_on_top: If True the window will remain above all current windows + :type keep_on_top: (bool) + :param location: Location of upper left corner of the window + :type location: (int, int) + :param relative_location: (x,y) location relative to the default location of the window, in pixels. Normally the window centers. This location is relative to the location the window would be created. Note they can be negative. + :type relative_location: (int, int) + :param image: Image to include at the top of the popup window + :type image: (str) or (bytes) + :param modal: If True then makes the popup will behave like a Modal window... all other windows are non-operational until this one is closed. Default = True + :type modal: bool + :return: Returns text of the button that was pressed. None will be returned if user closed window with X + :rtype: str | None | TIMEOUT_KEY + """ + return popup(*args, title=title, button_type=POPUP_BUTTONS_CANCELLED, background_color=background_color, + text_color=text_color, + non_blocking=non_blocking, icon=icon, line_width=line_width, button_color=button_color, auto_close=auto_close, + auto_close_duration=auto_close_duration, font=font, no_titlebar=no_titlebar, grab_anywhere=grab_anywhere, + keep_on_top=keep_on_top, location=location, relative_location=relative_location, image=image, modal=modal) + + +# --------------------------- popup_ok --------------------------- +def popup_ok(*args, title=None, button_color=None, background_color=None, text_color=None, auto_close=False, + auto_close_duration=None, non_blocking=False, icon=None, line_width=None, font=None, + no_titlebar=False, grab_anywhere=False, keep_on_top=None, location=(None, None), relative_location=(None, None), image=None, modal=True): + """ + Display Popup with OK button only + + :param *args: Variable number of items to display + :type *args: (Any) + :param title: Title to display in the window. + :type title: (str) + :param button_color: button color (foreground, background) + :type button_color: (str, str) | str + :param background_color: color of background + :type background_color: (str) + :param text_color: color of the text + :type text_color: (str) + :param auto_close: if True window will close itself + :type auto_close: (bool) + :param auto_close_duration: Older versions only accept int. Time in seconds until window will close + :type auto_close_duration: int | float + :param non_blocking: if True the call will immediately return rather than waiting on user input + :type non_blocking: (bool) + :param icon: filename or base64 string to be used for the window's icon + :type icon: bytes | str + :param line_width: Width of lines in characters + :type line_width: (int) + :param font: specifies the font family, size, etc. Tuple or Single string format 'name size styles'. Styles: italic * roman bold normal underline overstrike + :type font: (str or (str, int[, str]) or None) + :param no_titlebar: If True no titlebar will be shown + :type no_titlebar: (bool) + :param grab_anywhere: If True: can grab anywhere to move the window (Default = False) + :type grab_anywhere: (bool) + :param keep_on_top: If True the window will remain above all current windows + :type keep_on_top: (bool) + :param location: Location of upper left corner of the window + :type location: (int, int) + :param relative_location: (x,y) location relative to the default location of the window, in pixels. Normally the window centers. This location is relative to the location the window would be created. Note they can be negative. + :type relative_location: (int, int) + :param image: Image to include at the top of the popup window + :type image: (str) or (bytes) + :param modal: If True then makes the popup will behave like a Modal window... all other windows are non-operational until this one is closed. Default = True + :type modal: bool + :return: Returns text of the button that was pressed. None will be returned if user closed window with X + :rtype: str | None | TIMEOUT_KEY + """ + return popup(*args, title=title, button_type=POPUP_BUTTONS_OK, background_color=background_color, text_color=text_color, + non_blocking=non_blocking, icon=icon, line_width=line_width, button_color=button_color, auto_close=auto_close, + auto_close_duration=auto_close_duration, font=font, no_titlebar=no_titlebar, grab_anywhere=grab_anywhere, + keep_on_top=keep_on_top, location=location, relative_location=relative_location, image=image, modal=modal) + + +# --------------------------- popup_ok_cancel --------------------------- +def popup_ok_cancel(*args, title=None, button_color=None, background_color=None, text_color=None, auto_close=False, + auto_close_duration=None, non_blocking=False, icon=DEFAULT_WINDOW_ICON, line_width=None, font=None, + no_titlebar=False, grab_anywhere=False, keep_on_top=None, location=(None, None), relative_location=(None, None), image=None, modal=True): + """ + Display popup with OK and Cancel buttons + + :param *args: Variable number of items to display + :type *args: (Any) + :param title: Title to display in the window. + :type title: (str) + :param button_color: button color (foreground, background) + :type button_color: (str, str) | str + :param background_color: color of background + :type background_color: (str) + :param text_color: color of the text + :type text_color: (str) + :param auto_close: if True window will close itself + :type auto_close: (bool) + :param auto_close_duration: Older versions only accept int. Time in seconds until window will close + :type auto_close_duration: int | float + :param non_blocking: if True the call will immediately return rather than waiting on user input + :type non_blocking: (bool) + :param icon: filename or base64 string to be used for the window's icon + :type icon: bytes | str + :param line_width: Width of lines in characters + :type line_width: (int) + :param font: specifies the font family, size, etc. Tuple or Single string format 'name size styles'. Styles: italic * roman bold normal underline overstrike + :type font: (str or (str, int[, str]) or None) + :param no_titlebar: If True no titlebar will be shown + :type no_titlebar: (bool) + :param grab_anywhere: If True: can grab anywhere to move the window (Default = False) + :type grab_anywhere: (bool) + :param keep_on_top: If True the window will remain above all current windows + :type keep_on_top: (bool) + :param location: Location of upper left corner of the window + :type location: (int, int) + :param relative_location: (x,y) location relative to the default location of the window, in pixels. Normally the window centers. This location is relative to the location the window would be created. Note they can be negative. + :type relative_location: (int, int) + :param image: Image to include at the top of the popup window + :type image: (str) or (bytes) + :param modal: If True then makes the popup will behave like a Modal window... all other windows are non-operational until this one is closed. Default = True + :type modal: bool + :return: clicked button + :rtype: "OK" | "Cancel" | None + """ + return popup(*args, title=title, button_type=POPUP_BUTTONS_OK_CANCEL, background_color=background_color, + text_color=text_color, + non_blocking=non_blocking, icon=icon, line_width=line_width, button_color=button_color, + auto_close=auto_close, auto_close_duration=auto_close_duration, font=font, no_titlebar=no_titlebar, + grab_anywhere=grab_anywhere, keep_on_top=keep_on_top, location=location, relative_location=relative_location, image=image, modal=modal) + + +# --------------------------- popup_yes_no --------------------------- +def popup_yes_no(*args, title=None, button_color=None, background_color=None, text_color=None, auto_close=False, + auto_close_duration=None, non_blocking=False, icon=None, line_width=None, font=None, + no_titlebar=False, grab_anywhere=False, keep_on_top=None, location=(None, None), relative_location=(None, None), image=None, modal=True): + """ + Display Popup with Yes and No buttons + + :param *args: Variable number of items to display + :type *args: (Any) + :param title: Title to display in the window. + :type title: (str) + :param button_color: button color (foreground, background) + :type button_color: (str, str) | str + :param background_color: color of background + :type background_color: (str) + :param text_color: color of the text + :type text_color: (str) + :param auto_close: if True window will close itself + :type auto_close: (bool) + :param auto_close_duration: Older versions only accept int. Time in seconds until window will close + :type auto_close_duration: int | float + :param non_blocking: if True the call will immediately return rather than waiting on user input + :type non_blocking: (bool) + :param icon: filename or base64 string to be used for the window's icon + :type icon: bytes | str + :param line_width: Width of lines in characters + :type line_width: (int) + :param font: specifies the font family, size, etc. Tuple or Single string format 'name size styles'. Styles: italic * roman bold normal underline overstrike + :type font: (str or (str, int[, str]) or None) + :param no_titlebar: If True no titlebar will be shown + :type no_titlebar: (bool) + :param grab_anywhere: If True: can grab anywhere to move the window (Default = False) + :type grab_anywhere: (bool) + :param keep_on_top: If True the window will remain above all current windows + :type keep_on_top: (bool) + :param location: Location of upper left corner of the window + :type location: (int, int) + :param relative_location: (x,y) location relative to the default location of the window, in pixels. Normally the window centers. This location is relative to the location the window would be created. Note they can be negative. + :type relative_location: (int, int) + :param image: Image to include at the top of the popup window + :type image: (str) or (bytes) + :param modal: If True then makes the popup will behave like a Modal window... all other windows are non-operational until this one is closed. Default = True + :type modal: bool + :return: clicked button + :rtype: "Yes" | "No" | None + """ + return popup(*args, title=title, button_type=POPUP_BUTTONS_YES_NO, background_color=background_color, + text_color=text_color, + non_blocking=non_blocking, icon=icon, line_width=line_width, button_color=button_color, + auto_close=auto_close, auto_close_duration=auto_close_duration, font=font, no_titlebar=no_titlebar, + grab_anywhere=grab_anywhere, keep_on_top=keep_on_top, location=location, relative_location=relative_location, image=image, modal=modal) + + +############################################################################## +# The popup_get_____ functions - Will return user input # +############################################################################## + +# --------------------------- popup_get_folder --------------------------- + + +def popup_get_folder(message, title=None, default_path='', no_window=False, size=(None, None), button_color=None, + background_color=None, text_color=None, icon=None, font=None, no_titlebar=False, + grab_anywhere=False, keep_on_top=None, location=(None, None), relative_location=(None, None), initial_folder=None, image=None, modal=True, history=False, + history_setting_filename=None): + """ + Display popup with text entry field and browse button so that a folder can be chosen. + + :param message: message displayed to user + :type message: (str) + :param title: Window title + :type title: (str) + :param default_path: path to display to user as starting point (filled into the input field) + :type default_path: (str) + :param no_window: if True, no PySimpleGUI window will be shown. Instead just the tkinter dialog is shown + :type no_window: (bool) + :param size: (width, height) of the InputText Element + :type size: (int, int) + :param button_color: button color (foreground, background) + :type button_color: (str, str) | str + :param background_color: color of background + :type background_color: (str) + :param text_color: color of the text + :type text_color: (str) + :param icon: filename or base64 string to be used for the window's icon + :type icon: bytes | str + :param font: specifies the font family, size, etc. Tuple or Single string format 'name size styles'. Styles: italic * roman bold normal underline overstrike + :type font: (str or (str, int[, str]) or None) + :param no_titlebar: If True no titlebar will be shown + :type no_titlebar: (bool) + :param grab_anywhere: If True: can grab anywhere to move the window (Default = False) + :type grab_anywhere: (bool) + :param keep_on_top: If True the window will remain above all current windows + :type keep_on_top: (bool) + :param location: Location of upper left corner of the window + :type location: (int, int) + :param relative_location: (x,y) location relative to the default location of the window, in pixels. Normally the window centers. This location is relative to the location the window would be created. Note they can be negative. + :type relative_location: (int, int) + :param initial_folder: location in filesystem to begin browsing + :type initial_folder: (str) + :param image: Image to include at the top of the popup window + :type image: (str) or (bytes) + :param modal: If True then makes the popup will behave like a Modal window... all other windows are non-operational until this one is closed. Default = True + :type modal: bool + :param history: If True then enable a "history" feature that will display previous entries used. Uses settings filename provided or default if none provided + :type history: bool + :param history_setting_filename: Filename to use for the User Settings. Will store list of previous entries in this settings file + :type history_setting_filename: (str) + :return: string representing the path chosen, None if cancelled or window closed with X + :rtype: str | None + """ + + # First setup the history settings file if history feature is enabled + if history and history_setting_filename is not None: + try: + history_settings = UserSettings(history_setting_filename) + except Exception as e: + _error_popup_with_traceback('popup_get_folder - Something is wrong with your supplied history settings filename', + 'Exception: {}'.format(e)) + return None + elif history: + history_settings_filename = os.path.basename(inspect.stack()[1].filename) + history_settings_filename = os.path.splitext(history_settings_filename)[0] + '.json' + history_settings = UserSettings(history_settings_filename) + else: + history_settings = None + + # global _my_windows + if no_window: + _get_hidden_master_root() + root = tk.Toplevel() + + try: + root.attributes('-alpha', 0) # hide window while building it. makes for smoother 'paint' + # if not running_mac(): + try: + root.wm_overrideredirect(True) + except Exception as e: + print('* Error performing wm_overrideredirect while hiding the window during creation in get folder *', e) + root.withdraw() + except: + pass + folder_name = tk.filedialog.askdirectory(initialdir=initial_folder) # show the 'get folder' dialog box + + root.destroy() + + + return folder_name + + browse_button = FolderBrowse(initial_folder=initial_folder) + + if image is not None: + if isinstance(image, str): + layout = [[Image(filename=image)]] + else: + layout = [[Image(data=image)]] + else: + layout = [[]] + + layout += [[Text(message, auto_size_text=True, text_color=text_color, background_color=background_color)]] + + if not history: + layout += [[InputText(default_text=default_path, size=size, key='-INPUT-'), browse_button]] + else: + file_list = history_settings.get('-PSG folder list-', []) + last_entry = file_list[0] if file_list else '' + layout += [[Combo(file_list, default_value=last_entry, key='-INPUT-', size=size if size != (None, None) else (80, 1), bind_return_key=True), + browse_button, Button('Clear History', tooltip='Clears the list of folders shown in the combobox')]] + + layout += [[Button('Ok', size=(6, 1), bind_return_key=True), Button('Cancel', size=(6, 1))]] + + window = Window(title=title or message, layout=layout, icon=icon, auto_size_text=True, button_color=button_color, + font=font, background_color=background_color, no_titlebar=no_titlebar, grab_anywhere=grab_anywhere, keep_on_top=keep_on_top, + location=location, relative_location=relative_location, modal=modal) + + while True: + event, values = window.read() + if event in ('Cancel', WIN_CLOSED): + break + elif event == 'Clear History': + history_settings.set('-PSG folder list-', []) + window['-INPUT-'].update('', []) + popup_quick_message('History of Previous Choices Cleared', background_color='red', text_color='white', font='_ 20', keep_on_top=True) + elif event in ('Ok', '-INPUT-'): + if values['-INPUT-'] != '': + if history_settings is not None: + list_of_entries = history_settings.get('-PSG folder list-', []) + if values['-INPUT-'] in list_of_entries: + list_of_entries.remove(values['-INPUT-']) + list_of_entries.insert(0, values['-INPUT-']) + history_settings.set('-PSG folder list-', list_of_entries) + break + + window.close() + del window + if event in ('Cancel', WIN_CLOSED): + return None + + return values['-INPUT-'] + + +# --------------------------- popup_get_file --------------------------- + +def popup_get_file(message, title=None, default_path='', default_extension='', save_as=False, multiple_files=False, + file_types=FILE_TYPES_ALL_FILES, + no_window=False, size=(None, None), button_color=None, background_color=None, text_color=None, + icon=None, font=None, no_titlebar=False, grab_anywhere=False, keep_on_top=None, + location=(None, None), relative_location=(None, None), initial_folder=None, image=None, files_delimiter=BROWSE_FILES_DELIMITER, modal=True, history=False, show_hidden=True, + history_setting_filename=None): + """ + Display popup window with text entry field and browse button so that a file can be chosen by user. + + :param message: message displayed to user + :type message: (str) + :param title: Window title + :type title: (str) + :param default_path: path to display to user as starting point (filled into the input field) + :type default_path: (str) + :param default_extension: If no extension entered by user, add this to filename (only used in saveas dialogs) + :type default_extension: (str) + :param save_as: if True, the "save as" dialog is shown which will verify before overwriting + :type save_as: (bool) + :param multiple_files: if True, then allows multiple files to be selected that are returned with ';' between each filename + :type multiple_files: (bool) + :param file_types: List of extensions to show using wildcards. All files (the default) = (("ALL Files", "*.* *"),). + :type file_types: Tuple[Tuple[str,str]] + :param no_window: if True, no PySimpleGUI window will be shown. Instead just the tkinter dialog is shown + :type no_window: (bool) + :param size: (width, height) of the InputText Element or Combo element if using history feature + :type size: (int, int) + :param button_color: Color of the button (text, background) + :type button_color: (str, str) | str + :param background_color: background color of the entire window + :type background_color: (str) + :param text_color: color of the text + :type text_color: (str) + :param icon: filename or base64 string to be used for the window's icon + :type icon: bytes | str + :param font: specifies the font family, size, etc. Tuple or Single string format 'name size styles'. Styles: italic * roman bold normal underline overstrike + :type font: (str or (str, int[, str]) or None) + :param no_titlebar: If True no titlebar will be shown + :type no_titlebar: (bool) + :param grab_anywhere: If True: can grab anywhere to move the window (Default = False) + :type grab_anywhere: (bool) + :param keep_on_top: If True the window will remain above all current windows + :type keep_on_top: (bool) + :param location: Location of upper left corner of the window + :type location: (int, int) + :param relative_location: (x,y) location relative to the default location of the window, in pixels. Normally the window centers. This location is relative to the location the window would be created. Note they can be negative. + :type relative_location: (int, int) + :param initial_folder: location in filesystem to begin browsing + :type initial_folder: (str) + :param image: Image to include at the top of the popup window + :type image: (str) or (bytes) + :param files_delimiter: String to place between files when multiple files are selected. Normally a ; + :type files_delimiter: str + :param modal: If True then makes the popup will behave like a Modal window... all other windows are non-operational until this one is closed. Default = True + :type modal: bool + :param history: If True then enable a "history" feature that will display previous entries used. Uses settings filename provided or default if none provided + :type history: bool + :param show_hidden: If True then enables the checkbox in the system dialog to select hidden files to be shown + :type show_hidden: bool + :param history_setting_filename: Filename to use for the User Settings. Will store list of previous entries in this settings file + :type history_setting_filename: (str) + :return: string representing the file(s) chosen, None if cancelled or window closed with X + :rtype: str | None + """ + + # First setup the history settings file if history feature is enabled + if history and history_setting_filename is not None: + try: + history_settings = UserSettings(history_setting_filename) + except Exception as e: + _error_popup_with_traceback('popup_get_file - Something is wrong with your supplied history settings filename', + 'Exception: {}'.format(e)) + return None + elif history: + history_settings_filename = os.path.basename(inspect.stack()[1].filename) + history_settings_filename = os.path.splitext(history_settings_filename)[0] + '.json' + history_settings = UserSettings(history_settings_filename) + else: + history_settings = None + + if icon is None: + icon = Window._user_defined_icon or DEFAULT_BASE64_ICON + if no_window: + _get_hidden_master_root() + root = tk.Toplevel() + + try: + root.attributes('-alpha', 0) # hide window while building it. makes for smoother 'paint' + # if not running_mac(): + try: + root.wm_overrideredirect(True) + except Exception as e: + print('* Error performing wm_overrideredirect in get file *', e) + root.withdraw() + except: + pass + + if show_hidden is False: + try: + # call a dummy dialog with an impossible option to initialize the file + # dialog without really getting a dialog window; this will throw a + # TclError, so we need a try...except : + try: + root.tk.call('tk_getOpenFile', '-foobarbaz') + except tk.TclError: + pass + # now set the magic variables accordingly + root.tk.call('set', '::tk::dialog::file::showHiddenBtn', '1') + root.tk.call('set', '::tk::dialog::file::showHiddenVar', '0') + except: + pass + + if root and icon is not None: + _set_icon_for_tkinter_window(root, icon=icon) + # for Macs, setting parent=None fixes a warning problem. + if save_as: + if running_mac(): + is_all = [(x, y) for (x, y) in file_types if all(ch in '* .' for ch in y)] + if not len(set(file_types)) > 1 and (len(is_all) != 0 or file_types == FILE_TYPES_ALL_FILES): + filename = tk.filedialog.asksaveasfilename(initialdir=initial_folder, + initialfile=default_path, + defaultextension=default_extension) # show the 'get file' dialog box + else: + filename = tk.filedialog.asksaveasfilename(filetypes=file_types, + initialdir=initial_folder, + initialfile=default_path, + defaultextension=default_extension) # show the 'get file' dialog box + else: + filename = tk.filedialog.asksaveasfilename(filetypes=file_types, + initialdir=initial_folder, + initialfile=default_path, + parent=root, + defaultextension=default_extension) # show the 'get file' dialog box + elif multiple_files: + if running_mac(): + is_all = [(x, y) for (x, y) in file_types if all(ch in '* .' for ch in y)] + if not len(set(file_types)) > 1 and (len(is_all) != 0 or file_types == FILE_TYPES_ALL_FILES): + filename = tk.filedialog.askopenfilenames(initialdir=initial_folder, + initialfile=default_path, + defaultextension=default_extension) # show the 'get file' dialog box + else: + filename = tk.filedialog.askopenfilenames(filetypes=file_types, + initialdir=initial_folder, + initialfile=default_path, + defaultextension=default_extension) # show the 'get file' dialog box + else: + filename = tk.filedialog.askopenfilenames(filetypes=file_types, + initialdir=initial_folder, + initialfile=default_path, + parent=root, + defaultextension=default_extension) # show the 'get file' dialog box + else: + if running_mac(): + is_all = [(x, y) for (x, y) in file_types if all(ch in '* .' for ch in y)] + if not len(set(file_types)) > 1 and (len(is_all) != 0 or file_types == FILE_TYPES_ALL_FILES): + filename = tk.filedialog.askopenfilename(initialdir=initial_folder, + initialfile=default_path, + defaultextension=default_extension) # show the 'get files' dialog box + else: + filename = tk.filedialog.askopenfilename(filetypes=file_types, + initialdir=initial_folder, + initialfile=default_path, + defaultextension=default_extension) # show the 'get files' dialog box + else: + filename = tk.filedialog.askopenfilename(filetypes=file_types, + initialdir=initial_folder, + initialfile=default_path, + parent=root, + defaultextension=default_extension) # show the 'get files' dialog box + root.destroy() + + if not multiple_files and type(filename) in (tuple, list): + if len(filename): # only if not 0 length, otherwise will get an error + filename = filename[0] + if not filename: + return None + return filename + + if save_as: + browse_button = SaveAs(file_types=file_types, initial_folder=initial_folder, default_extension=default_extension) + elif multiple_files: + browse_button = FilesBrowse(file_types=file_types, initial_folder=initial_folder, files_delimiter=files_delimiter) + else: + browse_button = FileBrowse(file_types=file_types, initial_folder=initial_folder) + + if image is not None: + if isinstance(image, str): + layout = [[Image(filename=image)]] + else: + layout = [[Image(data=image)]] + else: + layout = [[]] + + layout += [[Text(message, auto_size_text=True, text_color=text_color, background_color=background_color)]] + + if not history: + layout += [[InputText(default_text=default_path, size=size, key='-INPUT-'), browse_button]] + else: + file_list = history_settings.get("-PSG file list-", []) + last_entry = file_list[0] if file_list else '' + layout += [[Combo(file_list, default_value=last_entry, key='-INPUT-', size=size if size != (None, None) else (80, 1), bind_return_key=True), + browse_button, Button('Clear History', tooltip='Clears the list of files shown in the combobox')]] + + layout += [[Button('Ok', size=(6, 1), bind_return_key=True), Button('Cancel', size=(6, 1))]] + + window = Window(title=title or message, layout=layout, icon=icon, auto_size_text=True, button_color=button_color, + font=font, background_color=background_color, no_titlebar=no_titlebar, grab_anywhere=grab_anywhere, keep_on_top=keep_on_top, location=location, relative_location=relative_location, modal=modal, finalize=True) + + if running_linux() and show_hidden is True: + window.TKroot.tk.eval('catch {tk_getOpenFile -badoption}') # dirty hack to force autoloading of Tk's file dialog code + window.TKroot.setvar('::tk::dialog::file::showHiddenBtn', 1) # enable the "show hidden files" checkbox (it's necessary) + window.TKroot.setvar('::tk::dialog::file::showHiddenVar', 0) # start with the hidden files... well... hidden + + while True: + event, values = window.read() + if event in ('Cancel', WIN_CLOSED): + break + elif event == 'Clear History': + history_settings.set('-PSG file list-', []) + window['-INPUT-'].update('', []) + popup_quick_message('History of Previous Choices Cleared', background_color='red', text_color='white', font='_ 20', keep_on_top=True) + elif event in ('Ok', '-INPUT-'): + if values['-INPUT-'] != '': + if history_settings is not None: + list_of_entries = history_settings.get('-PSG file list-', []) + if values['-INPUT-'] in list_of_entries: + list_of_entries.remove(values['-INPUT-']) + list_of_entries.insert(0, values['-INPUT-']) + history_settings.set('-PSG file list-', list_of_entries) + break + + window.close() + del window + if event in ('Cancel', WIN_CLOSED): + return None + + return values['-INPUT-'] + + +# --------------------------- popup_get_text --------------------------- + +def popup_get_text(message, title=None, default_text='', password_char='', size=(None, None), button_color=None, + background_color=None, text_color=None, icon=None, font=None, no_titlebar=False, + grab_anywhere=False, keep_on_top=None, location=(None, None), relative_location=(None, None), image=None, history=False, history_setting_filename=None, modal=True): + """ + Display Popup with text entry field. Returns the text entered or None if closed / cancelled + + :param message: message displayed to user + :type message: (str) + :param title: Window title + :type title: (str) + :param default_text: default value to put into input area + :type default_text: (str) + :param password_char: character to be shown instead of actually typed characters. WARNING - if history=True then can't hide passwords + :type password_char: (str) + :param size: (width, height) of the InputText Element + :type size: (int, int) + :param button_color: Color of the button (text, background) + :type button_color: (str, str) | str + :param background_color: background color of the entire window + :type background_color: (str) + :param text_color: color of the message text + :type text_color: (str) + :param icon: filename or base64 string to be used for the window's icon + :type icon: bytes | str + :param font: specifies the font family, size, etc. Tuple or Single string format 'name size styles'. Styles: italic * roman bold normal underline overstrike + :type font: (str or (str, int[, str]) or None) + :param no_titlebar: If True no titlebar will be shown + :type no_titlebar: (bool) + :param grab_anywhere: If True can click and drag anywhere in the window to move the window + :type grab_anywhere: (bool) + :param keep_on_top: If True the window will remain above all current windows + :type keep_on_top: (bool) + :param location: (x,y) Location on screen to display the upper left corner of window + :type location: (int, int) + :param relative_location: (x,y) location relative to the default location of the window, in pixels. Normally the window centers. This location is relative to the location the window would be created. Note they can be negative. + :type relative_location: (int, int) + :param image: Image to include at the top of the popup window + :type image: (str) or (bytes) + :param history: If True then enable a "history" feature that will display previous entries used. Uses settings filename provided or default if none provided + :type history: bool + :param history_setting_filename: Filename to use for the User Settings. Will store list of previous entries in this settings file + :type history_setting_filename: (str) + :param modal: If True then makes the popup will behave like a Modal window... all other windows are non-operational until this one is closed. Default = True + :type modal: bool + :return: Text entered or None if window was closed or cancel button clicked + :rtype: str | None + """ + + + # First setup the history settings file if history feature is enabled + if history and history_setting_filename is not None: + try: + history_settings = UserSettings(history_setting_filename) + except Exception as e: + _error_popup_with_traceback('popup_get_file - Something is wrong with your supplied history settings filename', + 'Exception: {}'.format(e)) + return None + elif history: + history_settings_filename = os.path.basename(inspect.stack()[1].filename) + history_settings_filename = os.path.splitext(history_settings_filename)[0] + '.json' + history_settings = UserSettings(history_settings_filename) + else: + history_settings = None + + if image is not None: + if isinstance(image, str): + layout = [[Image(filename=image)]] + else: + layout = [[Image(data=image)]] + else: + layout = [[]] + + layout += [[Text(message, auto_size_text=True, text_color=text_color, background_color=background_color)]] + if not history: + layout += [[InputText(default_text=default_text, size=size, key='-INPUT-', password_char=password_char)]] + else: + text_list = history_settings.get("-PSG text list-", []) + last_entry = text_list[0] if text_list else default_text + layout += [[Combo(text_list, default_value=last_entry, key='-INPUT-', size=size if size != (None, None) else (80, 1), bind_return_key=True), + Button('Clear History', tooltip='Clears the list of files shown in the combobox')]] + + layout += [[Button('Ok', size=(6, 1), bind_return_key=True), Button('Cancel', size=(6, 1))]] + + window = Window(title=title or message, layout=layout, icon=icon, auto_size_text=True, button_color=button_color, no_titlebar=no_titlebar, + background_color=background_color, grab_anywhere=grab_anywhere, keep_on_top=keep_on_top, location=location, relative_location=relative_location, finalize=True, modal=modal, font=font) + + + while True: + event, values = window.read() + if event in ('Cancel', WIN_CLOSED): + break + elif event == 'Clear History': + history_settings.set('-PSG text list-', []) + window['-INPUT-'].update('', []) + popup_quick_message('History of Previous Choices Cleared', background_color='red', text_color='white', font='_ 20', keep_on_top=True) + elif event in ('Ok', '-INPUT-'): + if values['-INPUT-'] != '': + if history_settings is not None: + list_of_entries = history_settings.get('-PSG text list-', []) + if values['-INPUT-'] in list_of_entries: + list_of_entries.remove(values['-INPUT-']) + list_of_entries.insert(0, values['-INPUT-']) + history_settings.set('-PSG text list-', list_of_entries) + break + + window.close() + del window + if event in ('Cancel', WIN_CLOSED): + return None + else: + text = values['-INPUT-'] + return text + + +def popup_get_date(start_mon=None, start_day=None, start_year=None, begin_at_sunday_plus=0, no_titlebar=True, title='Choose Date', keep_on_top=True, + location=(None, None), relative_location=(None, None), close_when_chosen=False, icon=None, locale=None, month_names=None, day_abbreviations=None, day_font = 'TkFixedFont 9', mon_year_font = 'TkFixedFont 10', arrow_font = 'TkFixedFont 7', modal=True): + """ + Display a calendar window, get the user's choice, return as a tuple (mon, day, year) + + :param start_mon: The starting month + :type start_mon: (int) + :param start_day: The starting day - optional. Set to None or 0 if no date to be chosen at start + :type start_day: int | None + :param start_year: The starting year + :type start_year: (int) + :param begin_at_sunday_plus: Determines the left-most day in the display. 0=sunday, 1=monday, etc + :type begin_at_sunday_plus: (int) + :param icon: Same as Window icon parameter. Can be either a filename or Base64 value. For Windows if filename, it MUST be ICO format. For Linux, must NOT be ICO + :type icon: (str | bytes) + :param location: (x,y) location on the screen to place the top left corner of your window. Default is to center on screen + :type location: (int, int) + :param relative_location: (x,y) location relative to the default location of the window, in pixels. Normally the window centers. This location is relative to the location the window would be created. Note they can be negative. + :type relative_location: (int, int) + :param title: Title that will be shown on the window + :type title: (str) + :param close_when_chosen: If True, the window will close and function return when a day is clicked + :type close_when_chosen: (bool) + :param locale: locale used to get the day names + :type locale: (str) + :param no_titlebar: If True no titlebar will be shown + :type no_titlebar: (bool) + :param keep_on_top: If True the window will remain above all current windows + :type keep_on_top: (bool) + :param month_names: optional list of month names to use (should be 12 items) + :type month_names: List[str] + :param day_abbreviations: optional list of abbreviations to display as the day of week + :type day_abbreviations: List[str] + :param day_font: Font and size to use for the calendar + :type day_font: str | tuple + :param mon_year_font: Font and size to use for the month and year at the top + :type mon_year_font: str | tuple + :param arrow_font: Font and size to use for the arrow buttons + :type arrow_font: str | tuple + :param modal: If True then makes the popup will behave like a Modal window... all other windows are non-operational until this one is closed. Default = True + :type modal: bool + :return: Tuple containing (month, day, year) of chosen date or None if was cancelled + :rtype: None | (int, int, int) + """ + + if month_names is not None and len(month_names) != 12: + if not SUPPRESS_ERROR_POPUPS: + popup_error('Incorrect month names list specified. Must have 12 entries.', 'Your list:', month_names) + + if day_abbreviations is not None and len(day_abbreviations) != 7: + if not SUPPRESS_ERROR_POPUPS: + popup_error('Incorrect day abbreviation list. Must have 7 entries.', 'Your list:', day_abbreviations) + + now = datetime.datetime.now() + cur_month, cur_day, cur_year = now.month, now.day, now.year + cur_month = start_mon or cur_month + if start_mon is not None: + cur_day = start_day + else: + cur_day = cur_day + cur_year = start_year or cur_year + + def update_days(window, month, year, begin_at_sunday_plus): + [window[(week, day)].update('') for day in range(7) for week in range(6)] + weeks = calendar.monthcalendar(year, month) + month_days = list(itertools.chain.from_iterable([[0 for _ in range(8 - begin_at_sunday_plus)]] + weeks)) + if month_days[6] == 0: + month_days = month_days[7:] + if month_days[6] == 0: + month_days = month_days[7:] + for i, day in enumerate(month_days): + offset = i + if offset >= 6 * 7: + break + window[(offset // 7, offset % 7)].update(str(day) if day else '') + + def make_days_layout(): + days_layout = [] + for week in range(6): + row = [] + for day in range(7): + row.append(T('', size=(4, 1), justification='c', font=day_font, key=(week, day), enable_events=True, pad=(0, 0))) + days_layout.append(row) + return days_layout + + # Create table of month names and week day abbreviations + + if day_abbreviations is None or len(day_abbreviations) != 7: + fwday = calendar.SUNDAY + try: + if locale is not None: + _cal = calendar.LocaleTextCalendar(fwday, locale) + else: + _cal = calendar.TextCalendar(fwday) + day_names = _cal.formatweekheader(3).split() + except Exception as e: + print('Exception building day names from locale', locale, e) + day_names = ('Sun', 'Mon', 'Tue', 'Wed', 'Th', 'Fri', 'Sat') + else: + day_names = day_abbreviations + + mon_names = month_names if month_names is not None and len(month_names) == 12 else [calendar.month_name[i] for i in range(1, 13)] + days_layout = make_days_layout() + + layout = [[B('◄◄', font=arrow_font, border_width=0, key='-YEAR-DOWN-', pad=((10, 2), 2)), + B('◄', font=arrow_font, border_width=0, key='-MON-DOWN-', pad=(0, 2)), + Text('{} {}'.format(mon_names[cur_month - 1], cur_year), size=(16, 1), justification='c', font=mon_year_font, key='-MON-YEAR-', pad=(0, 2)), + B('►', font=arrow_font, border_width=0, key='-MON-UP-', pad=(0, 2)), + B('►►', font=arrow_font, border_width=0, key='-YEAR-UP-', pad=(2, 2))]] + layout += [[Col([[T(day_names[i - (7 - begin_at_sunday_plus) % 7], size=(4, 1), font=day_font, background_color=theme_text_color(), + text_color=theme_background_color(), pad=(0, 0)) for i in range(7)]], background_color=theme_text_color(), pad=(0, 0))]] + layout += days_layout + if not close_when_chosen: + layout += [[Button('Ok', border_width=0, font='TkFixedFont 8'), Button('Cancel', border_width=0, font='TkFixedFont 8')]] + + window = Window(title, layout, no_titlebar=no_titlebar, grab_anywhere=True, keep_on_top=keep_on_top, font='TkFixedFont 12', use_default_focus=False, + location=location, relative_location=relative_location, finalize=True, icon=icon) + + update_days(window, cur_month, cur_year, begin_at_sunday_plus) + + prev_choice = chosen_mon_day_year = None + + if cur_day: + chosen_mon_day_year = cur_month, cur_day, cur_year + for week in range(6): + for day in range(7): + if window[(week, day)].DisplayText == str(cur_day): + window[(week, day)].update(background_color=theme_text_color(), text_color=theme_background_color()) + prev_choice = (week, day) + break + + if modal or DEFAULT_MODAL_WINDOWS_FORCED: + window.make_modal() + + while True: # Event Loop + event, values = window.read() + if event in (None, 'Cancel'): + chosen_mon_day_year = None + break + if event == 'Ok': + break + if event in ('-MON-UP-', '-MON-DOWN-', '-YEAR-UP-', '-YEAR-DOWN-'): + cur_month += (event == '-MON-UP-') + cur_month -= (event == '-MON-DOWN-') + cur_year += (event == '-YEAR-UP-') + cur_year -= (event == '-YEAR-DOWN-') + if cur_month > 12: + cur_month = 1 + cur_year += 1 + elif cur_month < 1: + cur_month = 12 + cur_year -= 1 + window['-MON-YEAR-'].update('{} {}'.format(mon_names[cur_month - 1], cur_year)) + update_days(window, cur_month, cur_year, begin_at_sunday_plus) + if prev_choice: + window[prev_choice].update(background_color=theme_background_color(), text_color=theme_text_color()) + elif type(event) is tuple: + if window[event].DisplayText != "": + chosen_mon_day_year = cur_month, int(window[event].DisplayText), cur_year + if prev_choice: + window[prev_choice].update(background_color=theme_background_color(), text_color=theme_text_color()) + window[event].update(background_color=theme_text_color(), text_color=theme_background_color()) + prev_choice = event + if close_when_chosen: + break + window.close() + return chosen_mon_day_year + + +# --------------------------- PopupAnimated --------------------------- + +def popup_animated(image_source, message=None, background_color=None, text_color=None, font=None, no_titlebar=True, grab_anywhere=True, keep_on_top=True, + location=(None, None), relative_location=(None, None), alpha_channel=None, time_between_frames=0, transparent_color=None, title='', icon=None, no_buffering=False): + """ + Show animation one frame at a time. This function has its own internal clocking meaning you can call it at any frequency + and the rate the frames of video is shown remains constant. Maybe your frames update every 30 ms but your + event loop is running every 10 ms. You don't have to worry about delaying, just call it every time through the + loop. + + :param image_source: Either a filename or a base64 string. Use None to close the window. + :type image_source: str | bytes | None + :param message: An optional message to be shown with the animation + :type message: (str) + :param background_color: color of background + :type background_color: (str) + :param text_color: color of the text + :type text_color: (str) + :param font: specifies the font family, size, etc. Tuple or Single string format 'name size styles'. Styles: italic * roman bold normal underline overstrike + :type font: str | tuple + :param no_titlebar: If True then the titlebar and window frame will not be shown + :type no_titlebar: (bool) + :param grab_anywhere: If True then you can move the window just clicking anywhere on window, hold and drag + :type grab_anywhere: (bool) + :param keep_on_top: If True then Window will remain on top of all other windows currently shownn + :type keep_on_top: (bool) + :param location: (x,y) location on the screen to place the top left corner of your window. Default is to center on screen + :type location: (int, int) + :param relative_location: (x,y) location relative to the default location of the window, in pixels. Normally the window centers. This location is relative to the location the window would be created. Note they can be negative. + :type relative_location: (int, int) + :param alpha_channel: Window transparency 0 = invisible 1 = completely visible. Values between are see through + :type alpha_channel: (float) + :param time_between_frames: Amount of time in milliseconds between each frame + :type time_between_frames: (int) + :param transparent_color: This color will be completely see-through in your window. Can even click through + :type transparent_color: (str) + :param title: Title that will be shown on the window + :type title: (str) + :param icon: Same as Window icon parameter. Can be either a filename or Base64 byte string. For Windows if filename, it MUST be ICO format. For Linux, must NOT be ICO + :type icon: str | bytes + :param no_buffering: If True then no buffering will be used for the GIF. May work better if you have a large animation + :type no_buffering: (bool) + :return: True if the window updated OK. False if the window was closed + :rtype: bool + """ + if image_source is None: + for image in Window._animated_popup_dict: + window = Window._animated_popup_dict[image] + window.close() + Window._animated_popup_dict = {} + return + + if image_source not in Window._animated_popup_dict: + if type(image_source) is bytes or len(image_source) > 300: + layout = [[Image(data=image_source, background_color=background_color, key='-IMAGE-')], ] + else: + layout = [[Image(filename=image_source, background_color=background_color, key='-IMAGE-', )], ] + if message: + layout.append([Text(message, background_color=background_color, text_color=text_color, font=font)]) + + window = Window(title, layout, no_titlebar=no_titlebar, grab_anywhere=grab_anywhere, + keep_on_top=keep_on_top, background_color=background_color, location=location, + alpha_channel=alpha_channel, element_padding=(0, 0), margins=(0, 0), + transparent_color=transparent_color, finalize=True, element_justification='c', icon=icon, relative_location=relative_location) + Window._animated_popup_dict[image_source] = window + else: + window = Window._animated_popup_dict[image_source] + if no_buffering: + window['-IMAGE-'].update_animation_no_buffering(image_source, time_between_frames=time_between_frames) + else: + window['-IMAGE-'].update_animation(image_source, time_between_frames=time_between_frames) + event, values = window.read(1) + if event == WIN_CLOSED: + return False + # window.refresh() # call refresh instead of Read to save significant CPU time + return True + + +# Popup Notify +def popup_notify(*args, title='', icon=SYSTEM_TRAY_MESSAGE_ICON_INFORMATION, display_duration_in_ms=SYSTEM_TRAY_MESSAGE_DISPLAY_DURATION_IN_MILLISECONDS, + fade_in_duration=SYSTEM_TRAY_MESSAGE_FADE_IN_DURATION, alpha=0.9, location=None): + """ + Displays a "notification window", usually in the bottom right corner of your display. Has an icon, a title, and a message. It is more like a "toaster" window than the normal popups. + + The window will slowly fade in and out if desired. Clicking on the window will cause it to move through the end the current "phase". For example, if the window was fading in and it was clicked, then it would immediately stop fading in and instead be fully visible. It's a way for the user to quickly dismiss the window. + + The return code specifies why the call is returning (e.g. did the user click the message to dismiss it) + + :param title: Text to be shown at the top of the window in a larger font + :type title: (str) + :param message: Text message that makes up the majority of the window + :type message: (str) + :param icon: A base64 encoded PNG/GIF image or PNG/GIF filename that will be displayed in the window + :type icon: bytes | str + :param display_duration_in_ms: Number of milliseconds to show the window + :type display_duration_in_ms: (int) + :param fade_in_duration: Number of milliseconds to fade window in and out + :type fade_in_duration: (int) + :param alpha: Alpha channel. 0 - invisible 1 - fully visible + :type alpha: (float) + :param location: Location on the screen to display the window + :type location: (int, int) + :return: reason for returning + :rtype: (int) + """ + + if not args: + args_to_print = [''] + else: + args_to_print = args + output = '' + max_line_total, total_lines, local_line_width = 0, 0, SYSTEM_TRAY_MESSAGE_MAX_LINE_LENGTH + for message in args_to_print: + # fancy code to check if string and convert if not is not need. Just always convert to string :-) + # if not isinstance(message, str): message = str(message) + message = str(message) + if message.count('\n'): + message_wrapped = message + else: + message_wrapped = textwrap.fill(message, local_line_width) + message_wrapped_lines = message_wrapped.count('\n') + 1 + longest_line_len = max([len(l) for l in message.split('\n')]) + width_used = min(longest_line_len, local_line_width) + max_line_total = max(max_line_total, width_used) + # height = _GetNumLinesNeeded(message, width_used) + height = message_wrapped_lines + output += message_wrapped + '\n' + total_lines += height + + message = output + + # def __init__(self, menu=None, filename=None, data=None, data_base64=None, tooltip=None, metadata=None): + return SystemTray.notify(title=title, message=message, icon=icon, display_duration_in_ms=display_duration_in_ms, fade_in_duration=fade_in_duration, + alpha=alpha, location=location) + + +def popup_menu(window, element, menu_def, title=None, location=(None, None)): + """ + Makes a "popup menu" + This type of menu is what you get when a normal menu or a right click menu is torn off + The settings for the menu are obtained from the window parameter's Window + + + :param window: The window associated with the popup menu. The theme and right click menu settings for this window will be used + :type window: Window + :param element: An element in your window to associate the menu to. It can be any element + :type element: Element + :param menu_def: A menu definition. This will be the same format as used for Right Click Menus1 + :type menu_def: List[List[ List[str] | str ]] + :param title: The title that will be shown on the torn off menu window. Defaults to window titlr + :type title: str + :param location: The location on the screen to place the window + :type location: (int, int) | (None, None) + """ + + element._popup_menu_location = location + top_menu = tk.Menu(window.TKroot, tearoff=True, tearoffcommand=element._tearoff_menu_callback) + if window.right_click_menu_background_color not in (COLOR_SYSTEM_DEFAULT, None): + top_menu.config(bg=window.right_click_menu_background_color) + if window.right_click_menu_text_color not in (COLOR_SYSTEM_DEFAULT, None): + top_menu.config(fg=window.right_click_menu_text_color) + if window.right_click_menu_disabled_text_color not in (COLOR_SYSTEM_DEFAULT, None): + top_menu.config(disabledforeground=window.right_click_menu_disabled_text_color) + if window.right_click_menu_font is not None: + top_menu.config(font=window.right_click_menu_font) + if window.right_click_menu_selected_colors[0] != COLOR_SYSTEM_DEFAULT: + top_menu.config(activeforeground=window.right_click_menu_selected_colors[0]) + if window.right_click_menu_selected_colors[1] != COLOR_SYSTEM_DEFAULT: + top_menu.config(activebackground=window.right_click_menu_selected_colors[1]) + top_menu.config(title=window.Title if title is None else title) + AddMenuItem(top_menu, menu_def[1], element, right_click_menu=True) + # element.Widget.bind('', element._RightClickMenuCallback) + top_menu.invoke(0) + + +def popup_error_with_traceback(title, *messages, emoji=None): + """ + Show an error message and as many additoinal lines of messages as you want. + Will show the same error window as PySimpleGUI uses internally. Has a button to + take the user to the line of code you called this popup from. + If you include the Exception information in your messages, then it will be parsed and additional information + will be in the window about such as the specific line the error itself occurred on. + + :param title: The title that will be shown in the popup's titlebar and in the first line of the window + :type title: str + :param *messages: A variable number of lines of messages you wish to show your user + :type *messages: Any + :param emoji: An optional BASE64 Encoded image to shows in the error window + :type emoji: bytes + """ + + # For now, call the function that PySimpleGUI uses internally + _error_popup_with_traceback(str(title), *messages, emoji=emoji) + + +def _error_popup_with_traceback(title, *args, emoji=None): + if SUPPRESS_ERROR_POPUPS: + return + trace_details = traceback.format_stack() + error_message = '' + file_info_pysimplegui = None + for line in reversed(trace_details): + if __file__ not in line: + file_info_pysimplegui = line.split(",")[0] + error_message = line + break + if file_info_pysimplegui is None: + _error_popup_with_code(title, None, None, 'Did not find your traceback info', *args,emoji=emoji) + return + + error_parts = None + if error_message != '': + error_parts = error_message.split(', ') + if len(error_parts) < 4: + error_message = error_parts[0] + '\n' + error_parts[1] + '\n' + ''.join(error_parts[2:]) + if error_parts is None: + print('*** Error popup attempted but unable to parse error details ***') + print(trace_details) + return + filename = error_parts[0][error_parts[0].index('File ') + 5:] + line_num = error_parts[1][error_parts[1].index('line ') + 5:] + _error_popup_with_code(title, filename, line_num, error_message, *args, emoji=emoji) + + +def _error_popup_with_code(title, filename, line_num, *args, emoji=None): + """ + Makes the error popup window + + :param title: The title that will be shown in the popup's titlebar and in the first line of the window + :type title: str + :param filename: The filename to show.. may not be the filename that actually encountered the exception! + :type filename: str + :param line_num: Line number within file with the error + :type line_num: int | str + :param args: A variable number of lines of messages + :type args: *Any + :param emoji: An optional BASE64 Encoded image to shows in the error window + :type emoji: bytes + """ + editor_filename = execute_get_editor() + emoji_data = emoji if emoji is not None else _random_error_emoji() + layout = [[Text('ERROR'), Text(title)], + [Image(data=emoji_data)]] + lines = [] + for msg in args: + if isinstance(msg, Exception): + lines += [[f'Additional Exception info pased in by PySimpleGUI or user: Error type is: {type(msg).__name__}']] + lines += [[f'In file {__file__} Line number {msg.__traceback__.tb_lineno}']] + lines += [[f'{msg}']] + else: + lines += [str(msg).split('\n')] + max_line_len = 0 + for line in lines: + max_line_len = max(max_line_len, max([len(s) for s in line])) + + layout += [[Text(''.join(line), size=(min(max_line_len, 90), None))] for line in lines] + layout += [[Button('Close'), Button('Take me to error', disabled=True if not editor_filename else False), Button('Kill Application', button_color='white on red')]] + if not editor_filename: + layout += [[Text('Configure editor in the Global settings to enable "Take me to error" feature')]] + window = Window(title, layout, keep_on_top=True) + + while True: + event, values = window.read() + if event in ('Close', WIN_CLOSED): + break + if event == 'Kill Application': + window.close() + popup_quick_message('KILLING APP! BYE!', font='_ 18', keep_on_top=True, text_color='white', background_color='red', non_blocking=False) + sys.exit() + if event == 'Take me to error' and filename is not None and line_num is not None: + execute_editor(filename, line_num) + + window.close() + + +##################################################################### +# Animated window while shell command is executed +##################################################################### + +def _process_thread(*args): + global __shell_process__ + + # start running the command with arugments + try: + __shell_process__ = subprocess.run(args, shell=True, stdout=subprocess.PIPE) + except Exception as e: + print('Exception running process args = {}'.format(args)) + __shell_process__ = None + + +def shell_with_animation(command, args=None, image_source=DEFAULT_BASE64_LOADING_GIF, message=None, background_color=None, text_color=None, font=None, + no_titlebar=True, grab_anywhere=True, keep_on_top=True, location=(None, None), alpha_channel=None, time_between_frames=100, + transparent_color=None): + """ + Execute a "shell command" (anything capable of being launched using subprocess.run) and + while the command is running, show an animated popup so that the user knows that a long-running + command is being executed. Without this mechanism, the GUI appears locked up. + + :param command: The command to run + :type command: (str) + :param args: List of arguments + :type args: List[str] + :param image_source: Either a filename or a base64 string. + :type image_source: str | bytes + :param message: An optional message to be shown with the animation + :type message: (str) + :param background_color: color of background + :type background_color: (str) + :param text_color: color of the text + :type text_color: (str) + :param font: specifies the font family, size, etc. Tuple or Single string format 'name size styles'. Styles: italic * roman bold normal underline overstrike + :type font: str | tuple + :param no_titlebar: If True then the titlebar and window frame will not be shown + :type no_titlebar: (bool) + :param grab_anywhere: If True then you can move the window just clicking anywhere on window, hold and drag + :type grab_anywhere: (bool) + :param keep_on_top: If True then Window will remain on top of all other windows currently shownn + :type keep_on_top: (bool) + :param location: (x,y) location on the screen to place the top left corner of your window. Default is to center on screen + :type location: (int, int) + :param alpha_channel: Window transparency 0 = invisible 1 = completely visible. Values between are see through + :type alpha_channel: (float) + :param time_between_frames: Amount of time in milliseconds between each frame + :type time_between_frames: (int) + :param transparent_color: This color will be completely see-through in your window. Can even click through + :type transparent_color: (str) + :return: The resulting string output from stdout + :rtype: (str) + """ + + global __shell_process__ + + real_args = [command] + if args is not None: + for arg in args: + real_args.append(arg) + # real_args.append(args) + thread = threading.Thread(target=_process_thread, args=real_args, daemon=True) + thread.start() + + # Poll to see if the thread is still running. If so, then continue showing the animation + while True: + popup_animated(image_source=image_source, message=message, time_between_frames=time_between_frames, transparent_color=transparent_color, + text_color=text_color, background_color=background_color, font=font, no_titlebar=no_titlebar, grab_anywhere=grab_anywhere, + keep_on_top=keep_on_top, location=location, alpha_channel=alpha_channel) + thread.join(timeout=time_between_frames / 1000) + if not thread.is_alive(): + break + popup_animated(None) # stop running the animation + + output = __shell_process__.__str__().replace('\\r\\n', '\n') # fix up the output string + output = output[output.index("stdout=b'") + 9:-2] + return output + + +####################################################################### +# 8888888888 +# 888 +# 888 +# 8888888 888d888 888d888 .d88b. 888d888 +# 888 888P" 888P" d88""88b 888P" +# 888 888 888 888 888 888 +# 888 888 888 Y88..88P 888 +# 8888888888 888 888 "Y88P" 888 +# +# +# +# 888b d888 +# 8888b d8888 +# 88888b.d88888 +# 888Y88888P888 .d88b. .d8888b .d8888b 8888b. .d88b. .d88b. +# 888 Y888P 888 d8P Y8b 88K 88K "88b d88P"88b d8P Y8b +# 888 Y8P 888 88888888 "Y8888b. "Y8888b. .d888888 888 888 88888888 +# 888 " 888 Y8b. X88 X88 888 888 Y88b 888 Y8b. +# 888 888 "Y8888 88888P' 88888P' "Y888888 "Y88888 "Y8888 +# 888 +# Y8b d88P +# "Y88P" +# Code to make messages to help user find errors in their code +####################################################################### + +def _create_error_message(): + """ + Creates an error message containing the filename and line number of the users + code that made the call into PySimpleGUI + :return: Error string to display with file, line number, and line of code + :rtype: str + """ + + called_func = inspect.stack()[1].function + trace_details = traceback.format_stack() + error_message = '' + file_info_pysimplegui = trace_details[-1].split(",")[0] + for line in reversed(trace_details): + if line.split(",")[0] != file_info_pysimplegui: + error_message = line + break + if error_message != '': + error_parts = error_message.split(', ') + if len(error_parts) < 4: + error_message = error_parts[0] + '\n' + error_parts[1] + '\n' + ''.join(error_parts[2:]) + return 'The PySimpleGUI internal reporting function is ' + called_func + '\n' + \ + 'The error originated from:\n' + error_message + + +# .d8888b. 888 888 d8b +# d88P Y88b 888 888 Y8P +# Y88b. 888 888 +# "Y888b. .d88b. 888888 888888 888 88888b. .d88b. .d8888b +# "Y88b. d8P Y8b 888 888 888 888 "88b d88P"88b 88K +# "888 88888888 888 888 888 888 888 888 888 "Y8888b. +# Y88b d88P Y8b. Y88b. Y88b. 888 888 888 Y88b 888 X88 +# "Y8888P" "Y8888 "Y888 "Y888 888 888 888 "Y88888 88888P' +# 888 +# Y8b d88P +# "Y88P" + +# Interface to saving / loading user program settings in json format +# This is a new set of APIs supplied by PySimpleGUI that enables users to easily set/save/load individual +# settings. They are automatically saved to a JSON file. If no file/path is specified then a filename is +# created from the source file filename. + +class UserSettings: + # A reserved settings object for use by the setting functions. It's a way for users + # to access the user settings without diarectly using the UserSettings class + _default_for_function_interface = None # type: UserSettings + + def __init__(self, filename=None, path=None, silent_on_error=False, autosave=True, use_config_file=None, convert_bools_and_none=True): + """ + User Settings + + :param filename: The name of the file to use. Can be a full path and filename or just filename + :type filename: (str or None) + :param path: The folder that the settings file will be stored in. Do not include the filename. + :type path: (str or None) + :param silent_on_error: If True errors will not be reported + :type silent_on_error: (bool) + :param autosave: If True the settings file is saved after every update + :type autosave: (bool) + :param use_config_file: If True then the file format will be a config.ini rather than json + :type use_config_file: (bool) + :param convert_bools_and_none: If True then "True", "False", "None" will be converted to the Python values True, False, None when using INI files. Default is TRUE + :type convert_bools_and_none: (bool) + """ + + self.path = path + self.filename = filename + self.full_filename = None + self.dict = {} + self.default_value = None + self.silent_on_error = silent_on_error + self.autosave = autosave + if filename is not None and filename.endswith('.ini') and use_config_file is None: + warnings.warn('[UserSettings] You have specified a filename with .ini extension but did not set use_config_file. Setting use_config_file for you.', UserWarning) + use_config_file = True + self.use_config_file = use_config_file + # self.retain_config_comments = retain_config_comments + self.convert_bools = convert_bools_and_none + if use_config_file: + self.config = configparser.ConfigParser() + self.config.optionxform = str + # self.config_dict = {} + self.section_class_dict = {} # type: dict[_SectionDict] + if filename is not None or path is not None: + self.load(filename=filename, path=path) + + + ######################################################################################################## + ## FIRST is the _SectionDict helper class + ## It is typically not directly accessed, although it is possible to call delete_section, get, set + ######################################################################################################## + + class _SectionDict: + item_count = 0 + def __init__(self, section_name, section_dict, config, user_settings_parent): # (str, Dict, configparser.ConfigParser) + """ + The Section Dictionary. It holds the values for a section. + + :param section_name: Name of the section + :type section_name: str + :param section_dict: Dictionary of values for the section + :type section_dict: dict + :param config: The configparser object + :type config: configparser.ConfigParser + :param user_settings_parent: The parent UserSettings object that hdas this section + :type user_settings_parent: UserSettings + """ + self.section_name = section_name + self.section_dict = section_dict # type: Dict + self.new_section = False + self.config = config # type: configparser.ConfigParser + self.user_settings_parent = user_settings_parent # type: UserSettings + UserSettings._SectionDict.item_count += 1 + + if self.user_settings_parent.convert_bools: + for key, value in self.section_dict.items(): + if value == 'True': + value = True + self.section_dict[key] = value + elif value == 'False': + value = False + self.section_dict[key] = value + elif value == 'None': + value = None + self.section_dict[key] = value + # print(f'++++++ making a new SectionDict with name = {section_name}') + + + def __repr__(self): + """ + Converts the settings dictionary into a string for easy display + + :return: the dictionary as a string + :rtype: (str) + """ + return_string = '{}:\n'.format(self.section_name) + for entry in self.section_dict.keys(): + return_string += ' {} : {}\n'.format(entry, self.section_dict[entry]) + + return return_string + + + def get(self, key, default=None): + """ + Returns the value of a specified setting. If the setting is not found in the settings dictionary, then + the user specified default value will be returned. It no default is specified and nothing is found, then + the "default value" is returned. This default can be specified in this call, or previously defined + by calling set_default. If nothing specified now or previously, then None is returned as default. + + :param key: Key used to lookup the setting in the settings dictionary + :type key: (Any) + :param default: Value to use should the key not be found in the dictionary + :type default: (Any) + :return: Value of specified settings + :rtype: (Any) + """ + value = self.section_dict.get(key, default) + if self.user_settings_parent.convert_bools: + if value == 'True': + value = True + elif value == 'False': + value = False + return value + + def set(self, key, value): + value = str(value) # all values must be strings + if self.new_section: + self.config.add_section(self.section_name) + self.new_section = False + self.config.set(section=self.section_name, option=key, value=value) + self.section_dict[key] = value + if self.user_settings_parent.autosave: + self.user_settings_parent.save() + + def delete_section(self): + # print(f'** Section Dict deleting section = {self.section_name}') + self.config.remove_section(section=self.section_name) + del self.user_settings_parent.section_class_dict[self.section_name] + if self.user_settings_parent.autosave: + self.user_settings_parent.save() + + def __getitem__(self, item): + # print('*** In SectionDict Get ***') + return self.get(item) + + def __setitem__(self, item, value): + """ + Enables setting a setting by using [ ] notation like a dictionary. + Your code will have this kind of design pattern: + settings = sg.UserSettings() + settings[item] = value + + :param item: The key for the setting to change. Needs to be a hashable type. Basically anything but a list + :type item: Any + :param value: The value to set the setting to + :type value: Any + """ + # print(f'*** In SectionDict SET *** item = {item} value = {value}') + self.set(item, value) + self.section_dict[item] = value + + def __delitem__(self, item): + """ + Delete an individual user setting. This is the same as calling delete_entry. The syntax + for deleting the item using this manner is: + del settings['entry'] + :param item: The key for the setting to delete + :type item: Any + """ + # print(f'** In SectionDict delete! section name = {self.section_name} item = {item} ') + self.config.remove_option(section=self.section_name, option=item) + try: + del self.section_dict[item] + except Exception as e: + pass + # print(e) + if self.user_settings_parent.autosave: + self.user_settings_parent.save() + + + ######################################################################################################## + + def __repr__(self): + """ + Converts the settings dictionary into a string for easy display + + :return: the dictionary as a string + :rtype: (str) + """ + if not self.use_config_file: + return pprint.pformat(self.dict) + else: + # rvalue = '-------------------- Settings ----------------------\n' + rvalue = '' + for name, section in self.section_class_dict.items(): + rvalue += str(section) + + # rvalue += '\n-------------------- Settings End----------------------\n' + rvalue += '\n' + return rvalue + + + def set_default_value(self, default): + """ + Set the value that will be returned if a requested setting is not found + + :param default: value to be returned if a setting is not found in the settings dictionary + :type default: Any + """ + self.default_value = default + + def _compute_filename(self, filename=None, path=None): + """ + Creates the full filename given the path or the filename or both. + + :param filename: The name of the file to use. Can be a full path and filename or just filename + :type filename: (str or None) + :param path: The folder that the settings file will be stored in. Do not include the filename. + :type path: (str or None) + :return: Tuple with (full filename, path, filename) + :rtype: Tuple[str, str, str] + """ + if filename is not None: + dirname_from_filename = os.path.dirname(filename) # see if a path was provided as part of filename + if dirname_from_filename: + path = dirname_from_filename + filename = os.path.basename(filename) + elif self.filename is not None: + filename = self.filename + else: + if not self.use_config_file: + filename = os.path.splitext(os.path.basename(sys.modules["__main__"].__file__))[0] + '.json' + else: + filename = os.path.splitext(os.path.basename(sys.modules["__main__"].__file__))[0] + '.ini' + + if path is None: + if self.path is not None: + # path = self.path + path = os.path.expanduser(self.path) # expand user provided path in case it has user ~ in it. Don't think it'll hurt + elif DEFAULT_USER_SETTINGS_PATH is not None: # if user set the path manually system-wide using set options + path = os.path.expanduser(DEFAULT_USER_SETTINGS_PATH) + elif running_trinket(): + path = os.path.expanduser(DEFAULT_USER_SETTINGS_TRINKET_PATH) + elif running_replit(): + path = os.path.expanduser(DEFAULT_USER_SETTINGS_REPLIT_PATH) + elif running_windows(): + path = os.path.expanduser(DEFAULT_USER_SETTINGS_WIN_PATH) + elif running_linux(): + path = os.path.expanduser(DEFAULT_USER_SETTINGS_LINUX_PATH) + elif running_mac(): + path = os.path.expanduser(DEFAULT_USER_SETTINGS_MAC_PATH) + else: + path = '.' + + full_filename = os.path.join(path, filename) + return (full_filename, path, filename) + + def set_location(self, filename=None, path=None): + """ + Sets the location of the settings file + + :param filename: The name of the file to use. Can be a full path and filename or just filename + :type filename: (str or None) + :param path: The folder that the settings file will be stored in. Do not include the filename. + :type path: (str or None) + """ + cfull_filename, cpath, cfilename = self._compute_filename(filename=filename, path=path) + + self.filename = cfilename + self.path = cpath + self.full_filename = cfull_filename + + def get_filename(self, filename=None, path=None): + """ + Sets the filename and path for your settings file. Either paramter can be optional. + + If you don't choose a path, one is provided for you that is OS specific + Windows path default = users/name/AppData/Local/PySimpleGUI/settings. + + If you don't choose a filename, your application's filename + '.json' will be used. + + Normally the filename and path are split in the user_settings calls. However for this call they + can be combined so that the filename contains both the path and filename. + + :param filename: The name of the file to use. Can be a full path and filename or just filename + :type filename: (str or None) + :param path: The folder that the settings file will be stored in. Do not include the filename. + :type path: (str or None) + :return: The full pathname of the settings file that has both the path and filename combined. + :rtype: (str) + """ + if filename is not None or path is not None or (filename is None and path is None and self.full_filename is None): + self.set_location(filename=filename, path=path) + self.read() + return self.full_filename + + + def save(self, filename=None, path=None): + """ + Saves the current settings dictionary. If a filename or path is specified in the call, then it will override any + previously specitfied filename to create a new settings file. The settings dictionary is then saved to the newly defined file. + + :param filename: The fFilename to save to. Can specify a path or just the filename. If no filename specified, then the caller's filename will be used. + :type filename: (str or None) + :param path: The (optional) path to use to save the file. + :type path: (str or None) + :return: The full path and filename used to save the settings + :rtype: (str) + """ + if filename is not None or path is not None: + self.set_location(filename=filename, path=path) + try: + if not os.path.exists(self.path): + os.makedirs(self.path) + with open(self.full_filename, 'w') as f: + if not self.use_config_file: + json.dump(self.dict, f) + else: + self.config.write(f) + except Exception as e: + if not self.silent_on_error: + _error_popup_with_traceback('UserSettings.save error', '*** UserSettings.save() Error saving settings to file:***\n', self.full_filename, e) + + return self.full_filename + + + + def load(self, filename=None, path=None): + """ + Specifies the path and filename to use for the settings and reads the contents of the file. + The filename can be a full filename including a path, or the path can be specified separately. + If no filename is specified, then the caller's filename will be used with the extension ".json" + + :param filename: Filename to load settings from (and save to in the future) + :type filename: (str or None) + :param path: Path to the file. Defaults to a specific folder depending on the operating system + :type path: (str or None) + :return: The settings dictionary (i.e. all settings) + :rtype: (dict) + """ + if filename is not None or path is not None or self.full_filename is None: + self.set_location(filename, path) + self.read() + return self.dict + + def delete_file(self, filename=None, path=None, report_error=False): + """ + Deltes the filename and path for your settings file. Either paramter can be optional. + If you don't choose a path, one is provided for you that is OS specific + Windows path default = users/name/AppData/Local/PySimpleGUI/settings. + If you don't choose a filename, your application's filename + '.json' will be used + Also sets your current dictionary to a blank one. + + :param filename: The name of the file to use. Can be a full path and filename or just filename + :type filename: (str or None) + :param path: The folder that the settings file will be stored in. Do not include the filename. + :type path: (str or None) + :param report_error: Determines if an error should be shown if a delete error happen (i.e. file isn't present) + :type report_error: (bool) + """ + + if filename is not None or path is not None or (filename is None and path is None): + self.set_location(filename=filename, path=path) + try: + os.remove(self.full_filename) + except Exception as e: + if report_error: + _error_popup_with_traceback('UserSettings delete_file warning ***', 'Exception trying to perform os.remove', e) + self.dict = {} + + def write_new_dictionary(self, settings_dict): + """ + Writes a specified dictionary to the currently defined settings filename. + + :param settings_dict: The dictionary to be written to the currently defined settings file + :type settings_dict: (dict) + """ + if self.full_filename is None: + self.set_location() + self.dict = settings_dict + self.save() + + + def read(self): + """ + Reads settings file and returns the dictionary. + If you have anything changed in an existing settings dictionary, you will lose your changes. + :return: settings dictionary + :rtype: (dict) + """ + if self.full_filename is None: + return {} + try: + if os.path.exists(self.full_filename): + with open(self.full_filename, 'r') as f: + if not self.use_config_file: # if using json + self.dict = json.load(f) + else: # if using a config file + self.config.read_file(f) + # Make a dictionary of SectionDict classses. Keys are the config.sections(). + self.section_class_dict = {} + for section in self.config.sections(): + section_dict = dict(self.config[section]) + self.section_class_dict[section] = self._SectionDict(section, section_dict, self.config, self) + + self.dict = self.section_class_dict + self.config_sections = self.config.sections() + # self.config_dict = {section_name : dict(self.config[section_name]) for section_name in self.config.sections()} + # if self.retain_config_comments: + # self.config_file_contents = f.readlines() + except Exception as e: + if not self.silent_on_error: + _error_popup_with_traceback('User Settings read warning', 'Error reading settings from file', self.full_filename, e) + # print('*** UserSettings.read - Error reading settings from file: ***\n', self.full_filename, e) + # print(_create_error_message()) + + return self.dict + + def exists(self, filename=None, path=None): + """ + Check if a particular settings file exists. Returns True if file exists + + :param filename: The name of the file to use. Can be a full path and filename or just filename + :type filename: (str or None) + :param path: The folder that the settings file will be stored in. Do not include the filename. + :type path: (str or None) + """ + cfull_filename, cpath, cfilename = self._compute_filename(filename=filename, path=path) + if os.path.exists(cfull_filename): + return True + return False + + def delete_entry(self, key, section=None, silent_on_error=None): + """ + Deletes an individual entry. If no filename has been specified up to this point, + then a default filename will be used. + After value has been deleted, the settings file is written to disk. + + :param key: Setting to be deleted. Can be any valid dictionary key type (i.e. must be hashable) + :type key: (Any) + :param silent_on_error: Determines if error should be shown. This parameter overrides the silent on error setting for the object. + :type silent_on_error: (bool) + """ + if self.full_filename is None: + self.set_location() + self.read() + if not self.use_config_file: # Is using JSON file + if key in self.dict: + del self.dict[key] + if self.autosave: + self.save() + else: + if silent_on_error is False or (silent_on_error is not True and not self.silent_on_error): + _error_popup_with_traceback('User Settings delete_entry Warning - key', key, ' not found in settings') + + else: + if section is not None: + section_dict = self.get(section) + # print(f'** Trying to delete an entry with a config file in use ** id of section_dict = {id(section_dict)}') + # section_dict = self.section_class_dict[section] + del self.get(section)[key] + # del section_dict[key] + # del section_dict[key] + + + def delete_section(self, section): + """ + Deletes a section with the name provided in the section parameter. Your INI file will be saved afterwards if auto-save enabled (default is ON) + :param section: Name of the section to delete + :type section: str + """ + if not self.use_config_file: + return + + section_dict = self.section_class_dict.get(section, None) + section_dict.delete_section() + del self.section_class_dict[section] + if self.autosave: + self.save() + + def set(self, key, value): + """ + Sets an individual setting to the specified value. If no filename has been specified up to this point, + then a default filename will be used. + After value has been modified, the settings file is written to disk. + Note that this call is not value for a config file normally. If it is, then the key is assumed to be the + Section key and the value written will be the default value. + :param key: Setting to be saved. Can be any valid dictionary key type + :type key: (Any) + :param value: Value to save as the setting's value. Can be anything + :type value: (Any) + :return: value that key was set to + :rtype: (Any) + """ + + if self.full_filename is None: + self.set_location() + # if not autosaving, then don't read the file or else will lose changes + if not self.use_config_file: + if self.autosave or self.dict == {}: + self.read() + self.dict[key] = value + else: + self.section_class_dict[key].set(value, self.default_value) + + if self.autosave: + self.save() + return value + + def get(self, key, default=None): + """ + Returns the value of a specified setting. If the setting is not found in the settings dictionary, then + the user specified default value will be returned. It no default is specified and nothing is found, then + the "default value" is returned. This default can be specified in this call, or previously defined + by calling set_default. If nothing specified now or previously, then None is returned as default. + + :param key: Key used to lookup the setting in the settings dictionary + :type key: (Any) + :param default: Value to use should the key not be found in the dictionary + :type default: (Any) + :return: Value of specified settings + :rtype: (Any) + """ + if self.default_value is not None: + default = self.default_value + + if self.full_filename is None: + self.set_location() + if self.autosave or self.dict == {}: + self.read() + if not self.use_config_file: + value = self.dict.get(key, default) + else: + value = self.section_class_dict.get(key, None) + if key not in list(self.section_class_dict.keys()): + self.section_class_dict[key] = self._SectionDict(key, {}, self.config, self) + value = self.section_class_dict[key] + value.new_section = True + return value + + def get_dict(self): + """ + Returns the current settings dictionary. If you've not setup the filename for the + settings, a default one will be used and then read. + + Note that you can display the dictionary in text format by printing the object itself. + + :return: The current settings dictionary + :rtype: Dict + """ + if self.full_filename is None: + self.set_location() + if self.autosave or self.dict == {}: + self.read() + self.save() + return self.dict + + def __setitem__(self, item, value): + """ + Enables setting a setting by using [ ] notation like a dictionary. + Your code will have this kind of design pattern: + settings = sg.UserSettings() + settings[item] = value + + :param item: The key for the setting to change. Needs to be a hashable type. Basically anything but a list + :type item: Any + :param value: The value to set the setting to + :type value: Any + """ + return self.set(item, value) + + def __getitem__(self, item): + """ + Enables accessing a setting using [ ] notation like a dictionary. + If the entry does not exist, then the default value will be returned. This default + value is None unless user sets by calling UserSettings.set_default_value(default_value) + + :param item: The key for the setting to change. Needs to be a hashable type. Basically anything but a list + :type item: Any + :return: The setting value + :rtype: Any + """ + return self.get(item, self.default_value) + + def __delitem__(self, item): + """ + Delete an individual user setting. This is the same as calling delete_entry. The syntax + for deleting the item using this manner is: + del settings['entry'] + :param item: The key for the setting to delete + :type item: Any + """ + if self.use_config_file: + return self.get(item) + else: + self.delete_entry(key=item) + + +# Create a singleton for the settings information so that the settings functions can be used +if UserSettings._default_for_function_interface is None: + UserSettings._default_for_function_interface = UserSettings() + + +def user_settings_filename(filename=None, path=None): + """ + Sets the filename and path for your settings file. Either paramter can be optional. + + If you don't choose a path, one is provided for you that is OS specific + Windows path default = users/name/AppData/Local/PySimpleGUI/settings. + + If you don't choose a filename, your application's filename + '.json' will be used. + + Normally the filename and path are split in the user_settings calls. However for this call they + can be combined so that the filename contains both the path and filename. + + :param filename: The name of the file to use. Can be a full path and filename or just filename + :type filename: (str) + :param path: The folder that the settings file will be stored in. Do not include the filename. + :type path: (str) + :return: The full pathname of the settings file that has both the path and filename combined. + :rtype: (str) + """ + settings = UserSettings._default_for_function_interface + return settings.get_filename(filename, path) + + +def user_settings_delete_filename(filename=None, path=None, report_error=False): + """ + Deltes the filename and path for your settings file. Either paramter can be optional. + If you don't choose a path, one is provided for you that is OS specific + Windows path default = users/name/AppData/Local/PySimpleGUI/settings. + If you don't choose a filename, your application's filename + '.json' will be used + Also sets your current dictionary to a blank one. + + :param filename: The name of the file to use. Can be a full path and filename or just filename + :type filename: (str) + :param path: The folder that the settings file will be stored in. Do not include the filename. + :type path: (str) + """ + settings = UserSettings._default_for_function_interface + settings.delete_file(filename, path, report_error=report_error) + + +def user_settings_set_entry(key, value): + """ + Sets an individual setting to the specified value. If no filename has been specified up to this point, + then a default filename will be used. + After value has been modified, the settings file is written to disk. + + :param key: Setting to be saved. Can be any valid dictionary key type + :type key: (Any) + :param value: Value to save as the setting's value. Can be anything + :type value: (Any) + """ + settings = UserSettings._default_for_function_interface + settings.set(key, value) + + + +def user_settings_delete_entry(key, silent_on_error=None): + """ + Deletes an individual entry. If no filename has been specified up to this point, + then a default filename will be used. + After value has been deleted, the settings file is written to disk. + + :param key: Setting to be saved. Can be any valid dictionary key type (hashable) + :type key: (Any) + :param silent_on_error: Determines if an error popup should be shown if an error occurs. Overrides the silent onf effort setting from initialization + :type silent_on_error: (bool) + """ + settings = UserSettings._default_for_function_interface + settings.delete_entry(key, silent_on_error=silent_on_error) + + + +def user_settings_get_entry(key, default=None): + """ + Returns the value of a specified setting. If the setting is not found in the settings dictionary, then + the user specified default value will be returned. It no default is specified and nothing is found, then + None is returned. If the key isn't in the dictionary, then it will be added and the settings file saved. + If no filename has been specified up to this point, then a default filename will be assigned and used. + The settings are SAVED prior to returning. + + :param key: Key used to lookup the setting in the settings dictionary + :type key: (Any) + :param default: Value to use should the key not be found in the dictionary + :type default: (Any) + :return: Value of specified settings + :rtype: (Any) + """ + settings = UserSettings._default_for_function_interface + return settings.get(key, default) + + +def user_settings_save(filename=None, path=None): + """ + Saves the current settings dictionary. If a filename or path is specified in the call, then it will override any + previously specitfied filename to create a new settings file. The settings dictionary is then saved to the newly defined file. + + :param filename: The fFilename to save to. Can specify a path or just the filename. If no filename specified, then the caller's filename will be used. + :type filename: (str) + :param path: The (optional) path to use to save the file. + :type path: (str) + :return: The full path and filename used to save the settings + :rtype: (str) + """ + settings = UserSettings._default_for_function_interface + return settings.save(filename, path) + + +def user_settings_load(filename=None, path=None): + """ + Specifies the path and filename to use for the settings and reads the contents of the file. + The filename can be a full filename including a path, or the path can be specified separately. + If no filename is specified, then the caller's filename will be used with the extension ".json" + + :param filename: Filename to load settings from (and save to in the future) + :type filename: (str) + :param path: Path to the file. Defaults to a specific folder depending on the operating system + :type path: (str) + :return: The settings dictionary (i.e. all settings) + :rtype: (dict) + """ + settings = UserSettings._default_for_function_interface + return settings.load(filename, path) + + +def user_settings_file_exists(filename=None, path=None): + """ + Determines if a settings file exists. If so a boolean True is returned. + If either a filename or a path is not included, then the appropriate default + will be used. + + :param filename: Filename to check + :type filename: (str) + :param path: Path to the file. Defaults to a specific folder depending on the operating system + :type path: (str) + :return: True if the file exists + :rtype: (bool) + """ + settings = UserSettings._default_for_function_interface + return settings.exists(filename=filename, path=path) + + +def user_settings_write_new_dictionary(settings_dict): + """ + Writes a specified dictionary to the currently defined settings filename. + + :param settings_dict: The dictionary to be written to the currently defined settings file + :type settings_dict: (dict) + """ + settings = UserSettings._default_for_function_interface + settings.write_new_dictionary(settings_dict) + + +def user_settings_silent_on_error(silent_on_error=False): + """ + Used to control the display of error messages. By default, error messages are displayed to stdout. + + :param silent_on_error: If True then all error messages are silenced (not displayed on the console) + :type silent_on_error: (bool) + """ + settings = UserSettings._default_for_function_interface + settings.silent_on_error = silent_on_error + + +def user_settings(): + """ + Returns the current settings dictionary. If you've not setup the filename for the + settings, a default one will be used and then read. + :return: The current settings dictionary as a dictionary or a nicely formatted string representing it + :rtype: (dict or str) + """ + settings = UserSettings._default_for_function_interface + return settings.get_dict() + + +def user_settings_object(): + """ + Returns the object that is used for the function version of this API. + With this object you can use the object interface, print it out in a nice format, etc. + + :return: The UserSettings obect used for the function level interface + :rtype: (UserSettings) + """ + return UserSettings._default_for_function_interface + + +''' +'########:'##::::'##:'########::'######::'##::::'##:'########:'########: + ##.....::. ##::'##:: ##.....::'##... ##: ##:::: ##:... ##..:: ##.....:: + ##::::::::. ##'##::: ##::::::: ##:::..:: ##:::: ##:::: ##:::: ##::::::: + ######:::::. ###:::: ######::: ##::::::: ##:::: ##:::: ##:::: ######::: + ##...:::::: ## ##::: ##...:::: ##::::::: ##:::: ##:::: ##:::: ##...:::: + ##:::::::: ##:. ##:: ##::::::: ##::: ##: ##:::: ##:::: ##:::: ##::::::: + ########: ##:::. ##: ########:. ######::. #######::::: ##:::: ########: +........::..:::::..::........:::......::::.......::::::..:::::........:: +:::'###::::'########::'####::'######:: +::'## ##::: ##.... ##:. ##::'##... ##: +:'##:. ##:: ##:::: ##:: ##:: ##:::..:: +'##:::. ##: ########::: ##::. ######:: + #########: ##.....:::: ##:::..... ##: + ##.... ##: ##::::::::: ##::'##::: ##: + ##:::: ##: ##::::::::'####:. ######:: +..:::::..::..:::::::::....:::......::: + + + +These are the functions used to implement the subprocess APIs (Exec APIs) of PySimpleGUI + +''' + + +def execute_command_subprocess(command, *args, wait=False, cwd=None, pipe_output=False, merge_stderr_with_stdout=True, stdin=None): + """ + Runs the specified command as a subprocess. + By default the call is non-blocking. + The function will immediately return without waiting for the process to complete running. You can use the returned Popen object to communicate with the subprocess and get the results. + Returns a subprocess Popen object. + + :param command: The command/file to execute. What you would type at a console to run a program or shell command. + :type command: (str) + :param *args: Variable number of arguments that are passed to the program being started as command line parms + :type *args: (Any) + :param wait: If True then wait for the subprocess to finish + :type wait: (bool) + :param cwd: Working directory to use when executing the subprocess + :type cwd: (str)) + :param pipe_output: If True then output from the subprocess will be piped. You MUST empty the pipe by calling execute_get_results or your subprocess will block until no longer full + :type pipe_output: (bool) + :param merge_stderr_with_stdout: If True then output from the subprocess stderr will be merged with stdout. The result is ALL output will be on stdout. + :type merge_stderr_with_stdout: (bool) + :param stdin: Value passed to the Popen call. Defaults to subprocess.DEVNULL so that the pyinstaller created executable work correctly + :type stdin: (bool) + :return: Popen object + :rtype: (subprocess.Popen) + """ + if stdin is None: + stdin = subprocess.DEVNULL + try: + if args is not None: + expanded_args = ' '.join(args) + # print('executing subprocess command:',command, 'args:',expanded_args) + if command[0] != '"' and ' ' in command: + command = '"' + command + '"' + # print('calling popen with:', command +' '+ expanded_args) + # sp = subprocess.Popen(command +' '+ expanded_args, shell=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, cwd=cwd) + if pipe_output: + if merge_stderr_with_stdout: + sp = subprocess.Popen(command + ' ' + expanded_args, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, cwd=cwd, stdin=stdin) + else: + sp = subprocess.Popen(command + ' ' + expanded_args, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=cwd, stdin=stdin) + else: + sp = subprocess.Popen(command + ' ' + expanded_args, shell=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, cwd=cwd, stdin=stdin) + else: + sp = subprocess.Popen(command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=cwd, stdin=stdin) + if wait: + out, err = sp.communicate() + if out: + print(out.decode("utf-8")) + if err: + print(err.decode("utf-8")) + except Exception as e: + warnings.warn('Error in execute_command_subprocess {}'.format(e), UserWarning) + _error_popup_with_traceback('Error in execute_command_subprocess', e, 'command={}'.format(command), 'args={}'.format(args), 'cwd={}'.format(cwd)) + sp = None + return sp + + +def execute_py_file(pyfile, parms=None, cwd=None, interpreter_command=None, wait=False, pipe_output=False, merge_stderr_with_stdout=True): + """ + Executes a Python file. + The interpreter to use is chosen based on this priority order: + 1. interpreter_command paramter + 2. global setting "-python command-" + 3. the interpreter running running PySimpleGUI + :param pyfile: the file to run + :type pyfile: (str) + :param parms: parameters to pass on the command line + :type parms: (str) + :param cwd: the working directory to use + :type cwd: (str) + :param interpreter_command: the command used to invoke the Python interpreter + :type interpreter_command: (str) + :param wait: the working directory to use + :type wait: (bool) + :param pipe_output: If True then output from the subprocess will be piped. You MUST empty the pipe by calling execute_get_results or your subprocess will block until no longer full + :type pipe_output: (bool) + :param merge_stderr_with_stdout: If True then output from the subprocess stderr will be merged with stdout. The result is ALL output will be on stdout. + :type merge_stderr_with_stdout: (bool) + :return: Popen object + :rtype: (subprocess.Popen) | None + """ + + if cwd is None: + # if the specific file is not found (not an absolute path) then assume it's relative to '.' + if not os.path.exists(pyfile): + cwd = '.' + + if pyfile[0] != '"' and ' ' in pyfile: + pyfile = '"' + pyfile + '"' + if interpreter_command is not None: + python_program = interpreter_command + else: + # use the version CURRENTLY RUNNING if nothing is specified. Previously used the one from the settings file + # ^ hmmm... that's not the code is doing now... it's getting the one from the settings file first + pysimplegui_user_settings.load() # Refresh the settings just in case they've changed via another program + python_program = pysimplegui_user_settings.get('-python command-', '') + if python_program == '': # if no interpreter set in the settings, then use the current one + python_program = sys.executable + # python_program = 'python' if running_windows() else 'python3' + if parms is not None and python_program: + sp = execute_command_subprocess(python_program, pyfile, parms, wait=wait, cwd=cwd, pipe_output=pipe_output, merge_stderr_with_stdout=merge_stderr_with_stdout) + elif python_program: + sp = execute_command_subprocess(python_program, pyfile, wait=wait, cwd=cwd, pipe_output=pipe_output, merge_stderr_with_stdout=merge_stderr_with_stdout) + else: + print('execute_py_file - No interpreter has been configured') + sp = None + return sp + + +def execute_py_get_interpreter(): + """ + Returns Python Interpreter from the system settings. If none found in the settings file + then the currently running interpreter is returned. + + :return: Full path to python interpreter (uses settings file or sys.executable) + :rtype: (str) + """ + pysimplegui_user_settings.load() # Refresh the settings just in case they've changed via another program + interpreter = pysimplegui_user_settings.get('-python command-', '') + if interpreter == '': + interpreter = sys.executable + return interpreter + + +def execute_py_get_running_interpreter(): + """ + Returns the command that is currently running. + + :return: Full path to python interpreter (uses sys.executable) + :rtype: (str) + """ + return sys.executable + + +def execute_editor(file_to_edit, line_number=None): + """ + Runs the editor that was configured in the global settings and opens the file to a specific line number. + Two global settings keys are used. + '-editor program-' the command line used to startup your editor. It's set + in the global settings window or by directly manipulating the PySimpleGUI settings object + '-editor format string-' a string containing 3 "tokens" that describes the command that is executed + + :param file_to_edit: the full path to the file to edit + :type file_to_edit: (str) + :param line_number: optional line number to place the cursor + :type line_number: (int) + :return: Popen object + :rtype: (subprocess.Popen) | None + """ + if file_to_edit is not None and len(file_to_edit) != 0 and file_to_edit[0] not in ('\"', "\'") and ' ' in file_to_edit: + file_to_edit = '"' + file_to_edit + '"' + pysimplegui_user_settings.load() # Refresh the settings just in case they've changed via another program + editor_program = pysimplegui_user_settings.get('-editor program-', None) + if editor_program is not None: + format_string = pysimplegui_user_settings.get('-editor format string-', None) + # if no format string, then just launch the editor with the filename + if not format_string or line_number is None: + sp = execute_command_subprocess(editor_program, file_to_edit) + else: + command = _create_full_editor_command(file_to_edit, line_number, format_string) + # print('final command line = ', command) + sp = execute_command_subprocess(editor_program, command) + else: + print('No editor has been configured in the global settings') + sp = None + return sp + + +def execute_get_results(subprocess_id, timeout=None): + """ + Get the text results of a previously executed execute call + Returns a tuple of the strings (stdout, stderr) + :param subprocess_id: a Popen subprocess ID returned from a previous execute call + :type subprocess_id: (subprocess.Popen) + :param timeout: Time in fractions of a second to wait. Returns '','' if timeout. Default of None means wait forever + :type timeout: (None | float) + :returns: Tuple with 2 strings (stdout, stderr) + :rtype: (str | None , str | None) + """ + + out_decoded = err_decoded = None + if subprocess_id is not None: + try: + out, err = subprocess_id.communicate(timeout=timeout) + if out: + out_decoded = out.decode("utf-8") + if err: + err_decoded = err.decode("utf-8") + except ValueError: + # will get an error if stdout and stderr are combined and attempt to read stderr + # so ignore the error that would be generated + pass + except subprocess.TimeoutExpired: + # a Timeout error is not actually an error that needs to be reported + pass + except Exception as e: + popup_error('Error in execute_get_results', e) + return out_decoded, err_decoded + + +def execute_subprocess_still_running(subprocess_id): + """ + Returns True is the subprocess ID provided is for a process that is still running + + :param subprocess_id: ID previously returned from Exec API calls that indicate this value is returned + :type subprocess_id: (subprocess.Popen) + :return: True if the subproces is running + :rtype: bool + """ + if subprocess_id.poll() == 0: + return False + return True + + +def execute_file_explorer(folder_to_open=''): + """ + The global settings has a setting called - "-explorer program-" + It defines the program to run when this function is called. + The optional folder paramter specified which path should be opened. + + :param folder_to_open: The path to open in the explorer program + :type folder_to_open: str + :return: Popen object + :rtype: (subprocess.Popen) | None + """ + pysimplegui_user_settings.load() # Refresh the settings just in case they've changed via another program + explorer_program = pysimplegui_user_settings.get('-explorer program-', None) + if explorer_program is not None: + sp = execute_command_subprocess(explorer_program, folder_to_open) + else: + print('No file explorer has been configured in the global settings') + sp = None + return sp + + +def execute_find_callers_filename(): + """ + Returns the first filename found in a traceback that is not the name of this file (__file__) + Used internally with the debugger for example. + + :return: filename of the caller, assumed to be the first non PySimpleGUI file + :rtype: str + """ + try: # lots can go wrong so wrapping the entire thing + trace_details = traceback.format_stack() + file_info_pysimplegui, error_message = None, '' + for line in reversed(trace_details): + if __file__ not in line: + file_info_pysimplegui = line.split(",")[0] + error_message = line + break + if file_info_pysimplegui is None: + return '' + error_parts = None + if error_message != '': + error_parts = error_message.split(', ') + if len(error_parts) < 4: + error_message = error_parts[0] + '\n' + error_parts[1] + '\n' + ''.join(error_parts[2:]) + if error_parts is None: + print('*** Error popup attempted but unable to parse error details ***') + print(trace_details) + return '' + filename = error_parts[0][error_parts[0].index('File ') + 5:] + return filename + except: + return '' + + +def _create_full_editor_command(file_to_edit, line_number, edit_format_string): + """ + The global settings has a setting called - "-editor format string-" + It uses 3 "tokens" to describe how to invoke the editor in a way that starts at a specific line # + + + :param file_to_edit: + :type file_to_edit: str + :param edit_format_string: + :type edit_format_string: str + :return: + :rtype: + """ + + command = edit_format_string + command = command.replace('', '') + command = command.replace('', file_to_edit) + command = command.replace('', str(line_number) if line_number is not None else '') + return command + + +def execute_get_editor(): + """ + Get the path to the editor based on user settings or on PySimpleGUI's global settings + + :return: Path to the editor + :rtype: str + """ + try: # in case running with old version of PySimpleGUI that doesn't have a global PSG settings path + global_editor = pysimplegui_user_settings.get('-editor program-') + except: + global_editor = '' + + return user_settings_get_entry('-editor program-', global_editor) + + +''' +'##::::'##::::'###:::::'######::::::'######::'########::'########::'######::'####:'########:'####::'######:: + ###::'###:::'## ##:::'##... ##::::'##... ##: ##.... ##: ##.....::'##... ##:. ##:: ##.....::. ##::'##... ##: + ####'####::'##:. ##:: ##:::..::::: ##:::..:: ##:::: ##: ##::::::: ##:::..::: ##:: ##:::::::: ##:: ##:::..:: + ## ### ##:'##:::. ##: ##::::::::::. ######:: ########:: ######::: ##:::::::: ##:: ######:::: ##:: ##::::::: + ##. #: ##: #########: ##:::::::::::..... ##: ##.....::: ##...:::: ##:::::::: ##:: ##...::::: ##:: ##::::::: + ##:.:: ##: ##.... ##: ##::: ##::::'##::: ##: ##:::::::: ##::::::: ##::: ##:: ##:: ##:::::::: ##:: ##::: ##: + ##:::: ##: ##:::: ##:. ######:::::. ######:: ##:::::::: ########:. ######::'####: ##:::::::'####:. ######:: +..:::::..::..:::::..:::......:::::::......:::..:::::::::........:::......:::....::..::::::::....:::......::: +''' + +''' +The Mac problems have been significant enough to warrant the addition of a series of settings that allow +users to turn specific patches and features on or off depending on their setup. There is not enough information +available to make this process more atuomatic. + +''' + + +# Dictionary of Mac Patches. Used to find the key in the global settings and the default value +MAC_PATCH_DICT = {'Enable No Titlebar Patch' : ('-mac feature enable no titlebar patch-', False), + 'Disable Modal Windows' : ('-mac feature disable modal windows-', True), + 'Disable Grab Anywhere with Titlebar' : ('-mac feature disable grab anywhere with titlebar-', True), + 'Set Alpha Channel to 0.99 for MacOS >= 12.3' : ('-mac feature disable Alpha 0.99', True)} + +def _read_mac_global_settings(): + """ + Reads the settings from the PySimpleGUI Global Settings and sets variables that + are used at runtime to control how certain features behave + """ + + global ENABLE_MAC_MODAL_DISABLE_PATCH + global ENABLE_MAC_NOTITLEBAR_PATCH + global ENABLE_MAC_DISABLE_GRAB_ANYWHERE_WITH_TITLEBAR + global ENABLE_MAC_ALPHA_99_PATCH + + ENABLE_MAC_MODAL_DISABLE_PATCH = pysimplegui_user_settings.get(MAC_PATCH_DICT['Disable Modal Windows'][0], + MAC_PATCH_DICT['Disable Modal Windows'][1]) + ENABLE_MAC_NOTITLEBAR_PATCH = pysimplegui_user_settings.get(MAC_PATCH_DICT['Enable No Titlebar Patch'][0], + MAC_PATCH_DICT['Enable No Titlebar Patch'][1]) + ENABLE_MAC_DISABLE_GRAB_ANYWHERE_WITH_TITLEBAR = pysimplegui_user_settings.get(MAC_PATCH_DICT['Disable Grab Anywhere with Titlebar'][0], + MAC_PATCH_DICT['Disable Grab Anywhere with Titlebar'][1]) + ENABLE_MAC_ALPHA_99_PATCH = pysimplegui_user_settings.get(MAC_PATCH_DICT['Set Alpha Channel to 0.99 for MacOS >= 12.3'][0], + MAC_PATCH_DICT['Set Alpha Channel to 0.99 for MacOS >= 12.3'][1]) + +def _mac_should_apply_notitlebar_patch(): + """ + Uses a combination of the tkinter version number and the setting from the global settings + to determine if the notitlebar patch should be applied + + :return: True if should apply the no titlebar patch on the Mac + :rtype: (bool) + """ + + if not running_mac(): + return False + + try: + tver = [int(n) for n in framework_version.split('.')] + if tver[0] == 8 and tver[1] == 6 and tver[2] < 10 and ENABLE_MAC_NOTITLEBAR_PATCH: + return True + except Exception as e: + warnings.warn('Exception while trying to parse tkinter version {} Error = {}'.format(framework_version, e), UserWarning) + + return False + +def _mac_should_set_alpha_to_99(): + + if not running_mac(): + return False + + if not ENABLE_MAC_ALPHA_99_PATCH: + return False + + # ONLY enable this patch for tkinter version 8.6.12 + if framework_version != '8.6.12': + return False + + # At this point, we're running a Mac and the alpha patch is enabled + # Final check is to see if Mac OS version is 12.3 or later + try: + platform_mac_ver = platform.mac_ver()[0] + mac_ver = platform_mac_ver.split('.') if '.' in platform_mac_ver else (platform_mac_ver, 0) + if (int(mac_ver[0]) >= 12 and int(mac_ver[1]) >= 3) or int(mac_ver[0]) >= 13 : + # print("Mac OS Version is {} and patch enabled so applying the patch".format(platform_mac_ver)) + return True + except Exception as e: + warnings.warn('_mac_should_seet_alpha_to_99 Exception while trying check mac_ver. Error = {}'.format(e), UserWarning) + return False + + return False + + +def main_mac_feature_control(): + """ + Window to set settings that will be used across all PySimpleGUI programs that choose to use them. + Use set_options to set the path to the folder for all PySimpleGUI settings. + + :return: True if settings were changed + :rtype: (bool) + """ + + current_theme = theme() + theme('dark red') + + layout = [[T('Mac PySimpleGUI Feature Control', font='DEFAIULT 18')], + [T('Use this window to enable / disable features.')], + [T('Unfortunately, on some releases of tkinter on the Mac, there are problems that')], + [T('create the need to enable and disable sets of features. This window facilitates the control.')], + [T('Feature Control / Settings', font='_ 16 bold')], + [T('You are running tkinter version:', font='_ 12 bold'), T(framework_version, font='_ 12 bold')]] + + + for key, value in MAC_PATCH_DICT.items(): + layout += [[Checkbox(key, k=value[0], default=pysimplegui_user_settings.get(value[0], value[1]))]] + layout += [[T('Currently the no titlebar patch ' + ('WILL' if _mac_should_apply_notitlebar_patch() else 'WILL NOT') + ' be applied')], + [T('The no titlebar patch will ONLY be applied on tkinter versions < 8.6.10')]] + layout += [[Button('Ok'), Button('Cancel')]] + + window = Window('Mac Feature Control', layout, keep_on_top=True, finalize=True ) + while True: + event, values = window.read() + if event in ('Cancel', WIN_CLOSED): + break + if event == 'Ok': + for key, value in values.items(): + print('setting {} to {}'.format(key, value)) + pysimplegui_user_settings.set(key, value) + break + window.close() + theme(current_theme) + + +''' +'########::'########:'########::'##::::'##::'######::::'######:::'########:'########:: + ##.... ##: ##.....:: ##.... ##: ##:::: ##:'##... ##::'##... ##:: ##.....:: ##.... ##: + ##:::: ##: ##::::::: ##:::: ##: ##:::: ##: ##:::..::: ##:::..::: ##::::::: ##:::: ##: + ##:::: ##: ######::: ########:: ##:::: ##: ##::'####: ##::'####: ######::: ########:: + ##:::: ##: ##...:::: ##.... ##: ##:::: ##: ##::: ##:: ##::: ##:: ##...:::: ##.. ##::: + ##:::: ##: ##::::::: ##:::: ##: ##:::: ##: ##::: ##:: ##::: ##:: ##::::::: ##::. ##:: + ########:: ########: ########::. #######::. ######:::. ######::: ########: ##:::. ##: +........:::........::........::::.......::::......:::::......::::........::..:::::..:: +''' + +##################################################################################################### +# Debugger +##################################################################################################### + + +red_x = b"R0lGODlhEAAQAPeQAIsAAI0AAI4AAI8AAJIAAJUAAJQCApkAAJoAAJ4AAJkJCaAAAKYAAKcAAKcCAKcDA6cGAKgAAKsAAKsCAKwAAK0AAK8AAK4CAK8DAqUJAKULAKwLALAAALEAALIAALMAALMDALQAALUAALYAALcEALoAALsAALsCALwAAL8AALkJAL4NAL8NAKoTAKwbAbEQALMVAL0QAL0RAKsREaodHbkQELMsALg2ALk3ALs+ALE2FbgpKbA1Nbc1Nb44N8AAAMIWAMsvAMUgDMcxAKVABb9NBbVJErFYEq1iMrtoMr5kP8BKAMFLAMxKANBBANFCANJFANFEB9JKAMFcANFZANZcANpfAMJUEMZVEc5hAM5pAMluBdRsANR8AM9YOrdERMpIQs1UVMR5WNt8X8VgYMdlZcxtYtx4YNF/btp9eraNf9qXXNCCZsyLeNSLd8SSecySf82kd9qqc9uBgdyBgd+EhN6JgtSIiNuJieGHhOGLg+GKhOKamty1ste4sNO+ueenp+inp+HHrebGrefKuOPTzejWzera1O7b1vLb2/bl4vTu7fbw7ffx7vnz8f///wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACH5BAEAAJAALAAAAAAQABAAAAjUACEJHEiwYEEABniQKfNFgQCDkATQwAMokEU+PQgUFDAjjR09e/LUmUNnh8aBCcCgUeRmzBkzie6EeQBAoAAMXuA8ciRGCaJHfXzUMCAQgYooWN48anTokR8dQk4sELggBhQrU9Q8evSHiJQgLCIIfMDCSZUjhbYuQkLFCRAMAiOQGGLE0CNBcZYmaRIDLqQFGF60eTRoSxc5jwjhACFWIAgMLtgUocJFy5orL0IQRHAiQgsbRZYswbEhBIiCCH6EiJAhAwQMKU5DjHCi9gnZEHMTDAgAOw==" + + + + +class _Debugger: + debugger = None + DEBUGGER_MAIN_WINDOW_THEME = 'dark grey 13' + DEBUGGER_POPOUT_THEME = 'dark grey 13' + WIDTH_VARIABLES = 23 + WIDTH_RESULTS = 46 + + WIDTH_WATCHER_VARIABLES = 20 + WIDTH_WATCHER_RESULTS = 60 + + WIDTH_LOCALS = 80 + NUM_AUTO_WATCH = 9 + + MAX_LINES_PER_RESULT_FLOATING = 4 + MAX_LINES_PER_RESULT_MAIN = 3 + + DEBUGGER_POPOUT_WINDOW_FONT = 'Sans 8' + DEBUGGER_VARIABLE_DETAILS_FONT = 'Courier 10' + + ''' + # # ###### + ## ## ## # # # # # ###### ##### # # #### #### ###### ##### + # # # # # # # ## # # # # # # # # # # # # # # # + # # # # # # # # # # # ##### ##### # # # # ##### # # + # # ###### # # # # # # # # # # # # ### # ### # ##### + # # # # # # ## # # # # # # # # # # # # # # + # # # # # # # ###### ###### ##### #### #### #### ###### # # + ''' + def __init__(self): + self.watcher_window = None # type: Window + self.popout_window = None # type: Window + self.local_choices = {} + self.myrc = '' + self.custom_watch = '' + self.locals = {} + self.globals = {} + self.popout_choices = {} + + # Includes the DUAL PANE (now 2 tabs)! Don't forget REPL is there too! + def _build_main_debugger_window(self, location=(None, None)): + old_theme = theme() + theme(_Debugger.DEBUGGER_MAIN_WINDOW_THEME) + + def InVar(key1): + row1 = [T(' '), + I(key=key1, size=(_Debugger.WIDTH_VARIABLES, 1)), + T('', key=key1 + 'CHANGED_', size=(_Debugger.WIDTH_RESULTS, 1)), B('Detail', key=key1 + 'DETAIL_'), + B('Obj', key=key1 + 'OBJ_'), ] + return row1 + + variables_frame = [InVar('_VAR0_'), + InVar('_VAR1_'), + InVar('_VAR2_'), ] + + interactive_frame = [[T('>>> '), In(size=(83, 1), key='-REPL-', + tooltip='Type in any "expression" or "statement"\n and it will be disaplayed below.\nPress RETURN KEY instead of "Go"\nbutton for faster use'), + B('Go', bind_return_key=True, visible=True)], + [Multiline(size=(93, 26), key='-OUTPUT-', autoscroll=True, do_not_clear=True)], ] + + autowatch_frame = [[Button('Choose Variables To Auto Watch', key='-LOCALS-'), + Button('Clear All Auto Watches'), + Button('Show All Variables', key='-SHOW_ALL-'), + Button('Locals', key='-ALL_LOCALS-'), + Button('Globals', key='-GLOBALS-'), + Button('Popout', key='-POPOUT-')]] + + var_layout = [] + for i in range(_Debugger.NUM_AUTO_WATCH): + var_layout.append([T('', size=(_Debugger.WIDTH_WATCHER_VARIABLES, 1), key='_WATCH%s_' % i), + T('', size=(_Debugger.WIDTH_WATCHER_RESULTS, _Debugger.MAX_LINES_PER_RESULT_MAIN), key='_WATCH%s_RESULT_' % i,)]) + + col1 = [ + # [Frame('Auto Watches', autowatch_frame+variable_values, title_color='blue')] + [Frame('Auto Watches', autowatch_frame + var_layout, title_color=theme_button_color()[0])] + ] + + col2 = [ + [Frame('Variables or Expressions to Watch', variables_frame, title_color=theme_button_color()[0]), ], + [Frame('REPL-Light - Press Enter To Execute Commands', interactive_frame, title_color=theme_button_color()[0]), ] + ] + + # Tab based layout + layout = [[Text('Debugging: ' + self._find_users_code())], + [TabGroup([[Tab('Variables', col1), Tab('REPL & Watches', col2)]])]] + + # ------------------------------- Create main window ------------------------------- + window = Window("PySimpleGUI Debugger", layout, icon=PSG_DEBUGGER_LOGO, margins=(0, 0), location=location, keep_on_top=True, right_click_menu=[[''], ['Exit', ]]) + + Window._read_call_from_debugger = True + window.finalize() + Window._read_call_from_debugger = False + + window.Element('_VAR1_').SetFocus() + self.watcher_window = window + theme(old_theme) + return window + + ''' + # # ####### # + ## ## ## # # # # # # ###### # # ##### # #### #### ##### + # # # # # # # ## # # # # # ## # # # # # # # # # + # # # # # # # # # ##### # # ##### # # # # # # # # # # # + # # ###### # # # # # # # # # # # # # # # # # ##### + # # # # # # ## # # # # # ## # # # # # # # + # # # # # # # ####### ## ###### # # # ####### #### #### # + ''' + + def _refresh_main_debugger_window(self, mylocals, myglobals): + if not self.watcher_window: # if there is no window setup, nothing to do + return False + event, values = self.watcher_window.read(timeout=1) + if event in (None, 'Exit', '_EXIT_', '-EXIT-'): # EXIT BUTTON / X BUTTON + try: + self.watcher_window.close() + except: + pass + self.watcher_window = None + return False + # ------------------------------- Process events from REPL Tab ------------------------------- + cmd = values['-REPL-'] # get the REPL entered + # BUTTON - GO (NOTE - This button is invisible!!) + if event == 'Go': # GO BUTTON + self.watcher_window.Element('-REPL-').Update('') + self.watcher_window.Element('-OUTPUT-').Update(">>> {}\n".format(cmd), append=True, autoscroll=True) + + try: + result = eval('{}'.format(cmd), myglobals, mylocals) + except Exception as e: + if sys.version_info[0] < 3: + result = 'Not available in Python 2' + else: + try: + result = exec('{}'.format(cmd), myglobals, mylocals) + except Exception as e: + result = 'Exception {}\n'.format(e) + + self.watcher_window.Element('-OUTPUT-').Update('{}\n'.format(result), append=True, autoscroll=True) + # BUTTON - DETAIL + elif event.endswith('_DETAIL_'): # DETAIL BUTTON + var = values['_VAR{}_'.format(event[4])] + try: + result = str(eval(str(var), myglobals, mylocals)) + except: + result = '' + old_theme = theme() + theme(_Debugger.DEBUGGER_MAIN_WINDOW_THEME) + popup_scrolled(str(values['_VAR{}_'.format(event[4])]) + '\n' + result, title=var, non_blocking=True, font=_Debugger.DEBUGGER_VARIABLE_DETAILS_FONT) + theme(old_theme) + # BUTTON - OBJ + elif event.endswith('_OBJ_'): # OBJECT BUTTON + var = values['_VAR{}_'.format(event[4])] + try: + result = ObjToStringSingleObj(mylocals[var]) + except Exception as e: + try: + result = eval('{}'.format(var), myglobals, mylocals) + result = ObjToStringSingleObj(result) + except Exception as e: + result = '{}\nError showing object {}'.format(e, var) + old_theme = theme() + theme(_Debugger.DEBUGGER_MAIN_WINDOW_THEME) + popup_scrolled(str(var) + '\n' + str(result), title=var, non_blocking=True, font=_Debugger.DEBUGGER_VARIABLE_DETAILS_FONT) + theme(old_theme) + # ------------------------------- Process Watch Tab ------------------------------- + # BUTTON - Choose Locals to see + elif event == '-LOCALS-': # Show all locals BUTTON + self._choose_auto_watches(mylocals) + # BUTTON - Locals (quick popup) + elif event == '-ALL_LOCALS-': + self._display_all_vars('All Locals', mylocals) + # BUTTON - Globals (quick popup) + elif event == '-GLOBALS-': + self._display_all_vars('All Globals', myglobals) + # BUTTON - clear all + elif event == 'Clear All Auto Watches': + if popup_yes_no('Do you really want to clear all Auto-Watches?', 'Really Clear??') == 'Yes': + self.local_choices = {} + self.custom_watch = '' + # BUTTON - Popout + elif event == '-POPOUT-': + if not self.popout_window: + self._build_floating_window() + # BUTTON - Show All + elif event == '-SHOW_ALL-': + for key in self.locals: + self.local_choices[key] = not key.startswith('_') + + # -------------------- Process the manual "watch list" ------------------ + for i in range(3): + key = '_VAR{}_'.format(i) + out_key = '_VAR{}_CHANGED_'.format(i) + self.myrc = '' + if self.watcher_window.Element(key): + var = values[key] + try: + result = eval(str(var), myglobals, mylocals) + except: + result = '' + self.watcher_window.Element(out_key).Update(str(result)) + else: + self.watcher_window.Element(out_key).Update('') + + # -------------------- Process the automatic "watch list" ------------------ + slot = 0 + for key in self.local_choices: + if key == '-CUSTOM_WATCH-': + continue + if self.local_choices[key]: + self.watcher_window.Element('_WATCH{}_'.format(slot)).Update(key) + try: + self.watcher_window.Element('_WATCH{}_RESULT_'.format(slot), silent_on_error=True).Update(mylocals[key]) + except: + self.watcher_window.Element('_WATCH{}_RESULT_'.format(slot)).Update('') + slot += 1 + + if slot + int(not self.custom_watch in (None, '')) >= _Debugger.NUM_AUTO_WATCH: + break + # If a custom watch was set, display that value in the window + if self.custom_watch: + self.watcher_window.Element('_WATCH{}_'.format(slot)).Update(self.custom_watch) + try: + self.myrc = eval(self.custom_watch, myglobals, mylocals) + except: + self.myrc = '' + self.watcher_window.Element('_WATCH{}_RESULT_'.format(slot)).Update(self.myrc) + slot += 1 + # blank out all of the slots not used (blank) + for i in range(slot, _Debugger.NUM_AUTO_WATCH): + self.watcher_window.Element('_WATCH{}_'.format(i)).Update('') + self.watcher_window.Element('_WATCH{}_RESULT_'.format(i)).Update('') + + return True # return indicating the window stayed open + + def _find_users_code(self): + try: # lots can go wrong so wrapping the entire thing + trace_details = traceback.format_stack() + file_info_pysimplegui, error_message = None, '' + for line in reversed(trace_details): + if __file__ not in line: + file_info_pysimplegui = line.split(",")[0] + error_message = line + break + if file_info_pysimplegui is None: + return '' + error_parts = None + if error_message != '': + error_parts = error_message.split(', ') + if len(error_parts) < 4: + error_message = error_parts[0] + '\n' + error_parts[1] + '\n' + ''.join(error_parts[2:]) + if error_parts is None: + print('*** Error popup attempted but unable to parse error details ***') + print(trace_details) + return '' + filename = error_parts[0][error_parts[0].index('File ') + 5:] + return filename + except: + return + ''' + ###### # # + # # #### ##### # # ##### # # # # # # ##### #### # # + # # # # # # # # # # # # # # ## # # # # # # # + ###### # # # # # # # # # # # # # # # # # # # # # + # # # ##### # # ##### # # # # # # # # # # # # ## # + # # # # # # # # # # # # ## # # # # ## ## + # #### # #### # ## ## # # # ##### #### # # + + ###### # # # + # # # # # # ##### #### # # # # # # ## ##### #### + # # # # ## ## # # # # # # # # # # # # # # + # # # # # ## # # # #### # # # # # # # # # # #### + # # # # # # ##### # ####### # # # # ###### ##### # + # # # # # # # # # # # # # # # # # # # # # + ###### #### # # # #### # # ###### ###### # # # # # #### + ''' + # displays them into a single text box + + def _display_all_vars(self, title, dict): + num_cols = 3 + output_text = '' + num_lines = 2 + cur_col = 0 + out_text = title + '\n' + longest_line = max([len(key) for key in dict]) + line = [] + sorted_dict = {} + for key in sorted(dict.keys()): + sorted_dict[key] = dict[key] + for key in sorted_dict: + value = dict[key] + # wrapped_list = textwrap.wrap(str(value), 60) + # wrapped_text = '\n'.join(wrapped_list) + wrapped_text = str(value) + out_text += '{} - {}\n'.format(key, wrapped_text) + # if cur_col + 1 == num_cols: + # cur_col = 0 + # num_lines += len(wrapped_list) + # else: + # cur_col += 1 + old_theme = theme() + theme(_Debugger.DEBUGGER_MAIN_WINDOW_THEME) + popup_scrolled(out_text, title=title, non_blocking=True, font=_Debugger.DEBUGGER_VARIABLE_DETAILS_FONT, keep_on_top=True, icon=PSG_DEBUGGER_LOGO) + theme(old_theme) + + ''' + ##### # # + # # # # #### #### #### ###### # # # ## ##### #### # # + # # # # # # # # # # # # # # # # # # # + # ###### # # # # #### ##### # # # # # # # ###### + # # # # # # # # # # # # ###### # # # # + # # # # # # # # # # # # # # # # # # # # # + ##### # # #### #### #### ###### ## ## # # # #### # # + + # # # # + # # ## ##### # ## ##### # ###### #### # # # # # # + # # # # # # # # # # # # # # # # # # ## # + # # # # # # # # # ##### # ##### #### # # # # # # # + # # ###### ##### # ###### # # # # # # # # # # # # + # # # # # # # # # # # # # # # # # # # # ## + # # # # # # # # ##### ###### ###### #### ## ## # # # + ''' + + def _choose_auto_watches(self, my_locals): + old_theme = theme() + theme(_Debugger.DEBUGGER_MAIN_WINDOW_THEME) + num_cols = 3 + output_text = '' + num_lines = 2 + cur_col = 0 + layout = [[Text('Choose your "Auto Watch" variables', font='ANY 14', text_color='red')]] + longest_line = max([len(key) for key in my_locals]) + line = [] + sorted_dict = {} + for key in sorted(my_locals.keys()): + sorted_dict[key] = my_locals[key] + for key in sorted_dict: + line.append(CB(key, key=key, size=(longest_line, 1), + default=self.local_choices[key] if key in self.local_choices else False)) + if cur_col + 1 == num_cols: + cur_col = 0 + layout.append(line) + line = [] + else: + cur_col += 1 + if cur_col: + layout.append(line) + + layout += [ + [Text('Custom Watch (any expression)'), Input(default_text=self.custom_watch, size=(40, 1), key='-CUSTOM_WATCH-')]] + layout += [ + [Ok(), Cancel(), Button('Clear All'), Button('Select [almost] All', key='-AUTO_SELECT-')]] + + window = Window('Choose Watches', layout, icon=PSG_DEBUGGER_LOGO, finalize=True, keep_on_top=True) + + while True: # event loop + event, values = window.read() + if event in (None, 'Cancel', '-EXIT-'): + break + elif event == 'Ok': + self.local_choices = values + self.custom_watch = values['-CUSTOM_WATCH-'] + break + elif event == 'Clear All': + popup_quick_message('Cleared Auto Watches', auto_close=True, auto_close_duration=3, non_blocking=True, text_color='red', font='ANY 18') + for key in sorted_dict: + window.Element(key).Update(False) + window.Element('-CUSTOM_WATCH-').Update('') + elif event == 'Select All': + for key in sorted_dict: + window.Element(key).Update(False) + elif event == '-AUTO_SELECT-': + for key in sorted_dict: + window.Element(key).Update(not key.startswith('_')) + + # exited event loop + window.Close() + theme(old_theme) + + + ''' + ###### ####### + # # # # # # ##### # # #### ## ##### # # # #### + # # # # # # # # # # # # # # # # ## # # # + ###### # # # # # # ##### # # # # # # # # # # # + # # # # # # # # # # # # ###### # # # # # # ### + # # # # # # # # # # # # # # # # # ## # # + ###### #### # ###### ##### # ###### #### # # # # # # #### + + # # + # # # # # # ##### #### # # + # # # # ## # # # # # # # + # # # # # # # # # # # # # + # # # # # # # # # # # # ## # + # # # # # ## # # # # ## ## + ## ## # # # ##### #### # # + ''' + + def _build_floating_window(self, location=(None, None)): + """ + + :param location: + :type location: + + """ + if self.popout_window: # if floating window already exists, close it first + self.popout_window.Close() + old_theme = theme() + theme(_Debugger.DEBUGGER_POPOUT_THEME) + num_cols = 2 + width_var = 15 + width_value = 30 + layout = [] + line = [] + col = 0 + # self.popout_choices = self.local_choices + self.popout_choices = {} + if self.popout_choices == {}: # if nothing chosen, then choose all non-_ variables + for key in sorted(self.locals.keys()): + self.popout_choices[key] = not key.startswith('_') + + width_var = max([len(key) for key in self.popout_choices]) + for key in self.popout_choices: + if self.popout_choices[key] is True: + value = str(self.locals.get(key)) + h = min(len(value) // width_value + 1, _Debugger.MAX_LINES_PER_RESULT_FLOATING) + line += [Text('{}'.format(key), size=(width_var, 1), font=_Debugger.DEBUGGER_POPOUT_WINDOW_FONT), + Text(' = ', font=_Debugger.DEBUGGER_POPOUT_WINDOW_FONT), + Text(value, key=key, size=(width_value, h), font=_Debugger.DEBUGGER_POPOUT_WINDOW_FONT)] + if col + 1 < num_cols: + line += [VerticalSeparator(), T(' ')] + col += 1 + if col >= num_cols: + layout.append(line) + line = [] + col = 0 + if col != 0: + layout.append(line) + layout = [[T(SYMBOL_X, enable_events=True, key='-EXIT-', font='_ 7')], [Column(layout)]] + + Window._read_call_from_debugger = True + self.popout_window = Window('Floating', layout, alpha_channel=0, no_titlebar=True, grab_anywhere=True, + element_padding=(0, 0), margins=(0, 0), keep_on_top=True, + right_click_menu=['&Right', ['Debugger::RightClick', 'Exit::RightClick']], location=location, finalize=True) + Window._read_call_from_debugger = False + + if location == (None, None): + screen_size = self.popout_window.GetScreenDimensions() + self.popout_window.Move(screen_size[0] - self.popout_window.Size[0], 0) + self.popout_window.SetAlpha(1) + theme(old_theme) + return True + + ''' + ###### + # # ###### ###### ##### ###### #### # # + # # # # # # # # # # + ###### ##### ##### # # ##### #### ###### + # # # # ##### # # # # + # # # # # # # # # # # + # # ###### # # # ###### #### # # + + ####### + # # #### ## ##### # # # #### + # # # # # # # # ## # # # + ##### # # # # # # # # # # # + # # # # ###### # # # # # # ### + # # # # # # # # # ## # # + # ###### #### # # # # # # #### + + # # + # # # # # # ##### #### # # + # # # # ## # # # # # # # + # # # # # # # # # # # # # + # # # # # # # # # # # # ## # + # # # # # ## # # # # ## ## + ## ## # # # ##### #### # # + ''' + + def _refresh_floating_window(self): + if not self.popout_window: + return + for key in self.popout_choices: + if self.popout_choices[key] is True and key in self.locals: + if key is not None and self.popout_window is not None: + self.popout_window.Element(key, silent_on_error=True).Update(self.locals.get(key)) + event, values = self.popout_window.read(timeout=5) + if event in (None, '_EXIT_', 'Exit::RightClick', '-EXIT-'): + self.popout_window.Close() + self.popout_window = None + elif event == 'Debugger::RightClick': + show_debugger_window() + + +# 888 888 .d8888b. d8888 888 888 888 888 +# 888 888 d88P Y88b d88888 888 888 888 888 +# 888 888 888 888 d88P888 888 888 888 888 +# 888 888 .d8888b .d88b. 888d888 888 d88P 888 888 888 8888b. 88888b. 888 .d88b. +# 888 888 88K d8P Y8b 888P" 888 d88P 888 888 888 "88b 888 "88b 888 d8P Y8b +# 888 888 "Y8888b. 88888888 888 888 888 d88P 888 888 888 .d888888 888 888 888 88888888 +# Y88b. .d88P X88 Y8b. 888 Y88b d88P d8888888888 888 888 888 888 888 d88P 888 Y8b. +# "Y88888P" 88888P' "Y8888 888 "Y8888P" d88P 888 888 888 "Y888888 88888P" 888 "Y8888 + +# 8888888888 888 d8b +# 888 888 Y8P +# 888 888 +# 8888888 888 888 88888b. .d8888b 888888 888 .d88b. 88888b. .d8888b +# 888 888 888 888 "88b d88P" 888 888 d88""88b 888 "88b 88K +# 888 888 888 888 888 888 888 888 888 888 888 888 "Y8888b. +# 888 Y88b 888 888 888 Y88b. Y88b. 888 Y88..88P 888 888 X88 +# 888 "Y88888 888 888 "Y8888P "Y888 888 "Y88P" 888 888 88888P' + + +def show_debugger_window(location=(None, None), *args): + """ + Shows the large main debugger window + :param location: Locations (x,y) on the screen to place upper left corner of the window + :type location: (int, int) + :return: None + :rtype: None + """ + if _Debugger.debugger is None: + _Debugger.debugger = _Debugger() + debugger = _Debugger.debugger + frame = inspect.currentframe() + prev_frame = inspect.currentframe().f_back + # frame, *others = inspect.stack()[1] + try: + debugger.locals = frame.f_back.f_locals + debugger.globals = frame.f_back.f_globals + finally: + del frame + + if not debugger.watcher_window: + debugger.watcher_window = debugger._build_main_debugger_window(location=location) + return True + + +def show_debugger_popout_window(location=(None, None), *args): + """ + Shows the smaller "popout" window. Default location is the upper right corner of your screen + + :param location: Locations (x,y) on the screen to place upper left corner of the window + :type location: (int, int) + :return: None + :rtype: None + """ + if _Debugger.debugger is None: + _Debugger.debugger = _Debugger() + debugger = _Debugger.debugger + frame = inspect.currentframe() + prev_frame = inspect.currentframe().f_back + # frame = inspect.getframeinfo(prev_frame) + # frame, *others = inspect.stack()[1] + try: + debugger.locals = frame.f_back.f_locals + debugger.globals = frame.f_back.f_globals + finally: + del frame + if debugger.popout_window: + debugger.popout_window.Close() + debugger.popout_window = None + debugger._build_floating_window(location=location) + + +def _refresh_debugger(): + """ + Refreshes the debugger windows. USERS should NOT be calling this function. Within PySimpleGUI it is called for the USER every time the Window.Read function is called. + + :return: return code False if user closed the main debugger window. + :rtype: (bool) + """ + if _Debugger.debugger is None: + _Debugger.debugger = _Debugger() + debugger = _Debugger.debugger + Window._read_call_from_debugger = True + rc = None + # frame = inspect.currentframe() + # frame = inspect.currentframe().f_back + + frame, *others = inspect.stack()[1] + try: + debugger.locals = frame.f_back.f_locals + debugger.globals = frame.f_back.f_globals + finally: + del frame + if debugger.popout_window: + rc = debugger._refresh_floating_window() + if debugger.watcher_window: + rc = debugger._refresh_main_debugger_window(debugger.locals, debugger.globals) + Window._read_call_from_debugger = False + return rc + + +def _debugger_window_is_open(): + """ + Determines if one of the debugger window is currently open + :return: returns True if the popout window or the main debug window is open + :rtype: (bool) + """ + + if _Debugger.debugger is None: + return False + debugger = _Debugger.debugger + if debugger.popout_window or debugger.watcher_window: + return True + return False + + +def get_versions(): + """ + Returns a human-readable string of version numbers for: + + Python version + Platform (Win, Mac, Linux) + Platform version (tuple with information from the platform module) + PySimpleGUI Port (PySimpleGUI in this case) + tkinter version + PySimpleGUI version + The location of the PySimpleGUI.py file + + The format is a newline between each value and descriptive text for each line + + :return: + :rtype: str + """ + if running_mac(): + platform_name, platform_ver = 'Mac', platform.mac_ver() + elif running_windows(): + platform_name, platform_ver = 'Windows', platform.win32_ver() + elif running_linux(): + platform_name, platform_ver = 'Linux', platform.libc_ver() + else: + platform_name, platform_ver = 'Unknown platorm', 'Unknown platform version' + + versions = "Python Interpeter: {}\nPython version: {}.{}.{}\nPlatform: {}\nPlatform version: {}\nPort: {}\ntkinter version: {}\nPySimpleGUI version: {}\nPySimpleGUI filename: {}".format(sys.executable, sys.version_info.major, + sys.version_info.minor, + sys.version_info.micro, + platform_name, platform_ver, + port, + tclversion_detailed, + ver, + __file__) + return versions + + +def scheck_hh(): + with open(__file__, "r",encoding="utf8") as file: + lines_in_file = file.readlines() + combined_lines = ''.join(lines_in_file[:-1]) + entire_file_bytes = bytearray(combined_lines, encoding='utf8') + cfileh = hh(entire_file_bytes) + return cfileh.hexdigest() + + +def read_last_line(): + with open(__file__, "r",encoding="utf8") as file: + last_line = file.readlines()[-1] + return last_line + +# ==================================================# +# +# MM""""""""`M oo oo +# MM mmmmmmmM +# M` MMMM 88d8b.d8b. .d8888b. dP dP +# MM MMMMMMMM 88'`88'`88 88' `88 88 88 +# MM MMMMMMMM 88 88 88 88. .88 88 88 +# MM .M dP dP dP `88888P' 88 dP +# MMMMMMMMMMMM 88 +# dP +# M""MMM""MMM""M dP dP +# M MMM MMM M 88 88 +# M MMP MMP M .d8888b. 88d888b. 88 .d888b88 +# M MM' MM' .M 88' `88 88' `88 88 88' `88 +# M `' . '' .MM 88. .88 88 88 88. .88 +# M .d .dMMM `88888P' dP dP `88888P8 +# MMMMMMMMMMMMMM +# +# When things look bleak, show your user an emoji +# and a little hope +# ==================================================# + + +EMOJI_BASE64_FACEPALM = b'iVBORw0KGgoAAAANSUhEUgAAADgAAAA4CAYAAACohjseAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyJpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuMy1jMDExIDY2LjE0NTY2MSwgMjAxMi8wMi8wNi0xNDo1NjoyNyAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENTNiAoV2luZG93cykiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6REQ2OTE2M0Q2RENEMTFFQkEwODdCNUZBQjI1NEUxQTAiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6REQ2OTE2M0U2RENEMTFFQkEwODdCNUZBQjI1NEUxQTAiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDpERDY5MTYzQjZEQ0QxMUVCQTA4N0I1RkFCMjU0RTFBMCIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDpERDY5MTYzQzZEQ0QxMUVCQTA4N0I1RkFCMjU0RTFBMCIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PrG3klYAABf+SURBVHjaxFoJcFzV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' +EMOJI_BASE64_FRUSTRATED = b'iVBORw0KGgoAAAANSUhEUgAAADgAAAA4CAYAAACohjseAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyJpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuMy1jMDExIDY2LjE0NTY2MSwgMjAxMi8wMi8wNi0xNDo1NjoyNyAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENTNiAoV2luZG93cykiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6Q0YzMDVFRjE2Q0U4MTFFQkFGM0JBRTY5MjFEM0EyRkMiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6Q0YzMDVFRjI2Q0U4MTFFQkFGM0JBRTY5MjFEM0EyRkMiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDpDRjMwNUVFRjZDRTgxMUVCQUYzQkFFNjkyMUQzQTJGQyIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDpDRjMwNUVGMDZDRTgxMUVCQUYzQkFFNjkyMUQzQTJGQyIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PrCGivkAABgCSURBVHja3FpneFTXmX7vvdNnNDMqoy4hid6NMMhgSrAJchYCCeZJcOwkttc4LmsHHCdOnuwmm002zeuyxsRx7OAQxymOCxjnoYNtwGB6l0ACJFRGbXrRlFv2O/eO+giD7d0fq+e5mn7uec/3fu/3fudeTlEU/H/+0+3du/dTDyLTGg1eJlkGeHrTLtBnCgzsXPSSp0eODoX+SfQsyXMQgxK9oDd4buAY7OXg9673j/sMF2tUHlBmBsay504TSnIykVVgg0Wnh5njoOd5db4cAZZpUSRZQjIpItoaRLTdi46IiEZagQvNQF0SuEzjtH7aSX1igATEUChgtt2ApVPKMbc0H6MmTTE5y0e6YLVnweEwwmH1w8B1QM/5wanh6IkfC7EW5QQhiXUD/gAQCmtHzQVCeRHuhlacP3cFW90JbOnmcSoq/x8AzBJgL+Tx9fIc3Pe5Wbjh1kVFGDttAkyFkwlyLiDSDGONQPAEEL0CJAND+Tv47Hy/o+ePfuPtAE6fAbbvQeLwSexpCGJdq4TNEfl/ASDj1jgzFo924hdfXmyaXH37dORPuhmwjCEQlESBk3TsByJnCaTYN/qnSQIdHXo6KMrnaNg/vQEcPYl3zoTwRFsCtbLyGQAU6BsuAaUFHFaPLcK3f/brmfzIxSvok3KKkh8J3yEkfe9BjLVApBBInAkiZyAFEYiFfO9JJPYZvdc3d/ZtpTe4gvqpREokao/0uY4e9UhAr6OQ2XiE2pMqyHffhrcxhH9vkLCBohn8VADHmTB3SlnuG8VVt+Q2nvwIhS4j8qdWga+8BVsrbkYXl4AUEwmUSZ0Wm6bIadNT+g0vpwHI9eNuH0B1BO25oEAwCrC0X0Du4U0oPLcLwdOXIeRNhyu/ADXvbTlwpCuxzCOi82okGPbPysFQbDc8s/jxn+bGYzHwOh1GT52BUFcnzv/tLwhGXsfl6ocRnfcFVTQgfgZa3LMmrLB0K8h+ax0mHXsXY4pyUH7jQxCqFNQeO4D5K+5EbnHpLO+6tT8ISHhMVD5BBMfo8PnPV8/bXv3Q97Hx1fWw5xXBZLXBYDQgOysL8dZG7N38JuonLUR81behN5vAS0lSTS06nEpxsfckQoqoIhGPfcq+Iyu8Gm2oz6kwss8MBsQbW+B8/seYJHlQtWwlYjoT/D6fWlu87lY4bBbcfMsivPr9+zt3nG+fElTQdl0RZLnnMGBVxY0349yZM1DMGQhHoyTjYTD309zYCIPJDPu0uXAd2oO5z7+O763hoVeSqsgoslbseJqQ5pa4FECkyMlrFCX1knkdBLWEEGiTHm1tevzbkwpalQoYKm/C6YsNEJNJ+lhbKp4Ka1cogvauLoyYOtOVW795WVDEi9cFMFeHEYV59oV6pwv1dfXQ6/Up5H15pEgijHrKtaJyHD7pw4++7YGg10RVTtU5SRpYJTSoiio7PerM85IKkIwADPoYQiQbjfx4mEryYWALQAPpdAOnKgg6XKyvh2vUeJRYNn/DHcZLJDjyNQPMF7Aoq7gs0xuOIhGP04DC0NJBM/L6/FT3QpAlGYc788iDCeBUv8IOXivwDBI3NBs4RZuPGuHUoRAYQUrAZAugOxJGMBSCPSNDBTn43H6vF/aiAjiyHdPt0cAkAnjqmgAyeuo5fNGYXQi/39dLjcEnCBNdu+gkxkAbknYX+OxiCpt03ck++DMGWGo6B13Yi7Z2Ayxms7rAgxsDmc7lj3QjI6/QmN8WmO8WhwLk09JTj7wcGz9DysiiVYyqYAZMiABLxL3Wtjbw3WHVa0kO5mISGsBPebD1TDryoPO1Id4dRUdHR9pFZu91d3dDoEAYOCxLB2YI7zIE6CodwvPZ0+febMotIcpIaaPX1dlF/jEAk68VSWsmFJNNo9ln8cfGMdN4IR90nIKwooOVFNpoMg2IIgMokw7w5H1NUmSELtDV1iXiqHy1CJJpGGMvLvmafdRkNR/SrZpIKukLBKFLdENhUTNZIcTC4Iah5/UDJFtAY8tWBwR/O0VVpFz3DW8jeTLIk2cJI3IyHs7gB6bdEIA0xUyd1a7TopE+IoyeTLCMYQ98Y2ehfvkT6Ki8DZLRQkAjnxgoEx0hHlXFSR33jn+Hr6ISumAXRFm+SsBpQfTkDIxmGz21XVVkrDzRWdBfU5crxbth7WpCVu1+XFnwDVxe/BDyP3oHBR++BUPIQ4DN1+y2hUQMksEE96zlaJl3h2oEivf9Deb2Rgi6j3HtFAyOygbH80atk4N/WIBFRhj5NCVhKFV74ssh++wHyKw9gFaaXMOSf4F79nJUbHwWrhM7oFBRVnjhqpMTiI6Bimm4uHwNQqUTUbzrNZTuWg99NACF5sLJSTX3rra9wlKH44We/uOqKqpa/HSq1V/GWVvOxlPokPSU/ESrkvdfQ+VTd8HsbUbNAz9H3e1PaHVRSm9SOaIdL8bR/Lm7cGLNS6pYTV37AEZtfIqoSspstEIhgyGzjvhjF5zrWQD5qgCbEvDEacC4KKm85gTdkIGYBVOIUuqgvK43pCIpqbnzCia98CgKd7wB98IVqPn6zyCzcQaBZPnGInN5yaO4uHINss4ewA3P3gdn3WEax6pFXVF9GZRkXBW8wcVetXY0P2bdIlQuFEmKSgo8acsEK+4TrFjuAH4pBUOFgdZWiFREefrAYLVq0SJgDFSUOouAuxkcgZSpVvaUByYuzHsKyW5k1XyIuK0AnfOqEbOV0Ov94KkV0ritqNFr+vx9uLL0HthrTmHCK9+Dyd9KjEgxjAFMpYGeOSWbExkOh2bZaAxeZ6ASkUTY7UZnbS3aTx1FzOMx2nl5eoYBJz1JrYXq5eF4C2aMsmHvnV+FMTsbaGmhLroWqG0QEBUK4Zo4CfbSMrUGNl5pQvflGshESykzX5V0FqGEw4VA2VR4x8+mXJqEuDMXYmaG2vZUPvNN2FpqKZpGdSFEMu/HH30FsdJC8L44TOSGrC31qmA5Lh6D2dOsEkyk6BhaLkDKKUIe+U5XrgtJipa3rhb++hrkmDyYOAYYO4oac/IaJ8nLvL0Vp8/FMa8rAb8K0CLAOMOAfQ+sxI0rH+z1wepj8xVg527gnW1kWKyjYBkzDp2BEPQtNUhmFqkUCo6YhLablsE7YQ5Eox3GYLuqfgyQte0STDRZS3sD0VLqW1OKYiynGN2uUkRzyxAuGotI/kjE2IIRdR31R1FAipxJTMCV8zDYHZBLxsFFAfadOoYieydWLAVmz6K65uqXcJSuP/4hsOUYnj0axxr1bOMMuHfM1NLff+M/p2CW6TgKxZY+Ehu0o/4c8OIrwKHTeggOoqXZgjDVqOYFX0e4ZDTMbY1EwwME6gK5G7da+FNOWxUgFrlel9LT9pBJ6K2ZzH9SmUg48xHJK4dvXBUt3GRaHDcK/v5fyKl5nz63wdTdjupbgVV3ketiwOIDG+0LpnHYWjca7/5wT/x0W3g65xCQUWXB7safvnrj+bl3oSDixkzlEL6ivI7b5K3Igre3oFwk1uzaA2x+BwjmjIG/chHi9hwCtk+te6xAM3FgyspAsUmrIFjXwJoF+kwmyrGmmGN9FMsxJlo6Y29u8uRaVEFigEm0vKOmI0kdTf7uP2JEdjeWrwDmzCQ6ZvUBu8yVYxO3DG/yt+MEdwPCNhtmr/tncH9c/xturgn38xPH/+bAs0eFBG8eILIVyiV8U96AVcpLKIAbLR00mBt47jnAbR4HzpkNPklCQwk/uNYxYAxkuHg8AqOmIVQ0Xs1JJiIMgCHkhc19nvLtODIaTkOgcsHKzRD5D/qQ8HpURlSND+Pue4ARTmrGCeAZZRLW8o/gDX4FhSFrAOsyL51C1WNVzTqLgsftlSMEq5lEYlC5ucRV4MfCT/A77lt4JPYUqmO/w85tYbSGHeCKXFpbYzAP9YYk61HKp4bFD8IzbjYUsw5I47S6uPnqlqDj0nGUbXkRzotHB4JkpYdFnqLOfOmx092YekhC94JCvMZ9H+uFexGBdejAZI8Lyq2wVuQVC0VmPPeT5fVYXfR3GKjRbODKEELGgO+z1zst1TjRUYzQho0Im7LAUQ5yzP8NBkf0i1IOnb7/OYRGTyCh4jUqSalD6fc8lX7xfConkxfC3nCW1LNJpbja3jPPG/CpWwPqe2Ty3cEMPP+l3dhmXIKkMvT8E5Rz+IH8CzwrrIZ0uRV82QiguJToKNbjSem7OCzOwFPSd3CDcmJgR0oMjO3ZizBvgWI0QezqhBwOaXsS6OngOVU02mYuQzwvV1U0FRBjr0kzUTzbq+dTr3Wpz2OEwWZCy/yv0e9FdUwlHoPo66JuRUyNS4Xe6YK/NQj52HEy1n2CbCSlWaRsx1+llTgkzsQa+RlkKx2YOJFOUUHgXNl9CVuotOIx5Wk8Iq/F+0Shv/B3YLtwK+KNQeTvfBWxvDLKIRKScBCSt4vyj2bJjC7zrywPqcgj7NfUV9uAoZYnhJyP/oGc49uhD5IJJ6fio5LSMed2JPLztO+xCYf8ENvcKu2VlND0KC5roTi7k4D7UPb2r9E1+4soFpvwZe5dfFX+K6YqJ4e0RYX5BHDcaPqnG9oZ6Sk5FnI7sTC0E/t8Lmx4y4rznXHoLQHwOWWQImHNtrIVZrto/Taicre8TPUsjyS/gFzKfmQf/AcsDWc0oVTVVUbGoW3I3boe3puWwDdlAVE7jqI3n4bE6CgmexnRey3OYIBAB5htO30a391YhdsrGzExN5ambdcAZpMYcYeegjJj2vCbJQ2kmo1U7F9+iRK3GDh8kqI3aiKSvAly0K9SCermEdfnD5llIxvXa84pwr11sP/wBISpbW8HxtSYBKV3Z4A9sm6C8l1wZKpbGKaOSygpIXNCQbl3Fc2JamFudj9zMriBZ6mSvk0GIhFiGxXS02TZOJrf1+4AvH4JtTVnoSdqKY5sSFRzZDLmSiLeF01OUZvfIdsQg7sS1okMMPOcJi4EkiNacQYj4eOphMSgkJuRfR7c9Dmgqgr4LS14Ey0+yQGy7andzDTdlM6RMXwL0hHQrt8dJ72ZQAnL5nzvvcC+fRI+PNiKyKVWJPVWSGYbOAsNZKJVECxUEbSdT2bO1VyS5fSbA4yCBID6ONXEa1dz6ddsoYgZiq8D+kSEohxDHuXTgsXA9OlaSpElBTEVpRRNbzBV+NMBNJnSU7ObaQUdbW3kWgho5VQ6J0XTTGVv2ReBdurTzpznMMUWQSIYQZenHWRBEVfIyjGqMTdDjywaakTYth+v7ZWqlFZ3hQlIUlR34zjV3dBqJhOw6CXYKSr5NPkwdfMtSQNmz5Ew9waJ2iJtipWUVvv2AwsXUj0lgNmO9Je7dUZjGuT0xa7UdUsWPRblKeOpw+jSvhuLa6IrOHlUV/Moy5HRQZ8FqWp4upLqwfaIAgFtURIJDYecEkY+xURWRslewkLMcJIgZGYCOS7tkToj5FBUdp3h0XKER0KS1XHU39Nvy8qATWQZGxqok6BugtYYTjuGGApdWuowELSYTeQ9DxxEgPrfC5s2Y8YcMh6UbmoZsxpS+zKiogZDnRwle0X5wKEY29jE2KMka2NzPQApuGyBBWFgirLnrLwyMVUXhZ7bTNrFHPb9AEVs21YopBF7t27D3MJCcCFbCuA1bd1z7BoBRcPDIoLmsB5LN7yBx+sv4zu3fQEooS5pbLGMo5cEdAR5VORJKtskKX2aMXVnR/9dkJ7d+pQuDbMTRSni5ym1FVTky+p1D9ajvvsOQofqcT+7UcHZhvcJqNGgH1Yr0/8xZWLRqJqBicTEjd0S3tzxEZY+tRbHN22iiAkSSvMIJDXECUmLRrocYCD6X4jpOVTdUYbb59T0yh3gUNfBY+oIGRGPgg1/BNa9iLdPXMICjpkUG96aOw9GVtCzMtKLDKfsHmbzk0YIRLS8O3QY2LpVtXYvhBX8SaegLD8Td7tyuXlihmCpHKOgaoLEWkQw0eK54e87+LhrnywFmMCFKJ93HhPQ2MTBEBHdLW5s80bxGhvbzONx0oRqxqYpZHdpLjCbkNbQc8p+mos4TKHkNfqEqWY3tgI7dlLSv4dAsx8bQgpe1vGI2nWYRj+tLsvGJIp6HoHMdjpgt1m1fGGAGbUEri//VHpyWk6yOs+EqJvOEQxCocNHADtI5Fpb/TgSk7EjJOFirg7legWPlhZg2ZJ/op6QOvm8bE0LmDCnXVG2fbN+FZSvLKMvOrU2I+2ypqjHauKZC8Du3STRBxB3e/C6j8OzzQkcU90UMYXAkqbCXqinsinATjpkdgp4nMstniVTIWWmWSEO6tjGsN/zMvmGdykq0eYYgoQxTmkXoNedCYV8PU28UIcvuXisHlWK+fPmc7h1LtGnpF86yMPfyNNYRwvbdRbLLzUibybVFZNjmOvsqV18ZjoKCoHZ8zMxd26JzmrC1Ki7e5VDxCiqCHv8Mny0Bh4Lj9aIBKNfpG5HQiDbgAWmnKxyqC2WAJ4UwSBGEItEd16K42BARoyG9xEjzhEz/LTOiTFWTBupw1s3VuDxu+8pKHv44XzcPLsbmTbq+JW+OaUNCLHnLJW3n/yKiFJiQkaWjH+dPhrfW/MIMIn50ig+PoksxI+smXA3ynj+F7vx3oHk9oQeTwsylriclgV6R9ZYnSVDxzip3svGLosNkle2p5pgrRGLajQkct3hWk8guIsofMgq45crVzpL7njwFtgs5CA81C3Ew1e/EqDTjm1bgBdeQui8Dw9zvTf5GHDPSCeeue9uOJYuSelr/GpXgFJJWnwbIrESfPfe36Puih6FVTdDn5UPndV+1d3xniEipPFer5cMTIJGo1JA7VT4zAl8eWk2Vv/qm+QXt5MXO3kVvU9FjXI9SvXxpd8Db23B2Y4k7rtA7BB6TuSRcYKXsO34YUxrbUIR47nDlQIqDZeb9Et/HQxFszBx6mh8sPU4hILxsOQWUIFOapFhl6SpWrMNYXVHnDWupC5iUqsdJqMeVotZjXAsSUfLFVSWR/HD/14NIXwE6PwwfTvUDxj7O0xfffIZEsF9eLUmiZVNCdQp/Xe22YsuEW3Ezr+5GyHs+wDTPZ3QsVbE6Up9c7irYnEvnFMW4crR3ai5KMBRWqYCYZab/ewyMtEGG7JYiy9rt4sUFGi1MBJRyATwsFqt0JOL9p8+iIe+VY6KWbOBS+vZHsjwwOj3x44RHV8GXv0z3KdaseaChB8FRHQPe4U3SpavXcJOyqWtp87CcmAfRra2UJCo8XbmpgaXB51MCMPX4scf1jeDL5pM6elSL0WTvcYRFOEDlKMB2QgoBozW+3HnSgVfvYPDzJlazWN+kiM2mAlkzONFousK5s/sBBdrGgou1VYeptr83G+BP/0VrVT4115O4v6GBN6Pyx9zCbsnmp1JuDtkvO2PY9MlYuGH+zHB79W2OCxZ/TaQ1PtbJDz58xbUeEpReGOVCo69fQAlOEYAe+6f9MrUO9qNeGyFHw67ApuNU9ufKNHmwgXmSQlkdg5OfFADh9yI8ZXajXi9AkLg6moI2AvAhj+j7WwjniRgD1xMYFNQREi5lmv0g4GGZXQS0M2ciO1155D/4UGMJaXH6JGEy6oBfJko8s4HdpTdsggCdaDsfrMjKMR5uNTeUO6RPl6BO2zDR7V6LK/0kxHQ3p9AbqSWPGZnpwIjOYQ4J+DA9iaMprUpGadtVgWpO/kDWbW1L6L7yHmsu5TEPQRsMwELX03whWuxUKn8bKVz/CURRe2Zo7ih7iKy7GTNNr4J/H2bBSXzq2HKzFEbXEbNowRwDDyUDDqwew8lVSM51dI0dhhw5wwPcrO0HTlW0FlrdeYMYaFIsb3RUEzCwV1doEKDZupqnn6OrNte7HDHcdfZOF4hdxNUrtH+XdcfKyn5BuQUCHjCrOAeS35hduFNcwicS1VObdUUdFC13YMyFVycQPqQ2iAmOt43/QrWPdgOQyqCzKqx3fKaGmbvePKhIfJqHsjURMcb6sFHQx2k8v/RmMRvwxKu6wYA4XoBslWj1Yt6JOwosWF+2fzq0cacfMjJRL/vcKpiWiiB6pCDYA84EpRV0xux7lsEzqQVthj5s/UklkePar5V20STESaQvM0JU1YmRJ9718mQ/Fi3fP0env+kd3pYiVk2syFX3ZoY1NCxCLKysB+lCKuyR7WQGuPVsy7jNw+1wWDWTstoyeg3Zw5QVNTXT7K7mtQ7mySqpXojrGZjgZX/ZPcO/48AAwDjybhRwYVFGAAAAABJRU5ErkJggg==' +EMOJI_BASE64_NOTUNDERSTANDING = b'iVBORw0KGgoAAAANSUhEUgAAADgAAAA4CAYAAACohjseAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyJpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuMy1jMDExIDY2LjE0NTY2MSwgMjAxMi8wMi8wNi0xNDo1NjoyNyAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENTNiAoV2luZG93cykiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6NTVEMDZBRDE2Q0U5MTFFQjg5MjRENTNGQzVCQkI1N0MiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6NTVEMDZBRDI2Q0U5MTFFQjg5MjRENTNGQzVCQkI1N0MiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDo1NUQwNkFDRjZDRTkxMUVCODkyNEQ1M0ZDNUJCQjU3QyIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDo1NUQwNkFEMDZDRTkxMUVCODkyNEQ1M0ZDNUJCQjU3QyIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PoEgddAAABw3SURBVHjarFoJdBzV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' +EMOJI_BASE64_PONDER = b'iVBORw0KGgoAAAANSUhEUgAAADgAAAA4CAYAAACohjseAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyJpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuMy1jMDExIDY2LjE0NTY2MSwgMjAxMi8wMi8wNi0xNDo1NjoyNyAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENTNiAoV2luZG93cykiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6RTk4OEJBQUY2RENEMTFFQjk3NEFFODU4Qjk5Qjk2NjAiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6RTk4OEJBQjA2RENEMTFFQjk3NEFFODU4Qjk5Qjk2NjAiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDpFOTg4QkFBRDZEQ0QxMUVCOTc0QUU4NThCOTlCOTY2MCIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDpFOTg4QkFBRTZEQ0QxMUVCOTc0QUU4NThCOTlCOTY2MCIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PohXc4wAABYiSURBVHjatFoLeFTl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' +EMOJI_BASE64_SAD = b'iVBORw0KGgoAAAANSUhEUgAAADgAAAA4CAYAAACohjseAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyJpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuMy1jMDExIDY2LjE0NTY2MSwgMjAxMi8wMi8wNi0xNDo1NjoyNyAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENTNiAoV2luZG93cykiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6QjhEQzE3NTU2Q0U4MTFFQjk0NEJFQkEyMDc5NzVGRjciIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6QjhEQzE3NTY2Q0U4MTFFQjk0NEJFQkEyMDc5NzVGRjciPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDpCOERDMTc1MzZDRTgxMUVCOTQ0QkVCQTIwNzk3NUZGNyIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDpCOERDMTc1NDZDRTgxMUVCOTQ0QkVCQTIwNzk3NUZGNyIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PqiratEAABX+SURBVHjavFoJcFz1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' +EMOJI_BASE64_SKEPTICAL = b'iVBORw0KGgoAAAANSUhEUgAAADgAAAA4CAYAAACohjseAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyJpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuMy1jMDExIDY2LjE0NTY2MSwgMjAxMi8wMi8wNi0xNDo1NjoyNyAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENTNiAoV2luZG93cykiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6MkM3RkFGNjQ2Q0U5MTFFQjg0NTRGNTdCQTc3ODhGNDUiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6MkM3RkFGNjU2Q0U5MTFFQjg0NTRGNTdCQTc3ODhGNDUiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDoyQzdGQUY2MjZDRTkxMUVCODQ1NEY1N0JBNzc4OEY0NSIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDoyQzdGQUY2MzZDRTkxMUVCODQ1NEY1N0JBNzc4OEY0NSIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PnKdXnkAABU6SURBVHjaxFppdBXn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' +EMOJI_BASE64_THINK = b'iVBORw0KGgoAAAANSUhEUgAAADgAAAA4CAYAAACohjseAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyJpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuMy1jMDExIDY2LjE0NTY2MSwgMjAxMi8wMi8wNi0xNDo1NjoyNyAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENTNiAoV2luZG93cykiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6MDkwMDIzODE2RENFMTFFQkIzQkQ4NjYzRTEwOEUyNUQiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6MDkwMDIzODI2RENFMTFFQkIzQkQ4NjYzRTEwOEUyNUQiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDowOTAwMjM3RjZEQ0UxMUVCQjNCRDg2NjNFMTA4RTI1RCIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDowOTAwMjM4MDZEQ0UxMUVCQjNCRDg2NjNFMTA4RTI1RCIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PraG6u0AABkKSURBVHjavFoJeFzl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' +EMOJI_BASE64_DREAMING = b'iVBORw0KGgoAAAANSUhEUgAAADgAAAA4CAYAAACohjseAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyJpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuMy1jMDExIDY2LjE0NTY2MSwgMjAxMi8wMi8wNi0xNDo1NjoyNyAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENTNiAoV2luZG93cykiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6Mzg4NEExODE2Q0U5MTFFQkFDNUU4NkQ5MTIxMzQ2QzAiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6Mzg4NEExODI2Q0U5MTFFQkFDNUU4NkQ5MTIxMzQ2QzAiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDozODg0QTE3RjZDRTkxMUVCQUM1RTg2RDkxMjEzNDZDMCIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDozODg0QTE4MDZDRTkxMUVCQUM1RTg2RDkxMjEzNDZDMCIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PqXm5wAAABe2SURBVHjazFoJdFzl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' +EMOJI_BASE64_WEARY = b'iVBORw0KGgoAAAANSUhEUgAAADgAAAA4CAYAAACohjseAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyJpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuMy1jMDExIDY2LjE0NTY2MSwgMjAxMi8wMi8wNi0xNDo1NjoyNyAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENTNiAoV2luZG93cykiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6RTg0MzYzM0I2Q0U4MTFFQkFDNENFQjAwN0U2RjM1RTYiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6RTg0MzYzM0M2Q0U4MTFFQkFDNENFQjAwN0U2RjM1RTYiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDpFODQzNjMzOTZDRTgxMUVCQUM0Q0VCMDA3RTZGMzVFNiIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDpFODQzNjMzQTZDRTgxMUVCQUM0Q0VCMDA3RTZGMzVFNiIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PuojFwAAABVfSURBVHjatFoJkFxX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' +EMOJI_BASE64_YIKES = b'iVBORw0KGgoAAAANSUhEUgAAADgAAAA4CAYAAACohjseAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyJpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuMy1jMDExIDY2LjE0NTY2MSwgMjAxMi8wMi8wNi0xNDo1NjoyNyAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENTNiAoV2luZG93cykiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6NDdDQTU3NTM2Q0U5MTFFQkEzNkM4MDYzQzlCMUIyNTMiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6NDdDQTU3NTQ2Q0U5MTFFQkEzNkM4MDYzQzlCMUIyNTMiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDo0N0NBNTc1MTZDRTkxMUVCQTM2QzgwNjNDOUIxQjI1MyIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDo0N0NBNTc1MjZDRTkxMUVCQTM2QzgwNjNDOUIxQjI1MyIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/Pv+eLOAAABVWSURBVHjaxFoJcBzV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' + +EMOJI_BASE64_HAPPY_GASP = b'iVBORw0KGgoAAAANSUhEUgAAADgAAAA4CAYAAACohjseAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyJpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuMy1jMDExIDY2LjE0NTY2MSwgMjAxMi8wMi8wNi0xNDo1NjoyNyAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENTNiAoV2luZG93cykiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6QjdGRDYyQjk3NkRGMTFFQjg5NzdERjY5MzJBQjA1RkMiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6QjdGRDYyQkE3NkRGMTFFQjg5NzdERjY5MzJBQjA1RkMiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDpCN0ZENjJCNzc2REYxMUVCODk3N0RGNjkzMkFCMDVGQyIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDpCN0ZENjJCODc2REYxMUVCODk3N0RGNjkzMkFCMDVGQyIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/Ptkir7oAABWySURBVHjatFoJdF1l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' + +EMOJI_BASE64_HAPPY_IDEA = b'iVBORw0KGgoAAAANSUhEUgAAADgAAAA4CAYAAACohjseAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyJpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuMy1jMDExIDY2LjE0NTY2MSwgMjAxMi8wMi8wNi0xNDo1NjoyNyAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENTNiAoV2luZG93cykiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6RUVEQTM3QTg3NkRGMTFFQjk1QkZCNjE0QUI5MUYwQzciIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6RUVEQTM3QTk3NkRGMTFFQjk1QkZCNjE0QUI5MUYwQzciPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDpFRURBMzdBNjc2REYxMUVCOTVCRkI2MTRBQjkxRjBDNyIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDpFRURBMzdBNzc2REYxMUVCOTVCRkI2MTRBQjkxRjBDNyIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PjcEIkMAABZPSURBVHjavFoJdBzVlb1V1Zu61VJr321ZQpYsbwEbsDF4ZQlgz4ATsjJJGHLIJJ7MyXLmzCQnyQDJ5EwgOVnIMEASDgSSkEmIQ0hsOICDHcAsxrLxgi0hy9rV2rq19FbdVTX3/+qWtVmSCZny+W51ddWvf/+77737/i/FsixMP1588UUMDAxgoYfBLqb3Iro1TMBkK3QAbpV/85yiACrbgA4keZ2m2eemHxrPKed53ngMKMpJYPsmsxaGUiev1Mzu3qBy7I+vuJHtVeBgv1u2bIFjtg7uuusuCXKhh5uN4y/3AFXlTlQTSI2qosTvR77Pi0C+B+4sB8esQiNw0zRgDMaRHI9jZHQcoaSOfoJuj5po602hk4Nqi9hzNOtx/eUI3Pnliq8ivPlmOKvL5RQZPYPu4f3P7v9t692796EzmgAOHDgwO8CFHJwkeCxcWuHCjY1V2LaoFLVLapSyZY1FyCvIh8utwe/qh881DFUzoHEGFM6qRYsaBj/Z4hwEASIRp1WIqPUscLIZ4aFhnDnSioODMTw9aOJlXjJuptHuuAJFv/nput3uxZ/bgFQZO01DMI2q/Jxtn378549tve2Tz/79I3us44IBjncBzFGq4MNL8/FP267Ehm1XFyvVjXXIW7IKyCoBUhxprAOIHmfjyJNGmrPTOlLsVqpigovrN8mPQGwEl7S345LX3sSuvc/jVHMPHjmTwE+W1GL4Nw8su89d870NMPykzSjvdaf742yhkLN3Y81/f2f4of2HXt8aSyC+YIDCb2qduGxJNu7dfo22cfuHVmLJJVcBuSsJyklTHAM6/wRETvB7dAqICz2yvEDDKrvd8H40/OlZ/NcTv8Mnl1XhSXf1Z3bCwR/UsbQT++yHmOKZpEgyAG/ZuvVfvLXppkTSfGJBAMUkr1Dx6S0N+MFnvlTnW3bN33EUDeQWI0Xwz8Dws+TbsG0lJX2DNJoCk18U/iX+JlH5z5Dfz/VtzhK10o1HUTHwqX8AllZhmeHL/xrcm+0fheU0zoSjwL4wxQdbHI8jhz8X4YpLKz5oBJLzAxTRbIUDuyqvv+rHV399J8y6jTis+9E8msLzA0MYHC9CQvkIdGc2UmS8aAKIACT+Fp9KmqFTAdpnHRKJJc870j04aRqPFYcXUfjMCHxKFMbaMdxR+AYBFAl/S7NDPeeDSprrCtmkO7Fmlb8Qixbgg40qrsm7ZssPy7+/Bz0Mh0FSvYle/1Af2ZB6N168wGM6tbOBTc47scoYt0GI/GPQxxFKUzRmW9BK2hNgKTIBzTm8EhW53urK+1xfeURbQ3DCj7sI6sEgGWGco+K5MVnSAoJ2mb8zI9XS56da1pJWFTTOnDendzqRWIFegyiNEcDF6JnipxG2AcnfCZbWhsHA44rgYFMkoo3CmhNghYpPXnLTivqrGyKoiR+BQzHRO/QCfqYfRZGH9LGizIFxSTMHaeVMU2wywIy3ib8z1ExJCquzAkxOIaoTEcWHcMqHoVQOKsNtwBj9rpCBzSSQFP1ejacB6nbTuzmbYYSGBw9aBersBEvnHHdFMW7/5qYzKFCY9C1aXB/CitidDP86mpuoRgYZNNlngpaNJ+1PPWk3kesEhYX9xKdhTQ1aTodtXKE4hJoR311knpuxw+1ijmVzsZXmkppLeY5GG6NbxLqSyArcyA4ZRY2QTc1MZEr18+E9DIDtY4/uHnn8tq84zgNQ0BO4dN1qrCxoXMMR+uyR6kdw7E0d9z0InGrldZrT1lnS2Uk50w4glqrZv012pCl6bLK2o+3oT4qRlNrFktpNtRWB8LNUEmWFFm7/BHDleqD71H7UFvwYSs6V/C1k+6Po2+Isxpo5K2E8/cRLdz71Elo/654jRFB2Xb9+HbOft8YekBZH51uv4s5v0xfifjjrq6G6PNIeYnAKB5fIK+VcBOCIjcEdpqMKoA7XTIAZEUYQmh5DypONeKBEfvcMdjF/D8MUfWsOUPKht7cb9/wgKJlVVW1g6PTPUbhcsyOPjJ6qTVdrBNH+N/78r98d+WGCeB3qeQD6OebFflxV15DPe6kOLF6pt+DJX3WiZ9QD54rl9gA4w2oyjlhZLc6+/zMI112KpCcHjsQoAi2HUL33QfiCZ2A4s2yLTA5IwjqcuK6NH0PPlbcgnlcuv2cNdaH0qftRvO8x8tZNSwWgLalHsiWFJ38/hM/tAoaC/ShceopUTE++mDCF9PSG8OuH2+4/3WlnUet8QV4xULy4BDXF5UyiCp1AtTDW8hoOHWbgKCqCJcAZDCakz1jVMpz+6N28zkLg9GtwjodpxVxEKpfixG3fQ8Mvvw5/59u0pHOqBWnQ1h1fwODF2+DtOYNA8xvSyrHiRej6+L8hXrIYi574NszQoLzFWVqOjrND6KAKrKdPHj54FDGCEvZz0XlVUtvpDI3e92v60aRjVoBuBZVlJSh1FVBGWC45Oy1vHUcXJ0mtzydVbGtY5I/pcKPuN/+J3DZG2eiYHKTFwae8uRipXg3D5ZXXTZlAAtSz8xA404RF+34OD62mpvR0nxqiJUswmr8IKUH36AjM8VGYBUWIKx40N8dxUS3w8J4i3PDxb2F4oBftrZ2IRKJ4+/iRnmOtR4LzAqx0o7KiiPHbWWiXALFTOPomLePwQHV7J3zIUh0SmJaIIlJag/EVm2A5XRxwL7JptfzTBwkwi4Fj6mMEYCcHXnTkObIlhVjhIoxX1kvau0JB5HQcQ3F3M6uNmASMVIq5m+7g9aOtLS6jtNeXjZHwGGLxGPLzAgjk5uDwm8l+5uexeQHSPlUlReLXgM3k4UM4cUqoYCp4ETRMI+1HhhxA242fR89VH4CeV2CrpbiB/FMHUfOHHyGr/+wMgLYVTXlv16Zb0bX1Y0jkF9v5g6km0HoItb//ATxNL8qsKsdEsBr9sZ+F+DDjiZM5xMEcYxD8yEgYHo9HUNU7oQEnpaRZRUyewOag/xkDGOlqRh8LfCU7e5qvJtF2wz/j7E13QM8pkINj3pcqaejiK3HiH+9lZC2zU8B0Ac8qt2PbbWi95QtIBAhOt+8VR3j5Why/4/uI0r/VZCKdx3mBO4sVMtMwBUsq1oOmoycwNjpKYC4JMBwOz1iGOF+ayM0WVYhGQInTGOofxzBVkVLhOyfC+eDQRWvRteWjNjBzapoD82+0ajHar/006p+4G4Z2LsgIfxtb1IiOa2+HVHPGzHsTxUVo3/ll1H/vU+lqISWpnVJdtKKOHSuP4Zu/vAdv95XCn+1AIhY50dnZuWt65TmrBVXV8glVIS+NNCFEcONxhXnPeS6HkZ79a24gVxTMVvHIg5M+tHwTfawS6iQrCmr3v+9amF7nVHCTD1ozvHoTIouW2QHISEl5Z9FFhIIqzgO+eNMw7th8Cnmh451tra0f1XW9bbZSbxaArG9FHtVp8dhphEbsgCJaJsybzFER5j9Yc61GUQTl5CCeXy6DyWRZPl7ZcP6JSQcC05+FBCNq5l5LJHX68zjDSILzxUCI7etMLM01h8mtY+erZc9frsRaOMJhuV4iQ70M95Ytx0g5UwQPa/5qeWJiMixkKplQOPOUTKbLPYW/YhyxtPwUFU1clzpXTa99LQwghXJCiGVBT6TFssxvaW0pZlLTo3CNDc+9JCHmJJ6SuUzO/qQI6g73zX2vVOmQacNKyzzpHewnlZoyVtFEqZJcMEDLUsbFSheSITsSabNehIKTL83FARnCfN0tUq5NUTI8Ck68NLf1eHlW5xl4O95mbnVPkbOadk7eykolhYSqzE748w1vWCznTSgbt60+wMhpielLU6y46Rn42lqlMp+51pFOqH9+VAoBTLKgwQEXnHgROaeOEsV5RsVW+fzDcI4M2Mk+bVbhj65J+l3En2CUXmSdfz1ptpNBkRYyh8yJggtCUcRjE5LKGRmh1vwGvJxpOVAx0S67FFENHTW7f4Sit/ZJNSMsLioH0RRRnOhxNPz6LvhbTs64Vxhj8R8fRsnB3fa9kzUsI3AmHYuQEBPRdhzBlHVeEs08oia6BgfP5aUCFhUeJ3kg1AcTrsWkqpBywhK+3hasun8XgpfeiNDSy6Vlff1tKH7jTwhQxhnUqlK1MCz3X3ydlGMFJw6w+opQ0nVj5UP/guDa7RhuWA/D7YF3sBPFh/ZSzbyJpKg4MrwUoV0sWFHg5+dPWsYn05JJdM7hJTOPrjj6AlHptE4R6gXAAoqablGyKw6YoyPQ8gvtaE6QzkgYi154hO1RGRBEKSSineH0TKiWM9s/i87rb5X0KDy4D8t+8TXpl4K+lfsfl00EInsyVPmbpSfPWY4aV/SrGgm5lJg5QiG58tc615LnTNQqegeHEExGbAsKLKwuYI6NQs3y2upeJCNZyYvky3mgCDdoHQHYoKQy04FBJOloSTV6NnzAlmN0x8GLt2JkyWqqIV3qVHkvm7Cu/BT3RyKw+HvGggoBijX+XJ+FwkK72Bc/BYOScS0XBNBUMcDSqGto0L5CYd8rG0XaGIPqsJOtERqCMdwPMzrOmRbBJ2k/1bKmbJsI1RKmpDOys5BeApW8EQpHqhMhwQRQ0Qf9W0ycMTQAYzQ0ZRVAzeL9PFdC6+UXpNd8+Mi+PoS6dbRfEEXHmE+7Qmjq6cW60sV2NLxsDfCLJ2OMWnEoHtZ4BCtmGaLJPTHSK702Yy/KpnNXPIq4WLxz2pVCJsJGckuQCvYglSm/MpMzfQ1HnBNUFnsHY2HUX2UvUIlgLiQkW5tTRdf5VJE6x5rrfop1+ws7W0ZltbSGMzfYDy03Tz50yoA4pcKKtjXY6K+imRxJ3qtPw93BOCCKGRpCjcZR8MpTDIh2ZJbgMqAyLQOaf6sswxRG7GxXEo2N9mmRC/upFc724w2miNQFWVAcvPe1o8cxYpL2ZI9cNdxGVh19YBBOFpdKES0QjcKKReTK18wZsqfJZJj3dZ7C8v+4GeNL10if83Qx+bcfp695Zy5ETYzMAYW/ayxrtGQcyY4O1K8Gysttaorc/A49b1jHsylrTq0xuwWJqf3tVrze3oZryiqA4+8AdZy9LRtNHHz1HWjePjjyimD4/TBFZBUWFNYTAlGIY1EUywVWU0ZE12AXCvrSYl+stol1HSttNc1eHFV4XmEWVwhOswxGX+ZNKiFjJIRG6vrtO+x5EPlPeEZzC2NMCi8DFwhQ0F2EipYQHt93ANfcfpt9TizefvhDQOMK4Hd7xjHKOtFK8geXl36ZBSWLmp6fYtfHSm+GmKo6dR9Nsa2lWXZCF2lByeyKikDDuK/QbxWmD6fYOGWX23cCG9fZXiEYLax3ku5zqhN7mOf7Lxhg5ug1sfup5/CVa7eiQeTBAZFzON7aOgU5jU44mSk21+gY6o2gszuCkfAgRoP2vqfJwlSIWGEVKNpU35LNlBFWWFokb4/TBMmAXD6npB646CL6SLcTXREN9St0eNymrByEseNx8R4B9HASP0zNU83MCTBuYexkEF++/6d4etcuqCIViclO2vUnvBzQmksU+NyWnNnRMbtFqC7CYR2j6eWFaMx2U3GNGCAZCIoh+GgdIbvyKCR8/JQAcxglaSkXr2ndD3S0pp9n2tQU9+7dw1jQgntbdRydr+Kad/PrrIE9vz+If2f5d8+Om+0B+WgBL2kSSdj7EaKoFwMQA2Z9O+tbE/MdmaApmuhT7OGLzyyneJYlgYmNpD0E9/w+/LYtibsMa/5+5wUoOmlO4t7f/QWR3iC+c931yF5FH1y+2MQzhzUM01q53nOvjJjmX781KNxgLKagN6ygutRCeaGF7m7gmb3A4bfwP8ei+NJQavb674IBZjZjTidxv9mCg+09uHvNKmxfutxAwKvh9TMO1JQl5XswGZDTxMz8hfuk9CfACYq+2qoiwgC22JvEH3YDL7+OI82D+BbjwpPjxsL7XvD+rIj4pxNo6kpiR+cr2FjQZH2irEDf3Nyt1j7HgdRWWvDRh4SiYlEgByl8Zj6g8oWhlL3dH6NOpfJD36CCV+ldSlAPPnPYeu2dEH7RncIfoxaiF8qGC96AjtBCzToOKGyBMSuvzG2sbH4baxgs11TloyaQjXyXGwEG0GyGcw+zhiasIlYF0lsS9jsG6azAiBhn4o5Q/Izy76GOELojMetIEsahPh1vEXdn5K+g/bveYReGCZmsVmI4wD9Fw6mI1H5eU74jZAuzZW5s9RcXPaAXLLJRiU2SSAiRjjMPn0zgnvRyb4TAow6FBrTmX8f6fwE42yFq/RyWgZVO1GUxfdFiLr+G1Q6xC5sYtk2YUqClxpHtxOIGDRvodyII93QncGzUwNn3Ety7Bphlq3QhRtxpjeJd5MWKXA078/1ZV3uKSpe584qhUnaZMqdPrxT4tbZhW0DXtxlC2ukxlIWDI4nRkddGYskn22PYH02h07SLgST/S0Tw7ix7QQDFVNe6sX55Mb7BAriEfuWJ2kWDNzzuqvbVX46ssmo4GG2UBSRDL51viEWn2PpSCxflOvTotUV9Z6+tTbSPMad2ZnmoWhUkYzFET3TghdMR3DWYhP43A0gh49+0PPfRr3/3A3WlOa3QIi+jh2q3nxLuqadTeMdTDqc/AFOsvk2UfvY7FKa9oiINKaoBgd/lUlDC8nzEOSI2TqA7fMhlSXTrB+GvrUXjklL7hQRaE3ufxuXf/Ak6CPDBCxmzeiEXFyu4eeendtRVVLZAG90v43s5K+yyAvHihVOsp1KBpCYtbZrogR+vYDEGGHMc1HmCrTWsKysqhFKxJOC8vDyUlpbCQ30W0R0yFy6ttEW1tDR94sYbgJVLsMs9+yLlXw9QvBl2WZ33jvXvo5ru/ovtEGkziRw2PJ4FhydrwtdctFcHcrEXS9GECuy16lhjZuNDNxn46lch286d6RcpUibzZxZKSoqhK34MDU/a5bPsTRxPgCA3Y2WZhqv/JgDLHVi7+bLkOr/zhRmbJoNDHINJgHQai1YSljuLAMHVcWwOuSQQ4RQ9h4uglbGA1UwpCHawvrvlFjt7mETqoOB0+nPQP2BvrkxZ2uf3TRuAVaW43aO8xwDFujLH9fHNVyQ1qccmR0N+HSDAmJkHzaHKd5iGGGdfQRVJmTxnZsXEmOnGR35Wi5Ntronz111H6q3kBIlSiNLHwZqyZ0CVJdeUg12VLQbWrsRWJtia9xQg6Zm7th7b65badJkMUKfLdffSCp5CWRSLLZpxEtTHEdURqh8JNj39WpOJwbAXJzvcU4K+WGeRzBbWZwQOhj0YHpl9prdciZwKF7a/pwCLHbjsisuwRLyeOT0ZjTJB9fQpcOfmQbzgLvbUKzBGgsbQgnwC1UnP9GZCXMOW2iC2roxMPFqURMeOpXfmxMp9tg+jCS96+9KvAkyjqahkllVhp3uBr9qqC6FnhR8716zGDN8T9BwK04KDPpkeMi/7iJfx1qMLeVRhfbSfeDUPCRVXlQfxq13tyM+3Z0nQ8rHHgCNHbHFub/Q4EE7mobOLkTkxDSDZksOo/b4GrMlWUfueAGRMLlm1BNurquwZnHxnTKxMddCKyWK4OfNCsShyHhQcpA+2E6I0uaFgY1U//vfzZ1FSanNbLBqJl3o2MHBUV6dfVCcDHNSqak4B3mlTMDQ6ezVy6SXILnfigwsB+H8CDAACcMlhCn8awgAAAABJRU5ErkJggg==' + +EMOJI_BASE64_HAPPY_JOY = b'iVBORw0KGgoAAAANSUhEUgAAADgAAAA4CAYAAACohjseAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyJpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuMy1jMDExIDY2LjE0NTY2MSwgMjAxMi8wMi8wNi0xNDo1NjoyNyAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENTNiAoV2luZG93cykiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6NjUxQTFBQzI3NkRGMTFFQkI5NDZCQjFDNkFDNjQ2OTUiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6NjUxQTFBQzM3NkRGMTFFQkI5NDZCQjFDNkFDNjQ2OTUiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDo2NTFBMUFDMDc2REYxMUVCQjk0NkJCMUM2QUM2NDY5NSIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDo2NTFBMUFDMTc2REYxMUVCQjk0NkJCMUM2QUM2NDY5NSIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PoEmG4sAABmjSURBVHjaxFoJeBxn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' + +EMOJI_BASE64_HAPPY_LAUGH = b'iVBORw0KGgoAAAANSUhEUgAAADgAAAA4CAYAAACohjseAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyJpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuMy1jMDExIDY2LjE0NTY2MSwgMjAxMi8wMi8wNi0xNDo1NjoyNyAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENTNiAoV2luZG93cykiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6QkFBNDU4OUE3NkRFMTFFQkJCQzhBRTkwQjJGMzAyNUMiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6QkFBNDU4OUI3NkRFMTFFQkJCQzhBRTkwQjJGMzAyNUMiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDpCQUE0NTg5ODc2REUxMUVCQkJDOEFFOTBCMkYzMDI1QyIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDpCQUE0NTg5OTc2REUxMUVCQkJDOEFFOTBCMkYzMDI1QyIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/Po9obewAABRZSURBVHjaxFoJkBTX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' + +EMOJI_BASE64_HAPPY_STARE = b'iVBORw0KGgoAAAANSUhEUgAAADgAAAA4CAYAAACohjseAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyJpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuMy1jMDExIDY2LjE0NTY2MSwgMjAxMi8wMi8wNi0xNDo1NjoyNyAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENTNiAoV2luZG93cykiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6RDgzMzhCODM3NkRGMTFFQjkwODBENDUzNzk4QzVBOEMiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6RDgzMzhCODQ3NkRGMTFFQjkwODBENDUzNzk4QzVBOEMiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDpEODMzOEI4MTc2REYxMUVCOTA4MEQ0NTM3OThDNUE4QyIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDpEODMzOEI4Mjc2REYxMUVCOTA4MEQ0NTM3OThDNUE4QyIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PrgNzd4AABMDSURBVHjaxFp5kBz1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' + +ICON_BUY_ME_A_COFFEE = b'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' + +EMOJI_BASE64_HAPPY_RELIEF = b'iVBORw0KGgoAAAANSUhEUgAAADgAAAA4CAYAAACohjseAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyJpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuMy1jMDExIDY2LjE0NTY2MSwgMjAxMi8wMi8wNi0xNDo1NjoyNyAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENTNiAoV2luZG93cykiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6NDBFNzI5NUU3NkRGMTFFQkI0RUZFM0E0MERFOUExQUEiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6NDBFNzI5NUY3NkRGMTFFQkI0RUZFM0E0MERFOUExQUEiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDo0MEU3Mjk1Qzc2REYxMUVCQjRFRkUzQTQwREU5QTFBQSIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDo0MEU3Mjk1RDc2REYxMUVCQjRFRkUzQTQwREU5QTFBQSIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PsnMYvAAABTwSURBVHjatFoJdFzV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' + +EMOJI_BASE64_HAPPY_BIG_SMILE = b'iVBORw0KGgoAAAANSUhEUgAAADgAAAA4CAYAAACohjseAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyJpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuMy1jMDExIDY2LjE0NTY2MSwgMjAxMi8wMi8wNi0xNDo1NjoyNyAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENTNiAoV2luZG93cykiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6NEMyMDNFQTY3OTk2MTFFQjg3NzRCNjNENENFODAxNDYiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6NEMyMDNFQTc3OTk2MTFFQjg3NzRCNjNENENFODAxNDYiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDo0QzIwM0VBNDc5OTYxMUVCODc3NEI2M0Q0Q0U4MDE0NiIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDo0QzIwM0VBNTc5OTYxMUVCODc3NEI2M0Q0Q0U4MDE0NiIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PrjncAkAABMkSURBVHja3FppcFzV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' +EMOJI_BASE64_HAPPY_CONTENT = b'iVBORw0KGgoAAAANSUhEUgAAADgAAAA4CAYAAACohjseAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyJpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuMy1jMDExIDY2LjE0NTY2MSwgMjAxMi8wMi8wNi0xNDo1NjoyNyAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENTNiAoV2luZG93cykiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6QjA0RDRDODM3OTk2MTFFQjk3QzQ5NjQxMjRDQTk0MEMiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6QjA0RDRDODQ3OTk2MTFFQjk3QzQ5NjQxMjRDQTk0MEMiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDpCMDRENEM4MTc5OTYxMUVCOTdDNDk2NDEyNENBOTQwQyIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDpCMDRENEM4Mjc5OTYxMUVCOTdDNDk2NDEyNENBOTQwQyIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PrB9/b8AABLJSURBVHja3Fp5dB3V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' + +EMOJI_BASE64_HAPPY_HEARTS = b'iVBORw0KGgoAAAANSUhEUgAAADgAAAA4CAYAAACohjseAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyJpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuMy1jMDExIDY2LjE0NTY2MSwgMjAxMi8wMi8wNi0xNDo1NjoyNyAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENTNiAoV2luZG93cykiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6NURFMEJBMjk3OTk2MTFFQjk1MTZENDVGRTM1MDlFRjYiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6NURFMEJBMkE3OTk2MTFFQjk1MTZENDVGRTM1MDlFRjYiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDo1REUwQkEyNzc5OTYxMUVCOTUxNkQ0NUZFMzUwOUVGNiIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDo1REUwQkEyODc5OTYxMUVCOTUxNkQ0NUZFMzUwOUVGNiIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PjWLaKkAABhuSURBVHja3FoJcF3V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' + +EMOJI_BASE64_HAPPY_THUMBS_UP = b'iVBORw0KGgoAAAANSUhEUgAAADgAAAA4CAYAAACohjseAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyJpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuMy1jMDExIDY2LjE0NTY2MSwgMjAxMi8wMi8wNi0xNDo1NjoyNyAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENTNiAoV2luZG93cykiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6N0M3M0ExQkM3OTk2MTFFQkFDREU4QkE2OUQ2Njk5ODUiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6N0M3M0ExQkQ3OTk2MTFFQkFDREU4QkE2OUQ2Njk5ODUiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDo3QzczQTFCQTc5OTYxMUVCQUNERThCQTY5RDY2OTk4NSIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDo3QzczQTFCQjc5OTYxMUVCQUNERThCQTY5RDY2OTk4NSIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PrSTufUAABP3SURBVHja3Fp5cF31df7u8vZF29PTZtmyLMuLbOMlGLxQ44KxzZKBJBCCm5Qmk6RMl5QAk2SmLTPpdCY001CndIY0pNMMgZQUSgdIMUtjG4z3Da8gW7YlS0/b09Pb93tvv9+9T5uNbMmm+YM3c6V377vL7/udc77znfO7kmEY+Cx/ZHzGP595gOqlBx599FEcPXr0qhdq9Gzh3JKYJX7xAgqP1cEwAi5FL29wwG9AruQZfp6icLPDuiTPrcgtJkmI9GQRz2gY5vdBDehPifvJY/dWpKuDWLp0KZ5++umpAdyzZw/27ds3lclROOIb6oBVQTfWNc9Ei8uJutp6BJqabGpFuQKXLQtVtgYslwaqc+S6TpQFIJ0GhoaBc53IRoYxGEmg9+IFnEoXsaMP2J8DTk9lIJlMZuoWdLlck/szB1klYUaNgofm1uLL61dh2c2rg1JdcxPqm+dC9lfTtDGO/Byf+jHtFCGa/FTG6KTJGgtJNHaHsLK7Bw+/txupPUew/1Q/fsW7vJIwENMn4cMrjVmdqi9XKvA2qfjuihY8cuc9FbVr71iGwLybePcmoMC5jn4I9P4vgXUQZNq6SJpevNgcwOxWbvOBW26Fh9Zc/+4OrH/zXXz/ZAhPdRbxi5R+nTH4SVZboOKGljL8/KtbXDfe/dUNcMxew18CQLIL6Hqe0bSHILUxUNI1MoIYfG5st5Fz9yffANauwNyXX8Vze/Zi854kHgnrGPxUAIoAX2LD2uC8hlce+N5twTWbPo+Q1IShrIG9Q/3ojsaQK96Fgnw/8jYX2cNubjrJWeOtC6XbS/Q/zeQZwTYa96wZEGfLPFscs49ebW0uZOAupuGhN9jncft2HMFlsS8uffH92ce6k/cOGLh43QBrgLmBBU0v+f7lreDw4la8ztkd4PazXuCiiGv5LvrV74HrxdyQzfAVYGbLW8uXP3nvS4f6shsGdaSuOQ96JajN1d5nUt9/vn4NwdkJKEUvfKZ/BNyVbqqPbjba0UG/G7+pzBIjv0uYgpIySu5LOF0rN+LMIz9dNc+tPKlcjwVnyLinecP8O25dF8CSzElzMJHoEfx1cg8Cjgx8UtJ0JYdhDVopOaU4T4U2bvI189iEHMqjemmGDHNfNfcLJnRV3BU5yYEsyTWhezBc9CKSc6Mz7kViqBz5FV4obcE/qz7Q+1yfjvZpARRUzAiRG8vw53+zsQcttm0kENKwTt9M/h1uy4Vx/gQJk4kqy0MpZoFh5rQcv+f4vUhsxaK1acTFBG7yj1bCqHLaRW4UEljl0xXu21TruJOJ1U4mdYj/dH0/H7uiEahvta5tH7Dub3cCO5fC3XkC36IyeNyYDkBxMp+xcNlCrJmzfClHJsQIjxaOoON4GD99FviQ6bcg2SGJDC4QSNZ/QypRqFSiU0m+st+ZQr8kiQT1GKVZKB03iMYl57FuNfAX3+QhNyeUbprnaa1MJS21uO9QB35Io8SnBTAIbFi9Eg6pfL71MEVDuH0Xnvx7oGPIC3vLbChOt8WGkjQKUhrZN/FNMVeMgrTsYGQZ4LrGyyXINHE+k8Qb2zuoVvL42h9baUujFcvLmC9no7m2A8t52Y4pk0wZXaXOgdsWLvIRWI11inYBv335Y3T02kjZC6D7q2Co9CNuEgemFPJQ8hluacgi6QtLcJBT2SStACWXtq4vZCHx+mJkCIUhbtEoNG8lnPNb8cEBCUeP0T3tY2OdO1dIIKyblovKOnyzgphXN6OCVig3pyzftR+79xShVNVAd3oIuGidywFlqmcitPZLSNW1wj14AbUf/Be8oXboQpZc5SPxPkWXD30334t40xLYE4OoPfAm3Ed/hyIDXFhTGwhBCtZBcnlx+HACbYvGDF9byzh14GZ1OgDzGmbMDKDBG6ziHsEgSlI5gnNMq0pTgCRkzZfMmRfgjn9rK7INDWZ9EG1bjsHF67H4X/8K3p6PoKv2ycHReprDjVNfewqxxctgEi/db+Bzd2L+zx9D2c6XoamcpEIBOsW0WlGF8+cToFHh81kO4BNuGsAsXRbRifSUXJRlTl19LYWEk1JMIv7sRzh+eABpwwaDs4gSEYjZD63+ErL1DdathaZm+BSqKtB96xYTwBUTcDGP8A23I9a2zLpeSLQscboduLj52zDcfqvsEOSVSUPylmEoKqG312JewfYetxDaCJKhg1OOQY+M+mBAJLAy60DsII6dFHrfA0Nw+LgWR6a6EbgUB/czVTOuwqDWJx2chcvyPD0hVxYkeQetSRIA8znoCnOk4sSFC2MuKtIMrVlOsJVTBsg5C1aUC2nPGNTCyPadRog5T3J7L1PRvounL3dy7nu7Pxq19JU+ntCZy8U5858zEoK9kIahlG5OoLrBkxxOhHom3qO8HIpHgW86Uq1MmB4qLZg9jUh/HBGWeJLHM3EiVBvqd78C/2lSm8tKnuK/59wZNG5/njN+ZZEq4itwbDuqDr1nUuHI9TY+rGnbz8htkmWikfRBpjZcVDVRS2CMZCG30wwr73SkmsNmK/2UOoh4EuYmVTsmeJMhq1BTw2j7xWMIL9uAVO0cuAa7UH30HbLh0BUJxppamXGYw/wX/xYDpzch0bTIZNHqQ2/D29cBze6il8dGn2mQbCRaUHQBRLIvL0WQUD0UWZ5paVFVAMxTVWfakeTNckUOhlrq0hajQSupuRTqd/1mzLI8dlVwo9erBFlA/Z6XAbEZ1jFtJMWMFwtm0NnAcDS3S/SEMh2Ahqkb06cY8FFkcijJsE8mDUNWzNkWAxCVgSFNr1Fn0JKmtRizxmVKSJqosfisEZ07clpJ4+anAzAjhDOSVmfN5ArpKmU6welUwIL11HTcZFABXAzeKOnUS88XgEztSd0l/ouEb1o0n72izCsJu7G8nTeJJDUdgMOJJKzqQfi4o+QeHLxhGJ8I00z6ZY04e+/jqGzfB/+FY7DHBmHLxKFkmcOY80aEtBDkwrVFki+4y5Cn7EvMWoyhBWsx57WfUCC0Wy5uGBNS0sjEyKUunfhJzEOKubdHRnLqUg0YjI7T5oJRRU2nM7EbuawZ6JcmL2EtQSyZwEycX7LCTPi2xDBsqZipMyXGmazlSzFqp44dAehHwV9psedAlNdExiwuFLU+PtVIpm8KUnHYx7DHYiik/EhMGWB3Dr21Q2NJUcgiH0FGeXO9kKK4USGZ+cm4BGAEZeePYjC4wbyu4K9Aoaxi8sp/pErXreTu7zwOZ2yABOMsEQEnVC8Fm1nRkEfoDaJDONIlJLEimURULsPwlPNgWsdAbz9tYJXaqGLSF4nfLGOEpybik4SjRKH8utWzlqxrze/5SbYCxlQQQdbuf22CSxqCCMY1QiWWEUKy+angREoWp4p+L4cVVmSEpwzQJqMvMoy+bKzUm6EH1bNqMpIJyE4n9GSM4jdtBcI4MhDUXsH4q937hpX4p1IOSlZyrz74LqpO7bLYWLgoXVNPJyfmTKKQaMGGGdZjxeM5JIRjCBUnEdqfCFCTMdw9gK7wUCm7cFu6WDR8knwI7yzy1FAYejxqqosSzZoULpL/nNf/CYEDO8fUiTwJMLvZz0blkd1oeeVHvA1JrCjiPMN6MEz9mR91T4kaWNFZNxYyaG4exYxIBLiYxLG4Po1yKcGTuxM42NWNdTPmWO6zYikt+WKOEinDuoyiOxGDFqXbU2mY8ViiNom1o0RyaP3nP4V/1RfQv+5BpGctpMSamIeljA5Xx8cIbn8Bte+9RFcuoCAmSLfSxoQkLwCKZ8aGEKg00NhonSJUXHePOf/7penUg5JV9ew8ehyPrb7VipcWVs4LWkHU/awJW1FMp6yilwMyxq09jEYMrVr722cR2P5rZGbMQy7YiIKvyhy0SjJyDnTBxXSgkmmFW4p8OdqhGgfMBMewUJyUiRcGMW+lKa5NJSNC+Pw5pELA4frptg0p0g4eP43BfBLVZg+Ez990O3DgJxEOir4brIFG+jIYE6asmDBDYpBMLE6vKcO8Zw/D137gMvViUGxrLu9EKTY+79nspuVU5il5sAc2PYMbb7Qyh3CagQGgswcnaIyOaVuQZu9tP49dZ9tx39x5wElWNY30/VtvMfDBnnbYfeUwKquhlfnNrqdRKFpqX/iOVuq3GIaV1K/UuhhdYFTMUUvUuxLPF4Qimv9KhpN4tgM2pqfNdwGzZlmpQYiPs2cZfxG8ZtKGNJ2ejGSx94Uk/v2dHbhv4RLrWJY3vv9+YFEb8N9vRTEUIsnoHJjdDUP0aVxu88mSIlbCZAscpNI6xDjrlGTf2K+GRVTCEygkEI+YbKkWU8RsoIkT+/lNFrgRDSoyyOHDSPYX8Ztr7mz3anjzzR3YR9e8qZrh0zNgAV1CwHsH7Eh2SdjcnEMumkBnVwIxpsc4eSeTl6HLdjMxCwFgsqs0Tl+JuNKtrpskEjktrhh5eJwG/BQV5XVA82yStiphd5cDzUs1zJ1TNCWZtRYIfLAL+PAMXgjrOHvNADMGCu1RfGfrs9j+xHd5X1K6yAqCvemRooOB5csk1JHZREc7wZwUj4taTUc0mkU8Zq3gik241cjsC/YTXWu3x5KBZSQNP2s7D8OxrJTERYf7WCcBhiRBsOb15iopx9DRAWzbhnOdObPhe32rS0M69h08hb/8x634h/u/jIpAtZU2yt0GuockE5hgNNFO93JgQmVI17g2aBpVt8CIRnq21F6t9BqmSnNypB9/DPznSxhoD2PLkIbQdS2fVdullhXV7udUt8dzvD0c7tmqqxs3wbeKVL18joajnQpBymis0kbXHjQdn8pHhEJHnwwvJ7Jtlm56x/t0y3e342BnAt9sz+GoPsXXeyYF2GQ3ttStuGWdfUYzqvp70H94b+I/ft1/ZC/V2I3L9bqZfg37zytoa9JR5jJMcMICxqVLDpjI/tK4pe3xte2I8hOLMJ2DEj7qUzCnTMOR3QYOHETX2RCePZfHM0PFySuHaQHkmIY1EoFC+s45vMg1LfCxyKk42B5+8MgZLGusKnxRdsptr8ZQuZBMV15hxY/oLIqejrlypFrWUORLClZjzB1FVhFxLQhUaEshv460M3a7Cz0n0vrRt6N4pa+INwY1DOrX8FLWpAB7snh3VndH2jtrnjsSHmSZX4QnMKPJGR5aeihhbO0IYatD0pvbu9H61k4sogUWlPsws86HcoLzEJydseM1ZKmaJZY8oVIoFguMswGKoSzJJ0ugyd4EItEkznMGTjGjnujPG+3E3Zu6TrefFCAF7Ol4X+j9inhko5M5Lk7lItYbKJ2WI5FBiuPldi6Swzmevs2qtWjFgZKb0pDknPkLgu6dmcZFFaMleCEH98UTp05Gi+sSBtLiiLBy8v/plblJAXLmjEgi+1L9YGijo7IBRlg21yVsklRepcCVM5BJjptdr2xu82vtWMMYu0lSpEqbBI9dKnjl4fMT1iQUG5qaKmz/VpRkwyjmo1LR2N+Xx664hlNJ/fcEUHy6Mnin4dzpuJzL+x1950nVMlxe1x9+zu04oBfyF48PpLYyJyZnunBTwGu7x1tRucJVN8vrrKql7LKZPRzNGLewOUIzslJWmct9IcHiWaN/qoXsN+oivel8Mn44HEu/3p3BbhYcZuYQcp9zF6K87zM+TYBuWXK0VqjfqZ/ZZHfV1KOqOgjJ4UZGl3wXukNtcjrZtrn6+KZlC7M4fpKJPrgGZS0LzYVLYxyFOsy9y181EOWinSpgiKySpfaSKxvcUqR/7fL0wbVb5lHVeK2uNfOhfuYMQm8exg9OpPGr6RLNpABnqsbtmx/4yuNq8xJ0XrgAtdQA8pNNfCz5w5kiVK8DD96fNZex/qfDWtLWC/lxN9eR5V87ilYXw5BMxhwpWN0uBxy1NRgeHkYsmaLGLGLljQbuuhOoocJR3abBZS2NGbkf4MehA3g7THKaVk6d7Ae/U7rJVdNIaXSWbpQ3K26N6j4zGIKH1ZjHpuPikBcvvKyaLyTYKBKNcV0wphScRjVeRRt+h2bkDBl2iuc7OfiHHjKXn5GhHpSoUwOBKtQEg1AdLvSFAZ/TSjOiMBXEpRDovXehts6OLdMWDZO+e+N0NRY0w1wEER2u3FA/csNhE4Tb54fMZBcPzMWrR5px6JgdTq97NKMLcMdRgx2YjWE6YzuBbsvPwe2bZTzwgIE77gCeeAK45RZRGRgMNgM+aj2XvxyhQbsp0yb4NPdXshZcNgcP261GyPUDVO0Of8Gs2A2zEeSoqIarthHO6np4qmr4u91q6/F/puCGItoKhm6+AfMhavEerWaMexOmV63EUwfnoCukjArnhx8GbrjBSvhi4hxeH+IZO+PykqYVxYCLQmLTOixuULD+UwHIILHrpYgefV1EFLG0pvjq8XhH1+7snH2FEkYmwLhpsSqsxEXzBSAnSqWAomFvRxV+/Hqg1Ay1Wp133z1WSTnJKvG8B32DY72s8Quj61ZDagrg69KnAVA0HoxPWMQUsZiPR0ntGTgMjeRDmveWl2wlwUVAlQyeLvgRRIoAi+NUaBGtgcyEkWvamIiz21WKhwp0s07IFS4ByP16Fr1rF2N9pYyG6waoa8W8g1YxSgrEEJ2vRBT5WAR6Psuazg6Nm86CVuS9EWcUzLmG1isnf6bJn9GRJikJ5XvrzuGRe2KjIxfL0b/85bi4p0kVXwU6u4Fo8hPaGySeP1iFQK2KjdcNMBVNbBe9kIrKSoLVzMRt85WbsehgDLqqgrCJAaVitKB/wtqheIstDLfprmJgTrr1D28/ix99PczYtR45SDfs7x+zoLheoWYTz+jodmFwqNTPusRNFy0EWmrwR25pau85/p8AAwCIMN9nzwHmcgAAAABJRU5ErkJggg==' + +EMOJI_BASE64_HAPPY_WINK = b'iVBORw0KGgoAAAANSUhEUgAAADgAAAA4CAYAAACohjseAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyJpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuMy1jMDExIDY2LjE0NTY2MSwgMjAxMi8wMi8wNi0xNDo1NjoyNyAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENTNiAoV2luZG93cykiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6OTFDMjA3MTk3OTk2MTFFQjg3QzdFQTc4QzI5RjM3OTMiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6OTFDMjA3MUE3OTk2MTFFQjg3QzdFQTc4QzI5RjM3OTMiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDo5MUMyMDcxNzc5OTYxMUVCODdDN0VBNzhDMjlGMzc5MyIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDo5MUMyMDcxODc5OTYxMUVCODdDN0VBNzhDMjlGMzc5MyIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PhbG1A0AABOISURBVHja3FoJcFz1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' + + +EMOJI_BASE64_CRY = b'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UmVhZHlxyWU8AAADJmlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDUgNzkuMTYzNDk5LCAyMDE4LzA4LzEzLTE2OjQwOjIyICAgICAgICAiPiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPiA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtbG5zOnhtcE1NPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvbW0vIiB4bWxuczpzdFJlZj0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL3NUeXBlL1Jlc291cmNlUmVmIyIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgQ0MgMjAxOSAoV2luZG93cykiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6ODFFRjMyQzU3NjZBMTFFQzk5RjlGOEVGNzI0N0Q5MkUiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6ODFFRjMyQzY3NjZBMTFFQzk5RjlGOEVGNzI0N0Q5MkUiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDo4MUVGMzJDMzc2NkExMUVDOTlGOUY4RUY3MjQ3RDkyRSIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDo4MUVGMzJDNDc2NkExMUVDOTlGOUY4RUY3MjQ3RDkyRSIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/Pn1wWbgAABOOSURBVHja3FoJkBzVef66p+ee2Z3Z+97VsZIWCQmEOASWBCXJSFy2MQFMKnawcJwEJ6SCyxSpJEXKYMc5IAVVxIVtHELZ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' + +EMOJI_BASE64_DEAD = b'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UmVhZHlxyWU8AAADJmlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDUgNzkuMTYzNDk5LCAyMDE4LzA4LzEzLTE2OjQwOjIyICAgICAgICAiPiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPiA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtbG5zOnhtcE1NPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvbW0vIiB4bWxuczpzdFJlZj0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL3NUeXBlL1Jlc291cmNlUmVmIyIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgQ0MgMjAxOSAoV2luZG93cykiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6OERFNzRCQkY3NjZBMTFFQzkyQjU5MzI2RDQ4OTlFMTgiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6OERFNzRCQzA3NjZBMTFFQzkyQjU5MzI2RDQ4OTlFMTgiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDo4REU3NEJCRDc2NkExMUVDOTJCNTkzMjZENDg5OUUxOCIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDo4REU3NEJCRTc2NkExMUVDOTJCNTkzMjZENDg5OUUxOCIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/Pmz6ud4AABNfSURBVHja3FoJkBzleX3dPffMzt73ag+dK4nViSLABolDgMDYYNmGBMJlm0DASYXEFWKCj1Bgp0wljp2EmFA+ioBJkB2D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' + +EMOJI_BASE64_FINGERS_CROSSED = b'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UmVhZHlxyWU8AAADJmlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDUgNzkuMTYzNDk5LCAyMDE4LzA4LzEzLTE2OjQwOjIyICAgICAgICAiPiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPiA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtbG5zOnhtcE1NPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvbW0vIiB4bWxuczpzdFJlZj0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL3NUeXBlL1Jlc291cmNlUmVmIyIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgQ0MgMjAxOSAoV2luZG93cykiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6NEY2RkNFOUY3NjZBMTFFQ0JCNjdDOEVFREIxMDEwREEiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6NEY2RkNFQTA3NjZBMTFFQ0JCNjdDOEVFREIxMDEwREEiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDo0RjZGQ0U5RDc2NkExMUVDQkI2N0M4RUVEQjEwMTBEQSIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDo0RjZGQ0U5RTc2NkExMUVDQkI2N0M4RUVEQjEwMTBEQSIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PvIpiW8AABYsSURBVHja7FpncBzneX529+72+uGAQ2+EQIAEQUKkRJEU1SiZjiUlskpmbMeSx3bcFDfNuMQtjmVnRilj/7CVWC4Zy5Ft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' + +EMOJI_BASE64_GUESS = b'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UmVhZHlxyWU8AAADJmlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDUgNzkuMTYzNDk5LCAyMDE4LzA4LzEzLTE2OjQwOjIyICAgICAgICAiPiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPiA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtbG5zOnhtcE1NPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvbW0vIiB4bWxuczpzdFJlZj0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL3NUeXBlL1Jlc291cmNlUmVmIyIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgQ0MgMjAxOSAoV2luZG93cykiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6QjBBQTA4OUU3NjZBMTFFQ0I1QzNGQjM3M0JDOEI4MTAiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6QjBBQTA4OUY3NjZBMTFFQ0I1QzNGQjM3M0JDOEI4MTAiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDpCMEFBMDg5Qzc2NkExMUVDQjVDM0ZCMzczQkM4QjgxMCIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDpCMEFBMDg5RDc2NkExMUVDQjVDM0ZCMzczQkM4QjgxMCIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PtYnwnMAABh/SURBVHja3FppdBzllb1VvXerW2pJrd2yJVmysC1vmMVgsCEQGwJJCDOEQAiTwGQyDJyTQMjChOw5SVhCDslMZk5mhgQI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' + +EMOJI_BASE64_CLAP = b'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UmVhZHlxyWU8AAADJmlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDUgNzkuMTYzNDk5LCAyMDE4LzA4LzEzLTE2OjQwOjIyICAgICAgICAiPiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPiA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtbG5zOnhtcE1NPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvbW0vIiB4bWxuczpzdFJlZj0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL3NUeXBlL1Jlc291cmNlUmVmIyIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgQ0MgMjAxOSAoV2luZG93cykiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6NTlFNEJBQ0Q3NjZBMTFFQzkxMUM5MzNGMEU4QTJEMkMiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6NTlFNEJBQ0U3NjZBMTFFQzkxMUM5MzNGMEU4QTJEMkMiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDo1OUU0QkFDQjc2NkExMUVDOTExQzkzM0YwRThBMkQyQyIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDo1OUU0QkFDQzc2NkExMUVDOTExQzkzM0YwRThBMkQyQyIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PpRQcoQAABN2SURBVHja7Fp5dFzldf+9ZfYZzYxWa7Mkb8JavGCxGLwJm8VsBwiEpTgQ3JjStCclCZSQhARSWs5Je2jLSctJAsGNwaFs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' + +EMOJI_BASE64_NO_HEAR = b'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UmVhZHlxyWU8AAADJmlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDUgNzkuMTYzNDk5LCAyMDE4LzA4LzEzLTE2OjQwOjIyICAgICAgICAiPiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPiA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtbG5zOnhtcE1NPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvbW0vIiB4bWxuczpzdFJlZj0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL3NUeXBlL1Jlc291cmNlUmVmIyIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgQ0MgMjAxOSAoV2luZG93cykiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6NDU0N0I2QUU3NjZBMTFFQ0JBMTJCNUY1RjE3MDA3QTQiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6NDU0N0I2QUY3NjZBMTFFQ0JBMTJCNUY1RjE3MDA3QTQiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDo0NTQ3QjZBQzc2NkExMUVDQkExMkI1RjVGMTcwMDdBNCIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDo0NTQ3QjZBRDc2NkExMUVDQkExMkI1RjVGMTcwMDdBNCIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PkaeQgQAABVrSURBVHja7FoJdFzldf7eNqtGI432xZJlLZZkA8YGb6wmGFyMISEkxCZtoTShTQiQQ5MGSA5pmnIIoQlNWNwcmgTXYXHK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' + +EMOJI_BASE64_NO_SEE = b'iVBORw0KGgoAAAANSUhEUgAAADgAAAA4CAYAAACohjseAAAKQ2lDQ1BJQ0MgcHJvZmlsZQAAeNqdU3dYk/cWPt/3ZQ9WQtjwsZdsgQAiI6wIyBBZohCSAGGEEBJAxYWIClYUFRGcSFXEgtUKSJ2I4qAouGdBiohai1VcOO4f3Ke1fXrv7e371/u855zn/M55zw+AERImkeaiagA5UoU8Otgfj09IxMm9gAIVSOAEIBDmy8JnBcUAAPADeXh+dLA//AGvbwACAHDVLiQSx+H/g7pQJlcAIJEA4CIS5wsBkFIAyC5UyBQAyBgAsFOzZAoAlAAAbHl8QiIAqg0A7PRJPgUA2KmT3BcA2KIcqQgAjQEAmShHJAJAuwBgVYFSLALAwgCgrEAiLgTArgGAWbYyRwKAvQUAdo5YkA9AYACAmUIszAAgOAIAQx4TzQMgTAOgMNK/4KlfcIW4SAEAwMuVzZdL0jMUuJXQGnfy8ODiIeLCbLFCYRcpEGYJ5CKcl5sjE0jnA0zODAAAGvnRwf44P5Dn5uTh5mbnbO/0xaL+a/BvIj4h8d/+vIwCBAAQTs/v2l/l5dYDcMcBsHW/a6lbANpWAGjf+V0z2wmgWgrQevmLeTj8QB6eoVDIPB0cCgsL7SViob0w44s+/zPhb+CLfvb8QB7+23rwAHGaQJmtwKOD/XFhbnauUo7nywRCMW735yP+x4V//Y4p0eI0sVwsFYrxWIm4UCJNx3m5UpFEIcmV4hLpfzLxH5b9CZN3DQCshk/ATrYHtctswH7uAQKLDljSdgBAfvMtjBoLkQAQZzQyefcAAJO/+Y9AKwEAzZek4wAAvOgYXKiUF0zGCAAARKCBKrBBBwzBFKzADpzBHbzAFwJhBkRADCTAPBBCBuSAHAqhGJZBGVTAOtgEtbADGqARmuEQtMExOA3n4BJcgetwFwZgGJ7CGLyGCQRByAgTYSE6iBFijtgizggXmY4EImFINJKApCDpiBRRIsXIcqQCqUJqkV1II/ItchQ5jVxA+pDbyCAyivyKvEcxlIGyUQPUAnVAuagfGorGoHPRdDQPXYCWomvRGrQePYC2oqfRS+h1dAB9io5jgNExDmaM2WFcjIdFYIlYGibHFmPlWDVWjzVjHVg3dhUbwJ5h7wgkAouAE+wIXoQQwmyCkJBHWExYQ6gl7CO0EroIVwmDhDHCJyKTqE+0JXoS+cR4YjqxkFhGrCbuIR4hniVeJw4TX5NIJA7JkuROCiElkDJJC0lrSNtILaRTpD7SEGmcTCbrkG3J3uQIsoCsIJeRt5APkE+S+8nD5LcUOsWI4kwJoiRSpJQSSjVlP+UEpZ8yQpmgqlHNqZ7UCKqIOp9aSW2gdlAvU4epEzR1miXNmxZDy6Qto9XQmmlnafdoL+l0ugndgx5Fl9CX0mvoB+nn6YP0dwwNhg2Dx0hiKBlrGXsZpxi3GS+ZTKYF05eZyFQw1zIbmWeYD5hvVVgq9ip8FZHKEpU6lVaVfpXnqlRVc1U/1XmqC1SrVQ+rXlZ9pkZVs1DjqQnUFqvVqR1Vu6k2rs5Sd1KPUM9RX6O+X/2C+mMNsoaFRqCGSKNUY7fGGY0hFsYyZfFYQtZyVgPrLGuYTWJbsvnsTHYF+xt2L3tMU0NzqmasZpFmneZxzQEOxrHg8DnZnErOIc4NznstAy0/LbHWaq1mrX6tN9p62r7aYu1y7Rbt69rvdXCdQJ0snfU6bTr3dQm6NrpRuoW623XP6j7TY+t56Qn1yvUO6d3RR/Vt9KP1F+rv1u/RHzcwNAg2kBlsMThj8MyQY+hrmGm40fCE4agRy2i6kcRoo9FJoye4Ju6HZ+M1eBc+ZqxvHGKsNN5l3Gs8YWJpMtukxKTF5L4pzZRrmma60bTTdMzMyCzcrNisyeyOOdWca55hvtm82/yNhaVFnMVKizaLx5balnzLBZZNlvesmFY+VnlW9VbXrEnWXOss623WV2xQG1ebDJs6m8u2qK2brcR2m23fFOIUjynSKfVTbtox7PzsCuya7AbtOfZh9iX2bfbPHcwcEh3WO3Q7fHJ0dcx2bHC866ThNMOpxKnD6VdnG2ehc53zNRemS5DLEpd2lxdTbaeKp26fesuV5RruutK10/Wjm7ub3K3ZbdTdzD3Ffav7TS6bG8ldwz3vQfTw91jicczjnaebp8LzkOcvXnZeWV77vR5Ps5wmntYwbcjbxFvgvct7YDo+PWX6zukDPsY+Ap96n4e+pr4i3z2+I37Wfpl+B/ye+zv6y/2P+L/hefIW8U4FYAHBAeUBvYEagbMDawMfBJkEpQc1BY0FuwYvDD4VQgwJDVkfcpNvwBfyG/ljM9xnLJrRFcoInRVaG/owzCZMHtYRjobPCN8Qfm+m+UzpzLYIiOBHbIi4H2kZmRf5fRQpKjKqLupRtFN0cXT3LNas5Fn7Z72O8Y+pjLk722q2cnZnrGpsUmxj7Ju4gLiquIF4h/hF8ZcSdBMkCe2J5MTYxD2J43MC52yaM5zkmlSWdGOu5dyiuRfm6c7Lnnc8WTVZkHw4hZgSl7I/5YMgQlAvGE/lp25NHRPyhJuFT0W+oo2iUbG3uEo8kuadVpX2ON07fUP6aIZPRnXGMwlPUit5kRmSuSPzTVZE1t6sz9lx2S05lJyUnKNSDWmWtCvXMLcot09mKyuTDeR55m3KG5OHyvfkI/lz89sVbIVM0aO0Uq5QDhZML6greFsYW3i4SL1IWtQz32b+6vkjC4IWfL2QsFC4sLPYuHhZ8eAiv0W7FiOLUxd3LjFdUrpkeGnw0n3LaMuylv1Q4lhSVfJqedzyjlKD0qWlQyuCVzSVqZTJy26u9Fq5YxVhlWRV72qX1VtWfyoXlV+scKyorviwRrjm4ldOX9V89Xlt2treSrfK7etI66Trbqz3Wb+vSr1qQdXQhvANrRvxjeUbX21K3nShemr1js20zcrNAzVhNe1bzLas2/KhNqP2ep1/XctW/a2rt77ZJtrWv913e/MOgx0VO97vlOy8tSt4V2u9RX31btLugt2PGmIbur/mft24R3dPxZ6Pe6V7B/ZF7+tqdG9s3K+/v7IJbVI2jR5IOnDlm4Bv2pvtmne1cFoqDsJB5cEn36Z8e+NQ6KHOw9zDzd+Zf7f1COtIeSvSOr91rC2jbaA9ob3v6IyjnR1eHUe+t/9+7zHjY3XHNY9XnqCdKD3x+eSCk+OnZKeenU4/PdSZ3Hn3TPyZa11RXb1nQ8+ePxd07ky3X/fJ897nj13wvHD0Ivdi2yW3S609rj1HfnD94UivW2/rZffL7Vc8rnT0Tes70e/Tf/pqwNVz1/jXLl2feb3vxuwbt24m3Ry4Jbr1+Hb27Rd3Cu5M3F16j3iv/L7a/eoH+g/qf7T+sWXAbeD4YMBgz8NZD+8OCYee/pT/04fh0kfMR9UjRiONj50fHxsNGr3yZM6T4aeypxPPyn5W/3nrc6vn3/3i+0vPWPzY8Av5i8+/rnmp83Lvq6mvOscjxx+8znk98ab8rc7bfe+477rfx70fmSj8QP5Q89H6Y8en0E/3Pud8/vwv94Tz+4A5JREAAAAZdEVYdFNvZnR3YXJlAEFkb2JlIEltYWdlUmVhZHlxyWU8AAADJmlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDUgNzkuMTYzNDk5LCAyMDE4LzA4LzEzLTE2OjQwOjIyICAgICAgICAiPiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPiA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtbG5zOnhtcE1NPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvbW0vIiB4bWxuczpzdFJlZj0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL3NUeXBlL1Jlc291cmNlUmVmIyIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgQ0MgMjAxOSAoV2luZG93cykiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6MzU4MEM5QjA3NjZBMTFFQ0I0RDFDM0JGRDAxREI4MzYiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6MzU4MEM5QjE3NjZBMTFFQ0I0RDFDM0JGRDAxREI4MzYiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDozNTgwQzlBRTc2NkExMUVDQjREMUMzQkZEMDFEQjgzNiIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDozNTgwQzlBRjc2NkExMUVDQjREMUMzQkZEMDFEQjgzNiIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/Ptt4wcgAABQcSURBVHja7FppcFvXdf7ee9gBggtAggQ3kZSofbVkbXZsRd7kJk7cyk1ST5xMGmebJuP0RzJum7SJE6fLpJ7Y03Qyjtt4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' + +EMOJI_BASE64_NO_SPEAK = b'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UmVhZHlxyWU8AAADJmlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDUgNzkuMTYzNDk5LCAyMDE4LzA4LzEzLTE2OjQwOjIyICAgICAgICAiPiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPiA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtbG5zOnhtcE1NPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvbW0vIiB4bWxuczpzdFJlZj0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL3NUeXBlL1Jlc291cmNlUmVmIyIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgQ0MgMjAxOSAoV2luZG93cykiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6MkNFRTlDNDc3NjZBMTFFQ0E0NEVEMEU3NzYzNTM5NjMiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6MkNFRTlDNDg3NjZBMTFFQ0E0NEVEMEU3NzYzNTM5NjMiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDoyQ0VFOUM0NTc2NkExMUVDQTQ0RUQwRTc3NjM1Mzk2MyIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDoyQ0VFOUM0Njc2NkExMUVDQTQ0RUQwRTc3NjM1Mzk2MyIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PjUQN8AAABUSSURBVHja7FppdBzVlf6qu3pfJLX2HVle5VUYgyE2igBbxmTweMgQlsQxECdxEpPtkGVOyDowk4QTCGQOITlMEkKcsCTg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' + +EMOJI_BASE64_PRAY = b'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UmVhZHlxyWU8AAADJmlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDUgNzkuMTYzNDk5LCAyMDE4LzA4LzEzLTE2OjQwOjIyICAgICAgICAiPiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPiA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtbG5zOnhtcE1NPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvbW0vIiB4bWxuczpzdFJlZj0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL3NUeXBlL1Jlc291cmNlUmVmIyIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgQ0MgMjAxOSAoV2luZG93cykiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6NkQ3NThDMjQ3NjZBMTFFQ0I4MzFDMjVGNjQ4NEE3MDIiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6NkQ3NThDMjU3NjZBMTFFQ0I4MzFDMjVGNjQ4NEE3MDIiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDo2RDc1OEMyMjc2NkExMUVDQjgzMUMyNUY2NDg0QTcwMiIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDo2RDc1OEMyMzc2NkExMUVDQjgzMUMyNUY2NDg0QTcwMiIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/Pil+3MoAABWHSURBVHjaxFoJeFzVdf7fm30kjaTRLmuxZUvygo2MHRuDHewAhkASMC0QklDHDiShSUqgab+2KQkkDR9p01JCE5OQ1ElLmqRQAjbBxiHgFGy84AV5kSV5kWRr10izr2/pf9+bsUb2yFawkz5/17M93Xf+e875z3/ue5Ku68gcw8PD2L59Oy71UDml/j7/1iIBEi7fYc3+cOTIEdx1112XPKlLGMoXFSjgWzfBOtLXks2foIl1SL8mCCjGESG4YPgSFueiAN/P4TStbq6yoMllwZU0dF5DOWorvChyueCx2uCm4U66xSpJBkBrGpjC4NE4EoqCWCKBcCgMf8cABkMxdFhkHDyTwNEU0B4BwvofE2AezXTqWFxpxe3NVVg9swazm5tRMO8KLzzeUhSXE12RFS7bGP00QDi+c+POYgxhtU6HqyhSEkAwBIzw1AgR9ZwB9h+C3teP9vdOYq8vjhd8OraHdAS13wOtlJ2DIv9WrVo1OTCaVSVhTZ0bX2yZi5U33eSWW5bOQHnjAiC/mSZ76JcAEGoDAgeBOK1UolOwIj0yASyNfx/z04UdwOukhp170NE9gp/2aXh2SMHwVIBOCaDMCzXaMb/Shm+vWIIPr7lrGhauWA6p7CoaVEcgAhQBBd4Cou30mjJu9KUeArDN8DTOdAPPv0w7/xddJ0bxWLeCn4TVSwxRAW6+FZ9qqcLTa+/3FK268yOA92pekPwRHwT8GzkILBEb94Z8GVlCTQ8eNfXAQ/cDV83F9Fc2Y+OhNizfl8CXRpjD7wugALfAgi9ULWl6+o6v3iDNvPpWHFWmQU1p2BNM4PCIDeHkR0mDdyNlcyNBskxJNvPVWPaMjRayipwjMnUaoGQ5S4UdSdj1pPnK4eBwIQq3GAx3ty0KZVEInqYg8rb0f2bRpjcq94Vx52QgrReqRy0kkcJli75b8PRWqbe+FH1xOo6h8vNh4C2/OOvqy8DDk+TkhY5ijhKe9mV6s/Rvbl3yw29v2B7Fuqh2foWZNJgqZNQU1pV/T33kOfm6ulLIMdOjvxwluDFcvhx7P0e6iupk3n33PIHR1WvWzpax1iJN0YPixOlW/C0+sa76wy31qEmMCjpCIO6H23cKtyEMDyJwSzEWOIaRFIdTEoGZMD6L0LJKCpdTFD7NCLPMYtj0FFRJVAjZCAcRyqm0GeL8pG7nLHYjzOM6h5g1/RrS8hBCPmPRjUDCgaj43pGPwD1fwKz9rz86cCa0qU/B6EUBlsqon1GDTzy6ci+m67+CpMZgkXnqyDO4V99lslqYBjFkVa4kCzUU1RyCQFWusJY1jDzUMEGiiLcs5pAkc8iyOcR3FjKnlZezpl/FZ1lcMz9tMat/3whr5ZgVSYsbqisf2+Yl67v7cTcBbrgowBIZd628FkWzZtbSsoS5+gnWtORBtLMKvLgZONnF66TSwNIAM2CzAeqaCcZ4f06GGKDSoW6AvQBA8VriBa5fCdx0A/Wfm+vsUxgxQTgSQSxrAfbuwqfdKfyQuajmBCgKp7hWRR5uv3Y5Z3A0m1ZJdgLcg22vxPHPXJ9w0gbZU8gr2AwrpczSp18lwy1Z1mco+dxUMpRMGrUmGIJByi91Y3VU4zddrFCS58RUtPeH8M7eCA62Ap//jFkjBRL+hGn0RUMtFraOYT6lxcGcAHVTWzY01WFBQ3MlbSszDbAEcerd3XiS4GL2QliamqDb887G3LnUdSli+ezf8rpqROQBrZctkO1OyHSpdagbm7f2wEsWXUFvRqOmiQ7K+cZG2CqPYdVwfBzgeSxKWAsbZyLfUV7Dv6TnJK5B/Ag2vTSIYIJLVjfLBKcp5ir/oYagITpdG/NBGxmCMnAGajAApXIGrCXFeONNCie/GTTp9UA9hYDDglVWaZIyoZvsu2RuE9/Ya81vLDrCp3Zh3wG+LSqC7sw3wf2hD1osufMg53tMFhKtR2AUGpW4VFaJQdbi4yfM3ExHOEpKyf6laMyXkJcToJ3zuGXMrxfY7BWm97R+dLYeQ08/T2an8Mc+5LwC002Smc9aKADNVQCVfdjRtvTXMAnOw7UoKEBNUsO0nAA9Muz1hajyeEkeUoH5c+Q9HDwQg8LapZGOz6VCKU0G56t4zRhTFi/psDzXizJdJNvsabKjuEsmDNknEfjpHrO1ks2SCqfTAJnvklGRE6CiwevJR7Gn0GX25UIqjL6Lw22CfdzMPZfJ+1kgVEfeeCJkGaZZ7RyOnOBzhaPizDufEgTR2DmHCNPMPGJudseSpwgjDFMxRBk5W8MZZDV21OTOQR0e1pfCvHwuhUwPpnowxh6lf0iESp7BZtkrnnIXovWz38XI/JWwpOLjSojd6/HbHsbx2x823l8wBKkMkoVlaH3gGfiblnCeRNZvSXTe+VV0f+zBCfPo7Fx0dz6ivOTg0HiYiqPQYyi5ysnqYJ7Twb7WxVWTCCj2OldIwYhQalX5EwJIYlarJJzwtNmIeadN9BTfR6obx+ntgt4z54nUzES8uDIrQnQj58JVs+Bgp2LU2fRcOhWGTk9IrMMDA6kJ0wkBwMObu0zoLCd2fmcToUWmjBwwwMWEcxyunEVLVlOQleR5oSU8Ibwz5eLHU8+dR6SAnIzTIDKqnPaFcBfn1YWQYBr4RiZORfuRFnU566BVyCPIDlOaxXswPJpW3xbrBBLQeSFJyDh6MllQAoswhAaKUBUXT7mLIKUSUyMYNWnMncwX88Q4T8p4FTmsMA1EqE7Ic15T42edTBoKjetdIz2sZzf2cmtR4wQyJsLvGX8ZCMLIPV1kcnYU8rPTP4jizj3oW3U38oa6UHh8L7+3o+/aP0V0ej1mPffLi3c+FhucvjMoOrEfPTetg3OsD56uVmhULmeu+yQS1RWYsWWDmf+aOsHrEhmWfINE0tStU2l4NSGgoVL/xA+bmz6iT5bSSnhiFhr/z3rpn9DhcKLj3r+DHE5CE5TOU2u2/AxVe16GanNexH2SEcqNzz+OjnsewbH1j0EOsRRQe0mUafUv/wjl7/0GKQKGEjmHoSxG/ROZYMsq+KI1mAxgMqWkO4cEwdjTgvgCq+/wD2Pej/8SgRlXGsQiQrWg5zDyz7TTm7acIjvXPC568YofPIhgw0JEKhtgSUToyUPI6z8OXSyaJOVcnHMPxeScWE6APD8eT7K3jMXPLrvdhguyocY8EGRQ3L4b3vZd5qlcWU0Q1e/TpJMwZEpAb9sOjrcnziMKvJZDNNDDmX4yc8TMahWYDGA4Hkc4wQY9Q5qi/BmxL5grlWTc286/DsNXtzsvXX5yHjXXPDomMknGe2w+RceWETriiESNDBnJyaIkJn84gkA4Ov5LSbEp9ISXtExv8r43k6QLhtekf6Olu+lzpJ1OgC63GWUZs/zsMCIa+nMCZC0NhMIY4zj7S2U53Sw4hiVAZ4Br0cj50iwzmUHvcYPipewaaBCUZPx9RjiffZ9piIXuFGVGlAmWFymLMQUQ3dhMlkx3kj0pu2lTCl7vxAZ6dAx6XxKDOUM0yEU5HcZp9lmLDYC8RikbSy+9OMzmU2KdUoN+yC6XWU/OelMXW+Ro//jXkcovQOXuV0g0R0hAg8Z5Kuulra/TuMUUp+YUhjkIJsq806c1ElDSyMFI5UyMUa4NLboZNb/7Bcr3/Zps6oYWj50V22Z5sEEWjTCFd3X1+PaHYHyaOcqKMZQToNgZIHkePd2LNVd+wFQX5fRgFbX50OkQ5JIqaH4FyqiPTWeZqXL19P0uhrDLdxr+5ttx9LNPwBoIwz3SjZIdL6HxV09g2S23wTvrCuz8ydMoa2xG08pb0bXjNew+fAyd9z+FwOwliJbVG5FTcLod9hDTSLCwkGXR8MTwFCUkHoWNvWpV1TjAEGt2OIQBRuIgtFwkY0bmgY6T6Q9CitFZ82YDB9vDxqpJZB89FoUyMmg0oxIzXOzJCMar3/Ysqne8gFDtPIzOXYYgdWQJa9gNd9yNkLcOnX1DRh+XV9eEttP9qL/mZqxgARvY/xqi1Q1o2PkiijrehbuvwyAQRQRiOGiEaHbOSqKz6es3OgfhAPGzaHz9Y0D3KI6HdcQnVTIDQNvJLsSUKFyGOiDIpYuB/36Js8RClE4U3QwZnRJCTQwbxVZKbzgpomgzyz09nfC8swkWevCDN92CaHkDek+dNDRlnDQ9QnlkKbShs/M4Zi9ZjRtf3ohDD/0MuqfUbJ2oO42bQpkUkMbzVKKosEj8PhjAjCVGg4tkWsL29TECVexW9AvsbPPck719ONnfP74HOZcebGD0qMP0mqCtrAZUhKah7glYp/EqX1OqhlRgFPWUa655S9HTdYorbDV334xinOKfyoYGOH7qFKZfvwbFbhtSdIWxKZwNLAucYTD7QEt4DBYthQULJhLMSUZeXMPOSbfuZTOH490+7DjWbm7LCYHvLARuWMn3/iBsUU5eXmV01NnaaIJBHE41gsYVN2LQN2peRHQYVrtxAZX5I7YWxVBIFAFNRvPS5bBEA4aIz+5Kzg47hUBZBdOS8mygFzW1wMyZ5t6soIIxNgV0yukBBYcmBZhOOwRUbN65x/wQIjN1dAItC4UXdaQ6jsE+eAr2PCesvKBcXALJ8KotbZNOT0bIbtWwlk5DhJkvZ8pKmuYTY+M9joXWjfp8KJ29AB6HbApLAUiEvtMFqaAQFl7HXlQMh7gbeuwgbMkoPrTK3KLQzaqB7i7g1BDeiulT2Lof1vDWe0fQNdiD6YUsEWNkJ7Fz8Jn7gNe2ajjc2oe4rx9W5qMi2iKSjeZ2GXslxm0ydhZVM5sQjCcm5JEm6ihrnBKbyIoKkyhpc6GqthaBUdbRolJDthmkRkKTBvrpXeYte0/huRtWk/jmjueeCKJWNj8BBT9X9CncfInrCHQN42evbsNX133abCIFU4l6uPZeYNPbFrzBSHeQdKwcYwOiwxKbQw62kvRsKgxPbQOGBZlkb5ikS8q5ulI4NkwRWVRdB/tJ6tDgKCQKBrA+uuklF7Mh7JVR22DFZz+mGF17It0vMHLBNAarzZFeBW9O6e5Sikb0afi3TVuwdvk1qCkjsN5hE6Tx9ICbbFlpxYIZMlbPTqGXAPv7FGOMjEQw7JPZr4gCP7F/y2zzyxbrOfcoZOMRi8KyahTLUdTPACoqYdS42mnAYEzGf+22w1pEQpMM3OnwNnNw22sEGcS3ohoiU74BOqJgoGMUf/Hk9/A/Dz8ISaykaCz1dOIKIVHg1FHGes80xBXzzAtSA+DHz+VjLMzO3maboJhlFm5DhdgdOe5T6FT6OqY3AH/+BROEuJYoVYl+cc/CvC+Y6UrFtcTYvBli1+8/+1T8Iqd8vNDTSseS+NX2w/jyvzwFfWDQTGrBF2Ue81ZqJC4ZGljkQvZIpFgT1dx7oiKULU53zq3DIFuBmOjQ4+ZiirmEZI0lzOsUunU47aa4FtH04ovAm9ux5XAcD0TU3LdELngDWtSWdgXfldswOrwBT666AaVLKeGaanSUF+o4MyohSoNslvSdKck0KJrQYGOe2QSx6NlbKZoRtnEtd9MQS1Ks85+iqJndeqN0nRqWjc8tDZpB1kePAb/ZRkXSjh8cSeAhX+p9PoSQAdmWwHO+Prwz/Dz+/t09uOfaZbpjXqWCHV02vHfGghXNKr1mhowwgDofw2N+1LocRn6J0iHIRrQqcSriOD1VQvc4KNMEaHFOnC4bYs30UBXZbKpxXbFwQ0EJB3ssmFHGxfGr2LiRWrIVe3vCeLxXxUuX/BhJBiQL6An2ieva2vCvh09iXX2ZerMjX2/epbCoxyRMq2T4ULeePsPwTioYo25SlQRKmaQOok6Gw+gfNEV+ignWx98rykoZshZECXp4aBixkWFEJdV4yik/z1Bj2HlURrJHgS+sDf/0dezojuA/yGWvsueb0pbd7/WkU7bi8cjIn2ZHi1PGNfx4dV0p6ui4K8UtPNFNBSI2jERcUKgv7RVVUNPtlaunFVpJDeJ5XvafTGjq2mR/L5wJP8oLoiwL5k4eQ9LvD2HXWAxHGBxv9yZwgGvZfTGPXZZn1YRHGS1hfwxi8+RtoU+Y9He01OCXjz1qbml1n0mhpzuFffuCaOvohVJSDwuBikZYjrCnJEC1+wQ8iX5cM0fBlS3ANJaExjqWKKqSxx5H4kQE63uS4935/8vThsYuqwxXvsvyjeX3zbLWNndBCidQW8RwrSExXEWiYhJv2dqBrqP9LB0atJAflrH9WNgQxk03s9ZRnRTR6+JvxB0DT50Xc48UV/T9/MQTvRLWqpewS3JZHrqaKeFO64rr533lxr1oSezFX1v+Ecc8V6GRhbqBImHhlcDnHwCWt4SgUbHYqHTW3BjGp9eTkVnUm7xAZaUNv81bjfX4d7Soe/GjT72JsplVH6+Q0PxH86DYwfdKmFVpwRxNMp43cjLnYmU2+a8O3fww/HoB/JiPVnk+nsRD+LB9K75S8SSWjr0htliNMNy9z4K8fIniXUUJvywlsBdcn8BT+CLelRaPP59G0KdvXG+fd+pbj5fq2MSyamPuR2IqRsieuymqg5cd4Ewbbv/IItvGJcvLijx2H2k8gdY2YO8hCz7UcBovUkgnYKoUhVNvlj6CX8u34k8KnsPHh76Jbb/uROmiFQZb/mbrIcz53HX4vvMfsFNeft69z3JlBFfPiVGD4o4/uw13VJSaAkDcytvyBvb8rhcfHVXH914uGSDNdrfUux//2oaHiwoLWWlH36FRvfBxHYeGUnjCcz8OJDbg+/IDeF6+EwEUpou7hOcL78WRAR/mRB5BWeMcJMsrsX97J57VvoMh52Kjy84cdXoP1uo/xX3xH7Pl6sY6Cu0WNrZXfCC9IU81NaMWS3q+gy/543jkYs+MTjkHqyR87La7r59TmHcQ6HyBdaDX6PYPH2HHP8dsJhfq+/Gsej/eVRbjUfVRzMYxQ51bEiq8r25EcvYKtkCsl8VezG6pQ9XLTxu3B0TZWabvwjPq57FX+QC+oX4NdUo3+0lz27K7Ow1O7LSEmMt0+FVNWF8io/SykAwXzTa3Qnpw5TXs4wa3mH5Pd8fi/uGshvSDq7I5ZknH8XXpMexOLMKGgY/i9lfvQ2QgiK31a3A06cHddyTxtR8uxIyjW/HJnV/GLwaWYYe6DJ+TfoByeWj8yV+rOffBw+nP6fwUOwyrV6K6VMInL0uITneiafUH4a72/G4/kpoBjkkv93RavU4HirxuyOEBOWnR5KQJXddVShRfQLcvGX1FPvVbSLtmrffZrJJyQK2WWseS6vw8K5YuK3cX/Pap4sW3QAlKDsVukZLGE3niWQOFvrVrztpyTT7eLSl+n9pX5NJCxu+MnJXLIV/1GpZ0t0OO6pj0aYf/E2AAhL+26ST4LjYAAAAASUVORK5CYII=' + +EMOJI_BASE64_ZIPPED_SHUT = b'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UmVhZHlxyWU8AAADJmlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDUgNzkuMTYzNDk5LCAyMDE4LzA4LzEzLTE2OjQwOjIyICAgICAgICAiPiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPiA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtbG5zOnhtcE1NPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvbW0vIiB4bWxuczpzdFJlZj0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL3NUeXBlL1Jlc291cmNlUmVmIyIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgQ0MgMjAxOSAoV2luZG93cykiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6NzZEQ0M2NzE3NjZBMTFFQ0E2M0NDMDgwQ0Q2ODFFRTciIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6NzZEQ0M2NzI3NjZBMTFFQ0E2M0NDMDgwQ0Q2ODFFRTciPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDo3NkRDQzY2Rjc2NkExMUVDQTYzQ0MwODBDRDY4MUVFNyIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDo3NkRDQzY3MDc2NkExMUVDQTYzQ0MwODBDRDY4MUVFNyIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PqXvtfMAABTzSURBVHja3FppdFzleX7uMrtmpJFGGm2WsGzLCxjbGLMTtpwYs8SkSQ+0h+JA0iUkOZQEkpbTJE0ICaS0NCc5pG0oPiGn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' + +EMOJI_BASE64_NO_HEAR = b'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UmVhZHlxyWU8AAADJmlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDUgNzkuMTYzNDk5LCAyMDE4LzA4LzEzLTE2OjQwOjIyICAgICAgICAiPiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPiA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtbG5zOnhtcE1NPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvbW0vIiB4bWxuczpzdFJlZj0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL3NUeXBlL1Jlc291cmNlUmVmIyIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgQ0MgMjAxOSAoV2luZG93cykiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6NDU0N0I2QUU3NjZBMTFFQ0JBMTJCNUY1RjE3MDA3QTQiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6NDU0N0I2QUY3NjZBMTFFQ0JBMTJCNUY1RjE3MDA3QTQiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDo0NTQ3QjZBQzc2NkExMUVDQkExMkI1RjVGMTcwMDdBNCIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDo0NTQ3QjZBRDc2NkExMUVDQkExMkI1RjVGMTcwMDdBNCIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PkaeQgQAABVrSURBVHja7FoJdFzldf7eNqtGI432xZJlLZZkA8YGb6wmGFyMISEkxCZtoTShTQiQQ5MGSA5pmnIIoQlNWNwcmgTXYXHK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' + +EMOJI_BASE64_MIKE = b'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' + +EMOJI_BASE64_SUPERHERO = b'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' + +EMOJI_BASE64_BLANK_STARE = b'iVBORw0KGgoAAAANSUhEUgAAADgAAAA4CAYAAACohjseAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAA3ZpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMTQ1IDc5LjE2MzQ5OSwgMjAxOC8wOC8xMy0xNjo0MDoyMiAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0UmVmPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VSZWYjIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtcE1NOk9yaWdpbmFsRG9jdW1lbnRJRD0ieG1wLmRpZDpkNTJlMTYxMC0xNThlLTlhNDItYTY0Ni0yNjViYjMyYzFkMDgiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6NDMxMTQxQTEyOUM0MTFFREE2QUNBOEEzMUIzMDUzQjMiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6NDMxMTQxQTAyOUM0MTFFREE2QUNBOEEzMUIzMDUzQjMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTkgKFdpbmRvd3MpIj4gPHhtcE1NOkRlcml2ZWRGcm9tIHN0UmVmOmluc3RhbmNlSUQ9InhtcC5paWQ6ZDUyZTE2MTAtMTU4ZS05YTQyLWE2NDYtMjY1YmIzMmMxZDA4IiBzdFJlZjpkb2N1bWVudElEPSJ4bXAuZGlkOmQ1MmUxNjEwLTE1OGUtOWE0Mi1hNjQ2LTI2NWJiMzJjMWQwOCIv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' + +EMOJI_BASE64_COOL = b'iVBORw0KGgoAAAANSUhEUgAAADgAAAA4CAYAAACohjseAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAA3ZpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMTQ1IDc5LjE2MzQ5OSwgMjAxOC8wOC8xMy0xNjo0MDoyMiAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0UmVmPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VSZWYjIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtcE1NOk9yaWdpbmFsRG9jdW1lbnRJRD0ieG1wLmRpZDpkNTJlMTYxMC0xNThlLTlhNDItYTY0Ni0yNjViYjMyYzFkMDgiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6NjU2QUIxRjUyOUM1MTFFRDk2Nzg4MjA4RThGMEY0RDUiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6NjU2QUIxRjQyOUM1MTFFRDk2Nzg4MjA4RThGMEY0RDUiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTkgKFdpbmRvd3MpIj4gPHhtcE1NOkRlcml2ZWRGcm9tIHN0UmVmOmluc3RhbmNlSUQ9InhtcC5paWQ6ZDUyZTE2MTAtMTU4ZS05YTQyLWE2NDYtMjY1YmIzMmMxZDA4IiBzdFJlZjpkb2N1bWVudElEPSJ4bXAuZGlkOmQ1MmUxNjEwLTE1OGUtOWE0Mi1hNjQ2LTI2NWJiMzJjMWQwOCIv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' + +EMOJI_BASE64_EYE_ROLL = b'iVBORw0KGgoAAAANSUhEUgAAADgAAAA4CAYAAACohjseAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAA3ZpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMTQ1IDc5LjE2MzQ5OSwgMjAxOC8wOC8xMy0xNjo0MDoyMiAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0UmVmPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VSZWYjIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtcE1NOk9yaWdpbmFsRG9jdW1lbnRJRD0ieG1wLmRpZDpkNTJlMTYxMC0xNThlLTlhNDItYTY0Ni0yNjViYjMyYzFkMDgiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6MzlDNDJCNTQyOUM1MTFFREE0NkZBRDk2MDIyOEZEQTIiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6MzlDNDJCNTMyOUM1MTFFREE0NkZBRDk2MDIyOEZEQTIiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTkgKFdpbmRvd3MpIj4gPHhtcE1NOkRlcml2ZWRGcm9tIHN0UmVmOmluc3RhbmNlSUQ9InhtcC5paWQ6ZDUyZTE2MTAtMTU4ZS05YTQyLWE2NDYtMjY1YmIzMmMxZDA4IiBzdFJlZjpkb2N1bWVudElEPSJ4bXAuZGlkOmQ1MmUxNjEwLTE1OGUtOWE0Mi1hNjQ2LTI2NWJiMzJjMWQwOCIv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' + +EMOJI_BASE64_OK = b'iVBORw0KGgoAAAANSUhEUgAAADgAAAA4CAYAAACohjseAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyZpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMTQ1IDc5LjE2MzQ5OSwgMjAxOC8wOC8xMy0xNjo0MDoyMiAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTkgKFdpbmRvd3MpIiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOjREOTYwNDQzMkMzNjExRUQ4MTVBQjZCNjk3OTUzN0IxIiB4bXBNTTpEb2N1bWVudElEPSJ4bXAuZGlkOjREOTYwNDQ0MkMzNjExRUQ4MTVBQjZCNjk3OTUzN0IxIj4gPHhtcE1NOkRlcml2ZWRGcm9tIHN0UmVmOmluc3RhbmNlSUQ9InhtcC5paWQ6NEQ5NjA0NDEyQzM2MTFFRDgxNUFCNkI2OTc5NTM3QjEiIHN0UmVmOmRvY3VtZW50SUQ9InhtcC5kaWQ6NEQ5NjA0NDIyQzM2MTFFRDgxNUFCNkI2OTc5NTM3QjEiLz4gPC9yZGY6RGVzY3JpcHRpb24+IDwvcmRmOlJERj4gPC94OnhtcG1ldGE+IDw/eHBhY2tldCBlbmQ9InIiPz67w/ebAAAYJUlEQVR42txa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' + +EMOJI_BASE64_QUESTION = b'iVBORw0KGgoAAAANSUhEUgAAADgAAAA4CAYAAACohjseAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAA3ZpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMTQ1IDc5LjE2MzQ5OSwgMjAxOC8wOC8xMy0xNjo0MDoyMiAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0UmVmPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VSZWYjIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtcE1NOk9yaWdpbmFsRG9jdW1lbnRJRD0ieG1wLmRpZDpkNTJlMTYxMC0xNThlLTlhNDItYTY0Ni0yNjViYjMyYzFkMDgiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6QTM5RTlGQzYyOUM0MTFFREIxNTdEMDBGMzhGNjVBNEIiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6QTM5RTlGQzUyOUM0MTFFREIxNTdEMDBGMzhGNjVBNEIiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTkgKFdpbmRvd3MpIj4gPHhtcE1NOkRlcml2ZWRGcm9tIHN0UmVmOmluc3RhbmNlSUQ9InhtcC5paWQ6ZDUyZTE2MTAtMTU4ZS05YTQyLWE2NDYtMjY1YmIzMmMxZDA4IiBzdFJlZjpkb2N1bWVudElEPSJ4bXAuZGlkOmQ1MmUxNjEwLTE1OGUtOWE0Mi1hNjQ2LTI2NWJiMzJjMWQwOCIv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' + +EMOJI_BASE64_SKEPTIC = b'iVBORw0KGgoAAAANSUhEUgAAADgAAAA4CAYAAACohjseAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAA3ZpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMTQ1IDc5LjE2MzQ5OSwgMjAxOC8wOC8xMy0xNjo0MDoyMiAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0UmVmPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VSZWYjIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtcE1NOk9yaWdpbmFsRG9jdW1lbnRJRD0ieG1wLmRpZDpkNTJlMTYxMC0xNThlLTlhNDItYTY0Ni0yNjViYjMyYzFkMDgiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6RTIzRDJEN0QyOUM0MTFFREE3NTdFMjk3NzIzN0VDMjYiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6RTIzRDJEN0MyOUM0MTFFREE3NTdFMjk3NzIzN0VDMjYiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTkgKFdpbmRvd3MpIj4gPHhtcE1NOkRlcml2ZWRGcm9tIHN0UmVmOmluc3RhbmNlSUQ9InhtcC5paWQ6ZDUyZTE2MTAtMTU4ZS05YTQyLWE2NDYtMjY1YmIzMmMxZDA4IiBzdFJlZjpkb2N1bWVudElEPSJ4bXAuZGlkOmQ1MmUxNjEwLTE1OGUtOWE0Mi1hNjQ2LTI2NWJiMzJjMWQwOCIv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' + +EMOJI_BASE64_SLEEPING = b'iVBORw0KGgoAAAANSUhEUgAAADgAAAA4CAYAAACohjseAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAA3ZpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMTQ1IDc5LjE2MzQ5OSwgMjAxOC8wOC8xMy0xNjo0MDoyMiAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0UmVmPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VSZWYjIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtcE1NOk9yaWdpbmFsRG9jdW1lbnRJRD0ieG1wLmRpZDpkNTJlMTYxMC0xNThlLTlhNDItYTY0Ni0yNjViYjMyYzFkMDgiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6OEMzN0M3NDAyOUM0MTFFREEyOUNENzdBMTRBNTI1NTkiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6OEMzN0M3M0YyOUM0MTFFREEyOUNENzdBMTRBNTI1NTkiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTkgKFdpbmRvd3MpIj4gPHhtcE1NOkRlcml2ZWRGcm9tIHN0UmVmOmluc3RhbmNlSUQ9InhtcC5paWQ6ZDUyZTE2MTAtMTU4ZS05YTQyLWE2NDYtMjY1YmIzMmMxZDA4IiBzdFJlZjpkb2N1bWVudElEPSJ4bXAuZGlkOmQ1MmUxNjEwLTE1OGUtOWE0Mi1hNjQ2LTI2NWJiMzJjMWQwOCIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PrS+R4AAAA5YSURBVHjazFoJdFNVGv5e1oa26Qbdi4VSqGVk3wV0QBBQFJRFzwzCGRBmEHEURdxGxmERFcVlUFARQQXZZa2CIFCkndKyU1ooLRRoE9omXWiTZnnz3/fSJmnSNuki3HPuuS/vLu9+///ff7vhcBeUESHoTs2pRoZ9sL8Yr3i7NneXAORTl6ejLK6X2351Tjr6z+vDE0CJt2tLcJcUq1RWb1+/+QNY85emrMu1Mme6sP1RTaQaR7Wd7ZvlVIupZlNNpjqa6gLHuUfW3IAxOBL+uacw4MWeIO41aa+yFgIyippnOA6TeB5S9u7B+8Ua3wEIDADkctd5lZVA7jXgvc+AAg0h53CL5m8ytwl4joFjhYGjMsTNNwOpiaIaTjXa1kY4PKcQUV7mmgjocdrMCtpMbKcO4F+YCa5znLDBZpXsfGDOS8DBjeWw+Pih3Ynd6LForNPZY+fVcY4hJBrVQeEwhkTBGBRJlT1Ho+PGhfApyv9U5gWo3gTgoEQC9fJ3SOa6hgKhE4gNmRx0h7wCUloGFJUAJTqgN+lPiW37B/bThtvdI4BjhcCxpnPd+ft38I1+I+RkEgMYKPMAGKHA5hdn0UF5WA20JxIrSBK0PwI3VnoMauTEOkpFpiDKR8Dn1lWk7dBBTxI3Yh+H5O1XhP6YfbVrX6I9HKR2OnEyz9PvhR3bzJqpsgaAMRkvmTwO3PQZRMT288SO8gw6OAs9+shJrid2cWOxW/IoLdbPLeVHjOMEcD2WjkNpfD/wnMjOkIwk3Bj5rKBozCr1sM7fzMutUYp9F9wPeYUOypKbkFWWCuMPf6tBdUCo8Nxr4cPCKyIIz9UDbr5Mind3b1Rxki4fitaEtxAt51JrdhmfxI3CJskkbJM8gVIEuCfYOPunqkJjkbw6F/1eHYRqdVucemOn0N+Q6An9pElpb4wCz1DVUy1gzyb/kNm/rS9yGetWi9IC2xLiMf6TL0kyg0eIL/VHAc13OMvdh/XSKVgvmYJCQVF5Xtjm2xRcxv3/iMexVTngeCsCso5jwZZhGGqSYwyNUWnzIC8vhrLoOpS6Aij1hcKzT/H1mr1JaONWelzrsN+UI1/n135n0PNdWbPOrZmgwf9O7ILxK757GxplT1zgEjG3oCPOVc4kPb/ea604gE/BI9Y9GMvvQnf+NBauAfZ17i+I4eBZHfEYGZd+FQeRlklGMpYmzOwApRJoGwz4kZ7xUQH+/mRmyGLcO4x8tQ+RRaPiHfa7qio8DlaFSvRazNXwzb/AuDfVBSA7c1Z14L98f9BhtVJ891UhCFzjQMbwezHRuhkTrFvghwqX/nMEYC15mr+nAf/bkQKpoQIqTS52JkGojsVoJN1FgpdEOuzkZec+EuBOdZaemfz5pdofQ6bHsGaRW0NPJqA4Z5MOk2zgLLRaWp29JvIXMNX6rVDDoHHqqzKQS5IKHE0BUtNpM26Ok2bQBHFtMgM1542dF2OHnrD6+NpQ8FBdPIZRk4Gli53nz5jOqI7VxKGZxJC9usQhtcZXXlYERamWce8tF1eNBofKhg7VPLbycK1FTSk4h8FlHwlgpLC4cCSJFPehY4DJ5AqEl/vAog6BxS8Y1jai0lFlJuPcP9cJG1LoCoVzpSwpIHW+CeX+USS2UpHiEVHgyDCqMo9h3GNA//7Oa7/2BmqUDe+olIZPUkFSbSiwKR/mBjFWJdZwcHvfmQPwN+takHUSea+fJbQ60sIrvgCOn3ADRCaHJTgUloB2xAG/BsXYEN8fXVfOAi+R0jyFUEHVGNsNfIm+dpy54AbkkTGwqtTYsbPMBWAoWQJSfacLhzxtjxiqDbj6+DwYA8MjjG2jI6iFrKqczMXIKzUc5H/ZRka80/v2lbJmYdY80VcUDLPSF5bAMJipQiJtWa+cxNJ0M98hxpFAHhEtcP0pEtXu3exd12g/n6/yzJth4m/Xor5d7D0V57BilQjOGNudqOnfurESiS1HKpM3VNmoaRUq4/LGH6udAF4ixVPU5xGPiCaIPHFP3o0FM4poe6fuF+w9QOJCrlSrg6vRdiHtYLpxrfa3SVsAjojrczkNZvItZDZW9KaY+MD7e/DQk3JwFnNjy77BpvXowpSvzAFIZZb4kfC4PzbqlZLoW2wKjVpeLqr0rduAyZPE14HkQLZvz0TV3JtJLCmcosYi+g5RLIqS+NS+zDhzZ6J6WXA7Z4e8opy0sBqnTjuP69NHaGIbA1cDMChA7Si71Thz4c4A5BQKp9+WMh1MoR1cxiWIAdQTnuZkjOxMw2qwBWvHkZ1zlyRqSE/U6IDCQvtr5r6RXhrhKcDq28wds9jclrJU3Cz847GY/IKQvPSooE3dlfQMFyUZ6inAHJYPgckmzlU5QsTd7PxDnaLv1Afp835w22emc5ZKIZOwIZUzQN7mKmVfauK5pnohj2lnU4mTMqtxnbwp5TH3wj8/031u8+pZVLWNEbgUlr4XYWm7BWBXHnkehpAoYUzXb16mc6h0BkimgKdoQautahJAFl+VX2TUMV63yzh5XZzV1caYfAPrXUjbaxROz15d/4dMRiRsEDMBmt5jcObvK3HhmXdrwUX/th5B2ankh9YhrNVC4ZBP0zVzjb8JM/mDxhvixyLEkKVuSX1zF3wLc9Dz42lO7zOnLEVx4uBGP9b2zK8YTPXSk69C23MUeKkM7U7tR+fNi4mgllqvxlW9SpoH0C5j4kmmoBepGa6DA3MyoI/rJYiZ2wz0knEefTR+6zKhulWcdb0TAlcLvikiaiOa6OlWnBQ36v6KAH/66gXEJn3hughF0gPfHgFFeXHzU/iVzhE2Rz4aV21oHgdJ5X6RdhKL+/YURVRIH7DF6dxYrTw4pf0MRB/+XqitdkdRXloHIPmcJgMiIprBQSorNu5wg74onyha8QcZdR5mbYHbSEPQsInNAEiatPJsHfesI3FRpi+ENCAYZs3NJi1eERmPlLd249TzX7tVFCw8MmsLhSiCxYN83fSAg77p07t5HGSEcmLV/OfswSdP8YpF5935MvkFE7A1ZOsCCGhnJC85DEupTgAiAKJqLr5FoKrrXUMeEQNZiUjcAId0q8UqNGleASQJmb1llzMHWVHcuAhpUAiJ6m2B2o2JGW80CEByh/7V9XxRdOA2G1VXKpVKyKPaQ67NhVxzBQkJzv3nzwvNGq/MBInpeqzDuglj7Z2PjgR2/1IESVSCwEFGbcegtKESmLEf2iETvRMnXz9IA4MhLbsFRWay8G7oEGD0KOdxP4le3SqvOGgTUycdPfdZGxcLLkEW5p0aC0r/Gf7Zdinqsnxa/dwKj4I8Mhqq21ohD6O4kQWJkoN/H6ULOCEerxQYwnvFQZuEPfSf5fj9rXn2dyz7vDNJAy4iHhJ1AKxlpR6DTFw0wRUQ2TWJn1rgFkf2U154BVIHT5opkycp0luyXeF2zU8+EzMXTfJkiCrHkeI8YM50MfvMKGuI6w2ezgajhLWqUhBZ3myypxnY+WKXfeRPcuSxc3KyYXJFrQMtMVRAqtdAdu2Ks2cTDyF71sYhkJCQBrXW4dH5TCEuzKZ9ZjTNVRPLi2s34KNpTwNnaB8m8pw+JK/q5dcAn5x0O7fJj+SVvkLehD2LZsBKRtkscIY5CVy1+wggKAj48wNA374NnGFfHiUVdjtxMRv4/nsYSCV08SpL4DafyPKkm8mO0f6y8p373vlaDpPOArnBiqpGIpiwMCAqkjyjODqDtC3fNp5vbMl2keuvjzfi2/W0jyzkELhOzXO27WXItDk4uvYzN7YpRAJZsAQvPWqEj7x1nZu+USYhVU/laRLLjU1K1Ll7eaUK10KteOBWMToMpyhIV27vK73NQVMqQUwIj2A/vlWAbfhZivzjJuRm8asIWF/az7kmJ7Ia6hzZFsfjOmDArJkULlrsXsSyn5RQq3jMGVXdIoCMtMyRI8DBQ4KpMpCuGk/AklokU9fYgJo/IYynUK+fTSmsOaRAoZ7D3NHV8PPxjotM4dJ5QgZFZuft/u8BqgsIVHqLpyI9HUhAmXX8gF0ODRoI7iqngL6KwwsE0rcOSD2ZyuJi8aIkhzRxjkMakl2nsdsoUGAr1+ZNJFBbWjXX2pRJ7D8ztsTrIBIpMoyoSUnrSLxYju4sM1tUfycAKTR+raHzgKnMnLD7BM5shOR2KfMz99AYEk7hPu8q1S9pfMUdB+gBAd6l5tVao03GXqpUQapqA6lPG+FZpvIld0yFMk7JrCckvBU8eTWm68K9tY7NJ7Bf0lqMgMG2pbLoXdUdAUgbGUbNHtISPqH9hsM3uqPHczUaDdnUKqdMmuz8rxg5HGgfTZGNmHjDiVPA1l04vU+LHs21gw2W0aHIJG2a0DkOfHSk6Inp9KQ4zqsQNXqK1+uFkUdgtVrpzNoiFRLfWTPEkO3ee8iW2UKC7l2By7noZj4NX+Lk7VYBSJya+snKQQmdhi8B8hZx0B1AHoWJxWVASlpVkyVAQj5sbGws9Hq9cMmeTvp0YE87uJqy9E1woyaDJXGDvA6XPCxrOz0wHzj5IBg4VmLDvV+kQqpCujpBOH9OPmhgIBQKBRGLtHE96qZXNwQSoRUtzkG26EMPkvNz1n3+08ff1+Wdv8qEZTPEfzDkafzw3qb7sCF8BMplomOaru6Cx7VHEVZtvzpQq9W4RZanrJ7/6Cwh923MU2C3mAktykEyCetfmV1/ElYWHOX0288BnMDpsAp8PDsV/UvPO0fooUNqAQvz2N+c2sY0mN0gGsS3uIjSByfVd+lURmdQ1S7S6d17M1z/eyKXWpE0YyseLkp19j/Dna/7uMg4nKDpWr377/13GSQkUYtbDCD7s8LYke77SsgZzybz1SYy1p6iiLZH/nmFfpj96UCnOVPiTzdyeBTYSW5AvtZ9d4ioYl5vbN//F2AAf/wxX7nIyEIAAAAASUVORK5CYII=' + +EMOJI_BASE64_TEAR = b'iVBORw0KGgoAAAANSUhEUgAAADgAAAA4CAYAAACohjseAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAA3ZpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMTQ1IDc5LjE2MzQ5OSwgMjAxOC8wOC8xMy0xNjo0MDoyMiAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0UmVmPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VSZWYjIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtcE1NOk9yaWdpbmFsRG9jdW1lbnRJRD0ieG1wLmRpZDpkNTJlMTYxMC0xNThlLTlhNDItYTY0Ni0yNjViYjMyYzFkMDgiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6NTAwRDAzM0EyOUM1MTFFRDlBNTNFODhEQ0E0RkNFQ0MiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6NTAwRDAzMzkyOUM1MTFFRDlBNTNFODhEQ0E0RkNFQ0MiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTkgKFdpbmRvd3MpIj4gPHhtcE1NOkRlcml2ZWRGcm9tIHN0UmVmOmluc3RhbmNlSUQ9InhtcC5paWQ6ZDUyZTE2MTAtMTU4ZS05YTQyLWE2NDYtMjY1YmIzMmMxZDA4IiBzdFJlZjpkb2N1bWVudElEPSJ4bXAuZGlkOmQ1MmUxNjEwLTE1OGUtOWE0Mi1hNjQ2LTI2NWJiMzJjMWQwOCIv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' + +EMOJI_BASE64_UPSIDE_DOWN = b'iVBORw0KGgoAAAANSUhEUgAAADgAAAA4CAYAAACohjseAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAA3ZpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMTQ1IDc5LjE2MzQ5OSwgMjAxOC8wOC8xMy0xNjo0MDoyMiAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0UmVmPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VSZWYjIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtcE1NOk9yaWdpbmFsRG9jdW1lbnRJRD0ieG1wLmRpZDpkNTJlMTYxMC0xNThlLTlhNDItYTY0Ni0yNjViYjMyYzFkMDgiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6MjVBRkMyMkUyOUM1MTFFRDgzMzZDOUJBMEFDQjg1REMiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6MjVBRkMyMkQyOUM1MTFFRDgzMzZDOUJBMEFDQjg1REMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTkgKFdpbmRvd3MpIj4gPHhtcE1NOkRlcml2ZWRGcm9tIHN0UmVmOmluc3RhbmNlSUQ9InhtcC5paWQ6ZDUyZTE2MTAtMTU4ZS05YTQyLWE2NDYtMjY1YmIzMmMxZDA4IiBzdFJlZjpkb2N1bWVudElEPSJ4bXAuZGlkOmQ1MmUxNjEwLTE1OGUtOWE0Mi1hNjQ2LTI2NWJiMzJjMWQwOCIv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' + +EMOJI_BASE64_WIZARD = b'iVBORw0KGgoAAAANSUhEUgAAADgAAAA4CAYAAACohjseAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAA3ZpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMTQ1IDc5LjE2MzQ5OSwgMjAxOC8wOC8xMy0xNjo0MDoyMiAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0UmVmPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VSZWYjIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtcE1NOk9yaWdpbmFsRG9jdW1lbnRJRD0ieG1wLmRpZDpkNTJlMTYxMC0xNThlLTlhNDItYTY0Ni0yNjViYjMyYzFkMDgiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6OEIwRDE1RUEyOUM1MTFFREFCMjU4NjU5N0VFM0Q1OUEiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6OEIwRDE1RTkyOUM1MTFFREFCMjU4NjU5N0VFM0Q1OUEiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTkgKFdpbmRvd3MpIj4gPHhtcE1NOkRlcml2ZWRGcm9tIHN0UmVmOmluc3RhbmNlSUQ9InhtcC5paWQ6ZDUyZTE2MTAtMTU4ZS05YTQyLWE2NDYtMjY1YmIzMmMxZDA4IiBzdFJlZjpkb2N1bWVudElEPSJ4bXAuZGlkOmQ1MmUxNjEwLTE1OGUtOWE0Mi1hNjQ2LTI2NWJiMzJjMWQwOCIv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' + +EMOJI_BASE64_CRAZY = b'iVBORw0KGgoAAAANSUhEUgAAADgAAAA4CAYAAACohjseAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyZpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMTQ1IDc5LjE2MzQ5OSwgMjAxOC8wOC8xMy0xNjo0MDoyMiAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTkgKFdpbmRvd3MpIiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOjZFNUE0RkQ2MzQwQjExRURCOURBOTRFOUM4NDIwRTUwIiB4bXBNTTpEb2N1bWVudElEPSJ4bXAuZGlkOjZFNUE0RkQ3MzQwQjExRURCOURBOTRFOUM4NDIwRTUwIj4gPHhtcE1NOkRlcml2ZWRGcm9tIHN0UmVmOmluc3RhbmNlSUQ9InhtcC5paWQ6NkU1QTRGRDQzNDBCMTFFREI5REE5NEU5Qzg0MjBFNTAiIHN0UmVmOmRvY3VtZW50SUQ9InhtcC5kaWQ6NkU1QTRGRDUzNDBCMTFFREI5REE5NEU5Qzg0MjBFNTAiLz4gPC9yZGY6RGVzY3JpcHRpb24+IDwvcmRmOlJERj4gPC94OnhtcG1ldGE+IDw/eHBhY2tldCBlbmQ9InIiPz64kfh5AAAWqElEQVR42rxa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' + +EMOJI_BASE64_GLASSES = b'iVBORw0KGgoAAAANSUhEUgAAADgAAAA4CAYAAACohjseAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyZpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMTQ1IDc5LjE2MzQ5OSwgMjAxOC8wOC8xMy0xNjo0MDoyMiAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTkgKFdpbmRvd3MpIiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOkFDODJFQTk4MzQwQjExRURCMjlGODk3NDQ5NTQ5RUZCIiB4bXBNTTpEb2N1bWVudElEPSJ4bXAuZGlkOkFDODJFQTk5MzQwQjExRURCMjlGODk3NDQ5NTQ5RUZCIj4gPHhtcE1NOkRlcml2ZWRGcm9tIHN0UmVmOmluc3RhbmNlSUQ9InhtcC5paWQ6QUM4MkVBOTYzNDBCMTFFREIyOUY4OTc0NDk1NDlFRkIiIHN0UmVmOmRvY3VtZW50SUQ9InhtcC5kaWQ6QUM4MkVBOTczNDBCMTFFREIyOUY4OTc0NDk1NDlFRkIiLz4gPC9yZGY6RGVzY3JpcHRpb24+IDwvcmRmOlJERj4gPC94OnhtcG1ldGE+IDw/eHBhY2tldCBlbmQ9InIiPz63QkX8AAAWkklEQVR42rxa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' + +EMOJI_BASE64_HEAD_EXPLODE = b'iVBORw0KGgoAAAANSUhEUgAAADgAAAA4CAYAAACohjseAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyZpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMTQ1IDc5LjE2MzQ5OSwgMjAxOC8wOC8xMy0xNjo0MDoyMiAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTkgKFdpbmRvd3MpIiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOjlCMDEyRDc5MzQwQzExRUQ4QjhGREE4RURBODNBOEY0IiB4bXBNTTpEb2N1bWVudElEPSJ4bXAuZGlkOjlCMDEyRDdBMzQwQzExRUQ4QjhGREE4RURBODNBOEY0Ij4gPHhtcE1NOkRlcml2ZWRGcm9tIHN0UmVmOmluc3RhbmNlSUQ9InhtcC5paWQ6OUIwMTJENzczNDBDMTFFRDhCOEZEQThFREE4M0E4RjQiIHN0UmVmOmRvY3VtZW50SUQ9InhtcC5kaWQ6OUIwMTJENzgzNDBDMTFFRDhCOEZEQThFREE4M0E4RjQiLz4gPC9yZGY6RGVzY3JpcHRpb24+IDwvcmRmOlJERj4gPC94OnhtcG1ldGE+IDw/eHBhY2tldCBlbmQ9InIiPz7feGyYAAAQ70lEQVR42sxaCXhUVZb+X9WrVCWpVFJZSAhLgABhF5BNBQRskR5paXtEabfuUXDwc2m3z2HaGXVGe3rstu2e6dbpaT7cetwGacF2YxFE2VR2SCRhS8heSaqSVGp99d6d/75XLJGoxEmQB5eqets9//3P+c857wEhBL5pzJo1C992S+V4HBgsgIdFpm218NpX8/vPuW9UMY8tBJwcJRz9pnfjvtKmc7FdRS9uowHlMQX3LJw77G7csGAYRl0m4MwDDlQseOzVlUtu2v7Rx0MHu4diWE4B/IGYv6L1i5XVsT/+DFgf6ykjeorBsRw/BcZtApZucruf2zSk31vRkZ7t4nfzhdCPirM3XYjDpUJEjwlhVPLnISFO/EWIn88T+522X/NeRZw1e+z/k8EeAfgDGhJNT39ZLF0aEx98IEQpDW+oEiKwS4i2DULEfELEWy1ciZAQjTwn3iK63njds3OEWDihQcwrORotdq9/AfjR2PMN0GkxVkTG5kezs7eIzZvFV24xgqkjOwceFGLnLUL4Noiv3aqe5j9cFNHAj41CPLFAHFLxL2nnC6Cc6G3gQTF/fpN48UUafkB846bHhfjseiF2XEuX1L/mvJgQ++8ToqPijJ0dQjx+vSCTV5+0YXZvAuREC8TDy8427qQbftW2Z4kQ2+YJoQW7Pi6BN3/C8xYzPslie+npxWipEKF89w56TYGdNlzSkwBnJwEm3XJgKNe9QzQd+dLKR7ny9zLmDnZtfNt+uudNQpT/QojaVUJEaroGGPWd8VvrzPba94W4+OKqN4CZs3syTbRzXAGov7Xj4bHXFP0Mi8b3QfBNQL+IicAG2Om0TRsA34dA3A8U3w9kTeBVitRpIFgG+Lcx8/07E2N/wGASCFfyMw7YUk5PJO8l08ip318yb+48YPLUgddPnfpiPBqdwj3NPZIm/n76TLUjBSvEf/1AiNCH3NVqMRalCNSuFGLTJCH23kk32iZEuMYSFWEk3ZbfW/dQPcOix7YVK4SYMmVJj7lo9SWXXC8e/z6/NnU9oX/H1wtHT2+HKEDX3/DmudhuOxcX7V9c+GPc/AS/5XZ9gneq5V7d3aL1gBY4e3/oKF295auvy+Jceam55zLFuZVqmY4ceNzdMz7RARz7A42ZCPSZ2/mYjMHmzUDlc4CDMZh1OWNzAGM6SnDljOePuN/Llb2RazoTSDkDS/ggsI+x3BY81nMAm9oOofTlGZj1iy6ObQRqX+cijAEyZPVJrQ0d4f71NKIUOPwW65y/EOQozpZO1mqBVoLQaZ9CEToe4bGjliW6jBnuc8tMS3YrfwnUvMTEO4THM7gwZFvhvVeXoubw4Df69xTAmsrgyv6rXlmCYQ6u5nRLHaUbte3h2EejqIZNO2kADUzlLaW3KsxWRzjawsDwz3hsT3I/h8YvtQbwLkGUNQIjCoGLyJKH17ZqZKgpiPFZGRjA6/uHrGvl1kzmPwojtKLuqSenDln/x54C+GSKbeNvXqx5PX3iu4tw0VoaqFurLeMuzcXPFOv3MRpzkEPj9woyU1XDGXjue2TERmbdyZQQpKEhnjeMYMYD29/zvxfeG3XY7bxfefPa5fH4O5e/jqLhqa5LUZx7Ub6a8HIu0dgQKa+sD7z5K2BDll1Bj7noTtWmL+jQF797f2nQedPwWzA+xcW4BGIJMkRjPwsG4WUyvMxjx9hs5jwafqIauIngiqSYkMVmjlYZm1KUkvUevQ701Mrn25ff2NC++sw5XyVURKLrcLAG6Se154zjs89RCs4JoIeDKTw0uS1yx8XP7fvPnwDT4EkbSIAxxOIVy4H9lInM4cudV2UV548cb2jjsLBxJCbRKaVV2VKKpbhYed9y0+TNqS0//le85P4lFt4awbrWLuYPdZW/z3c/eHJjeZEqbkOZOMFL93LsT47dHFs5Pk/u38GxmWMLxzaOG3HsSgU5F2xHn6HCKQykZRiI8o+Ol8hXE7nqx6EmWTtZ98nNnnyeUcNza2BU+aG0KHCcOzU96KLnso1Kw5wxRXi2qR7P/92buJuxNnbZZbh60AjMO1wHjKEPSxUNHALWvcvwJVe33s4s8jY2PbsbQTEYc4/a4M1S6AE2tLRpp5biwgBY3oE9i0bAPfNOPPHoU6hMUZG1SyC8/XNqENPeveMorEyL5RwTZ3DfbmAt6+/dBqbNfwyusn1oH34Et04Yiju2HcffEuBHFxRAw4D3yDG03Xc/8v7wb95BEdEXBw/GsvRALe64Ucfq1zRMZkq4i6ylMeUFfBRW5vzbH4K7D4Xm8y1IX3YnlrW2QrxxAK4LwkUVSyCLJubjkR9elXXDvB9O9CBvAgZJxYxWYeRwJv9Q1Iy38eOScadZcXjdj8743QbcvxhOF9227hiUcAivvbUZbx7w4clmA1XiuwAowV3kwDUL56j/vfiBqwr6TLrCTPChhk+QqN+LRLwZhk2FrvSFoScVRk9ebLPyoZIQsAudOHU4PQmuRQJ9i+K4+z4ta850LH7lFcxftwd37Irhr+J8ApQLP86FeUOW3v2/wx+43Xkwswhv1QtsbfYhnJjDSiwdiRQnpOkJNq0iKaEnP5WkVMpPVSRMgCoRO1SNUhxDaiIK18QYQiWJgpTVq1ZOfvHZa3eG8b5xvgD2AQo911y3fOiy3zvbWH2tYEn5gU8yQ4d1WPTKRXDQaJtimDtszBqqWcZI8lT+spmQDUqmxt/6mdlbJEcmx+2znVObWv5UuPL1qSz86nodoDR8eDbuXXzLqP7TXLtga69GSfuf8U+2FhhRBlRCg6ITSILgDA1Ct6wWumEyZXmqnbW41T8qrCkNG1dFJdN2mqM6YE9NQZSVX3mDGzV6DrQZRv9dm3BPfTP+Ue9tgCTMe/lEZdHNY/xsSpnUIivRtn4N/voBS01Z+BObTpyaYTUNSXwgvlMESUdN4jM/VR5LkZ+8uY0rmMni89qrgUtLWKrWWwWBNgKLPt2CX3GKQK8C7GvHpOkzsovgHkZrQvh4zWd49GkHwrnFjCEFcU8uogMHwB4LI91XyXNsFjMmMuVkfXjq0xaPIJrXD1GXG46G43AFQzDaFHz0dCXuuy2KwSXWZWNGY1Dhp7j4sIYNvQow34MZJaPyGGB50H078MYb9Yj0GYnUXC98465A5d8sRTS7EPZ4GLkHPsaQNc9AjYYgJDVfqpYVUl175R2omX0j4s4MqBtWIO/z91HUUIWIOhhr3vsCdw22mv7+/djcc+7DLd0DaOtu/A0swKQ+hfkmM1V7t+NwrQ0pGWkI9huBikX/jGifQtN43ZmGxunzcPz7dzEdaBZrZwy7FkHzuDk4du09ZJ0JkHGXmHg16kdOQV12X4aiDdX+NNTXWQxmscUqyMVkezcJ6RZAtqzqQC8KXLkEEfehdHcZ2AjCptrRRGONNIpFPKmAUg3YKraMnomot68lPJ3bGDROutoKyEQy4eeSpqwCtPQtQoLHw0oGKiut012Mw3w3ClKtde4dgLTb4/GwTU1lOgh/gVJ278KdbYlISurZTZoky26HoI8p4uxU3eka+ZlC23MGUFWZVKjCgl5QdcIinbsg5xZW8ugdgLIPz/SwwbanQzTtQm2DXNo0Uxezjuw8+46MHXf9Ebj8dTDsZ4e7t+JTdGqlJMg+Rcjw+2CPhGE4nGimZmqa5aaZGWZzn9abAJ1OFwthvRlhXwXaowoUB5M29T23dDMKN660HMhlDVdtDYa8/R+Weyqdn6HoqhOFW1ciZ+cn5kKYLz746Wmpw4Dy3ebDNZD9cFwFsbJg4GGneWdnr6qo+Xw3XIZweysnp+u57BYDzGXFVMycsi3oGFACR6gV3i+2w9naaDJx9tLazFQy8n8egX/npQjnD4Ir0Ajvvo0QWpyqq/IUO2IxlSNhzqug+1t3AcbjcUpHeB/YvROTcooZIYOEy+4t34bsQ1utFsru6ATOlohDYV+ly328TuZHxdCRt2/DqTypszLQpDsL67esX0+Gb1yTsmXKWK8BjLQHEUIiYlYcqiLMRvA0vSy7HF23ctJNO5hK4m4vso7uspRDAiA1ptgkrxex6KljchVlLXvyrUAwaD5/ivRmDLabAIkpnaGe5jRgMEAEaf1yjHWahMxF8gZi/9Lf4+A9v4Nv/FzYE13/PwqRSJzqPISuw6VyOK115NwdCrr3KKO7aSLuD8KvB5mXs6iCGQkY9Bs91HFGldk1e6GCYiRyPOZN2oZNMtk5+8TTDCqyxEvocKcm4HaTNvIWDCOgd9NFuwUwKp/TNuFwa8DSsmGDeAMtSkMIsi1g4ZOxqCidhsyDMl2k+PzmOWZ6oIh0Ok8CikZNj5C/FYeD946gX1+qJ9U1zDWsacPhSC+LDJqa8GmDD0tyioGJ44BXP2yHnpUJg6hFLAaF1igyQO320wA5nDUVGPPb26CleeCpOmA+3Uc0RiKlWukmW0KLnWJWcTrhaPNhaLGl3C0tbCP8+LTX+8HqKPaWVSAxegrUMWOAwd4gKnQr4RssqhX5mP4Mde2URJsa4CSgRFJFv1y6neo4WJdJnPnpQQxmsc1QRHUNEtUR7O2uvd1+axkRKNu5FxWCOHRePWOaDltdFYtjFWqmF0qa2/QpodjOAiELAiPF1Xl/UknlNUoaiyRPJntf1bznlIk6eEtIDasoR0XEQFmvM8i5IjvKsKbyGEbJRnT8FOBm0Y61m79AW8RNdtyW7Mt0wfLj9ItfBZ1bXmH9lRnBEGbrZO9oh6p1IDO1A3PnC0y7xDpeVwscPI410W6miG8FUHrjgWYsX7UGS2/5CbyyI798lnxrpmIAXXQUXfYoO4CWgIJQzIFQ1IGoZkeCdIskWoX+p9oNuBw60lM0pDs15HgFiilaZQEW2i4Vs2ZrZg0qQ3TLxwiUtmB5dx9XfOuHTm3A8ZfX48HcPDw/eXqSGLLVd4Ad37vEwEymslhUoKMjjnCYpU/UKpi15BslCqQ5WNcijflUpgH5PYXWBLbbUdOimPekp2Iji5x3d+BBv8Dx8/bYUOrcgTBeeOZVOK6rw9PfuxIZw/sZON5ok8JItizly5S50vvV/z/BLPeSxZCMM4opfG0KSnivdq7iurUIrtqMh+Rc38mT7YNh/KnhfezYX4Z/KBmpzw8kFE9FjQ0lA6zySohvLtxl8yGZkmDLq20I+ATcfr39N2/hnY8r8VSzwP7v7NG9tL+JBrxXiZu2VIvhRRnaZSuOKFcO8mIom9OcvDzkUBjTmdJU2e7Yk724lH3JHNOmFg4h1NQMPxlrqQyII/6Atn5dEFvbdfMl+IXx8kUaEqFBja2oQKt4IfW4Cd7FMMvOtiPXKTAhvyB7eTyrwGH2wa0Nmq/RvzimYL8/gSZ6p4y6aKSnXw725NulM5p40oUBhSmYlutNX5CdkdGPvpglUpyqcCQf2edkqulp6n0JTQt26GjRgm3vVIeNLcwUFfELDWCOHa4pA/BEQQEmyUZA1WALxTNHuIZM7ePMzYct1c30YLdaH1hlmEyPmqZNaAq0Qe1g5R7puHaQ/0iHx964nw183MlV8vlQtvU4lgUMBL9TgEM9uO3XD+Ch0VOsKqCqGnjmz6ns/UpYPycQ4z63KwqdzbDMh6k2DUGVZR2LgcIcBYEUBxr9Tn4vdz/8U1xaMsQq5E+UYdbiR9DyYT0eNb4rgExd6VdNxZLRFzP/R8wGX6V7KrriMsiYruuGMm1Es33+tGolFrdj1eaBxur28cr+rOE8x4bxwQox3nZct6mpiJ1It8NolkWsYD2oDxwFLJiFW7a9hmfZ5TZ+Wxv/T4ABAHjvtcP6fWBDAAAAAElFTkSuQmCC' + + +EMOJI_BASE64_LAPTOP = b'iVBORw0KGgoAAAANSUhEUgAAADgAAAA4CAYAAACohjseAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyZpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMTQ1IDc5LjE2MzQ5OSwgMjAxOC8wOC8xMy0xNjo0MDoyMiAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTkgKFdpbmRvd3MpIiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOjI3RUM1QzE3MzQwQzExRUQ4M0I0Q0I4NzYwOERFNjZDIiB4bXBNTTpEb2N1bWVudElEPSJ4bXAuZGlkOjI3RUM1QzE4MzQwQzExRUQ4M0I0Q0I4NzYwOERFNjZDIj4gPHhtcE1NOkRlcml2ZWRGcm9tIHN0UmVmOmluc3RhbmNlSUQ9InhtcC5paWQ6MjdFQzVDMTUzNDBDMTFFRDgzQjRDQjg3NjA4REU2NkMiIHN0UmVmOmRvY3VtZW50SUQ9InhtcC5kaWQ6MjdFQzVDMTYzNDBDMTFFRDgzQjRDQjg3NjA4REU2NkMiLz4gPC9yZGY6RGVzY3JpcHRpb24+IDwvcmRmOlJERj4gPC94OnhtcG1ldGE+IDw/eHBhY2tldCBlbmQ9InIiPz7XxuepAAASgUlEQVR42uxa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' + + + +EMOJI_BASE64_PARTY = b'iVBORw0KGgoAAAANSUhEUgAAADgAAAA4CAYAAACohjseAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyZpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMTQ1IDc5LjE2MzQ5OSwgMjAxOC8wOC8xMy0xNjo0MDoyMiAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTkgKFdpbmRvd3MpIiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOjIxMUE2MDI2MzQwQjExRURCNEM2QjE1MzA1QTRFQ0FFIiB4bXBNTTpEb2N1bWVudElEPSJ4bXAuZGlkOjIxMUE2MDI3MzQwQjExRURCNEM2QjE1MzA1QTRFQ0FFIj4gPHhtcE1NOkRlcml2ZWRGcm9tIHN0UmVmOmluc3RhbmNlSUQ9InhtcC5paWQ6MjExQTYwMjQzNDBCMTFFREI0QzZCMTUzMDVBNEVDQUUiIHN0UmVmOmRvY3VtZW50SUQ9InhtcC5kaWQ6MjExQTYwMjUzNDBCMTFFREI0QzZCMTUzMDVBNEVDQUUiLz4gPC9yZGY6RGVzY3JpcHRpb24+IDwvcmRmOlJERj4gPC94OnhtcG1ldGE+IDw/eHBhY2tldCBlbmQ9InIiPz5A1idCAAAUNElEQVR42tRa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' + +EMOJI_BASE64_RAINEDON = b'iVBORw0KGgoAAAANSUhEUgAAADgAAAA4CAYAAACohjseAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyZpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMTQ1IDc5LjE2MzQ5OSwgMjAxOC8wOC8xMy0xNjo0MDoyMiAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTkgKFdpbmRvd3MpIiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOjcyRjZCQzYyMzQwQzExRUQ5MjA3RUZFMzQ4MkFCQjI2IiB4bXBNTTpEb2N1bWVudElEPSJ4bXAuZGlkOjcyRjZCQzYzMzQwQzExRUQ5MjA3RUZFMzQ4MkFCQjI2Ij4gPHhtcE1NOkRlcml2ZWRGcm9tIHN0UmVmOmluc3RhbmNlSUQ9InhtcC5paWQ6NzJGNkJDNjAzNDBDMTFFRDkyMDdFRkUzNDgyQUJCMjYiIHN0UmVmOmRvY3VtZW50SUQ9InhtcC5kaWQ6NzJGNkJDNjEzNDBDMTFFRDkyMDdFRkUzNDgyQUJCMjYiLz4gPC9yZGY6RGVzY3JpcHRpb24+IDwvcmRmOlJERj4gPC94OnhtcG1ldGE+IDw/eHBhY2tldCBlbmQ9InIiPz5YPJNEAAAa/0lEQVR42rR6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' + +EMOJI_BASE64_READING = b'iVBORw0KGgoAAAANSUhEUgAAADgAAAA4CAYAAACohjseAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyZpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMTQ1IDc5LjE2MzQ5OSwgMjAxOC8wOC8xMy0xNjo0MDoyMiAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTkgKFdpbmRvd3MpIiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOjA3RjUzNDBDMzQwQzExRURCRUQ1RUI5NjYxNTQwNzBFIiB4bXBNTTpEb2N1bWVudElEPSJ4bXAuZGlkOjA3RjUzNDBEMzQwQzExRURCRUQ1RUI5NjYxNTQwNzBFIj4gPHhtcE1NOkRlcml2ZWRGcm9tIHN0UmVmOmluc3RhbmNlSUQ9InhtcC5paWQ6MDdGNTM0MEEzNDBDMTFFREJFRDVFQjk2NjE1NDA3MEUiIHN0UmVmOmRvY3VtZW50SUQ9InhtcC5kaWQ6MDdGNTM0MEIzNDBDMTFFREJFRDVFQjk2NjE1NDA3MEUiLz4gPC9yZGY6RGVzY3JpcHRpb24+IDwvcmRmOlJERj4gPC94OnhtcG1ldGE+IDw/eHBhY2tldCBlbmQ9InIiPz7w5cDgAAARpElEQVR42uxa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' + +EMOJI_BASE64_SANTA = b'iVBORw0KGgoAAAANSUhEUgAAADgAAAA4CAYAAACohjseAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyZpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMTQ1IDc5LjE2MzQ5OSwgMjAxOC8wOC8xMy0xNjo0MDoyMiAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTkgKFdpbmRvd3MpIiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOjQ0ODdCM0Y5MzQwQjExRUQ5M0NERjlEMzk2Q0RBNDlFIiB4bXBNTTpEb2N1bWVudElEPSJ4bXAuZGlkOjQ0ODdCM0ZBMzQwQjExRUQ5M0NERjlEMzk2Q0RBNDlFIj4gPHhtcE1NOkRlcml2ZWRGcm9tIHN0UmVmOmluc3RhbmNlSUQ9InhtcC5paWQ6NDQ4N0IzRjczNDBCMTFFRDkzQ0RGOUQzOTZDREE0OUUiIHN0UmVmOmRvY3VtZW50SUQ9InhtcC5kaWQ6NDQ4N0IzRjgzNDBCMTFFRDkzQ0RGOUQzOTZDREE0OUUiLz4gPC9yZGY6RGVzY3JpcHRpb24+IDwvcmRmOlJERj4gPC94OnhtcG1ldGE+IDw/eHBhY2tldCBlbmQ9InIiPz6dQR9sAAATTklEQVR42sxa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' + +EMOJI_BASE64_SEARCH = b'iVBORw0KGgoAAAANSUhEUgAAADgAAAA4CAYAAACohjseAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyZpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMTQ1IDc5LjE2MzQ5OSwgMjAxOC8wOC8xMy0xNjo0MDoyMiAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTkgKFdpbmRvd3MpIiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOkJDNDI1OTlGMzQwQzExRUQ5QjBFQjEzMjUwNkNBQ0QwIiB4bXBNTTpEb2N1bWVudElEPSJ4bXAuZGlkOkJDNDI1OUEwMzQwQzExRUQ5QjBFQjEzMjUwNkNBQ0QwIj4gPHhtcE1NOkRlcml2ZWRGcm9tIHN0UmVmOmluc3RhbmNlSUQ9InhtcC5paWQ6QkM0MjU5OUQzNDBDMTFFRDlCMEVCMTMyNTA2Q0FDRDAiIHN0UmVmOmRvY3VtZW50SUQ9InhtcC5kaWQ6QkM0MjU5OUUzNDBDMTFFRDlCMEVCMTMyNTA2Q0FDRDAiLz4gPC9yZGY6RGVzY3JpcHRpb24+IDwvcmRmOlJERj4gPC94OnhtcG1ldGE+IDw/eHBhY2tldCBlbmQ9InIiPz6a40hqAAAatklEQVR42sR6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' + +EMOJI_BASE64_WAVE = b'iVBORw0KGgoAAAANSUhEUgAAADgAAAA4CAYAAACohjseAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyZpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMTQ1IDc5LjE2MzQ5OSwgMjAxOC8wOC8xMy0xNjo0MDoyMiAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTkgKFdpbmRvd3MpIiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOkRGNTgwMkZBMzQwQTExRURBNkNDQkZERTBEOEZGMDRFIiB4bXBNTTpEb2N1bWVudElEPSJ4bXAuZGlkOkRGNTgwMkZCMzQwQTExRURBNkNDQkZERTBEOEZGMDRFIj4gPHhtcE1NOkRlcml2ZWRGcm9tIHN0UmVmOmluc3RhbmNlSUQ9InhtcC5paWQ6REY1ODAyRjgzNDBBMTFFREE2Q0NCRkRFMEQ4RkYwNEUiIHN0UmVmOmRvY3VtZW50SUQ9InhtcC5kaWQ6REY1ODAyRjkzNDBBMTFFREE2Q0NCRkRFMEQ4RkYwNEUiLz4gPC9yZGY6RGVzY3JpcHRpb24+IDwvcmRmOlJERj4gPC94OnhtcG1ldGE+IDw/eHBhY2tldCBlbmQ9InIiPz5C42NVAAAXfklEQVR42txa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' + +EMOJI_BASE64_DEPRESSED = b'iVBORw0KGgoAAAANSUhEUgAAADgAAAA4CAYAAACohjseAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAA+tpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMTQ1IDc5LjE2MzQ5OSwgMjAxOC8wOC8xMy0xNjo0MDoyMiAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczpkYz0iaHR0cDovL3B1cmwub3JnL2RjL2VsZW1lbnRzLzEuMS8iIHhtbG5zOnhtcE1NPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvbW0vIiB4bWxuczpzdFJlZj0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL3NUeXBlL1Jlc291cmNlUmVmIyIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgQ0MgMjAxOSAoV2luZG93cykiIHhtcDpDcmVhdGVEYXRlPSIyMDIyLTA5LTIyVDA4OjEwOjQzKzA3OjAwIiB4bXA6TW9kaWZ5RGF0ZT0iMjAyMi0wOS0yMlQwODo1Njo0NiswNzowMCIgeG1wOk1ldGFkYXRhRGF0ZT0iMjAyMi0wOS0yMlQwODo1Njo0NiswNzowMCIgZGM6Zm9ybWF0PSJpbWFnZS9wbmciIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6RDBCRDY1RDgzQTE5MTFFREEzQUVCMkM5Q0FGMjBGQkQiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6RDBCRDY1RDkzQTE5MTFFREEzQUVCMkM5Q0FGMjBGQkQiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RS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' + + +EMOJI_BASE64_GOLD_STAR = b'iVBORw0KGgoAAAANSUhEUgAAADgAAAA4CAYAAACohjseAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyZpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMTQ1IDc5LjE2MzQ5OSwgMjAxOC8wOC8xMy0xNjo0MDoyMiAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTkgKFdpbmRvd3MpIiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOjAwNzJEQTBGM0VENzExRUQ4MTVGRDVBOTkzQTU1RkNBIiB4bXBNTTpEb2N1bWVudElEPSJ4bXAuZGlkOjAwNzJEQTEwM0VENzExRUQ4MTVGRDVBOTkzQTU1RkNBIj4gPHhtcE1NOkRlcml2ZWRGcm9tIHN0UmVmOmluc3RhbmNlSUQ9InhtcC5paWQ6MDA3MkRBMEQzRUQ3MTFFRDgxNUZENUE5OTNBNTVGQ0EiIHN0UmVmOmRvY3VtZW50SUQ9InhtcC5kaWQ6MDA3MkRBMEUzRUQ3MTFFRDgxNUZENUE5OTNBNTVGQ0EiLz4gPC9yZGY6RGVzY3JpcHRpb24+IDwvcmRmOlJERj4gPC94OnhtcG1ldGE+IDw/eHBhY2tldCBlbmQ9InIiPz6kluKgAAAWMElEQVR42txa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' + +EMOJI_BASE64_HONEST = b'iVBORw0KGgoAAAANSUhEUgAAADgAAAA4CAYAAACohjseAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAA+tpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMTQ1IDc5LjE2MzQ5OSwgMjAxOC8wOC8xMy0xNjo0MDoyMiAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczpkYz0iaHR0cDovL3B1cmwub3JnL2RjL2VsZW1lbnRzLzEuMS8iIHhtbG5zOnhtcE1NPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvbW0vIiB4bWxuczpzdFJlZj0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL3NUeXBlL1Jlc291cmNlUmVmIyIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgQ0MgMjAxOSAoV2luZG93cykiIHhtcDpDcmVhdGVEYXRlPSIyMDIyLTA5LTIyVDA4OjEwOjQzKzA3OjAwIiB4bXA6TW9kaWZ5RGF0ZT0iMjAyMi0wOS0yMlQwODo1NToxMCswNzowMCIgeG1wOk1ldGFkYXRhRGF0ZT0iMjAyMi0wOS0yMlQwODo1NToxMCswNzowMCIgZGM6Zm9ybWF0PSJpbWFnZS9wbmciIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6OTcxODg4QkEzQTE5MTFFREI5NzBDNjdEOTlGMzkxMzYiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6OTcxODg4QkIzQTE5MTFFREI5NzBDNjdEOTlGMzkxMzYiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RS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' + +EMOJI_BASE64_ILL = b'iVBORw0KGgoAAAANSUhEUgAAADgAAAA4CAYAAACohjseAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAA+tpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMTQ1IDc5LjE2MzQ5OSwgMjAxOC8wOC8xMy0xNjo0MDoyMiAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczpkYz0iaHR0cDovL3B1cmwub3JnL2RjL2VsZW1lbnRzLzEuMS8iIHhtbG5zOnhtcE1NPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvbW0vIiB4bWxuczpzdFJlZj0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL3NUeXBlL1Jlc291cmNlUmVmIyIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgQ0MgMjAxOSAoV2luZG93cykiIHhtcDpDcmVhdGVEYXRlPSIyMDIyLTA5LTIyVDA4OjEwOjQzKzA3OjAwIiB4bXA6TW9kaWZ5RGF0ZT0iMjAyMi0wOS0yMlQwODo1NTo0NSswNzowMCIgeG1wOk1ldGFkYXRhRGF0ZT0iMjAyMi0wOS0yMlQwODo1NTo0NSswNzowMCIgZGM6Zm9ybWF0PSJpbWFnZS9wbmciIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6QUM0Q0M3NzQzQTE5MTFFRDk4OTc5NDZCQjZGRkVFNkYiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6QUM0Q0M3NzUzQTE5MTFFRDk4OTc5NDZCQjZGRkVFNkYiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RS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' + +EMOJI_BASE64_ILL2 = b'iVBORw0KGgoAAAANSUhEUgAAADgAAAA4CAYAAACohjseAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyZpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMTQ1IDc5LjE2MzQ5OSwgMjAxOC8wOC8xMy0xNjo0MDoyMiAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTkgKFdpbmRvd3MpIiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOjg3Rjc1MzhFMzk1RjExRURCNDE5OUIwMTlERDQxNDQ0IiB4bXBNTTpEb2N1bWVudElEPSJ4bXAuZGlkOjg3Rjc1MzhGMzk1RjExRURCNDE5OUIwMTlERDQxNDQ0Ij4gPHhtcE1NOkRlcml2ZWRGcm9tIHN0UmVmOmluc3RhbmNlSUQ9InhtcC5paWQ6ODdGNzUzOEMzOTVGMTFFREI0MTk5QjAxOURENDE0NDQiIHN0UmVmOmRvY3VtZW50SUQ9InhtcC5kaWQ6ODdGNzUzOEQzOTVGMTFFREI0MTk5QjAxOURENDE0NDQiLz4gPC9yZGY6RGVzY3JpcHRpb24+IDwvcmRmOlJERj4gPC94OnhtcG1ldGE+IDw/eHBhY2tldCBlbmQ9InIiPz5KgkPlAAATKUlEQVR42rxa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' + +EMOJI_BASE64_KEY = b'iVBORw0KGgoAAAANSUhEUgAAADgAAAA4CAYAAACohjseAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAA+tpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMTQ1IDc5LjE2MzQ5OSwgMjAxOC8wOC8xMy0xNjo0MDoyMiAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczpkYz0iaHR0cDovL3B1cmwub3JnL2RjL2VsZW1lbnRzLzEuMS8iIHhtbG5zOnhtcE1NPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvbW0vIiB4bWxuczpzdFJlZj0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL3NUeXBlL1Jlc291cmNlUmVmIyIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgQ0MgMjAxOSAoV2luZG93cykiIHhtcDpDcmVhdGVEYXRlPSIyMDIyLTA5LTI0VDA4OjE1OjA5KzA3OjAwIiB4bXA6TW9kaWZ5RGF0ZT0iMjAyMi0wOS0yNFQxMjowNToxMSswNzowMCIgeG1wOk1ldGFkYXRhRGF0ZT0iMjAyMi0wOS0yNFQxMjowNToxMSswNzowMCIgZGM6Zm9ybWF0PSJpbWFnZS9wbmciIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6Nzc2Rjg5REQzQkM2MTFFREI1NERBRUFBN0E2RjE4QTMiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6Nzc2Rjg5REUzQkM2MTFFREI1NERBRUFBN0E2RjE4QTMiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RS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' + +EMOJI_BASE64_MASK = b'iVBORw0KGgoAAAANSUhEUgAAADgAAAA4CAYAAACohjseAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyZpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMTQ1IDc5LjE2MzQ5OSwgMjAxOC8wOC8xMy0xNjo0MDoyMiAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTkgKFdpbmRvd3MpIiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOkEwREMzM0YzMzk1RjExRUQ4QTI1Q0I5Mjg1NTQzM0JDIiB4bXBNTTpEb2N1bWVudElEPSJ4bXAuZGlkOkEwREMzM0Y0Mzk1RjExRUQ4QTI1Q0I5Mjg1NTQzM0JDIj4gPHhtcE1NOkRlcml2ZWRGcm9tIHN0UmVmOmluc3RhbmNlSUQ9InhtcC5paWQ6QTBEQzMzRjEzOTVGMTFFRDhBMjVDQjkyODU1NDMzQkMiIHN0UmVmOmRvY3VtZW50SUQ9InhtcC5kaWQ6QTBEQzMzRjIzOTVGMTFFRDhBMjVDQjkyODU1NDMzQkMiLz4gPC9yZGY6RGVzY3JpcHRpb24+IDwvcmRmOlJERj4gPC94OnhtcG1ldGE+IDw/eHBhY2tldCBlbmQ9InIiPz72uRrEAAAUlUlEQVR42sRa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' + +EMOJI_BASE64_SALUTE = b'iVBORw0KGgoAAAANSUhEUgAAADgAAAA4CAYAAACohjseAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAA+tpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMTQ1IDc5LjE2MzQ5OSwgMjAxOC8wOC8xMy0xNjo0MDoyMiAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczpkYz0iaHR0cDovL3B1cmwub3JnL2RjL2VsZW1lbnRzLzEuMS8iIHhtbG5zOnhtcE1NPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvbW0vIiB4bWxuczpzdFJlZj0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL3NUeXBlL1Jlc291cmNlUmVmIyIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgQ0MgMjAxOSAoV2luZG93cykiIHhtcDpDcmVhdGVEYXRlPSIyMDIyLTA5LTI0VDA4OjE1OjA5KzA3OjAwIiB4bXA6TW9kaWZ5RGF0ZT0iMjAyMi0wOS0yNFQxMjowOTowMSswNzowMCIgeG1wOk1ldGFkYXRhRGF0ZT0iMjAyMi0wOS0yNFQxMjowOTowMSswNzowMCIgZGM6Zm9ybWF0PSJpbWFnZS9wbmciIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6MDBFQjc1QTUzQkM3MTFFREJBMzhGMzA3RkNBN0M3QUIiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6MDBFQjc1QTYzQkM3MTFFREJBMzhGMzA3RkNBN0M3QUIiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RS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' + +EMOJI_BASE64_SCREAM = b'iVBORw0KGgoAAAANSUhEUgAAADgAAAA4CAYAAACohjseAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAA+tpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMTQ1IDc5LjE2MzQ5OSwgMjAxOC8wOC8xMy0xNjo0MDoyMiAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczpkYz0iaHR0cDovL3B1cmwub3JnL2RjL2VsZW1lbnRzLzEuMS8iIHhtbG5zOnhtcE1NPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvbW0vIiB4bWxuczpzdFJlZj0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL3NUeXBlL1Jlc291cmNlUmVmIyIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgQ0MgMjAxOSAoV2luZG93cykiIHhtcDpDcmVhdGVEYXRlPSIyMDIyLTA5LTIyVDA4OjEwOjQzKzA3OjAwIiB4bXA6TW9kaWZ5RGF0ZT0iMjAyMi0wOS0yMlQwODo1NDozMiswNzowMCIgeG1wOk1ldGFkYXRhRGF0ZT0iMjAyMi0wOS0yMlQwODo1NDozMiswNzowMCIgZGM6Zm9ybWF0PSJpbWFnZS9wbmciIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6ODBFNDg4M0IzQTE5MTFFREI5MDlCRjk1RjI3MDhCODIiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6ODBFNDg4M0MzQTE5MTFFREI5MDlCRjk1RjI3MDhCODIiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RS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' + +EMOJI_BASE64_SMIRKING = b'iVBORw0KGgoAAAANSUhEUgAAADgAAAA4CAYAAACohjseAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyZpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMTQ1IDc5LjE2MzQ5OSwgMjAxOC8wOC8xMy0xNjo0MDoyMiAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTkgKFdpbmRvd3MpIiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOkIyMEM4ODY3Mzk1RjExRURCQ0VERkNGNDZDRTc2QURBIiB4bXBNTTpEb2N1bWVudElEPSJ4bXAuZGlkOkIyMEM4ODY4Mzk1RjExRURCQ0VERkNGNDZDRTc2QURBIj4gPHhtcE1NOkRlcml2ZWRGcm9tIHN0UmVmOmluc3RhbmNlSUQ9InhtcC5paWQ6QjIwQzg4NjUzOTVGMTFFREJDRURGQ0Y0NkNFNzZBREEiIHN0UmVmOmRvY3VtZW50SUQ9InhtcC5kaWQ6QjIwQzg4NjYzOTVGMTFFREJDRURGQ0Y0NkNFNzZBREEiLz4gPC9yZGY6RGVzY3JpcHRpb24+IDwvcmRmOlJERj4gPC94OnhtcG1ldGE+IDw/eHBhY2tldCBlbmQ9InIiPz64K2SuAAATvElEQVR42sRa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' + +EMOJI_BASE64_WARNING2 = b'iVBORw0KGgoAAAANSUhEUgAAADgAAAA4CAYAAACohjseAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAA+tpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMTQ1IDc5LjE2MzQ5OSwgMjAxOC8wOC8xMy0xNjo0MDoyMiAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczpkYz0iaHR0cDovL3B1cmwub3JnL2RjL2VsZW1lbnRzLzEuMS8iIHhtbG5zOnhtcE1NPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvbW0vIiB4bWxuczpzdFJlZj0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL3NUeXBlL1Jlc291cmNlUmVmIyIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgQ0MgMjAxOSAoV2luZG93cykiIHhtcDpDcmVhdGVEYXRlPSIyMDIyLTA5LTIyVDA4OjEwOjQzKzA3OjAwIiB4bXA6TW9kaWZ5RGF0ZT0iMjAyMi0wOS0yMlQwODo1NDoxMiswNzowMCIgeG1wOk1ldGFkYXRhRGF0ZT0iMjAyMi0wOS0yMlQwODo1NDoxMiswNzowMCIgZGM6Zm9ybWF0PSJpbWFnZS9wbmciIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6NzRFNzk4NjMzQTE5MTFFREFCNTRBMDE4Q0NEQTM2OTciIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6NzRFNzk4NjQzQTE5MTFFREFCNTRBMDE4Q0NEQTM2OTciPiA8eG1wTU06RGVyaXZlZEZyb20gc3RS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' + +EMOJI_BASE64_WARNING = b'iVBORw0KGgoAAAANSUhEUgAAADgAAAA4CAYAAACohjseAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAA+tpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMTQ1IDc5LjE2MzQ5OSwgMjAxOC8wOC8xMy0xNjo0MDoyMiAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczpkYz0iaHR0cDovL3B1cmwub3JnL2RjL2VsZW1lbnRzLzEuMS8iIHhtbG5zOnhtcE1NPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvbW0vIiB4bWxuczpzdFJlZj0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL3NUeXBlL1Jlc291cmNlUmVmIyIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgQ0MgMjAxOSAoV2luZG93cykiIHhtcDpDcmVhdGVEYXRlPSIyMDIyLTA5LTIyVDA4OjEwOjQzKzA3OjAwIiB4bXA6TW9kaWZ5RGF0ZT0iMjAyMi0wOS0yMlQwODo1Mzo1MyswNzowMCIgeG1wOk1ldGFkYXRhRGF0ZT0iMjAyMi0wOS0yMlQwODo1Mzo1MyswNzowMCIgZGM6Zm9ybWF0PSJpbWFnZS9wbmciIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6Njk5N0ZDN0QzQTE5MTFFREEyRTJFOTM2QUFEMzdENzMiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6Njk5N0ZDN0UzQTE5MTFFREEyRTJFOTM2QUFEMzdENzMiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RS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' + +EMOJI_BASE64_JEDI = b'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' + + +EMOJI_BASE64_HAPPY_LIST = [EMOJI_BASE64_HAPPY_STARE, EMOJI_BASE64_BLANK_STARE, EMOJI_BASE64_HAPPY_LAUGH, EMOJI_BASE64_HAPPY_JOY, EMOJI_BASE64_HAPPY_IDEA, EMOJI_BASE64_HAPPY_GASP, EMOJI_BASE64_HAPPY_RELIEF, EMOJI_BASE64_HAPPY_WINK, EMOJI_BASE64_HAPPY_THUMBS_UP, EMOJI_BASE64_HAPPY_HEARTS, EMOJI_BASE64_HAPPY_CONTENT, EMOJI_BASE64_HAPPY_BIG_SMILE, EMOJI_BASE64_PRAY, EMOJI_BASE64_GUESS, EMOJI_BASE64_FINGERS_CROSSED, EMOJI_BASE64_CLAP, EMOJI_BASE64_COOL, EMOJI_BASE64_UPSIDE_DOWN, EMOJI_BASE64_OK, EMOJI_BASE64_COOL, EMOJI_BASE64_GLASSES, EMOJI_BASE64_HEAD_EXPLODE, EMOJI_BASE64_GLASSES, EMOJI_BASE64_LAPTOP, EMOJI_BASE64_PARTY, EMOJI_BASE64_READING, EMOJI_BASE64_SANTA, EMOJI_BASE64_SEARCH, EMOJI_BASE64_WAVE, EMOJI_BASE64_KEY, EMOJI_BASE64_SALUTE, EMOJI_BASE64_HONEST,EMOJI_BASE64_WIZARD, EMOJI_BASE64_JEDI, EMOJI_BASE64_GOLD_STAR, EMOJI_BASE64_SMIRKING] + +EMOJI_BASE64_SAD_LIST = [EMOJI_BASE64_YIKES, EMOJI_BASE64_WEARY, EMOJI_BASE64_DREAMING, EMOJI_BASE64_SLEEPING, EMOJI_BASE64_THINK, EMOJI_BASE64_SKEPTIC, EMOJI_BASE64_SKEPTICAL, EMOJI_BASE64_FACEPALM, EMOJI_BASE64_FRUSTRATED, EMOJI_BASE64_PONDER, EMOJI_BASE64_NOTUNDERSTANDING, EMOJI_BASE64_QUESTION, EMOJI_BASE64_CRY, EMOJI_BASE64_TEAR, EMOJI_BASE64_DEAD, EMOJI_BASE64_ZIPPED_SHUT, EMOJI_BASE64_NO_HEAR, EMOJI_BASE64_NO_SEE, EMOJI_BASE64_NO_SPEAK, EMOJI_BASE64_EYE_ROLL, EMOJI_BASE64_CRAZY, EMOJI_BASE64_RAINEDON, EMOJI_BASE64_DEPRESSED, EMOJI_BASE64_ILL, EMOJI_BASE64_ILL2, EMOJI_BASE64_MASK, EMOJI_BASE64_WARNING, EMOJI_BASE64_WARNING2, EMOJI_BASE64_SCREAM] +EMOJI_BASE64_LIST = EMOJI_BASE64_HAPPY_LIST + EMOJI_BASE64_SAD_LIST + +EMOJI_BASE64_JASON = EMOJI_BASE64_WIZARD +EMOJI_BASE64_TANAY = EMOJI_BASE64_JEDI + +def _random_error_emoji(): + c = random.choice(EMOJI_BASE64_SAD_LIST) + return c + + +def _random_happy_emoji(): + c = random.choice(EMOJI_BASE64_HAPPY_LIST) + return c + + + +''' +M"""""`'"""`YM +M mm. mm. M +M MMM MMM M .d8888b. 88d888b. .d8888b. +M MMM MMM M 88' `88 88' `88 88ooood8 +M MMM MMM M 88. .88 88 88. ... +M MMM MMM M `88888P' dP `88888P' +MMMMMMMMMMMMMM + +M#"""""""'M .d8888P dP dP +## mmmm. `M 88' 88 88 +#' .M .d8888b. .d8888b. .d8888b. 88baaa. 88aaa88 +M# MMMb.'YM 88' `88 Y8ooooo. 88ooood8 88` `88 88 +M# MMMM' M 88. .88 88 88. ... 8b. .d8 88 +M# .;M `88888P8 `88888P' `88888P' `Y888P' dP +M#########M + +M""M +M M +M M 88d8b.d8b. .d8888b. .d8888b. .d8888b. .d8888b. +M M 88'`88'`88 88' `88 88' `88 88ooood8 Y8ooooo. +M M 88 88 88 88. .88 88. .88 88. ... 88 +M M dP dP dP `88888P8 `8888P88 `88888P' `88888P' +MMMM .88 + d8888P +''' + + + +''' + +90 x 90 pixel images + +These images are intentionally a little large so that you can use the image_subsample to reduce their size. + +This offers more flexibility for use in a main window (larger) or perhaps a titlebar (smaller) + +''' + +HEART_FLAT_BASE64 = b'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' + + +HEART_3D_BASE64 = b'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' + +PYTHON_COLORED_HEARTS_BASE64 = b'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' + +RED_X_BASE64 = b'iVBORw0KGgoAAAANSUhEUgAAAFoAAABaCAYAAAA4qEECAAAQ5ElEQVR4nO1ca3SV1Zl+3nefSwJ4IQaMXBJU1HIZqwSt1ULAG/VSuXm0hVTrtMvOz+n8mBln1pqU+TNrOqur7ZrlrMrMWJ22dGoUQscbFKtAaylNtK0IbRHIDYlKQTEhOef79vvMj5yPppRLzvlOElzrPGt9a0GSs/ezn+/93r33s9/zAWWUUUYZZZRRRhlllFFGGWWUUXIQEDY16VjzKBRsalICMtY8hoWhAn9UiJ8cGCMRJCMiwtHGz1yaloqa/QNom9vcnCMgAnAk+oqLiNuuTCZ1WQXqRXOHKp/c2F7qfkpy56KoZSZT2bdq5dfP08pWgb14eQXbjjeuvFEAnouphE1NKgCPN6688fIKtgnsxSTTbX2rVn6dmUwl8MexxUXsRqKI4OpPnz8g4x6tSCQbA/MQCARAYP4InNxX+eQzL51LkR1x6X9w5S3wfCqprooASCLpHLI+/K80j/21fO/HfaXgHSvKCKgAPLx69flZGfdohXON/UFgoTcG3jPnPR2kCgGf/rBx+c0C8FzI2ZFwHzYuvxkBn3aQqpz3DLxnaMb+ILC0ui9led43DjU2jheATTG1KnrQEdnDqz99/nirfLQimWjsCwJTET3p76iAGPiBQZeft279y3EIlwofrlqxWGEbFHKBAZSTtDDSxqeS2h8EayuPhV+RZ589HieyixL6jyKvPr/Seh8dl0g09gbhn4k85O+pgJD4QB2XVX5/4ytjkUZOpIvVSxeZlxYRnFLkCGZmE9Ip7c/ZY5W9ub+JI3ZRj4MAPLp06YUVYe9/jHOusS8IvAIKEqe6hBRPUgQXmOeGD1fds3i0J8ho4ju66p7F9Fgvggs8SSHldLxVRPtyOV+ZkC/3TtBvvJPJTBitiBYCQCYzvg8D3x6fTK7u86EHxA3nwySpIgLgSC7EyonPbHyFmYyT5mZfKPFCEPVxLHN3g1O3HkCVkZRBLsNpwY9PJF1vGH57QsX7X8GTW7OFCl5QRDU15ScRDKypcInVfWHgYXAwYjiXEOLNKGRVSm3dkfvvWiDNzb714YeThfAoBK0PP5yU5mZ/7HN3f8pBfiBk1SAHyHB5w+COB0E4TuSvjvWe/4/5Sb0g7YYd0WxqUqxZw6Mr7qxNiLyRcm5c4M0Vk+YJMqUqAe3tQLCqqvnZrXz44aSsXRsU3NiZ+sm3eWTFPQtTjusSkKk5MwqGG8l/yjrp1Oe8HQ8Sbk7V/27sBggRGVZkF3pXqE6qYUIahYPzHAq/IDnvLQmZkvTy/aMr7lwsa9cGzGRShfA5E5jJpGTt2uDoijsXJ4XrEsTUnPeWH0bBnAmQRgFB5cAkERBf/eqwb1hBd5ZNTYrdu9PHwt5XK5y7ZsCbSbz1pU+puiyt27x+YeLGZ19iJpOS5uZcjDYRtXF06d23qLMn0qLTcmYewLDmklO2CViFU816e/288/tuxIxFOVmzxob7+cKEzi9tepcvmedN1yed1g0+irE2IT6t6gJjt1C+PGHjc8/HSSPRZ3uX3nUnhY8lVaZl44vMlKqExnYvfuXEDZteK3SZV7BAUQfv3f3p69IOzU60LmB8sSucc/2hf9fAv6z60abnWuvrk/Pb2goSO/rMkXuW3KWQxysTbvKA97FFToqKp3UY5d4Lf/RCazFr6YIf+2j9O+nZF39p4laG9J0JQIwkSRR5ueNh6FMqk5V4/INlt90xv60tYENDYtiCNDQk5re1BR8su+0OJR5PqUw+HoaepCuWl5FMABLSdw5oYsWFP3qhNVqPF6FbcWgCdA1g7y27vT7pZYMTTA/JWJFNwNKqmjX/nph+/sLnNm1iU5OeLRdGf/P+XUuWUO27aXWTshZv/iDAhIh4oitwXD6pZXNbNOZi2otl8ORNJTt695J5gLU41emhNxOJNUBLqWjg7bCAn7vwuZe2sAkqa049wOh37991y62E/CDptDpnjCcyYQmn6s26AF028dlNr0VjLbbNktmk799x63wKnkk4rQ28NzmN7zHMNi0pooHxiEDvnfj85pdPlRejnx2947abTdicUqkKGFdkWtI5Dbx1KrHywhe2FJWTT0ZJLMvobh++/ebr1aE56bQ25xk3sulExJsdEdqKqhdf2Tp0wCcm5SUNi1Tceud0oo+bukhLOac5bx30vK968092xo3kCCUxdQQwNjVp9eaf7LSE3p8NrSOtojD60xk2Z7uEFG9GB1QZdcO7dy5eOMQXFgE4+DO33gkm5rf2pzWIznoZfVpVs6HvYEI+W735JzvzE19skfMalQ6RefOHOxbdROJ7aXUzSrBRGPSziaMKv7Rq07btAHDkzgULzSdaVDDxTFbnMOHTqm7AfLsIGi964ZWfldrsKvlpBxsaErJ1a3hkycIFZvhupUvUDXjvKeKK7cwAOogQfNepu9szSND0ORVM9PkbURRXAEIOruF92KGKz1dt2rY9GkORdE+JETlWijYO79y24Hah/Gelam2W5onixSZJpyKePAgACplqBEViiAz6tKjrN+s0xZdqNm/7cTEbpeFgxM7vWF+flLa24Mitn2rwxHcqnLs06y1eZJN0eQ+5MD/5JG4YjOS0Uzfg/QEneKhqy0+3RpyLpHdGjOhBKTOzU9K8O/furTc0wBJPpFVnZM08ZHgHBaduND85xVjRgPRpVZc1OwANH5q8ZcfWiGvRbZ4FI3qUJM27c7sys1OTt+zYCtgDAyE7UqKORivKXeUJzlrs52m0lKgbCNlB5QOTt+zYumuERQZG6eg/eiTfWbDgRnW2zqnWBfktcqxdQAEQ5DdCqurNOszrqou3b391JNPFyf2PCqKZ/PCCBdeb881OtDZnVvSKoVBY3ur0tE71LlO9ffvOkVhdnA6jWswSrU0PLbjhelV52kGmBzF3c8PqF2BSRDzYZcZ7L9m+Y+doHAoPxajWw0lzs38qk3GXbN+x0weywhu7Xd5iLXpHd5bLSDpAvLHbB7Liku07dj41yiIDY1SeFdmNHTdeNz+l0uJEp8a1WE+FyOo0stuAZVN++otYVmccjFkdXGTWvL3wmusQJjc4kakeiHsGObR9c4Aa2E2Gy6e8+nprqQyiYjCmBYeRA/fugvp5YSgbVWRaKcQ+ITLZTXLp1B1tBZ/xlRrDPioaSRwPgBRgYMl1sBFftw0TY1Yc3pQv+e26ad4NSePzQtZ60kBqjLNHcHASVE+akLVJ4/NdN827oRSlt3EwJqkjeoy7r7v2k4C0OMFkH9/qPFU/HFzV4F0nWFqz87UdY5VCRl3oaELqvu7aT4LcoCIXj4TIQ/qLxH4HEiyf9stdPx+LSXFUHyVm4ASwg/Ufv0nM1qvg4rOVzsa9opJhFVws5ta313/8JgGMmeIPI4rBqAndWl+flGb4juvnLSDQLCI1oTeClLg5eRg5W0LvKSI1CaC54/p5C6QZvrW+fsSqWE/GqJpK++fPbUiZrnMiUwKzWCflRfEgLamqIfl2oLbqstZdI+pBD8WIC71r9uzU3N27c+3zZt+sXr6bVJ2SY7wT8jggYSkRDWkH4fD56W1vvhxxHMl+R1ToaADd1/zFrd7sibTq1Gzcuov8JBa3jbSI5mjdKvrQtF+9sWWkxR4xoSPiHR//2O2E++80dFqW8Utnoy8kkTTELBlOi7osrFvgv1j3699uHkmxR/RwtmvOnEVe7X/SqtOzxhJUdYqExoMAxCmmhCxFmYG4rFmnM31w+ptvvvKROZyNzPT2WbNuocN3UiLTcyyNyAG5Lwm3IlTvxNiiIrWlEDsl4nJkl3g8NGPPnpfO+XKDpwB3H+APzLlqMSnfS4tMiZuT8UeDqJNiSy/dvfdXAHBg1sxrAdfiRGr9YN6OnbOz5NsibLz0zd+9HI0lBu8/QcmEjnZb7bOuvMWAHyREJ8UuOAQsAagnO0Vs2aV73nqd+fYEsAOzZl5LaosTqQ3ju34+KeJytEOe9rkrf/vW1lLuIEtV5CgCsGvOVYtyoT2dVL0o7hEVAUsCGpAdDG3lzH372ob6FNG/37r88npJ6DNJkbogvtiWGKxi7QmV9121Z+/2UnkjsdeyEZH9H5vZkA3sGQUuylm8HZ+RliQ1MGsHJTNz3762yO2L+o3cuJn79rWBkgnM2pOEGmkxdpE6eGDMGufRsv9jMxtkcDMfOyBLEtH7Z12x0EK2uHwtXEkmJ7MDSdhna/ceOGPpbPS7/VfM+IRH4ocVKnWlmHwdIJ44qglZdtmevduKHk0ecfKnAMD+K2Ys9KG1CDgxHAyLON6FTwFuwPsDZlidF9mdKU8KYE8B7rK97b+AcdWA9wdSgCPpY3kjJAWc6ENr2X/FjIVDx1wMihI6eoz3Xl63yJu0KDkxjguXH6ClATcQ+v1qeOCKfft+/nIDEjKMmf8+wL/cgMQV+/a9ap4PDnh/ID0otjGu60dO9CYtv58xoyHO4UHBdyjKyXvr6haL02YILopdn0z6lKjL0vbTwoeuau/eFufrb7+fMa0Bmng8LXpZjvFq/YjoFRj8Q8Lz3ks7Oop6BUZB4kQd/L6u7gZR/J8TqQ544o0FRYGkT4u4LNkBJw9e+Vb71ji7sxNiz5zRAM8n0yJ1WdJLDLGNHCzAId8zymeuam//RaFiFyRQE6BfnDYt3Z/QnyVFrg1JQxyrk/SpQZG7neILM/d3vrQLSM0FYvkNURtvXVZ7izc8kRKZFpAxq1g5uPQjXksRNz3R0ZErpD5k2CJFhSc51dkgLydpKOhVDH960cwSgBswO6hOHiiVyAAwF8jtAlIz93e+pE4eCMwOJgBHMyuWLwgBaaDN7CdnrwGskHxdSEQrAb5WU1M7IZV4IylSGYBFlSuQg19v88TBQINVc9p7trUCyflASc2cqM3fzahZqJZcp4KpQQwvPAkJA2N/EPq5cw4d6srPS8OK6kI6NACo7+npEMq3EkRCPEMaUchlRiYIDb31GML7R0pkAJgPBK1A8qr2nm2e4WdDbz0JQs3IQnmLZ5gkEwQfm3voUOdXCxAZKG6lIAfq6tLHffBPE0Qf6S/gW1cEmADEgE4PuX/2wYM78uvkkX3VT76P3VOn3uDAHypQGxa2UvLjRFyv8d8tCP5h7nvv9WEkVx1D0VqP5PhDU9aMV32k18wr4M7UcySyJ7uCBFdc3dUzqrVwUV+/mV4zPxnKeicy/Wxi50PWj1d1/WbfGqf6SG13d38x/ReVqwjI/DYEfZdc0tQXhv8yXsQZT//lTZJ0g7ut7gBYfnVXT2vTKNdWSH7yurqrpzUAlnuyOzG4iz1tybCRfryIOx6G39Rx4/6utru7v9jdYRx3TfKGS+K3NTX/PE7kkd5T26L5qk50BcCKq3tGN5JPwXswsmtq5ieADQ6Ydio/m4BNENHj5DddT8/fXwlk4zh5Ra+Bow4FCPt6epqOkf86DlCSln93B400R6o36xwisoyVyHm+RkCu7ulpFWB5aNblyMj1Y567VQLaS36zJ51+5Eogi5h2aUls0vlAkJ4woamP/FYloI4UR0oqL3JO5L6re06ki9jeblxEnsWsnp7WQCTjzTpT5AnelYAeB/5t9tSpf7u4o2OAJ75rNMaI8lYnUPmb6uqv7amuPrxr0qT335g06bVdVVXXA4MbnrFl+eeIOL1ZVfWJN6qrX39j0qT391RXH/5NdfXXXgVK+lrjEcGvq6qm7brggmui/5/LZIdwkzerq6/9dVXVtDElNFxwSORycDd5zoocgYCczHss+QwbBORcTBVnQ9NHJDDKKKOMMsooo4wyyiijjDLK+Cji/wF6UgmmVAL7cgAAAABJRU5ErkJggg==' + +GREEN_CHECK_BASE64 = b'iVBORw0KGgoAAAANSUhEUgAAAFoAAABaCAYAAAA4qEECAAAJV0lEQVR4nO2cTWwc5RnHf8/M7Dq7ttdxIIIUcqGA1BQU6Ac9VSkp0NwoJE5PJJygKki9tIIEO7ND3ICEeqJUJYcqCYdKDoS0lWgpH21KuVShH/TjUolLkIpKguO1vWvvfDw9zOxH1l8zjnc3Xs/vFEXy7uzPz/7f93nnGUNKSkpKSkpKSkpKSkpKzyFMYDKC2e0L2TjYGN2+hN5DkXoVP1s4wdjgDwB4jEw3L6u30CguAJzCCV4YUp4bUuzC94BlZaclHx9hPwb78bELp8jJQaa1yrx65OQljhSe4DguLy8uOxUdhzAuDE5HkvvlEWbVRcgSYDKnHnn5CXbhSR5fXHYqemXCSj6Nj1M4Qb88wrR6EMkUpC47Jy8yFsm2sa58kZSlUYTTUVw4hRPkjIPMBC6ySDwoioHPJrEo65M8W3qJx8hwHBdS0UujTZVcLJwkLweY0cUlN35GEQJyYlLRJ3BKP2UEk9P4qejFWTyTibGFq1V2ViwqPMXRqRcYwUgzupXmha9YOJlIMoSZ7ROQEZBgJ6DsQNKKbmZBJsvBFeOilQCPQbGo6Ens0qNRdARpRddollwsnAwXPq0mkgwug2Ixq69glx7Fjr4ZoGlFhyzM5KSVrLgMSIZZfQWndKBWyYBCuo9erhlJIrnKgJGhrKdwSgeYwGSiIRnS7V1Dci2Tp9XDuLLZWJZaJdcyOTw6DZCGZNjIFR0eEDVJNsKFL4lkIsllPVVf+BaRDBu1olfTjCzEpX/pTG5lI1Z0Q7JdOEVeDqwik0PJtUweWZjJrWws0VfbjISv4TJghJlcLB2sL3yLxEUzGyc62tiMsEwl19gYFd2OZiRGXDSzESq67c1IHHq7ojvUjMShlyu6Y81IHHqzojvcjMSh9yq6C81IHHqtorvSjMShd0R3sRmJQ29ER5ebkTjEE21j8EWE/fhr8aZrTFhvgoaZbBxgJqgiZBO8xsJMXqNKblzkStgYOAQL/n2tUB9UKfy8W81IHJbPaBsLh4DRgS8wVvgWDkHrBE5Xscni4Bk69H2GjEeY1fluNCNxWLqid2FxDo9nCp8ny/v0yQ1U/L04M2d4mQyPhxM4XSOaAio4N391Wqbf0ECHUQzixuEaNiNxWLyi7Ujy6OBtZHkPU25gTj2yxgSjAw8vNlvWUWwsjuMOjt30tWlj5k019HoChPiL+5o2I3FYeGFhXHg8PXg7A/I2yHaq6gMGJoopwpz/MOMzZ5tnyzpGdH2FwzffM52f+Y1qsAUXH4n9iMOaNyNxuFJ0TfIPB29jSN5BZDvz6iFR9SoayTZw/YdwZs52NEai68uPfu7uSt/sO4oOJ5KsTZVcLB1sx+5iKRqiJzDZj8/TQ7eQ1z9iyk3M68IP0ZAtzLGP8akz0aJUbeuVRpKH7G1fKlmz7yoMJZdsZKgEHcnkVsKMtuuT7LeS1/eXlAy12TLBVyXHBIcH9uJQbeszHJHk3OEbvzJllkPJVYLYkgO8cOELGs3I/s5JBpDGE0XDOzD9NzBl+5KSm1ECTMACZoN9HJt5vS2ZXYuLseu/XO5z30T1uqvO5A7FRTMG1JoQ/2fkje1UtIoR40MIBj7gAXnjDKMD3+Y47ppWdiQ5Yw/dVelzf5tYsi6x8HVYMoSig7Cqze9SDi6QkyxBzFY7lB2OqW4yXmds6KHlHphJxGNkcPAyo1t3ehbvqOr1CSV3rBmJQ6Oldib/ic9ufP2EPjHR2LKlIZtXGRvYy+O49cfEVkO0T87bW+9ys/PnFN0SO5MVRZlnQLJUgsYpXAcXvsVIvutYilpmmyjzwXc4OnOmfmyZhFpcjA7d7fbxFnAdbszrCKfthYJAqfNbuOVodIb78bGxeH7qI6b1XlQvRJXtxXolwcADAkyxjBMjE3YmPIBPcObdLHkTb5JMsk8WEZVJqyRPUiwdBOhWJrdypQQHDxuLF6b/w4zeh+oFsmLFjhEDAx9fTcm99u8Xz47YI1mKaCzZtWZpdPhOt4+3UN2aSHIGUzAuDTK4xytefimKLqFLmdzK4mcD9Q89eBsZOYcl2xLFSEDAgBjGvPHruz++Ze8H2z4If1FLHbHWK3n4TjfrncOQYaoxF76G5MlBb2BPyfn4zx1poBKy8uldmNl/wkwoO9paSdX45b4P79t7esfpsLJaZdclb97pZv3fIxK/rQ4IyGJIwPRgMLS75Fw435Xzlxgs/ZU+F8XI81MfUeLrBPoxfSTZjWSYVVezwYOv3vm718SRULA2/XJr3xw7f5e7Sd9GjPiSw0w2BJnMycCuknPhfG23Euv6OkycOyxXnuaJbGdO/VhNTUhY2WX9lRZLD9ZFFzFx8Hgqv5NB6y2QrVQTZrLIpZybeaDsXPxL/TqvUeLeM2zIzsu7GHJTbCnQfGp2ln+V9rEDwcHjUP8d5M0/APE7vkgyyKWcl9tTcT45f61LhiR3weuyC7eS5z1MuXE1mY2rZxgt7cUevgPLfw9hc+yFL8pk4HK+2n9f+eh/P1gPkiHpuMHVNzUeebGoBOdAbiebYIGtVzKXM17fva7z6d/Wi2RYzVzHSjcHViIgICcGnoIbdXIr0ZTJltu323X+9+F6kgyrHaBZ7HbXfIJJzXDnIkiMRkbxyYiJcDE/n9lTPnpx3cRFM6ufVGptavpkG+UEMRKHmmT4LFPJ3O8eu/Z3F0txdSNhTU2N5PmFCvfgaxDd9r86wn2yic9UxjV2ueOX/75eJcNazN5F00uCYBS3OH7OO0I54XBhK7WFT+Qz5oxvMD75j/UsGdZqyDE8NDLEEc90ho94m3yHirooVuL3UHyyYgKfUuYBjk2tq93FUqztNKmNJQ6e6WwZ9Tb5R6moF8mOR9PCl5njAXd86q+9IBnaMbYbyRZ782iQ11B2gLXiO9UkazBJ1byXdZ7JrbRjPlqww3MMoyF7+RipLXyBTlK1dvVCJrfSvkH0aILJKBaeCXIyHi2QC2XXFz4uMufvZny25yRDOx+tiP6iYVAs/YiKHiYvGcLhhMYdj3omy6e43v29Khk68WhF7SD+SOEQ/XIsWiBNlCBqRi4xL9/stUxupf0PCx2PRnyfLT3HrH+YnFgoLhlMVC9T9nb3uuTOUptgOlI4xI+HlKOFixzqvwNoejwiZW2oCS0WnuBw4Z4r/i9ljWkePUj/ZHubsbFSySkpKSkpKSkpKSkpKSkpKW3g/3+PYisYNf7zAAAAAElFTkSuQmCC' + + + +''' +M""MMMMM""M dP +M MMMMM M 88 +M MMMMM M 88d888b. .d8888b. 88d888b. .d8888b. .d888b88 .d8888b. +M MMMMM M 88' `88 88' `88 88' `88 88' `88 88' `88 88ooood8 +M `MMM' M 88. .88 88. .88 88 88. .88 88. .88 88. ... +Mb dM 88Y888P' `8888P88 dP `88888P8 `88888P8 `88888P' +MMMMMMMMMMM 88 .88 + dP d8888P +MP""""""`MM oo +M mmmmm..M +M. `YM .d8888b. 88d888b. dP .dP dP .d8888b. .d8888b. +MMMMMMM. M 88ooood8 88' `88 88 d8' 88 88' `"" 88ooood8 +M. .MMM' M 88. ... 88 88 .88' 88 88. ... 88. ... +Mb. .dM `88888P' dP 8888P' dP `88888P' `88888P' +MMMMMMMMMMM +''' + +__upgrade_server_ip = 'upgradeapi.PySimpleGUI.com' +__upgrade_server_port = '5353' + + +def __send_dict(ip, port, dict_to_send): + """ + Send a dictionary to the upgrade server and get back a dictionary in response + :param ip: ip address of the upgrade server + :type ip: str + :param port: port number + :type port: int | str + :param dict_to_send: dictionary of items to send + :type dict_to_send: dict + :return: dictionary that is the reply + :rtype: dict + """ + + # print(f'sending dictionary to ip {ip} port {port}') + try: + # Create a socket object + s = socket.socket() + + s.settimeout(5.0) # set a 5 second timeout + + # connect to the server on local computer + s.connect((ip , int(port))) + # send a python dictionary + s.send(json.dumps(dict_to_send).encode()) + + # receive data from the server + reply_data = s.recv(1024).decode() + # close the connection + s.close() + except Exception as e: + # print(f'Error sending to server:', e) + # print(f'payload:\n', dict_to_send) + reply_data = e + try: + data_dict = json.loads(reply_data) + except Exception as e: + # print(f'UPGRADE THREAD - Error decoding reply {reply_data} as a dictionary. Error = {e}') + data_dict = {} + return data_dict + +def __show_previous_upgrade_information(): + """ + Shows information about upgrades if upgrade information is waiting to be shown + + :return: + """ + + # if nothing to show, then just return + if pysimplegui_user_settings.get('-upgrade info seen-', True) and not pysimplegui_user_settings.get('-upgrade info available-', False): + return + if pysimplegui_user_settings.get('-upgrade show only critical-', False) and pysimplegui_user_settings.get('-severity level-', '') != 'Critical': + return + + message1 = pysimplegui_user_settings.get('-upgrade message 1-', '') + message2 = pysimplegui_user_settings.get('-upgrade message 2-', '') + recommended_version = pysimplegui_user_settings.get('-upgrade recommendation-', '') + severity_level = pysimplegui_user_settings.get('-severity level-', '') + + if severity_level != 'Critical': + return + + layout = [[Image(EMOJI_BASE64_HAPPY_THUMBS_UP), T('An upgrade is available & recommended', font='_ 14')], + [T('It is recommended you upgrade to version {}'.format(recommended_version))], + [T(message1, enable_events=True, k='-MESSAGE 1-')], + [T(message2, enable_events=True, k='-MESSAGE 2-')], + [CB('Do not show this message again in the future', default=True, k='-SKIP IN FUTURE-')], + [B('Close'), T('This window auto-closes in'), T('30', k='-CLOSE TXT-', text_color='white', background_color='red'), T('seconds')]] + + window = Window('PySimpleGUI Intelligent Upgrade', layout, finalize=True) + if 'http' in message1: + window['-MESSAGE 1-'].set_cursor('hand1') + if 'http' in message2: + window['-MESSAGE 2-'].set_cursor('hand1') + + seconds_left=30 + while True: + event, values = window.read(timeout=1000) + if event in ('Close', WIN_CLOSED) or seconds_left < 1: + break + if values['-SKIP IN FUTURE-']: + if not running_trinket(): + pysimplegui_user_settings['-upgrade info available-'] = False + pysimplegui_user_settings['-upgrade info seen-'] = True + if event == '-MESSAGE 1-' and 'http' in message1 and webbrowser_available: + webbrowser.open_new_tab(message1) + elif event == '-MESSAGE 2-' and 'http' in message2 and webbrowser_available: + webbrowser.open_new_tab(message2) + window['-CLOSE TXT-'].update(seconds_left) + seconds_left -= 1 + + window.close() + + +def __get_linux_distribution(): + line_tuple = ('Linux Distro', 'Unknown', 'No lines Found in //etc//os-release') + try: + with open('/etc/os-release') as f: + data = f.read() + lines = data.split('\n') + for line in lines: + if line.startswith('PRETTY_NAME'): + line_split = line.split('=')[1].strip('"') + line_tuple = tuple(line_split.split(' ')) + return line_tuple + except: + line_tuple = ('Linux Distro', 'Exception','Error reading//processing //etc//os-release') + + return line_tuple + + +def __perform_upgrade_check_thread(): + # print(f'Upgrade thread...seen = {pysimplegui_user_settings.get("-upgrade info seen-", False)}') + try: + if running_trinket(): + os_name = 'Trinket' + os_ver = __get_linux_distribution() + elif running_replit(): + os_name = 'REPL.IT' + os_ver = __get_linux_distribution() + elif running_windows(): + os_name = 'Windows' + os_ver = platform.win32_ver() + elif running_linux(): + os_name = 'Linux' + os_ver = __get_linux_distribution() + elif running_mac(): + os_name = 'Mac' + os_ver = platform.mac_ver() + else: + os_name = 'Other' + os_ver = '' + + psg_ver = version + framework_ver = framework_version + python_ver = sys.version + + upgrade_dict = { + 'OSName' : str(os_name), + 'OSVersion' : str(os_ver), + 'PythonVersion' : str(python_ver), + 'PSGVersion' : str(psg_ver), + 'FrameworkName' : 'tkinter', + 'FrameworkVersion' : str(framework_ver), + } + reply_data = __send_dict(__upgrade_server_ip, __upgrade_server_port, upgrade_dict) + + recommended_version = reply_data.get('SuggestedVersion', '') + message1 = reply_data.get('Message1', '') + message2 = reply_data.get('Message2', '') + severity_level = reply_data.get('SeverityLevel', '') + # If any part of the reply has changed from the last reply, overwrite the data and set flags so user will be informed + if (message1 or message2) and not running_trinket(): + if pysimplegui_user_settings.get('-upgrade message 1-', '') != message1 or \ + pysimplegui_user_settings.get('-upgrade message 2-', '') != message2 or \ + pysimplegui_user_settings.get('-upgrade recommendation-', '') != recommended_version or \ + pysimplegui_user_settings.get('-severity level-', '') != severity_level: + # Save the data to the settings file + pysimplegui_user_settings['-upgrade info seen-'] = False + pysimplegui_user_settings['-upgrade info available-'] = True + pysimplegui_user_settings['-upgrade message 1-'] = message1 + pysimplegui_user_settings['-upgrade message 2-'] = message2 + pysimplegui_user_settings['-upgrade recommendation-'] = recommended_version + pysimplegui_user_settings['-severity level-'] = severity_level + except Exception as e: + reply_data = {} + # print('Upgrade server error', e) + # print(f'Upgrade Reply = {reply_data}') + +def __perform_upgrade_check(): + # For now, do not show data returned. Still testing and do not want to "SPAM" users with any popups + __show_previous_upgrade_information() + threading.Thread(target=lambda: __perform_upgrade_check_thread(), daemon=True).start() + + +# =========================================================================# +# MP""""""`MM dP dP +# M mmmmm..M 88 88 +# M. `YM .d8888b. 88d888b. .d8888b. .d8888b. .d8888b. 88d888b. .d8888b. 88d888b. .d8888b. d8888P +# MMMMMMM. M 88' `"" 88' `88 88ooood8 88ooood8 88ooood8 88' `88 Y8ooooo. 88' `88 88' `88 88 +# M. .MMM' M 88. ... 88 88. ... 88. ... 88. ... 88 88 88 88 88 88. .88 88 +# Mb. .dM `88888P' dP `88888P' `88888P' `88888P' dP dP `88888P' dP dP `88888P' dP +# MMMMMMMMMMM +# +# M"""""`'"""`YM oo +# M mm. mm. M +# M MMM MMM M .d8888b. .d8888b. dP .d8888b. +# M MMM MMM M 88' `88 88' `88 88 88' `"" +# M MMM MMM M 88. .88 88. .88 88 88. ... +# M MMM MMM M `88888P8 `8888P88 dP `88888P' +# MMMMMMMMMMMMMM .88 +# d8888P +# M#"""""""'M oo M""MMMMM""MM +# ## mmmm. `M M MMMMM MM +# #' .M .d8888b. .d8888b. dP 88d888b. .d8888b. M `M .d8888b. 88d888b. .d8888b. +# M# MMMb.'YM 88ooood8 88' `88 88 88' `88 Y8ooooo. M MMMMM MM 88ooood8 88' `88 88ooood8 +# M# MMMM' M 88. ... 88. .88 88 88 88 88 M MMMMM MM 88. ... 88 88. ... +# M# .;M `88888P' `8888P88 dP dP dP `88888P' M MMMMM MM `88888P' dP `88888P' +# M#########M .88 MMMMMMMMMMMM +# d8888P +# =========================================================================# + + + +# =========================================================================# + +# MP""""""`MM dP dP .8888b +# M mmmmm..M 88 88 88 " +# M. `YM d8888P .d8888b. 88d888b. d8888P .d8888b. 88aaa +# MMMMMMM. M 88 88' `88 88' `88 88 88' `88 88 +# M. .MMM' M 88 88. .88 88 88 88. .88 88 +# Mb. .dM dP `88888P8 dP dP `88888P' dP +# MMMMMMMMMMM +# +# dP dP oo dP dP +# dP dP dP dP +# 88d8b.d8b. .d8888b. dP 88d888b. +# 88'`88'`88 88' `88 88 88' `88 +# 88 88 88 88. .88 88 88 88 +# dP dP dP `88888P8 dP dP dP +# +# +# MM""""""""`M dP +# MM mmmmmmmM 88 +# M` MMMM 88d888b. d8888P 88d888b. dP dP +# MM MMMMMMMM 88' `88 88 88' `88 88 88 +# MM MMMMMMMM 88 88 88 88 88. .88 +# MM .M dP dP dP dP `8888P88 +# MMMMMMMMMMMM .88 +# d8888P +# MM"""""""`YM oo dP +# MM mmmmm M 88 +# M' .M .d8888b. dP 88d888b. d8888P .d8888b. +# MM MMMMMMMM 88' `88 88 88' `88 88 Y8ooooo. +# MM MMMMMMMM 88. .88 88 88 88 88 88 +# MM MMMMMMMM `88888P' dP dP dP dP `88888P' +# MMMMMMMMMMMM + +# ==========================================================================# + + +# M"""""`'"""`YM oo +# M mm. mm. M +# M MMM MMM M .d8888b. dP 88d888b. +# M MMM MMM M 88' `88 88 88' `88 +# M MMM MMM M 88. .88 88 88 88 +# M MMM MMM M `88888P8 dP dP dP +# MMMMMMMMMMMMMM +# +# MM"""""""`YM dP MM'"""""`MM oo dP M""MMMMM""MM dP +# MM mmmmm M 88 M' .mmm. `M 88 M MMMMM MM 88 +# M' .M .d8888b. .d8888b. d8888P M MMMMMMMM dP d8888P M `M dP dP 88d888b. +# MM MMMMMMMM 88' `88 Y8ooooo. 88 M MMM `M 88 88 M MMMMM MM 88 88 88' `88 +# MM MMMMMMMM 88. .88 88 88 M. `MMM' .M 88 88 M MMMMM MM 88. .88 88. .88 +# MM MMMMMMMM `88888P' `88888P' dP MM. .MM dP dP M MMMMM MM `88888P' 88Y8888' +# MMMMMMMMMMMM MMMMMMMMMMM MMMMMMMMMMMM +# +# M""M +# M M +# M M .d8888b. .d8888b. dP dP .d8888b. +# M M Y8ooooo. Y8ooooo. 88 88 88ooood8 +# M M 88 88 88. .88 88. ... +# M M `88888P' `88888P' `88888P' `88888P' +# MMMM + + +def _github_issue_post_make_markdown(issue_type, operating_system, os_ver, psg_port, psg_ver, gui_ver, python_ver, + python_exp, prog_exp, used_gui, gui_notes, + cb_docs, cb_demos, cb_demo_port, cb_readme_other, cb_command_line, cb_issues, cb_latest_pypi, cb_github, + detailed_desc, code, project_details, where_found): + body = \ +""" +## Type of Issue (Enhancement, Error, Bug, Question) + +{} + +---------------------------------------- + +## Environment + +#### Operating System + +{} version {} + +#### PySimpleGUI Port (tkinter, Qt, Wx, Web) + +{} + +---------------------------------------- + +## Versions + + +#### Python version (`sg.sys.version`) + +{} + +#### PySimpleGUI Version (`sg.__version__`) + +{} + +#### GUI Version (tkinter (`sg.tclversion_detailed`), PySide2, WxPython, Remi) + +{} +""".format(issue_type, operating_system,os_ver, psg_port,python_ver, psg_ver, gui_ver, project_details) + + body2 = \ +""" + + +--------------------- + +## Your Experience In Months or Years (optional) + +{} Years Python programming experience +{} Years Programming experience overall +{} Have used another Python GUI Framework? (tkinter, Qt, etc) (yes/no is fine) +{} + +--------------------- + +## Troubleshooting + +These items may solve your problem. Please check those you've done by changing - [ ] to - [X] + +- [{}] Searched main docs for your problem www.PySimpleGUI.org +- [{}] Looked for Demo Programs that are similar to your goal. It is recommend you use the Demo Browser! Demos.PySimpleGUI.org +- [{}] If not tkinter - looked for Demo Programs for specific port +- [{}] For non tkinter - Looked at readme for your specific port if not PySimpleGUI (Qt, WX, Remi) +- [{}] Run your program outside of your debugger (from a command line) +- [{}] Searched through Issues (open and closed) to see if already reported Issues.PySimpleGUI.org +- [{}] Upgraded to the latest official release of PySimpleGUI on PyPI +- [{}] Tried using the PySimpleGUI.py file on GitHub. Your problem may have already been fixed but not released + +## Detailed Description + +{} + +#### Code To Duplicate + + +```python +{} + + +``` + +#### Screenshot, Sketch, or Drawing + + + +""".format(python_exp, prog_exp, used_gui, gui_notes, + cb_docs, cb_demos, cb_demo_port, cb_readme_other, cb_command_line, cb_issues, cb_latest_pypi, cb_github, + detailed_desc, code if len(code) > 10 else '# Paste your code here') + + + if project_details or where_found: + body2 += '------------------------' + + if project_details: + body2 += \ +""" +## Watcha Makin? +{} +""".format(str(project_details)) + + if where_found: + body2 += \ +""" +## How did you find PySimpleGUI? +{} +""".format(str(where_found)) + return body + body2 + + +def _github_issue_post_make_github_link(title, body): + pysimplegui_url = "https://github.com/PySimpleGUI/PySimpleGUI" + pysimplegui_issues = "{}/issues/new?".format(pysimplegui_url) + + # Fix body cuz urllib can't do it smfh + getVars = {'title': str(title), 'body': str(body)} + return (pysimplegui_issues + urllib.parse.urlencode(getVars).replace("%5Cn", "%0D")) + + +######################################################################################################### + +def _github_issue_post_validate(values, checklist, issue_types): + issue_type = None + for itype in issue_types: + if values[itype]: + issue_type = itype + break + if issue_type is None: + popup_error('Must choose issue type', keep_on_top=True) + return False + if values['-OS WIN-']: + os_ver = values['-OS WIN VER-'] + elif values['-OS LINUX-']: + os_ver = values['-OS LINUX VER-'] + elif values['-OS MAC-']: + os_ver = values['-OS MAC VER-'] + elif values['-OS OTHER-']: + os_ver = values['-OS OTHER VER-'] + else: + popup_error('Must choose Operating System', keep_on_top=True) + return False + + if os_ver == '': + popup_error('Must fill in an OS Version', keep_on_top=True) + return False + + checkboxes = any([values[('-CB-', i)] for i in range(len(checklist))]) + if not checkboxes: + popup_error('None of the checkboxes were checked.... you need to have tried something...anything...', keep_on_top=True) + return False + + title = values['-TITLE-'].strip() + if len(title) == 0: + popup_error("Title can't be blank", keep_on_top=True) + return False + elif title[1:len(title) - 1] == issue_type: + popup_error("Title can't be blank (only the type of issue isn't enough)", keep_on_top=True) + return False + + if len(values['-ML DETAILS-']) < 4: + popup_error("A little more details would be awesome", keep_on_top=True) + return False + + return True + + +def _github_issue_help(): + heading_font = '_ 12 bold underline' + text_font = '_ 10' + + def HelpText(text): + return Text(text, size=(80, None), font=text_font) + + help_why = \ +""" Let's start with a review of the Goals of the PySimpleGUI project +1. To have fun +2. For you to be successful + +This form is as important as the documentation and the demo programs to meeting those goals. + +The GitHub Issue GUI is here to help you more easily log issues on the PySimpleGUI GitHub Repo. """ + + help_goals = \ +""" The goals of using GitHub Issues for PySimpleGUI question, problems and suggestions are: +* Give you direct access to engineers with the most knowledge of PySimpleGUI +* Answer your questions in the most precise and correct way possible +* Provide the highest quality solutions possible +* Give you a checklist of things to try that may solve the problem +* A single, searchable database of known problems and their workarounds +* Provide a place for the PySimpleGUI project to directly provide support to users +* A list of requested enhancements +* An easy to use interface to post code and images +* A way to track the status and have converstaions about issues +* Enable multiple people to help users """ + + help_explain = \ +""" GitHub does not provide a "form" that normal bug-tracking-databases provide. As a result, a form was created specifically for the PySimpleGUI project. + +The most obvious questions about this form are +* Why is there a form? Other projects don't have one? +* My question is an easy one, why does it still need a form? + +The answer is: +I want you to get your question answered with the highest quality answer possible as quickly as possible. + +The longer answer - For quite a while there was no form. It resulted the same back and forth, multiple questions comversation. "What version are you running?" "What OS are you using?" These waste precious time. + +If asking nicely helps... PLEASE ... please fill out the form. + +I can assure you that this form is not here to punish you. It doesn't exist to make you angry and frustrated. It's not here for any purpose than to try and get you support and make PySimpleGUI better. """ + + help_experience = \ +""" Not many Bug-tracking systems ask about you as a user. Your experience in programming, programming in Python and programming a GUI are asked to provide you with the best possible answer. Here's why it's helpful. You're a human being, with a past, and a some amount of experience. Being able to taylor the reply to your issue in a way that fits you and your experience will result in a reply that's efficient and clear. It's not something normally done but perhaps it should be. It's meant to provide you with a personal response. + +If you've been programming for a month, the person answering your question can answer your question in a way that's understandable to you. Similarly, if you've been programming for 20 years and have used multiple Python GUI frameworks, then you are unlikely to need as much explanation. You'll also have a richer GUI vocabularly. It's meant to try and give you a peronally crafted response that's on your wavelength. Fun & success... Remember those are our shared goals""" + + help_steps = \ +""" The steps to log an issue are: +1. Fill in the form +2. Click Post Issue """ + + # layout = [ [T('Goals', font=heading_font, pad=(0,0))], + # [HelpText(help_goals)], + # [T('Why?', font=heading_font, pad=(0,0))], + # [HelpText(help_why)], + # [T('FAQ', font=heading_font, pad=(0,0))], + # [HelpText(help_explain)], + # [T('Experience (optional)', font=heading_font)], + # [HelpText(help_experience)], + # [T('Steps', font=heading_font, pad=(0,0))], + # [HelpText(help_steps)], + # [B('Close')]] + + t_goals = Tab('Goals', [[HelpText(help_goals)]]) + t_why = Tab('Why', [[HelpText(help_why)]]) + t_faq = Tab('FAQ', [[HelpText(help_explain)]]) + t_exp = Tab('Experience', [[HelpText(help_experience)]]) + t_steps = Tab('Steps', [[HelpText(help_steps)]]) + + layout = [[TabGroup([[t_goals, t_why, t_faq, t_exp, t_steps]])], + [B('Close')]] + + Window('GitHub Issue GUI Help', layout, keep_on_top=True).read(close=True) + + return + + +def main_open_github_issue(): + font_frame = '_ 14' + issue_types = ('Question', 'Bug', 'Enhancement', 'Error Message') + frame_type = [[Radio(t, 1, size=(10, 1), enable_events=True, k=t)] for t in issue_types] + + v_size = (15, 1) + frame_versions = [[T('Python', size=v_size), In(sys.version, size=(20, 1), k='-VER PYTHON-')], + [T('PySimpleGUI', size=v_size), In(ver, size=(20, 1), k='-VER PSG-')], + [T('tkinter', size=v_size), In(tclversion_detailed, size=(20, 1), k='-VER TK-')]] + + frame_platforms = [[T('OS '), T('Details')], + [Radio('Windows', 2, running_windows(), size=(8, 1), k='-OS WIN-'), In(size=(8, 1), k='-OS WIN VER-')], + [Radio('Linux', 2, running_linux(), size=(8, 1), k='-OS LINUX-'), In(size=(8, 1), k='-OS LINUX VER-')], + [Radio('Mac', 2, running_mac(), size=(8, 1), k='-OS MAC-'), In(size=(8, 1), k='-OS MAC VER-')], + [Radio('Other', 2, size=(8, 1), k='-OS OTHER-'), In(size=(8, 1), k='-OS OTHER VER-')]] + + col_experience = [[T('Optional Experience Info')], + [In(size=(4, 1), k='-EXP PROG-'), T('Years Programming')], + [In(size=(4, 1), k='-EXP PYTHON-'), T('Years Writing Python')], + [CB('Previously programmed a GUI', k='-CB PRIOR GUI-')], + [T('Share more if you want....')], + [In(size=(25, 1), k='-EXP NOTES-', expand_x=True)]] + + checklist = (('Searched main docs for your problem', 'www.PySimpleGUI.org'), + ('Looked for Demo Programs that are similar to your goal.\nIt is recommend you use the Demo Browser!', 'https://Demos.PySimpleGUI.org'), + ('If not tkinter - looked for Demo Programs for specific port', ''), + ('For non tkinter - Looked at readme for your specific port if not PySimpleGUI (Qt, WX, Remi)', ''), + ('Run your program outside of your debugger (from a command line)', ''), + ('Searched through Issues (open and closed) to see if already reported', 'https://Issues.PySimpleGUI.org'), + ('Upgraded to the latest official release of PySimpleGUI on PyPI', 'https://Upgrading.PySimpleGUI.org'), + ('Tried using the PySimpleGUI.py file on GitHub. Your problem may have already been fixed but not released.', '')) + + checklist_col1 = Col([[CB(c, k=('-CB-', i)), T(t, k='-T{}-'.format(i), enable_events=True)] for i, (c, t) in enumerate(checklist[:4])], k='-C FRAME CBs1-') + checklist_col2 = Col([[CB(c, k=('-CB-', i + 4)), T(t, k='-T{}-'.format(i + 4), enable_events=True)] for i, (c, t) in enumerate(checklist[4:])], pad=(0, 0), + k='-C FRAME CBs2-') + checklist_tabgropup = TabGroup( + [[Tab('Checklist 1 *', [[checklist_col1]], expand_x=True, expand_y=True), Tab('Checklist 2 *', [[checklist_col2]]), Tab('Experience', col_experience, k='-Tab Exp-', pad=(0, 0))]], expand_x=True, expand_y=True) + + frame_details = [[Multiline(size=(65, 10), font='Courier 10', k='-ML DETAILS-', expand_x=True, expand_y=True)]] + + tooltip_project_details = 'If you care to share a little about your project,\nthen by all means tell us what you are making!' + frame_project_details = [[Multiline(size=(65, 10), font='Courier 10', k='-ML PROJECT DETAILS-', expand_x=True, expand_y=True, tooltip=tooltip_project_details)]] + + tooltip_where_find_psg = 'Where did you learn about PySimpleGUI?' + frame_where_you_found_psg = [[Multiline(size=(65, 10), font='Courier 10', k='-ML FOUND PSG-', expand_x=True, expand_y=True, tooltip=tooltip_where_find_psg)]] + + tooltip_code = 'A short program that can be immediately run will considerably speed up getting you quality help.' + frame_code = [[Multiline(size=(80, 10), font='Courier 8', k='-ML CODE-', expand_x=True, expand_y=True, tooltip=tooltip_code)]] + + frame_markdown = [[Multiline(size=(80, 10), font='Courier 8', k='-ML MARKDOWN-', expand_x=True, expand_y=True)]] + + top_layout = [[Col([[Text('Open A GitHub Issue (* = Required Info)', font='_ 15')]], expand_x=True), + Col([[B('Help')]]) + ], + [Frame('Title *', [[Input(k='-TITLE-', size=(50, 1), font='_ 14', focus=True)]], font=font_frame)], + # Image(data=EMOJI_BASE64_WEARY)], + vtop([ + Frame('Platform *', frame_platforms, font=font_frame), + Frame('Type of Issue *', frame_type, font=font_frame), + Frame('Versions *', frame_versions, font=font_frame), + ])] + + middle_layout = [ + [Frame('Checklist * (note that you can click the links)', [[checklist_tabgropup]], font=font_frame, k='-CLIST FRAME-', expand_x=True, expand_y=True)], + [HorizontalSeparator()], + [T(SYMBOL_DOWN + ' If you need more room for details grab the dot and drag to expand', background_color='red', text_color='white')]] + + bottom_layout = [[TabGroup([[Tab('Details *\n', frame_details, pad=(0, 0)), + Tab('SHORT Program\nto duplicate problem *', frame_code, pad=(0, 0)), + Tab('Your Project Details\n(optional)', frame_project_details, pad=(0, 0)), + Tab('Where you found us?\n(optional)', frame_where_you_found_psg, pad=(0, 0)), + Tab('Markdown Output\n', frame_markdown, pad=(0, 0)), + ]], k='-TABGROUP-', expand_x=True, expand_y=True), + ]] + + + layout_pane = Pane([Col(middle_layout), Col(bottom_layout)], key='-PANE-', expand_x=True, expand_y=True) + + layout = [ + [pin(B(SYMBOL_DOWN, pad=(0, 0), k='-HIDE CLIST-', tooltip='Hide/show upper sections of window')), pin(Col(top_layout, k='-TOP COL-'))], + [layout_pane], + [Col([[B('Post Issue'), B('Create Markdown Only'), B('Quit')]])]] + + window = Window('Open A GitHub Issue', layout, finalize=True, resizable=True, enable_close_attempted_event=True, margins=(0, 0)) + + + + # for i in range(len(checklist)): + [window['-T{}-'.format(i)].set_cursor('hand1') for i in range(len(checklist))] + # window['-TABGROUP-'].expand(True, True, True) + # window['-ML CODE-'].expand(True, True, True) + # window['-ML DETAILS-'].expand(True, True, True) + # window['-ML MARKDOWN-'].expand(True, True, True) + # window['-PANE-'].expand(True, True, True) + + if running_mac(): + window['-OS MAC VER-'].update(platform.mac_ver()) + elif running_windows(): + window['-OS WIN VER-'].update(platform.win32_ver()) + elif running_linux(): + window['-OS LINUX VER-'].update(platform.libc_ver()) + + + window.bring_to_front() + while True: # Event Loop + event, values = window.read() + # print(event, values) + if event in (WINDOW_CLOSE_ATTEMPTED_EVENT, 'Quit'): + if popup_yes_no('Do you really want to exit?', + 'If you have not clicked Post Issue button and then clicked "Submit New Issue" button ' + 'then your issue will not have been submitted to GitHub.\n' + 'If you are having trouble with PySimpleGUI opening your browser, consider generating ' + 'the markdown, copying it to a text file, and then using it later to manually paste into a new issue ' + '\n' + 'Are you sure you want to quit?', + image=EMOJI_BASE64_PONDER, keep_on_top=True + ) == 'Yes': + break + if event == WIN_CLOSED: + break + if event in ['-T{}-'.format(i) for i in range(len(checklist))]: + webbrowser.open_new_tab(window[event].get()) + if event in issue_types: + title = str(values['-TITLE-']) + if len(title) != 0: + if title[0] == '[' and title.find(']'): + title = title[title.find(']') + 1:] + title = title.strip() + window['-TITLE-'].update('[{}] {}'.format(event, title)) + if event == '-HIDE CLIST-': + window['-TOP COL-'].update(visible=not window['-TOP COL-'].visible) + window['-HIDE CLIST-'].update(text=SYMBOL_UP if window['-HIDE CLIST-'].get_text() == SYMBOL_DOWN else SYMBOL_DOWN) + if event == 'Help': + _github_issue_help() + elif event in ('Post Issue', 'Create Markdown Only'): + issue_type = None + for itype in issue_types: + if values[itype]: + issue_type = itype + break + if issue_type is None: + popup_error('Must choose issue type', keep_on_top=True) + continue + if values['-OS WIN-']: + operating_system = 'Windows' + os_ver = values['-OS WIN VER-'] + elif values['-OS LINUX-']: + operating_system = 'Linux' + os_ver = values['-OS LINUX VER-'] + elif values['-OS MAC-']: + operating_system = 'Mac' + os_ver = values['-OS MAC VER-'] + elif values['-OS OTHER-']: + operating_system = 'Other' + os_ver = values['-OS OTHER VER-'] + else: + popup_error('Must choose Operating System', keep_on_top=True) + continue + checkboxes = ['X' if values[('-CB-', i)] else ' ' for i in range(len(checklist))] + + if not _github_issue_post_validate(values, checklist, issue_types): + continue + + cb_dict = {'cb_docs': checkboxes[0], 'cb_demos': checkboxes[1], 'cb_demo_port': checkboxes[2], 'cb_readme_other': checkboxes[3], + 'cb_command_line': checkboxes[4], 'cb_issues': checkboxes[5], 'cb_latest_pypi': checkboxes[6], 'cb_github': checkboxes[7], 'detailed_desc': values['-ML DETAILS-'], + 'code': values['-ML CODE-'], + 'project_details': values['-ML PROJECT DETAILS-'].rstrip(), + 'where_found': values['-ML FOUND PSG-']} + + markdown = _github_issue_post_make_markdown(issue_type, operating_system, os_ver, 'tkinter', values['-VER PSG-'], values['-VER TK-'], + values['-VER PYTHON-'], + values['-EXP PYTHON-'],values['-EXP PROG-'], 'Yes' if values['-CB PRIOR GUI-'] else 'No', + values['-EXP NOTES-'], + **cb_dict) + window['-ML MARKDOWN-'].update(markdown) + link = _github_issue_post_make_github_link(values['-TITLE-'], window['-ML MARKDOWN-'].get()) + if event == 'Post Issue': + webbrowser.open_new_tab(link) + else: + popup('Your markdown code is in the Markdown tab', keep_on_top=True) + + window.close() + + +''' +MM'"""""`MM oo dP M""MMMMM""MM dP +M' .mmm. `M 88 M MMMMM MM 88 +M MMMMMMMM dP d8888P M `M dP dP 88d888b. +M MMM `M 88 88 M MMMMM MM 88 88 88' `88 +M. `MMM' .M 88 88 M MMMMM MM 88. .88 88. .88 +MM. .MM dP dP M MMMMM MM `88888P' 88Y8888' +MMMMMMMMMMM MMMMMMMMMMMM + +M""MMMMM""M dP +M MMMMM M 88 +M MMMMM M 88d888b. .d8888b. 88d888b. .d8888b. .d888b88 .d8888b. +M MMMMM M 88' `88 88' `88 88' `88 88' `88 88' `88 88ooood8 +M `MMM' M 88. .88 88. .88 88 88. .88 88. .88 88. ... +Mb dM 88Y888P' `8888P88 dP `88888P8 `88888P8 `88888P' +MMMMMMMMMMM 88 .88 + dP d8888P + +''' + + +''' +M""""""""M dP dP +Mmmm mmmM 88 88 +MMMM MMMM 88d888b. 88d888b. .d8888b. .d8888b. .d888b88 +MMMM MMMM 88' `88 88' `88 88ooood8 88' `88 88' `88 +MMMM MMMM 88 88 88 88. ... 88. .88 88. .88 +MMMM MMMM dP dP dP `88888P' `88888P8 `88888P8 +MMMMMMMMMM +''' + +def _the_github_upgrade_thread(window, sp): + """ + The thread that's used to run the subprocess so that the GUI can continue and the stdout/stderror is collected + + :param window: + :param sp: + :return: + """ + + window.write_event_value('-THREAD-', (sp, '===THEAD STARTING===')) + window.write_event_value('-THREAD-', (sp, '----- STDOUT & STDERR Follows ----')) + for line in sp.stdout: + oline = line.decode().rstrip() + window.write_event_value('-THREAD-', (sp, oline)) + + # DO NOT CHECK STDERR because it won't exist anymore. The subprocess code now combines stdout and stderr + # window.write_event_value('-THREAD-', (sp, '----- STDERR ----')) + + # for line in sp.stderr: + # oline = line.decode().rstrip() + # window.write_event_value('-THREAD-', (sp, oline)) + window.write_event_value('-THREAD-', (sp, '===THEAD DONE===')) + + + +def _copy_files_from_github(): + """Update the local PySimpleGUI installation from Github""" + + github_url = 'https://raw.githubusercontent.com/PySimpleGUI/PySimpleGUI/master/' + #files = ["PySimpleGUI.py", "setup.py"] + files = ["PySimpleGUI.py"] + + # add a temp directory + temp_dir = tempfile.TemporaryDirectory() + psg_dir = os.path.join(temp_dir.name, 'PySimpleGUI') + path = psg_dir + + + os.mkdir(path) + # path = os.path.abspath('temp') + + # download the files + downloaded = [] + for file in files: + with request.urlopen(github_url + file) as response: + with open(os.path.join(path, file), 'wb') as f: + f.write(response.read()) + downloaded.append(file) + + # get the new version number if possible + with open(os.path.join(path, files[0]), encoding='utf-8') as f: + text_data = f.read() + + package_version = "Unknown" + match = re.search(r'__version__ = \"([\d\.]+)', text_data) + if match: + package_version = match.group(1) + + # create a setup.py file from scratch + setup_text = ''.join([ + "import setuptools\n", + "setuptools.setup(", + "name='PySimpleGUI',", + "author='PySimpleGUI'," + "author_email='PySimpleGUI@PySimpleGUI.org',", + "description='Unreleased Development Version',", + "url='https://github.com/PySimpleGUI/PySimpleGUI'," + "packages=setuptools.find_packages(),", + "version='", package_version, "',", + "entry_points={", + "'gui_scripts': [", + "'psgissue=PySimpleGUI.PySimpleGUI:main_open_github_issue',", + "'psgmain=PySimpleGUI.PySimpleGUI:_main_entry_point',", + "'psgupgrade=PySimpleGUI.PySimpleGUI:_upgrade_entry_point',", + "'psghelp=PySimpleGUI.PySimpleGUI:main_sdk_help',", + "'psgver=PySimpleGUI.PySimpleGUI:main_get_debug_data',", + "'psgsettings=PySimpleGUI.PySimpleGUI:main_global_pysimplegui_settings',", + "],", + "},)" + ]) + + with open(os.path.join(temp_dir.name, 'setup.py'), 'w', encoding='utf-8') as f: + f.write(setup_text) + + # create an __init__.py file + with open(os.path.join(path, '__init__.py'), 'w', encoding='utf-8') as f: + f.writelines([ + 'name="PySimpleGUI"\n', + 'from .PySimpleGUI import *\n', + 'from .PySimpleGUI import __version__' + ]) + + # install the pysimplegui package from local dist + # https://pip.pypa.io/en/stable/user_guide/?highlight=subprocess#using-pip-from-your-program + # subprocess.check_call([sys.executable, '-m', 'pip', 'install', path]) + # python_command = execute_py_get_interpreter() + python_command = sys.executable # always use the currently running interpreter to perform the pip! + if 'pythonw' in python_command: + python_command = python_command.replace('pythonw', 'python') + + layout = [[Text('Pip Upgrade Progress')], + [Multiline(s=(90,15), k='-MLINE-', reroute_cprint=True, write_only=True, expand_x=True, expand_y=True)], + [Button('Downloading...', k='-EXIT-'), Sizegrip()]] + + window = Window('Pip Upgrade', layout, finalize=True, keep_on_top=True, modal=True, disable_close=True, resizable=True) + + window.disable_debugger() + + cprint('The value of sys.executable = ', sys.executable, c='white on red') + + # if not python_command: + # python_command = sys.executable + + cprint('Installing with the Python interpreter =', python_command, c='white on purple') + + sp = execute_command_subprocess(python_command, '-m pip install', temp_dir.name, pipe_output=True) + + threading.Thread(target=_the_github_upgrade_thread, args=(window, sp), daemon=True).start() + + while True: + event, values = window.read() + if event == WIN_CLOSED or (event == '-EXIT-' and window['-EXIT-'].ButtonText == 'Done'): + break + if event == '-THREAD-': + cprint(values['-THREAD-'][1]) + if values['-THREAD-'][1] == '===THEAD DONE===': + window['-EXIT-'].update(text='Done', button_color='white on red') + window.close() + # cleanup and remove files + temp_dir.cleanup() + + + return package_version + + +def _upgrade_from_github(): + mod_version = _copy_files_from_github() + + popup("*** SUCCESS ***", "PySimpleGUI.py installed version:", mod_version, + "For python located at:", os.path.dirname(sys.executable), keep_on_top=True, background_color='red', + text_color='white') + + +def _upgrade_gui(): + try: + cur_ver = version[:version.index('\n')] + except: + cur_ver = version + + if popup_yes_no('* WARNING *', + 'You are about to upgrade your PySimpleGUI package previously installed via pip to the latest version location on the GitHub server.', + 'You are running verrsion {}'.format(cur_ver), + '', + 'Are you sure you want to overwrite this release?', title='Are you sure you want to overwrite?', + keep_on_top=True) == 'Yes': + _upgrade_from_github() + else: + popup_quick_message('Cancelled upgrade\nNothing overwritten', background_color='red', text_color='white', keep_on_top=True, non_blocking=False) + +# main_upgrade_from_github = _upgrade_gui + +def _upgrade_entry_point(): + """ + This function is entered via the psgupgrade.exe file. + + It is needed so that the exe file will exit and thus allow itself to be overwritten which + is what the upgrade will do. + It simply runs the PySimpleGUI.py file with a command line argument "upgrade" which will + actually do the upgrade. + """ + interpreter = sys.executable + if 'pythonw' in interpreter: + interpreter = interpreter.replace('pythonw', 'python') + execute_py_file(__file__, 'upgrade', interpreter_command=interpreter) + + + +def _main_entry_point(): + # print('Restarting main as a new process...(needed in case you want to GitHub Upgrade)') + # Relaunch using the same python interpreter that was used to run this function + interpreter = sys.executable + if 'pythonw' in interpreter: + interpreter = interpreter.replace('pythonw', 'python') + execute_py_file(__file__, interpreter_command=interpreter) + +main_upgrade_from_github = _upgrade_entry_point + +#################################################################################################### + +# M"""""`'"""`YM oo +# M mm. mm. M +# M MMM MMM M .d8888b. dP 88d888b. +# M MMM MMM M 88' `88 88 88' `88 +# M MMM MMM M 88. .88 88 88 88 +# M MMM MMM M `88888P8 dP dP dP +# MMMMMMMMMMMMMM +# +# MM'"""""`MM dP +# M' .mmm. `M 88 +# M MMMMMMMM .d8888b. d8888P +# M MMM `M 88ooood8 88 +# M. `MMM' .M 88. ... 88 +# MM. .MM `88888P' dP +# MMMMMMMMMMM +# +# M""""""'YMM dP +# M mmmm. `M 88 +# M MMMMM M .d8888b. 88d888b. dP dP .d8888b. +# M MMMMM M 88ooood8 88' `88 88 88 88' `88 +# M MMMM' .M 88. ... 88. .88 88. .88 88. .88 +# M .MM `88888P' 88Y8888' `88888P' `8888P88 +# MMMMMMMMMMM .88 +# d8888P +# M""""""'YMM dP +# M mmmm. `M 88 +# M MMMMM M .d8888b. d8888P .d8888b. +# M MMMMM M 88' `88 88 88' `88 +# M MMMM' .M 88. .88 88 88. .88 +# M .MM `88888P8 dP `88888P8 +# MMMMMMMMMMM + + +def main_get_debug_data(suppress_popup=False): + """ + Collect up and display the data needed to file GitHub issues. + This function will place the information on the clipboard. + You MUST paste the information from the clipboard prior to existing your application (except on Windows). + :param suppress_popup: If True no popup window will be shown. The string will be only returned, not displayed + :type suppress_popup: (bool) + :returns: String containing the information to place into the GitHub Issue + :rtype: (str) + """ + message = get_versions() + clipboard_set(message) + + if not suppress_popup: + popup_scrolled('*** Version information copied to your clipboard. Paste into your GitHub Issue. ***\n', + message, title='Select and copy this info to your GitHub Issue', keep_on_top=True, size=(100, 10)) + + return message + + +# ..######...##........#######..########.....###....##......... +# .##....##..##.......##.....##.##.....##...##.##...##......... +# .##........##.......##.....##.##.....##..##...##..##......... +# .##...####.##.......##.....##.########..##.....##.##......... +# .##....##..##.......##.....##.##.....##.#########.##......... +# .##....##..##.......##.....##.##.....##.##.....##.##......... +# ..######...########..#######..########..##.....##.########... +# ..######..########.########.########.########.####.##....##..######....######. +# .##....##.##..........##.......##.......##.....##..###...##.##....##..##....## +# .##.......##..........##.......##.......##.....##..####..##.##........##...... +# ..######..######......##.......##.......##.....##..##.##.##.##...####..######. +# .......##.##..........##.......##.......##.....##..##..####.##....##........## +# .##....##.##..........##.......##.......##.....##..##...###.##....##..##....## +# ..######..########....##.......##.......##....####.##....##..######....######. + + +def _global_settings_get_ttk_scrollbar_info(): + """ + This function reads the ttk scrollbar settings from the global PySimpleGUI settings file. + Each scrollbar setting is stored with a key that's a TUPLE, not a normal string key. + The settings are for pieces of the scrollbar and their associated piece of the PySimpleGUI theme. + + The whole ttk scrollbar feature is based on mapping parts of the scrollbar to parts of the PySimpleGUI theme. + That is what the ttk_part_mapping_dict does, maps between the two lists of items. + For example, the scrollbar arrow color may map to the theme input text color. + + """ + global ttk_part_mapping_dict, DEFAULT_TTK_THEME + for ttk_part in TTK_SCROLLBAR_PART_LIST: + value = pysimplegui_user_settings.get(json.dumps(('-ttk scroll-', ttk_part)), ttk_part_mapping_dict[ttk_part]) + ttk_part_mapping_dict[ttk_part] = value + + DEFAULT_TTK_THEME = pysimplegui_user_settings.get('-ttk theme-', DEFAULT_TTK_THEME) + + +def _global_settings_get_watermark_info(): + if not pysimplegui_user_settings.get('-watermark-', False) and not Window._watermark_temp_forced: + Window._watermark = None + return + forced = Window._watermark_temp_forced + prefix_text = pysimplegui_user_settings.get('-watermark text-', '') + + ver_text = ' ' + version.split(" ", 1)[0] if pysimplegui_user_settings.get('-watermark ver-', False if not forced else True) or forced else '' + framework_ver_text = ' Tk ' + framework_version if pysimplegui_user_settings.get('-watermark framework ver-', False if not forced else True) or forced else '' + watermark_font = pysimplegui_user_settings.get('-watermark font-', '_ 9 bold') + # background_color = pysimplegui_user_settings.get('-watermark bg color-', 'window.BackgroundColor') + user_text = pysimplegui_user_settings.get('-watermark text-', '') + python_text = ' Py {}.{}.{}'.format(sys.version_info.major, sys.version_info.minor, sys.version_info.micro) + if user_text: + text = str(user_text) + else: + text = prefix_text + ver_text + python_text + framework_ver_text + Window._watermark = lambda window: Text(text, font=watermark_font, background_color= window.BackgroundColor) + + + +def main_global_get_screen_snapshot_symcode(): + pysimplegui_user_settings = UserSettings(filename=DEFAULT_USER_SETTINGS_PYSIMPLEGUI_FILENAME, path=DEFAULT_USER_SETTINGS_PYSIMPLEGUI_PATH) + + settings = pysimplegui_user_settings.read() + + screenshot_keysym = '' + for i in range(4): + keysym = settings.get(json.dumps(('-snapshot keysym-', i)), '') + if keysym: + screenshot_keysym += "<{}>".format(keysym) + + screenshot_keysym_manual = settings.get('-snapshot keysym manual-', '') + + # print('BINDING INFO!', screenshot_keysym, screenshot_keysym_manual) + if screenshot_keysym_manual: + return screenshot_keysym_manual + elif screenshot_keysym: + return screenshot_keysym + return '' + +def main_global_pysimplegui_settings_erase(): + """ + *** WARNING *** + Deletes the PySimpleGUI settings file without asking for verification + + + """ + print('********** WARNING - you are deleting your PySimpleGUI settings file **********') + print('The file being deleted is:', pysimplegui_user_settings.full_filename) + + +def main_global_pysimplegui_settings(): + """ + Window to set settings that will be used across all PySimpleGUI programs that choose to use them. + Use set_options to set the path to the folder for all PySimpleGUI settings. + + :return: True if settings were changed + :rtype: (bool) + """ + global DEFAULT_WINDOW_SNAPSHOT_KEY_CODE, ttk_part_mapping_dict, DEFAULT_TTK_THEME + + key_choices = tuple(sorted(tkinter_keysyms)) + + settings = pysimplegui_user_settings.read() + + editor_format_dict = { + 'pycharm': ' --line ', + 'notepad++': ' -n ', + 'sublime': ' :', + 'vim': ' + ', + 'wing': ' :', + 'visual studio': ' /command "edit.goto "', + 'atom': ' :', + 'spyder': ' ', + 'thonny': ' ', + 'pydev': ' :', + 'idle': ' '} + + tooltip = 'Format strings for some popular editors/IDEs:\n' + \ + 'PyCharm - --line \n' + \ + 'Notepad++ - -n \n' + \ + 'Sublime - :\n' + \ + 'vim - + \n' + \ + 'wing - :\n' + \ + 'Visual Studio - /command "edit.goto "\n' + \ + 'Atom - :\n' + \ + 'Spyder - \n' + \ + 'Thonny - \n' + \ + 'PyDev - :\n' + \ + 'IDLE - \n' + + tooltip_file_explorer = 'This is the program you normally use to "Browse" for files\n' + \ + 'For Windows this is normally "explorer". On Linux "nemo" is sometimes used.' + + tooltip_theme = 'The normal default theme for PySimpleGUI is "Dark Blue 13\n' + \ + 'If you do not call theme("theme name") by your program to change the theme, then the default is used.\n' + \ + 'This setting allows you to set the theme that PySimpleGUI will use for ALL of your programs that\n' + \ + 'do not set a theme specifically.' + + # ------------------------- TTK Tab ------------------------- + ttk_scrollbar_tab_layout = [[T('Default TTK Theme', font='_ 16'), Combo([], DEFAULT_TTK_THEME, readonly=True, size=(20, 10), key='-TTK THEME-', font='_ 16')], + [HorizontalSeparator()], + [T('TTK Scrollbar Settings', font='_ 16')]] + + t_len = max([len(l) for l in TTK_SCROLLBAR_PART_LIST]) + ttk_layout = [[]] + for key, item in ttk_part_mapping_dict.items(): + if key in TTK_SCROLLBAR_PART_THEME_BASED_LIST: + ttk_layout += [[T(key, s=t_len, justification='r'), Combo(PSG_THEME_PART_LIST, default_value=settings.get(('-ttk scroll-', key), item), key=('-TTK SCROLL-', key))]] + elif key in (TTK_SCROLLBAR_PART_ARROW_WIDTH, TTK_SCROLLBAR_PART_SCROLL_WIDTH): + ttk_layout += [[T(key, s=t_len, justification='r'), Combo(list(range(100)), default_value=settings.get(('-ttk scroll-', key), item), key=('-TTK SCROLL-', key))]] + elif key == TTK_SCROLLBAR_PART_RELIEF: + ttk_layout += [[T(key, s=t_len, justification='r'), Combo(RELIEF_LIST, default_value=settings.get(('-ttk scroll-', key), item), readonly=True, key=('-TTK SCROLL-', key))]] + + ttk_scrollbar_tab_layout += ttk_layout + ttk_scrollbar_tab_layout += [[Button('Reset Scrollbar Settings'), Button('Test Scrollbar Settings')]] + ttk_tab = Tab('TTK', ttk_scrollbar_tab_layout) + + layout = [[T('Global PySimpleGUI Settings', text_color=theme_button_color()[0], background_color=theme_button_color()[1],font='_ 18', expand_x=True, justification='c')]] + + # ------------------------- Interpreter Tab ------------------------- + + + interpreter_tab = Tab('Python Interpreter', + [[T('Normally leave this blank')], + [T('Command to run a python program:'), In(settings.get('-python command-', ''), k='-PYTHON COMMAND-', enable_events=True), FileBrowse()]], font='_ 16', expand_x=True) + + # ------------------------- Editor Tab ------------------------- + + editor_tab = Tab('Editor Settings', + [[T('Command to invoke your editor:'), In(settings.get('-editor program-', ''), k='-EDITOR PROGRAM-', enable_events=True), FileBrowse()], + [T('String to launch your editor to edit at a particular line #.')], + [T('Use tags to specify the string')], + [T('that will be executed to edit python files using your editor')], + [T('Edit Format String (hover for tooltip)', tooltip=tooltip), + In(settings.get('-editor format string-', ' '), k='-EDITOR FORMAT-', tooltip=tooltip)]], font='_ 16', expand_x=True) + + # ------------------------- Explorer Tab ------------------------- + + explorer_tab = Tab('Explorer Program', + [[In(settings.get('-explorer program-', ''), k='-EXPLORER PROGRAM-', tooltip=tooltip_file_explorer)]], font='_ 16', expand_x=True, tooltip=tooltip_file_explorer) + + # ------------------------- Snapshots Tab ------------------------- + + snapshots_tab = Tab('Window Snapshots', + [[Combo(('',)+key_choices, default_value=settings.get(json.dumps(('-snapshot keysym-', i)), ''), readonly=True, k=('-SNAPSHOT KEYSYM-', i), s=(None, 30)) for i in range(4)], + [T('Manually Entered Bind String:'), Input(settings.get('-snapshot keysym manual-', ''),k='-SNAPSHOT KEYSYM MANUAL-')], + [T('Folder to store screenshots:'), Push(), In(settings.get('-screenshots folder-', ''), k='-SCREENSHOTS FOLDER-'), FolderBrowse()], + [T('Screenshots Filename or Prefix:'), Push(), In(settings.get('-screenshots filename-', ''), k='-SCREENSHOTS FILENAME-'), FileBrowse()], + [Checkbox('Auto-number Images', k='-SCREENSHOTS AUTONUMBER-')]], font='_ 16', expand_x=True,) + + # ------------------------- Theme Tab ------------------------- + + theme_tab = Tab('Theme', + [[T('Leave blank for "official" PySimpleGUI default theme: {}'.format(OFFICIAL_PYSIMPLEGUI_THEME))], + [T('Default Theme For All Programs:'), + Combo([''] + theme_list(), settings.get('-theme-', None), readonly=True, k='-THEME-', tooltip=tooltip_theme), Checkbox('Always use custom Titlebar', default=pysimplegui_user_settings.get('-custom titlebar-',False), k='-CUSTOM TITLEBAR-')], + [Frame('Window Watermarking', + [[Checkbox('Enable Window Watermarking', pysimplegui_user_settings.get('-watermark-', False), k='-WATERMARK-')], + [T('Prefix Text String:'), Input(pysimplegui_user_settings.get('-watermark text-', ''), k='-WATERMARK TEXT-')], + [Checkbox('PySimpleGUI Version', pysimplegui_user_settings.get('-watermark ver-', False), k='-WATERMARK VER-')], + [Checkbox('Framework Version',pysimplegui_user_settings.get('-watermark framework ver-', False), k='-WATERMARK FRAMEWORK VER-')], + [T('Font:'), Input(pysimplegui_user_settings.get('-watermark font-', '_ 9 bold'), k='-WATERMARK FONT-')], + # [T('Background Color:'), Input(pysimplegui_user_settings.get('-watermark bg color-', 'window.BackgroundColor'), k='-WATERMARK BG COLOR-')], + ], + font='_ 16', expand_x=True)]]) + + + + settings_tab_group = TabGroup([[theme_tab, ttk_tab, interpreter_tab, explorer_tab, editor_tab, snapshots_tab, ]]) + layout += [[settings_tab_group]] + # [T('Buttons (Leave Unchecked To Use Default) NOT YET IMPLEMENTED!', font='_ 16')], + # [Checkbox('Always use TTK buttons'), CBox('Always use TK Buttons')], + layout += [[B('Ok', bind_return_key=True), B('Cancel'), B('Mac Patch Control')]] + + window = Window('Settings', layout, keep_on_top=True, modal=False, finalize=True) + + # fill in the theme list into the Combo element - must do this AFTER the window is created or a tkinter temp window is auto created by tkinter + ttk_theme_list = ttk.Style().theme_names() + + window['-TTK THEME-'].update(value=DEFAULT_TTK_THEME, values=ttk_theme_list) + + while True: + event, values = window.read() + if event in ('Cancel', WIN_CLOSED): + break + if event == 'Ok': + new_theme = OFFICIAL_PYSIMPLEGUI_THEME if values['-THEME-'] == '' else values['-THEME-'] + pysimplegui_user_settings.set('-editor program-', values['-EDITOR PROGRAM-']) + pysimplegui_user_settings.set('-explorer program-', values['-EXPLORER PROGRAM-']) + pysimplegui_user_settings.set('-editor format string-', values['-EDITOR FORMAT-']) + pysimplegui_user_settings.set('-python command-', values['-PYTHON COMMAND-']) + pysimplegui_user_settings.set('-custom titlebar-', values['-CUSTOM TITLEBAR-']) + pysimplegui_user_settings.set('-theme-', new_theme) + pysimplegui_user_settings.set('-watermark-', values['-WATERMARK-']) + pysimplegui_user_settings.set('-watermark text-', values['-WATERMARK TEXT-']) + pysimplegui_user_settings.set('-watermark ver-', values['-WATERMARK VER-']) + pysimplegui_user_settings.set('-watermark framework ver-', values['-WATERMARK FRAMEWORK VER-']) + pysimplegui_user_settings.set('-watermark font-', values['-WATERMARK FONT-']) + # pysimplegui_user_settings.set('-watermark bg color-', values['-WATERMARK BG COLOR-']) + + # TTK SETTINGS + pysimplegui_user_settings.set('-ttk theme-', values['-TTK THEME-']) + DEFAULT_TTK_THEME = values['-TTK THEME-'] + + # Snapshots portion + screenshot_keysym_manual = values['-SNAPSHOT KEYSYM MANUAL-'] + pysimplegui_user_settings.set('-snapshot keysym manual-', values['-SNAPSHOT KEYSYM MANUAL-']) + screenshot_keysym = '' + for i in range(4): + pysimplegui_user_settings.set(json.dumps(('-snapshot keysym-',i)), values[('-SNAPSHOT KEYSYM-', i)]) + if values[('-SNAPSHOT KEYSYM-', i)]: + screenshot_keysym += "<{}>".format(values[('-SNAPSHOT KEYSYM-', i)]) + if screenshot_keysym_manual: + DEFAULT_WINDOW_SNAPSHOT_KEY_CODE = screenshot_keysym_manual + elif screenshot_keysym: + DEFAULT_WINDOW_SNAPSHOT_KEY_CODE = screenshot_keysym + + pysimplegui_user_settings.set('-screenshots folder-', values['-SCREENSHOTS FOLDER-']) + pysimplegui_user_settings.set('-screenshots filename-', values['-SCREENSHOTS FILENAME-']) + + # TTK Scrollbar portion + for key, value in values.items(): + if isinstance(key, tuple): + if key[0] == '-TTK SCROLL-': + pysimplegui_user_settings.set(json.dumps(('-ttk scroll-', key[1])), value) + + # Upgrade Service Settings + pysimplegui_user_settings.set('-upgrade show only critical-', values['-UPGRADE SHOW ONLY CRITICAL-']) + + + + theme(new_theme) + + _global_settings_get_ttk_scrollbar_info() + _global_settings_get_watermark_info() + + window.close() + return True + elif event == '-EDITOR PROGRAM-': + for key in editor_format_dict.keys(): + if key in values['-EDITOR PROGRAM-'].lower(): + window['-EDITOR FORMAT-'].update(value=editor_format_dict[key]) + elif event == 'Mac Patch Control': + main_mac_feature_control() + # re-read the settings in case they changed + _read_mac_global_settings() + elif event == 'Reset Scrollbar Settings': + ttk_part_mapping_dict = copy.copy(DEFAULT_TTK_PART_MAPPING_DICT) + for key, item in ttk_part_mapping_dict.items(): + window[('-TTK SCROLL-', key)].update(item) + elif event == 'Test Scrollbar Settings': + for ttk_part in TTK_SCROLLBAR_PART_LIST: + value = values[('-TTK SCROLL-', ttk_part)] + ttk_part_mapping_dict[ttk_part] = value + DEFAULT_TTK_THEME = values['-TTK THEME-'] + for i in range(100): + Print(i, keep_on_top=True) + Print('Close this window to continue...', keep_on_top=True) + + window.close() + # In case some of the settings were modified and tried out, reset the ttk info to be what's in the config file + style = ttk.Style(Window.hidden_master_root) + _change_ttk_theme(style, DEFAULT_TTK_THEME) + _global_settings_get_ttk_scrollbar_info() + + return False + + +# ..######..########..##....##....##.....##.########.##.......########. +# .##....##.##.....##.##...##.....##.....##.##.......##.......##.....## +# .##.......##.....##.##..##......##.....##.##.......##.......##.....## +# ..######..##.....##.#####.......#########.######...##.......########. +# .......##.##.....##.##..##......##.....##.##.......##.......##....... +# .##....##.##.....##.##...##.....##.....##.##.......##.......##....... +# ..######..########..##....##....##.....##.########.########.##....... + + +def main_sdk_help(): + """ + Display a window that will display the docstrings for each PySimpleGUI Element and the Window object + + """ + online_help_links = { + 'Button': r'https://PySimpleGUI.org/en/latest/call%20reference/#button-element', + 'ButtonMenu': r'https://PySimpleGUI.org/en/latest/call%20reference/#buttonmenu-element', + 'Canvas': r'https://PySimpleGUI.org/en/latest/call%20reference/#canvas-element', + 'Checkbox': r'https://PySimpleGUI.org/en/latest/call%20reference/#checkbox-element', + 'Column': r'https://PySimpleGUI.org/en/latest/call%20reference/#column-element', + 'Combo': r'https://PySimpleGUI.org/en/latest/call%20reference/#combo-element', + 'Frame': r'https://PySimpleGUI.org/en/latest/call%20reference/#frame-element', + 'Graph': r'https://PySimpleGUI.org/en/latest/call%20reference/#graph-element', + 'HorizontalSeparator': r'https://PySimpleGUI.org/en/latest/call%20reference/#horizontalseparator-element', + 'Image': r'https://PySimpleGUI.org/en/latest/call%20reference/#image-element', + 'Input': r'https://PySimpleGUI.org/en/latest/call%20reference/#input-element', + 'Listbox': r'https://PySimpleGUI.org/en/latest/call%20reference/#listbox-element', + 'Menu': r'https://PySimpleGUI.org/en/latest/call%20reference/#menu-element', + 'MenubarCustom': r'https://PySimpleGUI.org/en/latest/call%20reference/#menubarcustom-element', + 'Multiline': r'https://PySimpleGUI.org/en/latest/call%20reference/#multiline-element', + 'OptionMenu': r'https://PySimpleGUI.org/en/latest/call%20reference/#optionmenu-element', + 'Output': r'https://PySimpleGUI.org/en/latest/call%20reference/#output-element', + 'Pane': r'https://PySimpleGUI.org/en/latest/call%20reference/#pane-element', + 'ProgressBar': r'https://PySimpleGUI.org/en/latest/call%20reference/#progressbar-element', + 'Radio': r'https://PySimpleGUI.org/en/latest/call%20reference/#radio-element', + 'Slider': r'https://PySimpleGUI.org/en/latest/call%20reference/#slider-element', + 'Spin': r'https://PySimpleGUI.org/en/latest/call%20reference/#spin-element', + 'StatusBar': r'https://PySimpleGUI.org/en/latest/call%20reference/#statusbar-element', + 'Tab': r'https://PySimpleGUI.org/en/latest/call%20reference/#tab-element', + 'TabGroup': r'https://PySimpleGUI.org/en/latest/call%20reference/#tabgroup-element', + 'Table': r'https://PySimpleGUI.org/en/latest/call%20reference/#table-element', + 'Text': r'https://PySimpleGUI.org/en/latest/call%20reference/#text-element', + 'Titlebar': r'https://PySimpleGUI.org/en/latest/call%20reference/#titlebar-element', + 'Tree': r'https://PySimpleGUI.org/en/latest/call%20reference/#tree-element', + 'VerticalSeparator': r'https://PySimpleGUI.org/en/latest/call%20reference/#verticalseparator-element', + 'Window': r'https://PySimpleGUI.org/en/latest/call%20reference/#window', + } + + NOT_AN_ELEMENT = 'Not An Element' + element_classes = Element.__subclasses__() + element_names = {element.__name__: element for element in element_classes} + element_names['Window'] = Window + element_classes.append(Window) + element_arg_default_dict, element_arg_default_dict_update = {}, {} + vars3 = [m for m in inspect.getmembers(sys.modules[__name__])] + + functions = [m for m in inspect.getmembers(sys.modules[__name__], inspect.isfunction)] + functions_names_lower = [f for f in functions if f[0][0].islower()] + functions_names_upper = [f for f in functions if f[0][0].isupper()] + functions_names = sorted(functions_names_lower) + sorted(functions_names_upper) + + for element in element_classes: + # Build info about init method + args = inspect.getfullargspec(element.__init__).args[1:] + defaults = inspect.getfullargspec(element.__init__).defaults + # print('------------- {element}----------') + # print(args) + # print(defaults) + if len(args) != len(defaults): + diff = len(args) - len(defaults) + defaults = ('NO DEFAULT',) * diff + defaults + args_defaults = [] + for i, a in enumerate(args): + args_defaults.append((a, defaults[i])) + element_arg_default_dict[element.__name__] = args_defaults + + # Build info about update method + try: + args = inspect.getfullargspec(element.update).args[1:] + defaults = inspect.getfullargspec(element.update).defaults + if args is None or defaults is None: + element_arg_default_dict_update[element.__name__] = (('', ''),) + continue + if len(args) != len(defaults): + diff = len(args) - len(defaults) + defaults = ('NO DEFAULT',) * diff + defaults + args_defaults = [] + for i, a in enumerate(args): + args_defaults.append((a, defaults[i])) + element_arg_default_dict_update[element.__name__] = args_defaults if len(args_defaults) else (('', ''),) + except Exception as e: + pass + + # Add on the pseudo-elements + element_names['MenubarCustom'] = MenubarCustom + element_names['Titlebar'] = Titlebar + + buttons = [[B(e, pad=(0, 0), size=(22, 1), font='Courier 10')] for e in sorted(element_names.keys())] + buttons += [[B('Func Search', pad=(0, 0), size=(22, 1), font='Courier 10')]] + button_col = Col(buttons, vertical_alignment='t') + mline_col = Column([[Multiline(size=(100, 46), key='-ML-', write_only=True, reroute_stdout=True, font='Courier 10', expand_x=True, expand_y=True)], + [T(size=(80, 1), font='Courier 10 underline', k='-DOC LINK-', enable_events=True)]], pad=(0, 0), expand_x=True, expand_y=True, vertical_alignment='t') + layout = [[button_col, mline_col]] + layout += [[CBox('Summary Only', enable_events=True, k='-SUMMARY-'), CBox('Display Only PEP8 Functions', default=True, k='-PEP8-')]] + # layout = [[Column(layout, scrollable=True, p=0, expand_x=True, expand_y=True, vertical_alignment='t'), Sizegrip()]] + layout += [[Button('Exit', size=(15, 1)), Sizegrip()]] + + window = Window('SDK API Call Reference', layout, resizable=True, use_default_focus=False, keep_on_top=True, icon=EMOJI_BASE64_THINK, finalize=True, right_click_menu=MENU_RIGHT_CLICK_EDITME_EXIT) + window['-DOC LINK-'].set_cursor('hand1') + online_help_link = '' + ml = window['-ML-'] + current_element = '' + try: + while True: # Event Loop + event, values = window.read() + if event in (WIN_CLOSED, 'Exit'): + break + if event == '-DOC LINK-': + if webbrowser_available and online_help_link: + webbrowser.open_new_tab(online_help_link) + if event == '-SUMMARY-': + event = current_element + + if event in element_names.keys(): + current_element = event + window['-ML-'].update('') + online_help_link = online_help_links.get(event, '') + window['-DOC LINK-'].update(online_help_link) + if not values['-SUMMARY-']: + elem = element_names[event] + ml.print(pydoc.help(elem)) + # print the aliases for the class + ml.print('\n--- Shortcut Aliases for Class ---') + for v in vars3: + if elem == v[1] and elem.__name__ != v[0]: + print(v[0]) + ml.print('\n--- Init Parms ---') + else: + elem = element_names[event] + if inspect.isfunction(elem): + ml.print('Not a class...It is a function', background_color='red', text_color='white') + else: + element_methods = [m[0] for m in inspect.getmembers(Element, inspect.isfunction) if not m[0].startswith('_') and not m[0][0].isupper()] + methods = inspect.getmembers(elem, inspect.isfunction) + methods = [m[0] for m in methods if not m[0].startswith('_') and not m[0][0].isupper()] + + unique_methods = [m for m in methods if m not in element_methods and not m[0][0].isupper()] + + properties = inspect.getmembers(elem, lambda o: isinstance(o, property)) + properties = [p[0] for p in properties if not p[0].startswith('_')] + ml.print('--- Methods ---', background_color='red', text_color='white') + ml.print('\n'.join(methods)) + ml.print('--- Properties ---', background_color='red', text_color='white') + ml.print('\n'.join(properties)) + if elem != NOT_AN_ELEMENT: + if issubclass(elem, Element): + ml.print('Methods Unique to This Element', background_color='red', text_color='white') + ml.print('\n'.join(unique_methods)) + ml.print('========== Init Parms ==========', background_color='#FFFF00', text_color='black') + elem_text_name = event + for parm, default in element_arg_default_dict[elem_text_name]: + ml.print('{:18}'.format(parm), end=' = ') + ml.print(default, end=',\n') + if elem_text_name in element_arg_default_dict_update: + ml.print('========== Update Parms ==========', background_color='#FFFF00', text_color='black') + for parm, default in element_arg_default_dict_update[elem_text_name]: + ml.print('{:18}'.format(parm), end=' = ') + ml.print(default, end=',\n') + ml.set_vscroll_position(0) # scroll to top of multoline + elif event == 'Func Search': + search_string = popup_get_text('Search for this in function list:', keep_on_top=True) + if search_string is not None: + online_help_link = '' + window['-DOC LINK-'].update('') + ml.update('') + for f_entry in functions_names: + f = f_entry[0] + if search_string in f.lower() and not f.startswith('_'): + if (values['-PEP8-'] and not f[0].isupper()) or not values['-PEP8-']: + if values['-SUMMARY-']: + ml.print(f) + else: + ml.print('=========== ' + f + '===========', background_color='#FFFF00', text_color='black') + ml.print(pydoc.help(f_entry[1])) + ml.set_vscroll_position(0) # scroll to top of multoline + except Exception as e: + _error_popup_with_traceback('Exception in SDK reference', e) + window.close() + +# oo +# +# 88d8b.d8b. .d8888b. dP 88d888b. +# 88'`88'`88 88' `88 88 88' `88 +# 88 88 88 88. .88 88 88 88 +# dP dP dP `88888P8 dP dP dP +# +# +# M""MMM""MMM""M oo dP +# M MMM MMM M 88 +# M MMP MMP M dP 88d888b. .d888b88 .d8888b. dP dP dP +# M MM' MM' .M 88 88' `88 88' `88 88' `88 88 88 88 +# M `' . '' .MM 88 88 88 88. .88 88. .88 88.88b.88' +# M .d .dMMM dP dP dP `88888P8 `88888P' 8888P Y8P +# MMMMMMMMMMMMMM +# +# MP""""""`MM dP dP dP +# M mmmmm..M 88 88 88 +# M. `YM d8888P .d8888b. 88d888b. d8888P .d8888b. 88d888b. .d8888b. 88d888b. .d8888b. +# MMMMMMM. M 88 88' `88 88' `88 88 Y8ooooo. 88' `88 88ooood8 88' `88 88ooood8 +# M. .MMM' M 88 88. .88 88 88 88 88 88 88. ... 88 88. ... +# Mb. .dM dP `88888P8 dP dP `88888P' dP dP `88888P' dP `88888P' +# MMMMMMMMMMM + + +def _main_switch_theme(): + layout = [ + [Text('Click a look and feel color to see demo window')], + [Listbox(values=theme_list(), + size=(20, 20), key='-LIST-')], + [Button('Choose'), Button('Cancel')]] + + window = Window('Change Themes', layout) + + event, values = window.read(close=True) + + if event == 'Choose': + theme_name = values['-LIST-'][0] + theme(theme_name) + + +def _create_main_window(): + """ + Creates the main test harness window. + + :return: The test window + :rtype: Window + """ + + # theme('dark blue 3') + # theme('dark brown 2') + # theme('dark') + # theme('dark red') + # theme('Light Green 6') + # theme('Dark Grey 8') + + tkversion = tkinter.TkVersion + tclversion = tkinter.TclVersion + tclversion_detailed = tkinter.Tcl().eval('info patchlevel') + + print('Starting up PySimpleGUI Diagnostic & Help System') + print('PySimpleGUI long version = ', version) + print('PySimpleGUI Version ', ver, '\ntcl ver = {}'.format(tclversion), + 'tkinter version = {}'.format(tkversion), '\nPython Version {}'.format(sys.version)) + print('tcl detailed version = {}'.format(tclversion_detailed)) + print('PySimpleGUI.py location', __file__) + # ------ Menu Definition ------ # + menu_def = [['&File', ['!&Open', '&Save::savekey', '---', '&Properties', 'E&xit']], + ['&Edit', ['&Paste', ['Special', 'Normal', '!Disabled'], 'Undo'], ], + ['&Debugger', ['Popout', 'Launch Debugger']], + ['!&Disabled', ['Popout', 'Launch Debugger']], + ['&Toolbar', ['Command &1', 'Command &2', 'Command &3', 'Command &4']], + ['&Help', '&About...'], ] + + button_menu_def = ['unused', ['&Paste', ['Special', 'Normal', '!Disabled'], 'Undo', 'Exit'], ] + treedata = TreeData() + + treedata.Insert("", '_A_', 'Tree Item 1', [1, 2, 3], ) + treedata.Insert("", '_B_', 'B', [4, 5, 6], ) + treedata.Insert("_A_", '_A1_', 'Sub Item 1', ['can', 'be', 'anything'], ) + treedata.Insert("", '_C_', 'C', [], ) + treedata.Insert("_C_", '_C1_', 'C1', ['or'], ) + treedata.Insert("_A_", '_A2_', 'Sub Item 2', [None, None]) + treedata.Insert("_A1_", '_A3_', 'A30', ['getting deep']) + treedata.Insert("_C_", '_C2_', 'C2', ['nothing', 'at', 'all']) + + for i in range(100): + treedata.Insert('_C_', i, i, []) + + frame1 = [ + [Input('Input Text', size=(25, 1)), ], + [Multiline(size=(30, 5), default_text='Multiline Input')], + ] + + frame2 = [ + # [ProgressBar(100, bar_color=('red', 'green'), orientation='h')], + + [Listbox(['Listbox 1', 'Listbox 2', 'Listbox 3'], select_mode=SELECT_MODE_EXTENDED, size=(20, 5), no_scrollbar=True), + Spin([1, 2, 3, 'a', 'b', 'c'], initial_value='a', size=(4, 3), wrap=True)], + [Combo(['Combo item %s' % i for i in range(5)], size=(20, 3), default_value='Combo item 2', key='-COMBO1-', )], + [Combo(['Combo item %s' % i for i in range(5)], size=(20, 3), font='Courier 14', default_value='Combo item 2', key='-COMBO2-', )], + # [Combo(['Combo item 1', 2,3,4], size=(20, 3), readonly=False, text_color='blue', background_color='red', key='-COMBO2-')], + + ] + + frame3 = [ + [Checkbox('Checkbox1', True, k='-CB1-'), Checkbox('Checkbox2', k='-CB2-')], + [Radio('Radio Button1', 1, key='-R1-'), Radio('Radio Button2', 1, default=True, key='-R2-', tooltip='Radio 2')], + [T('', size=(1, 4))], + ] + + frame4 = [ + [Slider(range=(0, 100), orientation='v', size=(7, 15), default_value=40, key='-SLIDER1-'), + Slider(range=(0, 100), orientation='h', size=(11, 15), default_value=40, key='-SLIDER2-'), ], + ] + matrix = [[str(x * y) for x in range(1, 5)] for y in range(1, 8)] + + frame5 = [vtop([ + Table(values=matrix, headings=matrix[0], + auto_size_columns=False, display_row_numbers=True, change_submits=False, justification='right', header_border_width=4, + # header_relief=RELIEF_GROOVE, + num_rows=10, alternating_row_color='lightblue', key='-TABLE-', + col_widths=[5, 5, 5, 5]), + Tree(data=treedata, headings=['col1', 'col2', 'col3'], col_widths=[5, 5, 5, 5], change_submits=True, auto_size_columns=False, header_border_width=4, + # header_relief=RELIEF_GROOVE, + num_rows=8, col0_width=8, key='-TREE-', show_expanded=True )])] + frame7 = [[Image(EMOJI_BASE64_HAPPY_HEARTS, enable_events=True, k='-EMOJI-HEARTS-'), T('Do you'), Image(HEART_3D_BASE64, subsample=3, enable_events=True, k='-HEART-'), T('so far?')], + [T('Want to be taught PySimpleGUI?\nThen maybe the "Official PySimpleGUI Course" on Udemy is for you.')], + [B(image_data=UDEMY_ICON, enable_events=True, k='-UDEMY-'),T('Check docs, announcements, easter eggs on this page for coupons.')], + [B(image_data=ICON_BUY_ME_A_COFFEE, enable_events=True, k='-COFFEE-'), T('It is financially draining to operate a project this huge. $1 helps')]] + + + pop_test_tab_layout = [ + [Image(EMOJI_BASE64_HAPPY_IDEA), T('Popup tests? Good idea!')], + [B('Popup', k='P '), B('No Titlebar', k='P NoTitle'), B('Not Modal', k='P NoModal'), B('Non Blocking', k='P NoBlock'), B('Auto Close', k='P AutoClose')], + [T('"Get" popups too!')], + [B('Get File'), B('Get Folder'), B('Get Date'), B('Get Text')]] + + GRAPH_SIZE=(500, 200) + graph_elem = Graph(GRAPH_SIZE, (0, 0), GRAPH_SIZE, key='+GRAPH+') + + frame6 = [[VPush()],[graph_elem]] + + themes_tab_layout = [[T('You can see a preview of the themes, the color swatches, or switch themes for this window')], + [T('If you want to change the default theme for PySimpleGUI, use the Global Settings')], + [B('Themes'), B('Theme Swatches'), B('Switch Themes')]] + + + upgrade_recommendation_tab_layout = [[T('Latest Recommendation and Announcements For You', font='_ 14')], + [T('Severity Level of Update:'), T(pysimplegui_user_settings.get('-severity level-',''))], + [T('Recommended Version To Upgrade To:'), T(pysimplegui_user_settings.get('-upgrade recommendation-',''))], + [T(pysimplegui_user_settings.get('-upgrade message 1-',''))], + [T(pysimplegui_user_settings.get('-upgrade message 2-',''))], + [Checkbox('Show Only Critical Messages', default=pysimplegui_user_settings.get('-upgrade show only critical-', False), key='-UPGRADE SHOW ONLY CRITICAL-', enable_events=True)], + [Button('Show Notification Again'), +], + ] + tab_upgrade = Tab('Upgrade\n',upgrade_recommendation_tab_layout, expand_x=True) + + + tab1 = Tab('Graph\n', frame6, tooltip='Graph is in here', title_color='red') + tab2 = Tab('CB, Radio\nList, Combo', + [[Frame('Multiple Choice Group', frame2, title_color='#FFFFFF', tooltip='Checkboxes, radio buttons, etc', vertical_alignment='t',), + Frame('Binary Choice Group', frame3, title_color='#FFFFFF', tooltip='Binary Choice', vertical_alignment='t', ), ]]) + # tab3 = Tab('Table and Tree', [[Frame('Structured Data Group', frame5, title_color='red', element_justification='l')]], tooltip='tab 3', title_color='red', ) + tab3 = Tab('Table &\nTree', [[Column(frame5, element_justification='l', vertical_alignment='t')]], tooltip='tab 3', title_color='red', k='-TAB TABLE-') + tab4 = Tab('Sliders\n', [[Frame('Variable Choice Group', frame4, title_color='blue')]], tooltip='tab 4', title_color='red', k='-TAB VAR-') + tab5 = Tab('Input\nMultiline', [[Frame('TextInput', frame1, title_color='blue')]], tooltip='tab 5', title_color='red', k='-TAB TEXT-') + tab6 = Tab('Course or\nSponsor', frame7, k='-TAB SPONSOR-') + tab7 = Tab('Popups\n', pop_test_tab_layout, k='-TAB POPUP-') + tab8 = Tab('Themes\n', themes_tab_layout, k='-TAB THEMES-') + + def VerLine(version, description, justification='r', size=(40, 1)): + return [T(version, justification=justification, font='Any 12', text_color='yellow', size=size, pad=(0,0)), T(description, font='Any 12', pad=(0,0))] + + layout_top = Column([ + [Image(EMOJI_BASE64_HAPPY_BIG_SMILE, enable_events=True, key='-LOGO-', tooltip='This is PySimpleGUI logo'), + Image(data=DEFAULT_BASE64_LOADING_GIF, enable_events=True, key='-IMAGE-'), + Text('PySimpleGUI Test Harness', font='ANY 14', + tooltip='My tooltip', key='-TEXT1-')], + VerLine(ver, 'PySimpleGUI Version') + [Image(HEART_3D_BASE64, subsample=4)], + # VerLine('{}/{}'.format(tkversion, tclversion), 'TK/TCL Versions'), + VerLine(tclversion_detailed, 'detailed tkinter version'), + VerLine(os.path.dirname(os.path.abspath(__file__)), 'PySimpleGUI Location', size=(40, None)), + VerLine(sys.executable, 'Python Executable'), + VerLine(sys.version, 'Python Version', size=(40,2)) +[Image(PYTHON_COLORED_HEARTS_BASE64, subsample=3, k='-PYTHON HEARTS-', enable_events=True)]], pad=0) + + layout_bottom = [ + [B(SYMBOL_DOWN, pad=(0, 0), k='-HIDE TABS-'), + pin(Col([[TabGroup([[tab1, tab2, tab3, tab6, tab4, tab5, tab7, tab8, tab_upgrade]], key='-TAB_GROUP-')]], k='-TAB GROUP COL-'))], + [B('Button', highlight_colors=('yellow', 'red'),pad=(1, 0)), + B('ttk Button', use_ttk_buttons=True, tooltip='This is a TTK Button',pad=(1, 0)), + B('See-through Mode', tooltip='Make the background transparent',pad=(1, 0)), + B('Upgrade PySimpleGUI from GitHub', button_color='white on red', key='-INSTALL-',pad=(1, 0)), + B('Global Settings', tooltip='Settings across all PySimpleGUI programs',pad=(1, 0)), + B('Exit', tooltip='Exit button',pad=(1, 0))], + # [B(image_data=ICON_BUY_ME_A_COFFEE,pad=(1, 0), key='-COFFEE-'), + [B(image_data=UDEMY_ICON,pad=(1, 0), key='-UDEMY-'), + B('SDK Reference', pad=(1, 0)), B('Open GitHub Issue',pad=(1, 0)), B('Versions for GitHub',pad=(1, 0)), + ButtonMenu('ButtonMenu', button_menu_def, pad=(1, 0),key='-BMENU-', tearoff=True, disabled_text_color='yellow') + ]] + + layout = [[]] + + if not theme_use_custom_titlebar(): + layout += [[Menu(menu_def, key='-MENU-', font='Courier 15', background_color='red', text_color='white', disabled_text_color='yellow', tearoff=True)]] + else: + layout += [[MenubarCustom(menu_def, key='-MENU-', font='Courier 15', bar_background_color=theme_background_color(), bar_text_color=theme_text_color(), + background_color='red', text_color='white', disabled_text_color='yellow')]] + + layout += [[layout_top] + [ProgressBar(max_value=800, size=(20, 25), orientation='v', key='+PROGRESS+')]] + layout += layout_bottom + + window = Window('PySimpleGUI Main Test Harness', layout, + # font=('Helvetica', 18), + # background_color='black', + right_click_menu=['&Right', ['Right', 'Edit Me', '!&Click', '&Menu', 'E&xit', 'Properties']], + # transparent_color= '#9FB8AD', + resizable=True, + keep_on_top=False, + element_justification='left', # justify contents to the left + metadata='My window metadata', + finalize=True, + # grab_anywhere=True, + enable_close_attempted_event=True, + modal=False, + # ttk_theme=THEME_CLASSIC, + # scaling=2, + # icon=PSG_DEBUGGER_LOGO, + # icon=PSGDebugLogo, + ) + # window['-SPONSOR-'].set_cursor(cursor='hand2') + window._see_through = False + return window + + +# M"""""`'"""`YM oo +# M mm. mm. M +# M MMM MMM M .d8888b. dP 88d888b. +# M MMM MMM M 88' `88 88 88' `88 +# M MMM MMM M 88. .88 88 88 88 +# M MMM MMM M `88888P8 dP dP dP +# MMMMMMMMMMMMMM + + +def main(): + """ + The PySimpleGUI "Test Harness". This is meant to be a super-quick test of the Elements. + """ + forced_modal = DEFAULT_MODAL_WINDOWS_FORCED + # set_options(force_modal_windows=True) + window = _create_main_window() + set_options(keep_on_top=True) + graph_elem = window['+GRAPH+'] + i = 0 + graph_figures = [] + # Don't use the debug window + # Print('', location=(0, 0), font='Courier 10', size=(100, 20), grab_anywhere=True) + # print(window.element_list()) + while True: # Event Loop + event, values = window.read(timeout=5) + if event != TIMEOUT_KEY: + print(event, values) + # Print(event, text_color='white', background_color='red', end='') + # Print(values) + if event == WIN_CLOSED or event == WIN_CLOSE_ATTEMPTED_EVENT or event == 'Exit' or (event == '-BMENU-' and values['-BMENU-'] == 'Exit'): + break + if i < graph_elem.CanvasSize[0]: + x = i % graph_elem.CanvasSize[0] + fig = graph_elem.draw_line((x, 0), (x, random.randint(0, graph_elem.CanvasSize[1])), width=1, color='#{:06x}'.format(random.randint(0, 0xffffff))) + graph_figures.append(fig) + else: + x = graph_elem.CanvasSize[0] + graph_elem.move(-1, 0) + fig = graph_elem.draw_line((x, 0), (x, random.randint(0, graph_elem.CanvasSize[1])), width=1, color='#{:06x}'.format(random.randint(0, 0xffffff))) + graph_figures.append(fig) + graph_elem.delete_figure(graph_figures[0]) + del graph_figures[0] + window['+PROGRESS+'].UpdateBar(i % 800) + window.Element('-IMAGE-').UpdateAnimation(DEFAULT_BASE64_LOADING_GIF, time_between_frames=50) + if event == 'Button': + window.Element('-TEXT1-').SetTooltip('NEW TEXT') + window.Element('-MENU-').Update(visible=True) + elif event == 'Popout': + show_debugger_popout_window() + elif event == 'Launch Debugger': + show_debugger_window() + elif event == 'About...': + popup('About this program...', 'You are looking at the test harness for the PySimpleGUI program', version, keep_on_top=True, image=DEFAULT_BASE64_ICON) + elif event.startswith('See'): + window._see_through = not window._see_through + window.set_transparent_color(theme_background_color() if window._see_through else '') + elif event in ('-INSTALL-', '-UPGRADE FROM GITHUB-'): + _upgrade_gui() + elif event == 'Popup': + popup('This is your basic popup', keep_on_top=True) + elif event == 'Get File': + popup_scrolled('Returned:', popup_get_file('Get File', keep_on_top=True)) + elif event == 'Get Folder': + popup_scrolled('Returned:', popup_get_folder('Get Folder', keep_on_top=True)) + elif event == 'Get Date': + popup_scrolled('Returned:', popup_get_date(keep_on_top=True)) + elif event == 'Get Text': + popup_scrolled('Returned:', popup_get_text('Enter some text', keep_on_top=True)) + elif event.startswith('-UDEMY-'): + webbrowser.open_new_tab(r'https://www.udemy.com/course/pysimplegui/?couponCode=522B20BF5EF123C4AB30') + elif event.startswith('-SPONSOR-'): + if webbrowser_available: + webbrowser.open_new_tab(r'https://www.paypal.me/pythongui') + elif event == '-COFFEE-': + if webbrowser_available: + webbrowser.open_new_tab(r'https://www.buymeacoffee.com/PySimpleGUI') + elif event in ('-EMOJI-HEARTS-', '-HEART-', '-PYTHON HEARTS-'): + popup_scrolled("Oh look! It's a Udemy discount coupon!", '522B20BF5EF123C4AB30', + 'A personal message from Mike -- thank you so very much for supporting PySimpleGUI!', title='Udemy Coupon', image=EMOJI_BASE64_MIKE, keep_on_top=True) + elif event == 'Themes': + search_string = popup_get_text('Enter a search term or leave blank for all themes', 'Show Available Themes', keep_on_top=True) + if search_string is not None: + theme_previewer(search_string=search_string) + elif event == 'Theme Swatches': + theme_previewer_swatches() + elif event == 'Switch Themes': + window.close() + _main_switch_theme() + window = _create_main_window() + graph_elem = window['+GRAPH+'] + elif event == '-HIDE TABS-': + window['-TAB GROUP COL-'].update(visible=window['-TAB GROUP COL-'].metadata == True) + window['-TAB GROUP COL-'].metadata = not window['-TAB GROUP COL-'].metadata + window['-HIDE TABS-'].update(text=SYMBOL_UP if window['-TAB GROUP COL-'].metadata else SYMBOL_DOWN) + elif event == 'SDK Reference': + main_sdk_help() + elif event == 'Global Settings': + if main_global_pysimplegui_settings(): + theme(pysimplegui_user_settings.get('-theme-', OFFICIAL_PYSIMPLEGUI_THEME)) + window.close() + window = _create_main_window() + graph_elem = window['+GRAPH+'] + else: + Window('', layout=[[Multiline()]], alpha_channel=0).read(timeout=1, close=True) + elif event.startswith('P '): + if event == 'P ': + popup('Normal Popup - Modal', keep_on_top=True) + elif event == 'P NoTitle': + popup_no_titlebar('No titlebar', keep_on_top=True) + elif event == 'P NoModal': + set_options(force_modal_windows=False) + popup('Normal Popup - Not Modal', 'You can interact with main window menubar ', + 'but will have no effect immediately', 'button clicks will happen after you close this popup', modal=False, keep_on_top=True) + set_options(force_modal_windows=forced_modal) + elif event == 'P NoBlock': + popup_non_blocking('Non-blocking', 'The background window should still be running', keep_on_top=True) + elif event == 'P AutoClose': + popup_auto_close('Will autoclose in 3 seconds', auto_close_duration=3, keep_on_top=True) + elif event == 'Versions for GitHub': + main_get_debug_data() + elif event == 'Edit Me': + execute_editor(__file__) + elif event == 'Open GitHub Issue': + window.minimize() + main_open_github_issue() + window.normal() + elif event == 'Show Notification Again': + if not running_trinket(): + pysimplegui_user_settings.set('-upgrade info seen-', False) + __show_previous_upgrade_information() + elif event == '-UPGRADE SHOW ONLY CRITICAL-': + if not running_trinket(): + pysimplegui_user_settings.set('-upgrade show only critical-', values['-UPGRADE SHOW ONLY CRITICAL-']) + + + i += 1 + # _refresh_debugger() + print('event = ', event) + window.close() + set_options(force_modal_windows=forced_modal) + +# ------------------------ PEP8-ify The SDK ------------------------# + +ChangeLookAndFeel = change_look_and_feel +ConvertArgsToSingleString = convert_args_to_single_string +EasyPrint = easy_print +Print = easy_print +eprint = easy_print +sgprint = easy_print +PrintClose = easy_print_close +sgprint_close = easy_print_close +EasyPrintClose = easy_print_close +FillFormWithValues = fill_form_with_values +GetComplimentaryHex = get_complimentary_hex +ListOfLookAndFeelValues = list_of_look_and_feel_values +ObjToString = obj_to_string +ObjToStringSingleObj = obj_to_string_single_obj +OneLineProgressMeter = one_line_progress_meter +OneLineProgressMeterCancel = one_line_progress_meter_cancel +Popup = popup +PopupNoFrame = popup_no_titlebar +popup_no_frame = popup_no_titlebar +PopupNoBorder = popup_no_titlebar +popup_no_border = popup_no_titlebar +PopupAnnoying = popup_no_titlebar +popup_annoying = popup_no_titlebar +PopupAnimated = popup_animated +PopupAutoClose = popup_auto_close +PopupCancel = popup_cancel +PopupError = popup_error +PopupGetFile = popup_get_file +PopupGetFolder = popup_get_folder +PopupGetText = popup_get_text +PopupNoButtons = popup_no_buttons +PopupNoTitlebar = popup_no_titlebar +PopupNoWait = popup_non_blocking +popup_no_wait = popup_non_blocking +PopupNonBlocking = popup_non_blocking +PopupOK = popup_ok +PopupOKCancel = popup_ok_cancel +PopupQuick = popup_quick +PopupQuickMessage = popup_quick_message +PopupScrolled = popup_scrolled +PopupTimed = popup_auto_close +popup_timed = popup_auto_close +PopupYesNo = popup_yes_no + +RGB = rgb +SetGlobalIcon = set_global_icon +SetOptions = set_options +sprint = popup_scrolled +ScrolledTextBox = popup_scrolled +TimerStart = timer_start +TimerStop = timer_stop +test = main +sdk_help = main_sdk_help + +def _optional_window_data(window): + """ + A function to help with testing PySimpleGUI releases. Makes it easier to add a watermarked line to the bottom + of a window while testing release candidates. + + :param window: + :type window: Window + :return: An element that will be added to the bottom of the layout + :rtype: None | Element + """ + return None + +pysimplegui_user_settings = UserSettings(filename=DEFAULT_USER_SETTINGS_PYSIMPLEGUI_FILENAME, path=DEFAULT_USER_SETTINGS_PYSIMPLEGUI_PATH) +# ------------------------ Set the "Official PySimpleGUI Theme Colors" ------------------------ + + +theme(theme_global()) +# ------------------------ Read the ttk scrollbar info ------------------------ +_global_settings_get_ttk_scrollbar_info() + +# ------------------------ Read the window watermark info ------------------------ +_global_settings_get_watermark_info() + +# See if running on Trinket. If Trinket, then use custom titlebars since Trinket doesn't supply any +if running_trinket(): + USE_CUSTOM_TITLEBAR = True + +if tclversion_detailed.startswith('8.5'): + warnings.warn('You are running a VERY old version of tkinter {}. You cannot use PNG formatted images for example. Please upgrade to 8.6.x'.format(tclversion_detailed), UserWarning) + +# Enables the correct application icon to be shown on the Windows taskbar +if running_windows(): + try: + myappid = 'mycompany.myproduct.subproduct.version' # arbitrary string + ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid) + except Exception as e: + print('Error using the taskbar icon patch', e) + + +_read_mac_global_settings() + +if _mac_should_set_alpha_to_99(): + # Applyting Mac OS 12.3+ Alpha Channel fix. Sets the default Alpha Channel to 0.99 + set_options(alpha_channel=0.99) + + +__perform_upgrade_check() + + +# -------------------------------- ENTRY POINT IF RUN STANDALONE -------------------------------- # +if __name__ == '__main__': + # To execute the upgrade from command line, type: + # python -m PySimpleGUI.PySimpleGUI upgrade + if len(sys.argv) > 1 and sys.argv[1] == 'upgrade': + _upgrade_gui() + exit(0) + elif len(sys.argv) > 1 and sys.argv[1] == 'help': + main_sdk_help() + exit(0) + main() + exit(0) +#25424909a31c4fa789f5aa4e210e7e07d412560195dc21abe678b68a3b4bdb2a8a78651d8613daaded730bc2a31adc02ba8b99717fff701cda8ae13c31f1dcee9da8837908626f1c5cc81e7a34d3b9cd032dba190647564bba72d248ad6b83e30c8abc057f3f1b1fb3a2ca853069de936f3f53522fd4732b743268e0fcde54577a05880f2057efe6bbd6349f77d6c002544f38e24db40ab84f3dde4a4b8b31e84480db31656fb74ae0c01a7af0b35ac66cf8a0fbb8ca85685fea075608c7862da6635511d0e5403c4a637138324ce1fb1308b765cba53863ddf7b01ca4fc988932b03c4a8403a72b8105f821913f02925218dbecf1e089bd32e78667939503f2abfd89b37fa293927e30550d441f21dc68273d2d07ed910f6a69bc8c792015eb623ada7e65347cf0389cf2a1696a7ccf88098a4fb4bfa44e88fac2a94a44e25b010355e48d483d896c58eb771ef47e01066156f9344750b487e176ca0642601951f096d4c03045aa8f912d475dbe04b82c6ddf1ac3adbf815aef4ca2c6add058c2789b66a9abd875f334752ec1bde11b9b56e334823304b6cc3fadf7daae277c982ebc7eadb726a33e2740d075ad082b9c20304c4a53228d6f05357c40903a78113aea4e6169e1a5351866f7a9ffc6666eb08a31bfb84d90cb3002f7ebf87871988b88a7b8a52d36a1a7dd826360b5c6ad922829d9f73d204f09d1b9ad9ffd8d \ No newline at end of file diff --git a/npbackup/gui/__main__.py b/npbackup/gui/__main__.py index 779a74c..d4938c3 100644 --- a/npbackup/gui/__main__.py +++ b/npbackup/gui/__main__.py @@ -26,7 +26,7 @@ from ofunctions.process import kill_childs from ofunctions.threading import threaded from ofunctions.misc import BytesConverter -import PySimpleGUI as sg +import npbackup.gui.PySimpleGUI as sg import _tkinter import npbackup.configuration import npbackup.common diff --git a/npbackup/gui/config.py b/npbackup/gui/config.py index 0c87ee4..de61fdf 100644 --- a/npbackup/gui/config.py +++ b/npbackup/gui/config.py @@ -14,7 +14,7 @@ import os import pathlib from logging import getLogger -import PySimpleGUI as sg +import npbackup.gui.PySimpleGUI as sg import textwrap from ruamel.yaml.comments import CommentedMap import npbackup.configuration as configuration diff --git a/npbackup/gui/helpers.py b/npbackup/gui/helpers.py index 3ab7e74..fcca40b 100644 --- a/npbackup/gui/helpers.py +++ b/npbackup/gui/helpers.py @@ -15,7 +15,7 @@ from time import sleep import re import queue -import PySimpleGUI as sg +import npbackup.gui.PySimpleGUI as sg from npbackup.core.i18n_helper import _t from npbackup.customization import ( LOADER_ANIMATION, diff --git a/npbackup/gui/operations.py b/npbackup/gui/operations.py index fdf73e8..352b4dd 100644 --- a/npbackup/gui/operations.py +++ b/npbackup/gui/operations.py @@ -11,7 +11,7 @@ from logging import getLogger -import PySimpleGUI as sg +import npbackup.gui.PySimpleGUI as sg import npbackup.configuration as configuration from npbackup.core.i18n_helper import _t from npbackup.gui.helpers import get_anon_repo_uri, gui_thread_runner diff --git a/npbackup/requirements.txt b/npbackup/requirements.txt index ecd156a..1acd096 100644 --- a/npbackup/requirements.txt +++ b/npbackup/requirements.txt @@ -8,8 +8,8 @@ ofunctions.threading>=2.2.0 ofunctions.platform>=1.5.0 ofunctions.random python-pidfile>=3.0.0 -# pysimplegui 5 has gone commercial, let's keep this version for now -pysimplegui==4.60.5 +# pysimplegui 5 has gone commercial, let's keep an inline version for noww +#pysimplegui==4.60.5 requests ruamel.yaml psutil From f477103b9d81f060f40ca28e95b4c539b86154c3 Mon Sep 17 00:00:00 2001 From: deajan Date: Sun, 21 Apr 2024 12:15:40 +0200 Subject: [PATCH 319/328] Fix typo --- npbackup/configuration.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/npbackup/configuration.py b/npbackup/configuration.py index a17d8d6..bd26421 100644 --- a/npbackup/configuration.py +++ b/npbackup/configuration.py @@ -693,7 +693,8 @@ def _make_list(key: str, value: Union[str, int, float, dict, list]) -> Any: "exclude_files", "pre_exec_commands", "post_exec_commands", - "additional_labels" "env_variables", + "additional_labels", + "env_variables", "encrypted_env_variables", ): if not isinstance(value, list): From f910c72499d9aa44d9abbe2fa5707f639eb87334 Mon Sep 17 00:00:00 2001 From: deajan Date: Sun, 21 Apr 2024 12:16:13 +0200 Subject: [PATCH 320/328] Avoid duplicates in inherited config lists --- npbackup/configuration.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/npbackup/configuration.py b/npbackup/configuration.py index bd26421..49db558 100644 --- a/npbackup/configuration.py +++ b/npbackup/configuration.py @@ -7,7 +7,7 @@ __author__ = "Orsiris de Jong" __copyright__ = "Copyright (C) 2022-2024 NetInvent" __license__ = "GPL-3.0-only" -__build__ = "2024041701" +__build__ = "2024042101" __version__ = "npbackup 3.0.0+" MIN_CONF_VERSION = 3.0 @@ -540,6 +540,8 @@ def _inherit_group_settings( if can_replace_merged_list: merged_lists = merged_items_dict + # Make sure we avoid duplicates in lists + merged_lists = list(set(merged_lists)) _repo_config.s(key, merged_lists) _config_inheritance.s(key, {}) for v in merged_lists: @@ -577,6 +579,8 @@ def _inherit_group_settings( if can_replace_merged_list: merged_lists = merged_items_dict + # Make sure we avoid duplicates in lists + merged_lists = list(set(merged_lists)) _repo_config.s(key, merged_lists) _config_inheritance.s(key, {}) From 03709757183258da1eacb55a0f3d5da0b353fb46 Mon Sep 17 00:00:00 2001 From: deajan Date: Sun, 21 Apr 2024 14:35:04 +0200 Subject: [PATCH 321/328] CLI: Add --manager-password option for --show-config --- npbackup/__main__.py | 23 ++++++++++++++++++++++- npbackup/configuration.py | 4 +++- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/npbackup/__main__.py b/npbackup/__main__.py index ed2735c..7cbe62f 100644 --- a/npbackup/__main__.py +++ b/npbackup/__main__.py @@ -10,6 +10,7 @@ import sys from pathlib import Path import atexit +from time import sleep from argparse import ArgumentParser from datetime import datetime, timezone import logging @@ -235,8 +236,16 @@ def cli_interface(): parser.add_argument( "--show-config", action="store_true", + required=False, help="Show full inherited configuration for current repo" ) + parser.add_argument( + "--manager-password", + type=str, + default=None, + required=False, + help="Optional manager password when showing config" + ) parser.add_argument( "--external-backend-binary", type=str, @@ -330,8 +339,20 @@ def cli_interface(): sys.exit(73) if args.show_config: + # NPF-SEC-00009 # Load an anonymous version of the repo config - repo_config = npbackup.configuration.get_anonymous_repo_config(repo_config) + show_encrypted = False + if args.manager_password: + __current_manager_password = repo_config.g("__current_manager_password") + if __current_manager_password: + if __current_manager_password == args.manager_password: + show_encrypted = True + else: + # NPF-SEC + sleep(2) # Sleep to avoid brute force attacks + logger.error("Wrong manager password") + sys.exit(74) + repo_config = npbackup.configuration.get_anonymous_repo_config(repo_config, show_encrypted=show_encrypted) print(json.dumps(repo_config, indent=4)) sys.exit(0) diff --git a/npbackup/configuration.py b/npbackup/configuration.py index 49db558..fdd65b2 100644 --- a/npbackup/configuration.py +++ b/npbackup/configuration.py @@ -798,7 +798,7 @@ def get_repos_by_group(full_config: dict, group: str) -> List[str]: return repo_list -def get_anonymous_repo_config(repo_config: dict) -> dict: +def get_anonymous_repo_config(repo_config: dict, show_encrypted: bool = False) -> dict: """ Replace each encrypted value with """ @@ -814,6 +814,8 @@ def _get_anonymous_repo_config(key: str, value: Any) -> Any: # NPF-SEC-00008: Don't show manager password / sensible data with --show-config repo_config.pop("manager_password", None) repo_config.pop("__current_manager_password", None) + if show_encrypted: + return repo_config return replace_in_iterable( repo_config, _get_anonymous_repo_config, From d7c201a6e2c3e5deca1b42ca05494ee3bec2589e Mon Sep 17 00:00:00 2001 From: deajan Date: Sun, 21 Apr 2024 15:19:14 +0200 Subject: [PATCH 322/328] Add new security related entry --- SECURITY.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/SECURITY.md b/SECURITY.md index 779f3f8..2c7d6fe 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -43,4 +43,9 @@ Best ways: # NPF-SEC-00008: Don't show manager password / sensible data with --show-config Since v3.0.0, we have config inheritance. Showing the actual config helps diag issues, but we need to be careful not -to show actual secrets. \ No newline at end of file +to show actual secrets. + +# NPF-SEC-00009: Manager password in CLI mode + +When using `--show-config --manager-password password`, we should only show unencrypted config if password is set. +Also, when wrong password is entered, we should wait in order to reduce brute force attacks. \ No newline at end of file From af1732312d6df7145d7cd2c356460e81f92ee3a3 Mon Sep 17 00:00:00 2001 From: deajan Date: Sun, 21 Apr 2024 15:19:33 +0200 Subject: [PATCH 323/328] GUI: Fix tree data objects --- npbackup/gui/config.py | 73 ++++++++++++++++++++++-------------------- 1 file changed, 38 insertions(+), 35 deletions(-) diff --git a/npbackup/gui/config.py b/npbackup/gui/config.py index de61fdf..67c4400 100644 --- a/npbackup/gui/config.py +++ b/npbackup/gui/config.py @@ -217,6 +217,7 @@ def update_gui_values(key, value, inherited, object_type, unencrypted): window[key].Disabled = True else: window[key].Disabled = False + # Update the combo group selector window[key].Update(value=value) return @@ -270,8 +271,6 @@ def update_gui_values(key, value, inherited, object_type, unencrypted): "backup_opts.exclude_files", "backup_opts.exclude_patterns", "prometheus.additional_labels", - "env.env_variables", - "env.encrypted_env_variables", ): if key == "backup_opts.tags": tree = tags_tree @@ -285,38 +284,31 @@ def update_gui_values(key, value, inherited, object_type, unencrypted): tree = exclude_patterns_tree if key == "prometheus.additional_labels": tree = prometheus_labels_tree - if key == "env.env_variables": - tree = env_variables_tree - if key == "env.encrypted_env_variables": - tree = encrypted_env_variables_tree - if isinstance(value, dict): - for var_name, var_value in value.items(): - if object_type != "group" and inherited[var_name]: + for val in value: + if object_type != "group" and inherited[val]: icon = INHERITED_TREE_ICON else: icon = TREE_ICON - tree.insert("", var_name, var_name, var_value, icon=icon) - else: - for val in value: - if isinstance(val, dict): - for var_name, var_value in val.items(): - if object_type != "group" and inherited[var_name]: - icon = INHERITED_TREE_ICON - else: - icon = TREE_ICON - tree.insert( - "", var_name, var_name, var_value, icon=icon - ) - else: - if object_type != "group" and inherited[val]: - icon = INHERITED_TREE_ICON - else: - icon = TREE_ICON - tree.insert("", val, val, val, icon=icon) + tree.insert("", val, val, val, icon=icon) window[key].Update(values=tree) return + + if key in ("env.env_variables", "env.encrypted_env_variables"): + if key == "env.env_variables": + tree = env_variables_tree + if key == "env.encrypted_env_variables": + tree = encrypted_env_variables_tree + for skey, val in value.items(): + if object_type != "group" and inherited[skey]: + icon = INHERITED_TREE_ICON + else: + icon = TREE_ICON + tree.insert("", skey, skey, values=[val], icon=icon) + window[key].Update(values=tree) + return + # Update units into separate value and unit combobox if key in ( "backup_opts.minimum_backup_size_error", @@ -496,7 +488,7 @@ def update_config_dict(full_config, object_type, object_name, values: dict) -> d else: try: value = int(value) - except ValueError: + except (ValueError, TypeError): pass # Glue value and units back together for config file @@ -528,7 +520,7 @@ def update_config_dict(full_config, object_type, object_name, values: dict) -> d if object_group: inheritance_key = f"groups.{object_group}.{key}" # If object is a list, check which values are inherited from group and remove them - if isinstance(value, list): # WIP # TODO + if isinstance(value, list): inheritance_list = full_config.g(inheritance_key) if inheritance_list: for entry in inheritance_list: @@ -552,6 +544,7 @@ def update_config_dict(full_config, object_type, object_name, values: dict) -> d # Finally, update the config dictionary if object_type == "group": + # WIP print(f"UPDATING {active_object_key} curr={current_value} new={value}") else: print( @@ -1655,8 +1648,9 @@ def config_layout() -> List[list]: break # We need to patch values since sg.Tree() only returns selected data from TreeData() + # Hence we'll fill values with a list or a dict depending on our TreeData data structure # @PysimpleGUI: there should be a get_all_values() method or something - tree_data_keys = [ + list_tree_data_keys = [ "backup_opts.paths", "backup_opts.tags", "backup_opts.pre_exec_commands", @@ -1664,16 +1658,25 @@ def config_layout() -> List[list]: "backup_opts.exclude_files", "backup_opts.exclude_patterns", "prometheus.additional_labels", - "env.env_variables", - "env.encrypted_env_variables", ] - for tree_data_key in tree_data_keys: + for tree_data_key in list_tree_data_keys: values[tree_data_key] = [] # pylint: disable=E1101 (no-member) for node in window[tree_data_key].TreeData.tree_dict.values(): if node.values: values[tree_data_key].append(node.values) + dict_tree_data_keys = [ + "env.env_variables", + "env.encrypted_env_variables", + ] + for tree_data_key in dict_tree_data_keys: + values[tree_data_key] = CommentedMap() + # pylint: disable=E1101 (no-member) + for key, node in window[tree_data_key].TreeData.tree_dict.items(): + if key and node.values: + values[tree_data_key][key] = node.values[0] + if event == "-OBJECT-SELECT-": # Update full_config with current object before updating full_config = update_config_dict( @@ -1774,11 +1777,11 @@ def config_layout() -> List[list]: if event.startswith("--ADD-"): icon = TREE_ICON - if "ENV-VARIABLE" in event: + if "ENV-VARIABLE" in event or "ENCRYPTED-ENV-VARIABLE" in event: var_name = sg.PopupGetText(_t("config_gui.enter_var_name")) var_value = sg.PopupGetText(_t("config_gui.enter_var_value")) if var_name and var_value: - tree.insert("", var_name, var_name, var_value, icon=icon) + tree.insert("", var_name, var_name, [var_value], icon=icon) else: node = sg.PopupGetText(popup_text) if node: From 21ec56ea5556d1e98e6b87e8d7e63bba76214f8a Mon Sep 17 00:00:00 2001 From: deajan Date: Sun, 21 Apr 2024 16:04:20 +0200 Subject: [PATCH 324/328] GUI: Patch treedata values only on full_config dict update --- npbackup/gui/config.py | 83 +++++++++++++++++++++++------------------- 1 file changed, 46 insertions(+), 37 deletions(-) diff --git a/npbackup/gui/config.py b/npbackup/gui/config.py index 67c4400..f9a6517 100644 --- a/npbackup/gui/config.py +++ b/npbackup/gui/config.py @@ -464,6 +464,47 @@ def update_config_dict(full_config, object_type, object_name, values: dict) -> d object_group = full_config.g(f"repos.{object_name}.repo_group") else: object_group = None + + + # We need to patch values since sg.Tree() only returns selected data from TreeData() + # Hence we'll fill values with a list or a dict depending on our TreeData data structure + # @PysimpleGUI: there should be a get_all_values() method or something + list_tree_data_keys = [ + "backup_opts.paths", + "backup_opts.tags", + "backup_opts.pre_exec_commands", + "backup_opts.post_exec_commands", + "backup_opts.exclude_files", + "backup_opts.exclude_patterns", + "prometheus.additional_labels", + ] + for tree_data_key in list_tree_data_keys: + values[tree_data_key] = [] + # pylint: disable=E1101 (no-member) + for node in window[tree_data_key].TreeData.tree_dict.values(): + if node.values: + values[tree_data_key].append(node.values) + + dict_tree_data_keys = [ + "env.env_variables", + "env.encrypted_env_variables", + ] + for tree_data_key in dict_tree_data_keys: + values[tree_data_key] = CommentedMap() + # pylint: disable=E1101 (no-member) + for key, node in window[tree_data_key].TreeData.tree_dict.items(): + if key and node.values: + values[tree_data_key][key] = node.values[0] + + + # Special treatment for env.encrypted_env_variables since they might contain an ENCRYPTED_DATA_PLACEHOLDER + # We need to update the placeholder to the actual value if exists + for k, v in values["env.encrypted_env_variables"].items(): + if v == ENCRYPTED_DATA_PLACEHOLDER: + values["env.encrypted_env_variables"][k] = full_config.g( + f"{object_type}s.{object_name}.env.encrypted_env_variables.{k}" + ) + for key, value in values.items(): # Don't update placeholders ;) # TODO exclude encrypted env vars @@ -543,13 +584,11 @@ def update_config_dict(full_config, object_type, object_name, values: dict) -> d continue # Finally, update the config dictionary - if object_type == "group": - # WIP - print(f"UPDATING {active_object_key} curr={current_value} new={value}") - else: - print( - f"UPDATING {active_object_key} curr={current_value} inherited={inherited} new={value}" - ) + # Debug WIP + #if object_type == "group": + #print(f"UPDATING {active_object_key} curr={current_value} new={value}") + #else: + #print(f"UPDATING {active_object_key} curr={current_value} inherited={inherited} new={value}") full_config.s(active_object_key, value) return full_config @@ -1647,36 +1686,6 @@ def config_layout() -> List[list]: if event in (sg.WIN_CLOSED, sg.WIN_X_EVENT, "--CANCEL--"): break - # We need to patch values since sg.Tree() only returns selected data from TreeData() - # Hence we'll fill values with a list or a dict depending on our TreeData data structure - # @PysimpleGUI: there should be a get_all_values() method or something - list_tree_data_keys = [ - "backup_opts.paths", - "backup_opts.tags", - "backup_opts.pre_exec_commands", - "backup_opts.post_exec_commands", - "backup_opts.exclude_files", - "backup_opts.exclude_patterns", - "prometheus.additional_labels", - ] - for tree_data_key in list_tree_data_keys: - values[tree_data_key] = [] - # pylint: disable=E1101 (no-member) - for node in window[tree_data_key].TreeData.tree_dict.values(): - if node.values: - values[tree_data_key].append(node.values) - - dict_tree_data_keys = [ - "env.env_variables", - "env.encrypted_env_variables", - ] - for tree_data_key in dict_tree_data_keys: - values[tree_data_key] = CommentedMap() - # pylint: disable=E1101 (no-member) - for key, node in window[tree_data_key].TreeData.tree_dict.items(): - if key and node.values: - values[tree_data_key][key] = node.values[0] - if event == "-OBJECT-SELECT-": # Update full_config with current object before updating full_config = update_config_dict( From 2380dcb1e45d015a56e7452a3b9545828c86d502 Mon Sep 17 00:00:00 2001 From: deajan Date: Sun, 21 Apr 2024 16:06:47 +0200 Subject: [PATCH 325/328] Reformat files with black --- npbackup/__main__.py | 42 +++++++++++++++++++++++---------------- npbackup/configuration.py | 12 +++++------ npbackup/core/runner.py | 13 ++++++------ npbackup/gui/__main__.py | 10 +++++++--- npbackup/gui/config.py | 28 +++++++++++++------------- 5 files changed, 59 insertions(+), 46 deletions(-) diff --git a/npbackup/__main__.py b/npbackup/__main__.py index 7cbe62f..cf9bfec 100644 --- a/npbackup/__main__.py +++ b/npbackup/__main__.py @@ -82,7 +82,7 @@ def cli_interface(): type=str, default=None, required=False, - help="Comme separated list of groups to work with. Can accept special name '__all__' to work with all repositories." + help="Comme separated list of groups to work with. Can accept special name '__all__' to work with all repositories.", ) parser.add_argument("-b", "--backup", action="store_true", help="Run a backup") parser.add_argument( @@ -237,28 +237,28 @@ def cli_interface(): "--show-config", action="store_true", required=False, - help="Show full inherited configuration for current repo" + help="Show full inherited configuration for current repo", ) parser.add_argument( "--manager-password", type=str, default=None, required=False, - help="Optional manager password when showing config" + help="Optional manager password when showing config", ) parser.add_argument( "--external-backend-binary", type=str, default=None, required=False, - help="Full path to alternative external backend binary" + help="Full path to alternative external backend binary", ) parser.add_argument( "--group-operation", type=str, default=None, required=False, - help="Launch an operation on a group of repositories given by --repo-group" + help="Launch an operation on a group of repositories given by --repo-group", ) args = parser.parse_args() @@ -317,7 +317,7 @@ def cli_interface(): msg = "Cannot obtain repo config" json_error_logging(False, msg, "critical") sys.exit(71) - + if not args.group_operation: repo_name = None if not args.repo_name: @@ -329,7 +329,7 @@ def cli_interface(): sys.exit(72) else: repo_config = None - + binary = None if args.external_backend_binary: binary = args.external_backend_binary @@ -337,7 +337,7 @@ def cli_interface(): msg = f"External backend binary {binary} cannot be found." json_error_logging(False, msg, "critical") sys.exit(73) - + if args.show_config: # NPF-SEC-00009 # Load an anonymous version of the repo config @@ -348,11 +348,13 @@ def cli_interface(): if __current_manager_password == args.manager_password: show_encrypted = True else: - # NPF-SEC - sleep(2) # Sleep to avoid brute force attacks + # NPF-SEC + sleep(2) # Sleep to avoid brute force attacks logger.error("Wrong manager password") sys.exit(74) - repo_config = npbackup.configuration.get_anonymous_repo_config(repo_config, show_encrypted=show_encrypted) + repo_config = npbackup.configuration.get_anonymous_repo_config( + repo_config, show_encrypted=show_encrypted + ) print(json.dumps(repo_config, indent=4)) sys.exit(0) @@ -366,7 +368,9 @@ def cli_interface(): except KeyError: auto_upgrade_interval = 10 - if (auto_upgrade and upgrade_runner.need_upgrade(auto_upgrade_interval)) or args.auto_upgrade: + if ( + auto_upgrade and upgrade_runner.need_upgrade(auto_upgrade_interval) + ) or args.auto_upgrade: if args.auto_upgrade: logger.info("Running user initiated auto upgrade") else: @@ -406,7 +410,9 @@ def cli_interface(): cli_args["op_args"] = {"force": args.force} elif args.restore or args.group_operation == "restore": if args.restore_includes: - restore_includes = [include.strip() for include in args.restore_includes.split(',')] + restore_includes = [ + include.strip() for include in args.restore_includes.split(",") + ] else: restore_includes = None cli_args["operation"] = "restore" @@ -465,13 +471,15 @@ def cli_interface(): repo_config_list = [] if args.group_operation: if args.repo_group: - groups = [group.strip() for group in args.repo_group.split(',')] + groups = [group.strip() for group in args.repo_group.split(",")] for group in groups: repos = npbackup.configuration.get_repos_by_group(full_config, group) elif args.repo_name: - repos = [repo.strip() for repo in args.repo_name.split(',')] + repos = [repo.strip() for repo in args.repo_name.split(",")] else: - logger.critical("No repository names or groups have been provided for group operation. Please use --repo-group or --repo-name") + logger.critical( + "No repository names or groups have been provided for group operation. Please use --repo-group or --repo-name" + ) sys.exit(74) for repo in repos: repo_config, _ = npbackup.configuration.get_repo_config(full_config, repo) @@ -483,7 +491,7 @@ def cli_interface(): cli_args["op_args"] = { "repo_config_list": repo_config_list, "operation": args.group_operation, - **cli_args["op_args"] + **cli_args["op_args"], } if cli_args["operation"]: diff --git a/npbackup/configuration.py b/npbackup/configuration.py index fdd65b2..6b0166d 100644 --- a/npbackup/configuration.py +++ b/npbackup/configuration.py @@ -791,17 +791,17 @@ def get_repos_by_group(full_config: dict, group: str) -> List[str]: if full_config: for repo in list(full_config.g("repos").keys()): if ( - (full_config.g(f"repos.{repo}.repo_group") == group or group == "__all__") - and group not in repo_list - ): + full_config.g(f"repos.{repo}.repo_group") == group or group == "__all__" + ) and group not in repo_list: repo_list.append(repo) return repo_list def get_anonymous_repo_config(repo_config: dict, show_encrypted: bool = False) -> dict: """ - Replace each encrypted value with + Replace each encrypted value with """ + def _get_anonymous_repo_config(key: str, value: Any) -> Any: if key_should_be_encrypted(key, ENCRYPTED_OPTIONS): if isinstance(value, list): @@ -810,7 +810,7 @@ def _get_anonymous_repo_config(key: str, value: Any) -> Any: else: value = "__(o_O)__" return value - + # NPF-SEC-00008: Don't show manager password / sensible data with --show-config repo_config.pop("manager_password", None) repo_config.pop("__current_manager_password", None) @@ -821,4 +821,4 @@ def _get_anonymous_repo_config(key: str, value: Any) -> Any: _get_anonymous_repo_config, callable_wants_key=True, callable_wants_root_key=True, - ) \ No newline at end of file + ) diff --git a/npbackup/core/runner.py b/npbackup/core/runner.py index d7cf912..eab6f54 100644 --- a/npbackup/core/runner.py +++ b/npbackup/core/runner.py @@ -242,10 +242,7 @@ def binary(self): @binary.setter def binary(self, value): - if ( - not isinstance(value, str) - or not os.path.isfile(value) - ): + if not isinstance(value, str) or not os.path.isfile(value): raise ValueError("Backend binary {value} is not readable") self._binary = value @@ -417,7 +414,10 @@ def wrapper(self, *args, **kwargs): operation = fn.__name__ current_permissions = self.repo_config.g("permissions") - if current_permissions and not current_permissions in required_permissions[operation]: + if ( + current_permissions + and not current_permissions in required_permissions[operation] + ): self.write_logs( f"Required permissions for operation '{operation}' must be in {required_permissions[operation]}, current permission is [{current_permissions}]", level="critical", @@ -1330,7 +1330,8 @@ def group_runner(self, repo_config_list: List, operation: str, **kwargs) -> bool ) else: self.write_logs( - f"Operation {operation} failed for repo {repo_name}", level="error" + f"Operation {operation} failed for repo {repo_name}", + level="error", ) if not result: group_result = False diff --git a/npbackup/gui/__main__.py b/npbackup/gui/__main__.py index d4938c3..8b58923 100644 --- a/npbackup/gui/__main__.py +++ b/npbackup/gui/__main__.py @@ -64,7 +64,9 @@ sg.SetOptions(icon=OEM_ICON) -def about_gui(version_string: str, full_config: dict = None, auto_upgrade_result: bool = False) -> None: +def about_gui( + version_string: str, full_config: dict = None, auto_upgrade_result: bool = False +) -> None: if auto_upgrade_result: new_version = [ sg.Button( @@ -449,13 +451,15 @@ def check_for_auto_upgrade(full_config: dict) -> None: if full_config and full_config.g("global_options.auto_upgrade_server_url"): auto_upgrade_result = upgrade_runner.check_new_version(full_config) if auto_upgrade_result: - r = sg.Popup(_t("config_gui.auto_upgrade_launch"), custom_text=(_t("generic.yes"), _t("generic.no"))) + r = sg.Popup( + _t("config_gui.auto_upgrade_launch"), + custom_text=(_t("generic.yes"), _t("generic.no")), + ) if r == _t("generic.yes"): result = upgrade_runner.run_upgrade(full_config) if not result: sg.Popup(_t("config_gui.auto_upgrade_failed")) - def select_config_file(config_file: str = None) -> None: """ Option to select a configuration file diff --git a/npbackup/gui/config.py b/npbackup/gui/config.py index f9a6517..a78ea65 100644 --- a/npbackup/gui/config.py +++ b/npbackup/gui/config.py @@ -286,14 +286,14 @@ def update_gui_values(key, value, inherited, object_type, unencrypted): tree = prometheus_labels_tree for val in value: - if object_type != "group" and inherited[val]: - icon = INHERITED_TREE_ICON - else: - icon = TREE_ICON - tree.insert("", val, val, val, icon=icon) + if object_type != "group" and inherited[val]: + icon = INHERITED_TREE_ICON + else: + icon = TREE_ICON + tree.insert("", val, val, val, icon=icon) window[key].Update(values=tree) return - + if key in ("env.env_variables", "env.encrypted_env_variables"): if key == "env.env_variables": tree = env_variables_tree @@ -308,7 +308,7 @@ def update_gui_values(key, value, inherited, object_type, unencrypted): tree.insert("", skey, skey, values=[val], icon=icon) window[key].Update(values=tree) return - + # Update units into separate value and unit combobox if key in ( "backup_opts.minimum_backup_size_error", @@ -465,7 +465,6 @@ def update_config_dict(full_config, object_type, object_name, values: dict) -> d else: object_group = None - # We need to patch values since sg.Tree() only returns selected data from TreeData() # Hence we'll fill values with a list or a dict depending on our TreeData data structure # @PysimpleGUI: there should be a get_all_values() method or something @@ -496,7 +495,6 @@ def update_config_dict(full_config, object_type, object_name, values: dict) -> d if key and node.values: values[tree_data_key][key] = node.values[0] - # Special treatment for env.encrypted_env_variables since they might contain an ENCRYPTED_DATA_PLACEHOLDER # We need to update the placeholder to the actual value if exists for k, v in values["env.encrypted_env_variables"].items(): @@ -585,10 +583,10 @@ def update_config_dict(full_config, object_type, object_name, values: dict) -> d # Finally, update the config dictionary # Debug WIP - #if object_type == "group": - #print(f"UPDATING {active_object_key} curr={current_value} new={value}") - #else: - #print(f"UPDATING {active_object_key} curr={current_value} inherited={inherited} new={value}") + # if object_type == "group": + # print(f"UPDATING {active_object_key} curr={current_value} new={value}") + # else: + # print(f"UPDATING {active_object_key} curr={current_value} inherited={inherited} new={value}") full_config.s(active_object_key, value) return full_config @@ -1126,7 +1124,9 @@ def object_layout() -> List[list]: [sg.Button(_t("config_gui.set_permissions"), key="--SET-PERMISSIONS--")], [ sg.Text(_t("config_gui.repo_group"), size=(40, 1)), - sg.Combo(values=configuration.get_group_list(full_config), key="repo_group"), + sg.Combo( + values=configuration.get_group_list(full_config), key="repo_group" + ), ], [ sg.Text( From fc3936c441e1960ed7d2580ef613f3f5432a89a9 Mon Sep 17 00:00:00 2001 From: deajan Date: Sun, 21 Apr 2024 16:20:08 +0200 Subject: [PATCH 326/328] Don't bother to lint PySimpleGUI --- .github/workflows/pylint-linux.yaml | 8 ++++---- .github/workflows/pylint-windows.yaml | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/pylint-linux.yaml b/.github/workflows/pylint-linux.yaml index 3d41e29..e44df07 100644 --- a/.github/workflows/pylint-linux.yaml +++ b/.github/workflows/pylint-linux.yaml @@ -30,22 +30,22 @@ jobs: python -m pip install pylint # Do not run pylint on python 3.3 because isort is not available for python 3.3, don't run on python 3.4 because pylint: disable=xxxx does not exist # Disable E0401 import error since we lint on linux and pywin32 is obviously missing - python -m pylint --disable=C,W,R --max-line-length=127 npbackup + python -m pylint --disable=C,W,R --max-line-length=127 --ignore PySimpleGUI.py npbackup python -m pylint --disable=C,W,R --max-line-length=127 upgrade_server/upgrade_server - name: Lint with flake8 #if: ${{ matrix.python-version == '3.11' }} run: | python -m pip install flake8 # stop the build if there are Python syntax errors or undefined names - python -m flake8 --count --select=E9,F63,F7,F82 --show-source --statistics npbackup + python -m flake8 --count --select=E9,F63,F7,F82 --show-source --statistics --exclude PySimpleGUI.py npbackup python -m flake8 --count --select=E9,F63,F7,F82 --show-source --statistics upgrade_server/upgrade_server # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide - python -m flake8 --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics npbackup + python -m flake8 --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics --exclude PySimpleGUI.py npbackup python -m flake8 --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics upgrade_server - name: Lint with Black # Don't run on python < 3.6 since black does not exist there, run only once #if: ${{ matrix.python-version == '3.11' }} run: | pip install black - python -m black --check npbackup + python -m black --check --exclude PySimpleGUI.py npbackup python -m black --check upgrade_server/upgrade_server \ No newline at end of file diff --git a/.github/workflows/pylint-windows.yaml b/.github/workflows/pylint-windows.yaml index b02a00b..13e5fe2 100644 --- a/.github/workflows/pylint-windows.yaml +++ b/.github/workflows/pylint-windows.yaml @@ -30,22 +30,22 @@ jobs: run: | python -m pip install pylint # Do not run pylint on python 3.3 because isort is not available for python 3.3, don't run on python 3.4 because pylint: disable=xxxx does not exist - python -m pylint --disable=C,W,R --max-line-length=127 npbackup + python -m pylint --disable=C,W,R --max-line-length=127 --ignore PySimpleGUI.py npbackup python -m pylint --disable=C,W,R --max-line-length=127 upgrade_server/upgrade_server - name: Lint with flake8 #if: ${{ matrix.python-version == '3.12' }} run: | python -m pip install flake8 # stop the build if there are Python syntax errors or undefined names - python -m flake8 --count --select=E9,F63,F7,F82 --show-source --statistics npbackup + python -m flake8 --count --select=E9,F63,F7,F82 --show-source --statistics --exclude PySimpleGUI.py npbackup python -m flake8 --count --select=E9,F63,F7,F82 --show-source --statistics upgrade_server/upgrade_server # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide - python -m flake8 --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics npbackup + python -m flake8 --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics --exclude PySimpleGUI.py npbackup python -m flake8 --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics upgrade_server/upgrade_server - name: Lint with Black # Don't run on python < 3.6 since black does not exist there, run only once #if: ${{ matrix.python-version == '3.12' }} run: | pip install black - python -m black --check npbackup + python -m black --check --exclude PySimpleGUI.py npbackup python -m black --check upgrade_server/upgrade_server \ No newline at end of file From 80b55905ca274d5d53a6964cfe0a03371dd06e1d Mon Sep 17 00:00:00 2001 From: deajan Date: Sun, 21 Apr 2024 16:24:55 +0200 Subject: [PATCH 327/328] GUI: Fix bogus upgrade function name --- npbackup/gui/__main__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/npbackup/gui/__main__.py b/npbackup/gui/__main__.py index 8b58923..4bdb366 100644 --- a/npbackup/gui/__main__.py +++ b/npbackup/gui/__main__.py @@ -105,7 +105,7 @@ def about_gui( ) if result == "OK": logger.info("Running GUI initiated upgrade") - sub_result = upgrade_runner.upgrade(full_config) + sub_result = upgrade_runner.run_upgrade(full_config) if sub_result: sys.exit(0) else: From 05b89ff41b64a3375e3cf4e15f333ab3b8504144 Mon Sep 17 00:00:00 2001 From: deajan Date: Sun, 21 Apr 2024 16:30:21 +0200 Subject: [PATCH 328/328] Bump version --- npbackup/__version__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/npbackup/__version__.py b/npbackup/__version__.py index 7f70918..65a23b4 100644 --- a/npbackup/__version__.py +++ b/npbackup/__version__.py @@ -9,8 +9,8 @@ __description__ = "NetPerfect Backup Client" __copyright__ = "Copyright (C) 2022-2024 NetInvent" __license__ = "GPL-3.0-only" -__build__ = "2024041601" -__version__ = "3.0.0-alpha4" +__build__ = "2024042101" +__version__ = "3.0.0-beta1" import sys