diff --git a/common/backintime.py b/common/backintime.py index 968f60d68..3082e5425 100644 --- a/common/backintime.py +++ b/common/backintime.py @@ -20,6 +20,7 @@ import cli import cliarguments from diagnostics import collect_minimal_diagnostics +from konfig import Konfig def takeSnapshotAsync(cfg, checksum=False): @@ -94,10 +95,11 @@ def startApp(bin_name: str) -> config.Config | None: args = cliarguments.parse_arguments(args=None, agent=parser_agent) # Name, Version, As Root, OS - msg = '' - for key, val in collect_minimal_diagnostics().items(): - msg = f'{msg}; {key}: {val}' - logger.debug(msg[2:]) + if logger.DEBUG: + msg = '' + for key, val in collect_minimal_diagnostics().items(): + msg = f'{msg}; {key}: {val}' + logger.debug(msg[2:]) # Add source path to $PATH environ if running from source if tools.runningFromSource(): @@ -122,14 +124,20 @@ def startApp(bin_name: str) -> config.Config | None: cli.set_quiet(args) cli.print_header() - return cli.get_config_and_select_profile( + # This loads the real/new "Konfig" + real_konfig = cli.get_config_and_select_profile( config_path=args.config, - data_path=args.share_path, - profile=args.profile, + # data_path=args.share_path, + pid_or_name=args.profile, # Dev note (buhtz, 2025): There is not a default value in all cases, # because "--checksum" is exclusive to rsync-related commands. - checksum=getattr(args, 'checksum', None), - check=False) + # checksum=getattr(args, 'checksum', None), + # check=False + ) + + # Workaround: For the current time we use "Config" as surrogate, that is + # using the real "Konfig" in the back. + return config.Config() if __name__ == '__main__': diff --git a/common/bitbase.py b/common/bitbase.py index 7ecd75c8e..4517a2aaf 100644 --- a/common/bitbase.py +++ b/common/bitbase.py @@ -12,6 +12,16 @@ from enum import Enum from pathlib import Path +# Workaround: Mostly relevant on TravisCI but not exclusively. +# While unittesting and without regular invocation of BIT the GNU gettext +# class-based API isn't setup yet. +# pylint: disable=duplicate-code +try: + _('Warning') +except NameError: + def _(val): + return val + # |-------------| # | Application | # |-------------| @@ -47,6 +57,7 @@ # See issue #1734 and #1735 URL_ENCRYPT_TRANSITION = 'https://github.com/bit-team/backintime' \ '/blob/-/doc/ENCRYPT_TRANSITION.md' + URL_SOURCE = 'https://github.com/bit-team/backintime' URL_WEBSITE = URL_SOURCE URL_FAQ = f'{URL_WEBSITE}/blob/-/FAQ.md' @@ -63,12 +74,22 @@ XDG_DATA_HOME = Path(os.environ.get( 'XDG_DATA_HOME', - os.environ.get('HOME') + '/.local/share' + Path.home() / '.local' / 'share' )) +XDG_CONFIG_HOME = Path(os.environ.get( + 'XDG_CONFIG_HOME', + Path.home() / '.config' +)) + +# Dev note (2026-06, buhtz): Formerly known as Config._LOCAL_DATA_FOLDER +BIT_DATA_HOME = XDG_DATA_HOME / BINARY_NAME_BASE + FILENAME_CONFIG = 'config' CRON_ENV_PATH = XDG_DATA_HOME / 'cron_env.json' +CONFIG_FILE_PATH = XDG_CONFIG_HOME / BINARY_NAME_BASE / FILENAME_CONFIG + # See issue #1743 ENCFS_BACKUP_CONFIG_SUFFIX = '.encfs.backup' @@ -111,7 +132,15 @@ class TimeUnit(Enum): DAY = 20 # Config.DAY WEEK = 30 # Config.WEEK MONTH = 40 # Config.MONTH - YEAR = 80 # Config.YEAR + YEAR = 80 # Config.Year + + +# TODO: Duplicate of storagesize.SizeUnit +class StorageSizeUnit(Enum): + """Describe the units used to express the size of storage devices or file + system objects.""" + MB = 10 # Config.DISK_UNIT_MB + GB = 20 # Config.DISK_UNIT_GB class ScheduleMode(Enum): diff --git a/common/cli.py b/common/cli.py index 34f1b3731..bdbb9b269 100644 --- a/common/cli.py +++ b/common/cli.py @@ -19,8 +19,9 @@ import logger import bitbase import core_events +from konfig import Konfig from mount import MountManager, MountError -from typing import Optional +from typing import Optional, Union from version import __version__ @@ -324,7 +325,13 @@ def callback(self, line, *_args): def run(self): s = snapshots.Snapshots(self.config) s.restore( - self.sid, self.what, self.callback, self.where, **self.kwargs) + self.sid, + self.what, + self.callback, + self.where, + self.force_checksum_use, + **self.kwargs + ) print('\nLog saved to %s' % self.logFile) @@ -375,14 +382,15 @@ def print_header(): ) -def detect_cipher_settings(cfg: config.Config) -> tuple[str, str, str]: +def detect_cipher_settings(cfg: Konfig) -> tuple[str, str, str]: """See issue #2176.""" result = [] cipher_keys = list(filter( - lambda key: 'cipher' in key, cfg.dict.keys() + lambda key: 'cipher' in key, cfg._conf.keys() )) + for key in cipher_keys: - val = cfg.dict[key] + val = cfg._conf[key] if val.lower() == 'default': continue @@ -390,7 +398,7 @@ def detect_cipher_settings(cfg: config.Config) -> tuple[str, str, str]: if pid == '1': name = 'Main profile' else: - name = cfg.dict[f'{key.split('.')[0]}.name'] + name = cfg._conf[f'{key.split('.')[0]}.name'] result.append((f'"{name}" ({pid})', val, key)) @@ -411,7 +419,7 @@ def _warn_about_global_config(): ) -def _warn_about_cipher(cfg: config.Config) -> None: +def _warn_about_cipher(cfg: Konfig) -> None: """See issue #2176. Cipher options is not used anymore by BIT. Therefore, users having it in config need to be warned about it. """ @@ -423,7 +431,7 @@ def _warn_about_cipher(cfg: config.Config) -> None: ) -def _warn_about_remote_host_check(cfg: config.Config) -> None: +def _warn_about_remote_host_check(cfg: Konfig) -> None: """See issue #2482. Those settings are deprecated. """ for name, key in detect_remote_host_check_settings(cfg): @@ -434,37 +442,37 @@ def _warn_about_remote_host_check(cfg: config.Config) -> None: ) -def detect_remote_host_check_settings(cfg: config.Config) -> tuple[str, str, str]: +def detect_remote_host_check_settings(cfg: Konfig) -> tuple[str, str, str]: """See issue #2482.""" result = [] - cipher_keys = sorted(filter( - lambda key: 'snapshots.ssh.check_' in key, cfg.dict.keys() + rh_keys = sorted(filter( + lambda key: 'snapshots.ssh.check_' in key, cfg._conf.keys() )) - for key in cipher_keys: + for key in rh_keys: # ignore default (true) - if cfg.dict[key].lower() == 'true': + if cfg._conf[key].lower() == 'true': continue pid = key.split('.')[0].replace('profile', '') # SSH mode ? - if 'ssh' not in cfg.dict[f'profile{pid}.snapshots.mode']: + if 'ssh' not in cfg._conf[f'profile{pid}.snapshots.mode']: # irrelevant not SSH continue if pid == '1': name = 'Main profile' else: - name = cfg.dict[f'{key.split('.')[0]}.name'] + name = cfg._conf[f'{key.split('.')[0]}.name'] result.append((f'"{name}" ({pid})', key)) return result -def _backup_and_remove_encfs_config(cfg: config.Config) -> bool: +def _backup_and_remove_encfs_config(cfg: Konfig) -> bool: """EncFS encryption feature was removed from Back In Time (#1734). This function detects existing EncFS profiles. If detected a backup is created of the complete config file and the EncFS profiles removed after. @@ -472,11 +480,13 @@ def _backup_and_remove_encfs_config(cfg: config.Config) -> bool: encfs_pids = [] names = '' - for pid in cfg.profiles(): - if 'encfs' in cfg.snapshotsMode(profile_id=pid).lower(): - name = cfg.profileName(profile_id=pid) + for profile in cfg.iter_profiles(): + if 'encfs' in profile.mode.lower(): + name = profile.name + pid = profile.profile_id logger.critical( - f'Profile "{name}" ({pid}) uses obsolete EncFS encryption. ' + f'Profile "{name}" ({pid}) uses ' + 'obsolete EncFS encryption. ' 'EncFS support was removed from Back In Time.' ) encfs_pids.append(pid) @@ -493,27 +503,19 @@ def _backup_and_remove_encfs_config(cfg: config.Config) -> bool: ) shutil.copyfile(config_fp, config_fp_backup) - # remove profiles, but remember that BIT will refuse to remove the - # last standing profil - temp_pid = cfg.addProfile('tmp-f8f58e16-ff2eeb4da37d-remove-me') for pid in encfs_pids: - cfg.removeProfile(pid) - # if pid == '1': - # first_pid = cfg.addProfile(cfg.default_profile_name) - # logger.critical(f'Reset Hauptprofil. {first_pid=}') - - # If main profil was reset and it is the only profile left, - # delete the whole config file. - if '1' in encfs_pids and len(cfg.profiles()) == 1: - config_fp.unlink() + cfg.profile(pid).remove() + + if len(cfg.profiles): + cfg.save(config_fp) else: - cfg.removeProfile(temp_pid) - cfg.save() + # If no profile is left, remove the file itself + config_fp.unlink() logger.critical( f'A backup of the current config file was created: {config_fp} -> ' f'{config_fp_backup}. All detected EncFS profiles were removed ' - f'from the active configuration. Profiles affected: {names}' + f'from the active configuration. Affected profiles are: {names}' ) return True @@ -521,34 +523,47 @@ def _backup_and_remove_encfs_config(cfg: config.Config) -> bool: def get_config_and_select_profile( config_path: str, - data_path: str, - profile: str, - checksum: Optional[bool] = None, - check: bool = True) -> config.Config: + # data_path: str, + pid_or_name: Union[str, int] + # checksum: Optional[bool] = None + # check: bool = True +) -> Konfig: """Load config and change to profile selected on commandline. Args: config_path: Path to config file. data_path: Path to "share_path". - profile: Name or ID of the profile. + pid_or_name: Name or ID of the profile. checksum: Use checksum option. check: If ``True`` check if config is valid. Returns: - Current config with requested profile selected. + Current the config Raises: SystemExit: 1 if ``profile`` or ``profile_id`` is no valid profile. 2 if ``check`` is ``True`` and config is not configured """ + if config_path is None: + config_path = bitbase.CONFIG_FILE_PATH + + logger.debug(f'Config path: {config_path}') + + # Workaround: Sometimes the id is given as string. + if pid_or_name and pid_or_name.isdigit(): + pid_or_name = int(pid_or_name) + _warn_about_global_config() - cfg = config.Config(config_path=config_path, data_path=data_path) + cfg = Konfig() + cfg.load(config_path) + + # cfg = config.Config(config_path=config_path, data_path=data_path) # detect and remove encfs profiles if _backup_and_remove_encfs_config(cfg): # re-read again - cfg = config.Config(config_path=config_path, data_path=data_path) + cfg.load(config_path) # Just warn about cipher settings if present. _warn_about_cipher(cfg) @@ -556,26 +571,16 @@ def get_config_and_select_profile( # Warn about deprecated remote host check settings (#2482) _warn_about_remote_host_check(cfg) - # explicit profile? - if profile: - if profile.isdigit(): - if not cfg.setCurrentProfile(int(profile)): - logger.error(f'Profile-ID not found: {profile}') - sys.exit(bitbase.RETURN_ERR) - else: - if not cfg.setCurrentProfileByName(profile): - logger.error(f'Profile not found: {profile}') - sys.exit(bitbase.RETURN_ERR) - - else: - # Use the first available profile as default - cfg.setCurrentProfile(cfg.profiles()[0]) + # explicit select a profile? + if pid_or_name and not cfg.has_profile(pid_or_name): + logger.error(f'Profile not found: {pid_or_name}') + sys.exit(bitbase.RETURN_ERR) - if check and not cfg.isConfigured(): - logger.error(f'{cfg.APP_NAME} is not configured!') - sys.exit(bitbase.RETURN_NO_CFG) + # if check and not cfg.isConfigured(): + # logger.error(f'{cfg.APP_NAME} is not configured!') + # sys.exit(bitbase.RETURN_NO_CFG) - if checksum is not None: - cfg.forceUseChecksum = checksum + # if checksum is not None: + # cfg.forceUseChecksum = checksum return cfg diff --git a/common/cliarguments.py b/common/cliarguments.py index 4ad0827c4..5ded0f6e0 100644 --- a/common/cliarguments.py +++ b/common/cliarguments.py @@ -242,7 +242,7 @@ def _create_common_parser(self) -> ArgumentParser: parser.add_argument( '--config', metavar='PATH', - type=str, + type=Path, action='store', help='read config from %(metavar)s ' '(Default: $XDG_CONFIG_HOME/backintime/config)') diff --git a/common/clicommands.py b/common/clicommands.py index fa52abbc5..7e07460cb 100644 --- a/common/clicommands.py +++ b/common/clicommands.py @@ -122,7 +122,10 @@ def _do_backup(args: argparse.Namespace, force: bool): cfg = _get_config(args) tools.envLoad(bitbase.CRON_ENV_PATH) - ret = snapshots.Snapshots(cfg).backup(force) + ret = snapshots.Snapshots(cfg).backup( + force=force, + force_checksum_use = getattr(args, 'checksum', False) + ) sys.exit(int(ret)) diff --git a/common/config.py b/common/config.py index a4397963a..1c017f692 100644 --- a/common/config.py +++ b/common/config.py @@ -23,7 +23,7 @@ """ import os import datetime -import socket +# import socket import random import getpass import shlex @@ -40,18 +40,19 @@ import bitbase import tools -import configfile +# import configfile import encode import logger import password import pluginmanager import schedule import core_events -from storagesize import StorageSize, SizeUnit +from storagesize import StorageSize from exceptions import PermissionDeniedByPolicy +from konfig import Konfig -class Config(configfile.ConfigFileWithProfiles): +class Config: # (configfile.ConfigFileWithProfiles): APP_NAME = bitbase.APP_NAME # Version was introduced with 72fc490 in Sept. 2016 by Germar @@ -92,6 +93,7 @@ class Config(configfile.ConfigFileWithProfiles): DEFAULT_OFFSET = 0 ENCODE = encode.Bounce() + # Deprecated. See issue #2424 PLUGIN_MANAGER = pluginmanager.PluginManager() def __init__(self, config_path=None, data_path=None): @@ -105,50 +107,56 @@ def __init__(self, config_path=None, data_path=None): # Note: The main profiles name here is translated using the systems # current locale because the language code in the config file wasn't # read yet. - configfile.ConfigFileWithProfiles.__init__(self, _('Main profile')) - - self._unsaved_profiles = [] - - HOME_FOLDER = os.path.expanduser('~') - DATA_FOLDER = '.local/share' - CONFIG_FOLDER = '.config' - BIT_FOLDER = 'backintime' - self._DEFAULT_LOCAL_DATA_FOLDER = os.path.join(HOME_FOLDER, DATA_FOLDER, BIT_FOLDER) - self._LOCAL_CONFIG_FOLDER = os.path.join(HOME_FOLDER, CONFIG_FOLDER, BIT_FOLDER) - self._MOUNT_ROOT = os.path.join(DATA_FOLDER, BIT_FOLDER, 'mnt') - - if data_path: - # Deprecated: --share-path was removed - self.DATA_FOLDER_ROOT = data_path - self._LOCAL_DATA_FOLDER = os.path.join(data_path, DATA_FOLDER, BIT_FOLDER) - self._LOCAL_MOUNT_ROOT = os.path.join(data_path, self._MOUNT_ROOT) - else: - self.DATA_FOLDER_ROOT = HOME_FOLDER - self._LOCAL_DATA_FOLDER = self._DEFAULT_LOCAL_DATA_FOLDER - self._LOCAL_MOUNT_ROOT = os.path.join(HOME_FOLDER, self._MOUNT_ROOT) - - tools.makeDirs(self._LOCAL_CONFIG_FOLDER) - tools.makeDirs(self._LOCAL_DATA_FOLDER) - tools.makeDirs(self._LOCAL_MOUNT_ROOT) - - self._DEFAULT_CONFIG_PATH = os.path.join( - self._LOCAL_CONFIG_FOLDER, bitbase.FILENAME_CONFIG - ) - - if config_path is None: - self._LOCAL_CONFIG_PATH = self._DEFAULT_CONFIG_PATH - else: - self._LOCAL_CONFIG_PATH = os.path.abspath(config_path) - self._LOCAL_CONFIG_FOLDER = os.path.dirname(self._LOCAL_CONFIG_PATH) - - # Append local config file - self.append(self._LOCAL_CONFIG_PATH) + # configfile.ConfigFileWithProfiles.__init__(self, _('Main profile')) + + # self._unsaved_profiles = [] + + # HOME_FOLDER = os.path.expanduser('~') + # DATA_FOLDER = '.local/share' + # CONFIG_FOLDER = '.config' + # BIT_FOLDER = 'backintime' + # self._DEFAULT_LOCAL_DATA_FOLDER = os.path.join(HOME_FOLDER, DATA_FOLDER, BIT_FOLDER) + # self._LOCAL_CONFIG_FOLDER = os.path.join(HOME_FOLDER, CONFIG_FOLDER, BIT_FOLDER) + # self._MOUNT_ROOT = os.path.join(DATA_FOLDER, BIT_FOLDER, 'mnt') + self._MOUNT_ROOT = bitbase.XDG_DATA_HOME / bitbase.BINARY_NAME_BASE / 'mnt' + self._MOUNT_ROOT = str(self._MOUNT_ROOT) + self._LOCAL_MOUNT_ROOT = self._MOUNT_ROOT + self._LOCAL_DATA_FOLDER = str(bitbase.BIT_DATA_HOME) + + # if data_path: + # # Deprecated: --share-path was removed + # self.DATA_FOLDER_ROOT = data_path + # self._LOCAL_DATA_FOLDER = os.path.join(data_path, DATA_FOLDER, BIT_FOLDER) + # self._LOCAL_MOUNT_ROOT = os.path.join(data_path, self._MOUNT_ROOT) + # else: + # self.DATA_FOLDER_ROOT = HOME_FOLDER + # self._LOCAL_DATA_FOLDER = self._DEFAULT_LOCAL_DATA_FOLDER + # self._LOCAL_MOUNT_ROOT = os.path.join(HOME_FOLDER, self._MOUNT_ROOT) + + # tools.makeDirs(self._LOCAL_CONFIG_FOLDER) + # tools.makeDirs(self._LOCAL_DATA_FOLDER) + # tools.makeDirs(self._LOCAL_MOUNT_ROOT) + + # self._DEFAULT_CONFIG_PATH = os.path.join( + # self._LOCAL_CONFIG_FOLDER, bitbase.FILENAME_CONFIG + # ) + + # if config_path is None: + # self._LOCAL_CONFIG_PATH = self._DEFAULT_CONFIG_PATH + # else: + # self._LOCAL_CONFIG_PATH = os.path.abspath(config_path) + # self._LOCAL_CONFIG_FOLDER = os.path.dirname(self._LOCAL_CONFIG_PATH) + + # # Append local config file + # self.append(self._LOCAL_CONFIG_PATH) self.current_hash_id = 'local' self.pw = None - self.forceUseChecksum = False + # self.forceUseChecksum = False self.setupUdev = tools.SetupUdev() + self.current_profile_id = None + language_used = tools.initiate_translation(self.language()) # Development note (2023-08 by buhtz): @@ -157,9 +165,12 @@ def __init__(self, config_path=None, data_path=None): """ISO-639 language code of the used language. See `tools._determine_current_used_language_code()` for details.""" - # Workaround + # Workaround: Maybe into bitbase? self.default_profile_name = _('Main profile') + # Workaround + self.setCurrentProfile('1') + self.SNAPSHOT_MODES = { # mode: ( # , @@ -196,44 +207,108 @@ def __init__(self, config_path=None, data_path=None): def the_dict(self) -> dict: """Workaround to access the raw dictionary defined in ConfigFile. See #1923""" - return self.dict + # return self.dict + return Konfig()._conf + + def currentProfile(self): + return self.current_profile_id + + def get_profile(self, profile_id): + """Workaround""" + real_konfig = Konfig() + return real_konfig.profile( + int(profile_id) if profile_id else self.current_profile_id + ) + + def setCurrentProfile(self, profile_id): + """ + Change the current profile. + + Args: + profile_id (str, int): valid profile ID + + Returns: + bool: ``True`` if successful + """ + if isinstance(profile_id, int): + profile_id = str(profile_id) + + if self.current_profile_id == profile_id: + return True + + p = Konfig().profile(int(profile_id)) + self.current_profile_id = p.profile_id + + logger.changeProfile(p.profile_id, p.name) + logger.info( + f'Profile switched: {p.name}({p.profile_id})', + self) + + return True + + def setCurrentProfileByName(self, name): + """ + Change the current profile by a given name. + + Args: + name (str): valid profile name + + Returns: + bool: ``True`` if successful + """ + + p = Konfig().profile(name) + return self.setCurrentProfile(p.profile_id) def get_diff_cmd_and_params(self, default_cmd, default_params): - cmd = self.the_dict().get('qt.diff.cmd', default_cmd) - params = self.the_dict().get('qt.diff.params', default_params) - return (cmd, params) + # cmd = self.the_dict().get('qt.diff.cmd', default_cmd) + # params = self.the_dict().get('qt.diff.params', default_params) + # return (cmd, params) + return Konfig().diff_cmd_and_params def set_diff_cmd_and_params(self, cmd, params): - self.the_dict()['qt.diff.cmd'] = cmd - self.the_dict()['qt.diff.params'] = params + # self.the_dict()['qt.diff.cmd'] = cmd + # self.the_dict()['qt.diff.params'] = params + Konfig().diff_cmd_and_params = (cmd, params) def save(self): - self._unsaved_profiles = [] - self.setIntValue('config.version', self.CONFIG_VERSION) - return super().save(self._LOCAL_CONFIG_PATH) + # self._unsaved_profiles = [] + # self.setIntValue('config.version', self.CONFIG_VERSION) + # return super().save(self._LOCAL_CONFIG_PATH) + Konfig().save() def is_profile_unsaved(self, profile_id: str) -> bool: - return profile_id in self._unsaved_profiles + return Konfig().is_profile_unsaved(int(profile_id)) def is_current_profile_unsaved(self) -> bool: return self.is_profile_unsaved(self.currentProfile()) def checkConfig(self, profile_id = None): + """Dev note (2026-06, buhtz): Would say this method + should go into its own class. e.g. CheckConfigAgent + Who executes it? Not only by "check-config" CLI command I think. + """ if profile_id: - profiles = [profile_id] + profiles = [self.get_profile(profile_id)] else: - profiles = self.profiles() + real_konfig = Konfig() + profiles = list(real_konfig.iter_profiles()) - for profile_id in profiles: - profile_name = self.profileName(profile_id) - snapshots_path = self.snapshotsPath(profile_id) - logger.debug(f'Check profile {profile_name}', self) + for one_profile in profiles: + logger.debug(f'Check {profile}') + profile_id = one_profile.profile_id - # check snapshots path - if not snapshots_path: + profile_name = one_profile.name + mount_manager = MountManager.create(self) + mount_path = mount_manager.path + # snapshots_path = one_profile.snapshots_path + + # check the backups mountpoint (formerly known as "snapshot_path") + if not mount_path: core_events.event_error.notify( '{}\n{}'.format( _('Profile: "{name}"').format(name=profile_name), + # Don't like this error message! _('Backup directory is not valid.') ) ) @@ -253,14 +328,17 @@ def checkConfig(self, profile_id = None): return False - snapshots_path2 = snapshots_path + '/' + # ??? + snapshots_path2 = str(mount_path) + if snapshots_path2[-1] != '/': + snapshots_path2 = snapshots_path2 + '/' for item in include_list: if item[1] != 0: continue path = item[0] - if path == snapshots_path: + if path == str(mount_path): core_events.event_error.notify( '{}\n{}\n{}'.format( _('Profile: "{name}"').format(name=profile_name), @@ -312,8 +390,8 @@ def checkConfig(self, profile_id = None): return True - def host(self): - return socket.gethostname() + # def host(self): + # return socket.gethostname() def get_snapshots_mountpoint(self, profile_id=None, mode=None, tmp_mount=False): """Return the profiles snapshot path in form of a mount point. @@ -375,57 +453,60 @@ def snapshotsFullPath(self, profile_id=None): def get_snapshots_path(self, profile_id): """Return the value of the snapshot path (backup destination) field.""" - return self.profileStrValue('snapshots.path', '', profile_id) + # return self.profileStrValue('snapshots.path', '', profile_id) + p = self.get_profile(profile_id) + return p.snapshots_path def set_snapshots_path(self, value, profile_id=None): """Sets the snapshot path to value.""" - if profile_id is None: - profile_id = self.currentProfile() + # if profile_id is None: + # profile_id = self.currentProfile() - self.setProfileStrValue('snapshots.path', value, profile_id) + # self.setProfileStrValue('snapshots.path', value, profile_id) + p = self.get_profile(profile_id) + p.snapshots_path = value def snapshotsMode(self, profile_id=None): #? Use mode (or backend) for this snapshot. Look at 'man backintime' #? section 'Modes'.;local|ssh|ssh_gocryptfs|local_gocryptfs - return self.profileStrValue('snapshots.mode', 'local', profile_id) + # return self.profileStrValue('snapshots.mode', 'local', profile_id) + return self.get_profile(profile_id).mode def setSnapshotsMode(self, value, profile_id = None): - self.setProfileStrValue('snapshots.mode', value, profile_id) - - def setCurrentHashId(self, hash_id): - self.current_hash_id = hash_id - - def hashCollision(self): - #?Internal value used to prevent hash collisions on mountpoints. Do not change this. - return self.intValue('global.hash_collision', 0) - - def incrementHashCollision(self): - value = self.hashCollision() + 1 - self.setIntValue('global.hash_collision', value) + p = self.get_profile(profile_id) + p.mode = value def systray(self) -> str: #?Color of systray icon.;auto,dark,light - return self.strValue('global.systray', 'auto') + # return self.strValue('global.systray', 'auto') + return Konfig().systray def set_systray(self, value: str) -> None: - self.setStrValue('global.systray', value) + # self.setStrValue('global.systray', value) + k = Konfig() + k.systray = value def language(self) -> str: #?Language code (ISO 639) used to translate the user interface. #?If empty the operating systems current local is used. If 'en' the #?translation is not active and the original English source strings #?are used. It is the same if the value is unknown. - return self.strValue('global.language', '') + # return self.strValue('global.language', '') + return Konfig().language def setLanguage(self, language: str): - self.setStrValue('global.language', language if language else '') + # self.setStrValue('global.language', language if language else '') + k = Konfig() + k.language = language # SSH def sshSnapshotsPath(self, profile_id = None): #?Snapshot path on remote host. If the path is relative (no leading '/') #?it will start from remote Users homedir. An empty path will be replaced #?with './'.;absolute or relative path - return self.profileStrValue('snapshots.ssh.path', '', profile_id) + # return self.profileStrValue('snapshots.ssh.path', '', profile_id) + p = self.get_profile(profile_id) + return p.ssh_snapshots_path def sshSnapshotsFullPath(self, profile_id = None): """ @@ -438,29 +519,39 @@ def sshSnapshotsFullPath(self, profile_id = None): return os.path.join(path, 'backintime', host, user, profile) def setSshSnapshotsPath(self, value, profile_id = None): - self.setProfileStrValue('snapshots.ssh.path', value, profile_id) - return True + # self.setProfileStrValue('snapshots.ssh.path', value, profile_id) + p = self.get_profile(profile_id) + p.ssh_snapshots_path = value def sshHost(self, profile_id = None): #?Remote host used for mode 'ssh' and 'ssh_gocryptfs'.;IP or domain address - return self.profileStrValue('snapshots.ssh.host', '', profile_id) + # return self.profileStrValue('snapshots.ssh.host', '', profile_id) + self.get_profile(profile_id).ssh_host def setSshHost(self, value, profile_id = None): - self.setProfileStrValue('snapshots.ssh.host', value, profile_id) + # self.setProfileStrValue('snapshots.ssh.host', value, profile_id) + p = self.get_profile(profile_id) + p.ssh_host = value def sshPort(self, profile_id = None): #?SSH Port on remote host.;0-65535 - return self.profileIntValue('snapshots.ssh.port', '22', profile_id) + # return self.profileIntValue('snapshots.ssh.port', '22', profile_id) + self.get_profile(profile_id).ssh_port def setSshPort(self, value, profile_id = None): - self.setProfileIntValue('snapshots.ssh.port', value, profile_id) + # self.setProfileIntValue('snapshots.ssh.port', value, profile_id) + p = self.get_profile(profile_id) + p.ssh_port = value def sshUser(self, profile_id = None): #?Remote SSH user;;local users name - return self.profileStrValue('snapshots.ssh.user', getpass.getuser(), profile_id) + # return self.profileStrValue('snapshots.ssh.user', getpass.getuser(), profile_id) + self.get_profile(profile_id).ssh_user def setSshUser(self, value, profile_id = None): - self.setProfileStrValue('snapshots.ssh.user', value, profile_id) + # self.setProfileStrValue('snapshots.ssh.user', value, profile_id) + p = self.get_profile(profile_id) + p.ssh_user = value def sshPrivateKeyFile(self, profile_id=None) -> None | bool | str: """The field can have three states: @@ -468,68 +559,100 @@ def sshPrivateKeyFile(self, profile_id=None) -> None | bool | str: 2. Field exist but is empty: Using keys is disabled. 3. Field has a path: """ - val = self.profileStrValue('snapshots.ssh.private_key_file', None, profile_id) + # val = self.profileStrValue('snapshots.ssh.private_key_file', None, profile_id) - # Using keys is disabled - if val == '': - return False + # # Using keys is disabled + # if val == '': + # return False - return val + # return val + p = self.get_profile(profile_id) + return p.ssh_private_key_file def sshPrivateKeyFile_enabled(self, profile_id=None): - return self.sshPrivateKeyFile(profile_id) is not False + # return self.sshPrivateKeyFile(profile_id) is not False + p = self.get_profile(profile_id) + return p.ssh_private_key_file is not None def setSshPrivateKeyFile(self, value, profile_id=None): - self.setProfileStrValue('snapshots.ssh.private_key_file', value, profile_id) + # self.setProfileStrValue('snapshots.ssh.private_key_file', value, profile_id) + p = self.get_profile(profile_id) + p.ssh_private_key_file = value def sshProxyHost(self, profile_id=None): #?Proxy host used to connect to remote host.;;IP or domain address - return self.profileStrValue('snapshots.ssh.proxy_host', '', profile_id) + # return self.profileStrValue('snapshots.ssh.proxy_host', '', profile_id) + p = self.get_profile(profile_id) + return p.ssh_proxy_host def setSshProxyHost(self, value, profile_id=None): - self.setProfileStrValue('snapshots.ssh.proxy_host', value, profile_id) + # self.setProfileStrValue('snapshots.ssh.proxy_host', value, profile_id) + p = self.get_profile(profile_id) + p.ssh_proxy_host = value def sshProxyPort(self, profile_id=None): #?Proxy host port used to connect to remote host.;0-65535 - return self.profileIntValue('snapshots.ssh.proxy_host_port', '22', profile_id) + # return self.profileIntValue('snapshots.ssh.proxy_host_port', '22', profile_id) + p = self.get_profile(profile_id) + return p.ssh_proxy_port def setSshProxyPort(self, value, profile_id = None): - self.setProfileIntValue('snapshots.ssh.proxy_host_port', value, profile_id) + # self.setProfileIntValue('snapshots.ssh.proxy_host_port', value, profile_id) + p = self.get_profile(profile_id) + p.ssh_proxy_port = value def sshProxyUser(self, profile_id=None): #?Remote SSH user;;the local users name - return self.profileStrValue('snapshots.ssh.proxy_user', getpass.getuser(), profile_id) + # return self.profileStrValue('snapshots.ssh.proxy_user', getpass.getuser(), profile_id) + p = self.get_profile(profile_id) + return p.ssh_proxy_user def setSshProxyUser(self, value, profile_id=None): - self.setProfileStrValue('snapshots.ssh.proxy_user', value, profile_id) + # self.setProfileStrValue('snapshots.ssh.proxy_user', value, profile_id) + p = self.get_profile(profile_id) + p.ssh_proxy_user = value def sshMaxArgLength(self, profile_id = None): #?Maximum command length of commands run on remote host. This can be tested #?for all ssh profiles in the configuration #?with 'python3 /usr/share/backintime/common/ssh_max_arg.py LENGTH'.\n #?0 = unlimited;0, >700 - value = self.profileIntValue('snapshots.ssh.max_arg_length', 0, profile_id) + + # See #2532 about sshMaxArgs() removal. + # value = self.profileIntValue('snapshots.ssh.max_arg_length', 0, profile_id) + p = self.get_profile(profile_id) + value = p.ssh_max_arg_length if value and value < 700: raise ValueError('SSH max arg length %s is too low to run commands' % value) return value def setSshMaxArgLength(self, value, profile_id = None): self.setProfileIntValue('snapshots.ssh.max_arg_length', value, profile_id) + p = self.get_profile(profile_id) + p.ssh_max_arg_length = value def sshCheckCommands(self, profile_id = None): #?Check if all commands (used during takeSnapshot) work like expected #?on the remote host. - return self.profileBoolValue('snapshots.ssh.check_commands', True, profile_id) + # return self.profileBoolValue('snapshots.ssh.check_commands', True, profile_id) + p = self.get_profile(profile_id) + return p.ssh_check_commands def setSshCheckCommands(self, value, profile_id = None): - self.setProfileBoolValue('snapshots.ssh.check_commands', value, profile_id) + # self.setProfileBoolValue('snapshots.ssh.check_commands', value, profile_id) + p = self.get_profile(profile_id) + p.ssh_check_commands = value def sshCheckPingHost(self, profile_id = None): #?Check if the remote host is available before trying to mount. - return self.profileBoolValue('snapshots.ssh.check_ping', True, profile_id) + # return self.profileBoolValue('snapshots.ssh.check_ping', True, profile_id) + p = self.get_profile(profile_id) + return p.ssh_check_ping_host def setSshCheckPingHost(self, value, profile_id = None): self.setProfileBoolValue('snapshots.ssh.check_ping', value, profile_id) + p = self.get_profile(profile_id) + p.ssh_check_ping_host = value def sshDefaultArgs(self, profile_id = None): """ @@ -641,35 +764,66 @@ def sshCommand(self, # gocryptfs def localGocryptfsPath(self, profile_id): #?Where to save snapshots in mode 'local_gocryptfs'.;absolute path - return self.profileStrValue('snapshots.local_gocryptfs.path', '', profile_id) + # return self.profileStrValue('snapshots.local_gocryptfs.path', '', profile_id) + p = self.get_profile(profile_id) + return p.local_gocryptfs_path def setLocalGocryptfsPath(self, value, profile_id): - self.setProfileStrValue('snapshots.local_gocryptfs.path', value, profile_id) + # self.setProfileStrValue('snapshots.local_gocryptfs.path', value, profile_id) + p = self.get_profile(profile_id) + p.local_gocryptfs_path = value + + @staticmethod + def _mode_not_profile(profile_id, mode): + # Why is there an extra "mode" argument in this methods signature? + # Doesn't the profile itself defines the mode? + if mode is None: + return + + p = self.get_profile(profile_id) + if p.mode != mode: + raise RuntimeError( + 'Unexpected situation. Open an issue and report ' + 'steps to reproduce the situation!' + ) def passwordSave(self, profile_id = None, mode = None): - if mode is None: - mode = self.snapshotsMode(profile_id) + # if mode is None: + # mode = self.snapshotsMode(profile_id) + Config._mode_not_profile(profile_id, mode) #?Save password to system keyring (gnome-keyring or kwallet). #? must be the same as \fIprofile.snapshots.mode\fR - return self.profileBoolValue('snapshots.%s.password.save' % mode, False, profile_id) + # return self.profileBoolValue('snapshots.%s.password.save' % mode, False, profile_id) + + p = self.get_profile(profile_id) + return p.password_save def setPasswordSave(self, value, profile_id = None, mode = None): - if mode is None: - mode = self.snapshotsMode(profile_id) - self.setProfileBoolValue('snapshots.%s.password.save' % mode, value, profile_id) + # if mode is None: + # mode = self.snapshotsMode(profile_id) + Config._mode_not_profile(profile_id, mode) + # self.setProfileBoolValue('snapshots.%s.password.save' % mode, value, profile_id) + p = self.get_profile(profile_id) + p.password_save = value def passwordUseCache(self, profile_id = None, mode = None): - if mode is None: - mode = self.snapshotsMode(profile_id) + # if mode is None: + # mode = self.snapshotsMode(profile_id) + Config._mode_not_profile(profile_id, mode) #?Cache password in RAM so it can be read by cronjobs. #?Security issue: root might be able to read that password, too. #? must be the same as \fIprofile.snapshots.mode\fR;;true - return self.profileBoolValue('snapshots.%s.password.use_cache' % mode, True, profile_id) + # return self.profileBoolValue('snapshots.%s.password.use_cache' % mode, True, profile_id) + p = self.get_profile(profile_id) + return p.password_use_cache def setPasswordUseCache(self, value, profile_id = None, mode = None): - if mode is None: - mode = self.snapshotsMode(profile_id) - self.setProfileBoolValue('snapshots.%s.password.use_cache' % mode, value, profile_id) + # if mode is None: + # mode = self.snapshotsMode(profile_id) + Config._mode_not_profile(profile_id, mode) + # self.setProfileBoolValue('snapshots.%s.password.use_cache' % mode, value, profile_id) + p = self.get_profile(profile_id) + p.password_use_cache = value def password(self, parent=None, @@ -677,18 +831,29 @@ def password(self, mode=None, pw_id=1, only_from_keyring=False): + """Dev note (2026-06, buhtz): This password stuff is an ugly mess. + I am working on it, hoping not to break something. + """ if self.pw is None: self.pw = password.Password(self) - if profile_id is None: - profile_id = self.currentProfile() + # if profile_id is None: + # profile_id = self.currentProfile() + p = self.get_profile(profile_id) - if mode is None: - mode = self.snapshotsMode(profile_id) + Config._mode_not_profile(profile_id, mode) + + # if mode is None: + # mode = self.snapshotsMode(profile_id) return self.pw.password( - parent, profile_id, mode, pw_id, only_from_keyring) + parent=parent, + profile_id=p.profile_id, + mode=p.mode, + pw_id=pw_id, + only_from_keyring=only_from_keyring + ) def get_encryption_password(self): """Dirty workaround, because the meaning of password1 and password2 @@ -726,6 +891,8 @@ def setPassword(self, password_value, profile_id=None, mode=None, pw_id=1): if profile_id is None: profile_id = self.currentProfile() + Config._mode_not_profile(profile_id, mode) + if mode is None: mode = self.snapshotsMode(profile_id) @@ -749,40 +916,55 @@ def keyringUserName(self, profile_id = None): profile_id = self.currentProfile() return 'profile_id_%s' % profile_id - def hostUserProfileDefault(self, profile_id=None): - host = socket.gethostname() - user = getpass.getuser() - profile = profile_id - if profile is None: - profile = self.currentProfile() + # def hostUserProfileDefault(self, profile_id=None): + # host = socket.gethostname() + # user = getpass.getuser() + # profile = profile_id + # if profile is None: + # profile = self.currentProfile() - return (host, user, profile) + # return (host, user, profile) def hostUserProfile(self, profile_id = None): - default_host, default_user, default_profile = self.hostUserProfileDefault(profile_id) - #?Set Host for snapshot path;;local hostname - host = self.profileStrValue('snapshots.path.host', default_host, profile_id) - - #?Set User for snapshot path;;local username - user = self.profileStrValue('snapshots.path.user', default_user, profile_id) - - #?Set Profile-ID for snapshot path;1-99999;current Profile-ID - profile = self.profileStrValue('snapshots.path.profile', default_profile, profile_id) - - return (host, user, profile) + # default_host, default_user, default_profile = self.hostUserProfileDefault(profile_id) + p = self.get_profile(profile_id) + # #?Set Host for snapshot path;;local hostname + # host = self.profileStrValue('snapshots.path.host', default_host, profile_id) + # #?Set User for snapshot path;;local username + # user = self.profileStrValue('snapshots.path.user', default_user, profile_id) + # #?Set Profile-ID for snapshot path;1-99999;current Profile-ID + # profile = self.profileStrValue('snapshots.path.profile', default_profile, profile_id) + + # return (host, user, profile) + return ( + p.snapshots_path_host, + p.snapshots_path_user, + p.snapshots_path_profile + ) def setHostUserProfile(self, host, user, profile, profile_id = None): - self.setProfileStrValue('snapshots.path.host', host, profile_id) - self.setProfileStrValue('snapshots.path.user', user, profile_id) - self.setProfileStrValue('snapshots.path.profile', profile, profile_id) + # self.setProfileStrValue('snapshots.path.host', host, profile_id) + # self.setProfileStrValue('snapshots.path.user', user, profile_id) + # self.setProfileStrValue('snapshots.path.profile', profile, profile_id) + p = self.get_profile(profile_id) + p.snapshots_path_host = host + p.snapshots_path_user = user + p.snapshots_path_profile = profile def include(self, profile_id=None): #?Include this file or folder. must be a counter starting with 1;absolute path:: #?Specify if \fIprofile.snapshots.include..value\fR is a folder (0) or a file (1).;0|1;0 - return self.profileListValue(key='snapshots.include', type_key=('str:value', 'int:type'), default=[], profile_id=profile_id) + # return self.profileListValue( + # key='snapshots.include', + # type_key=('str:value', 'int:type'), default=[], + # profile_id=profile_id + # ) + return self.get_profile(profile_id).include + def setInclude(self, values, profile_id = None): - self.setProfileListValue('snapshots.include', ('str:value', 'int:type'), values, profile_id) + # self.setProfileListValue('snapshots.include', ('str:value', 'int:type'), values, profile_id) + self.get_profile(profile_id).include = values def exclude(self, profile_id = None): """ @@ -790,14 +972,20 @@ def exclude(self, profile_id = None): """ #?Exclude this file or folder. must be a counter #?starting with 1;file, folder or pattern (relative or absolute) - return self.profileListValue('snapshots.exclude', 'str:value', [], profile_id) + # return self.profileListValue('snapshots.exclude', 'str:value', [], profile_id) + p = self.get_profile(profile_id) + return p.exclude def setExclude(self, values, profile_id = None): - self.setProfileListValue('snapshots.exclude', 'str:value', values, profile_id) + # self.setProfileListValue('snapshots.exclude', 'str:value', values, profile_id) + p = self.get_profile(profile_id) + p.exclude = values def excludeBySizeEnabled(self, profile_id = None): #?Enable exclude files by size. - return self.profileBoolValue('snapshots.exclude.bysize.enabled', False, profile_id) + # return self.profileBoolValue('snapshots.exclude.bysize.enabled', False, profile_id) + p = self.get_profile(profile_id) + return p.exclude_by_size_enabled def excludeBySize(self, profile_id = None): #?Exclude files bigger than value in MiB. @@ -805,15 +993,22 @@ def excludeBySize(self, profile_id = None): #?because for rsync this is a transfer option, not an exclude option. #?So big files that has been backed up before will remain in snapshots #?even if they had changed. - return self.profileIntValue('snapshots.exclude.bysize.value', 500, profile_id) + # return self.profileIntValue('snapshots.exclude.bysize.value', 500, profile_id) + p = self.get_profile(profile_id) + return p.exclude_by_size_enabled def setExcludeBySize(self, enabled, value, profile_id = None): - self.setProfileBoolValue('snapshots.exclude.bysize.enabled', enabled, profile_id) - self.setProfileIntValue('snapshots.exclude.bysize.value', value, profile_id) + # self.setProfileBoolValue('snapshots.exclude.bysize.enabled', enabled, profile_id) + # self.setProfileIntValue('snapshots.exclude.bysize.value', value, profile_id) + p = self.get_profile(profile_id) + p.exclude_by_size = value + p.exclude_by_size_enabled = enabled def tag(self, profile_id = None): #?!ignore this in manpage - return self.profileStrValue('snapshots.tag', str(random.randint(100, 999)), profile_id) + # return self.profileStrValue('snapshots.tag', str(random.randint(100, 999)), profile_id) + + return str(random.randint(100, 999)) def scheduleMode(self, profile_id = None): #?Which schedule used for crontab. The crontab entry will be @@ -825,26 +1020,38 @@ def scheduleMode(self, profile_id = None): #?25 = daily anacron\n27 = when drive get connected\n30 = every week\n #?40 = every month\n80 = every year #?;0|1|2|4|7|10|12|14|16|18|19|20|25|27|30|40|80;0 - value = self.profileIntValue('schedule.mode', Config.NONE.value, profile_id) - return bitbase.ScheduleMode(value) + # value = self.profileIntValue('schedule.mode', Config.NONE.value, profile_id) + # return bitbase.ScheduleMode(value) + p = self.get_profile(profile_id) + return p.schedule_mode def setScheduleMode(self, value, profile_id = None): if isinstance(value, bitbase.ScheduleMode): value = value.value - self.setProfileIntValue('schedule.mode', value, profile_id) + # self.setProfileIntValue('schedule.mode', value, profile_id) + p = self.get_profile(profile_id) + p.schedule_mode = value def schedule_offset(self, profile_id = None): - return self.profileIntValue('schedule.offset', Config.DEFAULT_OFFSET, profile_id) + # return self.profileIntValue('schedule.offset', Config.DEFAULT_OFFSET, profile_id) + p = self.get_profile(profile_id) + return p.schedule_offset def set_schedule_offset(self, value, profile_id = None): - self.setProfileIntValue('schedule.offset', value, profile_id) + # self.setProfileIntValue('schedule.offset', value, profile_id) + p = self.get_profile(profile_id) + p.schedule_offset = value def scheduleDebug(self, profile_id = None): #?Enable debug output to system log for schedule mode. return self.profileBoolValue('schedule.debug', False, profile_id) + p = self.get_profile(profile_id) + return p.schedule_debug def setScheduleDebug(self, value, profile_id = None): self.setProfileBoolValue('schedule.debug', value, profile_id) + p = self.get_profile(profile_id) + p.schedule_debug = value def scheduleTime(self, profile_id = None): #?Position-coded number with the format "hhmm" to specify the hour @@ -853,80 +1060,122 @@ def scheduleTime(self, profile_id = None): #?Only valid for #?\fIprofile.schedule.mode\fR = 20 (daily), 30 (weekly), #?40 (monthly) and 80 (yearly);0-2400 - return self.profileIntValue('schedule.time', 0, profile_id) + # return self.profileIntValue('schedule.time', 0, profile_id) + p = self.get_profile(profile_id) + return p.schedule_time def scheduleHourMinute(self, profile_id: str = None ) -> tuple[int, int]: the_time = self.scheduleTime(profile_id) - return (the_time // 100, the_time % 100) def setScheduleTime(self, value, profile_id = None): - self.setProfileIntValue('schedule.time', value, profile_id) + # self.setProfileIntValue('schedule.time', value, profile_id) + p = self.get_profile(profile_id) + p.schedule_time = value def scheduleDay(self, profile_id = None): #?Which day of month the cronjob should run? Only valid for #?\fIprofile.schedule.mode\fR >= 40;1-28 - return self.profileIntValue('schedule.day', 1, profile_id) + # return self.profileIntValue('schedule.day', 1, profile_id) + p = self.get_profile(profile_id) + return p.schedule_day def setScheduleDay(self, value, profile_id = None): - self.setProfileIntValue('schedule.day', value, profile_id) + # self.setProfileIntValue('schedule.day', value, profile_id) + p = self.get_profile(profile_id) + p.schedule_day = value def scheduleWeekday(self, profile_id = None): #?Which day of week the cronjob should run? Only valid for #?\fIprofile.schedule.mode\fR = 30;1 = monday \- 7 = sunday - return self.profileIntValue('schedule.weekday', 7, profile_id) + # return self.profileIntValue('schedule.weekday', 7, profile_id) + p = self.get_profile(profile_id) + return p.schedule_weekday def setScheduleWeekday(self, value, profile_id = None): - self.setProfileIntValue('schedule.weekday', value, profile_id) + # self.setProfileIntValue('schedule.weekday', value, profile_id) + p = self.get_profile(profile_id) + p.schedule_weekday = value def customBackupTime(self, profile_id = None): #?Custom hours for cronjob. Only valid for #?\fIprofile.schedule.mode\fR = 19 #?;comma separated int (8,12,18,23) or */3;8,12,18,23 - return self.profileStrValue('schedule.custom_time', '8,12,18,23', profile_id) + # return self.profileStrValue('schedule.custom_time', '8,12,18,23', profile_id) + p = self.get_profile(profile_id) + return p.custom_backup_time def setCustomBackupTime(self, value, profile_id = None): - self.setProfileStrValue('schedule.custom_time', value, profile_id) + # self.setProfileStrValue('schedule.custom_time', value, profile_id) + p = self.get_profile(profile_id) + p.custom_backup_time = value def scheduleRepeatedPeriod(self, profile_id = None): #?How many units to wait between new snapshots with anacron? Only valid #?for \fIprofile.schedule.mode\fR = 25|27 - return self.profileIntValue('schedule.repeatedly.period', 1, profile_id) + # return self.profileIntValue('schedule.repeatedly.period', 1, profile_id) + p = self.get_profile(profile_id) + return p.schedule_repeated_period def setScheduleRepeatedPeriod(self, value, profile_id = None): - self.setProfileIntValue('schedule.repeatedly.period', value, profile_id) + # self.setProfileIntValue('schedule.repeatedly.period', value, profile_id) + p = self.get_profile(profile_id) + p.schedule_repeated_period = value def scheduleRepeatedUnit(self, profile_id = None): #?Units to wait between new snapshots with anacron.\n #?10 = hours\n20 = days\n30 = weeks\n40 = months\n #?Only valid for \fIprofile.schedule.mode\fR = 25|27; #?10|20|30|40;20 - value = self.profileIntValue('schedule.repeatedly.unit', bitbase.TimeUnit.DAY.value, profile_id) - return bitbase.TimeUnit(value) + # value = self.profileIntValue('schedule.repeatedly.unit', bitbase.TimeUnit.DAY.value, profile_id) + p = self.get_profile(profile_id) + return bitbase.TimeUnit(p.schedule_repeated_unit) def setScheduleRepeatedUnit(self, value, profile_id = None): - if isinstance(value, bitbase.TimeUnit): - value = value.value - self.setProfileIntValue('schedule.repeatedly.unit', value, profile_id) + # if isinstance(value, bitbase.TimeUnit): + # value = value.value + if not isinstance(value, bitbase.TimeUnit): + value = bitbase.TimeUnit(value) + # self.setProfileIntValue('schedule.repeatedly.unit', value, profile_id) + p = self.get_profile(profile_id) + p.schedule_repeated_unit = value def removeOldSnapshots(self, profile_id = None): #?Remove all snapshots older than value + unit - return (self.profileBoolValue('snapshots.remove_old_snapshots.enabled', True, profile_id), - #?Snapshots older than this times units will be removed - self.profileIntValue('snapshots.remove_old_snapshots.value', 10, profile_id), - #?20 = days\n30 = weeks\n80 = years;20|30|80;80 - bitbase.TimeUnit(self.profileIntValue('snapshots.remove_old_snapshots.unit', bitbase.TimeUnit.YEAR, profile_id))) + # return ( + # self.profileBoolValue( + # 'snapshots.remove_old_snapshots.enabled', True, profile_id + # ), + # #?Snapshots older than this times units will be removed + # self.profileIntValue( + # 'snapshots.remove_old_snapshots.value', 10, profile_id + # ), + # #?20 = days\n30 = weeks\n80 = years;20|30|80;80 + # bitbase.TimeUnit(self.profileIntValue( + # 'snapshots.remove_old_snapshots.unit', + # bitbase.TimeUnit.YEAR, + # profile_id + # )) + # ) + p = self.get_profile(profile_id) + return ( + p.remove_old_snapshots_enabled, + p.remove_old_snapshots_value, + p.remove_old_snapshots_unit + ) - def keepOnlyOneSnapshot(self, profile_id = None): - #?NOT YET IMPLEMENTED. Remove all snapshots but one. - return self.profileBoolValue('snapshots.keep_only_one_snapshot.enabled', False, profile_id) + # def keepOnlyOneSnapshot(self, profile_id = None): + # #?NOT YET IMPLEMENTED. Remove all snapshots but one. + # return self.profileBoolValue('snapshots.keep_only_one_snapshot.enabled', False, profile_id) - def setKeepOnlyOneSnapshot(self, value, profile_id = None): - self.setProfileBoolValue('snapshots.keep_only_one_snapshot.enabled', value, profile_id) + # def setKeepOnlyOneSnapshot(self, value, profile_id = None): + # self.setProfileBoolValue('snapshots.keep_only_one_snapshot.enabled', value, profile_id) def removeOldSnapshotsEnabled(self, profile_id = None): - return self.profileBoolValue('snapshots.remove_old_snapshots.enabled', True, profile_id) + # return self.profileBoolValue('snapshots.remove_old_snapshots.enabled', True, profile_id) + p = self.get_profile(profile_id) + return p.remove_old_snapshots_enabled def removeOldSnapshotsDate(self, profile_id=None): enabled, value, unit = self.removeOldSnapshots(profile_id) @@ -936,92 +1185,131 @@ def removeOldSnapshotsDate(self, profile_id=None): return _remove_old_snapshots_date(value, unit) def setRemoveOldSnapshots(self, enabled, value, unit, profile_id = None): - self.setProfileBoolValue('snapshots.remove_old_snapshots.enabled', enabled, profile_id) - self.setProfileIntValue('snapshots.remove_old_snapshots.value', value, profile_id) - self.setProfileIntValue('snapshots.remove_old_snapshots.unit', unit, profile_id) + # self.setProfileBoolValue( + # 'snapshots.remove_old_snapshots.enabled', enabled, profile_id) + # self.setProfileIntValue('snapshots.remove_old_snapshots.value', value, profile_id) + # self.setProfileIntValue('snapshots.remove_old_snapshots.unit', unit, profile_id) + p = self.get_profile(profile_id) + p.remove_old_snapshots_enabled = enabled + p.remove_old_snapshots_value = value + p.remove_old_snapshots_unit = unit def warnFreeSpaceEnabled(self, profile_id=None): - value = self.profileIntValue('snapshots.warn_free_space.value', 0, profile_id) - return value > 0 + p = self.get_profile(profile_id) + return p.warn_free_space_enabled def warnFreeSpace(self, profile_id=None) -> StorageSize: - value = self.profileIntValue('snapshots.warn_free_space.value', 0, profile_id) - unit = self.profileIntValue('snapshots.warn_free_space.unit', SizeUnit.MIB, profile_id) - return StorageSize(value, SizeUnit(unit)) + p = self.get_profile(profile_id) + return p.warn_free_space def setWarnFreeSpaceDisabled(self, profile_id=None): - self.setWarnFreeSpace(value=StorageSize(0, SizeUnit.MIB), profile_id=profile_id) + p = self.get_profile(profile_id) + p.set_warn_free_space_disabled() def setWarnFreeSpace(self, value: StorageSize, profile_id=None): - self.setProfileIntValue('snapshots.warn_free_space.value', value.value(), profile_id) - self.setProfileIntValue('snapshots.warn_free_space.unit', value.unit.value, profile_id) + p = self.get_profile(profile_id) + p.warn_free_space = value def minFreeSpace(self, profile_id = None): #?Remove snapshots until \fIprofile.snapshots.min_free_space.value\fR #?free space is reached. + + # return ( + # self.profileBoolValue('snapshots.min_free_space.enabled', True, profile_id), + # #?Keep at least value + unit free space.;1-99999 + # self.profileIntValue('snapshots.min_free_space.value', 1, profile_id), + # #?10 = MB\n20 = GB;10|20;20 + # SizeUnit(self.profileIntValue('snapshots.min_free_space.unit', SizeUnit.GIB, profile_id)) + # ) + p = self.get_profile(profile_id) + size = p.min_free_space return ( - self.profileBoolValue('snapshots.min_free_space.enabled', True, profile_id), - #?Keep at least value + unit free space.;1-99999 - self.profileIntValue('snapshots.min_free_space.value', 1, profile_id), - #?10 = MB\n20 = GB;10|20;20 - SizeUnit(self.profileIntValue('snapshots.min_free_space.unit', SizeUnit.GIB, profile_id)) + p.min_free_space_enabled, + size.value(), + size.unit.value ) - def minFreeSpaceAsStorageSize(self, profile_id = None): - enabled, value, unit = self.minFreeSpace(profile_id) + def minFreeSpaceAsStorageSize(self, profile_id = None): + # enabled, value, unit = self.minFreeSpace(profile_id) + p = self.get_profile(profile_id) return ( - enabled, - StorageSize(value, SizeUnit(unit)) + p.min_free_space_enabled, + p.min_free_space ) def minFreeSpaceEnabled(self, profile_id = None): - return self.profileBoolValue('snapshots.min_free_space.enabled', False, profile_id) + # return self.profileBoolValue('snapshots.min_free_space.enabled', False, profile_id) + p = self.get_profile(profile_id) + return p.min_free_space_enabled def setMinFreeSpace(self, enabled, value, unit, profile_id = None): - self.setProfileBoolValue('snapshots.min_free_space.enabled', enabled, profile_id) - self.setProfileIntValue('snapshots.min_free_space.value', value, profile_id) - self.setProfileIntValue('snapshots.min_free_space.unit', unit, profile_id) + # self.setProfileBoolValue('snapshots.min_free_space.enabled', enabled, profile_id) + # self.setProfileIntValue('snapshots.min_free_space.value', value, profile_id) + # self.setProfileIntValue('snapshots.min_free_space.unit', unit, profile_id) + raise RuntimeError('Use setMinFreeSpaceWithStorageSize()') def setMinFreeSpaceWithStorageSize(self, enabled, value: StorageSize, profile_id = None): - self.setMinFreeSpace( - enabled=enabled, - value=value.value(), - unit=value.unit.value, - profile_id=profile_id - ) + # self.setMinFreeSpace( + # enabled=enabled, + # value=value.value(), + # unit=value.unit.value, + # profile_id=profile_id + # ) + p = self.get_profile(profile_id) + p.set_min_free_space_enabled(enabled) + p.min_free_space = value def minFreeInodes(self, profile_id = None): #?Keep at least value % free inodes.;1-15 - return self.profileIntValue('snapshots.min_free_inodes.value', 2, profile_id) + # return self.profileIntValue('snapshots.min_free_inodes.value', 2, profile_id) + p = self.get_profile(profile_id) + return p.min_free_inodes_value def minFreeInodesEnabled(self, profile_id = None): #?Remove snapshots until \fIprofile.snapshots.min_free_inodes.value\fR #?free inodes in % is reached. - return self.profileBoolValue('snapshots.min_free_inodes.enabled', False, profile_id) + # return self.profileBoolValue('snapshots.min_free_inodes.enabled', False, profile_id) + p = self.get_profile(profile_id) + return p.min_free_inodes_enabled def setMinFreeInodes(self, enabled, value, profile_id = None): - self.setProfileBoolValue('snapshots.min_free_inodes.enabled', enabled, profile_id) - self.setProfileIntValue('snapshots.min_free_inodes.value', value, profile_id) + # self.setProfileBoolValue('snapshots.min_free_inodes.enabled', enabled, profile_id) + # self.setProfileIntValue('snapshots.min_free_inodes.value', value, profile_id) + p = self.get_profile(profile_id) + p.min_free_inodes_enabled = enabled + p.min_free_inodes_value = value def dontRemoveNamedSnapshots(self, profile_id = None): #?Keep snapshots with names during smart_remove. - return self.profileBoolValue('snapshots.dont_remove_named_snapshots', True, profile_id) + # return self.profileBoolValue('snapshots.dont_remove_named_snapshots', True, profile_id) + p = self.get_profile(profile_id) + return p.dont_remove_named_snapshots def setDontRemoveNamedSnapshots(self, value, profile_id = None): - self.setProfileBoolValue('snapshots.dont_remove_named_snapshots', value, profile_id) + # self.setProfileBoolValue('snapshots.dont_remove_named_snapshots', value, profile_id) + p = self.get_profile(profile_id) + p.dont_remove_named_snapshots = value def smartRemove(self, profile_id = None): #?Run smart_remove to clean up old snapshots after a new snapshot was created. - return (self.profileBoolValue('snapshots.smart_remove', False, profile_id), - #?Keep all snapshots for X days. - self.profileIntValue('snapshots.smart_remove.keep_all', 2, profile_id), - #?Keep one snapshot per day for X days. - self.profileIntValue('snapshots.smart_remove.keep_one_per_day', 7, profile_id), - #?Keep one snapshot per week for X weeks. - self.profileIntValue('snapshots.smart_remove.keep_one_per_week', 4, profile_id), - #?Keep one snapshot per month for X month. - self.profileIntValue('snapshots.smart_remove.keep_one_per_month', 24, profile_id)) + # return (self.profileBoolValue('snapshots.smart_remove', False, profile_id), + # #?Keep all snapshots for X days. + # self.profileIntValue('snapshots.smart_remove.keep_all', 2, profile_id), + # #?Keep one snapshot per day for X days. + # self.profileIntValue('snapshots.smart_remove.keep_one_per_day', 7, profile_id), + # #?Keep one snapshot per week for X weeks. + # self.profileIntValue('snapshots.smart_remove.keep_one_per_week', 4, profile_id), + # #?Keep one snapshot per month for X month. + # self.profileIntValue('snapshots.smart_remove.keep_one_per_month', 24, profile_id)) + p = self.get_profile(profile_id) + return ( + p.smart_remove, + p.smart_remove_keep_all, + p.smart_remove_keep_one_per_day, + p.smart_remove_keep_one_per_week, + p.smart_remove_keep_one_per_month + ) def setSmartRemove(self, value, @@ -1030,192 +1318,291 @@ def setSmartRemove(self, keep_one_per_week, keep_one_per_month, profile_id = None): - self.setProfileBoolValue('snapshots.smart_remove', value, profile_id) - self.setProfileIntValue('snapshots.smart_remove.keep_all', keep_all, profile_id) - self.setProfileIntValue('snapshots.smart_remove.keep_one_per_day', keep_one_per_day, profile_id) - self.setProfileIntValue('snapshots.smart_remove.keep_one_per_week', keep_one_per_week, profile_id) - self.setProfileIntValue('snapshots.smart_remove.keep_one_per_month', keep_one_per_month, profile_id) + # self.setProfileBoolValue('snapshots.smart_remove', value, profile_id) + # self.setProfileIntValue('snapshots.smart_remove.keep_all', keep_all, profile_id) + # self.setProfileIntValue('snapshots.smart_remove.keep_one_per_day', keep_one_per_day, profile_id) + # self.setProfileIntValue('snapshots.smart_remove.keep_one_per_week', keep_one_per_week, profile_id) + # self.setProfileIntValue('snapshots.smart_remove.keep_one_per_month', keep_one_per_month, profile_id) + p = self.get_profile(profile_id) + p.smart_remove = value + p.smart_remove_keep_all = keep_all + p.smart_remove_keep_one_per_day = keep_one_per_day + p.smart_remove_keep_one_per_week = keep_one_per_week + p.smart_remove_keep_one_per_month = keep_one_per_month def smartRemoveRunRemoteInBackground(self, profile_id = None): #?If using mode SSH or SSH-encrypted, run smart_remove in background on remote machine - return self.profileBoolValue('snapshots.smart_remove.run_remote_in_background', False, profile_id) + # return self.profileBoolValue('snapshots.smart_remove.run_remote_in_background', False, profile_id) + p = self.get_profile(profile_id) + return p.smart_remove_run_remote_in_background def setSmartRemoveRunRemoteInBackground(self, value, profile_id = None): self.setProfileBoolValue('snapshots.smart_remove.run_remote_in_background', value, profile_id) + p = self.get_profile(profile_id) + p.smart_remove_run_remote_in_background = value def notify(self, profile_id = None): #?Display notifications (errors, warnings) through libnotify. - return self.profileBoolValue('snapshots.notify.enabled', True, profile_id) + # return self.profileBoolValue('snapshots.notify.enabled', True, profile_id) + p = self.get_profile(profile_id) + return p.notify def setNotify(self, value, profile_id = None): self.setProfileBoolValue('snapshots.notify.enabled', value, profile_id) + p = self.get_profile(profile_id) + p.notify = value def backupOnRestore(self, profile_id = None): #?Rename existing files before restore into FILE.backup.YYYYMMDD return self.profileBoolValue('snapshots.backup_on_restore.enabled', True, profile_id) + p = self.get_profile(profile_id) def setBackupOnRestore(self, value, profile_id = None): - self.setProfileBoolValue('snapshots.backup_on_restore.enabled', value, profile_id) + # self.setProfileBoolValue('snapshots.backup_on_restore.enabled', value, profile_id) + p = self.get_profile(profile_id) + p.backup_on_restore = value def niceOnCron(self, profile_id = None): #?Run cronjobs with 'nice \-n19'. This will give BackInTime the #?lowest CPU priority to not interrupt any other working process. - return self.profileBoolValue('snapshots.cron.nice', self.DEFAULT_RUN_NICE_FROM_CRON, profile_id) + # return self.profileBoolValue('snapshots.cron.nice', self.DEFAULT_RUN_NICE_FROM_CRON, profile_id) + p = self.get_profile(profile_id) + return p.ionice_on_cron def setNiceOnCron(self, value, profile_id = None): - self.setProfileBoolValue('snapshots.cron.nice', value, profile_id) + # self.setProfileBoolValue('snapshots.cron.nice', value, profile_id) + p = self.get_profile(profile_id) + p.ionice_on_cron = value def ioniceOnCron(self, profile_id = None): #?Run cronjobs with 'ionice \-c2 \-n7'. This will give BackInTime the #?lowest IO bandwidth priority to not interrupt any other working process. - return self.profileBoolValue('snapshots.cron.ionice', self.DEFAULT_RUN_IONICE_FROM_CRON, profile_id) + # return self.profileBoolValue('snapshots.cron.ionice', self.DEFAULT_RUN_IONICE_FROM_CRON, profile_id) + p = self.get_profile(profile_id) + return p.ionice_on_cron def setIoniceOnCron(self, value, profile_id = None): - self.setProfileBoolValue('snapshots.cron.ionice', value, profile_id) + # self.setProfileBoolValue('snapshots.cron.ionice', value, profile_id) + p = self.get_profile(profile_id) + p.ionice_on_cron = value def ioniceOnUser(self, profile_id = None): #?Run BackInTime with 'ionice \-c2 \-n7' when taking a manual snapshot. #?This will give BackInTime the lowest IO bandwidth priority to not #?interrupt any other working process. - return self.profileBoolValue('snapshots.user_backup.ionice', self.DEFAULT_RUN_IONICE_FROM_USER, profile_id) + # return self.profileBoolValue('snapshots.user_backup.ionice', self.DEFAULT_RUN_IONICE_FROM_USER, profile_id) + p = self.get_profile(profile_id) + return p.ionice_on_user def setIoniceOnUser(self, value, profile_id = None): - self.setProfileBoolValue('snapshots.user_backup.ionice', value, profile_id) + # self.setProfileBoolValue('snapshots.user_backup.ionice', value, profile_id) + p = self.get_profile(profile_id) + p.ionice_on_user = value def niceOnRemote(self, profile_id = None): #?Run rsync and other commands on remote host with 'nice \-n19' - return self.profileBoolValue('snapshots.ssh.nice', self.DEFAULT_RUN_NICE_ON_REMOTE, profile_id) + # return self.profileBoolValue('snapshots.ssh.nice', self.DEFAULT_RUN_NICE_ON_REMOTE, profile_id) + p = self.get_profile(profile_id) + return p.nice_on_remote def setNiceOnRemote(self, value, profile_id = None): - self.setProfileBoolValue('snapshots.ssh.nice', value, profile_id) + # self.setProfileBoolValue('snapshots.ssh.nice', value, profile_id) + p = self.get_profile(profile_id) + p.nice_on_remote = value def ioniceOnRemote(self, profile_id = None): #?Run rsync and other commands on remote host with 'ionice \-c2 \-n7' - return self.profileBoolValue('snapshots.ssh.ionice', self.DEFAULT_RUN_IONICE_ON_REMOTE, profile_id) + # return self.profileBoolValue('snapshots.ssh.ionice', self.DEFAULT_RUN_IONICE_ON_REMOTE, profile_id) + p = self.get_profile(profile_id) + return p.ionice_on_remote def setIoniceOnRemote(self, value, profile_id = None): - self.setProfileBoolValue('snapshots.ssh.ionice', value, profile_id) + # self.setProfileBoolValue('snapshots.ssh.ionice', value, profile_id) + p = self.get_profile(profile_id) + p.ionice_on_remote = value def nocacheOnLocal(self, profile_id = None): #?Run rsync on local machine with 'nocache'. #?This will prevent files from being cached in memory. - return self.profileBoolValue('snapshots.local.nocache', self.DEFAULT_RUN_NOCACHE_ON_LOCAL, profile_id) + # return self.profileBoolValue('snapshots.local.nocache', self.DEFAULT_RUN_NOCACHE_ON_LOCAL, profile_id) + p = self.get_profile(profile_id) + return p.nocache_on_local def setNocacheOnLocal(self, value, profile_id = None): - self.setProfileBoolValue('snapshots.local.nocache', value, profile_id) + # self.setProfileBoolValue('snapshots.local.nocache', value, profile_id) + p = self.get_profile(profile_id) + p.nocache_on_local = value def nocacheOnRemote(self, profile_id = None): #?Run rsync on remote host with 'nocache'. #?This will prevent files from being cached in memory. - return self.profileBoolValue('snapshots.ssh.nocache', self.DEFAULT_RUN_NOCACHE_ON_REMOTE, profile_id) + # return self.profileBoolValue('snapshots.ssh.nocache', self.DEFAULT_RUN_NOCACHE_ON_REMOTE, profile_id) + p = self.get_profile(profile_id) + return p.nocache_on_remote def setNocacheOnRemote(self, value, profile_id = None): - self.setProfileBoolValue('snapshots.ssh.nocache', value, profile_id) + # self.setProfileBoolValue('snapshots.ssh.nocache', value, profile_id) + p = self.get_profile(profile_id) + p.nocache_on_remote = value def redirectStdoutInCron(self, profile_id = None): #?redirect stdout to /dev/null in cronjobs - return self.profileBoolValue('snapshots.cron.redirect_stdout', self.DEFAULT_REDIRECT_STDOUT_IN_CRON, profile_id) + # return self.profileBoolValue('snapshots.cron.redirect_stdout', self.DEFAULT_REDIRECT_STDOUT_IN_CRON, profile_id) + p = self.get_profile(profile_id) + return p.redirect_stdout_in_cron def redirectStderrInCron(self, profile_id = None): #?redirect stderr to /dev/null in cronjobs;;self.DEFAULT_REDIRECT_STDERR_IN_CRON - if self.isConfigured(profile_id): - default = True - else: - default = self.DEFAULT_REDIRECT_STDERR_IN_CRON - return self.profileBoolValue('snapshots.cron.redirect_stderr', default, profile_id) + # if self.isConfigured(profile_id): + # default = True + # else: + # default = self.DEFAULT_REDIRECT_STDERR_IN_CRON + # return self.profileBoolValue('snapshots.cron.redirect_stderr', default, profile_id) + p = self.get_profile(profile_id) + return p.redirect_stderr_in_cron def setRedirectStdoutInCron(self, value, profile_id = None): - self.setProfileBoolValue('snapshots.cron.redirect_stdout', value, profile_id) + # self.setProfileBoolValue('snapshots.cron.redirect_stdout', value, profile_id) + p = self.get_profile(profile_id) + p.redirect_stdout_in_cron = value def setRedirectStderrInCron(self, value, profile_id = None): - self.setProfileBoolValue('snapshots.cron.redirect_stderr', value, profile_id) + # self.setProfileBoolValue('snapshots.cron.redirect_stderr', value, profile_id) + p = self.get_profile(profile_id) + p.redirect_stderr_in_cron = value def bwlimitEnabled(self, profile_id = None): #?Limit rsync bandwidth usage over network. Use this with mode SSH. #?For mode Local you should rather use ionice. return self.profileBoolValue('snapshots.bwlimit.enabled', False, profile_id) + p = self.get_profile(profile_id) + return p.bw_limit_enabled def bwlimit(self, profile_id = None): #?Bandwidth limit in KB/sec. - return self.profileIntValue('snapshots.bwlimit.value', 3000, profile_id) + # return self.profileIntValue('snapshots.bwlimit.value', 3000, profile_id) + p = self.get_profile(profile_id) + return p.bw_limit def setBwlimit(self, enabled, value, profile_id = None): - self.setProfileBoolValue('snapshots.bwlimit.enabled', enabled, profile_id) - self.setProfileIntValue('snapshots.bwlimit.value', value, profile_id) + # self.setProfileBoolValue('snapshots.bwlimit.enabled', enabled, profile_id) + # self.setProfileIntValue('snapshots.bwlimit.value', value, profile_id) + p = self.get_profile(profile_id) + p.bw_limit_enabled = enabled + p.bw_limit = value def noSnapshotOnBattery(self, profile_id = None): #?Don't take snapshots if the Computer runs on battery. - return self.profileBoolValue('snapshots.no_on_battery', False, profile_id) + # return self.profileBoolValue('snapshots.no_on_battery', False, profile_id) + p = self.get_profile(profile_id) + return p.no_backup_on_battery - def setNoSnapshotOnBattery(self, value, profile_id = None): - self.setProfileBoolValue('snapshots.no_on_battery', value, profile_id) + def setNoSnapshotOnBattery(self, value, profile_id=None): + # self.setProfileBoolValue('snapshots.no_on_battery', value, profile_id) + p = self.get_profile(profile_id) + p.no_backup_on_battery = value - def preserveAcl(self, profile_id = None): + # --- WEITER WEITER WEITER + def preserveAcl(self, profile_id=None): #?Preserve ACL. The source and destination systems must have #?compatible ACL entries for this option to work properly. - return self.profileBoolValue('snapshots.preserve_acl', False, profile_id) + # return self.profileBoolValue('snapshots.preserve_acl', False, profile_id) + p = self.get_profile(profile_id) + return p.preserve_acl def setPreserveAcl(self, value, profile_id = None): - return self.setProfileBoolValue('snapshots.preserve_acl', value, profile_id) + # return self.setProfileBoolValue('snapshots.preserve_acl', value, profile_id) + p = self.get_profile(profile_id) + p.preserve_acl = value def preserveXattr(self, profile_id = None): #?Preserve extended attributes (xattr). - return self.profileBoolValue('snapshots.preserve_xattr', False, profile_id) + # return self.profileBoolValue('snapshots.preserve_xattr', False, profile_id) + p = self.get_profile(profile_id) + return p.preserve_xattr def setPreserveXattr(self, value, profile_id = None): - return self.setProfileBoolValue('snapshots.preserve_xattr', value, profile_id) + # return self.setProfileBoolValue('snapshots.preserve_xattr', value, profile_id) + p = self.get_profile(profile_id) + p.preserve_xattr = value def copyUnsafeLinks(self, profile_id = None): #?This tells rsync to copy the referent of symbolic links that point #?outside the copied tree. Absolute symlinks are also treated like #?ordinary files. - return self.profileBoolValue('snapshots.copy_unsafe_links', False, profile_id) + # return self.profileBoolValue('snapshots.copy_unsafe_links', False, profile_id) + p = self.get_profile(profile_id) + return p.copy_unsafe_links def setCopyUnsafeLinks(self, value, profile_id = None): - return self.setProfileBoolValue('snapshots.copy_unsafe_links', value, profile_id) + # return self.setProfileBoolValue('snapshots.copy_unsafe_links', value, profile_id) + p = self.get_profile(profile_id) + p.copy_unsafe_links = value - def copyLinks(self, profile_id = None): + def copyLinks(self, profile_id=None): #?When symlinks are encountered, the item that they point to #?(the reference) is copied, rather than the symlink. - return self.profileBoolValue('snapshots.copy_links', False, profile_id) + # return self.profileBoolValue('snapshots.copy_links', False, profile_id) + p = self.get_profile(profile_id) + return p.copy_links def setCopyLinks(self, value, profile_id = None): - return self.setProfileBoolValue('snapshots.copy_links', value, profile_id) + # return self.setProfileBoolValue('snapshots.copy_links', value, profile_id) + p = self.get_profile(profile_id) + p.copy_links = value def oneFileSystem(self, profile_id = None): #?Use rsync's "--one-file-system" to avoid crossing filesystem #?boundaries when recursing. - return self.profileBoolValue('snapshots.one_file_system', False, profile_id) + # return self.profileBoolValue('snapshots.one_file_system', False, profile_id) + p = self.get_profile(profile_id) + return p.one_file_system def setOneFileSystem(self, value, profile_id = None): - return self.setProfileBoolValue('snapshots.one_file_system', value, profile_id) + # return self.setProfileBoolValue('snapshots.one_file_system', value, profile_id) + p = self.get_profile(profile_id) + p.one_file_system = value def rsyncOptionsEnabled(self, profile_id = None): #?Past additional options to rsync - return self.profileBoolValue('snapshots.rsync_options.enabled', False, profile_id) + # return self.profileBoolValue('snapshots.rsync_options.enabled', False, profile_id) + p = self.get_profile(profile_id) + return p.rsync_options_enabled def rsyncOptions(self, profile_id = None): #?rsync options. Options must be quoted e.g. \-\-exclude-from="/path/to/my exclude file" - return self.profileStrValue('snapshots.rsync_options.value', '', profile_id) + # return self.profileStrValue('snapshots.rsync_options.value', '', profile_id) + p = self.get_profile(profile_id) + return p.rsync_options def setRsyncOptions(self, enabled, value, profile_id = None): - self.setProfileBoolValue('snapshots.rsync_options.enabled', enabled, profile_id) - self.setProfileStrValue('snapshots.rsync_options.value', value, profile_id) + # self.setProfileBoolValue('snapshots.rsync_options.enabled', enabled, profile_id) + # self.setProfileStrValue('snapshots.rsync_options.value', value, profile_id) + p = self.get_profile(profile_id) + p.rsync_options = value + p.rsync_options_enabled = enabled def sshPrefixEnabled(self, profile_id = None): #?Add prefix to every command which run through SSH on remote host. - return self.profileBoolValue('snapshots.ssh.prefix.enabled', False, profile_id) + # return self.profileBoolValue('snapshots.ssh.prefix.enabled', False, profile_id) + p = self.get_profile(profile_id) + return p.ssh_prefix_enabled - def sshPrefix(self, profile_id = None): + def sshPrefix(self, profile_id=None): #?Prefix to run before every command on remote host. Variables need to be escaped with \\$FOO. #?This doesn't touch rsync. So to add a prefix for rsync use #?\fIprofile.snapshots.rsync_options.value\fR with #?--rsync-path="FOO=bar:\\$FOO /usr/bin/rsync" - return self.profileStrValue('snapshots.ssh.prefix.value', self.DEFAULT_SSH_PREFIX, profile_id) + # return self.profileStrValue('snapshots.ssh.prefix.value', self.DEFAULT_SSH_PREFIX, profile_id) + p = self.get_profile(profile_id) + return p.ssh_prefix def setSshPrefix(self, enabled, value, profile_id = None): - self.setProfileBoolValue('snapshots.ssh.prefix.enabled', enabled, profile_id) - self.setProfileStrValue('snapshots.ssh.prefix.value', value, profile_id) + # self.setProfileBoolValue('snapshots.ssh.prefix.enabled', enabled, profile_id) + # self.setProfileStrValue('snapshots.ssh.prefix.value', value, profile_id) + p = self.get_profile(profile_id) + p.ssh_prefix = value + p.ssh_prefix_enabled = enabled def sshPrefixCmd(self, profile_id=None, cmd_type=str): """Return the config value of sshPrefix if enabled. @@ -1240,41 +1627,61 @@ def sshPrefixCmd(self, profile_id=None, cmd_type=str): def continueOnErrors(self, profile_id = None): #?Continue on errors. This will keep incomplete snapshots rather than #?deleting and start over again. - return self.profileBoolValue('snapshots.continue_on_errors', True, profile_id) + # return self.profileBoolValue('snapshots.continue_on_errors', True, profile_id) + p = self.get_profile(profile_id) + return p.continue_on_errors def setContinueOnErrors(self, value, profile_id = None): - return self.setProfileBoolValue('snapshots.continue_on_errors', value, profile_id) + # return self.setProfileBoolValue('snapshots.continue_on_errors', value, profile_id) + p = self.get_profile(profile_id) + p.continue_on_errors = value def useChecksum(self, profile_id = None): #?Use checksum to detect changes rather than size + time. - return self.profileBoolValue('snapshots.use_checksum', False, profile_id) + # return self.profileBoolValue('snapshots.use_checksum', False, profile_id) + p = self.get_profile(profile_id) + return p.use_checksum def setUseChecksum(self, value, profile_id = None): - return self.setProfileBoolValue('snapshots.use_checksum', value, profile_id) + # return self.setProfileBoolValue('snapshots.use_checksum', value, profile_id) + p = self.get_profile(profile_id) + p.use_checksum = value def logLevel(self, profile_id = None): #?Log level used during takeSnapshot.\n1 = Error\n2 = Changes\n3 = Info;1-3 - return self.profileIntValue('snapshots.log_level', 3, profile_id) + # return self.profileIntValue('snapshots.log_level', 3, profile_id) + p = self.get_profile(profile_id) + return p.log_level def setLogLevel(self, value, profile_id = None): - return self.setProfileIntValue('snapshots.log_level', value, profile_id) + # return self.setProfileIntValue('snapshots.log_level', value, profile_id) + p = self.get_profile(profile_id) + p.log_level = value def takeSnapshotRegardlessOfChanges(self, profile_id = None): #?Create a new snapshot regardless if there were changes or not. - return self.profileBoolValue('snapshots.take_snapshot_regardless_of_changes', False, profile_id) + # return self.profileBoolValue('snapshots.take_snapshot_regardless_of_changes', False, profile_id) + p = self.get_profile(profile_id) + return p.take_snapshot_regardless_of_changes def setTakeSnapshotRegardlessOfChanges(self, value, profile_id = None): - return self.setProfileBoolValue('snapshots.take_snapshot_regardless_of_changes', value, profile_id) + # return self.setProfileBoolValue('snapshots.take_snapshot_regardless_of_changes', value, profile_id) + p = self.get_profile(profile_id) + p.take_snapshot_regardless_of_changes = value def globalFlock(self): #?Prevent multiple snapshots (from different profiles or users) to be run at the same time - return self.boolValue('global.use_flock', False) + # return self.boolValue('global.use_flock', False) + k = Konfig() + return k.global_flock def setGlobalFlock(self, value): - self.setBoolValue('global.use_flock', value) + # self.setBoolValue('global.use_flock', value) + k = Konfig() + k.global_flock = value - def appInstanceFile(self): - return os.path.join(self._LOCAL_DATA_FOLDER, 'app.lock') + # def appInstanceFile(self): + # return os.path.join(self._LOCAL_DATA_FOLDER, 'app.lock') def fileId(self, profile_id=None): if profile_id is None: @@ -1303,7 +1710,8 @@ def takeSnapshotInstanceFile(self, profile_id=None): "worker%s.lock" % self.fileId(profile_id)) def takeSnapshotUserCallback(self): - return os.path.join(self._LOCAL_CONFIG_FOLDER, "user-callback") + # return os.path.join(self._LOCAL_CONFIG_FOLDER, "user-callback") + return str(bitbase.CONFIG_FILE_PATH.parent / 'user-callback') def passwordCacheFolder(self): return os.path.join(self._LOCAL_DATA_FOLDER, "password_cache") @@ -1342,10 +1750,16 @@ def anacronJobIdentify(self, profile_id=None): return profile_id + '_' + profile_name.replace(' ', '_') def udevRulesPath(self): - return os.path.join('/etc/udev/rules.d', '99-backintime-%s.rules' % getpass.getuser()) + return os.path.join( + '/etc/udev/rules.d', + '99-backintime-%s.rules' % getpass.getuser() + ) - def restoreLogFile(self, profile_id = None): - return os.path.join(self._LOCAL_DATA_FOLDER, "restore_%s.log" % self.fileId(profile_id)) + def restoreLogFile(self, profile_id=None): + return os.path.join( + self._LOCAL_DATA_FOLDER, + "restore_%s.log" % self.fileId(profile_id) + ) def restoreInstanceFile(self, profile_id=None): return os.path.join( @@ -1358,11 +1772,15 @@ def lastSnapshotSymlink(self, profile_id = None): def isConfigured(self, profile_id=None) -> bool: """Checks if the program is configured. - It is assumed as configured if a snapshot path (backup destination) is + It is assumed as configured if a snapshot path (backup destination) and include files/directories (backup source) are given. + + Dev note (2026-06, buhtz): I don't see much value in this. Today the + GUI initiates enough check and validations before storing new or + modified profile. """ - if not profile_id: - profile_id = self.profiles()[0] + p = self.get_profile(profile_id) + profile_id = str(p.profile_id) path = self.snapshotsPath(profile_id) includes = self.include(profile_id) @@ -1573,7 +1991,7 @@ def _cron_cmd(self, profile_id): cmd += '--profile %s ' % profile_id # User defined path to config file - if not self._LOCAL_CONFIG_PATH is self._DEFAULT_CONFIG_PATH: + if self._LOCAL_CONFIG_PATH is not self._DEFAULT_CONFIG_PATH: cmd += '--config %s ' % self._LOCAL_CONFIG_PATH # Enable debug output @@ -1608,12 +2026,8 @@ def _cron_cmd(self, profile_id): return cmd def addProfile(self, name: str) -> str | None: - pid = super().addProfile(name) - - if pid: - self._unsaved_profiles.append(pid) - - return pid + p = Konfig().new_profile(name) + return str(p.profile_id) def _remove_old_snapshots_date(value, unit): diff --git a/common/guiapplicationinstance.py b/common/guiapplicationinstance.py index e404461c3..da9d7483f 100644 --- a/common/guiapplicationinstance.py +++ b/common/guiapplicationinstance.py @@ -10,14 +10,16 @@ # . import os import logger +import bitbase from applicationinstance import ApplicationInstance class GUIApplicationInstance(ApplicationInstance): """Handle one application instance mechanism. """ - def __init__(self, baseControlFile, raiseCmd=''): + def __init__(self, raiseCmd=''): """Specify the base for control files.""" + baseControlFile = str(bitbase.BIT_DATA_HOME / 'app.lock') self.raiseFile = baseControlFile + '.raise' self.raiseCmd = raiseCmd diff --git a/common/konfig.py b/common/konfig.py new file mode 100644 index 000000000..d6bfb8047 --- /dev/null +++ b/common/konfig.py @@ -0,0 +1,1524 @@ +# SPDX-FileCopyrightText: © 2024 Christian BUHTZ +# +# SPDX-License-Identifier: GPL-2.0-or-later +# +# This file is part of the program "Back In Time" which is released under GNU +# General Public License v2 (GPLv2). +# See file LICENSE or go to . +# pylint: disable=too-many-lines,anomalous-backslash-in-string +"""Configuration mangament. +""" +from __future__ import annotations +import configparser +import getpass +import socket +import re +from typing import Union, Any +from pathlib import Path +from io import StringIO, TextIOWrapper +import singleton +import logger +from storagesize import SizeUnit, StorageSize +from bitbase import TimeUnit, StorageSizeUnit, ScheduleMode + +# Workaround: Mostly relevant on TravisCI but not exclusively. +# While unittesting and without regular invocation of BIT the GNU gettext +# class-based API isn't setup yet. +# The bigger problem with config.py is that it do use translatable strings. +# Strings like this do not belong into a config file or its context. +try: + _('Warning') +except NameError: + def _(val): + return val + +# WORKAROUND +import tools # <- get rid of that import asap +if tools.checkCommand('meld'): + DIFF_CMD = 'meld' +elif tools.checkCommand('kompare'): + DIFF_CMD = 'kompare' +else: + DIFF_CMD = '' + + +class Profile: # pylint: disable=too-many-public-methods + """Manages access to profile-specific configuration data.""" + _DEFAULT_VALUES = { + 'name': _('Main profile'), # A workaround we will get rid of soon + 'snapshots.mode': 'local', + 'snapshots.path': '', + 'snapshots.path.host': socket.gethostname(), + 'snapshots.path.user': getpass.getuser(), + 'snapshots.ssh.port': 22, + 'snapshots.ssh.cipher': 'default', + 'snapshots.ssh.user': getpass.getuser(), + 'snapshots.ssh.private_key_file': None, + 'snapshots.ssh.max_arg_length': 0, + 'snapshots.ssh.check_commands': True, + 'snapshots.ssh.check_ping': True, + 'snapshots.local_gocryptfs.path': '', + # This is fragil. Why not 'snapshots.password.save' ? + 'snapshots.local.password.save': False, + 'snapshots.ssh.password.save': False, + 'snapshots.local_gocryptfs.password.save': False, + 'snapshots.ssh_gocryptfs.password.save': False, + # This is fragil. Why not 'snapshots.password.use_cache' ? + 'snapshots.local.password.use_cache': True, + 'snapshots.ssh.password.use_cache': True, + 'snapshots.local_gocryptfs.password.use_cache': True, + 'snapshots.ssh_gocryptfs.password.use_cache': True, + 'snapshots.include': [], + 'snapshots.exclude': [], + 'snapshots.exclude.bysize.enabled': False, + 'snapshots.exclude.bysize.value': 500, + 'schedule.mode': 0, + 'schedule.offset': 0, + 'schedule.debug': False, + 'schedule.time': 0, + 'schedule.day': 1, + 'schedule.weekday': 7, + 'schedule.custom_time': '8,12,18,23', + 'schedule.repeatedly.period': 1, + 'schedule.repeatedly.unit': TimeUnit.DAY, + 'snapshots.remove_old_snapshots.enabled': True, + 'snapshots.remove_old_snapshots.value': 10, + 'snapshots.remove_old_snapshots.unit': TimeUnit.YEAR, + 'snapshots.min_free_space.enabled': True, + 'snapshots.min_free_space.value': 1, + 'snapshots.min_free_space.unit': StorageSizeUnit.GB, + 'snapshots.min_free_inodes.enabled': True, + 'snapshots.min_free_inodes.value': 2, + 'snapshots.warn_free_space.value': 0, + 'snapshots.warn_free_space.unit': SizeUnit.MIB, + 'snapshots.dont_remove_named_snapshots': True, + 'snapshots.smart_remove': False, + 'snapshots.smart_remove.keep_all': 2, + 'snapshots.smart_remove.keep_one_per_day': 7, + 'snapshots.smart_remove.keep_one_per_week': 4, + 'snapshots.smart_remove.keep_one_per_month': 24, + 'snapshots.smart_remove.run_remote_in_background': False, + 'snapshots.notify.enabled': True, + 'snapshots.backup_on_restore.enabled': True, + 'snapshots.cron.nice': True, + 'snapshots.cron.ionice': True, + 'snapshots.user_backup.ionice': False, + 'snapshots.ssh.nice': False, + 'snapshots.ssh.ionice': False, + 'snapshots.local.nocache': False, + 'snapshots.ssh.nocache': False, + 'snapshots.cron.redirect_stdout': True, + 'snapshots.cron.redirect_stderr': False, + 'snapshots.bwlimit.enabled': False, + 'snapshots.bwlimit.value': 3000, + 'snapshots.no_on_battery': False, + 'snapshots.preserve_acl': False, + 'snapshots.preserve_xattr': False, + 'snapshots.copy_unsafe_links': False, + 'snapshots.copy_links': False, + 'snapshots.one_file_system': False, + 'snapshots.rsync_options.enabled': False, + 'snapshots.ssh.prefix.enabled': False, + # Config.DEFAULT_SSH_PREFIX + 'snapshots.ssh.prefix.value': 'PATH=/opt/bin:/opt/sbin:\\$PATH', + 'snapshots.continue_on_errors': True, + 'snapshots.use_checksum': False, + 'snapshots.log_level': 3, + 'snapshots.take_snapshot_regardless_of_changes': False, + } + + def __init__(self, profile_id: int, config: Konfig): + self._config = config + self._prefix = f'profile{profile_id}' + + def __getitem__(self, key: str) -> Any: + """Select a field of the profiles config by its name as a string and + return its value. + + For example `self['field.name']` will return the value of field + `profile.field.name`. + + Return: The value of the config field if present. Otherwise a default + value taken from `self._DEFAULT_VALUES`. + + Raises: + KeyError if the field or its default value is unknown. + """ + try: + return self._config[f'{self._prefix}.{key}'] + + except KeyError: + return self._DEFAULT_VALUES[key] + + def __setitem__(self, key: str, val: Any) -> None: + """Set the value of a field of the profiles config. + + For example `self['field.name'] = 7` will set the value `7` to the + field `profile.field.name`. + + Raises: + KeyError if the field is unknown. + """ + self._config[f'{self._prefix}.{key}'] = val + + def __delitem__(self, key: str) -> None: + del self._config[f'{self._prefix}.{key}'] + + @property + def name(self) -> str: + """The name of the profile""" + result = self['name'] + + # Workaround + if result == self._DEFAULT_VALUES['name']: + if self._prefix != 'profile1': + raise RuntimeError( + f'Unexpected situation. {result=} {self._prefix=}' + ) + + @property + def profile_id(self) -> int: + return int(self._prefix.replace('profile', '')) + + def remove(self): + """Remove the profile including all its keys and values. + """ + + my_keys = list(filter( + lambda key: key.startswith(self._prefix), + self._config.keys() + )) + + for key in my_keys: + del self._config[key] + + @property + def mode(self) -> str: + """Used mode (or backend) for this backup profile. Look at 'man + backintime' section 'Modes'. + + { + 'values': 'local|local_gocryptfs|ssh|ssh_gocryptfs', + 'default': 'local', + } + """ + return self['snapshots.mode'] + + @mode.setter + def mode(self, val: str) -> None: + self['snapshots.mode'] = val + + @property + def snapshots_path(self) -> str: + """Where to save snapshots in mode 'local'. This path must contain + a folderstructure like 'backintime///'. + + { + 'values': 'absolute path', + } + """ + return self['snapshots.path'] + + @snapshots_path.setter + def snapshots_path(self, path: str): + self['snapshots.path'] = path + + @property + def snapshots_path_host(self) -> str: + """Set Host for snapshot path. + + { 'values': 'local hostname' } + """ + return self['snapshots.path.host'] + + @snapshots_path_host.setter + def snapshots_path_host(self, value: str) -> None: + self['snapshots.path.host'] = value + + @property + def snapshots_path_user(self) -> str: + """Set User for snapshot path. + + { 'values': 'local username' } + """ + return self['snapshots.path.user'] + + @snapshots_path_user.setter + def snapshots_path_user(self, value: str) -> None: + self['snapshots.path.user'] = value + + @property + def snapshots_path_profile(self) -> str: + """Set Profile-ID for backup path + + { + 'values': '1-99999', + 'default': 'current Profile-ID' + } + """ + try: + return self['snapshots.path.profile'] + + except KeyError: + # Extract number from field prefix + # e.g. "profile1" -> "1" + return self.profile_id + + @snapshots_path_profile.setter + def snapshots_path_profile(self, value: str) -> None: + self['snapshots.path.profile'] = value + + @property + def ssh_snapshots_path(self) -> str: + """Snapshot path on remote host. If the path is relative (no + leading '/') it will start from remote Users homedir. An empty path + will be replaced with './'. + + { + 'values': 'absolute or relative path', + } + + """ + return self['snapshots.ssh.path'] + + @ssh_snapshots_path.setter + def ssh_snapshots_path(self, path): + self['snapshots.ssh.path'] = path + + @property + def ssh_host(self) -> str: + """Remote host used for mode 'ssh' and 'ssh_gocryptfs'. + + { + 'values': 'IP or domain address', + } + """ + return self['snapshots.ssh.host'] + + @ssh_host.setter + def ssh_host(self, value: str) -> None: + self['snapshots.ssh.host'] = value + + @property + def ssh_port(self) -> int: + """SSH Port on remote host. + + { + 'values': '0-65535', + 'default': 22, + } + """ + return self['snapshots.ssh.port'] + + @ssh_port.setter + def ssh_port(self, value: int) -> None: + self['snapshots.ssh.port'] = value + + @property + def ssh_user(self) -> str: + """Remote SSH user. + + { + 'default': 'local users name', + 'values': 'text', + } + """ + return self['snapshots.ssh.user'] + + @ssh_user.setter + def ssh_user(self, value: str) -> None: + self['snapshots.ssh.user'] = value + + @property + def ssh_private_key_file(self) -> Path: + """Private key file used for password-less authentication on remote + host. + + { + 'values': 'absolute path to private key file', + 'default': None, + 'type': 'str' + } + + """ + value = self['snapshots.ssh.private_key_file'] + if value: + return Path(value) + + return value + + @ssh_private_key_file.setter + def ssh_private_key_file(self, path: Path) -> None: + self['snapshots.ssh.private_key_file'] = str(path) if path else None + + @property + def ssh_proxy_host(self) -> str: + """Proxy host (or jump host) used to connect to remote host. + + { + 'values': 'IP or domain address', + } + """ + return self['snapshots.ssh.proxy_host'] + + @ssh_proxy_host.setter + def ssh_proxy_host(self, value: str) -> None: + self['snapshots.ssh.proxy_host'] = value + + @property + def ssh_proxy_port(self) -> int: + """Port of SSH proxy (jump) host used to connect to remote host. + + { + 'values': '0-65535', + 'default': 22, + } + """ + return self['snapshots.ssh.proxy_port'] + + @ssh_proxy_port.setter + def ssh_proxy_port(self, value: int) -> None: + self['snapshots.ssh.proxy_port'] = value + + @property + def ssh_proxy_user(self) -> str: + """SSH user at proxy (jump) host. + + { + 'default': 'local users name', + 'values': 'text', + } + """ + return self['snapshots.ssh.proxy_user'] + + @ssh_proxy_user.setter + def ssh_proxy_user(self, value: str) -> None: + self['snapshots.ssh.proxy_user'] = value + + @property + def ssh_max_arg_length(self) -> int: + """Maximum command length of commands run on remote host. This can + be tested for all ssh profiles in the configuration with 'python3 + /usr/share/backintime/common/sshMaxArg.py LENGTH'. The value '0' + means unlimited length. + + { + 'values': '0, >700', + } + """ + raise NotImplementedError('see org in Config') + # return self['snapshots.ssh.max_arg_length'] + + @ssh_max_arg_length.setter + def ssh_max_arg_length(self, length: int) -> None: + self['snapshots.ssh.max_arg_length'] = length + + @property + def ssh_check_commands(self) -> bool: + """Check if all commands (used during takeSnapshot) work like + expected on the remote host. + { 'values': 'true|false' } + """ + + # Deprecated. See issue #2509 + return self['snapshots.ssh.check_commands'] + + @ssh_check_commands.setter + def ssh_check_commands(self, value: bool) -> None: + self['snapshots.ssh.check_commands'] = value + + @property + def ssh_check_ping_host(self) -> bool: + """Check if the remote host is available before trying to mount. + { 'values': 'true|false' } + """ + return self['snapshots.ssh.check_ping'] + + @ssh_check_ping_host.setter + def ssh_check_ping_host(self, value: bool) -> None: + self['snapshots.ssh.check_ping'] = value + + @property + def local_gocryptfs_path(self) -> Path: + """Where to save snapshots in mode 'local_gocryptfs'. + + { 'values': 'absolute path' } + """ + return self['snapshots.local_gocryptfs.path'] + + @local_gocryptfs_path.setter + def local_gocryptfs_path(self, path: Path): + self['snapshots.local_gocryptfs.path'] = str(path) + + @property + def password_save(self) -> bool: + """Save password to system keyring (gnome-keyring or kwallet). + { 'values': 'true|false' } + """ + return self[f'snapshots.{self.mode}.password.save'] + + @password_save.setter + def password_save(self, value: bool) -> None: + self[f'snapshots.{self.mode}.password.save'] = value + + @property + def password_use_cache(self) -> None: + """Cache password in RAM so it can be read by cronjobs. + Security issue: root might be able to read that password, too. + { + 'values': 'true|false', + 'default': 'see #1855' + } + """ + return self[f'snapshots.{self.mode}.password.use_cache'] + + @password_use_cache.setter + def password_use_cache(self, value: bool) -> None: + self[f'snapshots.{self.mode}.password.use_cache'] = value + + def _generic_include_exclude_ids(self, inc_exc_str: str) -> tuple[int]: + """Return two list of numeric IDs used for include and exclude values. + + The config file does have lines like this: + + profile1.snapshots.include.1.values + profile1.snapshots.include.2.values + profile1.snapshots.include.3.values + ... + profile1.snapshots.include.8.values + + or + + profile1.snapshots.exclude.1.values + profile1.snapshots.exclude.2.values + profile1.snapshots.exclude.3.values + ... + profile1.snapshots.exclude.8.values + + The numerical value between (in this example 1, 2, 3, 8) is extracted + via regex. + + Return: + A two item tuple, first with include IDs and second with exclude. + """ + rex = re.compile(r'^' + + self._prefix + + r'.snapshots.' + + inc_exc_str + + r'.(\d+).value') + + ids = [] + + # Ugly, I know. Handling of in/exclude will be rewritten soon. So no + # need to fix this. + for item in self._config._conf: # pylint: disable=protected-access + try: + ids.append(int(rex.findall(item)[0])) + except IndexError: + pass + + return tuple(ids) + + def _get_include_ids(self) -> tuple[int]: + """List of numeric IDs used for include values.""" + + return self._generic_include_exclude_ids('include') + + def _get_exclude_ids(self) -> tuple[int]: + """List of numeric IDs used for exclude values.""" + return self._generic_include_exclude_ids('exclude') + + @property + def include(self) -> list[str, int]: # pylint: disable=C0116 + # Man page docu is added manually. See + # create-manpage-backintime-config.sh script. + + # ('name', 0|1) + result = [] + + for id_val in self._get_include_ids(): + result.append( + ( + self[f'snapshots.include.{id_val}.value'], + int(self[f'snapshots.include.{id_val}.type']) + ) + ) + + return result + + @include.setter + def include(self, values: list[str, int]) -> None: + # delete existing values + for id_val in self._get_include_ids(): + del self[f'snapshots.include.{id_val}.value'] + del self[f'snapshots.include.{id_val}.type'] + + for idx, val in enumerate(values, 1): + self[f'snapshots.include.{idx}.value'] = val[0] + self[f'snapshots.include.{idx}.type'] = str(val[1]) + + @property + def exclude(self) -> list[str]: # pylint: disable=C0116 + # Man page docu is added manually. See + # create-manpage-backintime-config.sh script. + result = [] + + for id_val in self._get_exclude_ids(): + result.append(self[f'snapshots.exclude.{id_val}.value']) + + return result + + @exclude.setter + def exclude(self, values: list[str]) -> None: + # delete existing values + for id_val in self._get_exclude_ids(): + del self[f'snapshots.exclude.{id_val}.value'] + + for idx, val in enumerate(values, 1): + self[f'snapshots.exclude.{idx}.value'] = val + + @property + def exclude_by_size_enabled(self) -> bool: + """Enable exclude files by size.""" + return self['snapshots.exclude.bysize.enabled'] + + @exclude_by_size_enabled.setter + def exclude_by_size_enabled(self, value: bool) -> None: + self['snapshots.exclude.bysize.enabled'] = value + + @property + def exclude_by_size(self) -> int: + """Exclude files bigger than value in MiB. With 'Full rsync mode' + disabled this will only affect new files because for rsync this is a + transfer option, not an exclude option. So big files that has been + backed up before will remain in snapshots even if they had changed. + """ + return self['snapshots.exclude.bysize.value'] + + @exclude_by_size.setter + def exclude_by_size(self, value: int): + self['snapshots.exclude.bysize.value'] = value + + @property + def schedule_mode(self) -> ScheduleMode: + """Which schedule used for crontab. The crontab entry will be + generated with 'backintime check-config'.\n + 0 = Disabled\n 1 = at every boot\n 2 = every 5 minute\n + 4 = every 10 minute\n 7 = every 30 minute\n10 = every hour\n + 12 = every 2 hours\n14 = every 4 hours\n16 = every 6 hours\n + 18 = every 12 hours\n19 = custom defined hours\n20 = every day\n + 25 = daily anacron\n27 = when drive get connected\n30 = every week\n + 40 = every month\n80 = every year + + { + 'values': '0|1|2|4|7|10|12|14|16|18|19|20|25|27|30|40|80' + } + """ + return ScheduleMode(self['schedule.mode']) + + @schedule_mode.setter + def schedule_mode(self, value: int) -> None: + self['schedule.mode'] = value + + @property + def schedule_offset(self) -> int: + """TODO""" + return self['schedule.offset'] + + @schedule_offset.setter + def schedule_offset(self, value: int) -> None: + self['schedule.offset'] = value + + @property + def schedule_debug(self) -> bool: + """Enable debug output to system log for schedule mode.""" + return self['schedule.debug'] + + @schedule_debug.setter + def schedule_debug(self, value: bool) -> None: + self['schedule.debug'] = value + + @property + def schedule_time(self) -> int: + """Position-coded number with the format "hhmm" to specify the hour + and minute the cronjob should start (eg. 2015 means a quarter + past 8pm). Leading zeros can be omitted (eg. 30 = 0030). + Only valid for \\fIprofile.schedule.mode\\fR = 20 (daily), + 30 (weekly), 40 (monthly) and 80 (yearly). + { 'values': '0-2400' } + """ + return self['schedule.time'] + + @schedule_time.setter + def schedule_time(self, value: int) -> None: + self['schedule.time'] = value + + @property + def schedule_day(self) -> int: + """Which day of month the cronjob should run? Only valid for + \\fIprofile.schedule.mode\\fR >= 40. + { 'values': '1-28' } + """ + return self['schedule.day'] + + @schedule_day.setter + def schedule_day(self, value: int) -> None: + self['schedule.day'] = value + + @property + def schedule_weekday(self) -> int: + """Which day of week the cronjob should run? Only valid for + \\fIprofile.schedule.mode\\fR = 30. + { 'values': '1 (monday) to 7 (sunday)' } + """ + return self['schedule.weekday'] + + @schedule_weekday.setter + def schedule_weekday(self, value: int) -> None: + self['schedule.weekday'] = value + + @property + def custom_backup_time(self) -> str: + """Custom hours for cronjob. Only valid for + \\fIprofile.schedule.mode\\fR = 19 + { 'values': 'comma separated int (8,12,18,23) or */3;8,12,18,23' } + """ + return self['schedule.custom_time'] + + @custom_backup_time.setter + def custom_backup_time(self, value: str) -> None: + self['schedule.custom_time'] = value + + @property + def schedule_repeated_period(self) -> int: + """How many units to wait between new snapshots with anacron? Only + valid for \\fIprofile.schedule.mode\\fR = 25|27. + """ + return self['schedule.repeatedly.period'] + + @schedule_repeated_period.setter + def schedule_repeated_period(self, value: int) -> None: + self['schedule.repeatedly.period'] = value + + @property + def schedule_repeated_unit(self) -> int: + """Units to wait between new snapshots with anacron.\n + 10 = hours\n20 = days\n30 = weeks\n40 = months\n + Only valid for \\fIprofile.schedule.mode\\fR = 25|27; + { 'values': '10|20|30|40' } + """ + return self['schedule.repeatedly.unit'] + + @schedule_repeated_unit.setter + def schedule_repeated_unit(self, value: int) -> None: + self['schedule.repeatedly.unit'] = value + + @property + def remove_old_snapshots_enabled(self) -> bool: + """Remove all snapshots older than value + unit. + """ + return self['snapshots.remove_old_snapshots.enabled'] + + @remove_old_snapshots_enabled.setter + def remove_old_snapshots_enabled(self, enabled: bool) -> None: + self['snapshots.remove_old_snapshots.enabled'] = enabled + + @property + def remove_old_snapshots_value(self) -> int: + """Snapshots older than this times units will be removed.""" + return self['snapshots.remove_old_snapshots.value'] + + @remove_old_snapshots_value.setter + def remove_old_snapshots_value(self, value: int) -> None: + self['snapshots.remove_old_snapshots.value'] = value + + @property + def remove_old_snapshots_unit(self) -> TimeUnit: + """Time unit to use to calculate removing of old snapshots. + 20 = days; 30 = weeks; 80 = years + { + 'values': '20|30|80' + } + """ + return self['snapshots.remove_old_snapshots.unit'] + + @remove_old_snapshots_unit.setter + def remove_old_snapshots_unit(self, unit: TimeUnit) -> None: + self['snapshots.remove_old_snapshots.unit'] = unit + + @property + def warn_free_space(self) -> StorageSize: + """TODO""" + value = self['snapshots.warn_free_space.value'] + unit = self['snapshots.warn_free_space.unit'] + return StorageSize(value, SizeUnit(unit)) + + @warn_free_space.setter + def warn_free_space(self, value: StorageSize) -> None: + self['snapshots.warn_free_space.value'] = value.value() + self['snapshots.warn_free_space.unit'] = value.unit.value + + @property + def warn_free_space_enabled(self) -> bool: + return self['snapshots.warn_free_space.value'] > 0 + + def set_warn_free_space_disabled(self) -> None: + self.warn_free_space = StorageSize(0, SizeUnit.MIB) + + @property + def min_free_space(self) -> StorageSize: + """Keep at least value + unit free space.""" + return StorageSize( + self['snapshots.min_free_space.value'], + self['snapshots.min_free_space.unit'] + ) + + @min_free_space.setter + def min_free_space(self, value: StorageSize) -> None: + self['snapshots.min_free_space.value'] = value.value() + self['snapshots.min_free_space.unit'] = value.unit.value + + @property + def min_free_space_enabled(self) -> bool: + """Remove snapshots until \\fIprofile.snapshots.min_free_space. + value\\fR free space is reached. + """ + return self['snapshots.min_free_space.enabled'] == 'true' + + def set_min_free_space_enabled(self, enable: bool): + value = 'true' if enable else 'false' + self['snapshots.min_free_space.enabled'] = value + + @property + def min_free_inodes_enabled(self) -> bool: + """Remove snapshots until + \\fIprofile.snapshots.min_free_inodes.value\\fR + free inodes in % is reached. + """ + return self['snapshots.min_free_inodes.enabled'] + + @min_free_inodes_enabled.setter + def min_free_inodes_enabled(self, enable: bool) -> None: + self['snapshots.min_free_inodes.enabled'] = enable + + @property + def min_free_inodes_value(self) -> int: + """Keep at least value % free inodes. + { 'values': '1-15' } + """ + return self['snapshots.min_free_inodes.value'] + + @min_free_inodes_value.setter + def min_free_inodes_value(self, value: int) -> None: + self['snapshots.min_free_inodes.value'] = value + + @property + def dont_remove_named_snapshots(self) -> bool: + """Keep snapshots with names during smart_remove.""" + return self['snapshots.dont_remove_named_snapshots'] + + @dont_remove_named_snapshots.setter + def dont_remove_named_snapshots(self, value: bool) -> None: + """Keep snapshots with names during smart_remove.""" + self['snapshots.dont_remove_named_snapshots'] = value + + @property + def keep_named_snapshots(self) -> bool: + """Keep snapshots with names during smart_remove.""" + return self.dont_remove_named_snapshots + + @keep_named_snapshots.setter + def keep_named_snapshots(self, value: bool) -> None: + self.dont_remove_named_snapshots = value + + @property + def smart_remove(self) -> bool: + """Run smart_remove to clean up old snapshots after a new snapshot was + created.""" + return self['snapshots.smart_remove'] + + @smart_remove.setter + def smart_remove(self, enable: bool) -> None: + self['snapshots.smart_remove'] = enable + + @property + def smart_remove_keep_all(self) -> int: + """Keep all snapshots for X days.""" + return self['snapshots.smart_remove.keep_all'] + + @smart_remove_keep_all.setter + def smart_remove_keep_all(self, days: int) -> None: + self['snapshots.smart_remove.keep_all'] = days + + @property + def smart_remove_keep_one_per_day(self) -> int: + """Keep one snapshot per day for X days.""" + return self['snapshots.smart_remove.keep_one_per_day'] + + @smart_remove_keep_one_per_day.setter + def smart_remove_keep_one_per_day(self, days: int) -> None: + self['snapshots.smart_remove.keep_one_per_day'] = days + + @property + def smart_remove_keep_one_per_week(self) -> int: + """Keep one snapshot per week for X weeks.""" + return self['snapshots.smart_remove.keep_one_per_week'] + + @smart_remove_keep_one_per_week.setter + def smart_remove_keep_one_per_week(self, weeks: int) -> None: + self['snapshots.smart_remove.keep_one_per_week'] = weeks + + @property + def smart_remove_keep_one_per_month(self) -> int: + """Keep one snapshot per month for X months.""" + return self['snapshots.smart_remove.keep_one_per_month'] + + @smart_remove_keep_one_per_month.setter + def smart_remove_keep_one_per_month(self, months: int) -> None: + self['snapshots.smart_remove.keep_one_per_month'] = months + + @property + def smart_remove_run_remote_in_background(self) -> bool: + """If using modes SSH or SSH-encrypted, run smart_remove in background + on remote machine""" + return self['snapshots.smart_remove.run_remote_in_background'] + + @smart_remove_run_remote_in_background.setter + def smart_remove_run_remote_in_background(self, enable: bool) -> None: + self['snapshots.smart_remove.run_remote_in_background'] = enable + + @property + def notify(self) -> bool: + """Display notifications (errors, warnings) through libnotify or DBUS. + """ + return self['snapshots.notify.enabled'] + + @notify.setter + def notify(self, enable: bool) -> None: + self['snapshots.notify.enabled'] = enable + + @property + def backup_on_restore(self) -> bool: + """Rename existing files before restore into FILE.backup.YYYYMMDD""" + return self['snapshots.backup_on_restore.enabled'] + + @backup_on_restore.setter + def backup_on_restore(self, enable: bool) -> None: + self['snapshots.backup_on_restore.enabled'] = enable + + @property + def nice_on_cron(self) -> bool: + """Run cronjobs with nice-Value 19. This will give Back In Time the + lowest CPU priority to not interrupt any other working process.""" + return self['snapshots.cron.nice'] + + @nice_on_cron.setter + def nice_on_cron(self, enable: bool) -> None: + self['snapshots.cron.nice'] = enable + + @property + def ionice_on_cron(self) -> bool: + """Run cronjobs with 'ionice' and class 2 and level 7. This will give + Back In Time the lowest IO bandwidth priority to not interrupt any + other working process. + """ + return self['snapshots.cron.ionice'] + + @ionice_on_cron.setter + def ionice_on_cron(self, enable: bool) -> None: + self['snapshots.cron.ionice'] = enable + + @property + def ionice_on_user(self) -> bool: + """Run Back In Time with 'ionice' and class 2 and level 7 when taking + a manual snapshot. This will give Back In Time the lowest IO bandwidth + priority to not interrupt any other working process. + """ + return self['snapshots.user_backup.ionice'] + + @ionice_on_user.setter + def ionice_on_user(self, enable: bool) -> None: + self['snapshots.user_backup.ionice'] = enable + + @property + def nice_on_remote(self) -> bool: + """Run rsync and other commands on remote host with 'nice' value 19.""" + return self['snapshots.ssh.nice'] + + @nice_on_remote.setter + def nice_on_remote(self, enable: bool) -> None: + self['snapshots.ssh.nice'] = enable + + @property + def ionice_on_remote(self) -> bool: + """Run rsync and other commands on remote host with + 'ionice' and class 2 and level 7.""" + return self['snapshots.ssh.ionice'] + + @ionice_on_remote.setter + def ionice_on_remote(self, enable: bool) -> None: + self['snapshots.ssh.ionice'] = enable + + @property + def nocache_on_local(self) -> bool: + """Run rsync on local machine with 'nocache'. + This will prevent files from being cached in memory.""" + return self['snapshots.local.nocache'] + + @nocache_on_local.setter + def nocache_on_local(self, enable: bool) -> None: + self['snapshots.local.nocache'] = enable + + @property + def nocache_on_remote(self) -> bool: + """Run rsync on remote host with 'nocache'. + This will prevent files from being cached in memory.""" + return self['snapshots.ssh.nocache'] + + @nocache_on_remote.setter + def nocache_on_remote(self, enable: bool) -> None: + self['snapshots.ssh.nocache'] = enable + + @property + def redirect_stdout_in_cron(self) -> bool: + """Redirect stdout to /dev/null in cronjobs.""" + return self['snapshots.cron.redirect_stdout'] + + @redirect_stdout_in_cron.setter + def redirect_stdout_in_cron(self, enable: bool) -> None: + self['snapshots.cron.redirect_stdout'] = enable + + @property + def redirect_stderr_in_cron(self) -> bool: + """Redirect stderr to /dev/null in cronjobs.""" + # Dev note (buhtz, 2024-09): Makes not much sense to me, to have a + # depended default value here. Can't find something helpful in the git + # logs about it. + # if self.isConfigured(profile_id): + # default = True + # else: + # default = self.DEFAULT_REDIRECT_STDERR_IN_CRON + return self['snapshots.cron.redirect_stderr'] + + @redirect_stderr_in_cron.setter + def redirect_stderr_in_cron(self, enable: bool) -> None: + self['snapshots.cron.redirect_stderr'] = enable + + @property + def bw_limit_enabled(self) -> bool: + """Limit rsync bandwidth usage over network. Use this with mode SSH. + For mode Local you should rather use ionice.""" + return self['snapshots.bwlimit.enabled'] + + @bw_limit_enabled.setter + def bw_limit_enabled(self, enable: bool) -> None: + self['snapshots.bwlimit.enabled'] = enable + + @property + def bw_limit(self) -> int: + """Bandwidth limit in KB/sec.""" + return self['snapshots.bwlimit.value'] + + @bw_limit.setter + def bw_limit(self, limit_kb_sec: int) -> None: + self['snapshots.bwlimit.value'] = limit_kb_sec + + @property + def no_backup_on_battery(self) -> bool: + """Don't take backups if the computer runs on battery.""" + return self['snapshots.no_on_battery'] + + @no_backup_on_battery.setter + def no_backup_on_battery(self, enable: bool) -> None: + self['snapshots.no_on_battery'] = enable + + @property + def preserve_acl(self) -> bool: + """Preserve Access Control Lists (ACL). The source and destination + systems must have compatible ACL entries for this option to work + properly. + """ + return self['snapshots.preserve_acl'] + + @preserve_acl.setter + def preserve_acl(self, preserve: bool) -> None: + self['snapshots.preserve_acl'] = preserve + + @property + def preserve_xattr(self) -> bool: + """Preserve extended attributes (xattr).""" + return self['snapshots.preserve_xattr'] + + @preserve_xattr.setter + def preserve_xattr(self, preserve: bool) -> None: + """Preserve extended attributes (xattr).""" + self['snapshots.preserve_xattr'] = preserve + + @property + def copy_unsafe_links(self) -> bool: + """This tells rsync to copy the referent of symbolic links that point + outside the copied tree. Absolute symlinks are also treated like + ordinary files.""" + return self['snapshots.copy_unsafe_links'] + + @copy_unsafe_links.setter + def copy_unsafe_links(self, enable: bool) -> None: + self['snapshots.copy_unsafe_links'] = enable + + @property + def copy_links(self) -> bool: + """When symlinks are encountered, the item that they point to (the + reference) is copied, rather than the symlink. + """ + return self['snapshots.copy_links'] + + @copy_links.setter + def copy_links(self, enable: bool) -> None: + self['snapshots.copy_links'] = enable + + @property + def one_file_system(self) -> bool: + """Use rsync's "--one-file-system" to avoid crossing filesystem + boundaries when recursing. + """ + return self['snapshots.one_file_system'] + + @one_file_system.setter + def one_file_system(self, enable: bool) -> None: + self['snapshots.one_file_system'] = enable + + @property + def rsync_options_enabled(self) -> bool: + """Past additional options to rsync""" + return self['snapshots.rsync_options.enabled'] + + @rsync_options_enabled.setter + def rsync_options_enabled(self, enable: bool) -> None: + self['snapshots.rsync_options.enabled'] = enable + + @property + def rsync_options(self) -> str: + """Rsync options. Options must be quoted.""" + return self['snapshots.rsync_options.value'] + + @rsync_options.setter + def rsync_options(self, options: str) -> None: + self['snapshots.rsync_options.value'] = options + + @property + def ssh_prefix_enabled(self) -> bool: + """Add prefix to every command which run through SSH on remote host.""" + return self['snapshots.ssh.prefix.enabled'] + + @ssh_prefix_enabled.setter + def ssh_prefix_enabled(self, enable: bool) -> None: + self['snapshots.ssh.prefix.enabled'] = enable + + @property + def ssh_prefix(self) -> str: + """Prefix to run before every command on remote host. Variables need to + be escaped with \\\\$FOO. This doesn't touch rsync. So to add a prefix + for rsync use \\fIprofile.snapshots.rsync_options.value\\fR with + --rsync-path="FOO=bar:\\\\$FOO /usr/bin/rsync" + """ + return self['snapshots.ssh.prefix.value'] + + @ssh_prefix.setter + def ssh_prefix(self, prefix: str) -> None: + self['snapshots.ssh.prefix.value'] = prefix + + @property + def continue_on_errors(self) -> bool: + """Continue on errors. This will keep incomplete snapshots rather than + deleting and start over again.""" + return self['snapshots.continue_on_errors'] + + @continue_on_errors.setter + def continue_on_errors(self, enable: bool) -> None: + self['snapshots.continue_on_errors'] = enable + + @property + def use_checksum(self) -> bool: + """Use checksum to detect changes rather than size + time.""" + return self['snapshots.use_checksum'] + + @use_checksum.setter + def use_checksum(self, enable: bool) -> None: + self['snapshots.use_checksum'] = enable + + @property + def log_level(self) -> int: + """Log level used during takeSnapshot.\n1 = Error\n2 = Changes\n3 = + Info. + { 'values': '1-3' } + """ + return self['snapshots.log_level'] + + @log_level.setter + def log_level(self, level: int) -> None: + self['snapshots.log_level'] = level + + @property + def take_snapshot_regardless_of_changes(self) -> bool: + """Create a new snapshot regardless if there were changes or not.""" + return self['snapshots.take_snapshot_regardless_of_changes'] + + @take_snapshot_regardless_of_changes.setter + def take_snapshot_regardless_of_changes(self, enable: bool) -> None: + self['snapshots.take_snapshot_regardless_of_changes'] = enable + + +class Konfig(metaclass=singleton.Singleton): + """Manage configuration data for Back In Time. + + Dev note: + + That class is a replacement for the `config.Config` class. + """ + + _DEFAULT_VALUES = { + 'global.language': '', + 'global.systray': 'auto', + 'global.use_flock': False, + 'internal.manual_starts_countdown': 10, + 'qt.diff.cmd': DIFF_CMD, + 'qt.diff.params': '%1 %2', + } + + _DEFAULT_SECTION = 'bit' + + def __init__(self): + """Constructor. + + Note: That method is executed only once because `Konfig` is a + singleton. + """ + self._conf = {} + self._profiles = {} + self._unsaved_profiles = [] + + def __getitem__(self, key: str) -> Any: + try: + return self._conf[key] + except KeyError: + return self._DEFAULT_VALUES[key] + + def __setitem__(self, key: str, val: Any) -> None: + self._conf[key] = val + + def __delitem__(self, key: str) -> None: + self._config_parser.remove_option(self._DEFAULT_SECTION, key) + + def profile(self, name_or_id: Union[str, int]) -> Profile: + """Return a `Profile` object related to the given name or id. + + Args: + name_or_id: A name or an numeric id of a backup profile. + + Raises: + KeyError: If no corresponding profile exists. + """ + if isinstance(name_or_id, int): + profile_id = name_or_id + + else: + profile_id = self._profiles[name_or_id] + + return Profile(profile_id=profile_id, config=self) + + def has_profile(self, name_or_id: Union[str, int]) -> bool: + """Check if the profile exists""" + try: + self.profile(name_or_id) + except KeyError: + return False + + return True + + @property + def profile_names(self) -> list[str]: + """List of profile names.""" + return list(self._profiles.keys()) + + @property + def profile_ids(self) -> list[int]: + """List of numerical profile ids.""" + return list(self._profiles.values()) + + def _profile_list(self) -> dict: + """Create a dictionary of profile names and ids. + + Example: :: .. + + { + 'simple': 2, + 'lcoal-gocrypt': 3, + 'Main profile': 1 + } + """ + # Names and IDs of profiles + # Extract all relevant lines of format 'profile*.name=*' + name_items = filter( + lambda val: + val[0].startswith('profile') and val[0].endswith('.name'), + self._conf.items() + ) + return { + name: int(pid.replace('profile', '').replace('.name', '')) + for pid, name in name_items + } + + def iter_profiles(self): + for pid in self.profile_ids: + yield Profile(profile_id=pid, config=self) + + def new_profile(self, name: str) -> Profile: + """Create and add a new profile and returns it. + + The name need to be unique, otherwise a ValueException is raised. + + Args: + name: The name of the profile. + + Returns: + The new created profile + + Raises: + ValueError if the name is not unique. + """ + if name in self.profile_names: + raise ValueError( + 'Profile name need to be unique, but "{name}" already exists.' + ) + + new_pid = self._next_free_id() + prefix = f'profile{new_pid}.' + + self[f'{prefix}name'] = name + + self._unsaved_profiles.append(new_pid) + + return self.profile(new_pid) + + def _next_free_id(self) -> int: + """Compute the next free profile id. + + Returns: + An unused and free id. + """ + + # Assumentions: Sorted, no negative values, no zero, no duplicates + pids = self.profile_ids + + try: + # contiguous sequence starting at 1? + if len(pids) == pids[-1]: + return pids[-1] + 1 + + # find the gap + free_pid = 1 + used = set(pids) + while free_pid in used: + free_pid = free_pid + 1 + + except IndexError: + return 1 + + # gap found + return free_pid + + def load(self, buffer_or_path: Union[Path, TextIOWrapper, StringIO]): + """Load configuration from file like object. + + Args: + buffer: A path object, an open text-file handle or a string + buffer ready to read. + """ + + self._config_parser = configparser.ConfigParser( + interpolation=None, + defaults={'profile1.name': _('Main profile')}) + + # raw content + if isinstance(buffer_or_path, Path): + try: + content = buffer_or_path.read_text(encoding='utf-8') + except FileNotFoundError: + logger.warning( + f'Config file not found: {buffer_or_path}, but ' + 'starting with empty config.' + ) + content = '' + else: + content = buffer_or_path.read() + + # Add section header to make it a real INI file + self._config_parser.read_string( + f'[{self._DEFAULT_SECTION}]\n{content}') + + # The one and only main section + self._conf = self._config_parser[self._DEFAULT_SECTION] + + self._profiles = self._profile_list() + + def save(self, buffer: TextIOWrapper): + """Store configuration to the config file.""" + + self._unsaved_profiles = [] + raise NotImplementedError('Prevent overwriting real config data.') + + # tmp_io_buffer = StringIO() + # self._config_parser.write(tmp_io_buffer) + # tmp_io_buffer.seek(0) + + # # Write to file without section header + # # Discard unwanted first line + # tmp_io_buffer.readline() + # handle.write(tmp_io_buffer.read()) + + def is_profile_unsaved(self, name_or_id: Union[str, int]) -> bool: + p = self.profile(name_or_id) + return p.profile_id in self._unsaved_profiles + + @property + def diff_cmd_and_params(self) -> tuple[str, str]: + """TODO""" + return (self['qt.diff.cmd'], self['qt.diff.params']) + + @diff_cmd_and_params.setter + def diff_cmd_and_params(self, value: tuple[str, str]) -> None: + self['qt.diff.cmd'] = value[0] + self['qt.diff.params'] = value[1] + + @property + def language(self) -> str: + """Language code (ISO 639) used to translate the user interface. If + empty the operating systems current local is used. If 'en' the + translation is not active and the original English source strings are + used. It is the same if the value is unknown. + { + 'values': 'ISO 639 language codes', + 'type': str + } + """ + return self['global.language'] + + @language.setter + def language(self, lang: str) -> None: + self['global.language'] = lang + + @property + def global_flock(self) -> bool: + """Prevent multiple snapshots (from different profiles or users) to be + run at the same time. + { + 'values': 'true|false', + 'default': 'false', + 'type': bool + } + """ + return self['global.use_flock'] + + @global_flock.setter + def global_flock(self, value: bool) -> None: + self['global.use_flock'] = value + + @property + def systray(self) -> str: + """Color of systray icon.;auto,dark,light + { + 'values': 'auto|dark|light', + 'default': 'auto', + 'type': str + } + """ + return self['global.systray'] + + @systray.setter + def systray(self, color_mode: str) -> None: + self['global.systray'] = color_mode + + @property + def manual_starts_countdown(self) -> int: # pylint: disable=C0116 + # Countdown value about how often the users started the Back In Time + # GUI. + + # It is an internal variable not meant to be used or manipulated be the + # users. At the end of the countown the + # :py:class:`ApproachTranslatorDialog` is presented to the user. + return self['internal.manual_starts_countdown'] + + def decrement_manual_starts_countdown(self): + """Counts down to -1. + + See `manual_starts_countdown()` for details. + """ + val = self.manual_starts_countdown + + if val > -1: + self['internal.manual_starts_countdown'] = val - 1 + + +if __name__ == '__main__': + # Empty in-memory config file + # k = Konfig(StringIO()) + + import inspect + from typing import get_type_hints + properties = inspect.getmembers( + Profile, + lambda obj: isinstance(obj, property) + ) + for name, p in properties: + type_return = get_type_hints(p.fget)['return'] + if p.fset: + hints = get_type_hints(p.fset) + param = list(inspect.signature(p.fset).parameters)[1] + print(p) + print(p.fset) + type_setter = hints[param] + else: + type_setter = None + + print(f'{name=} {p=}\n {type_setter=}\n {type_return=}\n') + + sys.exit() + k = Konfig() + print(k) + print(f'{k._conf=}') # pylint: disable=protected-access + print(f'{k._profiles=}') # pylint: disable=protected-access + + k.load(Path.home() / '.config' / 'backintime' / 'config') + sys.exit() + # Regular config file + import bitbase + cfp = bitbase.CONFIG_FILE_PATH + with cfp.open('r', encoding='utf-8') as handle: + k = Konfig() + k.load(handle) + + print(k) + print(f'{k._conf=}') # pylint: disable=protected-access + print(f'{k._profiles=}') # pylint: disable=protected-access + + print(f'{k.profile_names=}') + print(f'{k.profile_ids=}') + print(f'{k.hash_collision=}') + print(f'{k.language=}') + print(f'{k.global_flock=}') + + p = k.profile(2) + print(f'{p.snapshots_mode=}') + p.snapshots_mode = 'ssh' + print(f'{p.snapshots_mode=}') + print(f'{p.include=}') + + p = k.profile(8) + print(f'{p.include=}') + + p = k.profile(9) + print(f'{p.include=}') + print(f'{p.exclude=}') + + p.include = [('foo', 0), ('bar', 1)] + print(f'{p.include=}') diff --git a/common/password.py b/common/password.py index 1c02ad338..248f0dd27 100644 --- a/common/password.py +++ b/common/password.py @@ -18,7 +18,6 @@ from exceptions import Timeout - class Password_Cache(daemon.Daemon): """ Password_Cache get started on User login. It provides passwords for diff --git a/common/pluginmanager.py b/common/pluginmanager.py index be5addb06..201963134 100644 --- a/common/pluginmanager.py +++ b/common/pluginmanager.py @@ -273,6 +273,9 @@ def _load_plugin_from_file(self, file_name: str, snapshots: list): self.loadedPlugins.append(file_name) + except AttributeError as exc: + raise + except BaseException as exc: logger.critical(f'Failed to load plugin {file_name}: {exc=}', self) diff --git a/common/singleton.py b/common/singleton.py index 5f5bdb794..61b0d203b 100644 --- a/common/singleton.py +++ b/common/singleton.py @@ -17,7 +17,6 @@ # question as his inspiration. # # Original code adapted by Christian Buhtz. - """Flexible and pythonic singleton implementation. Support inheritance and multiple classes. Multilevel inheritance is diff --git a/common/snapshots.py b/common/snapshots.py index 49a1fad73..84e94f90c 100644 --- a/common/snapshots.py +++ b/common/snapshots.py @@ -26,6 +26,7 @@ import shutil import time import re +import socket from tempfile import TemporaryDirectory import config import logger @@ -462,6 +463,7 @@ def restore(self, paths, callback = None, restore_to = '', + force_checksum_use = False, delete = False, backup = True, only_new = False): @@ -514,7 +516,10 @@ def restore(self, info_dict = sid.load_from_info_file() cmd_prefix = tools.rsyncPrefix( - self.config, no_perms=False, use_mode=['ssh'] + self.config, + no_perms=False, + use_mode=['ssh'], + force_checksum_use=force_checksum_use ) cmd_prefix.extend(('-R', '-v')) @@ -815,7 +820,7 @@ def _can_backup(self, profile_id=None): return True # TODO Refactor: This functions is extremely difficult to understand. - def backup(self, force=False): + def backup(self, force=False, force_checksum_use=False): """Wrapper for :py:func:`takeSnapshot` which will prepare and clean up things for the main :py:func:`takeSnapshot` method. @@ -1024,7 +1029,8 @@ def backup(self, force=False): ret_val, ret_error = self.takeSnapshot( sid, now, - include_folders + include_folders, + force_checksum_use ) except: # TODO too broad exception @@ -1248,7 +1254,7 @@ def _backup_info_file(self, sid): info_dict = { 'backup': { 'date': sid.withoutTag, - 'machine': self.config.host(), + 'machine': socket.gethostname(), 'profile_id': self.config.currentProfile(), 'tag': sid.tag, 'user': getpass.getuser(), @@ -1342,7 +1348,7 @@ def collectPermission(self, fileinfo, path): group = self.groupName(info.st_gid).encode('utf-8', 'replace') fileinfo[path] = (mode, user, group) - def takeSnapshot(self, sid, now, include_folders): + def takeSnapshot(self, sid, now, include_folders, force_checksum_use): """This is the main backup routine. It will take a new snapshot and store permissions of included files @@ -1448,7 +1454,11 @@ def takeSnapshot(self, sid, now, include_folders): prev_sid = snapshots[0] # rsync prefix & suffix - rsync_prefix = tools.rsyncPrefix(self.config, no_perms=False) + rsync_prefix = tools.rsyncPrefix( + self.config, + no_perms=False, + force_checksum_use=force_checksum_use + ) if self.config.excludeBySizeEnabled(): rsync_prefix.append('--max-size=%sM' % self.config.excludeBySize()) @@ -1882,6 +1892,8 @@ def smartRemove(self, del_snapshots, log = None): if mode is `ssh` or `ssh_gocryptfs` and smart-remove in background is activated. + See #2532 about sshMaxArgs() removal. + Args: del_snapshots (list): list of :py:class:`SID` that should be removed log (method): callable method that will handle progress log @@ -1892,7 +1904,9 @@ def smartRemove(self, del_snapshots, log = None): if not log: log = lambda x: self.setTakeSnapshotMessage(0, x) - if self.config.snapshotsMode() in ['ssh', 'ssh_gocryptfs'] and self.config.smartRemoveRunRemoteInBackground(): + if (self.config.snapshotsMode() in ['ssh', 'ssh_gocryptfs'] + and self.config.smartRemoveRunRemoteInBackground()): + logger.info('[smart remove] remove snapshots in background: %s' % del_snapshots, self) @@ -1935,8 +1949,14 @@ def smartRemove(self, del_snapshots, log = None): cmds = [] for sid in del_snapshots: - remote = self.rsyncRemotePath(sid.path(use_mode = ['ssh', 'ssh_gocryptfs']), use_mode = [], quote = '\\\"') - rsync = ' '.join(tools.rsyncRemove(self.config, run_local = False)) + remote = self.rsyncRemotePath( + sid.path(use_mode=['ssh', 'ssh_gocryptfs']), + use_mode=[], + quote = '\\\"' + ) + rsync = ' '.join( + tools.rsyncRemove(self.config, run_local=False) + ) rsync += ' \\\"\\$TMP/\\\" {}; '.format(remote) s = 'test -e \\\"%s\\\" && (' %sid.path(use_mode = ['ssh', 'ssh_gocryptfs']) @@ -3500,8 +3520,7 @@ def get_backup_ids_and_paths(cfg: config.Config, return result - -if __name__ == '__main__': - config = config.Config() - snapshots = Snapshots(config) - snapshots.backup() +# if __name__ == '__main__': +# config = config.Config() +# snapshots = Snapshots(config) +# snapshots.backup() diff --git a/common/ssh_max_arg.py b/common/ssh_max_arg.py index 543f34908..4f7887d5e 100644 --- a/common/ssh_max_arg.py +++ b/common/ssh_max_arg.py @@ -34,6 +34,8 @@ def probe_max_ssh_command_size( The function calls itself recursively until it finds the maximum possible length. The offset ``size_offset`` is bisect in each try. + See #2532 about sshMaxArgs() removal. + Args: config: Back In Time config instance including the details about the current SSH snapshot profile. The current profile must use the SSH diff --git a/common/sshsetupvalidator.py b/common/sshsetupvalidator.py index a9e878bcc..faa668158 100644 --- a/common/sshsetupvalidator.py +++ b/common/sshsetupvalidator.py @@ -139,7 +139,7 @@ def run(self): # --- Check remote capabilities --- try: - if self.cfg.sshCheckCommands(): # See issue #2482 + if self.cfg.sshCheckCommands(): # Deprecated. See issue #2509 self._check_rsync_basic() self._check_rsync_hardlinks() self._check_remote_tools() diff --git a/common/test/test_konfig.py b/common/test/test_konfig.py new file mode 100644 index 000000000..c83ad2fe6 --- /dev/null +++ b/common/test/test_konfig.py @@ -0,0 +1,191 @@ +# SPDX-FileCopyrightText: © 2024 Christian BUHTZ +# +# SPDX-License-Identifier: GPL-2.0-or-later +# +# This file is part of the program "Back In Time" which is released under GNU +# General Public License v2 (GPLv2). +# See file LICENSE or go to . +import unittest +import configparser +import pyfakefs.fake_filesystem_unittest as pyfakefs_ut +from pathlib import Path +from io import StringIO +from konfig import Konfig + + +class General(unittest.TestCase): + """Konfig class""" + + def setUp(self): + Konfig._instances = {} + + def test_empty(self): + """Empty config file""" + sut = Konfig(StringIO('')) + + self.assertEqual( + dict(sut._conf.items()), + {'profile1.name': 'Main profile'} + ) + + def test_default_values(self): + """Default values and their types of fields if not present.""" + sut = Konfig(StringIO('')) + + self.assertEqual(sut.global_flock, False) + self.assertIsInstance(sut.global_flock, bool) + self.assertEqual(sut.language, '') + self.assertIsInstance(sut.language, str) + self.assertEqual(sut.hash_collision, 0) + self.assertIsInstance(sut.hash_collision, int) + + def test_no_interpolation(self): + """Interpolation should be turned off""" + try: + Konfig(StringIO('qt.diff.params=%6 %1 %2')) + except configparser.InterpolationSyntaxError as exc: + self.fail(f'InterpolationSyntaxError was raised. {exc}') + + +class Read(unittest.TestCase): + """Read a config file/object""" + + def setUp(self): + Konfig._instances = {} + + def test_from_memory_via_ctor(self): + """Config in memory""" + buffer = StringIO('global.language=xz') + sut = Konfig(buffer) + + self.assertEqual(sut.language, 'xz') + + def test_from_memory_via_load(self): + """Config in memory""" + sut = Konfig() + self.assertEqual(sut.language, '') + + buffer = StringIO('global.language=ab') + sut.load(buffer) + self.assertEqual(sut.language, 'ab') + + @pyfakefs_ut.patchfs + def test_from_file_via_ctor(self, fake_fs): + """Config in from file""" + fp = Path.cwd() / 'file' + with fp.open('w', encoding='utf-8') as handle: + handle.write('global.language=rt\n') + + with fp.open('r', encoding='utf-8') as handle: + sut = Konfig(handle) + self.assertEqual(sut.language, 'rt') + + @pyfakefs_ut.patchfs + def test_from_file_via_load(self, fake_fs): + """Config in from file""" + sut = Konfig() + self.assertEqual(sut.language, '') + + fp = Path.cwd() / 'filezwei' + with fp.open('w', encoding='utf-8') as handle: + handle.write('global.language=wq\n') + + with fp.open('r', encoding='utf-8') as handle: + sut.load(handle) + self.assertEqual(sut.language, 'wq') + + +class Profiles(unittest.TestCase): + """Konfig.Profile class""" + + def setUp(self): + Konfig._instances = {} + + def test_empty(self): + """Profile child objects""" + konf = Konfig(StringIO('')) + sut = konf.profile(1) + self.assertEqual(sut['name'], 'Main profile') + + def test_default_values(self): + """Default values and their types of fields if not present.""" + sut = Konfig(StringIO('')).profile(0) + + self.assertEqual(sut.ssh_check_commands, True) + self.assertIsInstance(sut.ssh_check_commands, bool) + self.assertEqual(sut.ssh_cipher, 'default') + self.assertIsInstance(sut.ssh_cipher, str) + self.assertEqual(sut.ssh_port, 22) + self.assertIsInstance(sut.ssh_port, int) + + +class IncExc(unittest.TestCase): + """About include and exclude fields""" + + def setUp(self): + Konfig._instances = {} + + def test_exclude_write(self): + """Write exclude fields""" + config = Konfig() + sut = config.profile(1) + + self.assertEqual(sut.exclude, []) + + sut.exclude = ['Worf', 'Garak'] + + self.assertEqual(sut.exclude, ['Worf', 'Garak']) + + def test_include_write(self): + """Write include fields""" + config = Konfig() + sut = config.profile(1) + + self.assertEqual(sut.include, []) + + sut.include = [ + ('/Cardassia/Prime', 0), + ('/Ferengi/Nar', 1), + ] + + self.assertEqual( + sut.include, + [ + ('/Cardassia/Prime', 0), + ('/Ferengi/Nar', 1), + ] + ) + + def test_include_read(self): + """Read include fields""" + config = Konfig(StringIO('\n'.join([ + 'profile1.snapshots.include.1.value=/foo/bar/folder', + 'profile1.snapshots.include.1.type=0', + 'profile1.snapshots.include.2.value=/foo/bar/file', + 'profile1.snapshots.include.2.type=1', + ]))) + sut = config.profile(1) + + self.assertEqual( + sut.include, + [ + ('/foo/bar/folder', 0), + ('/foo/bar/file', 1) + ] + ) + + def test_exclude_read(self): + """Read exclude fields""" + config = Konfig(StringIO('\n'.join([ + 'profile1.snapshots.exclude.2.value=/bar/foo/file', + 'profile1.snapshots.exclude.1.value=/bar/foo/folder', + ]))) + sut = config.profile(1) + + self.assertEqual( + sut.exclude, + [ + '/bar/foo/file', + '/bar/foo/folder', + ] + ) diff --git a/common/test/test_lint.py b/common/test/test_lint.py index 732654172..074aeb4b5 100644 --- a/common/test/test_lint.py +++ b/common/test/test_lint.py @@ -44,6 +44,7 @@ full_test_files = [_base_dir / fp for fp in ( 'askpass.py', 'bitbase.py', + 'konfig.py', 'bitlicense.py', # 'cliarguments.py', # 'clicommands.py', @@ -267,6 +268,7 @@ def test010_ruff_default_ruleset(self): @unittest.skipUnless(FLAKE8_AVAILABLE, BASE_REASON.format('flake8')) def test020_flake8_default_ruleset(self): """Flake8 in default mode.""" + cmd = [ 'flake8', f'--max-line-length={PEP8_MAX_LINE_LENGTH}', diff --git a/common/tools.py b/common/tools.py index 150a98cba..5ef45f022 100644 --- a/common/tools.py +++ b/common/tools.py @@ -1336,7 +1336,8 @@ def rsyncCaps() -> list[str]: def rsyncPrefix(config, no_perms: bool = True, use_mode: list[str] = ['ssh', 'ssh_gocryptfs'], - progress: bool = True) -> list[str]: + progress: bool = True, + force_checksum_use: bool = False) -> list[str]: """ Get rsync command and all args for creating a new snapshot. Args are based on current profile in ``config``. @@ -1380,7 +1381,7 @@ def rsyncPrefix(config, '-s' )) - if config.useChecksum() or config.forceUseChecksum: + if config.useChecksum() or force_checksum_use: cmd.append('--checksum') if config.copyUnsafeLinks(): diff --git a/create-manpage-backintime-config.py b/create-manpage-backintime-config.py index d1bcb05ab..b4e51c750 100755 --- a/create-manpage-backintime-config.py +++ b/create-manpage-backintime-config.py @@ -37,72 +37,61 @@ force_default and force_var. If there is no forced value it will chose the value based on the instance with `select_values` """ - -import re import os import sys +import re +import inspect +import subprocess +from pathlib import Path from time import strftime, gmtime +from typing import Any +# Workaround (see #1575) +sys.path.insert(0, str(Path.cwd() / 'common')) +import konfig +import version -PATH = os.path.join(os.getcwd(), 'common') +MAN = Path.cwd() / 'common' / 'man' / 'C' / 'backintime-config.1' + +# Extract multiline string between { and the latest } +REX_DICT_EXTRACT = re.compile(r'\{([\s\S]*)\}') +# Extract attribute name +REX_ATTR_NAME = re.compile(r"self(?:\._conf)?\[['\"](.*)['\"]\]") CONFIG = os.path.join(PATH, 'config.py') MAN = os.path.join(PATH, 'man/C/backintime-config.5') -with open(os.path.join(PATH, '../VERSION'), 'r') as f: - VERSION = f.read().strip('\n') - -SORT = True # True = sort by alphabet; False = sort by line numbering - -c_list = re.compile(r'.*?self\.(?!set)((?:profile)?)(List)Value ?\( ?[\'"](.*?)[\'"], ?((?:\(.*\)|[^,]*)), ?[\'"]?([^\'",\)]*)[\'"]?') -c = re.compile(r'.*?self\.(?!set)((?:profile)?)(.*?)Value ?\( ?[\'"](.*?)[\'"] ?(%?[^,]*?), ?[\'"]?([^\'",\)]*)[\'"]?') - -HEADER = r'''.TH backintime-config 5 "%s" "version %s" "USER COMMANDS" -.SH NAME -config \- BackInTime configuration files. -.SH SYNOPSIS -~/.config/backintime/config -.br -/etc/backintime/config -.SH DESCRIPTION -Back In Time was developed as pure GUI program and so most functions are only -usable with backintime-qt. But it is possible to use -Back In Time e.g. on a headless server. You have to create the configuration file -(~/.config/backintime/config) manually. Look inside /usr/share/doc/backintime\-common/examples/ for examples. -.PP -The configuration file has the following format: -.br -keyword=arguments -.PP -Arguments don't need to be quoted. All characters are allowed except '='. -.PP -Run 'backintime check-config' to verify the configfile, create the snapshot folder and crontab entries. -.SH POSSIBLE KEYWORDS -''' % (strftime('%b %Y', gmtime()), VERSION) - -FOOTER = r'''.SH SEE ALSO -backintime, backintime-qt, backintime-askpass. -.PP -Back In Time also has a website: https://github.com/bit-team/backintime -.SH AUTHOR -This manual page was written by BIT Team(). -''' - -INSTANCE = 'instance' -NAME = 'name' -VALUES = 'values' -DEFAULT = 'default' -COMMENT = 'comment' -REFERENCE = 'reference' -LINE = 'line' +# |--------------------------| +# | GNU Trof (groff) helpers | +# |--------------------------| + +def groff_section(section: str) -> str: + """Section header""" + return f'.SH {section}\n' + def groff_indented_paragraph(label: str, indent: int=6) -> str: """.IP - Indented Paragraph""" return f'.IP "{label}" {indent}' + def groff_italic(text: str) -> str: """\\fi - Italic""" return f'\\fI{text}\\fR' + +def groff_bold(text: str) -> str: + """Bold""" + return f'\\fB{text}\\fR' + + +def groff_bold_roman(text: str) -> str: + """The first part of the text is marked bold the rest is + roman/normal. + + Used to reference other man pages.""" + return f'.BR {text}\n' + + def groff_indented_block(text: str) -> str: """ .RS - Start indented block @@ -110,30 +99,87 @@ def groff_indented_block(text: str) -> str: """ return f'\n.RS\n{text}\n.RE\n' + def groff_linebreak() -> str: """.br - Line break""" return '.br\n' + def groff_paragraph_break() -> str: """.PP - Paragraph break""" return '.PP\n' +# |--------------------| +# | Content generation | +# |--------------------| + + +def header(): + stamp = strftime('%b %Y', gmtime()) + ver = version.__version__ + + content = ''.join([ + f'.TH backintime-config 1 "{stamp}" ' + f'"version {ver}" "USER COMMANDS"\n', + + groff_section('NAME'), + 'config \\- Back In Time configuration file.\n', + + groff_section('SYNOPSIS'), + '~/.config/backintime/config\n', + groff_linebreak(), + '/etc/backintime/config\n', -def output(instance='', name='', values='', default='', - comment='', reference='', line=0): + groff_section('DESCRIPTION'), + 'Back In Time was developed as pure GUI program and so most ' + 'functions are only usable with ', + groff_bold('backintime-qt'), + '. But it is possible to use Back In Time e.g. on a ' + 'headless server. You have to create the configuration file ' + '(~/.config/backintime/config) manually. Look inside ' + '/usr/share/doc/backintime\\-common/examples/ for examples.\n', + + groff_paragraph_break(), + 'The configuration file has the following format:\n', + groff_linebreak(), + 'keyword=arguments\n', + + groff_paragraph_break(), + "Arguments don't need to be quoted. All characters are " + "allowed except '='.\n", + + groff_paragraph_break(), + "Run 'backintime check-config' to verify the configfile, " + "create the snapshot folder and crontab entries.\n", + + groff_section('POSSIBLE KEYWORDS'), + ]) + + return content + + +def entry_to_groff(name: str, + doc: str, + values: Any, + default: Any, + its_type: type) -> None: """Generate GNU Troff (groff) markup code for the given config entry.""" - if not default: - default = "''" - ret = f'Type: {instance.lower():<10}Allowed Values: {values}\n' + if its_type is not None: + if isinstance(its_type, str): + type_name = its_type + else: + type_name = its_type.__name__ + else: + type_name = '' + + ret = f'Type: {type_name:<10}Allowed Values: {values}\n' ret += groff_linebreak() - ret += f'{comment}\n' + ret += f'{doc}\n' ret += groff_paragraph_break() - if SORT: + if default is not None: ret += f'Default: {default}' - else: - ret += f'Default: {default:<18} {reference} line: {line}' ret = groff_indented_block(ret) ret = groff_indented_paragraph(groff_italic(name)) + ret @@ -141,261 +187,309 @@ def output(instance='', name='', values='', default='', return ret -def select(a, b): - if a: - return a +def footer() -> str: + content = groff_section('SEE ALSO') + content += groff_bold_roman('backintime (1),') + content += groff_bold_roman('backintime-qt (1)') + content += groff_paragraph_break() + content += 'Back In Time also has a website: ' \ + 'https://github.com/bit-team/backintime\n' - return b + content += groff_section('AUTHOR') + content += 'This manual page was written by the ' \ + 'Back In Time Team ().' + return content -def select_values(instance, values): - if values: - return values +# |------| +# | Misc | +# |------| - return { - 'bool': 'true|false', - 'str': 'text', - 'int': '0-99999' - }[instance.lower()] +def _get_public_properties(cls: type) -> tuple: + """Extract the public properties from our target config class.""" + def _is_public_property(val): + return ( + not val.startswith('_') + and isinstance(getattr(cls, val), property) + ) -def process_line(d, key, profile, instance, name, var, default, commentline, - values, force_var, force_default, replace_default, counter): - """Parsing the config.py Python code""" - # Ignore commentlines with #?! and 'config.version' - comment = None + return tuple(filter(_is_public_property, cls.__dict__.keys())) - if not commentline.startswith('!') and key not in d: - d[key] = {} - commentline = commentline.split(';') - try: - comment = commentline[0] - values = commentline[1] - force_default = commentline[2] - force_var = commentline[3] +def lint_manpage(path: Path) -> bool: + """Lint the manpage the same way as the Debian Lintian does.""" - except IndexError: - pass + print('Linting man page…') - if default.startswith('self.') and default[5:] in replace_default: - default = replace_default[default[5:]] + cmd = [ + 'man', + '--warnings', + '-E', + 'UTF-8', + '-l', + '-Tutf8', + '-Z', + str(path) + ] - if isinstance(force_default, str) \ - and force_default.startswith('self.') \ - and force_default[5:] in replace_default: - force_default = replace_default[force_default[5:]] + env = dict( + **os.environ, + LC_ALL='C.UTF-8', + # MANROFFSEQ="''", + MANWIDTH='80', + ) - if instance.lower() == 'bool': - default = default.lower() + try: + with open('/dev/null', 'w') as devnull: + result = subprocess.run( + cmd, + env=env, + check=True, + text=True, + stdout=devnull, + stderr=subprocess.PIPE + ) - d[key][INSTANCE] = instance - d[key][NAME] = re.sub( - r'%[\S]', '<%s>' % select(force_var, var).upper(), name - ) - d[key][VALUES] = select_values(instance, values) - d[key][DEFAULT] = select(force_default, default) - d[key][COMMENT] = re.sub(r'\\n', '\n.br\n', comment) - d[key][REFERENCE] = 'config.py' - d[key][LINE] = counter + except subprocess.CalledProcessError as exc: + raise RuntimeError(f'Unexpected error: {exc.stderr=}') from exc + # Report warnings + if result.stderr: + print(result.stderr) + return False -def main(): - d = { - 'profiles.version': { - INSTANCE: 'int', - NAME: 'profiles.version', - VALUES: '1', - DEFAULT: '1', - COMMENT: 'Internal version of profiles config.', - REFERENCE: 'configfile.py', - LINE: 419 - }, - 'profiles': { - INSTANCE: 'str', - NAME: 'profiles', - VALUES: 'int separated by colon (e.g. 1:3:4)', - DEFAULT: '1', - COMMENT: 'All active Profiles ( in profile.snapshots...).', - REFERENCE: 'configfile.py', - LINE: 472 - }, - 'profile.name': { - INSTANCE: 'str', - NAME: 'profile.name', - VALUES: 'text', - DEFAULT: 'Main profile', - COMMENT: 'Name of this profile.', - REFERENCE: 'configfile.py', - LINE: 704 + print('No problems reported.') + + return True + + +def inspect_properties(cls: type, name_prefix: str = '') -> dict[str, dict]: + """Collect details about propiertes of the class `cls`. + + All public properties containing a doc string are considered. + Some values can be specified with a dictionary contained in the doc string + but don't have to, except the 'values' field containing the allowed values. + The docstring is used as description ('doc'). The type annotation of the + return value is used as 'type'. The name of the config field is extracted + from the code body of the property method. + + Example of a result :: + + { + 'global.hash_collision': + { + 'values': '0-99999', + 'default': 0, + 'doc': 'Internal value ...', + 'type': 'int' + }, } - } - # Default variables and there values collected from config.py - replace_default = {} - - # Variables named "CONFIG_VERSION" or with names starting with "DEFAULT" - regex_default = re.compile(r'(^DEFAULT[\w]*|CONFIG_VERSION)[\s]*= (.*)') - - with open(CONFIG, 'r') as f: - print(f'Read and parse "{CONFIG}".') - commentline = '' - values = force_var = force_default = instance \ - = name = var = default = None - - for counter, line in enumerate(f, 1): - line = line.lstrip() - - # parse for DEFAULT variable - m_default = regex_default.match(line) - - # DEFAULT variable - if m_default: - replace_default[m_default.group(1)] \ - = m_default.group(2) - continue - - # Comment intended to use for the manpage - if line.startswith('#?'): - if commentline \ - and ';' not in commentline \ - and not commentline.endswith('\\n'): - commentline += ' ' - - commentline += line.lstrip('#?').rstrip('\n') - - continue - - # Simple comments are ignored - if line.startswith('#'): - commentline = '' - continue - - # e.g. "return self.profileListValue('snapshots.include', ('str:value', 'int:type'), [], profile_id)" - # becomes - # ('profile', 'List', 'snapshots.include', "('str:value', 'int:type')", '[]') - m = c_list.match(line) - - if not m: - # e.g. "return self.profileBoolValue('snapshots.use_checksum', False, profile_id)" - # becomes - # ('profile', 'Bool', 'snapshots.use_checksum', '', 'False') - m = c.match(line) - - # Ignore undocumented (without "#?" comments) variables. - if m and not commentline: - continue - - if m: - profile, instance, name, var, default = m.groups() - - if profile == 'profile': - name = 'profile.' + name - - var = var.lstrip('% ') - - if instance.lower() == 'list': - - type_key = [x.strip('"\'') for x in re.findall(r'["\'].*?["\']', var)] - - commentline_split = commentline.split('::') - - for i, tk in enumerate(type_key): - t, k = tk.split(':', maxsplit=1) - - process_line( - d, - key, - profile, - 'int', - '%s.size' % name, - var, - r'\-1', - 'Quantity of %s. entries.' % name, - values, - force_var, - force_default, - replace_default, - counter) - - key = '%s.%s' % (name, k) - key = key.lower() - - process_line( - d, - key, - profile, - t, - '%s..%s' % (name, k), - var, - '', - commentline_split[i], - values, - force_var, - force_default, - replace_default, - counter - ) - - else: - key = re.sub(r'%[\S]', var, name).lower() - - process_line( - d, - key, - profile, - instance, - name, - var, - default, - commentline, - values, - force_var, - force_default, - replace_default, - counter - ) + Results in a man page entry like this :: + + POSSIBLE KEYWORDS - values = force_var = force_default = instance \ - = name = var = default = None + global.hash_collision + Type: int Allowed Values: 0-99999 + Internal value ... - commentline = '' + Default: 0 + Returns: + A dictionary indexed by the config option field names. """ - Example for content of 'd': + # The following fields/properties will produce warnings. But this is + # expected on happens on purpose. The list is used to "ease" the warning + # message. + expect_to_be_ignored = ( + 'Konfig.profile_names', + 'Konfig.profile_ids', + 'Konfig.manual_starts_countdown', + 'Profile.include', + 'Profile.exclude', + 'Profile.keep_named_snapshots', + ) + + entries = {} + + # Each public property in the class + for prop in _get_public_properties(cls): + attr = getattr(cls, prop) + + # Ignore properties without docstring + if not attr.__doc__: + full_prop = f'{cls.__name__}.{prop}' + ok = '(No problem) ' if full_prop in expect_to_be_ignored else '' + print(f'{ok}Ignoring "{full_prop}" because of ' + 'missing docstring.') + continue + + # DEBUG + # print(f'{cls.__name__}.{prop}') + + # Extract config field name from code (self._conf['config.field']) + try: + name = REX_ATTR_NAME.findall(inspect.getsource(attr.fget))[0] + name = f'{name_prefix}{name}' + except IndexError as exc: + full_prop = f'{cls.__name__}.{prop}' + ok = '(No problem) ' if full_prop in expect_to_be_ignored else '' + print(f'{ok}Ignoring "{full_prop}" because it is not ' + 'possible to find name of config field in its body.') + continue + + # Full doc string + doc = attr.__doc__ + + # extract the dict from docstring + try: + the_dict = REX_DICT_EXTRACT.search(doc).groups()[0] + except AttributeError: + the_dict = '' + + the_dict = '{' + the_dict + '}' + + # remove the dict from docstring + doc = doc.replace(the_dict, '') + + # Make it a real dict + the_dict = eval(the_dict) + + # Clean up the docstring from empty lines and other blanks + doc = ' '.join(line.strip() + for line in + filter(lambda val: len(val.strip()), doc.split('\n'))) + + # store the result + the_dict['doc'] = doc + + # default value + if 'default' not in the_dict: + try: + the_dict['default'] = cls._DEFAULT_VALUES[name] + except KeyError: + pass + + # type (by return value annotation) + if 'type' not in the_dict: + return_type = inspect.signature(attr.fget).return_annotation + + if return_type != inspect.Signature.empty: + the_dict['type'] = return_type + + # type by default values + if 'type' not in the_dict and 'default' in the_dict: + the_dict['type'] = type(the_dict['default']).__name__ + + # values if bool + if 'values' not in the_dict: + if the_dict['type'] == 'bool': + the_dict['values'] = 'true|false' + elif the_dict['type'] == 'int': + the_dict['values'] = '0-99999' + elif the_dict['type'] == 'str': + the_dict['values'] = 'text' + + + entries[name] = the_dict + + # DEBUG + # print(f'entries[{name}]={entries[name]}') + + return entries + + +def main(): + """The classes `Konfig` and `Konfig.Profile` are inspected and relevant + information is extracted to create a man page of it. + + Only public properties with doc strings are used. The doc strings also + need to contain a dict with additional information like allowed values and + default values. The data type is determined form the default value. The + property name is determined from the property methods name. + + Example :: + { - "profiles": { - "instance": "str", - "name": "profiles", - "values": "int separated by colon (e.g. 1:3:4)", - "default": "1", - "comment": "All active Profiles ( in profile.snapshots...).", - "reference": "configfile.py", - "line": 472 - }, - "profile.name": { - "instance": "str", - "name": "profile.name", - "values": "text", - "default": "Main profile", - "comment": "Name of this profile.", - "reference": "configfile.py", - "line": 704 + 'values': (0, 99999), + 'default': 0, } """ - with open(MAN, 'w') as f: - print(f'Write GNU Troff (groff) markup to "{MAN}". {SORT=}') - f.write(HEADER) - if SORT: - # Sort by alphabet - s = lambda x: x - else: - # Sort by line numbering (in the source file) - s = lambda x: d[x][LINE] + # Inspect the classes and extract man page related data from them. + global_entries = inspect_properties( + cls=konfig.Konfig, + ) + profile_entries = inspect_properties( + cls=konfig.Profile, + name_prefix='profile.' + ) + + # WORKAROuND: + # Structure of include/exclude fields can not be easily handled via + # properties and doc-string inspection. The structure will get + # modified in the future. Until then we add their man page docu + # manual. + inc_exc = { + 'profile.snapshots.exclude..value': { + 'doc': 'Exclude this file or folder. must be a counter ' + 'starting with 1', + 'values': 'file, folder or pattern (relative or absolute)', + 'default': '', + 'type': 'str' + }, + # Don't worry. "exclude..type" does not exist. + 'profile.snapshots.include..value': { + 'doc': 'Include this file or folder. must be a counter ' + 'starting with 1', + 'values': 'absolute path', + 'default': '', + 'type': 'str' + }, + 'profile.snapshots.include..type': { + 'doc': + 'Specify if ' + groff_indented_block( + 'profile.snapshots.include..value') + + ' is a folder (0) or a file (1)', + 'values': '0|1', + 'default': 0, + 'type': 'int' + }, + } + + # Create the man page file + with MAN.open('w', encoding='utf-8') as handle: + print(f'Write GNU Troff (groff) markup to "{MAN}".') + + # HEADER + handle.write(header()) + + # PROPERTIES + for name, entry in {**global_entries, **profile_entries}.items(): + try: + handle.write( + entry_to_groff( + name=name, + doc=entry['doc'], + values=entry['values'], + default=entry.get('default', None), + its_type=entry.get('type', None), + ) + ) + except Exception as exc: + print(f'{name=} {entry=}') + raise exc + + handle.write('\n') + + # FOOTER + handle.write(footer()) + handle.write('\n') + + print('Finished creating man page.') - f.write('\n'.join(output(**d[key]) for key in sorted(d, key=s))) - f.write(FOOTER) + lint_manpage(MAN) if __name__ == '__main__': diff --git a/doc/manpages/backintime-config.1.org b/doc/manpages/backintime-config.1.org new file mode 100644 index 000000000..afd118b19 --- /dev/null +++ b/doc/manpages/backintime-config.1.org @@ -0,0 +1,858 @@ +.TH backintime-config 1 "Aug 2024" "version 1.5.3-dev" "USER COMMANDS" +.SH NAME +config \- BackInTime configuration files. +.SH SYNOPSIS +~/.config/backintime/config +.br +/etc/backintime/config +.SH DESCRIPTION +Back In Time was developed as pure GUI program and so most functions are only +usable with backintime-qt. But it is possible to use +Back In Time e.g. on a headless server. You have to create the configuration file +(~/.config/backintime/config) manually. Look inside /usr/share/doc/backintime\-common/examples/ for examples. +.PP +The configuration file has the following format: +.br +keyword=arguments +.PP +Arguments don't need to be quoted. All characters are allowed except '='. +.PP +Run 'backintime check-config' to verify the configfile, create the snapshot folder and crontab entries. +.SH POSSIBLE KEYWORDS +.IP "\fIglobal.hash_collision\fR" 6 +.RS +Type: int Allowed Values: 0-99999 +.br +Internal value used to prevent hash collisions on mountpoints. Do not change this. +.PP +Default: 0 +.RE + +.IP "\fIglobal.language\fR" 6 +.RS +Type: str Allowed Values: text +.br +Language code (ISO 639) used to translate the user interface. If empty the operating systems current local is used. If 'en' the translation is not active and the original English source strings are used. It is the same if the value is unknown. +.PP +Default: '' +.RE + +.IP "\fIglobal.use_flock\fR" 6 +.RS +Type: bool Allowed Values: true|false +.br +Prevent multiple snapshots (from different profiles or users) to be run at the same time +.PP +Default: false +.RE + +.IP "\fIprofile.name\fR" 6 +.RS +Type: str Allowed Values: text +.br +Name of this profile. +.PP +Default: Main profile +.RE + +.IP "\fIprofile.schedule.custom_time\fR" 6 +.RS +Type: str Allowed Values: comma separated int (8,12,18,23) or */3 +.br +Custom hours for cronjob. Only valid for \fIprofile.schedule.mode\fR = 19 +.PP +Default: 8,12,18,23 +.RE + +.IP "\fIprofile.schedule.day\fR" 6 +.RS +Type: int Allowed Values: 1-28 +.br +Which day of month the cronjob should run? Only valid for \fIprofile.schedule.mode\fR >= 40 +.PP +Default: 1 +.RE + +.IP "\fIprofile.schedule.debug\fR" 6 +.RS +Type: bool Allowed Values: true|false +.br +Enable debug output to system log for schedule mode. +.PP +Default: false +.RE + +.IP "\fIprofile.schedule.mode\fR" 6 +.RS +Type: int Allowed Values: 0|1|2|4|7|10|12|14|16|18|19|20|25|27|30|40|80 +.br +Which schedule used for crontab. The crontab entry will be generated with 'backintime check-config'. +.br + 0 = Disabled +.br + 1 = at every boot +.br + 2 = every 5 minute +.br + 4 = every 10 minute +.br + 7 = every 30 minute +.br +10 = every hour +.br +12 = every 2 hours +.br +14 = every 4 hours +.br +16 = every 6 hours +.br +18 = every 12 hours +.br +19 = custom defined hours +.br +20 = every day +.br +25 = daily anacron +.br +27 = when drive get connected +.br +30 = every week +.br +40 = every month +.br +80 = every year +.PP +Default: 0 +.RE + +.IP "\fIprofile.schedule.repeatedly.period\fR" 6 +.RS +Type: int Allowed Values: 0-99999 +.br +How many units to wait between new snapshots with anacron? Only valid for \fIprofile.schedule.mode\fR = 25|27 +.PP +Default: 1 +.RE + +.IP "\fIprofile.schedule.repeatedly.unit\fR" 6 +.RS +Type: int Allowed Values: 10|20|30|40 +.br +Units to wait between new snapshots with anacron. +.br +10 = hours +.br +20 = days +.br +30 = weeks +.br +40 = months +.br +Only valid for \fIprofile.schedule.mode\fR = 25|27 +.PP +Default: 20 +.RE + +.IP "\fIprofile.schedule.time\fR" 6 +.RS +Type: int Allowed Values: 0-2400 +.br +Position-coded number with the format "hhmm" to specify the hour and minute the cronjob should start (eg. 2015 means a quarter past 8pm). Leading zeros can be omitted (eg. 30 = 0030). Only valid for \fIprofile.schedule.mode\fR = 20 (daily), 30 (weekly), 40 (monthly) and 80 (yearly) +.PP +Default: 0 +.RE + +.IP "\fIprofile.schedule.weekday\fR" 6 +.RS +Type: int Allowed Values: 1 = monday \- 7 = sunday +.br +Which day of week the cronjob should run? Only valid for \fIprofile.schedule.mode\fR = 30 +.PP +Default: 7 +.RE + +.IP "\fIprofile.snapshots.backup_on_restore.enabled\fR" 6 +.RS +Type: bool Allowed Values: true|false +.br +Rename existing files before restore into FILE.backup.YYYYMMDD +.PP +Default: true +.RE + +.IP "\fIprofile.snapshots.bwlimit.enabled\fR" 6 +.RS +Type: bool Allowed Values: true|false +.br +Limit rsync bandwidth usage over network. Use this with mode SSH. For mode Local you should rather use ionice. +.PP +Default: false +.RE + +.IP "\fIprofile.snapshots.bwlimit.value\fR" 6 +.RS +Type: int Allowed Values: 0-99999 +.br +Bandwidth limit in KB/sec. +.PP +Default: 3000 +.RE + +.IP "\fIprofile.snapshots.continue_on_errors\fR" 6 +.RS +Type: bool Allowed Values: true|false +.br +Continue on errors. This will keep incomplete snapshots rather than deleting and start over again. +.PP +Default: true +.RE + +.IP "\fIprofile.snapshots.copy_links\fR" 6 +.RS +Type: bool Allowed Values: true|false +.br +When symlinks are encountered, the item that they point to (the reference) is copied, rather than the symlink. +.PP +Default: false +.RE + +.IP "\fIprofile.snapshots.copy_unsafe_links\fR" 6 +.RS +Type: bool Allowed Values: true|false +.br +This tells rsync to copy the referent of symbolic links that point outside the copied tree. Absolute symlinks are also treated like ordinary files. +.PP +Default: false +.RE + +.IP "\fIprofile.snapshots.cron.ionice\fR" 6 +.RS +Type: bool Allowed Values: true|false +.br +Run cronjobs with 'ionice \-c2 \-n7'. This will give BackInTime the lowest IO bandwidth priority to not interrupt any other working process. +.PP +Default: true +.RE + +.IP "\fIprofile.snapshots.cron.nice\fR" 6 +.RS +Type: bool Allowed Values: true|false +.br +Run cronjobs with 'nice \-n19'. This will give BackInTime the lowest CPU priority to not interrupt any other working process. +.PP +Default: true +.RE + +.IP "\fIprofile.snapshots.cron.redirect_stderr\fR" 6 +.RS +Type: bool Allowed Values: true|false +.br +redirect stderr to /dev/null in cronjobs +.PP +Default: False +.RE + +.IP "\fIprofile.snapshots.cron.redirect_stdout\fR" 6 +.RS +Type: bool Allowed Values: true|false +.br +redirect stdout to /dev/null in cronjobs +.PP +Default: true +.RE + +.IP "\fIprofile.snapshots.dont_remove_named_snapshots\fR" 6 +.RS +Type: bool Allowed Values: true|false +.br +Keep snapshots with names during smart_remove. +.PP +Default: true +.RE + +.IP "\fIprofile.snapshots.exclude.bysize.enabled\fR" 6 +.RS +Type: bool Allowed Values: true|false +.br +Enable exclude files by size. +.PP +Default: false +.RE + +.IP "\fIprofile.snapshots.exclude.bysize.value\fR" 6 +.RS +Type: int Allowed Values: 0-99999 +.br +Exclude files bigger than value in MiB. With 'Full rsync mode' disabled this will only affect new files because for rsync this is a transfer option, not an exclude option. So big files that has been backed up before will remain in snapshots even if they had changed. +.PP +Default: 500 +.RE + +.IP "\fIprofile.snapshots.exclude..value\fR" 6 +.RS +Type: str Allowed Values: file, folder or pattern (relative or absolute) +.br +Exclude this file or folder. must be a counter starting with 1 +.PP +Default: '' +.RE + +.IP "\fIprofile.snapshots.exclude.size\fR" 6 +.RS +Type: int Allowed Values: 0-99999 +.br +Quantity of profile.snapshots.exclude. entries. +.PP +Default: \-1 +.RE + +.IP "\fIprofile.snapshots.include..type\fR" 6 +.RS +Type: int Allowed Values: 0|1 +.br +Specify if \fIprofile.snapshots.include..value\fR is a folder (0) or a file (1). +.PP +Default: 0 +.RE + +.IP "\fIprofile.snapshots.include..value\fR" 6 +.RS +Type: str Allowed Values: absolute path +.br +Include this file or folder. must be a counter starting with 1 +.PP +Default: '' +.RE + +.IP "\fIprofile.snapshots.include.size\fR" 6 +.RS +Type: int Allowed Values: 0-99999 +.br +Quantity of profile.snapshots.include. entries. +.PP +Default: \-1 +.RE + +.IP "\fIprofile.snapshots.keep_only_one_snapshot.enabled\fR" 6 +.RS +Type: bool Allowed Values: true|false +.br +NOT YET IMPLEMENTED. Remove all snapshots but one. +.PP +Default: false +.RE + +.IP "\fIprofile.snapshots.local.nocache\fR" 6 +.RS +Type: bool Allowed Values: true|false +.br +Run rsync on local machine with 'nocache'. This will prevent files from being cached in memory. +.PP +Default: false +.RE + +.IP "\fIprofile.snapshots.local_encfs.path\fR" 6 +.RS +Type: str Allowed Values: absolute path +.br +Where to save snapshots in mode 'local_encfs'. +.PP +Default: '' +.RE + +.IP "\fIprofile.snapshots.log_level\fR" 6 +.RS +Type: int Allowed Values: 1-3 +.br +Log level used during takeSnapshot. +.br +1 = Error +.br +2 = Changes +.br +3 = Info +.PP +Default: 3 +.RE + +.IP "\fIprofile.snapshots.min_free_inodes.enabled\fR" 6 +.RS +Type: bool Allowed Values: true|false +.br +Remove snapshots until \fIprofile.snapshots.min_free_inodes.value\fR free inodes in % is reached. +.PP +Default: true +.RE + +.IP "\fIprofile.snapshots.min_free_inodes.value\fR" 6 +.RS +Type: int Allowed Values: 1-15 +.br +Keep at least value % free inodes. +.PP +Default: 2 +.RE + +.IP "\fIprofile.snapshots.min_free_space.enabled\fR" 6 +.RS +Type: bool Allowed Values: true|false +.br +Remove snapshots until \fIprofile.snapshots.min_free_space.value\fR free space is reached. +.PP +Default: true +.RE + +.IP "\fIprofile.snapshots.min_free_space.unit\fR" 6 +.RS +Type: int Allowed Values: 10|20 +.br +10 = MB +.br +20 = GB +.PP +Default: 20 +.RE + +.IP "\fIprofile.snapshots.min_free_space.value\fR" 6 +.RS +Type: int Allowed Values: 1-99999 +.br +Keep at least value + unit free space. +.PP +Default: 1 +.RE + +.IP "\fIprofile.snapshots.mode\fR" 6 +.RS +Type: str Allowed Values: local|local_encfs|ssh|ssh_encfs +.br + Use mode (or backend) for this snapshot. Look at 'man backintime' section 'Modes'. +.PP +Default: local +.RE + +.IP "\fIprofile.snapshots..password.save\fR" 6 +.RS +Type: bool Allowed Values: true|false +.br +Save password to system keyring (gnome-keyring or kwallet). must be the same as \fIprofile.snapshots.mode\fR +.PP +Default: false +.RE + +.IP "\fIprofile.snapshots..password.use_cache\fR" 6 +.RS +Type: bool Allowed Values: true|false +.br +Cache password in RAM so it can be read by cronjobs. Security issue: root might be able to read that password, too. must be the same as \fIprofile.snapshots.mode\fR +.PP +Default: true if home is not encrypted +.RE + +.IP "\fIprofile.snapshots.no_on_battery\fR" 6 +.RS +Type: bool Allowed Values: true|false +.br +Don't take snapshots if the Computer runs on battery. +.PP +Default: false +.RE + +.IP "\fIprofile.snapshots.notify.enabled\fR" 6 +.RS +Type: bool Allowed Values: true|false +.br +Display notifications (errors, warnings) through libnotify. +.PP +Default: true +.RE + +.IP "\fIprofile.snapshots.one_file_system\fR" 6 +.RS +Type: bool Allowed Values: true|false +.br +Use rsync's "--one-file-system" to avoid crossing filesystem boundaries when recursing. +.PP +Default: false +.RE + +.IP "\fIprofile.snapshots.path\fR" 6 +.RS +Type: str Allowed Values: absolute path +.br +Where to save snapshots in mode 'local'. This path must contain a folderstructure like 'backintime///' +.PP +Default: '' +.RE + +.IP "\fIprofile.snapshots.path.host\fR" 6 +.RS +Type: str Allowed Values: text +.br +Set Host for snapshot path +.PP +Default: local hostname +.RE + +.IP "\fIprofile.snapshots.path.profile\fR" 6 +.RS +Type: str Allowed Values: 1-99999 +.br +Set Profile-ID for snapshot path +.PP +Default: current Profile-ID +.RE + +.IP "\fIprofile.snapshots.path.user\fR" 6 +.RS +Type: str Allowed Values: text +.br +Set User for snapshot path +.PP +Default: local username +.RE + +.IP "\fIprofile.snapshots.path.uuid\fR" 6 +.RS +Type: str Allowed Values: text +.br +Devices uuid used to automatically set up udev rule if the drive is not connected. +.PP +Default: '' +.RE + +.IP "\fIprofile.snapshots.preserve_acl\fR" 6 +.RS +Type: bool Allowed Values: true|false +.br +Preserve ACL. The source and destination systems must have compatible ACL entries for this option to work properly. +.PP +Default: false +.RE + +.IP "\fIprofile.snapshots.preserve_xattr\fR" 6 +.RS +Type: bool Allowed Values: true|false +.br +Preserve extended attributes (xattr). +.PP +Default: false +.RE + +.IP "\fIprofile.snapshots.remove_old_snapshots.enabled\fR" 6 +.RS +Type: bool Allowed Values: true|false +.br +Remove all snapshots older than value + unit +.PP +Default: true +.RE + +.IP "\fIprofile.snapshots.remove_old_snapshots.unit\fR" 6 +.RS +Type: int Allowed Values: 20|30|80 +.br +20 = days +.br +30 = weeks +.br +80 = years +.PP +Default: 80 +.RE + +.IP "\fIprofile.snapshots.remove_old_snapshots.value\fR" 6 +.RS +Type: int Allowed Values: 0-99999 +.br +Snapshots older than this times units will be removed +.PP +Default: 10 +.RE + +.IP "\fIprofile.snapshots.rsync_options.enabled\fR" 6 +.RS +Type: bool Allowed Values: true|false +.br +Past additional options to rsync +.PP +Default: false +.RE + +.IP "\fIprofile.snapshots.rsync_options.value\fR" 6 +.RS +Type: str Allowed Values: text +.br +rsync options. Options must be quoted e.g. \-\-exclude-from="/path/to/my exclude file" +.PP +Default: '' +.RE + +.IP "\fIprofile.snapshots.smart_remove\fR" 6 +.RS +Type: bool Allowed Values: true|false +.br +Run smart_remove to clean up old snapshots after a new snapshot was created. +.PP +Default: false +.RE + +.IP "\fIprofile.snapshots.smart_remove.keep_all\fR" 6 +.RS +Type: int Allowed Values: 0-99999 +.br +Keep all snapshots for X days. +.PP +Default: 2 +.RE + +.IP "\fIprofile.snapshots.smart_remove.keep_one_per_day\fR" 6 +.RS +Type: int Allowed Values: 0-99999 +.br +Keep one snapshot per day for X days. +.PP +Default: 7 +.RE + +.IP "\fIprofile.snapshots.smart_remove.keep_one_per_month\fR" 6 +.RS +Type: int Allowed Values: 0-99999 +.br +Keep one snapshot per month for X month. +.PP +Default: 24 +.RE + +.IP "\fIprofile.snapshots.smart_remove.keep_one_per_week\fR" 6 +.RS +Type: int Allowed Values: 0-99999 +.br +Keep one snapshot per week for X weeks. +.PP +Default: 4 +.RE + +.IP "\fIprofile.snapshots.smart_remove.run_remote_in_background\fR" 6 +.RS +Type: bool Allowed Values: true|false +.br +If using mode SSH or SSH-encrypted, run smart_remove in background on remote machine +.PP +Default: false +.RE + +.IP "\fIprofile.snapshots.ssh.check_commands\fR" 6 +.RS +Type: bool Allowed Values: true|false +.br +Check if all commands (used during takeSnapshot) work like expected on the remote host. +.PP +Default: true +.RE + +.IP "\fIprofile.snapshots.ssh.check_ping\fR" 6 +.RS +Type: bool Allowed Values: true|false +.br +Check if the remote host is available before trying to mount. +.PP +Default: true +.RE + +.IP "\fIprofile.snapshots.ssh.cipher\fR" 6 +.RS +Type: str Allowed Values: default | aes192-cbc | aes256-cbc | aes128-ctr | aes192-ctr | aes256-ctr | arcfour | arcfour256 | arcfour128 | aes128-cbc | 3des-cbc | blowfish-cbc | cast128-cbc +.br +Cipher that is used for encrypting the SSH tunnel. Depending on the environment (network bandwidth, cpu and hdd performance) a different cipher might be faster. +.PP +Default: default +.RE + +.IP "\fIprofile.snapshots.ssh.host\fR" 6 +.RS +Type: str Allowed Values: IP or domain address +.br +Remote host used for mode 'ssh' and 'ssh_encfs'. +.PP +Default: '' +.RE + +.IP "\fIprofile.snapshots.ssh.ionice\fR" 6 +.RS +Type: bool Allowed Values: true|false +.br +Run rsync and other commands on remote host with 'ionice \-c2 \-n7' +.PP +Default: false +.RE + +.IP "\fIprofile.snapshots.ssh.max_arg_length\fR" 6 +.RS +Type: int Allowed Values: 0, >700 +.br +Maximum command length of commands run on remote host. This can be tested for all ssh profiles in the configuration with 'python3 /usr/share/backintime/common/sshMaxArg.py LENGTH'. +.br +0 = unlimited +.PP +Default: 0 +.RE + +.IP "\fIprofile.snapshots.ssh.nice\fR" 6 +.RS +Type: bool Allowed Values: true|false +.br +Run rsync and other commands on remote host with 'nice \-n19' +.PP +Default: false +.RE + +.IP "\fIprofile.snapshots.ssh.nocache\fR" 6 +.RS +Type: bool Allowed Values: true|false +.br +Run rsync on remote host with 'nocache'. This will prevent files from being cached in memory. +.PP +Default: false +.RE + +.IP "\fIprofile.snapshots.ssh.path\fR" 6 +.RS +Type: str Allowed Values: absolute or relative path +.br +Snapshot path on remote host. If the path is relative (no leading '/') it will start from remote Users homedir. An empty path will be replaced with './'. +.PP +Default: '' +.RE + +.IP "\fIprofile.snapshots.ssh.port\fR" 6 +.RS +Type: int Allowed Values: 0-65535 +.br +SSH Port on remote host. +.PP +Default: 22 +.RE + +.IP "\fIprofile.snapshots.ssh.prefix.enabled\fR" 6 +.RS +Type: bool Allowed Values: true|false +.br +Add prefix to every command which run through SSH on remote host. +.PP +Default: false +.RE + +.IP "\fIprofile.snapshots.ssh.prefix.value\fR" 6 +.RS +Type: str Allowed Values: text +.br +Prefix to run before every command on remote host. Variables need to be escaped with \\$FOO. This doesn't touch rsync. So to add a prefix for rsync use \fIprofile.snapshots.rsync_options.value\fR with --rsync-path="FOO=bar:\\$FOO /usr/bin/rsync" +.PP +Default: 'PATH=/opt/bin:/opt/sbin:\\$PATH' +.RE + +.IP "\fIprofile.snapshots.ssh.private_key_file\fR" 6 +.RS +Type: str Allowed Values: absolute path to private key file +.br +Private key file used for password-less authentication on remote host. +.PP +Default: ~/.ssh/id_dsa +.RE + +.IP "\fIprofile.snapshots.ssh.proxy_host\fR" 6 +.RS +Type: str Allowed Values: text +.br +Proxy host used to connect to remote host. +.PP +Default: IP or domain address +.RE + +.IP "\fIprofile.snapshots.ssh.proxy_host_port\fR" 6 +.RS +Type: int Allowed Values: 0-65535 +.br +SSH Port on remote proxy host. +.PP +Default: 22 +.RE + +.IP "\fIprofile.snapshots.ssh.proxy_user\fR" 6 +.RS +Type: str Allowed Values: text +.br +Remote SSH Proxy user +.PP +Default: getpass.getuser( +.RE + +.IP "\fIprofile.snapshots.ssh.user\fR" 6 +.RS +Type: str Allowed Values: text +.br +Remote SSH user +.PP +Default: local users name +.RE + +.IP "\fIprofile.snapshots.take_snapshot_regardless_of_changes\fR" 6 +.RS +Type: bool Allowed Values: true|false +.br +Create a new snapshot regardless if there were changes or not. +.PP +Default: false +.RE + +.IP "\fIprofile.snapshots.use_checksum\fR" 6 +.RS +Type: bool Allowed Values: true|false +.br +Use checksum to detect changes rather than size + time. +.PP +Default: false +.RE + +.IP "\fIprofile.snapshots.user_backup.ionice\fR" 6 +.RS +Type: bool Allowed Values: true|false +.br +Run BackInTime with 'ionice \-c2 \-n7' when taking a manual snapshot. This will give BackInTime the lowest IO bandwidth priority to not interrupt any other working process. +.PP +Default: false +.RE + +.IP "\fIprofile.user_callback.no_logging\fR" 6 +.RS +Type: bool Allowed Values: true|false +.br +Do not catch std{out|err} from user-callback script. The script will only write to current TTY. Default is to catch std{out|err} and write it to syslog and TTY again. +.PP +Default: false +.RE + +.IP "\fIprofiles\fR" 6 +.RS +Type: str Allowed Values: int separated by colon (e.g. 1:3:4) +.br +All active Profiles ( in profile.snapshots...). +.PP +Default: 1 +.RE + +.IP "\fIprofiles.version\fR" 6 +.RS +Type: int Allowed Values: 1 +.br +Internal version of profiles config. +.PP +Default: 1 +.RE +.SH SEE ALSO +backintime, backintime-qt. +.PP +Back In Time also has a website: https://github.com/bit-team/backintime +.SH AUTHOR +This manual page was written by BIT Team(). diff --git a/doc/manpages/backintime-config.5 b/doc/manpages/backintime-config.5 index 87598e7e4..c2e1d9abd 100644 --- a/doc/manpages/backintime-config.5 +++ b/doc/manpages/backintime-config.5 @@ -1,15 +1,12 @@ .TH backintime-config 5 "November 2024" "version 1.6.0-dev" "USER COMMANDS" .SH NAME -config \- BackInTime configuration files. +config \- Back In Time configuration file. .SH SYNOPSIS ~/.config/backintime/config .br /etc/backintime/config .SH DESCRIPTION -Back In Time was developed as pure GUI program and so most functions are only -usable with backintime-qt. But it is possible to use -Back In Time e.g. on a headless server. You have to create the configuration file -(~/.config/backintime/config) manually. Look inside /usr/share/doc/backintime\-common/examples/ for examples. +Back In Time was developed as pure GUI program and so most functions are only usable with \fBbackintime-qt\fR. But it is possible to use Back In Time e.g. on a headless server. You have to create the configuration file (~/.config/backintime/config) manually. Look inside /usr/share/doc/backintime\-common/examples/ for examples. .PP The configuration file has the following format: .br @@ -30,244 +27,200 @@ Default: 0 .IP "\fIglobal.language\fR" 6 .RS -Type: str Allowed Values: text +Type: str Allowed Values: ISO 639 language codes .br Language code (ISO 639) used to translate the user interface. If empty the operating systems current local is used. If 'en' the translation is not active and the original English source strings are used. It is the same if the value is unknown. .PP -Default: '' +Default: .RE .IP "\fIglobal.use_flock\fR" 6 .RS Type: bool Allowed Values: true|false .br -Prevent multiple snapshots (from different profiles or users) to be run at the same time +Prevent multiple snapshots (from different profiles or users) to be run at the same time. .PP Default: false .RE -.IP "\fIprofile.name\fR" 6 +.IP "\fIprofile.snapshots.mode\fR" 6 .RS -Type: str Allowed Values: text +Type: str Allowed Values: local|local_encfs|ssh|ssh_encfs .br -Name of this profile. +Use mode (or backend) for this snapshot. Look at 'man backintime' section 'Modes'. .PP -Default: Main profile +Default: local .RE -.IP "\fIprofile.schedule.custom_time\fR" 6 +.IP "\fIprofile.snapshots.path\fR" 6 .RS -Type: str Allowed Values: comma separated int (8,12,18,23) or */3 +Type: str Allowed Values: absolute path .br -Custom hours for cronjob. Only valid for \fIprofile.schedule.mode\fR = 19 +Where to save snapshots in mode 'local'. This path must contain a folderstructure like 'backintime///'. .PP -Default: 8,12,18,23 + .RE -.IP "\fIprofile.schedule.day\fR" 6 +.IP "\fIprofile.snapshots.path.host\fR" 6 .RS -Type: int Allowed Values: 1-28 +Type: str Allowed Values: local hostname .br -Which day of month the cronjob should run? Only valid for \fIprofile.schedule.mode\fR >= 40 +Set Host for snapshot path. .PP -Default: 1 + .RE -.IP "\fIprofile.schedule.debug\fR" 6 +.IP "\fIprofile.snapshots.path.user\fR" 6 .RS -Type: bool Allowed Values: true|false +Type: str Allowed Values: local username .br -Enable debug output to system log for schedule mode. +Set User for snapshot path. .PP -Default: false + .RE -.IP "\fIprofile.schedule.mode\fR" 6 +.IP "\fIprofile.snapshots.path.profile\fR" 6 .RS -Type: int Allowed Values: 0|1|2|4|7|10|12|14|16|18|19|20|25|27|30|40|80 -.br -Which schedule used for crontab. The crontab entry will be generated with 'backintime check-config'. -.br - 0 = Disabled -.br - 1 = at every boot -.br - 2 = every 5 minute -.br - 4 = every 10 minute -.br - 7 = every 30 minute -.br -10 = every hour -.br -12 = every 2 hours -.br -14 = every 4 hours -.br -16 = every 6 hours -.br -18 = every 12 hours -.br -19 = custom defined hours -.br -20 = every day -.br -25 = daily anacron -.br -27 = when drive get connected -.br -30 = every week -.br -40 = every month +Type: str Allowed Values: 1-99999 .br -80 = every year +Set Profile-ID for snapshot path .PP -Default: 0 +Default: current Profile-ID .RE -.IP "\fIprofile.schedule.repeatedly.period\fR" 6 +.IP "\fIprofile.snapshots.ssh.path\fR" 6 .RS -Type: int Allowed Values: 0-99999 +Type: str Allowed Values: absolute or relative path .br -How many units to wait between new snapshots with anacron? Only valid for \fIprofile.schedule.mode\fR = 25|27 +Snapshot path on remote host. If the path is relative (no leading '/') it will start from remote Users homedir. An empty path will be replaced with './'. .PP -Default: 1 + .RE -.IP "\fIprofile.schedule.repeatedly.unit\fR" 6 +.IP "\fIprofile.snapshots.ssh.host\fR" 6 .RS -Type: int Allowed Values: 10|20|30|40 -.br -Units to wait between new snapshots with anacron. -.br -10 = hours -.br -20 = days -.br -30 = weeks -.br -40 = months +Type: str Allowed Values: IP or domain address .br -Only valid for \fIprofile.schedule.mode\fR = 25|27 +Remote host used for mode 'ssh' and 'ssh_encfs'. .PP -Default: 20 + .RE -.IP "\fIprofile.schedule.time\fR" 6 +.IP "\fIprofile.snapshots.ssh.port\fR" 6 .RS -Type: int Allowed Values: 0-2400 +Type: int Allowed Values: 0-65535 .br -Position-coded number with the format "hhmm" to specify the hour and minute the cronjob should start (eg. 2015 means a quarter past 8pm). Leading zeros can be omitted (eg. 30 = 0030). Only valid for \fIprofile.schedule.mode\fR = 20 (daily), 30 (weekly), 40 (monthly) and 80 (yearly) +SSH Port on remote host. .PP -Default: 0 +Default: 22 .RE -.IP "\fIprofile.schedule.weekday\fR" 6 +.IP "\fIprofile.snapshots.ssh.user\fR" 6 .RS -Type: int Allowed Values: 1 = monday \- 7 = sunday +Type: str Allowed Values: text .br -Which day of week the cronjob should run? Only valid for \fIprofile.schedule.mode\fR = 30 +Remote SSH user. .PP -Default: 7 +Default: local users name .RE -.IP "\fIprofile.snapshots.backup_on_restore.enabled\fR" 6 +.IP "\fIprofile.snapshots.ssh.cipher\fR" 6 .RS -Type: bool Allowed Values: true|false +Type: str Allowed Values: default | aes192-cbc | aes256-cbc | aes128-ctr | aes192-ctr | aes256-ctr | arcfour | arcfour256 | arcfour128 | aes128-cbc | 3des-cbc | blowfish-cbc | cast128-cbc .br -Rename existing files before restore into FILE.backup.YYYYMMDD +Cipher that is used for encrypting the SSH tunnel. Depending on the environment (network bandwidth, cpu and hdd performance) a different cipher might be faster. .PP -Default: true + .RE -.IP "\fIprofile.snapshots.bwlimit.enabled\fR" 6 +.IP "\fIprofile.snapshots.ssh.private_key_file\fR" 6 .RS -Type: bool Allowed Values: true|false +Type: str Allowed Values: absolute path to private key file .br -Limit rsync bandwidth usage over network. Use this with mode SSH. For mode Local you should rather use ionice. +Private key file used for password-less authentication on remote host. .PP -Default: false + .RE -.IP "\fIprofile.snapshots.bwlimit.value\fR" 6 +.IP "\fIprofile.snapshots.ssh.proxy_host\fR" 6 .RS -Type: int Allowed Values: 0-99999 +Type: str Allowed Values: IP or domain address .br -Bandwidth limit in KB/sec. +Proxy host (or jump host) used to connect to remote host. .PP -Default: 3000 + .RE -.IP "\fIprofile.snapshots.continue_on_errors\fR" 6 +.IP "\fIprofile.snapshots.ssh.proxy_port\fR" 6 .RS -Type: bool Allowed Values: true|false +Type: int Allowed Values: 0-65535 .br -Continue on errors. This will keep incomplete snapshots rather than deleting and start over again. +Port of SSH proxy (jump) host used to connect to remote host. .PP -Default: true +Default: 22 .RE -.IP "\fIprofile.snapshots.copy_links\fR" 6 +.IP "\fIprofile.snapshots.ssh.proxy_user\fR" 6 .RS -Type: bool Allowed Values: true|false +Type: str Allowed Values: text .br -When symlinks are encountered, the item that they point to (the reference) is copied, rather than the symlink. +SSH user at proxy (jump) host. .PP -Default: false +Default: local users name .RE -.IP "\fIprofile.snapshots.copy_unsafe_links\fR" 6 +.IP "\fIprofile.snapshots.ssh.max_arg_length\fR" 6 .RS -Type: bool Allowed Values: true|false +Type: int Allowed Values: 0, >700 .br -This tells rsync to copy the referent of symbolic links that point outside the copied tree. Absolute symlinks are also treated like ordinary files. +Maximum command length of commands run on remote host. This can be tested for all ssh profiles in the configuration with 'python3 /usr/share/backintime/common/sshMaxArg.py LENGTH'. The value '0' means unlimited length. .PP -Default: false + .RE -.IP "\fIprofile.snapshots.cron.ionice\fR" 6 +.IP "\fIprofile.snapshots.ssh.check_commands\fR" 6 .RS Type: bool Allowed Values: true|false .br -Run cronjobs with 'ionice \-c2 \-n7'. This will give BackInTime the lowest IO bandwidth priority to not interrupt any other working process. +Check if all commands (used during takeSnapshot) work like expected on the remote host. .PP -Default: true + .RE -.IP "\fIprofile.snapshots.cron.nice\fR" 6 +.IP "\fIprofile.snapshots.ssh.check_ping\fR" 6 .RS Type: bool Allowed Values: true|false .br -Run cronjobs with 'nice \-n19'. This will give BackInTime the lowest CPU priority to not interrupt any other working process. +Check if the remote host is available before trying to mount. .PP -Default: true + .RE -.IP "\fIprofile.snapshots.cron.redirect_stderr\fR" 6 +.IP "\fIprofile.snapshots.local_encfs.path\fR" 6 .RS -Type: bool Allowed Values: true|false +Type: Path Allowed Values: absolute path .br -redirect stderr to /dev/null in cronjobs +Where to save snapshots in mode 'local_encfs'. .PP -Default: False + .RE -.IP "\fIprofile.snapshots.cron.redirect_stdout\fR" 6 +.IP "\fIprofile.snapshots.password.save\fR" 6 .RS Type: bool Allowed Values: true|false .br -redirect stdout to /dev/null in cronjobs +Save password to system keyring (gnome-keyring or kwallet). .PP -Default: true + .RE -.IP "\fIprofile.snapshots.dont_remove_named_snapshots\fR" 6 +.IP "\fIprofile.snapshots.password.use_cache\fR" 6 .RS -Type: bool Allowed Values: true|false +Type: None Allowed Values: true|false .br -Keep snapshots with names during smart_remove. +Cache password in RAM so it can be read by cronjobs. Security issue: root might be able to read that password, too. .PP -Default: true +Default: see #1855 .RE .IP "\fIprofile.snapshots.exclude.bysize.enabled\fR" 6 @@ -276,7 +229,7 @@ Type: bool Allowed Values: true|false .br Enable exclude files by size. .PP -Default: false + .RE .IP "\fIprofile.snapshots.exclude.bysize.value\fR" 6 @@ -285,435 +238,392 @@ Type: int Allowed Values: 0-99999 .br Exclude files bigger than value in MiB. With 'Full rsync mode' disabled this will only affect new files because for rsync this is a transfer option, not an exclude option. So big files that has been backed up before will remain in snapshots even if they had changed. .PP -Default: 500 + .RE -.IP "\fIprofile.snapshots.exclude..value\fR" 6 +.IP "\fIprofile.schedule.mode\fR" 6 .RS -Type: str Allowed Values: file, folder or pattern (relative or absolute) +Type: int Allowed Values: 0|1|2|4|7|10|12|14|16|18|19|20|25|27|30|40|80 .br -Exclude this file or folder. must be a counter starting with 1 +Which schedule used for crontab. The crontab entry will be generated with 'backintime check-config'. 0 = Disabled 1 = at every boot 2 = every 5 minute 4 = every 10 minute 7 = every 30 minute 10 = every hour 12 = every 2 hours 14 = every 4 hours 16 = every 6 hours 18 = every 12 hours 19 = custom defined hours 20 = every day 25 = daily anacron 27 = when drive get connected 30 = every week 40 = every month 80 = every year .PP -Default: '' + .RE -.IP "\fIprofile.snapshots.exclude.size\fR" 6 +.IP "\fIprofile.schedule.debug\fR" 6 .RS -Type: int Allowed Values: 0-99999 +Type: bool Allowed Values: true|false .br -Quantity of profile.snapshots.exclude. entries. +Enable debug output to system log for schedule mode. .PP -Default: \-1 + .RE -.IP "\fIprofile.snapshots.include..type\fR" 6 +.IP "\fIprofile.schedule.time\fR" 6 .RS -Type: int Allowed Values: 0|1 +Type: int Allowed Values: 0-2400 .br -Specify if \fIprofile.snapshots.include..value\fR is a folder (0) or a file (1). +Position-coded number with the format "hhmm" to specify the hour and minute the cronjob should start (eg. 2015 means a quarter past 8pm). Leading zeros can be omitted (eg. 30 = 0030). Only valid for \fIprofile.schedule.mode\fR = 20 (daily), 30 (weekly), 40 (monthly) and 80 (yearly). .PP -Default: 0 + .RE -.IP "\fIprofile.snapshots.include..value\fR" 6 +.IP "\fIprofile.schedule.day\fR" 6 .RS -Type: str Allowed Values: absolute path +Type: int Allowed Values: 1-28 .br -Include this file or folder. must be a counter starting with 1 +Which day of month the cronjob should run? Only valid for \fIprofile.schedule.mode\fR >= 40. .PP -Default: '' + .RE -.IP "\fIprofile.snapshots.include.size\fR" 6 +.IP "\fIprofile.schedule.weekday\fR" 6 .RS -Type: int Allowed Values: 0-99999 +Type: int Allowed Values: 1 (monday) to 7 (sunday) .br -Quantity of profile.snapshots.include. entries. +Which day of week the cronjob should run? Only valid for \fIprofile.schedule.mode\fR = 30. .PP -Default: \-1 + .RE -.IP "\fIprofile.snapshots.keep_only_one_snapshot.enabled\fR" 6 +.IP "\fIprofile.schedule.custom_time\fR" 6 .RS -Type: bool Allowed Values: true|false +Type: str Allowed Values: comma separated int (8,12,18,23) or */3;8,12,18,23 .br -NOT YET IMPLEMENTED. Remove all snapshots but one. +Custom hours for cronjob. Only valid for \fIprofile.schedule.mode\fR = 19 .PP -Default: false + .RE -.IP "\fIprofile.snapshots.local.nocache\fR" 6 +.IP "\fIprofile.schedule.repeatedly.period\fR" 6 .RS -Type: bool Allowed Values: true|false +Type: int Allowed Values: 0-99999 .br -Run rsync on local machine with 'nocache'. This will prevent files from being cached in memory. +How many units to wait between new snapshots with anacron? Only valid for \fIprofile.schedule.mode\fR = 25|27. .PP -Default: false + .RE .IP "\fIprofile.snapshots.log_level\fR" 6 .RS -Type: int Allowed Values: 1-3 -.br -Log level used during takeSnapshot. -.br -1 = Error -.br -2 = Changes +Type: bool Allowed Values: true|false .br -3 = Info +Remove all snapshots older than value + unit. .PP -Default: 3 + .RE -.IP "\fIprofile.snapshots.min_free_inodes.enabled\fR" 6 +.IP "\fIprofile.snapshots.remove_old_snapshots.value\fR" 6 .RS -Type: bool Allowed Values: true|false +Type: int Allowed Values: 0-99999 .br -Remove snapshots until \fIprofile.snapshots.min_free_inodes.value\fR free inodes in % is reached. +Snapshots older than this times units will be removed. .PP -Default: false + .RE -.IP "\fIprofile.snapshots.min_free_inodes.value\fR" 6 +.IP "\fIprofile.snapshots.remove_old_snapshots.unit\fR" 6 .RS -Type: int Allowed Values: 1-15 +Type: TimeUnit Allowed Values: 20|30|80 .br -Keep at least value % free inodes. +Time unit to use to calculate removing of old snapshots. 20 = days; 30 = weeks; 80 = years .PP -Default: 2 + .RE .IP "\fIprofile.snapshots.min_free_space.enabled\fR" 6 .RS Type: bool Allowed Values: true|false .br -Remove snapshots until \fIprofile.snapshots.min_free_space.value\fR free space is reached. +Remove snapshots until \fIprofile.snapshots.min_free_space. value\fR free space is reached. .PP -Default: false -.RE -.IP "\fIprofile.snapshots.min_free_space.unit\fR" 6 -.RS -Type: int Allowed Values: 10|20 -.br -10 = MB -.br -20 = GB -.PP -Default: 20 .RE .IP "\fIprofile.snapshots.min_free_space.value\fR" 6 .RS -Type: int Allowed Values: 1-99999 +Type: int Allowed Values: 0-99999 .br Keep at least value + unit free space. .PP -Default: 1 + .RE -.IP "\fIprofile.snapshots.mode\fR" 6 +.IP "\fIprofile.snapshots.min_free_space.unit\fR" 6 .RS Type: str Allowed Values: local|local_gocryptfs|ssh|ssh_gocryptfs .br - Use mode (or backend) for this snapshot. Look at 'man backintime' section 'Modes'. +10 = MB 20 = GB .PP -Default: local + .RE -.IP "\fIprofile.snapshots..password.save\fR" 6 +.IP "\fIprofile.snapshots.min_free_inodes.enabled\fR" 6 .RS Type: bool Allowed Values: true|false .br -Save password to system keyring (gnome-keyring or kwallet). must be the same as \fIprofile.snapshots.mode\fR +Remove snapshots until \fIprofile.snapshots.min_free_inodes.value\fR free inodes in % is reached. .PP -Default: false + .RE -.IP "\fIprofile.snapshots..password.use_cache\fR" 6 +.IP "\fIprofile.snapshots.min_free_inodes.value\fR" 6 .RS -Type: bool Allowed Values: true|false +Type: int Allowed Values: 1-15 .br -Cache password in RAM so it can be read by cronjobs. Security issue: root might be able to read that password, too. must be the same as \fIprofile.snapshots.mode\fR +Keep at least value % free inodes. .PP -Default: true if home is not encrypted + .RE -.IP "\fIprofile.snapshots.no_on_battery\fR" 6 +.IP "\fIprofile.snapshots.dont_remove_named_snapshots\fR" 6 .RS Type: bool Allowed Values: true|false .br -Don't take snapshots if the Computer runs on battery. +Keep snapshots with names during smart_remove. .PP -Default: false + .RE -.IP "\fIprofile.snapshots.notify.enabled\fR" 6 +.IP "\fIprofile.snapshots.smart_remove\fR" 6 .RS Type: bool Allowed Values: true|false .br -Display notifications (errors, warnings) through libnotify. +Run smart_remove to clean up old snapshots after a new snapshot was created. .PP -Default: true + .RE -.IP "\fIprofile.snapshots.one_file_system\fR" 6 +.IP "\fIprofile.snapshots.smart_remove.keep_all\fR" 6 .RS -Type: bool Allowed Values: true|false +Type: int Allowed Values: 0-99999 .br -Use rsync's "--one-file-system" to avoid crossing filesystem boundaries when recursing. +Keep all snapshots for X days. .PP -Default: false + .RE -.IP "\fIprofile.snapshots.path\fR" 6 +.IP "\fIprofile.snapshots.smart_remove.keep_one_per_day\fR" 6 .RS -Type: str Allowed Values: absolute path +Type: int Allowed Values: 0-99999 .br -Where to save snapshots in mode 'local'. This path must contain a folderstructure like 'backintime///' +Keep one snapshot per day for X days. .PP -Default: '' + .RE -.IP "\fIprofile.snapshots.path.host\fR" 6 +.IP "\fIprofile.snapshots.smart_remove.keep_one_per_week\fR" 6 .RS -Type: str Allowed Values: text +Type: int Allowed Values: 0-99999 .br -Set Host for snapshot path +Keep one snapshot per week for X weeks. .PP -Default: local hostname + .RE -.IP "\fIprofile.snapshots.path.profile\fR" 6 +.IP "\fIprofile.snapshots.smart_remove.keep_one_per_month\fR" 6 .RS -Type: str Allowed Values: 1-99999 +Type: int Allowed Values: 0-99999 .br -Set Profile-ID for snapshot path +Keep one snapshot per month for X months. .PP -Default: current Profile-ID + .RE -.IP "\fIprofile.snapshots.path.user\fR" 6 +.IP "\fIprofile.snapshots.smart_remove.run_remote_in_background\fR" 6 .RS -Type: str Allowed Values: text +Type: bool Allowed Values: true|false .br -Set User for snapshot path +If using modes SSH or SSH-encrypted, run smart_remove in background on remote machine .PP -Default: local username + .RE -.IP "\fIprofile.snapshots.path.uuid\fR" 6 +.IP "\fIprofile.snapshots.notify.enabled\fR" 6 .RS -Type: str Allowed Values: text +Type: bool Allowed Values: true|false .br -Devices uuid used to automatically set up udev rule if the drive is not connected. +Display notifications (errors, warnings) through libnotify or DBUS. .PP -Default: '' + .RE -.IP "\fIprofile.snapshots.preserve_acl\fR" 6 +.IP "\fIprofile.snapshots.backup_on_restore.enabled\fR" 6 .RS Type: bool Allowed Values: true|false .br -Preserve ACL. The source and destination systems must have compatible ACL entries for this option to work properly. +Rename existing files before restore into FILE.backup.YYYYMMDD .PP -Default: false + .RE -.IP "\fIprofile.snapshots.preserve_xattr\fR" 6 +.IP "\fIprofile.snapshots.cron.nice\fR" 6 .RS Type: bool Allowed Values: true|false .br -Preserve extended attributes (xattr). +Run cronjobs with nice-Value 19. This will give Back In Time the lowest CPU priority to not interrupt any other working process. .PP -Default: false + .RE -.IP "\fIprofile.snapshots.remove_old_snapshots.enabled\fR" 6 +.IP "\fIprofile.snapshots.cron.ionice\fR" 6 .RS Type: bool Allowed Values: true|false .br -Remove all snapshots older than value + unit +Run cronjobs with 'ionice' and class 2 and level 7. This will give Back In Time the lowest IO bandwidth priority to not interrupt any other working process. .PP -Default: true + .RE -.IP "\fIprofile.snapshots.remove_old_snapshots.unit\fR" 6 +.IP "\fIprofile.snapshots.user_backup.ionice\fR" 6 .RS -Type: int Allowed Values: 20|30|80 -.br -20 = days -.br -30 = weeks +Type: bool Allowed Values: true|false .br -80 = years +Run Back In Time with 'ionice' and class 2 and level 7 when taking a manual snapshot. This will give Back In Time the lowest IO bandwidth priority to not interrupt any other working process. .PP -Default: 80 + .RE -.IP "\fIprofile.snapshots.remove_old_snapshots.value\fR" 6 +.IP "\fIprofile.snapshots.ssh.nice\fR" 6 .RS -Type: int Allowed Values: 0-99999 +Type: bool Allowed Values: true|false .br -Snapshots older than this times units will be removed +Run rsync and other commands on remote host with 'nice' value 19. .PP -Default: 10 + .RE -.IP "\fIprofile.snapshots.rsync_options.enabled\fR" 6 +.IP "\fIprofile.snapshots.ssh.ionice\fR" 6 .RS Type: bool Allowed Values: true|false .br -Past additional options to rsync +Run rsync and other commands on remote host with 'ionice' and class 2 and level 7. .PP -Default: false + .RE -.IP "\fIprofile.snapshots.rsync_options.value\fR" 6 +.IP "\fIprofile.snapshots.local.nocache\fR" 6 .RS -Type: str Allowed Values: text +Type: bool Allowed Values: true|false .br -rsync options. Options must be quoted e.g. \-\-exclude-from="/path/to/my exclude file" +Run rsync on local machine with 'nocache'. This will prevent files from being cached in memory. .PP -Default: '' + .RE -.IP "\fIprofile.snapshots.smart_remove\fR" 6 +.IP "\fIprofile.snapshots.ssh.nocache\fR" 6 .RS Type: bool Allowed Values: true|false .br -Run smart_remove to clean up old snapshots after a new snapshot was created. +Run rsync on remote host with 'nocache'. This will prevent files from being cached in memory. .PP -Default: false + .RE -.IP "\fIprofile.snapshots.smart_remove.keep_all\fR" 6 +.IP "\fIprofile.snapshots.cron.redirect_stdout\fR" 6 .RS -Type: int Allowed Values: 0-99999 +Type: bool Allowed Values: true|false .br -Keep all snapshots for X days. +Redirect stdout to /dev/null in cronjobs. .PP -Default: 2 + .RE -.IP "\fIprofile.snapshots.smart_remove.keep_one_per_day\fR" 6 +.IP "\fIprofile.snapshots.cron.redirect_stderr\fR" 6 .RS -Type: int Allowed Values: 0-99999 +Type: bool Allowed Values: true|false .br -Keep one snapshot per day for X days. +Redirect stderr to /dev/null in cronjobs. .PP -Default: 7 + .RE -.IP "\fIprofile.snapshots.smart_remove.keep_one_per_month\fR" 6 +.IP "\fIprofile.snapshots.bwlimit.enabled\fR" 6 .RS -Type: int Allowed Values: 0-99999 +Type: bool Allowed Values: true|false .br -Keep one snapshot per month for X month. +Limit rsync bandwidth usage over network. Use this with mode SSH. For mode Local you should rather use ionice. .PP -Default: 24 + .RE -.IP "\fIprofile.snapshots.smart_remove.keep_one_per_week\fR" 6 +.IP "\fIprofile.snapshots.bwlimit.value\fR" 6 .RS Type: int Allowed Values: 0-99999 .br -Keep one snapshot per week for X weeks. +Bandwidth limit in KB/sec. .PP -Default: 4 + .RE -.IP "\fIprofile.snapshots.smart_remove.run_remote_in_background\fR" 6 +.IP "\fIprofile.snapshots.no_on_battery\fR" 6 .RS Type: bool Allowed Values: true|false .br -If using mode SSH or SSH-encrypted, run smart_remove in background on remote machine +Don't take snapshots if the Computer runs on battery. .PP -Default: false + .RE -.IP "\fIprofile.snapshots.ssh.check_commands\fR" 6 +.IP "\fIprofile.snapshots.preserve_acl\fR" 6 .RS Type: bool Allowed Values: true|false .br -Check if all commands (used during takeSnapshot) work like expected on the remote host. +Preserve Access Control Lists (ACL). The source and destination systems must have compatible ACL entries for this option to work properly. .PP -Default: true + .RE -.IP "\fIprofile.snapshots.ssh.check_ping\fR" 6 +.IP "\fIprofile.snapshots.preserve_xattr\fR" 6 .RS Type: bool Allowed Values: true|false .br -Check if the remote host is available before trying to mount. +Preserve extended attributes (xattr). .PP -Default: true -.RE .IP "\fIprofile.snapshots.ssh.host\fR" 6 .RS -Type: str Allowed Values: IP or domain address +Type: bool Allowed Values: true|false .br Remote host used for mode 'ssh' and 'ssh_gocryptfs. .PP -Default: '' + .RE -.IP "\fIprofile.snapshots.ssh.ionice\fR" 6 +.IP "\fIprofile.snapshots.copy_links\fR" 6 .RS Type: bool Allowed Values: true|false .br -Run rsync and other commands on remote host with 'ionice \-c2 \-n7' +When symlinks are encountered, the item that they point to (the reference) is copied, rather than the symlink. .PP -Default: false -.RE -.IP "\fIprofile.snapshots.ssh.max_arg_length\fR" 6 -.RS -Type: int Allowed Values: 0, >700 -.br -Maximum command length of commands run on remote host. This can be tested for all ssh profiles in the configuration with 'python3 /usr/share/backintime/common/sshMaxArg.py LENGTH'. -.br -0 = unlimited -.PP -Default: 0 .RE -.IP "\fIprofile.snapshots.ssh.nice\fR" 6 +.IP "\fIprofile.snapshots.one_file_system\fR" 6 .RS Type: bool Allowed Values: true|false .br -Run rsync and other commands on remote host with 'nice \-n19' +Use rsync's "--one-file-system" to avoid crossing filesystem boundaries when recursing. .PP -Default: false + .RE -.IP "\fIprofile.snapshots.ssh.nocache\fR" 6 +.IP "\fIprofile.snapshots.rsync_options.enabled\fR" 6 .RS Type: bool Allowed Values: true|false .br -Run rsync on remote host with 'nocache'. This will prevent files from being cached in memory. +Past additional options to rsync .PP -Default: false -.RE -.IP "\fIprofile.snapshots.ssh.path\fR" 6 -.RS -Type: str Allowed Values: absolute or relative path -.br -Snapshot path on remote host. If the path is relative (no leading '/') it will start from remote Users homedir. An empty path will be replaced with './'. -.PP -Default: '' .RE -.IP "\fIprofile.snapshots.ssh.port\fR" 6 +.IP "\fIprofile.snapshots.rsync_options.value\fR" 6 .RS -Type: int Allowed Values: 0-65535 +Type: str Allowed Values: text .br -SSH Port on remote host. +Rsync options. Options must be quoted. .PP -Default: 22 + .RE .IP "\fIprofile.snapshots.ssh.prefix.enabled\fR" 6 @@ -722,7 +632,7 @@ Type: bool Allowed Values: true|false .br Add prefix to every command which run through SSH on remote host. .PP -Default: false + .RE .IP "\fIprofile.snapshots.ssh.prefix.value\fR" 6 @@ -731,52 +641,34 @@ Type: str Allowed Values: text .br Prefix to run before every command on remote host. Variables need to be escaped with \\$FOO. This doesn't touch rsync. So to add a prefix for rsync use \fIprofile.snapshots.rsync_options.value\fR with --rsync-path="FOO=bar:\\$FOO /usr/bin/rsync" .PP -Default: 'PATH=/opt/bin:/opt/sbin:\\$PATH' -.RE -.IP "\fIprofile.snapshots.ssh.private_key_file\fR" 6 -.RS -Type: str Allowed Values: absolute path to private key file -.br -Private key file used for password-less authentication on remote host. -.PP -Default: ~/.ssh/id_dsa .RE -.IP "\fIprofile.snapshots.ssh.proxy_host\fR" 6 +.IP "\fIprofile.snapshots.continue_on_errors\fR" 6 .RS -Type: str Allowed Values: text +Type: bool Allowed Values: true|false .br -Proxy host used to connect to remote host. +Continue on errors. This will keep incomplete snapshots rather than deleting and start over again. .PP -Default: IP or domain address -.RE -.IP "\fIprofile.snapshots.ssh.proxy_host_port\fR" 6 -.RS -Type: int Allowed Values: 0-65535 -.br -Proxy host port used to connect to remote host. -.PP -Default: 22 .RE -.IP "\fIprofile.snapshots.ssh.proxy_user\fR" 6 +.IP "\fIprofile.snapshots.use_checksum\fR" 6 .RS -Type: str Allowed Values: text +Type: bool Allowed Values: true|false .br -Remote SSH user +Use checksum to detect changes rather than size + time. .PP -Default: the local users name + .RE -.IP "\fIprofile.snapshots.ssh.user\fR" 6 +.IP "\fIprofile.snapshots.log_level\fR" 6 .RS -Type: str Allowed Values: text +Type: int Allowed Values: 1-3 .br -Remote SSH user +Log level used during takeSnapshot. 1 = Error 2 = Changes 3 = Info. .PP -Default: local users name + .RE .IP "\fIprofile.snapshots.take_snapshot_regardless_of_changes\fR" 6 @@ -785,56 +677,22 @@ Type: bool Allowed Values: true|false .br Create a new snapshot regardless if there were changes or not. .PP -Default: false -.RE -.IP "\fIprofile.snapshots.use_checksum\fR" 6 -.RS -Type: bool Allowed Values: true|false -.br -Use checksum to detect changes rather than size + time. -.PP -Default: false .RE -.IP "\fIprofile.snapshots.user_backup.ionice\fR" 6 +.IP "\fIprofile.global.use_flock\fR" 6 .RS Type: bool Allowed Values: true|false .br -Run BackInTime with 'ionice \-c2 \-n7' when taking a manual snapshot. This will give BackInTime the lowest IO bandwidth priority to not interrupt any other working process. +Prevent multiple snapshots (from different profiles or users) to be run at the same time. .PP -Default: false -.RE -.IP "\fIprofile.user_callback.no_logging\fR" 6 -.RS -Type: bool Allowed Values: true|false -.br -Do not catch std{out|err} from user-callback script. The script will only write to current TTY. Default is to catch std{out|err} and write it to syslog and TTY again. -.PP -Default: false -.RE - -.IP "\fIprofiles\fR" 6 -.RS -Type: str Allowed Values: int separated by colon (e.g. 1:3:4) -.br -All active Profiles ( in profile.snapshots...). -.PP -Default: 1 .RE -.IP "\fIprofiles.version\fR" 6 -.RS -Type: int Allowed Values: 1 -.br -Internal version of profiles config. -.PP -Default: 1 -.RE .SH SEE ALSO -backintime, backintime-qt. +.BR backintime (1), +.BR backintime-qt (1) .PP Back In Time also has a website: https://github.com/bit-team/backintime .SH AUTHOR -This manual page was written by BIT Team(). +This manual page was written by the Back In Time Team (). diff --git a/qt/app.py b/qt/app.py index 87d3fa2d9..2c6a0e36f 100644 --- a/qt/app.py +++ b/qt/app.py @@ -212,7 +212,7 @@ def __init__(self, config, appInstance, qapp): self.snapshotsList = [] # ??? - self.path = self.config.profileStrValue('qt.last_path', '/') + self.path = self.config.the_dict().get('qt.last_path', '/') self.widget_current_path.setText(self.path) self.path_history = tools.PathHistory(self.path) @@ -2606,8 +2606,7 @@ def load_state_data(cfg: config.Config) -> None: if len(sys.argv) > 1: raiseCmd = '\n'.join(sys.argv[1:]) - appInstance = guiapplicationinstance.GUIApplicationInstance( - cfg.appInstanceFile(), raiseCmd) + appInstance = guiapplicationinstance.GUIApplicationInstance(raiseCmd) cfg.PLUGIN_MANAGER.load(cfg=cfg) cfg.PLUGIN_MANAGER.appStart() diff --git a/qt/restoreconfigdialog.py b/qt/restoreconfigdialog.py index d15156253..3be7f285b 100644 --- a/qt/restoreconfigdialog.py +++ b/qt/restoreconfigdialog.py @@ -15,6 +15,7 @@ import getpass import threading import subprocess +import socket from typing import Any, Generator from pathlib import Path from queue import Queue @@ -196,7 +197,8 @@ def _create_hint(self, (QLabel): The label """ - sample_path = Path.home() / 'backintime' / config.host() \ + sample_path = Path.home() / 'backintime' \ + / socket.gethostname() \ / getpass.getuser() / '1' / '20250203-172341-123' sample_path = f'{str(sample_path)}' diff --git a/qt/test/test_lint.py b/qt/test/test_lint.py index b42d1218a..6a0fc226e 100644 --- a/qt/test/test_lint.py +++ b/qt/test/test_lint.py @@ -277,6 +277,7 @@ def test010_ruff_default_ruleset(self): @unittest.skipUnless(FLAKE8_AVAILABLE, BASE_REASON.format('flake8')) def test020_flake8_default_ruleset(self): """Flake8 in default mode.""" + cmd = [ 'flake8', f'--max-line-length={PEP8_MAX_LINE_LENGTH}',