From 585e7fb7abc4050421396dbf3288fc0e32449da7 Mon Sep 17 00:00:00 2001 From: Christian Buhtz Date: Thu, 15 Aug 2024 10:35:27 +0200 Subject: [PATCH 01/54] singleton structure --- common/konfig.py | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 common/konfig.py diff --git a/common/konfig.py b/common/konfig.py new file mode 100644 index 000000000..39a6820f1 --- /dev/null +++ b/common/konfig.py @@ -0,0 +1,37 @@ +# 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 . + +class Konfig: + """Manage configuration of Back In Time. + + That class is a replacement for the `config.Config` class. + """ + _instance = None + _buhtz = [] + + @classmethod + def instance(cls): + """Provide the singleton instance of that class.""" + + # Provide the instance if it exists + if cls._instance: + return cls._instance + + # But don't created implicite when needed. + raise RuntimeError( + f'No instance of class "{cls}" exists. Create an instance first.') + + def __init__(self): + # Exception when an instance exists + if __class__._instance: + raise Exception( + f'Instance of class "{self.__class__.__name__}" still exists! ' + f'Use "{self.__class__.__name__}.instance()" to access it.') + + # Remember the instance as the one and only singleton + __class__._instance = self From 9c9e3d27fa9bdf1ddacf7f3cb9f4cdc925941180 Mon Sep 17 00:00:00 2001 From: Christian Buhtz Date: Thu, 15 Aug 2024 13:57:25 +0200 Subject: [PATCH 02/54] singleton ala Mars Landis --- common/konfig.py | 36 ++++++---------- common/singleton.py | 77 +++++++++++++++++++++++++++++++++++ common/test/test_singleton.py | 59 +++++++++++++++++++++++++++ 3 files changed, 149 insertions(+), 23 deletions(-) create mode 100644 common/singleton.py create mode 100644 common/test/test_singleton.py diff --git a/common/konfig.py b/common/konfig.py index 39a6820f1..28b658f18 100644 --- a/common/konfig.py +++ b/common/konfig.py @@ -5,33 +5,23 @@ # 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 . +from pathlib import Path +import singleton -class Konfig: +class Konfig(singleton.Singleton): """Manage configuration of Back In Time. That class is a replacement for the `config.Config` class. """ - _instance = None - _buhtz = [] + def __init__(self, config_path: Path = None): + """ + """ + self._determine_config_path(config_path) - @classmethod - def instance(cls): - """Provide the singleton instance of that class.""" + def _determine_config_path(self, path: Path): + if path: + self._path = path + return - # Provide the instance if it exists - if cls._instance: - return cls._instance - - # But don't created implicite when needed. - raise RuntimeError( - f'No instance of class "{cls}" exists. Create an instance first.') - - def __init__(self): - # Exception when an instance exists - if __class__._instance: - raise Exception( - f'Instance of class "{self.__class__.__name__}" still exists! ' - f'Use "{self.__class__.__name__}.instance()" to access it.') - - # Remember the instance as the one and only singleton - __class__._instance = self + # TODO + # ...determine... diff --git a/common/singleton.py b/common/singleton.py new file mode 100644 index 000000000..1055888d0 --- /dev/null +++ b/common/singleton.py @@ -0,0 +1,77 @@ +# SPDX-FileCopyrightText: © 2022 Mars Landis +# SPDX-FileCopyrightText: © 2024 Christian BUHTZ +# +# SPDX-License-Identifier: CC0-1.0 +# +# This file is licensed under Creative Commons Zero v1.0 Universal (CC0-1.0) +# and is part of the program "Back In time" which is released under GNU General +# Public License v2 (GPLv2). See file LICENSE or go to +# . +# +# Credits to Mr. Mars Landis describing that solution in his artile +# 'Better Python Singleton with a Metaclass' at +# +# himself refering to this Stack Overflow +# question as his inspiration. +# +# Original code adapted by Christian Buhtz. + +"""Flexible and pythonic singleton implemention. + +Support inheritance and multiple classes. Multilevel inheritance is +theoretically possible if the '__allow_reinitialization' approach would be +implemented as described in the original article. + +Example :: + + >>> from singleton import Singleton + >>> + >>> class Foo(metaclass=Singleton): + ... def __init__(self): + ... self.value = 'Alyssa Ogawa' + >>> + >>> class Bar(metaclass=Singleton): + ... def __init__(self): + ... self.value = 'Naomi Wildmann' + >>> + >>> f = Foo() + >>> ff = Foo() + >>> f'{f.value=} :: {ff.value=}' + "f.value='Alyssa Ogawa' :: ff.value='Alyssa Ogawa'" + >>> ff.value = 'Who?' + >>> f'{f.value=} :: {ff.value=}' + "f.value='Who?' :: ff.value='Who?'" + >>> + >>> b = Bar() + >>> bb = Bar() + >>> f'{b.value=} :: {bb.value=}' + "b.value='Naomi Wildmann' :: bb.value='Naomi Wildmann'" + >>> b.value = 'thinking ...' + >>> f'{b.value=} :: {bb.value=}' + "b.value='thinking ...' :: bb.value='thinking ...'" + >>> + >>> id(f) == id(ff) + True + >>> id(b) == id(bb) + True + >>> id(f) == id(b) + False +""" +class Singleton(type): + """ + """ + _instances = {} + """Hold single instances of multiple classes.""" + + def __call__(cls, *args, **kwargs): + + try: + # Re-use existing instance + return cls._instances[cls] + + except KeyError as exc: + # Create new instance + cls._instances[cls] = super().__call__(*args, **kwargs) + + return cls._instances[cls] + diff --git a/common/test/test_singleton.py b/common/test/test_singleton.py new file mode 100644 index 000000000..0731d99ed --- /dev/null +++ b/common/test/test_singleton.py @@ -0,0 +1,59 @@ +# 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 singleton + + +class Test(unittest.TestCase): + class Foo(metaclass=singleton.Singleton): + def __init__(self): + self.value = 'Ogawa' + + class Bar(metaclass=singleton.Singleton): + def __init__(self): + self.value = 'Naomi' + + def setUp(self): + # Clean up all instances + Singleton._instances = {} + + def test_twins(self): + """Identical id and values.""" + a = Foo() + b = Foo() + + self.assertEqual(id(a), id(b)) + self.assertEqual(a.value, b.value) + + def test_share_value(self): + """Modify value""" + a = Foo() + b = Foo() + a.value = 'foobar' + + self.assertEqual(a.value, 'foobar') + self.assertEqual(a.value, b.value) + + def test_multi_class(self): + """Two different singleton classes.""" + a = Foo() + b = Foo() + x = Bar() + y = Bar() + + self.assertEqual(id(a), id(b)) + self.assertEqual(id(x), id(y)) + self.assertNotEqual(id(a), id(y)) + + self.assertEqual(a.value, 'Ogawa') + self.assertEqual(x.value, 'Naomi') + + a.value = 'who' + self.assertEqual(b.value, 'who') + self.assertEqual(x.value, 'Naomi') + self.assertEqual(x.value, y.value) From bcb9df581bec7ecb7609094f78f359a98fccba33 Mon Sep 17 00:00:00 2001 From: Christian Buhtz Date: Thu, 15 Aug 2024 23:36:43 +0200 Subject: [PATCH 03/54] x --- common/konfig.py | 120 +++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 112 insertions(+), 8 deletions(-) diff --git a/common/konfig.py b/common/konfig.py index 28b658f18..fa9c6d8cc 100644 --- a/common/konfig.py +++ b/common/konfig.py @@ -5,23 +5,127 @@ # 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 . +from __future__ import annotations +import os +import configparser +from typing import Union from pathlib import Path +from io import StringIO import singleton +import logger -class Konfig(singleton.Singleton): + +class Konfig(metaclass=singleton.Singleton): """Manage configuration of Back In Time. That class is a replacement for the `config.Config` class. """ + # use with ConfigParser(defaults=_DEFAULT) + # _DEFAULT = { + # 'foo': 7, + # 'bar': 'bähm', + # 'profiles': '24842' + # } + + # class _AttrSection: + # def __init__(self, + # name: str, + # parent: _AttrSection, + # section: configparser.SectionProxy): + # self._name = name + # self._parent = parent + # self._section = section + + # def full_attr_name(self) -> str: + # return f'{self.parent.full_attr_name}.{self._name}' + + # def __getattr__(self, attr: str): + # if '.' in attr: + # attr_section = _AttrSection( + # name=attr, parent=self, section=self._section) + # return attr_section[attr] + + # return self._conf[attr] + + class Profile: + def __init__(self, profile_id: int, config: Konfig): + self._config = config + self._prefix = f'profile{profile_id}' + + def __getitem__(self, key: str): + return self._config[f'{self._prefix}.{key}'] + + _DEFAULT_SECTION = '[bit]' + def __init__(self, config_path: Path = None): """ """ - self._determine_config_path(config_path) + if not config_path: + xdg_config = os.environ.get('XDG_CONFIG_HOME', + os.environ['HOME'] + '/.config') + self._path = Path(xdg_config) / 'backintime' / 'config' + + logger.debug(f'Config path used: {self._path}') + + self.load() + + # Names and IDs of profiles + name_items = filter( + lambda val: + val[0].startswith('profile') and val[0].endswith('name'), + self._conf.items() + ) + self._profiles = { + name: int(pid.replace('profile', '').replace('.name', '')) + for pid, name in name_items + } + # # First/Default profile not stored with name + # self._profiles[1] = _('Main profile') + + def __getitem__(self, key: str): + return self._conf[key] + + def profile(self, name_or_id: Union[str, int]) -> Profile: + if isinstance(name_or_id, int): + profile_id = name_or_id + else: + profile_id = self._profiles[name_or_id] + + return self.Profile(profile_id=profile_id, config=self) + + @property + def profile_names(self) -> list[str]: + return list(self._profiles.keys()) + + @property + def profile_ids(self) -> list[int]: + return list(self._profiles.values()) + + def load(self): + self._config_parser = configparser.ConfigParser( + defaults={'profile1.name': _('Main profile')}) + + with self._path.open('r', encoding='utf-8') as handle: + content = handle.read() + logger.debug(f'Configuration read from "{self._path}".') + + # 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['bit'] + + def save(self): + buffer = StringIO() + self._config_parser.write(buffer) + buffer.seek(0) + + with self._path.open('w', encoding='utf-8') as handle: + # Write to file without section header + handle.write(''.join(buffer.readlines()[1:])) + logger.debug(f'Configuration written to "{self._path}".') - def _determine_config_path(self, path: Path): - if path: - self._path = path - return - # TODO - # ...determine... +if __name__ == '__main__': + _ = lambda s: s + k = Konfig() From 7f02bb14e1f6987bd8afe50826e1283348e1a6ab Mon Sep 17 00:00:00 2001 From: Christian Buhtz Date: Mon, 19 Aug 2024 17:17:22 +0200 Subject: [PATCH 04/54] initial neo config inspection --- common/konfig.py | 7 +++++ create-manapge-backintime-config2.py | 42 ++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100755 create-manapge-backintime-config2.py diff --git a/common/konfig.py b/common/konfig.py index fa9c6d8cc..ec6d11ce1 100644 --- a/common/konfig.py +++ b/common/konfig.py @@ -95,10 +95,17 @@ def profile(self, name_or_id: Union[str, int]) -> Profile: @property def profile_names(self) -> list[str]: + "bar" return list(self._profiles.keys()) + @profile_names.setter + def profile_names(self, val): + """boom""" + pass + @property def profile_ids(self) -> list[int]: + """foo""" return list(self._profiles.values()) def load(self): diff --git a/create-manapge-backintime-config2.py b/create-manapge-backintime-config2.py new file mode 100755 index 000000000..a753f55b7 --- /dev/null +++ b/create-manapge-backintime-config2.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +import sys +import inspect +from pathlib import Path + +SRC_PATH = Path.cwd() / 'common' / 'konfig.py' + +# Workaround (see #1575) +sys.path.insert(0, str(SRC_PATH.parent)) +import konfig + +def _get_public_properties() -> tuple: + """Extract the public properties from our target config class.""" + def _is_public_property(val): + return ( + not val.startswith('_') + and isinstance(getattr(konfig.Konfig, val), property) + ) + + return tuple(filter(_is_public_property, dir(konfig.Konfig))) + +def lint_manpage() -> bool: + """ + LC_ALL=C.UTF-8 MANROFFSEQ='' MANWIDTH=80 man --warnings -E UTF-8 -l -Tutf8 -Z >/dev/null + """ + return False + +def main(): + for prop in _get_public_properties(): + attr = getattr(konfig.Konfig, prop) + + # Ignore properties without docstring + if not attr.__doc__: + print('Missing docstring for "{prop}". Ignoring it.') + continue + + print(f'Public property: {prop}') + print(attr.__doc__) + + +if __name__ == '__main__': + main() From 289e57279903ee4103d4ef12dfbc43044f5f1f75 Mon Sep 17 00:00:00 2001 From: Christian Buhtz Date: Mon, 19 Aug 2024 17:24:56 +0200 Subject: [PATCH 05/54] x --- create-manapge-backintime-config2.py | 39 ++++++++++++++++++++++++++-- create-manpage-backintime-config.py | 5 ++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/create-manapge-backintime-config2.py b/create-manapge-backintime-config2.py index a753f55b7..7d8f2396a 100755 --- a/create-manapge-backintime-config2.py +++ b/create-manapge-backintime-config2.py @@ -3,11 +3,46 @@ import inspect from pathlib import Path -SRC_PATH = Path.cwd() / 'common' / 'konfig.py' - # Workaround (see #1575) sys.path.insert(0, str(SRC_PATH.parent)) import konfig +import version + +VERSION = version.__version__ +TIMESTAMP = strftime('%b %Y', gmtime()) + +HEADER = r'''.TH backintime-config 1 "{TIMESTAMP}" "version {VERSION}" "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 +''' + +FOOTER = r'''.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 the BIT Team(). +''' + + def _get_public_properties() -> tuple: """Extract the public properties from our target config class.""" diff --git a/create-manpage-backintime-config.py b/create-manpage-backintime-config.py index c489e8bc1..bc884851f 100755 --- a/create-manpage-backintime-config.py +++ b/create-manpage-backintime-config.py @@ -109,6 +109,9 @@ def output(instance='', name='', values='', default='', comment='', reference='', line=0): """ """ + print(f'output() :: {instance=} {name=} {values=} {default=} {comment=} ' + f'{reference=} {line=}') + if not default: default = "''" @@ -126,6 +129,8 @@ def output(instance='', name='', values='', default='', ret += '.RE\n' + print(f'output() :: {ret=}\n') + return ret From 43034b6c1aa70243261b6a5f7d888f40d8f9efe0 Mon Sep 17 00:00:00 2001 From: Christian Buhtz Date: Mon, 19 Aug 2024 21:29:20 +0200 Subject: [PATCH 06/54] fixed manpage --- common/config.py | 17 ++++------ common/man/C/backintime-config.1 | 50 +++++++++++++++++++++++----- create-manapge-backintime-config2.py | 0 create-manpage-backintime-config.py | 1 + 4 files changed, 49 insertions(+), 19 deletions(-) mode change 100755 => 100644 create-manapge-backintime-config2.py diff --git a/common/config.py b/common/config.py index 3e76b3644..e8cc26618 100644 --- a/common/config.py +++ b/common/config.py @@ -668,18 +668,15 @@ def setSshProxyHost(self, value, profile_id=None): self.setProfileStrValue('snapshots.ssh.proxy_host', value, profile_id) 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) + #?SSH Port on remote proxy host.;0-65535 + return self.profileIntValue('snapshots.ssh.proxy_host_port', '22', profile_id) - def setSshProxyPort(self, value, profile_id = None): - self.setProfileIntValue( - 'snapshots.ssh.proxy_host_port', value, profile_id) + def setSshProxyPort(self, value, profile_id=None): + self.setProfileIntValue('snapshots.ssh.proxy_host_port', value, profile_id) def sshProxyUser(self, profile_id=None): - #?Remote SSH user;;local users name - return self.profileStrValue( - 'snapshots.ssh.proxy_user', getpass.getuser(), profile_id) + #?Remote SSH Proxy user + return self.profileStrValue('snapshots.ssh.proxy_user', getpass.getuser(), profile_id) def setSshProxyUser(self, value, profile_id=None): self.setProfileStrValue('snapshots.ssh.proxy_user', value, profile_id) @@ -687,7 +684,7 @@ def setSshProxyUser(self, value, profile_id=None): 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/sshMaxArg.py [initial_ssh_cmd_length]'.\n + #?with 'python3 /usr/share/backintime/common/sshMaxArg.py LENGTH'.\n #?0 = unlimited;0, >700 value = self.profileIntValue('snapshots.ssh.max_arg_length', 0, profile_id) if value and value < 700: diff --git a/common/man/C/backintime-config.1 b/common/man/C/backintime-config.1 index f57acba19..afd118b19 100644 --- a/common/man/C/backintime-config.1 +++ b/common/man/C/backintime-config.1 @@ -1,4 +1,4 @@ -.TH backintime-config 1 "August 2024" "version 1.5.3-dev.3e80feee" "USER COMMANDS" +.TH backintime-config 1 "Aug 2024" "version 1.5.3-dev" "USER COMMANDS" .SH NAME config \- BackInTime configuration files. .SH SYNOPSIS @@ -73,6 +73,15 @@ Which day of month the cronjob should run? Only valid for \fIprofile.schedule 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 @@ -682,7 +691,7 @@ Default: false .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 [initial_ssh_cmd_length]'. +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 @@ -752,6 +761,33 @@ Private key file used for password-less authentication on remote host. 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 @@ -815,12 +851,8 @@ Internal version of profiles config. Default: 1 .RE .SH SEE ALSO -.BR backintime (1), -.BR backintime-qt (1), -.BR backintime-askpass (1) -.PP -\fBBack In Time\fP project website: https://github.com/bit-team/backintime +backintime, backintime-qt. .PP -\fBBack In Time\fP mailing list: https://mail.python.org/mailman3/lists/bit-dev.python.org +Back In Time also has a website: https://github.com/bit-team/backintime .SH AUTHOR -\fBBack In Time\fP Team +This manual page was written by BIT Team(). diff --git a/create-manapge-backintime-config2.py b/create-manapge-backintime-config2.py old mode 100755 new mode 100644 diff --git a/create-manpage-backintime-config.py b/create-manpage-backintime-config.py index bc884851f..9f6537591 100755 --- a/create-manpage-backintime-config.py +++ b/create-manpage-backintime-config.py @@ -182,6 +182,7 @@ def process_line(d, key, profile, instance, name, var, default, commentline, default = default.lower() d[key][INSTANCE] = instance + print(f'\n{force_var=} {var=} {name=}') d[key][NAME] = re.sub( r'%[\S]', '<%s>' % select(force_var, var).upper(), name ) From 53f25499dea0760e3bd413d8a1a4c9096c34b899 Mon Sep 17 00:00:00 2001 From: Christian Buhtz Date: Mon, 19 Aug 2024 21:39:45 +0200 Subject: [PATCH 07/54] fix --- common/config.py | 13 +++----- common/man/C/backintime-config.1 | 52 ++++++++++++++++++++++++++------ 2 files changed, 47 insertions(+), 18 deletions(-) diff --git a/common/config.py b/common/config.py index 3e76b3644..8f903d014 100644 --- a/common/config.py +++ b/common/config.py @@ -669,17 +669,14 @@ def setSshProxyHost(self, value, profile_id=None): 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) 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) def sshProxyUser(self, profile_id=None): - #?Remote SSH user;;local users name - return self.profileStrValue( - 'snapshots.ssh.proxy_user', getpass.getuser(), profile_id) + #?Remote SSH user;;the local users name + return self.profileStrValue('snapshots.ssh.proxy_user', getpass.getuser(), profile_id) def setSshProxyUser(self, value, profile_id=None): self.setProfileStrValue('snapshots.ssh.proxy_user', value, profile_id) @@ -687,7 +684,7 @@ def setSshProxyUser(self, value, profile_id=None): 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/sshMaxArg.py [initial_ssh_cmd_length]'.\n + #?with 'python3 /usr/share/backintime/common/sshMaxArg.py LENGTH'.\n #?0 = unlimited;0, >700 value = self.profileIntValue('snapshots.ssh.max_arg_length', 0, profile_id) if value and value < 700: diff --git a/common/man/C/backintime-config.1 b/common/man/C/backintime-config.1 index f57acba19..46b9b3cf3 100644 --- a/common/man/C/backintime-config.1 +++ b/common/man/C/backintime-config.1 @@ -1,4 +1,4 @@ -.TH backintime-config 1 "August 2024" "version 1.5.3-dev.3e80feee" "USER COMMANDS" +.TH backintime-config 1 "Aug 2024" "version 1.5.3-dev" "USER COMMANDS" .SH NAME config \- BackInTime configuration files. .SH SYNOPSIS @@ -73,6 +73,15 @@ Which day of month the cronjob should run? Only valid for \fIprofile.schedule 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 @@ -469,7 +478,7 @@ Default: false .IP "\fIprofile.snapshots.path\fR" 6 .RS -Type: str Allowed Values: absolute path +Type: st Allowed Values: absolute path .br Where to save snapshots in mode 'local'. This path must contain a folderstructure like 'backintime///' .PP @@ -682,7 +691,7 @@ Default: false .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 [initial_ssh_cmd_length]'. +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 @@ -752,6 +761,33 @@ Private key file used for password-less authentication on remote host. 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 +Proxy host port used to connect to remote host. +.PP +Default: 22 +.RE + +.IP "\fIprofile.snapshots.ssh.proxy_user\fR" 6 +.RS +Type: str Allowed Values: text +.br +Remote SSH user +.PP +Default: the local users name +.RE + .IP "\fIprofile.snapshots.ssh.user\fR" 6 .RS Type: str Allowed Values: text @@ -815,12 +851,8 @@ Internal version of profiles config. Default: 1 .RE .SH SEE ALSO -.BR backintime (1), -.BR backintime-qt (1), -.BR backintime-askpass (1) -.PP -\fBBack In Time\fP project website: https://github.com/bit-team/backintime +backintime, backintime-qt. .PP -\fBBack In Time\fP mailing list: https://mail.python.org/mailman3/lists/bit-dev.python.org +Back In Time also has a website: https://github.com/bit-team/backintime .SH AUTHOR -\fBBack In Time\fP Team +This manual page was written by BIT Team(). From 4c4f9a6f372d45740d2d8d88717ffa3f24b965a0 Mon Sep 17 00:00:00 2001 From: Christian Buhtz Date: Tue, 20 Aug 2024 10:40:40 +0200 Subject: [PATCH 08/54] add man page validation into release-howto [skip ci] --- common/doc-dev/BiT_release_process.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/common/doc-dev/BiT_release_process.md b/common/doc-dev/BiT_release_process.md index e95418df6..c3ab30a00 100644 --- a/common/doc-dev/BiT_release_process.md +++ b/common/doc-dev/BiT_release_process.md @@ -44,6 +44,12 @@ using a "feature" branch and sending a pull request asking for a review. - Execute the script `./updateversion.sh` to update the version numbers (based on `VERSION` file) in several files. - Update the "as at" date in the man page files `backintime.1` and `backintime-askpass.1`. - Autogenerate and update the man page file `backintime-config.1` by executing the script `common/create-manapge-backintime-config.py`. + - Validate the content of the created man page. It could be compared to the + previous man page. + - Create a plain text file from the man pages: `man | col -b > + man.plain.txt` + - Use `git diff` (or another diff tool) to compare them and see if the + content is as expected. - Update `README.md` file. - Run `codespell` to check for common spelling errors. - Commit the changes. From 2d03f6c0091780a9dad925c07b4ae456baa13ec5 Mon Sep 17 00:00:00 2001 From: Christian Buhtz Date: Tue, 20 Aug 2024 10:44:23 +0200 Subject: [PATCH 09/54] typo [skip ci] --- common/man/C/backintime-config.1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/man/C/backintime-config.1 b/common/man/C/backintime-config.1 index 46b9b3cf3..27663819c 100644 --- a/common/man/C/backintime-config.1 +++ b/common/man/C/backintime-config.1 @@ -478,7 +478,7 @@ Default: false .IP "\fIprofile.snapshots.path\fR" 6 .RS -Type: st Allowed Values: absolute path +Type: str Allowed Values: absolute path .br Where to save snapshots in mode 'local'. This path must contain a folderstructure like 'backintime///' .PP From 6a459ce830bf6d637907b7ae55ea8a2591149cca Mon Sep 17 00:00:00 2001 From: Christian Buhtz Date: Tue, 20 Aug 2024 11:56:31 +0200 Subject: [PATCH 10/54] improve original create manapge script --- create-manpage-backintime-config.py | 76 ++++++++++++++++++++++------- 1 file changed, 58 insertions(+), 18 deletions(-) diff --git a/create-manpage-backintime-config.py b/create-manpage-backintime-config.py index 9f6537591..154bfafdc 100755 --- a/create-manpage-backintime-config.py +++ b/create-manpage-backintime-config.py @@ -104,32 +104,48 @@ REFERENCE = 'reference' LINE = 'line' +def groff_indented_paragraph(label: str, indent: int=6) -> str: + """.IP - Indented Paragraph""" + return f'.IP "{label}" {indent}' -def output(instance='', name='', values='', default='', - comment='', reference='', line=0): +def groff_italic(text: str) -> str: + """\\fi - Italic""" + return f'\\fI{text}\\fR' + +def groff_indented_block(text: str) -> str: """ + .RS - Start indented block + .RE - End indented block """ - print(f'output() :: {instance=} {name=} {values=} {default=} {comment=} ' - f'{reference=} {line=}') + 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' + + +def output(instance='', name='', values='', default='', + comment='', reference='', line=0): + """Generate GNU Troff (groff) markup code for the given config entry.""" if not default: default = "''" - ret = '.IP "\\fI%s\\fR" 6\n' % name - ret += '.RS\n' - ret += 'Type: %-10sAllowed Values: %s\n' % (instance.lower(), values) - ret += '.br\n' - ret += '%s\n' % comment - ret += '.PP\n' + ret = f'Type: {instance.lower():<10}Allowed Values: {values}\n' + ret += groff_linebreak() + ret += f'{comment}\n' + ret += groff_paragraph_break() if SORT: - ret += 'Default: %s\n' % default + ret += f'Default: {default}' else: - ret += 'Default: %-18s %s line: %d\n' % (default, reference, line) - - ret += '.RE\n' + ret += f'Default: {default:<18} {reference} line: {line}' - print(f'output() :: {ret=}\n') + ret = groff_indented_block(ret) + ret = groff_indented_paragraph(groff_italic(name)) + ret return ret @@ -154,6 +170,7 @@ def select_values(instance, values): 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 @@ -182,7 +199,6 @@ def process_line(d, key, profile, instance, name, var, default, commentline, default = default.lower() d[key][INSTANCE] = instance - print(f'\n{force_var=} {var=} {name=}') d[key][NAME] = re.sub( r'%[\S]', '<%s>' % select(force_var, var).upper(), name ) @@ -231,7 +247,7 @@ def main(): regex_default = re.compile(r'(^DEFAULT[\w]*|CONFIG_VERSION)[\s]*= (.*)') with open(CONFIG, 'r') as f: - print(f'Read "{CONFIG}".') + print(f'Read and parse "{CONFIG}".') commentline = '' values = force_var = force_default = instance \ = name = var = default = None @@ -354,13 +370,37 @@ def main(): commentline = '' + """ + Example for content of 'd': + { + "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 + } + """ with open(MAN, 'w') as f: - print(f'Write into "{MAN}".') + 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] f.write('\n'.join(output(**d[key]) for key in sorted(d, key=s))) From cdff4a7339482c01090759cfb38d9302140abf6a Mon Sep 17 00:00:00 2001 From: Christian Buhtz Date: Tue, 20 Aug 2024 11:58:45 +0200 Subject: [PATCH 11/54] refactor create manpage script [skip ci] --- create-manpage-backintime-config.py | 72 +++++++++++++++++++++++------ 1 file changed, 59 insertions(+), 13 deletions(-) diff --git a/create-manpage-backintime-config.py b/create-manpage-backintime-config.py index c489e8bc1..154bfafdc 100755 --- a/create-manpage-backintime-config.py +++ b/create-manpage-backintime-config.py @@ -104,27 +104,48 @@ REFERENCE = 'reference' LINE = 'line' +def groff_indented_paragraph(label: str, indent: int=6) -> str: + """.IP - Indented Paragraph""" + return f'.IP "{label}" {indent}' -def output(instance='', name='', values='', default='', - comment='', reference='', line=0): +def groff_italic(text: str) -> str: + """\\fi - Italic""" + return f'\\fI{text}\\fR' + +def groff_indented_block(text: str) -> str: """ + .RS - Start indented block + .RE - End indented block """ + 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' + + +def output(instance='', name='', values='', default='', + comment='', reference='', line=0): + """Generate GNU Troff (groff) markup code for the given config entry.""" if not default: default = "''" - ret = '.IP "\\fI%s\\fR" 6\n' % name - ret += '.RS\n' - ret += 'Type: %-10sAllowed Values: %s\n' % (instance.lower(), values) - ret += '.br\n' - ret += '%s\n' % comment - ret += '.PP\n' + ret = f'Type: {instance.lower():<10}Allowed Values: {values}\n' + ret += groff_linebreak() + ret += f'{comment}\n' + ret += groff_paragraph_break() if SORT: - ret += 'Default: %s\n' % default + ret += f'Default: {default}' else: - ret += 'Default: %-18s %s line: %d\n' % (default, reference, line) + ret += f'Default: {default:<18} {reference} line: {line}' - ret += '.RE\n' + ret = groff_indented_block(ret) + ret = groff_indented_paragraph(groff_italic(name)) + ret return ret @@ -149,6 +170,7 @@ def select_values(instance, values): 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 @@ -225,7 +247,7 @@ def main(): regex_default = re.compile(r'(^DEFAULT[\w]*|CONFIG_VERSION)[\s]*= (.*)') with open(CONFIG, 'r') as f: - print(f'Read "{CONFIG}".') + print(f'Read and parse "{CONFIG}".') commentline = '' values = force_var = force_default = instance \ = name = var = default = None @@ -348,13 +370,37 @@ def main(): commentline = '' + """ + Example for content of 'd': + { + "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 + } + """ with open(MAN, 'w') as f: - print(f'Write into "{MAN}".') + 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] f.write('\n'.join(output(**d[key]) for key in sorted(d, key=s))) From afde40f68e160c1e382b5e844ca39daba5705c36 Mon Sep 17 00:00:00 2001 From: Christian Buhtz Date: Tue, 20 Aug 2024 17:27:01 +0200 Subject: [PATCH 12/54] x --- common/konfig.py | 21 +- common/man/C/backintime-config.1.org | 858 +++++++++++++++++++++++++++ create-manapge-backintime-config2.py | 2 +- create-manpage-backintime-config.py | 329 +++++----- 4 files changed, 1007 insertions(+), 203 deletions(-) create mode 100644 common/man/C/backintime-config.1.org diff --git a/common/konfig.py b/common/konfig.py index ec6d11ce1..f1fb9b8b6 100644 --- a/common/konfig.py +++ b/common/konfig.py @@ -95,17 +95,10 @@ def profile(self, name_or_id: Union[str, int]) -> Profile: @property def profile_names(self) -> list[str]: - "bar" return list(self._profiles.keys()) - @profile_names.setter - def profile_names(self, val): - """boom""" - pass - @property def profile_ids(self) -> list[int]: - """foo""" return list(self._profiles.values()) def load(self): @@ -132,6 +125,20 @@ def save(self): handle.write(''.join(buffer.readlines()[1:])) logger.debug(f'Configuration written to "{self._path}".') + @property + def hash_collision(self): + """Internal value used to prevent hash collisions on mountpoints. + Do not change this. + + { + 'name': 'global.hash_collision', + 'values': (0, 99999), + 'default': 0, + } + """ + return self._conf['global.hash_collision'] + + if __name__ == '__main__': _ = lambda s: s diff --git a/common/man/C/backintime-config.1.org b/common/man/C/backintime-config.1.org new file mode 100644 index 000000000..afd118b19 --- /dev/null +++ b/common/man/C/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/create-manapge-backintime-config2.py b/create-manapge-backintime-config2.py index 7d8f2396a..40a74f549 100644 --- a/create-manapge-backintime-config2.py +++ b/create-manapge-backintime-config2.py @@ -44,7 +44,7 @@ -def _get_public_properties() -> tuple: +def _get_public_properties(cls) -> tuple: """Extract the public properties from our target config class.""" def _is_public_property(val): return ( diff --git a/create-manpage-backintime-config.py b/create-manpage-backintime-config.py index 154bfafdc..7bbd733fe 100755 --- a/create-manpage-backintime-config.py +++ b/create-manpage-backintime-config.py @@ -1,21 +1,12 @@ #!/usr/bin/env python3 -# Back In Time -# Copyright (C) 2012-2022 Germar Reitze +# SPDX-FileCopyrightText: © 2012-2022 Germar Reitze +# SPDX-FileCopyrightText: © 2024 Christian BUHTZ # -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation; either version 2 of the License, or -# (at your option) any later version. +# SPDX-License-Identifier: GPL-2.0 # -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License along -# with this program; if not, write to the Free Software Foundation, Inc., -# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. - +# 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 . """This script is a helper to create a manpage about Back In Times's config file. @@ -46,26 +37,34 @@ 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 +from pathlib import Path from time import strftime, gmtime +# Workaround (see #1575) +sys.path.insert(0, str(Path.cwd() / 'common')) +import konfig +import version -PATH = os.path.join(os.getcwd(), 'common') +# PATH = os.path.join(os.getcwd(), 'common') +# CONFIG = os.path.join(PATH, 'config.py') +MAN = Path.cwd() / 'common' / 'man' / 'C' / 'backintime-config.1' -CONFIG = os.path.join(PATH, 'config.py') -MAN = os.path.join(PATH, 'man/C/backintime-config.1') - -with open(os.path.join(PATH, '../VERSION'), 'r') as f: - VERSION = f.read().strip('\n') +# 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 ?\( ?[\'"](.*?)[\'"] ?(%?[^,]*?), ?[\'"]?([^\'",\)]*)[\'"]?') +# c_list = re.compile(r'.*?self\.(?!set)((?:profile)?)(List)Value ?\( ?[\'"](.*?)[\'"], ?((?:\(.*\)|[^,]*)), ?[\'"]?([^\'",\)]*)[\'"]?') +# c = re.compile(r'.*?self\.(?!set)((?:profile)?)(.*?)Value ?\( ?[\'"](.*?)[\'"] ?(%?[^,]*?), ?[\'"]?([^\'",\)]*)[\'"]?') + + +VERSION = version.__version__ +TIMESTAMP = strftime('%b %Y', gmtime()) -HEADER = r'''.TH backintime-config 1 "%s" "version %s" "USER COMMANDS" +HEADER = r'''.TH backintime-config 1 "{TIMESTAMP}" "version {VERSION}" "USER COMMANDS" .SH NAME config \- BackInTime configuration files. .SH SYNOPSIS @@ -86,14 +85,14 @@ .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. .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 BIT Team(). ''' INSTANCE = 'instance' @@ -128,7 +127,7 @@ def groff_paragraph_break() -> str: return '.PP\n' -def output(instance='', name='', values='', default='', +def entry_to_groff(instance='', name='', values='', default='', comment='', reference='', line=0): """Generate GNU Troff (groff) markup code for the given config entry.""" if not default: @@ -157,7 +156,7 @@ def select(a, b): return b -def select_values(instance, values): +def _DEPRECATED_select_values(instance, values): if values: return values @@ -168,7 +167,7 @@ def select_values(instance, values): }[instance.lower()] -def process_line(d, key, profile, instance, name, var, default, commentline, +def _DEPRECATED_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' @@ -209,166 +208,102 @@ def process_line(d, key, profile, instance, name, var, default, commentline, d[key][LINE] = counter +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) + ) + + return tuple(filter(_is_public_property, dir(konfig.Konfig))) + +def lint_manpage() -> bool: + """ + LC_ALL=C.UTF-8 MANROFFSEQ='' MANWIDTH=80 man --warnings -E UTF-8 -l -Tutf8 -Z >/dev/null + """ + 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 - } - } - - # 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 - ) - - values = force_var = force_default = instance \ - = name = var = default = None - - commentline = '' + # Extract multiline string between { and the latest } + rex = re.compile(r'\{([\s\S]*)\}') + + entries = {} + profile_entries = {} + + # Each "global" public property + for prop in _get_public_properties(konfig.Konfig): + attr = getattr(konfig.Konfig, prop) + + # Ignore properties without docstring + if not attr.__doc__: + print(f'Ignoring "{prop}" because of missing docstring.') + continue + + print(f'Public property: {prop}') + print(attr.__doc__) + + doc = attr.__doc__ + + # extract the dict + the_dict = rex.search(doc).groups()[0] + the_dict = '{' + the_dict + '}' + # Remove dict-like string from the doc string + doc = doc.replace(the_dict, '') + # Remove empty lines and other blanks + doc = ' '.join(line.strip() + for line in + filter(lambda val: len(val.strip()), doc.split('\n'))) + # Make it a real dict + the_dict = eval(the_dict) + the_dict['doc'] = doc + + # store the result + entries[the_dict.pop('name')] = the_dict + + import json + print(json.dumps(entries, indent=4)) + + # Each "profile" public property + for prop in _get_public_properties(konfig.Konfig.Profile): + attr = getattr(konfig.Konfig.Profile, prop) + + + + sys.exit() + + + + # 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 + # } + # } """ Example for content of 'd': @@ -382,7 +317,7 @@ def main(): "reference": "configfile.py", "line": 472 }, - "profile.name": { + "profile.name": { "instance": "str", "name": "profile.name", "values": "text", @@ -392,9 +327,9 @@ def main(): "line": 704 } """ - with open(MAN, 'w') as f: + with MAN.open('w', encoding='utf-8') as handle: print(f'Write GNU Troff (groff) markup to "{MAN}". {SORT=}') - f.write(HEADER) + handle.write(HEADER) if SORT: # Sort by alphabet @@ -403,8 +338,12 @@ def main(): # Sort by line numbering (in the source file) s = lambda x: d[x][LINE] - f.write('\n'.join(output(**d[key]) for key in sorted(d, key=s))) - f.write(FOOTER) + handle.write('\n'.join( + entry_to_groff(**d[key]) + for key in sorted(d, key=s) + )) + + handle.write(FOOTER) if __name__ == '__main__': From 40426712375ce65cf78caa1c97902e5d40f6a4e4 Mon Sep 17 00:00:00 2001 From: Christian Buhtz Date: Wed, 21 Aug 2024 16:19:41 +0200 Subject: [PATCH 13/54] x --- common/man/C/backintime-config.1 | 842 +--------------------------- create-manpage-backintime-config.py | 186 +++--- 2 files changed, 114 insertions(+), 914 deletions(-) diff --git a/common/man/C/backintime-config.1 b/common/man/C/backintime-config.1 index 27663819c..c314e5fbf 100644 --- a/common/man/C/backintime-config.1 +++ b/common/man/C/backintime-config.1 @@ -1,15 +1,12 @@ -.TH backintime-config 1 "Aug 2024" "version 1.5.3-dev" "USER COMMANDS" +.TH backintime-config 1 "Aug 2024" "version 1.5.3-dev.3e80feee" "USER COMMANDS" .SH NAME -config \- BackInTime configuration files. +config \- Back In Time configuration file. .SH SYNOPSIS -~/.config/backintime/config -.br +~/.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 .Bbackintime-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 @@ -21,838 +18,17 @@ Run 'backintime check-config' to verify the configfile, create the snapshot fold .SH POSSIBLE KEYWORDS .IP "\fIglobal.hash_collision\fR" 6 .RS -Type: int Allowed Values: 0-99999 +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 -Proxy host port used to connect to remote host. -.PP -Default: 22 -.RE - -.IP "\fIprofile.snapshots.ssh.proxy_user\fR" 6 -.RS -Type: str Allowed Values: text -.br -Remote SSH user -.PP -Default: the local users name -.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. +.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 BIT Team(). diff --git a/create-manpage-backintime-config.py b/create-manpage-backintime-config.py index 7bbd733fe..ea539867d 100755 --- a/create-manpage-backintime-config.py +++ b/create-manpage-backintime-config.py @@ -43,58 +43,14 @@ import inspect 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') -# CONFIG = os.path.join(PATH, 'config.py') MAN = Path.cwd() / 'common' / 'man' / 'C' / 'backintime-config.1' -# 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 ?\( ?[\'"](.*?)[\'"] ?(%?[^,]*?), ?[\'"]?([^\'",\)]*)[\'"]?') - - -VERSION = version.__version__ -TIMESTAMP = strftime('%b %Y', gmtime()) - -HEADER = r'''.TH backintime-config 1 "{TIMESTAMP}" "version {VERSION}" "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 -''' - -FOOTER = r'''.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 the BIT Team(). -''' - INSTANCE = 'instance' NAME = 'name' VALUES = 'values' @@ -103,6 +59,10 @@ REFERENCE = 'reference' LINE = 'line' +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}' @@ -111,6 +71,17 @@ def groff_italic(text: str) -> str: """\\fi - Italic""" return f'\\fI{text}\\fR' +def groff_bold(text: str) -> str: + """.B Bold""" + return f'.B{text}\n' + +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 @@ -126,28 +97,78 @@ def groff_paragraph_break() -> str: """.PP - Paragraph break""" return '.PP\n' +def header(): + stamp = strftime('%b %Y', gmtime()) + ver = version.__version__ + + content = f'.TH backintime-config 1 "{stamp}" ' \ + f'"version {ver}" "USER COMMANDS"\n' + + content += groff_section('NAME') + content += 'config \- Back In Time configuration file.\n' + + content += groff_section('SYNOPSIS') + content += '~/.config/backintime/config' + content += groff_linebreak() + content += '/etc/backintime/config\n' + + content += groff_section('DESCRIPTION') + content += 'Back In Time was developed as pure GUI program and so most ' \ + 'functions are only usable with ' + content += groff_bold('backintime-qt') + content += '. 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' + + content += groff_paragraph_break() + content += 'The configuration file has the following format:\n' + content += groff_linebreak() + content += 'keyword=arguments\n' + + content += groff_paragraph_break() + content += "Arguments don't need to be quoted. All characters are " \ + "allowed except '='.\n" + + content += groff_paragraph_break() + content += "Run 'backintime check-config' to verify the configfile, " \ + "create the snapshot folder and crontab entries.\n" + + content += groff_section('POSSIBLE KEYWORDS') -def entry_to_groff(instance='', name='', values='', default='', - comment='', reference='', line=0): + return content + + +def entry_to_groff(name: str, doc: str, values: Any, default: Any) -> None: """Generate GNU Troff (groff) markup code for the given config entry.""" - if not default: - default = "''" + type_name = type(default).__name__ - ret = f'Type: {instance.lower():<10}Allowed Values: {values}\n' + 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: - ret += f'Default: {default}' - else: - ret += f'Default: {default:<18} {reference} line: {line}' + ret += f'Default: {default}' ret = groff_indented_block(ret) ret = groff_indented_paragraph(groff_italic(name)) + ret return ret +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' + + content += groff_section('AUTHOR') + content += 'This manual page was written by the ' \ + 'Back In Time Team ().' + + return content + def select(a, b): if a: @@ -226,6 +247,15 @@ def lint_manpage() -> bool: def main(): + """ + { + 'global.hash_collision': { + 'values': (0, 99999), + 'default': 0, + 'doc': 'description text', + }, + } + """ # Extract multiline string between { and the latest } rex = re.compile(r'\{([\s\S]*)\}') @@ -265,15 +295,9 @@ def main(): import json print(json.dumps(entries, indent=4)) - # Each "profile" public property - for prop in _get_public_properties(konfig.Konfig.Profile): - attr = getattr(konfig.Konfig.Profile, prop) - - - - sys.exit() - - + # # Each "profile" public property + # for prop in _get_public_properties(konfig.Konfig.Profile): + # attr = getattr(konfig.Konfig.Profile, prop) # d = { # 'profiles.version': { @@ -306,7 +330,7 @@ def main(): # } """ - Example for content of 'd': + Example for content of 'entries': { "profiles": { "instance": "str", @@ -328,22 +352,22 @@ def main(): } """ with MAN.open('w', encoding='utf-8') as handle: - print(f'Write GNU Troff (groff) markup to "{MAN}". {SORT=}') - handle.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] - - handle.write('\n'.join( - entry_to_groff(**d[key]) - for key in sorted(d, key=s) - )) - - handle.write(FOOTER) + print(f'Write GNU Troff (groff) markup to "{MAN}".') + handle.write(header()) + + for name, entry in entries.items(): + handle.write( + entry_to_groff( + name=name, + doc=entry['doc'], + values=entry['values'], + default=entry['default'] + ) + ) + handle.write('\n') + + handle.write(footer()) + handle.write('\n') if __name__ == '__main__': From be5fa062b6215d9e672821a6c4e9e0c85ca1bc8c Mon Sep 17 00:00:00 2001 From: Christian Buhtz Date: Wed, 21 Aug 2024 17:01:55 +0200 Subject: [PATCH 14/54] linting man page --- common/man/C/backintime-config.1 | 12 +-- create-manpage-backintime-config.py | 139 +++++++++++++--------------- 2 files changed, 68 insertions(+), 83 deletions(-) diff --git a/common/man/C/backintime-config.1 b/common/man/C/backintime-config.1 index c314e5fbf..74aaf5b20 100644 --- a/common/man/C/backintime-config.1 +++ b/common/man/C/backintime-config.1 @@ -2,11 +2,11 @@ .SH NAME config \- Back In Time configuration file. .SH SYNOPSIS -~/.config/backintime/config.br +~/.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 .Bbackintime-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 @@ -26,9 +26,9 @@ Default: 0 .RE .SH SEE ALSO -.BR backintime (1), -.BR backintime-qt (1) +.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 the BIT Team(). +This manual page was written by the Back In Time Team (). diff --git a/create-manpage-backintime-config.py b/create-manpage-backintime-config.py index ea539867d..e54a8d315 100755 --- a/create-manpage-backintime-config.py +++ b/create-manpage-backintime-config.py @@ -41,6 +41,7 @@ import sys import re import inspect +import subprocess from pathlib import Path from time import strftime, gmtime from typing import Any @@ -51,13 +52,9 @@ MAN = Path.cwd() / 'common' / 'man' / 'C' / 'backintime-config.1' -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""" @@ -72,8 +69,8 @@ def groff_italic(text: str) -> str: return f'\\fI{text}\\fR' def groff_bold(text: str) -> str: - """.B Bold""" - return f'.B{text}\n' + """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 @@ -97,6 +94,10 @@ def groff_paragraph_break() -> str: """.PP - Paragraph break""" return '.PP\n' +# |--------------------| +# | Content generation | +# |--------------------| + def header(): stamp = strftime('%b %Y', gmtime()) ver = version.__version__ @@ -108,7 +109,7 @@ def header(): content += 'config \- Back In Time configuration file.\n' content += groff_section('SYNOPSIS') - content += '~/.config/backintime/config' + content += '~/.config/backintime/config\n' content += groff_linebreak() content += '/etc/backintime/config\n' @@ -138,7 +139,6 @@ def header(): return content - def entry_to_groff(name: str, doc: str, values: Any, default: Any) -> None: """Generate GNU Troff (groff) markup code for the given config entry.""" type_name = type(default).__name__ @@ -157,8 +157,8 @@ def entry_to_groff(name: str, doc: str, values: Any, default: Any) -> None: def footer() -> str: content = groff_section('SEE ALSO') - content += groff_bold_roman('backintime (1),') - content += groff_bold_roman('backintime-qt (1)') + 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' @@ -169,65 +169,9 @@ def footer() -> str: return content - -def select(a, b): - if a: - return a - - return b - - -def _DEPRECATED_select_values(instance, values): - if values: - return values - - return { - 'bool': 'true|false', - 'str': 'text', - 'int': '0-99999' - }[instance.lower()] - - -def _DEPRECATED_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 - - 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] - - except IndexError: - pass - - if default.startswith('self.') and default[5:] in replace_default: - default = replace_default[default[5:]] - - if isinstance(force_default, str) \ - and force_default.startswith('self.') \ - and force_default[5:] in replace_default: - force_default = replace_default[force_default[5:]] - - if instance.lower() == 'bool': - default = default.lower() - - 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 - +# |------| +# | Misc | +# |------| def _get_public_properties(cls: type) -> tuple: """Extract the public properties from our target config class.""" @@ -239,12 +183,50 @@ def _is_public_property(val): return tuple(filter(_is_public_property, dir(konfig.Konfig))) -def lint_manpage() -> bool: - """ - LC_ALL=C.UTF-8 MANROFFSEQ='' MANWIDTH=80 man --warnings -E UTF-8 -l -Tutf8 -Z >/dev/null - """ - return False +def lint_manpage(path: Path) -> bool: + """Lint the manpage the same way as the Debian Lintian does.""" + + print('Linting man page...') + + cmd = [ + 'man', + '--warnings', + '-E', + 'UTF-8', + '-l', + '-Tutf8', + '-Z', + str(path) + ] + + env = dict( + **os.environ, + LC_ALL='C.UTF-8', + # MANROFFSEQ="''", + MANWIDTH='80', + ) + + try: + with open('/dev/null', 'w') as devnull: + result = subprocess.run( + cmd, + env=env, + check=True, + text=True, + stdout=devnull, + stderr=subprocess.PIPE + ) + except subprocess.CalledProcessError as exc: + raise RuntimeError(f'Unexpected error: {exc.stderr=}') from exc + + # Report warnings + if result.stderr: + print(result.stderr) + return False + + print('No problems reported') + return True def main(): """ @@ -369,6 +351,9 @@ def main(): handle.write(footer()) handle.write('\n') + print(f'Finished creating man page.') + + lint_manpage(MAN) if __name__ == '__main__': main() From 1bf383ee4669d61d8561e6a9d2a1e77b5414bf37 Mon Sep 17 00:00:00 2001 From: Christian Buhtz Date: Wed, 21 Aug 2024 17:33:38 +0200 Subject: [PATCH 15/54] x --- common/konfig.py | 35 ++++++- common/man/C/backintime-config.1 | 9 ++ create-manpage-backintime-config.py | 154 +++++++++++++--------------- 3 files changed, 113 insertions(+), 85 deletions(-) diff --git a/common/konfig.py b/common/konfig.py index f1fb9b8b6..cf3a13570 100644 --- a/common/konfig.py +++ b/common/konfig.py @@ -53,7 +53,40 @@ def __init__(self, profile_id: int, config: Konfig): self._prefix = f'profile{profile_id}' def __getitem__(self, key: str): - return self._config[f'{self._prefix}.{key}'] + try: + return self._config[f'{self._prefix}.{key}'] + except KeyError as exc: + # RETURN DEFAULT + raise exc + + @property + def snapshots_mode(self): + """Use mode (or backend) for this snapshot. Look at 'man backintime' + section 'Modes'. + + { + 'name': 'profile.snapshots.mode', + 'values': 'local|local_encfs|ssh|ssh_encfs', + 'default': 'local', + } + + Eigenen NAmen herausfinden: + inspect.currentframe().f_code.co_name + + + lass MyClass: + def get_current_method_name(self): + return inspect.currentframe().f_back.f_code.co_name + + def my_method1(self): + print("Current method name:", + self.get_current_method_name()) + + def my_method2(self): + print("Current method name:", self.get_current_method_name()) + """ + return self['snapshots.mode'] + _DEFAULT_SECTION = '[bit]' diff --git a/common/man/C/backintime-config.1 b/common/man/C/backintime-config.1 index 74aaf5b20..b9bb4151d 100644 --- a/common/man/C/backintime-config.1 +++ b/common/man/C/backintime-config.1 @@ -25,6 +25,15 @@ Internal value used to prevent hash collisions on mountpoints. Do not change thi Default: 0 .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 + .SH SEE ALSO .BR backintime (1), .BR backintime-qt (1) diff --git a/create-manpage-backintime-config.py b/create-manpage-backintime-config.py index e54a8d315..fbb751beb 100755 --- a/create-manpage-backintime-config.py +++ b/create-manpage-backintime-config.py @@ -42,6 +42,7 @@ import re import inspect import subprocess +import json from pathlib import Path from time import strftime, gmtime from typing import Any @@ -52,6 +53,9 @@ MAN = Path.cwd() / 'common' / 'man' / 'C' / 'backintime-config.1' +# Extract multiline string between { and the latest } +REX_DICT_EXTRACT = re.compile(r'\{([\s\S]*)\}') + # |--------------------------| # | GNU Trof (groff) helpers | # |--------------------------| @@ -181,7 +185,7 @@ def _is_public_property(val): and isinstance(getattr(cls, val), property) ) - return tuple(filter(_is_public_property, dir(konfig.Konfig))) + return tuple(filter(_is_public_property, dir(cls))) def lint_manpage(path: Path) -> bool: """Lint the manpage the same way as the Debian Lintian does.""" @@ -228,116 +232,98 @@ def lint_manpage(path: Path) -> bool: print('No problems reported') return True -def main(): - """ - { - 'global.hash_collision': { - 'values': (0, 99999), - 'default': 0, - 'doc': 'description text', - }, - } - """ - # Extract multiline string between { and the latest } - rex = re.compile(r'\{([\s\S]*)\}') - +def inspect_properties(cls: type): entries = {} - profile_entries = {} - # Each "global" public property - for prop in _get_public_properties(konfig.Konfig): - attr = getattr(konfig.Konfig, prop) + # 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__: - print(f'Ignoring "{prop}" because of missing docstring.') + print(f'Ignoring "{cls.__name__}.{prop}" because of ' + 'missing docstring.') continue - print(f'Public property: {prop}') - print(attr.__doc__) + print(f'{cls.__name__}.{prop}') doc = attr.__doc__ - # extract the dict - the_dict = rex.search(doc).groups()[0] + # extract the dict from docstring + the_dict = REX_DICT_EXTRACT.search(doc).groups()[0] the_dict = '{' + the_dict + '}' - # Remove dict-like string from the doc string + + # remove the dict from docstring doc = doc.replace(the_dict, '') - # Remove empty lines and other blanks + + # 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'))) - # Make it a real dict - the_dict = eval(the_dict) - the_dict['doc'] = doc # store the result + the_dict['doc'] = doc entries[the_dict.pop('name')] = the_dict - import json - print(json.dumps(entries, indent=4)) - - # # Each "profile" public property - # for prop in _get_public_properties(konfig.Konfig.Profile): - # attr = getattr(konfig.Konfig.Profile, prop) - - # 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 - # } - # } + 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 with additional information. + + Example :: - """ - Example for content of 'entries': { - "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 + 'option.name': { + 'values': (0, 99999), + 'default': 0, + 'doc': 'description text', + }, } """ + + global_entries = inspect_properties(konfig.Konfig) + profile_entries = inspect_properties(konfig.Konfig.Profile) + # # Each "global" public property + # for prop in _get_public_properties(konfig.Konfig): + # attr = getattr(konfig.Konfig, prop) + + # # Ignore properties without docstring + # if not attr.__doc__: + # print(f'Ignoring "{prop}" because of missing docstring.') + # continue + + # doc = attr.__doc__ + + # # extract the dict + # the_dict = rex.search(doc).groups()[0] + # the_dict = '{' + the_dict + '}' + # # Remove dict-like string from the doc string + # doc = doc.replace(the_dict, '') + # # Remove empty lines and other blanks + # doc = ' '.join(line.strip() + # for line in + # filter(lambda val: len(val.strip()), doc.split('\n'))) + # # Make it a real dict + # the_dict = eval(the_dict) + # the_dict['doc'] = doc + + # # store the result + # entries[the_dict.pop('name')] = the_dict + + with MAN.open('w', encoding='utf-8') as handle: print(f'Write GNU Troff (groff) markup to "{MAN}".') handle.write(header()) - for name, entry in entries.items(): + for name, entry in {**global_entries, **profile_entries}.items(): handle.write( entry_to_groff( name=name, From 65457a05ee8a73686e9db51cdfe137afc966fa72 Mon Sep 17 00:00:00 2001 From: Christian Buhtz Date: Wed, 21 Aug 2024 22:09:10 +0200 Subject: [PATCH 16/54] before remove value-by-property-name --- common/konfig.py | 109 ++++++++++++++++------------ common/man/C/backintime-config.1 | 32 +++++++- create-manpage-backintime-config.py | 90 ++++++++++++----------- 3 files changed, 139 insertions(+), 92 deletions(-) diff --git a/common/konfig.py b/common/konfig.py index cf3a13570..6b766c198 100644 --- a/common/konfig.py +++ b/common/konfig.py @@ -8,45 +8,35 @@ from __future__ import annotations import os import configparser -from typing import Union +import inspect +from typing import Union, Any from pathlib import Path from io import StringIO import singleton import logger +def _attr_by_caller() -> str: + """The name of the calling method is transformed into an config file attribute name. + + It is a helper function used `Konfig` and `Konfig.Profile` class. + + Returns: + The attribute name. + """ + + # e.g. "hash_collision" if called from "Konfig.hash_collision" property + method_name = inspect.currentframe().f_back.f_code.co_name + + # e.g. "hash_collision" -> "hash.collision" + return method_name.replace('_', '.') + + class Konfig(metaclass=singleton.Singleton): """Manage configuration of Back In Time. That class is a replacement for the `config.Config` class. """ - # use with ConfigParser(defaults=_DEFAULT) - # _DEFAULT = { - # 'foo': 7, - # 'bar': 'bähm', - # 'profiles': '24842' - # } - - # class _AttrSection: - # def __init__(self, - # name: str, - # parent: _AttrSection, - # section: configparser.SectionProxy): - # self._name = name - # self._parent = parent - # self._section = section - - # def full_attr_name(self) -> str: - # return f'{self.parent.full_attr_name}.{self._name}' - - # def __getattr__(self, attr: str): - # if '.' in attr: - # attr_section = _AttrSection( - # name=attr, parent=self, section=self._section) - # return attr_section[attr] - - # return self._conf[attr] - class Profile: def __init__(self, profile_id: int, config: Konfig): self._config = config @@ -59,34 +49,37 @@ def __getitem__(self, key: str): # RETURN DEFAULT raise exc + def _value_by_property_name(self) -> Any: + """Return the value based on the calling property method.""" + attr_name = foobar() + # method_name = inspect.currentframe().f_back.f_code.co_name + # attr_name = method_name.replace('_', '.') + + return self[attr_name] + @property def snapshots_mode(self): - """Use mode (or backend) for this snapshot. Look at 'man backintime' - section 'Modes'. + """Use mode (or backend) for this snapshot. Look at 'man + backintime' section 'Modes'. { - 'name': 'profile.snapshots.mode', 'values': 'local|local_encfs|ssh|ssh_encfs', 'default': 'local', } - - Eigenen NAmen herausfinden: - inspect.currentframe().f_code.co_name - - - lass MyClass: - def get_current_method_name(self): - return inspect.currentframe().f_back.f_code.co_name - - def my_method1(self): - print("Current method name:", - self.get_current_method_name()) - - def my_method2(self): - print("Current method name:", self.get_current_method_name()) """ return self['snapshots.mode'] + # return self._value_by_property_name() + @property + def snapshots_path(self): + """Where to save snapshots in mode 'local'. This path must contain + a folderstructure like 'backintime///'. + + { + 'values': 'absolute path', + } + """ + return self._value_by_property_name() _DEFAULT_SECTION = '[bit]' @@ -159,20 +152,40 @@ def save(self): logger.debug(f'Configuration written to "{self._path}".') @property - def hash_collision(self): + def hash_collision(self) -> int: """Internal value used to prevent hash collisions on mountpoints. Do not change this. { - 'name': 'global.hash_collision', 'values': (0, 99999), 'default': 0, } """ return self._conf['global.hash_collision'] + @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' + } + """ + return self._conf['global.language'] + if __name__ == '__main__': + # Workaround because of missing gettext config _ = lambda s: s + k = Konfig() + print(f'{k._conf.keys()=}') + + print(f'{k.profile_names=}') + print(f'{k.profile_ids=}') + print(f'{k.global_hash_collision=}') + p = k.profile(2) + print(f'{p.snapshots_mode=}') diff --git a/common/man/C/backintime-config.1 b/common/man/C/backintime-config.1 index b9bb4151d..f52b9d38f 100644 --- a/common/man/C/backintime-config.1 +++ b/common/man/C/backintime-config.1 @@ -16,7 +16,10 @@ 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 +.IP "\fI + 'values': (0, 99999), + 'default': 0, + \fR" 6 .RS Type: int Allowed Values: (0, 99999) .br @@ -25,7 +28,21 @@ Internal value used to prevent hash collisions on mountpoints. Do not change thi Default: 0 .RE -.IP "\fIprofile.snapshots.mode\fR" 6 +.IP "\fI + 'values': 'ISO 639 language codes' + \fR" 6 +.RS +Type: NoneType 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 + +.RE + +.IP "\fI + 'values': 'local|local_encfs|ssh|ssh_encfs', + 'default': 'local', + \fR" 6 .RS Type: str Allowed Values: local|local_encfs|ssh|ssh_encfs .br @@ -34,6 +51,17 @@ Use mode (or backend) for this snapshot. Look at 'man backintime' section 'Modes Default: local .RE +.IP "\fI + 'values': 'absolute path', + \fR" 6 +.RS +Type: NoneType Allowed Values: absolute path +.br +Where to save snapshots in mode 'local'. This path must contain a folderstructure like 'backintime///'. +.PP + +.RE + .SH SEE ALSO .BR backintime (1), .BR backintime-qt (1) diff --git a/create-manpage-backintime-config.py b/create-manpage-backintime-config.py index fbb751beb..f7ada353c 100755 --- a/create-manpage-backintime-config.py +++ b/create-manpage-backintime-config.py @@ -42,7 +42,6 @@ import re import inspect import subprocess -import json from pathlib import Path from time import strftime, gmtime from typing import Any @@ -55,27 +54,34 @@ # 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\[['\"](.*)['\"]\]") # |--------------------------| # | 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. @@ -83,6 +89,7 @@ def groff_bold_roman(text: str) -> str: Used to reference other man pages.""" return f'.BR {text}\n' + def groff_indented_block(text: str) -> str: """ .RS - Start indented block @@ -90,10 +97,12 @@ 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' @@ -102,6 +111,7 @@ def groff_paragraph_break() -> str: # | Content generation | # |--------------------| + def header(): stamp = strftime('%b %Y', gmtime()) ver = version.__version__ @@ -110,7 +120,7 @@ def header(): f'"version {ver}" "USER COMMANDS"\n' content += groff_section('NAME') - content += 'config \- Back In Time configuration file.\n' + content += 'config \\- Back In Time configuration file.\n' content += groff_section('SYNOPSIS') content += '~/.config/backintime/config\n' @@ -124,7 +134,7 @@ def header(): content += '. 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' + '/usr/share/doc/backintime\\-common/examples/ for examples.\n' content += groff_paragraph_break() content += 'The configuration file has the following format:\n' @@ -143,6 +153,7 @@ def header(): return content + def entry_to_groff(name: str, doc: str, values: Any, default: Any) -> None: """Generate GNU Troff (groff) markup code for the given config entry.""" type_name = type(default).__name__ @@ -152,13 +163,15 @@ def entry_to_groff(name: str, doc: str, values: Any, default: Any) -> None: ret += f'{doc}\n' ret += groff_paragraph_break() - ret += f'Default: {default}' + if default is not None: + ret += f'Default: {default}' ret = groff_indented_block(ret) ret = groff_indented_paragraph(groff_italic(name)) + ret return ret + def footer() -> str: content = groff_section('SEE ALSO') content += groff_bold_roman('backintime (1),') @@ -177,6 +190,7 @@ def footer() -> str: # | Misc | # |------| + def _get_public_properties(cls: type) -> tuple: """Extract the public properties from our target config class.""" def _is_public_property(val): @@ -187,6 +201,7 @@ def _is_public_property(val): return tuple(filter(_is_public_property, dir(cls))) + def lint_manpage(path: Path) -> bool: """Lint the manpage the same way as the Debian Lintian does.""" @@ -232,7 +247,8 @@ def lint_manpage(path: Path) -> bool: print('No problems reported') return True -def inspect_properties(cls: type): + +def inspect_properties(cls: type, name_prefix: str = ''): entries = {} # Each public property in the class @@ -247,6 +263,14 @@ def inspect_properties(cls: type): 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] + except IndexError as exc: + raise RuntimeError('Can not find name of config field in ' + f'the body of "{prop}".') from exc + + # Full doc string doc = attr.__doc__ # extract the dict from docstring @@ -266,7 +290,9 @@ def inspect_properties(cls: type): # store the result the_dict['doc'] = doc - entries[the_dict.pop('name')] = the_dict + # name = the_dict.pop('name') + # name = name_prefix + prop.replace('_', '.') + entries[name] = the_dict return entries @@ -276,70 +302,50 @@ def main(): 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 with additional information. + 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 :: { - 'option.name': { - 'values': (0, 99999), - 'default': 0, - 'doc': 'description text', - }, + 'values': (0, 99999), + 'default': 0, } """ + # Inspect the classes and extract man page related data from them. global_entries = inspect_properties(konfig.Konfig) - profile_entries = inspect_properties(konfig.Konfig.Profile) - # # Each "global" public property - # for prop in _get_public_properties(konfig.Konfig): - # attr = getattr(konfig.Konfig, prop) - - # # Ignore properties without docstring - # if not attr.__doc__: - # print(f'Ignoring "{prop}" because of missing docstring.') - # continue - - # doc = attr.__doc__ - - # # extract the dict - # the_dict = rex.search(doc).groups()[0] - # the_dict = '{' + the_dict + '}' - # # Remove dict-like string from the doc string - # doc = doc.replace(the_dict, '') - # # Remove empty lines and other blanks - # doc = ' '.join(line.strip() - # for line in - # filter(lambda val: len(val.strip()), doc.split('\n'))) - # # Make it a real dict - # the_dict = eval(the_dict) - # the_dict['doc'] = doc - - # # store the result - # entries[the_dict.pop('name')] = the_dict - + profile_entries = inspect_properties( + konfig.Konfig.Profile, 'profile.') + # 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(): handle.write( entry_to_groff( name=name, doc=entry['doc'], values=entry['values'], - default=entry['default'] + default=entry.get('default', None), ) ) handle.write('\n') + # FOOTER handle.write(footer()) handle.write('\n') - print(f'Finished creating man page.') + print('Finished creating man page.') lint_manpage(MAN) + if __name__ == '__main__': main() From bbcaef134f0c097ea054ba08d866ee826d3eefca Mon Sep 17 00:00:00 2001 From: Christian Buhtz Date: Wed, 21 Aug 2024 22:47:09 +0200 Subject: [PATCH 17/54] x --- common/konfig.py | 69 ++++++++++++++++------------- common/man/C/backintime-config.1 | 31 +++++++------ create-manpage-backintime-config.py | 33 +++++++++++--- 3 files changed, 79 insertions(+), 54 deletions(-) diff --git a/common/konfig.py b/common/konfig.py index 6b766c198..f5e62cfbf 100644 --- a/common/konfig.py +++ b/common/konfig.py @@ -16,22 +16,6 @@ import logger -def _attr_by_caller() -> str: - """The name of the calling method is transformed into an config file attribute name. - - It is a helper function used `Konfig` and `Konfig.Profile` class. - - Returns: - The attribute name. - """ - - # e.g. "hash_collision" if called from "Konfig.hash_collision" property - method_name = inspect.currentframe().f_back.f_code.co_name - - # e.g. "hash_collision" -> "hash.collision" - return method_name.replace('_', '.') - - class Konfig(metaclass=singleton.Singleton): """Manage configuration of Back In Time. @@ -49,14 +33,6 @@ def __getitem__(self, key: str): # RETURN DEFAULT raise exc - def _value_by_property_name(self) -> Any: - """Return the value based on the calling property method.""" - attr_name = foobar() - # method_name = inspect.currentframe().f_back.f_code.co_name - # attr_name = method_name.replace('_', '.') - - return self[attr_name] - @property def snapshots_mode(self): """Use mode (or backend) for this snapshot. Look at 'man @@ -68,7 +44,6 @@ def snapshots_mode(self): } """ return self['snapshots.mode'] - # return self._value_by_property_name() @property def snapshots_path(self): @@ -77,9 +52,10 @@ def snapshots_path(self): { 'values': 'absolute path', + 'type': str, } """ - return self._value_by_property_name() + return self['snapshots.path'] _DEFAULT_SECTION = '[bit]' @@ -108,9 +84,12 @@ def __init__(self, config_path: Path = None): # # First/Default profile not stored with name # self._profiles[1] = _('Main profile') - def __getitem__(self, key: str): + def __getitem__(self, key: str) -> Any: return self._conf[key] + def __setitem__(self, key: str, val: Any) -> None: + self._conf[key] = val + def profile(self, name_or_id: Union[str, int]) -> Profile: if isinstance(name_or_id, int): profile_id = name_or_id @@ -161,7 +140,11 @@ def hash_collision(self) -> int: 'default': 0, } """ - return self._conf['global.hash_collision'] + return self['global.hash_collision'] + + @hash_collision.setter + def hash_collision(self, val: int) -> None: + self['global.hash_collision'] = val @property def language(self) -> str: @@ -170,11 +153,31 @@ def language(self) -> str: 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' + 'values': 'ISO 639 language codes', + 'type': str } """ - return self._conf['global.language'] + 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 if __name__ == '__main__': @@ -182,10 +185,12 @@ def language(self) -> str: _ = lambda s: s k = Konfig() - print(f'{k._conf.keys()=}') print(f'{k.profile_names=}') print(f'{k.profile_ids=}') - print(f'{k.global_hash_collision=}') + print(f'{k.hash_collision=}') + print(f'{k.language=}') + print(f'{k.global_flock=}') + p = k.profile(2) print(f'{p.snapshots_mode=}') diff --git a/common/man/C/backintime-config.1 b/common/man/C/backintime-config.1 index f52b9d38f..ea794ddeb 100644 --- a/common/man/C/backintime-config.1 +++ b/common/man/C/backintime-config.1 @@ -16,10 +16,16 @@ 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 "\fI - 'values': (0, 99999), - 'default': 0, - \fR" 6 +.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 "\fIglobal.hash_collision\fR" 6 .RS Type: int Allowed Values: (0, 99999) .br @@ -28,21 +34,16 @@ Internal value used to prevent hash collisions on mountpoints. Do not change thi Default: 0 .RE -.IP "\fI - 'values': 'ISO 639 language codes' - \fR" 6 +.IP "\fIglobal.language\fR" 6 .RS -Type: NoneType Allowed Values: ISO 639 language codes +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 .RE -.IP "\fI - 'values': 'local|local_encfs|ssh|ssh_encfs', - 'default': 'local', - \fR" 6 +.IP "\fIsnapshots.mode\fR" 6 .RS Type: str Allowed Values: local|local_encfs|ssh|ssh_encfs .br @@ -51,11 +52,9 @@ Use mode (or backend) for this snapshot. Look at 'man backintime' section 'Modes Default: local .RE -.IP "\fI - 'values': 'absolute path', - \fR" 6 +.IP "\fIsnapshots.path\fR" 6 .RS -Type: NoneType Allowed Values: absolute path +Type: str Allowed Values: absolute path .br Where to save snapshots in mode 'local'. This path must contain a folderstructure like 'backintime///'. .PP diff --git a/create-manpage-backintime-config.py b/create-manpage-backintime-config.py index f7ada353c..e86944c73 100755 --- a/create-manpage-backintime-config.py +++ b/create-manpage-backintime-config.py @@ -55,7 +55,7 @@ # 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\[['\"](.*)['\"]\]") +REX_ATTR_NAME = re.compile(r"self(?:\._conf)?\[['\"](.*)['\"]\]") # |--------------------------| # | GNU Trof (groff) helpers | @@ -154,16 +154,31 @@ def header(): return content -def entry_to_groff(name: str, doc: str, values: Any, default: Any) -> None: +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.""" - type_name = type(default).__name__ + + if its_type is not None: + if isinstance(its_type, str): + type_name = its_type + else: + type_name = its_type.__name__ + elif default is not None: + type_name = type(default).__name__ + else: + type_name = '' ret = f'Type: {type_name:<10}Allowed Values: {values}\n' ret += groff_linebreak() ret += f'{doc}\n' ret += groff_paragraph_break() + print(f'{name=} {default=}') if default is not None: + print('BUT') ret += f'Default: {default}' ret = groff_indented_block(ret) @@ -248,7 +263,8 @@ def lint_manpage(path: Path) -> bool: return True -def inspect_properties(cls: type, name_prefix: str = ''): +def inspect_properties(cls: type, + name_prefix: str = ''): entries = {} # Each public property in the class @@ -315,9 +331,13 @@ def main(): """ # Inspect the classes and extract man page related data from them. - global_entries = inspect_properties(konfig.Konfig) + global_entries = inspect_properties( + cls=konfig.Konfig, + ) profile_entries = inspect_properties( - konfig.Konfig.Profile, 'profile.') + cls=konfig.Konfig.Profile, + name_prefix='profile.' + ) # Create the man page file with MAN.open('w', encoding='utf-8') as handle: @@ -334,6 +354,7 @@ def main(): doc=entry['doc'], values=entry['values'], default=entry.get('default', None), + its_type=entry.get('type', None), ) ) handle.write('\n') From 1bbc30f4d187884508078ffc3cc97c864e2ebcf8 Mon Sep 17 00:00:00 2001 From: Christian Buhtz Date: Thu, 22 Aug 2024 07:58:35 +0200 Subject: [PATCH 18/54] x --- common/konfig.py | 56 ++++++++++++++++++++++++++--- common/man/C/backintime-config.1 | 27 ++++++++++++++ create-manpage-backintime-config.py | 17 +++++++-- 3 files changed, 93 insertions(+), 7 deletions(-) diff --git a/common/konfig.py b/common/konfig.py index f5e62cfbf..42613f1c5 100644 --- a/common/konfig.py +++ b/common/konfig.py @@ -22,6 +22,10 @@ class Konfig(metaclass=singleton.Singleton): That class is a replacement for the `config.Config` class. """ class Profile: + DEFAULT_VALUES = { + 'snapshots.ssh.port': 22, + } + def __init__(self, profile_id: int, config: Konfig): self._config = config self._prefix = f'profile{profile_id}' @@ -30,11 +34,10 @@ def __getitem__(self, key: str): try: return self._config[f'{self._prefix}.{key}'] except KeyError as exc: - # RETURN DEFAULT - raise exc + return self.DEFAULT_VALUES[key] @property - def snapshots_mode(self): + def snapshots_mode(self) -> str: """Use mode (or backend) for this snapshot. Look at 'man backintime' section 'Modes'. @@ -46,7 +49,7 @@ def snapshots_mode(self): return self['snapshots.mode'] @property - def snapshots_path(self): + def snapshots_path(self) -> str: """Where to save snapshots in mode 'local'. This path must contain a folderstructure like 'backintime///'. @@ -55,8 +58,53 @@ def snapshots_path(self): 'type': str, } """ + raise NotImplementedError('see original in Config class') return self['snapshots.path'] + @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', + 'type': str, + } + + """ + return self['snapshots.ssh.path'] + + @property + def ssh_host(self): + """Remote host used for mode 'ssh' and 'ssh_encfs'. + + { + '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) -> str: + """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 + + _DEFAULT_SECTION = '[bit]' def __init__(self, config_path: Path = None): diff --git a/common/man/C/backintime-config.1 b/common/man/C/backintime-config.1 index ea794ddeb..0a3afd9df 100644 --- a/common/man/C/backintime-config.1 +++ b/common/man/C/backintime-config.1 @@ -61,6 +61,33 @@ Where to save snapshots in mode 'local'. This path must contain a folderstructur .RE +.IP "\fIsnapshots.ssh.host\fR" 6 +.RS +Type: _empty Allowed Values: IP or domain address +.br +Remote host used for mode 'ssh' and 'ssh_encfs'. +.PP + +.RE + +.IP "\fIsnapshots.ssh.port\fR" 6 +.RS +Type: str Allowed Values: 0-65535 +.br +SSH Port on remote host. +.PP +Default: 22 +.RE + +.IP "\fIsnapshots.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 + +.RE + .SH SEE ALSO .BR backintime (1), .BR backintime-qt (1) diff --git a/create-manpage-backintime-config.py b/create-manpage-backintime-config.py index e86944c73..15b52340b 100755 --- a/create-manpage-backintime-config.py +++ b/create-manpage-backintime-config.py @@ -166,8 +166,6 @@ def entry_to_groff(name: str, type_name = its_type else: type_name = its_type.__name__ - elif default is not None: - type_name = type(default).__name__ else: type_name = '' @@ -176,7 +174,6 @@ def entry_to_groff(name: str, ret += f'{doc}\n' ret += groff_paragraph_break() - print(f'{name=} {default=}') if default is not None: print('BUT') ret += f'Default: {default}' @@ -306,6 +303,20 @@ def inspect_properties(cls: type, # store the result the_dict['doc'] = doc + + # type (by return value annotation) + if 'type' not in the_dict: + sig = inspect.signature(attr.fget) + try: + the_dict['type'] = sig.return_annotation + print(f'{prop=} {the_dict["type"]=}') + except AttributeError: + pass + + # type by default values + if 'type' not in the_dict and 'default' in the_dict: + the_dict['type'] = type(the_dict['default']).__name__ + # name = the_dict.pop('name') # name = name_prefix + prop.replace('_', '.') entries[name] = the_dict From b2f30914c49663277d99804ea264990a8738868e Mon Sep 17 00:00:00 2001 From: Christian Buhtz Date: Thu, 22 Aug 2024 10:14:33 +0200 Subject: [PATCH 19/54] x# --- common/bitbase.py | 16 ++++ common/config.py | 18 +---- common/konfig.py | 111 +++++++++++++++++++++++++-- common/man/C/backintime-config.1 | 84 ++++++++++++++++---- create-manapge-backintime-config2.py | 77 ------------------- create-manpage-backintime-config.py | 65 ++++++++++++---- 6 files changed, 245 insertions(+), 126 deletions(-) delete mode 100644 create-manapge-backintime-config2.py diff --git a/common/bitbase.py b/common/bitbase.py index 9a209182d..567e74f37 100644 --- a/common/bitbase.py +++ b/common/bitbase.py @@ -10,3 +10,19 @@ # See issue #1734 and #1735 URL_ENCRYPT_TRANSITION = 'https://github.com/bit-team/backintime' \ '/blob/-/doc/ENCRYPT_TRANSITION.md' + +SSH_CIPHERS = { + 'default': _('Default'), + 'aes128-ctr': 'AES128-CTR', + 'aes192-ctr': 'AES192-CTR', + 'aes256-ctr': 'AES256-CTR', + 'arcfour256': 'ARCFOUR256', + 'arcfour128': 'ARCFOUR128', + 'aes128-cbc': 'AES128-CBC', + '3des-cbc': '3DES-CBC', + 'blowfish-cbc': 'Blowfish-CBC', + 'cast128-cbc': 'Cast128-CBC', + 'aes192-cbc': 'AES192-CBC', + 'aes256-cbc': 'AES256-CBC', + 'arcfour': 'ARCFOUR' +} diff --git a/common/config.py b/common/config.py index 8f903d014..83b4cbadd 100644 --- a/common/config.py +++ b/common/config.py @@ -43,7 +43,7 @@ _('Warning') except NameError: _ = lambda val: val - +import bitbase import tools import configfile import logger @@ -322,21 +322,7 @@ def __init__(self, config_path=None, data_path=None): ) } - self.SSH_CIPHERS = { - 'default': _('Default'), - 'aes128-ctr': 'AES128-CTR', - 'aes192-ctr': 'AES192-CTR', - 'aes256-ctr': 'AES256-CTR', - 'arcfour256': 'ARCFOUR256', - 'arcfour128': 'ARCFOUR128', - 'aes128-cbc': 'AES128-CBC', - '3des-cbc': '3DES-CBC', - 'blowfish-cbc': 'Blowfish-CBC', - 'cast128-cbc': 'Cast128-CBC', - 'aes192-cbc': 'AES192-CBC', - 'aes256-cbc': 'AES256-CBC', - 'arcfour': 'ARCFOUR' - } + self.SSH_CIPHERS = bitbase.SSH_CIPHERS def save(self): self.setIntValue('config.version', self.CONFIG_VERSION) diff --git a/common/konfig.py b/common/konfig.py index 42613f1c5..52c47a040 100644 --- a/common/konfig.py +++ b/common/konfig.py @@ -9,6 +9,7 @@ import os import configparser import inspect +import getpass from typing import Union, Any from pathlib import Path from io import StringIO @@ -21,9 +22,18 @@ class Konfig(metaclass=singleton.Singleton): That class is a replacement for the `config.Config` class. """ + + DEFAULT_VALUES = { + } + class Profile: DEFAULT_VALUES = { 'snapshots.ssh.port': 22, + 'snapshots.ssh.cipher': 'default', + 'snapshots.ssh.user': getpass.getuser(), + 'snapshots.cipher': 'default', + 'snapshots.ssh.private_key_file': + str(Path('~') / '.ssh' / 'id_rsa'), } def __init__(self, profile_id: int, config: Konfig): @@ -55,7 +65,6 @@ def snapshots_path(self) -> str: { 'values': 'absolute path', - 'type': str, } """ raise NotImplementedError('see original in Config class') @@ -69,14 +78,13 @@ def ssh_snapshots_path(self) -> str: { 'values': 'absolute or relative path', - 'type': str, } """ return self['snapshots.ssh.path'] @property - def ssh_host(self): + def ssh_host(self) -> str: """Remote host used for mode 'ssh' and 'ssh_encfs'. { @@ -90,7 +98,7 @@ def ssh_host(self, value: str) -> None: self['snapshots.ssh.host'] = value @property - def ssh_port(self) -> str: + def ssh_port(self) -> int: """SSH Port on remote host. { @@ -104,6 +112,99 @@ def ssh_port(self) -> str: 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_cipher(self) -> str: + """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. + + { + 'values': 'default | aes192-cbc | aes256-cbc | aes128-ctr ' \ + '| aes192-ctr | aes256-ctr | arcfour | arcfour256 ' \ + '| arcfour128 | aes128-cbc | 3des-cbc | ' \ + 'blowfish-cbc | cast128-cbc', + } + """ + return self['snapshots.ssh.cipher'] + + @ssh_cipher.setter + def ssh_cipher(self, value: str) -> None: + self['snapshots.ssh.cipher'] = 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', + 'type': 'str' + } + + """ + raise NotImplementedError('see original in Config class') + path_string = self['snapshots.ssh.private_key_file'] + return Path(path_string) + + @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 + _DEFAULT_SECTION = '[bit]' @@ -184,7 +285,7 @@ def hash_collision(self) -> int: Do not change this. { - 'values': (0, 99999), + 'values': '0-99999', 'default': 0, } """ diff --git a/common/man/C/backintime-config.1 b/common/man/C/backintime-config.1 index 0a3afd9df..4d4420a57 100644 --- a/common/man/C/backintime-config.1 +++ b/common/man/C/backintime-config.1 @@ -16,18 +16,9 @@ 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.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 "\fIglobal.hash_collision\fR" 6 .RS -Type: int Allowed Values: (0, 99999) +Type: int Allowed Values: 0-99999 .br Internal value used to prevent hash collisions on mountpoints. Do not change this. .PP @@ -43,6 +34,15 @@ Language code (ISO 639) used to translate the user interface. If empty the opera .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 "\fIsnapshots.mode\fR" 6 .RS Type: str Allowed Values: local|local_encfs|ssh|ssh_encfs @@ -61,9 +61,18 @@ Where to save snapshots in mode 'local'. This path must contain a folderstructur .RE +.IP "\fIsnapshots.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 + +.RE + .IP "\fIsnapshots.ssh.host\fR" 6 .RS -Type: _empty Allowed Values: IP or domain address +Type: str Allowed Values: IP or domain address .br Remote host used for mode 'ssh' and 'ssh_encfs'. .PP @@ -72,20 +81,65 @@ Remote host used for mode 'ssh' and 'ssh_encfs'. .IP "\fIsnapshots.ssh.port\fR" 6 .RS -Type: str Allowed Values: 0-65535 +Type: int Allowed Values: 0-65535 .br SSH Port on remote host. .PP Default: 22 .RE -.IP "\fIsnapshots.ssh.path\fR" 6 +.IP "\fIsnapshots.ssh.user\fR" 6 .RS -Type: str Allowed Values: absolute or relative path +Type: str Allowed Values: text .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 './'. +Remote SSH user. +.PP +Default: local users name +.RE + +.IP "\fIsnapshots.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 "\fIsnapshots.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_rsa +.RE + +.IP "\fIsnapshots.ssh.proxy_host\fR" 6 +.RS +Type: str Allowed Values: IP or domain address +.br +Proxy host (or jump host) used to connect to remote host. +.PP + +.RE + +.IP "\fIsnapshots.ssh.proxy_port\fR" 6 +.RS +Type: int Allowed Values: 0-65535 +.br +Port of SSH proxy (jump) host used to connect to remote host. .PP +Default: 22 +.RE +.IP "\fIsnapshots.ssh.proxy_user\fR" 6 +.RS +Type: str Allowed Values: text +.br +SSH user at proxy (jump) host. +.PP +Default: local users name .RE .SH SEE ALSO diff --git a/create-manapge-backintime-config2.py b/create-manapge-backintime-config2.py deleted file mode 100644 index 40a74f549..000000000 --- a/create-manapge-backintime-config2.py +++ /dev/null @@ -1,77 +0,0 @@ -#!/usr/bin/env python3 -import sys -import inspect -from pathlib import Path - -# Workaround (see #1575) -sys.path.insert(0, str(SRC_PATH.parent)) -import konfig -import version - -VERSION = version.__version__ -TIMESTAMP = strftime('%b %Y', gmtime()) - -HEADER = r'''.TH backintime-config 1 "{TIMESTAMP}" "version {VERSION}" "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 -''' - -FOOTER = r'''.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 the BIT Team(). -''' - - - -def _get_public_properties(cls) -> tuple: - """Extract the public properties from our target config class.""" - def _is_public_property(val): - return ( - not val.startswith('_') - and isinstance(getattr(konfig.Konfig, val), property) - ) - - return tuple(filter(_is_public_property, dir(konfig.Konfig))) - -def lint_manpage() -> bool: - """ - LC_ALL=C.UTF-8 MANROFFSEQ='' MANWIDTH=80 man --warnings -E UTF-8 -l -Tutf8 -Z >/dev/null - """ - return False - -def main(): - for prop in _get_public_properties(): - attr = getattr(konfig.Konfig, prop) - - # Ignore properties without docstring - if not attr.__doc__: - print('Missing docstring for "{prop}". Ignoring it.') - continue - - print(f'Public property: {prop}') - print(attr.__doc__) - - -if __name__ == '__main__': - main() diff --git a/create-manpage-backintime-config.py b/create-manpage-backintime-config.py index 15b52340b..1e4c6cf51 100755 --- a/create-manpage-backintime-config.py +++ b/create-manpage-backintime-config.py @@ -175,7 +175,6 @@ def entry_to_groff(name: str, ret += groff_paragraph_break() if default is not None: - print('BUT') ret += f'Default: {default}' ret = groff_indented_block(ret) @@ -211,7 +210,7 @@ def _is_public_property(val): and isinstance(getattr(cls, val), property) ) - return tuple(filter(_is_public_property, dir(cls))) + return tuple(filter(_is_public_property, cls.__dict__.keys())) def lint_manpage(path: Path) -> bool: @@ -256,12 +255,45 @@ def lint_manpage(path: Path) -> bool: print(result.stderr) return False - print('No problems reported') + print('No problems reported.') return True -def inspect_properties(cls: type, - name_prefix: str = ''): +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 annoation 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' + }, + } + + Results in a man page entry like this :: + + POSSIBLE KEYWORDS + + global.hash_collision + Type: int Allowed Values: 0-99999 + Internal value ... + + Default: 0 + + Returns: + A dictionary indexed by the config option field names. + """ entries = {} # Each public property in the class @@ -274,6 +306,7 @@ def inspect_properties(cls: type, 'missing docstring.') continue + # DEBUG print(f'{cls.__name__}.{prop}') # Extract config field name from code (self._conf['config.field']) @@ -304,23 +337,29 @@ def inspect_properties(cls: type, # store the result the_dict['doc'] = doc - # type (by return value annotation) - if 'type' not in the_dict: - sig = inspect.signature(attr.fget) + # default value + if 'default' not in the_dict: try: - the_dict['type'] = sig.return_annotation - print(f'{prop=} {the_dict["type"]=}') - except AttributeError: + 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__ - # name = the_dict.pop('name') - # name = name_prefix + prop.replace('_', '.') entries[name] = the_dict + # DEBUG + # print(f'entries[{name}]={entries[name]}') + return entries From 3543ad35461ca6c00b242b914d410536ae5759c7 Mon Sep 17 00:00:00 2001 From: Christian Buhtz Date: Fri, 23 Aug 2024 00:15:56 +0200 Subject: [PATCH 20/54] x --- common/konfig.py | 106 ++++++++++++++++++++++++++++--- common/man/C/backintime-config.1 | 27 ++++++++ common/test/test_konfig.py | 53 ++++++++++++++++ 3 files changed, 177 insertions(+), 9 deletions(-) create mode 100644 common/test/test_konfig.py diff --git a/common/konfig.py b/common/konfig.py index 52c47a040..23e0b5e9a 100644 --- a/common/konfig.py +++ b/common/konfig.py @@ -6,16 +6,26 @@ # General Public License v2 (GPLv2). # See file LICENSE or go to . from __future__ import annotations -import os import configparser -import inspect import getpass +import contextlib +import os from typing import Union, Any from pathlib import Path -from io import StringIO +from io import StringIO, TextIOWrapper import singleton import logger +# 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: + _ = lambda val: val + class Konfig(metaclass=singleton.Singleton): """Manage configuration of Back In Time. @@ -24,16 +34,22 @@ class Konfig(metaclass=singleton.Singleton): """ DEFAULT_VALUES = { + 'global.hash_collision': 0, + 'global.language': '', + 'global.use_flock': False, } class Profile: DEFAULT_VALUES = { + 'snapshots.mode': 'local', 'snapshots.ssh.port': 22, 'snapshots.ssh.cipher': 'default', 'snapshots.ssh.user': getpass.getuser(), - 'snapshots.cipher': 'default', 'snapshots.ssh.private_key_file': str(Path('~') / '.ssh' / 'id_rsa'), + 'snapshots.ssh.max_arg_length': 0, + 'snapshots.ssh.check_commands': True, + 'snapshots.ssh.check_ping': True, } def __init__(self, profile_id: int, config: Konfig): @@ -43,7 +59,7 @@ def __init__(self, profile_id: int, config: Konfig): def __getitem__(self, key: str): try: return self._config[f'{self._prefix}.{key}'] - except KeyError as exc: + except KeyError: return self.DEFAULT_VALUES[key] @property @@ -205,6 +221,46 @@ def ssh_proxy_user(self) -> str: 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' } + """ + 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 _DEFAULT_SECTION = '[bit]' @@ -215,7 +271,10 @@ def __init__(self, config_path: Path = None): xdg_config = os.environ.get('XDG_CONFIG_HOME', os.environ['HOME'] + '/.config') self._path = Path(xdg_config) / 'backintime' / 'config' + else: + self._path = config_path + print(f'Config path used: {self._path} {type(self._path)=}') logger.debug(f'Config path used: {self._path}') self.load() @@ -234,7 +293,10 @@ def __init__(self, config_path: Path = None): # self._profiles[1] = _('Main profile') def __getitem__(self, key: str) -> Any: - return self._conf[key] + try: + return self._conf[key] + except KeyError: + return self.DEFAULT_VALUES[key] def __setitem__(self, key: str, val: Any) -> None: self._conf[key] = val @@ -256,10 +318,35 @@ def profile_ids(self) -> list[int]: return list(self._profiles.values()) def load(self): + @contextlib.contextmanager + def _path_or_buffer(path_or_buffer: Union[Path, StringIO] + ) -> Union[TextIOWrapper, StringIO]: + """Using a path or a in-memory file (buffer) with a with + statement.""" + try: + # It is a regular file + path_or_buffer = path_or_buffer.open('r', encoding='utf-8') + print(f'{type(path_or_buffer)=}') + + except AttributeError: + # Assuming a StringIO instance as in-memory file + pass + + yield path_or_buffer + + try: + # regular file: close it + path_or_buffer.close() + + except AttributeError: + # in-memory file: "cursor" back to first byte + path_or_buffer.seek(0) + self._config_parser = configparser.ConfigParser( defaults={'profile1.name': _('Main profile')}) - with self._path.open('r', encoding='utf-8') as handle: + with _path_or_buffer(self._path) as handle: + print(handle) content = handle.read() logger.debug(f'Configuration read from "{self._path}".') @@ -330,9 +417,10 @@ def global_flock(self, value: bool) -> None: if __name__ == '__main__': - # Workaround because of missing gettext config - _ = lambda s: s + # # Workaround because of missing gettext config + # _ = lambda s: s + buffer = StringIO() k = Konfig() print(f'{k.profile_names=}') diff --git a/common/man/C/backintime-config.1 b/common/man/C/backintime-config.1 index 4d4420a57..f676eeeb3 100644 --- a/common/man/C/backintime-config.1 +++ b/common/man/C/backintime-config.1 @@ -142,6 +142,33 @@ SSH user at proxy (jump) host. Default: local users name .RE +.IP "\fIsnapshots.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'. The value '0' means unlimited length. +.PP +Default: 0 +.RE + +.IP "\fIsnapshots.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 "\fIsnapshots.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 + .SH SEE ALSO .BR backintime (1), .BR backintime-qt (1) diff --git a/common/test/test_konfig.py b/common/test/test_konfig.py new file mode 100644 index 000000000..33b10ce86 --- /dev/null +++ b/common/test/test_konfig.py @@ -0,0 +1,53 @@ +# 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 +from io import StringIO +from konfig import Konfig + + +class General(unittest.TestCase): + """Konfig class""" + 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) + + +class Profiles(unittest.TestCase): + """Konfig.Profile class""" + 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_max_arg_length, 0) + self.assertIsInstance(sut.ssh_max_arg_length, int) From a8ac3e34ccc01f9b84b1ea8681cf246d32f9c151 Mon Sep 17 00:00:00 2001 From: Christian Buhtz Date: Fri, 23 Aug 2024 22:40:17 +0200 Subject: [PATCH 21/54] x --- common/konfig.py | 479 +++++++++++++++++----------------- common/singleton.py | 7 +- common/test/test_konfig.py | 4 +- common/test/test_singleton.py | 18 +- 4 files changed, 257 insertions(+), 251 deletions(-) diff --git a/common/konfig.py b/common/konfig.py index 23e0b5e9a..062a41d91 100644 --- a/common/konfig.py +++ b/common/konfig.py @@ -27,246 +27,248 @@ _ = lambda val: val +class Profile: + """Manages access to profile-specific configuration data.""" + _DEFAULT_VALUES = { + 'snapshots.mode': 'local', + 'snapshots.ssh.port': 22, + 'snapshots.ssh.cipher': 'default', + 'snapshots.ssh.user': getpass.getuser(), + 'snapshots.ssh.private_key_file': + str(Path('~') / '.ssh' / 'id_rsa'), + 'snapshots.ssh.max_arg_length': 0, + 'snapshots.ssh.check_commands': True, + 'snapshots.ssh.check_ping': True, + } + + def __init__(self, profile_id: int, config: Konfig): + self._config = config + self._prefix = f'profile{profile_id}' + + def __getitem__(self, key: str): + try: + return self._config[f'{self._prefix}.{key}'] + except KeyError: + return self._DEFAULT_VALUES[key] + + @property + def snapshots_mode(self) -> str: + """Use mode (or backend) for this snapshot. Look at 'man + backintime' section 'Modes'. + + { + 'values': 'local|local_encfs|ssh|ssh_encfs', + 'default': 'local', + } + """ + return self['snapshots.mode'] + + @property + def snapshots_path(self) -> str: + """Where to save snapshots in mode 'local'. This path must contain + a folderstructure like 'backintime///'. + + { + 'values': 'absolute path', + } + """ + raise NotImplementedError('see original in Config class') + return self['snapshots.path'] + + @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'] + + @property + def ssh_host(self) -> str: + """Remote host used for mode 'ssh' and 'ssh_encfs'. + + { + '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_cipher(self) -> str: + """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. + + { + 'values': 'default | aes192-cbc | aes256-cbc | aes128-ctr ' \ + '| aes192-ctr | aes256-ctr | arcfour | arcfour256 ' \ + '| arcfour128 | aes128-cbc | 3des-cbc | ' \ + 'blowfish-cbc | cast128-cbc', + } + """ + return self['snapshots.ssh.cipher'] + + @ssh_cipher.setter + def ssh_cipher(self, value: str) -> None: + self['snapshots.ssh.cipher'] = 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', + 'type': 'str' + } + + """ + raise NotImplementedError('see original in Config class') + path_string = self['snapshots.ssh.private_key_file'] + return Path(path_string) + + @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' } + """ + 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 + + class Konfig(metaclass=singleton.Singleton): - """Manage configuration of Back In Time. + """Manage configuration data for Back In Time. - That class is a replacement for the `config.Config` class. + Dev note: + + That class is a replacement for the `config.Config` class. """ - DEFAULT_VALUES = { + _DEFAULT_VALUES = { 'global.hash_collision': 0, 'global.language': '', 'global.use_flock': False, } - class Profile: - DEFAULT_VALUES = { - 'snapshots.mode': 'local', - 'snapshots.ssh.port': 22, - 'snapshots.ssh.cipher': 'default', - 'snapshots.ssh.user': getpass.getuser(), - 'snapshots.ssh.private_key_file': - str(Path('~') / '.ssh' / 'id_rsa'), - 'snapshots.ssh.max_arg_length': 0, - 'snapshots.ssh.check_commands': True, - 'snapshots.ssh.check_ping': True, - } - - def __init__(self, profile_id: int, config: Konfig): - self._config = config - self._prefix = f'profile{profile_id}' - - def __getitem__(self, key: str): - try: - return self._config[f'{self._prefix}.{key}'] - except KeyError: - return self.DEFAULT_VALUES[key] - - @property - def snapshots_mode(self) -> str: - """Use mode (or backend) for this snapshot. Look at 'man - backintime' section 'Modes'. - - { - 'values': 'local|local_encfs|ssh|ssh_encfs', - 'default': 'local', - } - """ - return self['snapshots.mode'] - - @property - def snapshots_path(self) -> str: - """Where to save snapshots in mode 'local'. This path must contain - a folderstructure like 'backintime///'. - - { - 'values': 'absolute path', - } - """ - raise NotImplementedError('see original in Config class') - return self['snapshots.path'] - - @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'] - - @property - def ssh_host(self) -> str: - """Remote host used for mode 'ssh' and 'ssh_encfs'. - - { - '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_cipher(self) -> str: - """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. - - { - 'values': 'default | aes192-cbc | aes256-cbc | aes128-ctr ' \ - '| aes192-ctr | aes256-ctr | arcfour | arcfour256 ' \ - '| arcfour128 | aes128-cbc | 3des-cbc | ' \ - 'blowfish-cbc | cast128-cbc', - } - """ - return self['snapshots.ssh.cipher'] - - @ssh_cipher.setter - def ssh_cipher(self, value: str) -> None: - self['snapshots.ssh.cipher'] = 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', - 'type': 'str' - } - - """ - raise NotImplementedError('see original in Config class') - path_string = self['snapshots.ssh.private_key_file'] - return Path(path_string) - - @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' } - """ - 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 - _DEFAULT_SECTION = '[bit]' def __init__(self, config_path: Path = None): - """ - """ if not config_path: xdg_config = os.environ.get('XDG_CONFIG_HOME', os.environ['HOME'] + '/.config') @@ -274,12 +276,12 @@ def __init__(self, config_path: Path = None): else: self._path = config_path - print(f'Config path used: {self._path} {type(self._path)=}') logger.debug(f'Config path used: {self._path}') self.load() # 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'), @@ -289,14 +291,12 @@ def __init__(self, config_path: Path = None): name: int(pid.replace('profile', '').replace('.name', '')) for pid, name in name_items } - # # First/Default profile not stored with name - # self._profiles[1] = _('Main profile') def __getitem__(self, key: str) -> Any: try: return self._conf[key] except KeyError: - return self.DEFAULT_VALUES[key] + return self._DEFAULT_VALUES[key] def __setitem__(self, key: str, val: Any) -> None: self._conf[key] = val @@ -307,7 +307,7 @@ def profile(self, name_or_id: Union[str, int]) -> Profile: else: profile_id = self._profiles[name_or_id] - return self.Profile(profile_id=profile_id, config=self) + return Profile(profile_id=profile_id, config=self) @property def profile_names(self) -> list[str]: @@ -318,6 +318,7 @@ def profile_ids(self) -> list[int]: return list(self._profiles.values()) def load(self): + """Load configuration from file like object.""" @contextlib.contextmanager def _path_or_buffer(path_or_buffer: Union[Path, StringIO] ) -> Union[TextIOWrapper, StringIO]: @@ -357,6 +358,10 @@ def _path_or_buffer(path_or_buffer: Union[Path, StringIO] self._conf = self._config_parser['bit'] def save(self): + """Store configuraton to the config file.""" + + raise NotImplementedError('Prevent overwritting real config data.') + buffer = StringIO() self._config_parser.write(buffer) buffer.seek(0) @@ -417,10 +422,10 @@ def global_flock(self, value: bool) -> None: if __name__ == '__main__': - # # Workaround because of missing gettext config - # _ = lambda s: s + # Empty in-memory config file + # k = Konfig(StringIO()) - buffer = StringIO() + # Regular config file k = Konfig() print(f'{k.profile_names=}') @@ -431,3 +436,5 @@ def global_flock(self, value: bool) -> None: p = k.profile(2) print(f'{p.snapshots_mode=}') + p.snapshots_mode='ssh' + print(f'{p.snapshots_mode=}') diff --git a/common/singleton.py b/common/singleton.py index 1055888d0..cbd61034d 100644 --- a/common/singleton.py +++ b/common/singleton.py @@ -4,12 +4,12 @@ # SPDX-License-Identifier: CC0-1.0 # # This file is licensed under Creative Commons Zero v1.0 Universal (CC0-1.0) -# and is part of the program "Back In time" which is released under GNU General +# and is part of the program "Back In Time" which is released under GNU General # Public License v2 (GPLv2). See file LICENSE or go to # . # -# Credits to Mr. Mars Landis describing that solution in his artile -# 'Better Python Singleton with a Metaclass' at +# Credits to Mr. Mars Landis describing that solution and comparing it to +# alternatives in his article # 'Better Python Singleton with a Metaclass' at # # himself refering to this Stack Overflow # question as his inspiration. @@ -74,4 +74,3 @@ def __call__(cls, *args, **kwargs): cls._instances[cls] = super().__call__(*args, **kwargs) return cls._instances[cls] - diff --git a/common/test/test_konfig.py b/common/test/test_konfig.py index 33b10ce86..69b2f9979 100644 --- a/common/test/test_konfig.py +++ b/common/test/test_konfig.py @@ -49,5 +49,5 @@ def test_default_values(self): self.assertIsInstance(sut.ssh_check_commands, bool) self.assertEqual(sut.ssh_cipher, 'default') self.assertIsInstance(sut.ssh_cipher, str) - self.assertEqual(sut.ssh_max_arg_length, 0) - self.assertIsInstance(sut.ssh_max_arg_length, int) + self.assertEqual(sut.ssh_port, 22) + self.assertIsInstance(sut.ssh_port, int) diff --git a/common/test/test_singleton.py b/common/test/test_singleton.py index 0731d99ed..2b0b2bc22 100644 --- a/common/test/test_singleton.py +++ b/common/test/test_singleton.py @@ -20,20 +20,20 @@ def __init__(self): def setUp(self): # Clean up all instances - Singleton._instances = {} + singleton.Singleton._instances = {} def test_twins(self): """Identical id and values.""" - a = Foo() - b = Foo() + a = self.Foo() + b = self.Foo() self.assertEqual(id(a), id(b)) self.assertEqual(a.value, b.value) def test_share_value(self): """Modify value""" - a = Foo() - b = Foo() + a = self.Foo() + b = self.Foo() a.value = 'foobar' self.assertEqual(a.value, 'foobar') @@ -41,10 +41,10 @@ def test_share_value(self): def test_multi_class(self): """Two different singleton classes.""" - a = Foo() - b = Foo() - x = Bar() - y = Bar() + a = self.Foo() + b = self.Foo() + x = self.Bar() + y = self.Bar() self.assertEqual(id(a), id(b)) self.assertEqual(id(x), id(y)) From b499c6f35c5955e00e4bbc0a7af0637280f0a910 Mon Sep 17 00:00:00 2001 From: Christian Buhtz Date: Sat, 24 Aug 2024 22:50:09 +0200 Subject: [PATCH 22/54] x --- common/man/C/backintime-config.1 | 97 ++++++++++++++++++++++++++++- create-manpage-backintime-config.py | 5 +- 2 files changed, 99 insertions(+), 3 deletions(-) diff --git a/common/man/C/backintime-config.1 b/common/man/C/backintime-config.1 index 508edbb1b..2fb9f10f2 100644 --- a/common/man/C/backintime-config.1 +++ b/common/man/C/backintime-config.1 @@ -31,7 +31,7 @@ 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: .RE .IP "\fIglobal.use_flock\fR" 6 @@ -43,6 +43,24 @@ Prevent multiple snapshots (from different profiles or users) to be run at the s Default: false .RE +.IP "\fIsnapshots.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 "\fIsnapshots.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 + +.RE + .IP "\fIsnapshots.ssh.path\fR" 6 .RS Type: str Allowed Values: absolute or relative path @@ -75,6 +93,83 @@ Default: 22 Type: str Allowed Values: text .br Remote SSH user. +.PP +Default: local users name +.RE + +.IP "\fIsnapshots.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 "\fIsnapshots.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_rsa +.RE + +.IP "\fIsnapshots.ssh.proxy_host\fR" 6 +.RS +Type: str Allowed Values: IP or domain address +.br +Proxy host (or jump host) used to connect to remote host. +.PP + +.RE + +.IP "\fIsnapshots.ssh.proxy_port\fR" 6 +.RS +Type: int Allowed Values: 0-65535 +.br +Port of SSH proxy (jump) host used to connect to remote host. +.PP +Default: 22 +.RE + +.IP "\fIsnapshots.ssh.proxy_user\fR" 6 +.RS +Type: str Allowed Values: text +.br +SSH user at proxy (jump) host. +.PP +Default: local users name +.RE + +.IP "\fIsnapshots.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'. The value '0' means unlimited length. +.PP +Default: 0 +.RE + +.IP "\fIsnapshots.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 "\fIsnapshots.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 + +.SH SEE ALSO .BR backintime (1), .BR backintime-qt (1) .PP diff --git a/create-manpage-backintime-config.py b/create-manpage-backintime-config.py index 98bb5e615..044741338 100755 --- a/create-manpage-backintime-config.py +++ b/create-manpage-backintime-config.py @@ -339,7 +339,7 @@ def inspect_properties(cls: type, name_prefix: str = '') -> dict[str, dict]: # default value if 'default' not in the_dict: try: - the_dict['default'] = cls.DEFAULT_VALUES[name] + the_dict['default'] = cls._DEFAULT_VALUES[name] except KeyError: pass @@ -384,7 +384,7 @@ def main(): cls=konfig.Konfig, ) profile_entries = inspect_properties( - cls=konfig.Konfig.Profile, + cls=konfig.Profile, name_prefix='profile.' ) @@ -416,5 +416,6 @@ def main(): lint_manpage(MAN) + if __name__ == '__main__': main() From a83eed2ecf825675532163e1e3834b7de37b8ca6 Mon Sep 17 00:00:00 2001 From: Christian Buhtz Date: Sat, 24 Aug 2024 23:25:35 +0200 Subject: [PATCH 23/54] x [skip ci] --- common/konfig.py | 9 ++++++- common/man/C/backintime-config.1 | 38 ++++++++++++++--------------- common/singleton.py | 10 +++++--- create-manpage-backintime-config.py | 1 + 4 files changed, 34 insertions(+), 24 deletions(-) diff --git a/common/konfig.py b/common/konfig.py index 062a41d91..731dfe645 100644 --- a/common/konfig.py +++ b/common/konfig.py @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: GPL-2.0-or-later # -# This file is part of the program "Back In time" which is released under GNU +# 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 . from __future__ import annotations @@ -51,6 +51,9 @@ def __getitem__(self, key: str): except KeyError: return self._DEFAULT_VALUES[key] + def __setitem__(self, key: str, val: Any): + self._config[f'{self._prefix}.{key}'] = val + @property def snapshots_mode(self) -> str: """Use mode (or backend) for this snapshot. Look at 'man @@ -63,6 +66,10 @@ def snapshots_mode(self) -> str: """ return self['snapshots.mode'] + @snapshots_mode.setter + def snapshots_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 diff --git a/common/man/C/backintime-config.1 b/common/man/C/backintime-config.1 index 2fb9f10f2..ef25e9bed 100644 --- a/common/man/C/backintime-config.1 +++ b/common/man/C/backintime-config.1 @@ -43,7 +43,7 @@ Prevent multiple snapshots (from different profiles or users) to be run at the s Default: false .RE -.IP "\fIsnapshots.mode\fR" 6 +.IP "\fIprofile.snapshots.mode\fR" 6 .RS Type: str Allowed Values: local|local_encfs|ssh|ssh_encfs .br @@ -52,7 +52,7 @@ Use mode (or backend) for this snapshot. Look at 'man backintime' section 'Modes Default: local .RE -.IP "\fIsnapshots.path\fR" 6 +.IP "\fIprofile.snapshots.path\fR" 6 .RS Type: str Allowed Values: absolute path .br @@ -61,7 +61,7 @@ Where to save snapshots in mode 'local'. This path must contain a folderstructur .RE -.IP "\fIsnapshots.ssh.path\fR" 6 +.IP "\fIprofile.snapshots.ssh.path\fR" 6 .RS Type: str Allowed Values: absolute or relative path .br @@ -70,7 +70,7 @@ Snapshot path on remote host. If the path is relative (no leading '/') it will s .RE -.IP "\fIsnapshots.ssh.host\fR" 6 +.IP "\fIprofile.snapshots.ssh.host\fR" 6 .RS Type: str Allowed Values: IP or domain address .br @@ -79,7 +79,7 @@ Remote host used for mode 'ssh' and 'ssh_encfs'. .RE -.IP "\fIsnapshots.ssh.port\fR" 6 +.IP "\fIprofile.snapshots.ssh.port\fR" 6 .RS Type: int Allowed Values: 0-65535 .br @@ -88,7 +88,7 @@ SSH Port on remote host. Default: 22 .RE -.IP "\fIsnapshots.ssh.user\fR" 6 +.IP "\fIprofile.snapshots.ssh.user\fR" 6 .RS Type: str Allowed Values: text .br @@ -97,25 +97,25 @@ Remote SSH user. Default: local users name .RE -.IP "\fIsnapshots.ssh.cipher\fR" 6 +.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 "\fIsnapshots.ssh.private_key_file\fR" 6 +.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_rsa + .RE -.IP "\fIsnapshots.ssh.proxy_host\fR" 6 +.IP "\fIprofile.snapshots.ssh.proxy_host\fR" 6 .RS Type: str Allowed Values: IP or domain address .br @@ -124,7 +124,7 @@ Proxy host (or jump host) used to connect to remote host. .RE -.IP "\fIsnapshots.ssh.proxy_port\fR" 6 +.IP "\fIprofile.snapshots.ssh.proxy_port\fR" 6 .RS Type: int Allowed Values: 0-65535 .br @@ -133,7 +133,7 @@ Port of SSH proxy (jump) host used to connect to remote host. Default: 22 .RE -.IP "\fIsnapshots.ssh.proxy_user\fR" 6 +.IP "\fIprofile.snapshots.ssh.proxy_user\fR" 6 .RS Type: str Allowed Values: text .br @@ -142,31 +142,31 @@ SSH user at proxy (jump) host. Default: local users name .RE -.IP "\fIsnapshots.ssh.max_arg_length\fR" 6 +.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'. The value '0' means unlimited length. .PP -Default: 0 + .RE -.IP "\fIsnapshots.ssh.check_commands\fR" 6 +.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 "\fIsnapshots.ssh.check_ping\fR" 6 +.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 .SH SEE ALSO diff --git a/common/singleton.py b/common/singleton.py index cbd61034d..384788c3e 100644 --- a/common/singleton.py +++ b/common/singleton.py @@ -9,7 +9,7 @@ # . # # Credits to Mr. Mars Landis describing that solution and comparing it to -# alternatives in his article # 'Better Python Singleton with a Metaclass' at +# alternatives in his article 'Better Python Singleton with a Metaclass' at # # himself refering to this Stack Overflow # question as his inspiration. @@ -57,9 +57,11 @@ >>> id(f) == id(b) False """ + + class Singleton(type): - """ - """ + """Singleton implemention supporting inheritance and multiple classes.""" + _instances = {} """Hold single instances of multiple classes.""" @@ -69,7 +71,7 @@ def __call__(cls, *args, **kwargs): # Re-use existing instance return cls._instances[cls] - except KeyError as exc: + except KeyError: # Create new instance cls._instances[cls] = super().__call__(*args, **kwargs) diff --git a/create-manpage-backintime-config.py b/create-manpage-backintime-config.py index 044741338..30ec8ab3c 100755 --- a/create-manpage-backintime-config.py +++ b/create-manpage-backintime-config.py @@ -311,6 +311,7 @@ def inspect_properties(cls: type, name_prefix: str = '') -> dict[str, dict]: # 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: raise RuntimeError('Can not find name of config field in ' f'the body of "{prop}".') from exc From 1c81c3a3a5e617bcaa0d65a854c9bf072405b10f Mon Sep 17 00:00:00 2001 From: buhtz Date: Thu, 29 Aug 2024 15:25:11 +0200 Subject: [PATCH 24/54] Update common/konfig.py [skip ci] Co-authored-by: David Wales --- common/konfig.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/konfig.py b/common/konfig.py index 731dfe645..2ded9e7aa 100644 --- a/common/konfig.py +++ b/common/konfig.py @@ -275,7 +275,7 @@ class Konfig(metaclass=singleton.Singleton): _DEFAULT_SECTION = '[bit]' - def __init__(self, config_path: Path = None): + def __init__(self, config_path: Optional[Path] = None): if not config_path: xdg_config = os.environ.get('XDG_CONFIG_HOME', os.environ['HOME'] + '/.config') From 96951166db618ea503bb5d68c222c7903dbae7f9 Mon Sep 17 00:00:00 2001 From: buhtz Date: Thu, 29 Aug 2024 15:30:24 +0200 Subject: [PATCH 25/54] Update common/konfig.py [skip Co-authored-by: David Wales --- common/konfig.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/konfig.py b/common/konfig.py index 2ded9e7aa..632a1aa51 100644 --- a/common/konfig.py +++ b/common/konfig.py @@ -278,7 +278,7 @@ class Konfig(metaclass=singleton.Singleton): def __init__(self, config_path: Optional[Path] = None): if not config_path: xdg_config = os.environ.get('XDG_CONFIG_HOME', - os.environ['HOME'] + '/.config') + Path.home() / '.config') self._path = Path(xdg_config) / 'backintime' / 'config' else: self._path = config_path From 74ded332cc7fe2587e40c0a095045b8dd4573207 Mon Sep 17 00:00:00 2001 From: buhtz Date: Thu, 29 Aug 2024 15:52:35 +0200 Subject: [PATCH 26/54] Apply suggestions from code review [skip ci] Co-authored-by: David Wales --- common/konfig.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/common/konfig.py b/common/konfig.py index 632a1aa51..049cf6938 100644 --- a/common/konfig.py +++ b/common/konfig.py @@ -291,7 +291,7 @@ def __init__(self, config_path: Optional[Path] = None): # EXtract all relevant lines of format 'profile*.name=*' name_items = filter( lambda val: - val[0].startswith('profile') and val[0].endswith('name'), + val[0].startswith('profile') and val[0].endswith('.name'), self._conf.items() ) self._profiles = { @@ -375,7 +375,9 @@ def save(self): with self._path.open('w', encoding='utf-8') as handle: # Write to file without section header - handle.write(''.join(buffer.readlines()[1:])) + # Discard unwanted first line + buffer.readline() + handle.write(buffer.read()) logger.debug(f'Configuration written to "{self._path}".') @property From 511a565a89c540cf3243ecc948b01dc916330bc9 Mon Sep 17 00:00:00 2001 From: buhtz Date: Thu, 29 Aug 2024 15:56:00 +0200 Subject: [PATCH 27/54] Update create-manpage-backintime-config.py [skip ci] Co-authored-by: David Wales --- create-manpage-backintime-config.py | 70 +++++++++++++++-------------- 1 file changed, 36 insertions(+), 34 deletions(-) diff --git a/create-manpage-backintime-config.py b/create-manpage-backintime-config.py index 30ec8ab3c..03a676783 100755 --- a/create-manpage-backintime-config.py +++ b/create-manpage-backintime-config.py @@ -115,40 +115,42 @@ def header(): stamp = strftime('%b %Y', gmtime()) ver = version.__version__ - content = f'.TH backintime-config 1 "{stamp}" ' \ - f'"version {ver}" "USER COMMANDS"\n' - - content += groff_section('NAME') - content += 'config \\- Back In Time configuration file.\n' - - content += groff_section('SYNOPSIS') - content += '~/.config/backintime/config\n' - content += groff_linebreak() - content += '/etc/backintime/config\n' - - content += groff_section('DESCRIPTION') - content += 'Back In Time was developed as pure GUI program and so most ' \ - 'functions are only usable with ' - content += groff_bold('backintime-qt') - content += '. 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' - - content += groff_paragraph_break() - content += 'The configuration file has the following format:\n' - content += groff_linebreak() - content += 'keyword=arguments\n' - - content += groff_paragraph_break() - content += "Arguments don't need to be quoted. All characters are " \ - "allowed except '='.\n" - - content += groff_paragraph_break() - content += "Run 'backintime check-config' to verify the configfile, " \ - "create the snapshot folder and crontab entries.\n" - - content += groff_section('POSSIBLE KEYWORDS') + 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', + + 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 From 2891c5895051bec1c9dc7190bc5a7108949a0f67 Mon Sep 17 00:00:00 2001 From: Christian Buhtz Date: Sat, 31 Aug 2024 13:58:24 +0200 Subject: [PATCH 28/54] no interpolation --- common/konfig.py | 3 ++- common/test/test_konfig.py | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/common/konfig.py b/common/konfig.py index 049cf6938..4e41cda1c 100644 --- a/common/konfig.py +++ b/common/konfig.py @@ -351,6 +351,7 @@ def _path_or_buffer(path_or_buffer: Union[Path, StringIO] path_or_buffer.seek(0) self._config_parser = configparser.ConfigParser( + interpolation=None, defaults={'profile1.name': _('Main profile')}) with _path_or_buffer(self._path) as handle: @@ -435,7 +436,7 @@ def global_flock(self, value: bool) -> None: # k = Konfig(StringIO()) # Regular config file - k = Konfig() + k = Konfig(StringIO('Foo=%3 %1 %2')) print(f'{k.profile_names=}') print(f'{k.profile_ids=}') diff --git a/common/test/test_konfig.py b/common/test/test_konfig.py index 69b2f9979..f84224b49 100644 --- a/common/test/test_konfig.py +++ b/common/test/test_konfig.py @@ -6,12 +6,17 @@ # General Public License v2 (GPLv2). # See file LICENSE or go to . import unittest +import configparser 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('')) @@ -32,9 +37,20 @@ def test_default_values(self): 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 Profiles(unittest.TestCase): """Konfig.Profile class""" + + def setUp(self): + Konfig._instances = {} + def test_empty(self): """Profile child objects""" konf = Konfig(StringIO('')) From 9da6ad5980e27574ee369b40a20219ece764671c Mon Sep 17 00:00:00 2001 From: Christian Buhtz Date: Sat, 31 Aug 2024 14:33:25 +0200 Subject: [PATCH 29/54] extern file loading --- common/konfig.py | 69 ++++++++++++++------------------------ common/test/test_konfig.py | 48 ++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 43 deletions(-) diff --git a/common/konfig.py b/common/konfig.py index 4e41cda1c..ed9b6e327 100644 --- a/common/konfig.py +++ b/common/konfig.py @@ -8,9 +8,8 @@ from __future__ import annotations import configparser import getpass -import contextlib import os -from typing import Union, Any +from typing import Union, Any, Optional from pathlib import Path from io import StringIO, TextIOWrapper import singleton @@ -275,20 +274,14 @@ class Konfig(metaclass=singleton.Singleton): _DEFAULT_SECTION = '[bit]' - def __init__(self, config_path: Optional[Path] = None): - if not config_path: - xdg_config = os.environ.get('XDG_CONFIG_HOME', - Path.home() / '.config') - self._path = Path(xdg_config) / 'backintime' / 'config' + def __init__(self, buffer: Optional[TextIOWrapper] = None): + if buffer: + self.load(buffer) else: - self._path = config_path - - logger.debug(f'Config path used: {self._path}') - - self.load() + self._conf = {} # Names and IDs of profiles - # EXtract all relevant lines of format 'profile*.name=*' + # Extract all relevant lines of format 'profile*.name=*' name_items = filter( lambda val: val[0].startswith('profile') and val[0].endswith('.name'), @@ -324,46 +317,21 @@ def profile_names(self) -> list[str]: def profile_ids(self) -> list[int]: return list(self._profiles.values()) - def load(self): + def load(self, buffer: TextIOWrapper): """Load configuration from file like object.""" - @contextlib.contextmanager - def _path_or_buffer(path_or_buffer: Union[Path, StringIO] - ) -> Union[TextIOWrapper, StringIO]: - """Using a path or a in-memory file (buffer) with a with - statement.""" - try: - # It is a regular file - path_or_buffer = path_or_buffer.open('r', encoding='utf-8') - print(f'{type(path_or_buffer)=}') - - except AttributeError: - # Assuming a StringIO instance as in-memory file - pass - - yield path_or_buffer - - try: - # regular file: close it - path_or_buffer.close() - - except AttributeError: - # in-memory file: "cursor" back to first byte - path_or_buffer.seek(0) self._config_parser = configparser.ConfigParser( interpolation=None, defaults={'profile1.name': _('Main profile')}) - with _path_or_buffer(self._path) as handle: - print(handle) - content = handle.read() - logger.debug(f'Configuration read from "{self._path}".') + # raw content + content = buffer.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['bit'] + self._conf = self._config_parser[self._DEFAULT_SECTION[1:-1]] def save(self): """Store configuraton to the config file.""" @@ -431,12 +399,27 @@ def global_flock(self, value: bool) -> None: self['global.use_flock'] = value +def config_file_path() -> Path: + """Return the config file path. + + Could be moved into backintime.py. sys.argv (--config) needs to be + considered. + """ + xdg_config = os.environ.get('XDG_CONFIG_HOME', Path.home() / '.config') + path = Path(xdg_config) / 'backintime' / 'config' + + logger.debug(f'Config path: {path}') + + return path + + if __name__ == '__main__': # Empty in-memory config file # k = Konfig(StringIO()) # Regular config file - k = Konfig(StringIO('Foo=%3 %1 %2')) + with config_file_path().open('r', encoding='utf-8') as handle: + k = Konfig(handle) print(f'{k.profile_names=}') print(f'{k.profile_ids=}') diff --git a/common/test/test_konfig.py b/common/test/test_konfig.py index f84224b49..dec7f6d6f 100644 --- a/common/test/test_konfig.py +++ b/common/test/test_konfig.py @@ -7,6 +7,8 @@ # 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 @@ -45,6 +47,52 @@ def test_no_interpolation(self): self.fail(f'InterpolationSyntaxError was raised. {exc}') +class Read(unittest.TestCase): + 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""" From 8e9c6f42d830e7c1c921fcf71545036bdc859072 Mon Sep 17 00:00:00 2001 From: Christian Buhtz Date: Sat, 31 Aug 2024 15:22:12 +0200 Subject: [PATCH 30/54] minor fixes --- common/bitbase.py | 11 +++++++++++ common/konfig.py | 18 ++++++++---------- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/common/bitbase.py b/common/bitbase.py index 567e74f37..00a7c2391 100644 --- a/common/bitbase.py +++ b/common/bitbase.py @@ -7,6 +7,17 @@ # See file LICENSE or go to . """Basic constants used in multiple modules.""" +# 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: + _ = lambda val: val + + # See issue #1734 and #1735 URL_ENCRYPT_TRANSITION = 'https://github.com/bit-team/backintime' \ '/blob/-/doc/ENCRYPT_TRANSITION.md' diff --git a/common/konfig.py b/common/konfig.py index ed9b6e327..d3b5b314e 100644 --- a/common/konfig.py +++ b/common/konfig.py @@ -333,21 +333,19 @@ def load(self, buffer: TextIOWrapper): # The one and only main section self._conf = self._config_parser[self._DEFAULT_SECTION[1:-1]] - def save(self): + def save(self, buffer: TextIOWrapper): """Store configuraton to the config file.""" raise NotImplementedError('Prevent overwritting real config data.') - buffer = StringIO() - self._config_parser.write(buffer) - buffer.seek(0) + tmp_io_buffer = StringIO() + self._config_parser.write(tmp_io_buffer) + tmp_io_buffer.seek(0) - with self._path.open('w', encoding='utf-8') as handle: - # Write to file without section header - # Discard unwanted first line - buffer.readline() - handle.write(buffer.read()) - logger.debug(f'Configuration written to "{self._path}".') + # Write to file without section header + # Discard unwanted first line + tmp_io_buffer.readline() + handle.write(tmp_io_buffer.read()) @property def hash_collision(self) -> int: From bdd8ea280dfe674ad7ee038ef49aadb2dd316ea6 Mon Sep 17 00:00:00 2001 From: Christian Buhtz Date: Sat, 31 Aug 2024 15:26:01 +0200 Subject: [PATCH 31/54] [skip ci] --- common/bitbase.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/common/bitbase.py b/common/bitbase.py index 00a7c2391..03c400b54 100644 --- a/common/bitbase.py +++ b/common/bitbase.py @@ -10,8 +10,6 @@ # 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: From 293bcf2bfbe61f16bc18215eb46b12db781b73b2 Mon Sep 17 00:00:00 2001 From: Christian Buhtz Date: Sat, 31 Aug 2024 18:16:55 +0200 Subject: [PATCH 32/54] dirty --- common/konfig.py | 121 +++++++++++++++++++++++++++++++++++-- common/test/test_konfig.py | 2 + 2 files changed, 118 insertions(+), 5 deletions(-) diff --git a/common/konfig.py b/common/konfig.py index d3b5b314e..f5ac39e66 100644 --- a/common/konfig.py +++ b/common/konfig.py @@ -38,6 +38,8 @@ class Profile: 'snapshots.ssh.max_arg_length': 0, 'snapshots.ssh.check_commands': True, 'snapshots.ssh.check_ping': True, + 'snapshots.local_encfs.path': '', + 'snapshots.password.save': False, } def __init__(self, profile_id: int, config: Konfig): @@ -78,9 +80,16 @@ def snapshots_path(self) -> str: 'values': 'absolute path', } """ - raise NotImplementedError('see original in Config class') + raise NotImplementedError( + 'see original in Config class. See also ' + 'Config.snapshotsFullPath(self, profile_id = None)') + return self['snapshots.path'] + @snapshots_path.setter + def snapshots_path(self, path): + raise NotImplementedError('see original in Config class.') + @property def ssh_snapshots_path(self) -> str: """Snapshot path on remote host. If the path is relative (no @@ -94,6 +103,10 @@ def ssh_snapshots_path(self) -> str: """ return self['snapshots.ssh.path'] + @ssh_snapshots_path.setter + def ssh_snapshots_path(self, path): + raise NotImplementedError('see original in Config class.') + @property def ssh_host(self) -> str: """Remote host used for mode 'ssh' and 'ssh_encfs'. @@ -172,6 +185,10 @@ def ssh_private_key_file(self) -> Path: path_string = self['snapshots.ssh.private_key_file'] return Path(path_string) + @ssh_private_key_file.setter + def ssh_private_key_file(self, path: Path) -> None: + self['snapshots.ssh.private_key_file'] = path + @property def ssh_proxy_host(self) -> str: """Proxy host (or jump host) used to connect to remote host. @@ -257,6 +274,48 @@ def ssh_check_ping_host(self) -> bool: def ssh_check_ping_host(self, value: bool) -> None: self['snapshots.ssh.check_ping'] = value + @property + def local_encfs_path(self) -> Path: + """Where to save snapshots in mode 'local_encfs'. + + { values: 'absolute path' } + """ + return self['snapshots.local_encfs.path'] + + @local_encfs_path.setter + def local_encfs_path(self, path: Path): + self['snapshots.local_encfs.path'] = str(path) + + @property + def password_save(self) -> bool: + """Save password to system keyring (gnome-keyring or kwallet). + """ + raise NotImplementedError( + 'Refactor it first to make the field name mode independed. ' + 'profileN.snapshots.password.save') + return self['snapshots.password.save'] + + @password_save.setter + def password_save(self, value: bool) -> None: + self['snapshots.password.save'] = value + + ------------- WEITER --------------- + @property + def password_use_cache(self, value: bool) -> None: + if mode is None: + mode = self.snapshotsMode(profile_id) + default = not tools.checkHomeEncrypt() + #?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 if home is not encrypted + return self.profileBoolValue('snapshots.%s.password.use_cache' % mode, default, profile_id) + + 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) + + class Konfig(metaclass=singleton.Singleton): """Manage configuration data for Back In Time. @@ -270,11 +329,20 @@ class Konfig(metaclass=singleton.Singleton): 'global.hash_collision': 0, 'global.language': '', 'global.use_flock': False, + 'internal.manual_starts_countdown': 10, } _DEFAULT_SECTION = '[bit]' - def __init__(self, buffer: Optional[TextIOWrapper] = None): + def __init__(self, buffer: Optional[TextIOWrapper, StringIO] = None): + """Constructor. + + Args: + buffer: An open text-file handle or a string buffer ready to read. + + Note: That method is executed only once because `Konfig` is a + singleton. + """ if buffer: self.load(buffer) else: @@ -302,6 +370,14 @@ def __setitem__(self, key: str, val: Any) -> None: self._conf[key] = val 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 snapshot profile. + + Raises: + KeyError: If no corresponding profile exists. + """ if isinstance(name_or_id, int): profile_id = name_or_id else: @@ -317,8 +393,12 @@ def profile_names(self) -> list[str]: def profile_ids(self) -> list[int]: return list(self._profiles.values()) - def load(self, buffer: TextIOWrapper): - """Load configuration from file like object.""" + def load(self, buffer: Union[TextIOWrapper, StringIO]): + """Load configuration from file like object. + + Args: + buffer: An open text-file handle or a string buffer ready to read. + """ self._config_parser = configparser.ConfigParser( interpolation=None, @@ -396,6 +476,26 @@ def global_flock(self) -> bool: def global_flock(self, value: bool) -> None: self['global.use_flock'] = value + @property + def manual_starts_countdown(self) -> int: + # 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 + def config_file_path() -> Path: """Return the config file path. @@ -415,9 +515,17 @@ def config_file_path() -> Path: # Empty in-memory config file # k = Konfig(StringIO()) + x = Konfig() + print(x) + print(x._conf) + # Regular config file with config_file_path().open('r', encoding='utf-8') as handle: - k = Konfig(handle) + k = Konfig() + k.load(handle) + + print(k) + print(k._conf) print(f'{k.profile_names=}') print(f'{k.profile_ids=}') @@ -429,3 +537,6 @@ def config_file_path() -> Path: print(f'{p.snapshots_mode=}') p.snapshots_mode='ssh' print(f'{p.snapshots_mode=}') + + k.foobarasd = 7 + p.foobarasd = 7 diff --git a/common/test/test_konfig.py b/common/test/test_konfig.py index dec7f6d6f..5b626075a 100644 --- a/common/test/test_konfig.py +++ b/common/test/test_konfig.py @@ -48,6 +48,8 @@ def test_no_interpolation(self): class Read(unittest.TestCase): + """Read a config file/object""" + def setUp(self): Konfig._instances = {} From f98cf5c05a777a8bebbe33fb154260d0885db476 Mon Sep 17 00:00:00 2001 From: Christian Buhtz Date: Sun, 1 Sep 2024 22:10:15 +0200 Subject: [PATCH 33/54] x --- common/konfig.py | 80 ++++++++++++++++++++++++----- common/man/C/backintime-config.1 | 56 +++++++++++++++++++- create-manpage-backintime-config.py | 7 ++- 3 files changed, 127 insertions(+), 16 deletions(-) diff --git a/common/konfig.py b/common/konfig.py index f5ac39e66..261db0fba 100644 --- a/common/konfig.py +++ b/common/konfig.py @@ -9,6 +9,7 @@ import configparser import getpass import os +import socket from typing import Union, Any, Optional from pathlib import Path from io import StringIO, TextIOWrapper @@ -30,6 +31,8 @@ class Profile: """Manages access to profile-specific configuration data.""" _DEFAULT_VALUES = { 'snapshots.mode': 'local', + 'snapshots.path.host': socket.gethostname(), + 'snapshots.path.user': getpass.getuser(), 'snapshots.ssh.port': 22, 'snapshots.ssh.cipher': 'default', 'snapshots.ssh.user': getpass.getuser(), @@ -90,6 +93,50 @@ def snapshots_path(self) -> str: def snapshots_path(self, path): raise NotImplementedError('see original in Config class.') + @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_profileid(self) -> str: + """Set Profile-ID for snapshot 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._prefix.replace('profile', '') + + @snapshots_path_profileid.setter + def snapshots_path_profileid(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 @@ -278,7 +325,7 @@ def ssh_check_ping_host(self, value: bool) -> None: def local_encfs_path(self) -> Path: """Where to save snapshots in mode 'local_encfs'. - { values: 'absolute path' } + { 'values': 'absolute path' } """ return self['snapshots.local_encfs.path'] @@ -289,6 +336,7 @@ def local_encfs_path(self, path: Path): @property def password_save(self) -> bool: """Save password to system keyring (gnome-keyring or kwallet). + { 'values': 'true|false' } """ raise NotImplementedError( 'Refactor it first to make the field name mode independed. ' @@ -299,22 +347,26 @@ def password_save(self) -> bool: def password_save(self, value: bool) -> None: self['snapshots.password.save'] = value - ------------- WEITER --------------- @property def password_use_cache(self, value: bool) -> None: - if mode is None: - mode = self.snapshotsMode(profile_id) - default = not tools.checkHomeEncrypt() - #?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 if home is not encrypted - return self.profileBoolValue('snapshots.%s.password.use_cache' % mode, default, profile_id) - - 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) + """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' + } + """ + raise NotImplementedError( + 'Refactor it first to make the field name mode independed. ' + 'profileN.snapshots.password.use_cache.' + 'See also Issue #1855 about encrypted home dir') + # ??? default = not tools.checkHomeEncrypt() + return self['snapshots.password.use_cache'] + @password_use_cache.setter + def password_use_cache(self, value: bool) -> None: + self['snapshots.password.use_cache'] = value class Konfig(metaclass=singleton.Singleton): diff --git a/common/man/C/backintime-config.1 b/common/man/C/backintime-config.1 index ef25e9bed..76c2f2b93 100644 --- a/common/man/C/backintime-config.1 +++ b/common/man/C/backintime-config.1 @@ -1,4 +1,4 @@ -.TH backintime-config 1 "Aug 2024" "version 1.5.3-dev.3e80feee" "USER COMMANDS" +.TH backintime-config 1 "Sep 2024" "version 1.5.3-dev.3e80feee" "USER COMMANDS" .SH NAME config \- Back In Time configuration file. .SH SYNOPSIS @@ -61,6 +61,33 @@ Where to save snapshots in mode 'local'. This path must contain a folderstructur .RE +.IP "\fIprofile.snapshots.path.host\fR" 6 +.RS +Type: str Allowed Values: local hostname +.br +Set Host for snapshot path. +.PP + +.RE + +.IP "\fIprofile.snapshots.path.user\fR" 6 +.RS +Type: str Allowed Values: local username +.br +Set User for snapshot path. +.PP + +.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.ssh.path\fR" 6 .RS Type: str Allowed Values: absolute or relative path @@ -169,6 +196,33 @@ Check if the remote host is available before trying to mount. .RE +.IP "\fIprofile.snapshots.local_encfs.path\fR" 6 +.RS +Type: Path Allowed Values: absolute path +.br +Where to save snapshots in mode 'local_encfs'. +.PP + +.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). +.PP + +.RE + +.IP "\fIprofile.snapshots.password.use_cache\fR" 6 +.RS +Type: None 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. +.PP +Default: see #1855 +.RE + .SH SEE ALSO .BR backintime (1), .BR backintime-qt (1) diff --git a/create-manpage-backintime-config.py b/create-manpage-backintime-config.py index 03a676783..dca2f7b03 100755 --- a/create-manpage-backintime-config.py +++ b/create-manpage-backintime-config.py @@ -322,7 +322,11 @@ def inspect_properties(cls: type, name_prefix: str = '') -> dict[str, dict]: doc = attr.__doc__ # extract the dict from docstring - the_dict = REX_DICT_EXTRACT.search(doc).groups()[0] + try: + the_dict = REX_DICT_EXTRACT.search(doc).groups()[0] + except AttributeError: + the_dict = '' + the_dict = '{' + the_dict + '}' # remove the dict from docstring @@ -400,6 +404,7 @@ def main(): # PROPERTIES for name, entry in {**global_entries, **profile_entries}.items(): + # print(f'{name=} {entry=}') handle.write( entry_to_groff( name=name, From e6373a6d7f8fe168e879beb844613570640d2307 Mon Sep 17 00:00:00 2001 From: Christian Buhtz Date: Mon, 2 Sep 2024 22:14:40 +0200 Subject: [PATCH 34/54] next: excludeBySizeEnabled --- common/konfig.py | 111 ++++++++++++++++++++++++++-- create-manpage-backintime-config.py | 32 ++++++++ 2 files changed, 137 insertions(+), 6 deletions(-) diff --git a/common/konfig.py b/common/konfig.py index 261db0fba..8b6d30532 100644 --- a/common/konfig.py +++ b/common/konfig.py @@ -10,6 +10,7 @@ import getpass import os import socket +import re from typing import Union, Any, Optional from pathlib import Path from io import StringIO, TextIOWrapper @@ -43,6 +44,8 @@ class Profile: 'snapshots.ssh.check_ping': True, 'snapshots.local_encfs.path': '', 'snapshots.password.save': False, + 'snapshots.include': [], + 'snapshots.exclude': [], } def __init__(self, profile_id: int, config: Konfig): @@ -58,6 +61,9 @@ def __getitem__(self, key: str): def __setitem__(self, key: str, val: Any): self._config[f'{self._prefix}.{key}'] = val + def __delitem__(self, key: str) -> None: + del self._config[f'{self._prefix}.{key}'] + @property def snapshots_mode(self) -> str: """Use mode (or backend) for this snapshot. Look at 'man @@ -351,7 +357,6 @@ def password_save(self, value: bool) -> None: def password_use_cache(self, value: bool) -> 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' @@ -368,6 +373,86 @@ def password_use_cache(self, value: bool) -> None: def password_use_cache(self, value: bool) -> None: self['snapshots.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. + + 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 = [] + + for item in self._config._conf: # <-- Ugly, I know. + 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]: + # 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]: + # 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 + class Konfig(metaclass=singleton.Singleton): """Manage configuration data for Back In Time. @@ -384,7 +469,7 @@ class Konfig(metaclass=singleton.Singleton): 'internal.manual_starts_countdown': 10, } - _DEFAULT_SECTION = '[bit]' + _DEFAULT_SECTION = 'bit' def __init__(self, buffer: Optional[TextIOWrapper, StringIO] = None): """Constructor. @@ -421,6 +506,9 @@ def __getitem__(self, key: str) -> Any: 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. @@ -460,10 +548,10 @@ def load(self, buffer: Union[TextIOWrapper, StringIO]): content = buffer.read() # Add section header to make it a real INI file - self._config_parser.read_string(f'{self._DEFAULT_SECTION}\n{content}') + 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[1:-1]] + self._conf = self._config_parser[self._DEFAULT_SECTION] def save(self, buffer: TextIOWrapper): """Store configuraton to the config file.""" @@ -586,9 +674,20 @@ def config_file_path() -> Path: print(f'{k.global_flock=}') p = k.profile(2) + print(p._prefix) print(f'{p.snapshots_mode=}') p.snapshots_mode='ssh' print(f'{p.snapshots_mode=}') + print(f'{p.include=}') + + p = k.profile(8) + print(p._prefix) + print(f'{p.include=}') + + p = k.profile(9) + print(p._prefix) + print(f'{p.include=}') + print(f'{p.exclude=}') - k.foobarasd = 7 - p.foobarasd = 7 + p.include=[('foo', 0), ('bar', 1)] + print(f'{p.include=}') diff --git a/create-manpage-backintime-config.py b/create-manpage-backintime-config.py index dca2f7b03..ab298fca2 100755 --- a/create-manpage-backintime-config.py +++ b/create-manpage-backintime-config.py @@ -395,6 +395,38 @@ def main(): name_prefix='profile.' ) + # WORKAROuND: + # Structure of include/exclude fields can not be easly 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}".') From be1918a18158ca538114346b0af0d4cac6d36250 Mon Sep 17 00:00:00 2001 From: Christian Buhtz Date: Tue, 3 Sep 2024 20:00:02 +0200 Subject: [PATCH 35/54] X --- common/config.py | 1 + common/konfig.py | 51 ++++++++++++++++++++ common/man/C/backintime-config.1 | 27 +++++++++++ common/test/test_konfig.py | 73 +++++++++++++++++++++++++++++ create-manpage-backintime-config.py | 9 +++- 5 files changed, 160 insertions(+), 1 deletion(-) diff --git a/common/config.py b/common/config.py index f2a37fda8..862bc3d2e 100644 --- a/common/config.py +++ b/common/config.py @@ -67,6 +67,7 @@ class Config(configfile.ConfigFileWithProfiles): CONFIG_VERSION = 6 """Latest or highest possible version of Back in Time's config file.""" + # Schedule mode codes NONE = 0 AT_EVERY_BOOT = 1 _5_MIN = 2 diff --git a/common/konfig.py b/common/konfig.py index 8b6d30532..2dfa2b71f 100644 --- a/common/konfig.py +++ b/common/konfig.py @@ -46,6 +46,9 @@ class Profile: 'snapshots.password.save': False, 'snapshots.include': [], 'snapshots.exclude': [], + 'snapshots.exclude.bysize.enabled': False, + 'snapshots.exclude.bysize.value': 500, + 'schedule.mode': 0, } def __init__(self, profile_id: int, config: Konfig): @@ -388,11 +391,15 @@ def _generic_include_exclude_ids(self, inc_exc_str: str) -> tuple[int]: ids = [] for item in self._config._conf: # <-- Ugly, I know. + # print(f'{item=}') # DEBUG try: ids.append(int(rex.findall(item)[0])) except IndexError: pass + # DEBUG + # print(f'{inc_exc_str=} {ids=}') + return tuple(ids) def _get_include_ids(self) -> tuple[int]: @@ -453,6 +460,50 @@ def exclude(self, values: list[str]) -> None: 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): + self['snapshots.exclude.bysize.value'] = value + + @property + def schedule_mode(self) -> int: + """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 self['schedule.mode'] + + @schedule_mode.setter + def schedule_mode(self, value: int) -> None: + self['schedule.mode'] = value + class Konfig(metaclass=singleton.Singleton): """Manage configuration data for Back In Time. diff --git a/common/man/C/backintime-config.1 b/common/man/C/backintime-config.1 index 76c2f2b93..441f8ab2a 100644 --- a/common/man/C/backintime-config.1 +++ b/common/man/C/backintime-config.1 @@ -223,6 +223,33 @@ Cache password in RAM so it can be read by cronjobs. Security issue: root might Default: see #1855 .RE +.IP "\fIprofile.snapshots.exclude.bysize.enabled\fR" 6 +.RS +Type: bool Allowed Values: true|false +.br +Enable exclude files by size. +.PP + +.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 + +.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'. 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 + +.RE + .SH SEE ALSO .BR backintime (1), .BR backintime-qt (1) diff --git a/common/test/test_konfig.py b/common/test/test_konfig.py index 5b626075a..79203188f 100644 --- a/common/test/test_konfig.py +++ b/common/test/test_konfig.py @@ -117,3 +117,76 @@ def test_default_values(self): self.assertIsInstance(sut.ssh_cipher, str) self.assertEqual(sut.ssh_port, 22) self.assertIsInstance(sut.ssh_port, int) + + +class IncludeExclude(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_included_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/create-manpage-backintime-config.py b/create-manpage-backintime-config.py index ab298fca2..bb273fb05 100755 --- a/create-manpage-backintime-config.py +++ b/create-manpage-backintime-config.py @@ -361,6 +361,13 @@ def inspect_properties(cls: type, name_prefix: str = '') -> dict[str, dict]: 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' + entries[name] = the_dict # DEBUG @@ -436,7 +443,7 @@ def main(): # PROPERTIES for name, entry in {**global_entries, **profile_entries}.items(): - # print(f'{name=} {entry=}') + print(f'{name=} {entry=}') handle.write( entry_to_groff( name=name, From 8c803281c931ae654c092fa4c83706b53fab8b8a Mon Sep 17 00:00:00 2001 From: Christian Buhtz Date: Thu, 5 Sep 2024 19:55:12 +0200 Subject: [PATCH 36/54] x --- common/config.py | 1 + common/konfig.py | 93 +++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 93 insertions(+), 1 deletion(-) diff --git a/common/config.py b/common/config.py index 862bc3d2e..fb03a5e9f 100644 --- a/common/config.py +++ b/common/config.py @@ -1063,6 +1063,7 @@ def scheduleRepeatedUnit(self, profile_id = None): def setScheduleRepeatedUnit(self, value, profile_id = None): self.setProfileIntValue('schedule.repeatedly.unit', value, profile_id) + ---- WEITER ---- def removeOldSnapshots(self, profile_id = None): #?Remove all snapshots older than value + unit return (self.profileBoolValue('snapshots.remove_old_snapshots.enabled', True, profile_id), diff --git a/common/konfig.py b/common/konfig.py index 2dfa2b71f..1b541b1ad 100644 --- a/common/konfig.py +++ b/common/konfig.py @@ -49,6 +49,13 @@ class Profile: 'snapshots.exclude.bysize.enabled': False, 'snapshots.exclude.bysize.value': 500, 'schedule.mode': 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': 20, # DAY } def __init__(self, profile_id: int, config: Konfig): @@ -475,7 +482,6 @@ def exclude_by_size(self) -> int: 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'] @@ -504,6 +510,91 @@ def schedule_mode(self) -> int: def schedule_mode(self, value: int) -> None: self['schedule.mode'] = 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 + class Konfig(metaclass=singleton.Singleton): """Manage configuration data for Back In Time. From 51573f7dcc492f5b41373e49f25cc84aa3094ade Mon Sep 17 00:00:00 2001 From: Christian Buhtz Date: Sun, 22 Sep 2024 23:11:40 +0200 Subject: [PATCH 37/54] linting. replace pycodestyle by flake8 --- .travis.yml | 2 +- CONTRIBUTING.md | 2 +- common/bitbase.py | 11 ++--- common/konfig.py | 92 ++++++++++++++++++++++++---------------- common/test/test_lint.py | 65 +++++++++++++++++++++------- qt/test/test_lint.py | 63 ++++++++++++++++++++------- 6 files changed, 162 insertions(+), 73 deletions(-) diff --git a/.travis.yml b/.travis.yml index 2142e4275..aad690292 100644 --- a/.travis.yml +++ b/.travis.yml @@ -50,7 +50,7 @@ jobs: install: - pip install -U pip - - pip install pylint ruff pycodestyle pyfakefs keyring + - pip install flake8 pylint ruff pycodestyle pyfakefs keyring - pip install pyqt6 dbus-python # add ssh public / private key pair to ensure user can start ssh session to localhost for tests - ssh-keygen -b 2048 -t rsa -f /home/travis/.ssh/id_rsa -N "" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 36008743d..628ee364a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -151,7 +151,7 @@ the packages provided by the official repository of your GNU/Linux distribution. - `python3-pyfakefs` - Optional but recommended: - `pylint` (>= 3.3.0) - - `pycodestyle` + - `flake8` - `ruff` (>= 0.6.6) - `codespell` diff --git a/common/bitbase.py b/common/bitbase.py index 9c2bd49b4..f4944b520 100644 --- a/common/bitbase.py +++ b/common/bitbase.py @@ -6,16 +6,17 @@ # General Public License v2 (GPLv2). See file/folder LICENSE or go to # . """Basic constants used in multiple modules.""" - +from enum import Enum # 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: - _ = lambda val: val + def _(val): + return val -from enum import Enum # See issue #1734 and #1735 URL_ENCRYPT_TRANSITION = 'https://github.com/bit-team/backintime' \ @@ -37,9 +38,9 @@ 'arcfour': 'ARCFOUR' } + class TimeUnit(Enum): - """Describe time units used in context of scheduling. - """ + """Describe time units used in context of scheduling.""" HOUR = 10 # Config.HOUR DAY = 20 # Config.DAY WEEK = 30 # Config.WEEK diff --git a/common/konfig.py b/common/konfig.py index 1b541b1ad..63281edfa 100644 --- a/common/konfig.py +++ b/common/konfig.py @@ -5,6 +5,8 @@ # 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 . +"""Configuration mangament. +""" from __future__ import annotations import configparser import getpass @@ -25,10 +27,11 @@ try: _('Warning') except NameError: - _ = lambda val: val + def _(val): + return val -class Profile: +class Profile: # pylint: disable=too-many-public-methods """Manages access to profile-specific configuration data.""" _DEFAULT_VALUES = { 'snapshots.mode': 'local', @@ -103,7 +106,7 @@ def snapshots_path(self) -> str: 'see original in Config class. See also ' 'Config.snapshotsFullPath(self, profile_id = None)') - return self['snapshots.path'] + # return self['snapshots.path'] @snapshots_path.setter def snapshots_path(self, path): @@ -245,8 +248,8 @@ def ssh_private_key_file(self) -> Path: """ raise NotImplementedError('see original in Config class') - path_string = self['snapshots.ssh.private_key_file'] - return Path(path_string) + # path_string = self['snapshots.ssh.private_key_file'] + # return Path(path_string) @ssh_private_key_file.setter def ssh_private_key_file(self, path: Path) -> None: @@ -308,7 +311,7 @@ def ssh_max_arg_length(self) -> int: } """ raise NotImplementedError('see org in Config') - return self['snapshots.ssh.max_arg_length'] + # return self['snapshots.ssh.max_arg_length'] @ssh_max_arg_length.setter def ssh_max_arg_length(self, length: int) -> None: @@ -357,14 +360,14 @@ def password_save(self) -> bool: raise NotImplementedError( 'Refactor it first to make the field name mode independed. ' 'profileN.snapshots.password.save') - return self['snapshots.password.save'] + # return self['snapshots.password.save'] @password_save.setter def password_save(self, value: bool) -> None: self['snapshots.password.save'] = value @property - def password_use_cache(self, value: bool) -> None: + 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. { @@ -377,7 +380,7 @@ def password_use_cache(self, value: bool) -> None: 'profileN.snapshots.password.use_cache.' 'See also Issue #1855 about encrypted home dir') # ??? default = not tools.checkHomeEncrypt() - return self['snapshots.password.use_cache'] + # return self['snapshots.password.use_cache'] @password_use_cache.setter def password_use_cache(self, value: bool) -> None: @@ -386,6 +389,25 @@ def password_use_cache(self, value: bool) -> None: 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. """ @@ -397,16 +419,14 @@ def _generic_include_exclude_ids(self, inc_exc_str: str) -> tuple[int]: ids = [] - for item in self._config._conf: # <-- Ugly, I know. - # print(f'{item=}') # DEBUG + # 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 - # DEBUG - # print(f'{inc_exc_str=} {ids=}') - return tuple(ids) def _get_include_ids(self) -> tuple[int]: @@ -419,7 +439,7 @@ def _get_exclude_ids(self) -> tuple[int]: return self._generic_include_exclude_ids('exclude') @property - def include(self) -> list[str, int]: + def include(self) -> list[str, int]: # pylint: disable=C0116 # Man page docu is added manually. See # create-manpage-backintime-config.sh script. @@ -448,7 +468,7 @@ def include(self, values: list[str, int]) -> None: self[f'snapshots.include.{idx}.type'] = str(val[1]) @property - def exclude(self) -> list[str]: + def exclude(self) -> list[str]: # pylint: disable=C0116 # Man page docu is added manually. See # create-manpage-backintime-config.sh script. result = [] @@ -524,8 +544,8 @@ 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). + Only valid for \fIprofile.schedule.mode\fR = 20 (daily), + 30 (weekly), 40 (monthly) and 80 (yearly). { 'values': '0-2400' } """ return self['schedule.time'] @@ -669,10 +689,12 @@ def profile(self, name_or_id: Union[str, int]) -> Profile: @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 load(self, buffer: Union[TextIOWrapper, StringIO]): @@ -690,7 +712,8 @@ def load(self, buffer: Union[TextIOWrapper, StringIO]): content = buffer.read() # Add section header to make it a real INI file - self._config_parser.read_string(f'[{self._DEFAULT_SECTION}]\n{content}') + 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] @@ -700,14 +723,14 @@ def save(self, buffer: TextIOWrapper): raise NotImplementedError('Prevent overwritting real config data.') - tmp_io_buffer = StringIO() - self._config_parser.write(tmp_io_buffer) - tmp_io_buffer.seek(0) + # 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()) + # # Write to file without section header + # # Discard unwanted first line + # tmp_io_buffer.readline() + # handle.write(tmp_io_buffer.read()) @property def hash_collision(self) -> int: @@ -759,7 +782,7 @@ def global_flock(self, value: bool) -> None: self['global.use_flock'] = value @property - def manual_starts_countdown(self) -> int: + def manual_starts_countdown(self) -> int: # pylint: disable=C0116 # Countdown value about how often the users started the Back In Time # GUI. @@ -797,9 +820,9 @@ def config_file_path() -> Path: # Empty in-memory config file # k = Konfig(StringIO()) - x = Konfig() - print(x) - print(x._conf) + k = Konfig() + print(k) + print(k._conf) # pylint: disable=protected-access # Regular config file with config_file_path().open('r', encoding='utf-8') as handle: @@ -807,7 +830,7 @@ def config_file_path() -> Path: k.load(handle) print(k) - print(k._conf) + print(k._conf) # pylint: disable=protected-access print(f'{k.profile_names=}') print(f'{k.profile_ids=}') @@ -816,20 +839,17 @@ def config_file_path() -> Path: print(f'{k.global_flock=}') p = k.profile(2) - print(p._prefix) print(f'{p.snapshots_mode=}') - p.snapshots_mode='ssh' + p.snapshots_mode = 'ssh' print(f'{p.snapshots_mode=}') print(f'{p.include=}') p = k.profile(8) - print(p._prefix) print(f'{p.include=}') p = k.profile(9) - print(p._prefix) print(f'{p.include=}') print(f'{p.exclude=}') - p.include=[('foo', 0), ('bar', 1)] + p.include = [('foo', 0), ('bar', 1)] print(f'{p.include=}') diff --git a/common/test/test_lint.py b/common/test/test_lint.py index 89530a4ca..22aadc77d 100644 --- a/common/test/test_lint.py +++ b/common/test/test_lint.py @@ -17,11 +17,11 @@ import subprocess import shutil from typing import Iterable -try: - import pycodestyle - PYCODESTYLE_AVAILABLE = True -except ImportError: - PYCODESTYLE_AVAILABLE = False +# try: +# import pycodestyle +# PYCODESTYLE_AVAILABLE = True +# except ImportError: +# PYCODESTYLE_AVAILABLE = False BASE_REASON = ('Using package {0} is mandatory on TravisCI, on ' 'other systems it runs only if `{0}` is available.') @@ -32,11 +32,13 @@ PYLINT_AVAILABLE = shutil.which('pylint') is not None RUFF_AVAILABLE = shutil.which('ruff') is not None +FLAKE8_AVAILABLE = shutil.which('flake8') is not None ANY_LINTER_AVAILABLE = any(( PYLINT_AVAILABLE, RUFF_AVAILABLE, - PYCODESTYLE_AVAILABLE, + # PYCODESTYLE_AVAILABLE, + FLAKE8_AVAILABLE, )) # "common" directory @@ -45,6 +47,7 @@ # Files in this lists will get the full battery of linters and rule sets. full_test_files = [_base_dir / fp for fp in ( 'bitbase.py', + 'konfig.py', 'schedule.py', 'version.py', 'test/test_lint.py', @@ -154,6 +157,9 @@ def test010_ruff_default_ruleset(self): # See: '--config', 'pylint.max-branches=13', + '--config', 'flake8-quotes.inline-quotes = "single"', + # one error per line (no context lines) + '--output-format=concise', '--quiet', ] @@ -178,17 +184,46 @@ def test010_ruff_default_ruleset(self): # any other errors? self.assertEqual(proc.stderr, '') - @unittest.skipUnless(PYCODESTYLE_AVAILABLE, - BASE_REASON.format('pycodestyle')) - def test020_pycodestyle_default_ruleset(self): - """PEP8 conformance via pycodestyle""" + @unittest.skipUnless(FLAKE8_AVAILABLE, BASE_REASON.format('flake8')) + def test020_flake8_default_ruleset(self): + """Flake8 in default mode.""" - style = pycodestyle.StyleGuide(quite=True) - result = style.check_files(full_test_files) + cmd = [ + 'flake8', + f'--max-line-length={PEP8_MAX_LINE_LENGTH}', + '--builtins=_,ngettext', + # '--enable-extensions=' + ] + + cmd.extend(full_test_files) + + proc = subprocess.run( + cmd, + check=False, + universal_newlines=True, + capture_output=True + ) + + error_n = len(proc.stdout.splitlines()) + if error_n > 0: + print(proc.stdout) + + self.assertEqual(0, error_n, f'Flake8 found {error_n} problem(s).') + + # any other errors? + self.assertEqual(proc.stderr, '') + + # @unittest.skipUnless(PYCODESTYLE_AVAILABLE, + # BASE_REASON.format('pycodestyle')) + # def test020_pycodestyle_default_ruleset(self): + # """PEP8 conformance via pycodestyle""" + + # style = pycodestyle.StyleGuide(quite=True) + # result = style.check_files(full_test_files) - self.assertEqual(result.total_errors, 0, - f'pycodestyle found {result.total_errors} code ' - 'style error(s)/warning(s).') + # self.assertEqual(result.total_errors, 0, + # f'pycodestyle found {result.total_errors} code ' + # 'style error(s)/warning(s).') @unittest.skipUnless(PYLINT_AVAILABLE, BASE_REASON.format('PyLint')) def test030_pylint_default_ruleset(self): diff --git a/qt/test/test_lint.py b/qt/test/test_lint.py index f4b56cb9e..61bbb6f89 100644 --- a/qt/test/test_lint.py +++ b/qt/test/test_lint.py @@ -15,11 +15,11 @@ import subprocess import shutil from typing import Iterable -try: - import pycodestyle - PYCODESTYLE_AVAILABLE = True -except ImportError: - PYCODESTYLE_AVAILABLE = False +# try: +# import pycodestyle +# PYCODESTYLE_AVAILABLE = True +# except ImportError: +# PYCODESTYLE_AVAILABLE = False BASE_REASON = ('Using package {0} is mandatory on TravisCI, on ' 'other systems it runs only if `{0}` is available.') @@ -30,11 +30,13 @@ PYLINT_AVAILABLE = shutil.which('pylint') is not None RUFF_AVAILABLE = shutil.which('ruff') is not None +FLAKE8_AVAILABLE = shutil.which('flake8') is not None ANY_LINTER_AVAILABLE = any(( PYLINT_AVAILABLE, RUFF_AVAILABLE, - PYCODESTYLE_AVAILABLE, + # PYCODESTYLE_AVAILABLE, + FLAKE8_AVAILABLE, )) # "qt" directory @@ -154,6 +156,8 @@ def test010_ruff_default_ruleset(self): # 1buojae/comment/kxu0mp3> '--config', 'pylint.max-branches=13', '--config', 'flake8-quotes.inline-quotes = "single"', + # one error per line (no context lines) + '--output-format=concise', '--quiet', ] @@ -178,17 +182,46 @@ def test010_ruff_default_ruleset(self): # any other errors? self.assertEqual(proc.stderr, '') - @unittest.skipUnless(PYCODESTYLE_AVAILABLE, - BASE_REASON.format('pycodestyle')) - def test020_pycodestyle_default_ruleset(self): - """PEP8 conformance via pycodestyle""" + @unittest.skipUnless(FLAKE8_AVAILABLE, BASE_REASON.format('flake8')) + def test020_flake8_default_ruleset(self): + """Flake8 in default mode.""" - style = pycodestyle.StyleGuide(quite=True) - result = style.check_files(full_test_files) + cmd = [ + 'flake8', + f'--max-line-length={PEP8_MAX_LINE_LENGTH}', + '--builtins=_,ngettext', + # '--enable-extensions=' + ] + + cmd.extend(full_test_files) + + proc = subprocess.run( + cmd, + check=False, + universal_newlines=True, + capture_output=True + ) + + error_n = len(proc.stdout.splitlines()) + if error_n > 0: + print(proc.stdout) + + self.assertEqual(0, error_n, f'Flake8 found {error_n} problem(s).') + + # any other errors? + self.assertEqual(proc.stderr, '') + + # @unittest.skipUnless(PYCODESTYLE_AVAILABLE, + # BASE_REASON.format('pycodestyle')) + # def test020_pycodestyle_default_ruleset(self): + # """PEP8 conformance via pycodestyle""" + + # style = pycodestyle.StyleGuide(quite=True) + # result = style.check_files(full_test_files) - self.assertEqual(result.total_errors, 0, - f'pycodestyle found {result.total_errors} code ' - 'style error(s)/warning(s).') + # self.assertEqual(result.total_errors, 0, + # f'pycodestyle found {result.total_errors} code ' + # 'style error(s)/warning(s).') @unittest.skipUnless(PYLINT_AVAILABLE, BASE_REASON.format('PyLint')) def test030_pylint_default_ruleset(self): From e5efdf8bb6897761ef2f68745447a82aea5958bc Mon Sep 17 00:00:00 2001 From: Christian Buhtz Date: Fri, 27 Sep 2024 12:04:55 +0200 Subject: [PATCH 38/54] some more steps [skip ci] --- common/bitbase.py | 8 + common/config.py | 4 +- common/konfig.py | 365 +++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 371 insertions(+), 6 deletions(-) diff --git a/common/bitbase.py b/common/bitbase.py index f4944b520..762c91dea 100644 --- a/common/bitbase.py +++ b/common/bitbase.py @@ -45,3 +45,11 @@ class TimeUnit(Enum): DAY = 20 # Config.DAY WEEK = 30 # Config.WEEK MONTH = 40 # Config.MONTH + YEAR = 80 # Config.Year + + +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 diff --git a/common/config.py b/common/config.py index 0791a2040..b62866a2a 100644 --- a/common/config.py +++ b/common/config.py @@ -917,7 +917,6 @@ def scheduleRepeatedUnit(self, profile_id = None): def setScheduleRepeatedUnit(self, value, profile_id = None): self.setProfileIntValue('schedule.repeatedly.unit', value, profile_id) - ---- WEITER ---- def removeOldSnapshots(self, profile_id = None): #?Remove all snapshots older than value + unit return (self.profileBoolValue('snapshots.remove_old_snapshots.enabled', True, profile_id), @@ -1167,6 +1166,7 @@ def preserveXattr(self, profile_id = None): def setPreserveXattr(self, value, profile_id = None): return self.setProfileBoolValue('snapshots.preserve_xattr', value, profile_id) + ---- WEITER ---- 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 @@ -1391,7 +1391,7 @@ def preparePath(self, path): def isConfigured(self, profile_id=None): """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. """ path = self.snapshotsPath(profile_id) diff --git a/common/konfig.py b/common/konfig.py index 63281edfa..b5cefe93d 100644 --- a/common/konfig.py +++ b/common/konfig.py @@ -18,6 +18,7 @@ from io import StringIO, TextIOWrapper import singleton import logger +from bitbase import TimeUnit, StorageSizeUnit # Workaround: Mostly relevant on TravisCI but not exclusively. # While unittesting and without regular invocation of BIT the GNU gettext @@ -58,7 +59,38 @@ class Profile: # pylint: disable=too-many-public-methods 'schedule.weekday': 7, 'schedule.custom_time': '8,12,18,23', 'schedule.repeatedly.period': 1, - 'schedule.repeatedly.unit': 20, # DAY + '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.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, } def __init__(self, profile_id: int, config: Konfig): @@ -592,9 +624,8 @@ def custom_backup_time(self, value: str) -> None: @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. + """How many units to wait between new snapshots with anacron? Only valid + for \fIprofile.schedule.mode\fR = 25|27. """ return self['schedule.repeatedly.period'] @@ -615,6 +646,332 @@ def schedule_repeated_unit(self) -> int: 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['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 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'] + + @min_free_space_enabled.setter + def min_free_space_enabled(self, enable: bool) -> None: + self['snapshots.min_free_space.enabled'] = enable + + @property + def min_free_space_value(self) -> int: + """Keep at least value + unit free space.""" + return self['snapshots.min_free_space.value'] + + @min_free_space_value.setter + def min_free_space_value(self, value: int) -> None: + self['snapshots.min_free_space.value'] = value + + @property + def min_free_space_unit(self) -> StorageSizeUnit: + """10 = MB\n20 = GB + { 'values': '10|20' } + """ + return self['snapshots.min_free_space.unit'] + + @min_free_space_unit.setter + def min_free_space_unit(self, unit: StorageSizeUnit) -> None: + self['snapshots.min_free_space.unit'] = unit + + @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: + 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 \-n19'. 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 \-c2 \-n7'. This will give Back In Time + the owest 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 \-c2 \-n7' 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 \-n19'.""" + 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 \-c2 \-n7'.""" + 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_snapshot_on_battery(self) -> bool: + """Don't take snapshots if the Computer runs on battery.""" + return self['snapshots.no_on_battery'] + + @no_snapshot_on_battery.setter + def no_snapshot_on_battery(self, enable: bool) -> None: + self['snapshots.no_on_battery'] = enable + + @property + def preserve_alc(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_alc.setter + def preserve_alc(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 + class Konfig(metaclass=singleton.Singleton): """Manage configuration data for Back In Time. From af9225571e5291b2d3d423a48d24975bf8c27039 Mon Sep 17 00:00:00 2001 From: Christian Buhtz Date: Fri, 27 Sep 2024 13:26:50 +0200 Subject: [PATCH 39/54] x --- common/config.py | 2 +- common/konfig.py | 80 ++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 79 insertions(+), 3 deletions(-) diff --git a/common/config.py b/common/config.py index b62866a2a..dd44140bb 100644 --- a/common/config.py +++ b/common/config.py @@ -1166,7 +1166,6 @@ def preserveXattr(self, profile_id = None): def setPreserveXattr(self, value, profile_id = None): return self.setProfileBoolValue('snapshots.preserve_xattr', value, profile_id) - ---- WEITER ---- 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 @@ -1192,6 +1191,7 @@ def oneFileSystem(self, profile_id = None): def setOneFileSystem(self, value, profile_id = None): return self.setProfileBoolValue('snapshots.one_file_system', value, profile_id) + ---- WEITER ---- def rsyncOptionsEnabled(self, profile_id = None): #?Past additional options to rsync return self.profileBoolValue('snapshots.rsync_options.enabled', False, profile_id) diff --git a/common/konfig.py b/common/konfig.py index b5cefe93d..ccfc8339c 100644 --- a/common/konfig.py +++ b/common/konfig.py @@ -91,19 +91,43 @@ class Profile: # pylint: disable=too-many-public-methods '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, } def __init__(self, profile_id: int, config: Konfig): self._config = config self._prefix = f'profile{profile_id}' - def __getitem__(self, key: str): + 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): + 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: @@ -972,6 +996,58 @@ 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.settter + 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.profileBoolValue('snapshots.rsync_options.enabled', False, profile_id) + + @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 + e.g. \-\-exclude-from="/path/to/my exclude file".""" + return self['snapshots.rsync_options.value'] + + @rsync_options.setter + def rsync_options(self, options: str) -> None: + self['snapshots.rsync_options.value'] = options + class Konfig(metaclass=singleton.Singleton): """Manage configuration data for Back In Time. From d23da6cdd6302390b408515c6811ed7f6873f6b5 Mon Sep 17 00:00:00 2001 From: Christian Buhtz Date: Fri, 27 Sep 2024 13:42:17 +0200 Subject: [PATCH 40/54] improved create script --- common/konfig.py | 6 +- common/man/C/backintime-config.1 | 387 ++++++++++++++++++++++++++++ create-manpage-backintime-config.py | 53 +++- 3 files changed, 429 insertions(+), 17 deletions(-) diff --git a/common/konfig.py b/common/konfig.py index ccfc8339c..114bf571a 100644 --- a/common/konfig.py +++ b/common/konfig.py @@ -683,7 +683,7 @@ def remove_old_snapshots_enabled(self, enabled: bool) -> None: @property def remove_old_snapshots_value(self) -> int: """Snapshots older than this times units will be removed.""" - return['snapshots.remove_old_snapshots.value'] + return self['snapshots.remove_old_snapshots.value'] @remove_old_snapshots_value.setter def remove_old_snapshots_value(self, value: int) -> None: @@ -1014,7 +1014,7 @@ def copy_links(self) -> bool: """ return self['snapshots.copy_links'] - @copy_links.settter + @copy_links.setter def copy_links(self, enable: bool) -> None: self['snapshots.copy_links'] = enable @@ -1032,7 +1032,7 @@ def one_file_system(self, enable: bool) -> None: @property def rsync_options_enabled(self) -> bool: """Past additional options to rsync""" - return self.profileBoolValue('snapshots.rsync_options.enabled', False, profile_id) + return self['snapshots.rsync_options.enabled'] @rsync_options_enabled.setter def rsync_options_enabled(self, enable: bool) -> None: diff --git a/common/man/C/backintime-config.1 b/common/man/C/backintime-config.1 index 441f8ab2a..5c4ec1331 100644 --- a/common/man/C/backintime-config.1 +++ b/common/man/C/backintime-config.1 @@ -250,6 +250,393 @@ Which schedule used for crontab. The crontab entry will be generated with 'backi .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 + +.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 Iprofile.schedule.mode R = 20 (daily), 30 (weekly), 40 (monthly) and 80 (yearly). +.PP + +.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 Iprofile.schedule.mode R >= 40. +.PP + +.RE + +.IP "\fIprofile.schedule.weekday\fR" 6 +.RS +Type: int Allowed Values: 1 (monday) to 7 (sunday) +.br +Which day of week the cronjob should run? Only valid for Iprofile.schedule.mode R = 30. +.PP + +.RE + +.IP "\fIprofile.schedule.custom_time\fR" 6 +.RS +Type: str Allowed Values: comma separated int (8,12,18,23) or */3;8,12,18,23 +.br +Custom hours for cronjob. Only valid for Iprofile.schedule.mode R = 19 +.PP + +.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 Iprofile.schedule.mode R = 25|27. +.PP + +.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. 10 = hours 20 = days 30 = weeks 40 = months Only valid for Iprofile.schedule.mode R = 25|27; +.PP + +.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 + +.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 + +.RE + +.IP "\fIprofile.snapshots.remove_old_snapshots.unit\fR" 6 +.RS +Type: TimeUnit Allowed Values: 20|30|80 +.br +Time unit to use to calculate removing of old snapshots. 20 = days; 30 = weeks; 80 = years +.PP + +.RE + +.IP "\fIprofile.snapshots.min_free_space.enabled\fR" 6 +.RS +Type: bool Allowed Values: true|false +.br +Remove snapshots until Iprofile.snapshots.min_free_space.value R free space is reached. +.PP + +.RE + +.IP "\fIprofile.snapshots.min_free_space.value\fR" 6 +.RS +Type: int Allowed Values: 0-99999 +.br +Keep at least value + unit free space. +.PP + +.RE + +.IP "\fIprofile.snapshots.min_free_space.unit\fR" 6 +.RS +Type: StorageSizeUnitAllowed Values: 10|20 +.br +10 = MB 20 = GB +.PP + +.RE + +.IP "\fIprofile.snapshots.min_free_inodes.enabled\fR" 6 +.RS +Type: bool Allowed Values: true|false +.br +Remove snapshots until Iprofile.snapshots.min_free_inodes.value R #?free inodes in % is reached. +.PP + +.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 + +.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 + +.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 + +.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 + +.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 + +.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 + +.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 months. +.PP + +.RE + +.IP "\fIprofile.snapshots.smart_remove.run_remote_in_background\fR" 6 +.RS +Type: bool Allowed Values: true|false +.br +If using modes SSH or SSH-encrypted, run smart_remove in background on remote machine +.PP + +.RE + +.IP "\fIprofile.snapshots.notify.enabled\fR" 6 +.RS +Type: bool Allowed Values: true|false +.br +Display notifications (errors, warnings) through libnotify or DBUS. +.PP + +.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 + +.RE + +.IP "\fIprofile.snapshots.cron.nice\fR" 6 +.RS +Type: bool Allowed Values: true|false +.br +Run cronjobs with 'nice \-n19'. This will give Back In Time the lowest CPU priority to not interrupt any other working process. +.PP + +.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 Back In Time the owest IO bandwidth priority to not interrupt any other working process. +.PP + +.RE + +.IP "\fIprofile.snapshots.user_backup.ionice\fR" 6 +.RS +Type: bool Allowed Values: true|false +.br +Run Back In Time with 'ionice \-c2 \-n7' when taking a manual snapshot. This will give Back In Time the lowest IO bandwidth priority to not interrupt any other working process. +.PP + +.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 + +.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 + +.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 + +.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 + +.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 + +.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 + +.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 + +.RE + +.IP "\fIprofile.snapshots.bwlimit.value\fR" 6 +.RS +Type: int Allowed Values: 0-99999 +.br +Bandwidth limit in KB/sec. +.PP + +.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 + +.RE + +.IP "\fIprofile.snapshots.preserve_acl\fR" 6 +.RS +Type: bool Allowed Values: true|false +.br +Preserve Access Control Lists (ACL). The source and destination systems must have compatible ACL entries for this option to work properly. +.PP + +.RE + +.IP "\fIprofile.snapshots.preserve_xattr\fR" 6 +.RS +Type: bool Allowed Values: true|false +.br +Preserve extended attributes (xattr). +.PP + +.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 + +.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 + +.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 + +.RE + +.IP "\fIprofile.snapshots.rsync_options.enabled\fR" 6 +.RS +Type: bool Allowed Values: true|false +.br +Past additional options to rsync +.PP + +.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 + +.RE + .SH SEE ALSO .BR backintime (1), .BR backintime-qt (1) diff --git a/create-manpage-backintime-config.py b/create-manpage-backintime-config.py index bb273fb05..c82496c7c 100755 --- a/create-manpage-backintime-config.py +++ b/create-manpage-backintime-config.py @@ -217,7 +217,7 @@ def _is_public_property(val): def lint_manpage(path: Path) -> bool: """Lint the manpage the same way as the Debian Lintian does.""" - print('Linting man page...') + print('Linting man page…') cmd = [ 'man', @@ -257,6 +257,7 @@ def lint_manpage(path: Path) -> bool: return False print('No problems reported.') + return True @@ -295,6 +296,18 @@ def inspect_properties(cls: type, name_prefix: str = '') -> dict[str, dict]: Returns: A dictionary indexed by the config option field names. """ + # The folloing 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 @@ -303,20 +316,25 @@ def inspect_properties(cls: type, name_prefix: str = '') -> dict[str, dict]: # Ignore properties without docstring if not attr.__doc__: - print(f'Ignoring "{cls.__name__}.{prop}" because of ' + 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}') + # 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: - raise RuntimeError('Can not find name of config field in ' - f'the body of "{prop}".') from 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__ @@ -367,6 +385,9 @@ def inspect_properties(cls: type, name_prefix: str = '') -> dict[str, dict]: 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 @@ -443,16 +464,20 @@ def main(): # PROPERTIES for name, entry in {**global_entries, **profile_entries}.items(): - print(f'{name=} {entry=}') - handle.write( - entry_to_groff( - name=name, - doc=entry['doc'], - values=entry['values'], - default=entry.get('default', None), - its_type=entry.get('type', None), + 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 From 6ed254c6f37d25181b7b86709433e43429412cb6 Mon Sep 17 00:00:00 2001 From: Christian Buhtz Date: Fri, 27 Sep 2024 14:38:38 +0200 Subject: [PATCH 41/54] properties finished [skip ci] --- common/config.py | 1 - common/konfig.py | 82 ++++++++++++++++++++++++++++++++ common/man/C/backintime-config.1 | 63 ++++++++++++++++++++++++ 3 files changed, 145 insertions(+), 1 deletion(-) diff --git a/common/config.py b/common/config.py index dd44140bb..b16fcc7ce 100644 --- a/common/config.py +++ b/common/config.py @@ -1191,7 +1191,6 @@ def oneFileSystem(self, profile_id = None): def setOneFileSystem(self, value, profile_id = None): return self.setProfileBoolValue('snapshots.one_file_system', value, profile_id) - ---- WEITER ---- def rsyncOptionsEnabled(self, profile_id = None): #?Past additional options to rsync return self.profileBoolValue('snapshots.rsync_options.enabled', False, profile_id) diff --git a/common/konfig.py b/common/konfig.py index 114bf571a..33ace7fd0 100644 --- a/common/konfig.py +++ b/common/konfig.py @@ -95,6 +95,14 @@ class Profile: # pylint: disable=too-many-public-methods '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, + 'global.use_flock': False, } def __init__(self, profile_id: int, config: Konfig): @@ -1048,6 +1056,80 @@ def rsync_options(self) -> str: 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 + + @property + def global_flock(self) -> bool: + """Prevent multiple snapshots (from different profiles or users) to be + run at the same time. + """ + return self['global.use_flock'] + + @global_flock.setter + def global_flock(self, enable: bool) -> None: + self['global.use_flock'] = enable + + class Konfig(metaclass=singleton.Singleton): """Manage configuration data for Back In Time. diff --git a/common/man/C/backintime-config.1 b/common/man/C/backintime-config.1 index 5c4ec1331..b5bd1ec22 100644 --- a/common/man/C/backintime-config.1 +++ b/common/man/C/backintime-config.1 @@ -637,6 +637,69 @@ Rsync options. Options must be quoted e.g. \-\-exclude-from="/path/to/my exclude .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 + +.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 Iprofile.snapshots.rsync_options.value R with --rsync-path="FOO=bar:\\$FOO /usr/bin/rsync" +.PP + +.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 + +.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 + +.RE + +.IP "\fIprofile.snapshots.log_level\fR" 6 +.RS +Type: int Allowed Values: 1-3 +.br +Log level used during takeSnapshot. 1 = Error 2 = Changes 3 = Info. +.PP + +.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 + +.RE + +.IP "\fIprofile.global.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 + +.RE + .SH SEE ALSO .BR backintime (1), .BR backintime-qt (1) From b66ce2bc11898fd9f347fcba4e82cd05e6d16735 Mon Sep 17 00:00:00 2001 From: Christian Buhtz Date: Thu, 3 Oct 2024 17:49:02 +0200 Subject: [PATCH 42/54] x --- common/konfig.py | 52 +++++++++++++------------ common/man/C/backintime-config.1 | 32 +++++++-------- common/test/test_konfig.py | 5 +-- common/test/test_plugin_usercallback.py | 19 ++++----- 4 files changed, 55 insertions(+), 53 deletions(-) diff --git a/common/konfig.py b/common/konfig.py index 33ace7fd0..f4889a9f9 100644 --- a/common/konfig.py +++ b/common/konfig.py @@ -5,6 +5,7 @@ # 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 @@ -608,7 +609,7 @@ 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), + Only valid for \\fIprofile.schedule.mode\\fR = 20 (daily), 30 (weekly), 40 (monthly) and 80 (yearly). { 'values': '0-2400' } """ @@ -621,7 +622,7 @@ def schedule_time(self, value: int) -> None: @property def schedule_day(self) -> int: """Which day of month the cronjob should run? Only valid for - \fIprofile.schedule.mode\fR >= 40. + \\fIprofile.schedule.mode\\fR >= 40. { 'values': '1-28' } """ return self['schedule.day'] @@ -633,7 +634,7 @@ def schedule_day(self, value: int) -> None: @property def schedule_weekday(self) -> int: """Which day of week the cronjob should run? Only valid for - \fIprofile.schedule.mode\fR = 30. + \\fIprofile.schedule.mode\\fR = 30. { 'values': '1 (monday) to 7 (sunday)' } """ return self['schedule.weekday'] @@ -645,7 +646,7 @@ def schedule_weekday(self, value: int) -> None: @property def custom_backup_time(self) -> str: """Custom hours for cronjob. Only valid for - \fIprofile.schedule.mode\fR = 19 + \\fIprofile.schedule.mode\\fR = 19 { 'values': 'comma separated int (8,12,18,23) or */3;8,12,18,23' } """ return self['schedule.custom_time'] @@ -656,8 +657,8 @@ def custom_backup_time(self, value: str) -> None: @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. + """How many units to wait between new snapshots with anacron? Only + valid for \\fIprofile.schedule.mode\\fR = 25|27. """ return self['schedule.repeatedly.period'] @@ -669,7 +670,7 @@ def schedule_repeated_period(self, value: int) -> None: 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; + Only valid for \\fIprofile.schedule.mode\\fR = 25|27; { 'values': '10|20|30|40' } """ return self['schedule.repeatedly.unit'] @@ -713,8 +714,8 @@ def remove_old_snapshots_unit(self, unit: TimeUnit) -> None: @property def min_free_space_enabled(self) -> bool: - """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. """ return self['snapshots.min_free_space.enabled'] @@ -744,8 +745,9 @@ def min_free_space_unit(self, unit: StorageSizeUnit) -> None: @property def min_free_inodes_enabled(self) -> bool: - """Remove snapshots until \fIprofile.snapshots.min_free_inodes.value\fR - #?free inodes in % is reached. + """Remove snapshots until + \\fIprofile.snapshots.min_free_inodes.value\\fR + free inodes in % is reached. """ return self['snapshots.min_free_inodes.enabled'] @@ -776,6 +778,7 @@ def dont_remove_named_snapshots(self, value: bool) -> None: @property def keep_named_snapshots(self) -> bool: + """Keep snapshots with names during smart_remove.""" return self.dont_remove_named_snapshots @keep_named_snapshots.setter @@ -840,7 +843,8 @@ def smart_remove_run_remote_in_background(self, enable: bool) -> None: @property def notify(self) -> bool: - """Display notifications (errors, warnings) through libnotify or DBUS.""" + """Display notifications (errors, warnings) through libnotify or DBUS. + """ return self['snapshots.notify.enabled'] @notify.setter @@ -858,7 +862,7 @@ def backup_on_restore(self, enable: bool) -> None: @property def nice_on_cron(self) -> bool: - """Run cronjobs with 'nice \-n19'. This will give Back In Time the + """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'] @@ -868,9 +872,9 @@ def nice_on_cron(self, enable: bool) -> None: @property def ionice_on_cron(self) -> bool: - """Run cronjobs with 'ionice \-c2 \-n7'. This will give Back In Time - the owest IO bandwidth priority to not interrupt any other working - process. + """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'] @@ -880,9 +884,9 @@ def ionice_on_cron(self, enable: bool) -> None: @property def ionice_on_user(self) -> bool: - """Run Back In Time with 'ionice \-c2 \-n7' when taking a manual - snapshot. This will give Back In Time the lowest IO bandwidth priority - to not interrupt any other working process. + """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'] @@ -892,7 +896,7 @@ def ionice_on_user(self, enable: bool) -> None: @property def nice_on_remote(self) -> bool: - """Run rsync and other commands on remote host with 'nice \-n19'.""" + """Run rsync and other commands on remote host with 'nice' value 19.""" return self['snapshots.ssh.nice'] @nice_on_remote.setter @@ -902,7 +906,7 @@ def nice_on_remote(self, enable: bool) -> None: @property def ionice_on_remote(self) -> bool: """Run rsync and other commands on remote host with - 'ionice \-c2 \-n7'.""" + 'ionice' and class 2 and level 7.""" return self['snapshots.ssh.ionice'] @ionice_on_remote.setter @@ -1048,8 +1052,7 @@ def rsync_options_enabled(self, enable: bool) -> None: @property def rsync_options(self) -> str: - """Rsync options. Options must be quoted - e.g. \-\-exclude-from="/path/to/my exclude file".""" + """Rsync options. Options must be quoted.""" return self['snapshots.rsync_options.value'] @rsync_options.setter @@ -1069,7 +1072,7 @@ def ssh_prefix_enabled(self, enable: bool) -> None: 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 + for rsync use \\fIprofile.snapshots.rsync_options.value\\fR with --rsync-path="FOO=bar:\\\\$FOO /usr/bin/rsync" """ return self['snapshots.ssh.prefix.value'] @@ -1130,7 +1133,6 @@ def global_flock(self, enable: bool) -> None: self['global.use_flock'] = enable - class Konfig(metaclass=singleton.Singleton): """Manage configuration data for Back In Time. diff --git a/common/man/C/backintime-config.1 b/common/man/C/backintime-config.1 index b5bd1ec22..6f853910d 100644 --- a/common/man/C/backintime-config.1 +++ b/common/man/C/backintime-config.1 @@ -1,4 +1,4 @@ -.TH backintime-config 1 "Sep 2024" "version 1.5.3-dev.3e80feee" "USER COMMANDS" +.TH backintime-config 1 "Oct 2024" "version 1.5.3-dev.3e80feee" "USER COMMANDS" .SH NAME config \- Back In Time configuration file. .SH SYNOPSIS @@ -263,7 +263,7 @@ Enable debug output to system log for schedule mode. .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 Iprofile.schedule.mode R = 20 (daily), 30 (weekly), 40 (monthly) and 80 (yearly). +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 .RE @@ -272,7 +272,7 @@ Position-coded number with the format "hhmm" to specify the hour and minute the .RS Type: int Allowed Values: 1-28 .br -Which day of month the cronjob should run? Only valid for Iprofile.schedule.mode R >= 40. +Which day of month the cronjob should run? Only valid for \fIprofile.schedule.mode\fR >= 40. .PP .RE @@ -281,7 +281,7 @@ Which day of month the cronjob should run? Only valid for Iprofile.schedule.m .RS Type: int Allowed Values: 1 (monday) to 7 (sunday) .br -Which day of week the cronjob should run? Only valid for Iprofile.schedule.mode R = 30. +Which day of week the cronjob should run? Only valid for \fIprofile.schedule.mode\fR = 30. .PP .RE @@ -290,7 +290,7 @@ Which day of week the cronjob should run? Only valid for Iprofile.schedule.mo .RS Type: str Allowed Values: comma separated int (8,12,18,23) or */3;8,12,18,23 .br -Custom hours for cronjob. Only valid for Iprofile.schedule.mode R = 19 +Custom hours for cronjob. Only valid for \fIprofile.schedule.mode\fR = 19 .PP .RE @@ -299,7 +299,7 @@ Custom hours for cronjob. Only valid for Iprofile.schedule.mode R = 19 .RS Type: int Allowed Values: 0-99999 .br -How many units to wait between new snapshots with anacron? Only valid for Iprofile.schedule.mode R = 25|27. +How many units to wait between new snapshots with anacron? Only valid for \fIprofile.schedule.mode\fR = 25|27. .PP .RE @@ -308,7 +308,7 @@ How many units to wait between new snapshots with anacron? Only valid for Iprof .RS Type: int Allowed Values: 10|20|30|40 .br -Units to wait between new snapshots with anacron. 10 = hours 20 = days 30 = weeks 40 = months Only valid for Iprofile.schedule.mode R = 25|27; +Units to wait between new snapshots with anacron. 10 = hours 20 = days 30 = weeks 40 = months Only valid for \fIprofile.schedule.mode\fR = 25|27; .PP .RE @@ -344,7 +344,7 @@ Time unit to use to calculate removing of old snapshots. 20 = days; 30 = weeks; .RS Type: bool Allowed Values: true|false .br -Remove snapshots until Iprofile.snapshots.min_free_space.value R free space is reached. +Remove snapshots until \fIprofile.snapshots.min_free_space. value\fR free space is reached. .PP .RE @@ -371,7 +371,7 @@ Type: StorageSizeUnitAllowed Values: 10|20 .RS Type: bool Allowed Values: true|false .br -Remove snapshots until Iprofile.snapshots.min_free_inodes.value R #?free inodes in % is reached. +Remove snapshots until \fIprofile.snapshots.min_free_inodes.value\fR free inodes in % is reached. .PP .RE @@ -470,7 +470,7 @@ Rename existing files before restore into FILE.backup.YYYYMMDD .RS Type: bool Allowed Values: true|false .br -Run cronjobs with 'nice \-n19'. This will give Back In Time the lowest CPU priority to not interrupt any other working process. +Run cronjobs with nice-Value 19. This will give Back In Time the lowest CPU priority to not interrupt any other working process. .PP .RE @@ -479,7 +479,7 @@ Run cronjobs with 'nice \-n19'. This will give Back In Time the lowest CPU prior .RS Type: bool Allowed Values: true|false .br -Run cronjobs with 'ionice \-c2 \-n7'. This will give Back In Time the owest IO bandwidth priority to not interrupt any other working process. +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 .RE @@ -488,7 +488,7 @@ Run cronjobs with 'ionice \-c2 \-n7'. This will give Back In Time the owest IO b .RS Type: bool Allowed Values: true|false .br -Run Back In Time with 'ionice \-c2 \-n7' when taking a manual snapshot. This will give Back In Time the lowest IO bandwidth priority to not interrupt any other working process. +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 .RE @@ -497,7 +497,7 @@ Run Back In Time with 'ionice \-c2 \-n7' when taking a manual snapshot. This wil .RS Type: bool Allowed Values: true|false .br -Run rsync and other commands on remote host with 'nice \-n19'. +Run rsync and other commands on remote host with 'nice' value 19. .PP .RE @@ -506,7 +506,7 @@ Run rsync and other commands on remote host with 'nice \-n19'. .RS Type: bool Allowed Values: true|false .br -Run rsync and other commands on remote host with 'ionice \-c2 \-n7'. +Run rsync and other commands on remote host with 'ionice' and class 2 and level 7. .PP .RE @@ -632,7 +632,7 @@ Past additional options to rsync .RS Type: str Allowed Values: text .br -Rsync options. Options must be quoted e.g. \-\-exclude-from="/path/to/my exclude file". +Rsync options. Options must be quoted. .PP .RE @@ -650,7 +650,7 @@ Add prefix to every command which run through SSH on remote host. .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 Iprofile.snapshots.rsync_options.value R with --rsync-path="FOO=bar:\\$FOO /usr/bin/rsync" +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 .RE diff --git a/common/test/test_konfig.py b/common/test/test_konfig.py index 79203188f..c83ad2fe6 100644 --- a/common/test/test_konfig.py +++ b/common/test/test_konfig.py @@ -119,7 +119,7 @@ def test_default_values(self): self.assertIsInstance(sut.ssh_port, int) -class IncludeExclude(unittest.TestCase): +class IncExc(unittest.TestCase): """About include and exclude fields""" def setUp(self): @@ -156,8 +156,7 @@ def test_include_write(self): ] ) - - def test_included_read(self): + def test_include_read(self): """Read include fields""" config = Konfig(StringIO('\n'.join([ 'profile1.snapshots.include.1.value=/foo/bar/folder', diff --git a/common/test/test_plugin_usercallback.py b/common/test/test_plugin_usercallback.py index 7213e485d..e7c87e38f 100644 --- a/common/test/test_plugin_usercallback.py +++ b/common/test/test_plugin_usercallback.py @@ -19,7 +19,7 @@ from usercallbackplugin import UserCallbackPlugin -class UserCallback(unittest.TestCase): +class Reasons(unittest.TestCase): """Simple test related to to UserCallbackPlugin class. Dev note (buhtz, 2024-02-08): Test value is low because they depend on @@ -33,6 +33,7 @@ class UserCallback(unittest.TestCase): - Unit tests about logger output. But migrate "logger" to Python's inbuild "logging" module first. """ + def _generic_called_with(self, the_step, reason, *args): sut = UserCallbackPlugin() sut.config = Config() @@ -44,16 +45,16 @@ def _generic_called_with(self, the_step, reason, *args): func_callback.assert_called_once() func_callback.assert_called_with(reason, *args) - def test_reason_processBegin(self): + def test_processBegin(self): self._generic_called_with(UserCallbackPlugin.processBegin, '1') - def test_reason_processEnd(self): + def test_processEnd(self): self._generic_called_with(UserCallbackPlugin.processEnd, '2') - def test_reason_processnewSnapshot(self): + def test_processnewSnapshot(self): self._generic_called_with(UserCallbackPlugin.newSnapshot, '3', 'id1', 'path') - def test_reason_error(self): + def test_error(self): sut = UserCallbackPlugin() sut.config = Config() sut.script = '' @@ -72,13 +73,13 @@ def test_reason_error(self): func_callback.assert_called_once() func_callback.assert_called_with('4', 'code2') - def test_reason_appStart(self): + def test_appStart(self): self._generic_called_with(UserCallbackPlugin.appStart, '5') - def test_reason_appExit(self): + def test_appExit(self): self._generic_called_with(UserCallbackPlugin.appExit, '6') - def test_reason_mount(self): + def test_mount(self): sut = UserCallbackPlugin() sut.config = Config() sut.script = '' @@ -97,7 +98,7 @@ def test_reason_mount(self): func_callback.assert_called_once() func_callback.assert_called_with('7', profileID='123') - def test_reason_unmount(self): + def test_unmount(self): sut = UserCallbackPlugin() sut.config = Config() sut.script = '' From f61833d8bd7ed04ef4bf815e748d62bec34779a4 Mon Sep 17 00:00:00 2001 From: Christian Buhtz Date: Mon, 21 Oct 2024 15:23:41 +0200 Subject: [PATCH 43/54] Python 3.13 again after contacting Travis support --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index ad2faf7ba..ed435b6a8 100644 --- a/.travis.yml +++ b/.travis.yml @@ -24,7 +24,7 @@ python: - "3.10" - "3.11" - "3.12" - # - "3.13" currently some dependencies do not support it yet + - "3.13" addons: # add localhost to known_hosts to prevent ssh unknown host prompt during unit tests From 2e19723ed7c9ff01e209ad7610ebd205bfea8245 Mon Sep 17 00:00:00 2001 From: Christian Buhtz Date: Tue, 6 May 2025 13:23:21 +0200 Subject: [PATCH 44/54] x --- common/test/test_plugin_usercallback.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/common/test/test_plugin_usercallback.py b/common/test/test_plugin_usercallback.py index 056d0fd44..ec4e9710b 100644 --- a/common/test/test_plugin_usercallback.py +++ b/common/test/test_plugin_usercallback.py @@ -26,7 +26,7 @@ from usercallbackplugin import UserCallbackPlugin -class Reasons(unittest.TestCase): +class UserCallback(unittest.TestCase): """Simple test related to to UserCallbackPlugin class. Dev note (buhtz, 2024-02-08): Test value is low because they depend on @@ -40,7 +40,6 @@ class Reasons(unittest.TestCase): - Unit tests about logger output. But migrate "logger" to Python's inbuild "logging" module first. """ - def _generic_called_with(self, the_step, reason, *args): sut = UserCallbackPlugin() sut.config = Config() @@ -52,16 +51,16 @@ def _generic_called_with(self, the_step, reason, *args): func_callback.assert_called_once() func_callback.assert_called_with(reason, *args) - def test_processBegin(self): + def test_reason_processBegin(self): self._generic_called_with(UserCallbackPlugin.processBegin, '1') - def test_processEnd(self): + def test_reason_processEnd(self): self._generic_called_with(UserCallbackPlugin.processEnd, '2') - def test_processnewSnapshot(self): + def test_reason_processnewSnapshot(self): self._generic_called_with(UserCallbackPlugin.newSnapshot, '3', 'id1', 'path') - def test_error(self): + def test_reason_error(self): sut = UserCallbackPlugin() sut.config = Config() sut.script = '' @@ -80,13 +79,13 @@ def test_error(self): func_callback.assert_called_once() func_callback.assert_called_with('4', 'code2') - def test_appStart(self): + def test_reason_appStart(self): self._generic_called_with(UserCallbackPlugin.appStart, '5') - def test_appExit(self): + def test_reason_appExit(self): self._generic_called_with(UserCallbackPlugin.appExit, '6') - def test_mount(self): + def test_reason_mount(self): sut = UserCallbackPlugin() sut.config = Config() sut.script = '' @@ -105,7 +104,7 @@ def test_mount(self): func_callback.assert_called_once() func_callback.assert_called_with('7', profileID='123') - def test_unmount(self): + def test_reason_unmount(self): sut = UserCallbackPlugin() sut.config = Config() sut.script = '' From 334251193779e3c50de2c77304da650fe31f0255 Mon Sep 17 00:00:00 2001 From: Christian Buhtz Date: Mon, 21 Jul 2025 10:39:36 +0200 Subject: [PATCH 45/54] [skip ci] --- common/bitbase.py | 33 ++++++++++++++++----------------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/common/bitbase.py b/common/bitbase.py index 3e339b8bc..a9d4c7bed 100644 --- a/common/bitbase.py +++ b/common/bitbase.py @@ -58,22 +58,6 @@ def _(val): URL_ENCRYPT_TRANSITION = 'https://github.com/bit-team/backintime' \ '/blob/-/doc/ENCRYPT_TRANSITION.md' -SSH_CIPHERS = { - 'default': _('Default'), - 'aes128-ctr': 'AES128-CTR', - 'aes192-ctr': 'AES192-CTR', - 'aes256-ctr': 'AES256-CTR', - 'arcfour256': 'ARCFOUR256', - 'arcfour128': 'ARCFOUR128', - 'aes128-cbc': 'AES128-CBC', - '3des-cbc': '3DES-CBC', - 'blowfish-cbc': 'Blowfish-CBC', - 'cast128-cbc': 'Cast128-CBC', - 'aes192-cbc': 'AES192-CBC', - 'aes256-cbc': 'AES256-CBC', - 'arcfour': 'ARCFOUR' -} - URL_SOURCE = 'https://github.com/bit-team/backintime' URL_WEBSITE = URL_SOURCE URL_FAQ = f'{URL_WEBSITE}/blob/-/FAQ.md' @@ -152,7 +136,6 @@ class StorageSizeUnit(Enum): system objects.""" MB = 10 # Config.DISK_UNIT_MB GB = 20 # Config.DISK_UNIT_GB - YEAR = 80 # Config.YEAR class ScheduleMode(Enum): @@ -218,3 +201,19 @@ class ScheduleMode(Enum): # last shown intensity is stored in the state data file. If they don't fit, the # message is displayed. ENCFS_MSG_STAGE = 2 + +SSH_CIPHERS = { + 'default': _('Default'), + 'aes128-ctr': 'AES128-CTR', + 'aes192-ctr': 'AES192-CTR', + 'aes256-ctr': 'AES256-CTR', + 'arcfour256': 'ARCFOUR256', + 'arcfour128': 'ARCFOUR128', + 'aes128-cbc': 'AES128-CBC', + '3des-cbc': '3DES-CBC', + 'blowfish-cbc': 'Blowfish-CBC', + 'cast128-cbc': 'Cast128-CBC', + 'aes192-cbc': 'AES192-CBC', + 'aes256-cbc': 'AES256-CBC', + 'arcfour': 'ARCFOUR' +} From d2e044bfb97713f9c789a60dfddf1c16cff1b356 Mon Sep 17 00:00:00 2001 From: Christian Buhtz Date: Tue, 10 Feb 2026 16:55:29 +0100 Subject: [PATCH 46/54] x --- create-manpage-backintime-config.py | 1 + 1 file changed, 1 insertion(+) diff --git a/create-manpage-backintime-config.py b/create-manpage-backintime-config.py index 8864b94b0..0048c48ed 100755 --- a/create-manpage-backintime-config.py +++ b/create-manpage-backintime-config.py @@ -68,6 +68,7 @@ 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}' From 5f73423158e85d5fc4845fd542f38f5b7b4981f1 Mon Sep 17 00:00:00 2001 From: Christian Buhtz Date: Fri, 26 Jun 2026 20:49:43 +0200 Subject: [PATCH 47/54] WEITeR --- common/backintime.py | 18 +- common/cli.py | 139 ++++++++------- common/clicommands.py | 5 +- common/config.py | 357 +++++++++++++++++++++++++------------- common/konfig.py | 343 ++++++++++++++++++++++++------------ common/snapshots.py | 31 ++-- common/tools.py | 5 +- qt/restoreconfigdialog.py | 4 +- 8 files changed, 582 insertions(+), 320 deletions(-) diff --git a/common/backintime.py b/common/backintime.py index 968f60d68..cc700d75d 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): @@ -68,6 +69,7 @@ def takeSnapshotAsync(cfg, checksum=False): subprocess.Popen(cmd, env=env) + def startApp(bin_name: str) -> config.Config | None: """ Start the requested command or return config. @@ -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() if __name__ == '__main__': diff --git a/common/cli.py b/common/cli.py index 34f1b3731..d43ffcf65 100644 --- a/common/cli.py +++ b/common/cli.py @@ -19,6 +19,7 @@ import logger import bitbase import core_events +from konfig import Konfig from mount import MountManager, MountError from typing import Optional 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 = getattr(kwargs, 'checksum', False), + **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: Konfi) -> 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.snapshots_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,62 +503,79 @@ 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 +def config_file_path() -> Path: + """Return the config file path. + + Could be moved into backintime.py. sys.argv (--config) needs to be + considered. + """ + xdg_config = os.environ.get('XDG_CONFIG_HOME', Path.home() / '.config') + path = Path(xdg_config) / 'backintime' / 'config' + + logger.debug(f'Config path: {path}') + + return path + + 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 = config_file_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 +583,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/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 a39a4d445..a2448a3f3 100644 --- a/common/config.py +++ b/common/config.py @@ -51,7 +51,7 @@ from exceptions import PermissionDeniedByPolicy -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 +92,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 +106,54 @@ 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 / BINARY_NAME_BASE / 'mnt' + self._MOUNT_ROOT = str(self._MOUNT_ROOT) + + # 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,7 +162,7 @@ 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') self.SNAPSHOT_MODES = { @@ -198,42 +203,105 @@ def the_dict(self) -> dict: #1923""" return self.dict + 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 (formaly 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 errro message! _('Backup directory is not valid.') ) ) @@ -253,14 +321,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 +383,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. @@ -387,45 +458,44 @@ def set_snapshots_path(self, value, profile_id=None): 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 +508,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,20 +548,27 @@ 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 + # --- WEITER WEITER WEITER 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) @@ -779,10 +866,17 @@ def setHostUserProfile(self, host, user, profile, profile_id = None): 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): """ @@ -941,55 +1035,70 @@ def setRemoveOldSnapshots(self, enabled, value, unit, profile_id = None): self.setProfileIntValue('snapshots.remove_old_snapshots.unit', unit, profile_id) 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 diff --git a/common/konfig.py b/common/konfig.py index f4889a9f9..fda62d4ab 100644 --- a/common/konfig.py +++ b/common/konfig.py @@ -32,18 +32,27 @@ 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.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': - str(Path('~') / '.ssh' / 'id_rsa'), + 'snapshots.ssh.private_key_file': None, 'snapshots.ssh.max_arg_length': 0, 'snapshots.ssh.check_commands': True, 'snapshots.ssh.check_ping': True, @@ -69,6 +78,8 @@ class Profile: # pylint: disable=too-many-public-methods '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, @@ -125,6 +136,7 @@ def __getitem__(self, key: str) -> Any: """ try: return self._config[f'{self._prefix}.{key}'] + except KeyError: return self._DEFAULT_VALUES[key] @@ -143,8 +155,36 @@ def __delitem__(self, key: str) -> None: del self._config[f'{self._prefix}.{key}'] @property - def snapshots_mode(self) -> str: - """Use mode (or backend) for this snapshot. Look at 'man + 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'. { @@ -154,8 +194,8 @@ def snapshots_mode(self) -> str: """ return self['snapshots.mode'] - @snapshots_mode.setter - def snapshots_mode(self, val: str) -> None: + @mode.setter + def mode(self, val: str) -> None: self['snapshots.mode'] = val @property @@ -167,12 +207,11 @@ def snapshots_path(self) -> str: 'values': 'absolute path', } """ + # return self['snapshots.path'] raise NotImplementedError( 'see original in Config class. See also ' 'Config.snapshotsFullPath(self, profile_id = None)') - # return self['snapshots.path'] - @snapshots_path.setter def snapshots_path(self, path): raise NotImplementedError('see original in Config class.') @@ -236,7 +275,7 @@ def ssh_snapshots_path(self) -> str: @ssh_snapshots_path.setter def ssh_snapshots_path(self, path): - raise NotImplementedError('see original in Config class.') + self['snapshots.ssh.path'] = path @property def ssh_host(self) -> str: @@ -282,25 +321,6 @@ def ssh_user(self) -> str: def ssh_user(self, value: str) -> None: self['snapshots.ssh.user'] = value - @property - def ssh_cipher(self) -> str: - """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. - - { - 'values': 'default | aes192-cbc | aes256-cbc | aes128-ctr ' \ - '| aes192-ctr | aes256-ctr | arcfour | arcfour256 ' \ - '| arcfour128 | aes128-cbc | 3des-cbc | ' \ - 'blowfish-cbc | cast128-cbc', - } - """ - return self['snapshots.ssh.cipher'] - - @ssh_cipher.setter - def ssh_cipher(self, value: str) -> None: - self['snapshots.ssh.cipher'] = value - @property def ssh_private_key_file(self) -> Path: """Private key file used for password-less authentication on remote @@ -308,17 +328,20 @@ def ssh_private_key_file(self) -> Path: { 'values': 'absolute path to private key file', + 'default': None, 'type': 'str' } """ - raise NotImplementedError('see original in Config class') - # path_string = self['snapshots.ssh.private_key_file'] - # return Path(path_string) + 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'] = path + self['snapshots.ssh.private_key_file'] = str(path) if path else None @property def ssh_proxy_host(self) -> str: @@ -713,35 +736,47 @@ def remove_old_snapshots_unit(self, unit: TimeUnit) -> None: self['snapshots.remove_old_snapshots.unit'] = unit @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'] + 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 - @min_free_space_enabled.setter - def min_free_space_enabled(self, enable: bool) -> None: - self['snapshots.min_free_space.enabled'] = enable + def set_warn_free_space_disabled(self) -> None: + self.warn_free_space = StorageSize(0, SizeUnit.MIB) @property - def min_free_space_value(self) -> int: + def min_free_space(self) -> StorageSize: """Keep at least value + unit free space.""" - return self['snapshots.min_free_space.value'] + return StorageSize( + self['snapshots.min_free_space.value'], + self['snapshots.min_free_space.unit'] + ) @min_free_space_value.setter - def min_free_space_value(self, value: int) -> None: - self['snapshots.min_free_space.value'] = value + 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_unit(self) -> StorageSizeUnit: - """10 = MB\n20 = GB - { 'values': '10|20' } + 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.unit'] + return self['snapshots.min_free_space.enabled'] == 'true' - @min_free_space_unit.setter - def min_free_space_unit(self, unit: StorageSizeUnit) -> None: - self['snapshots.min_free_space.unit'] = unit + 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: @@ -1121,17 +1156,6 @@ def take_snapshot_regardless_of_changes(self) -> bool: def take_snapshot_regardless_of_changes(self, enable: bool) -> None: self['snapshots.take_snapshot_regardless_of_changes'] = enable - @property - def global_flock(self) -> bool: - """Prevent multiple snapshots (from different profiles or users) to be - run at the same time. - """ - return self['global.use_flock'] - - @global_flock.setter - def global_flock(self, enable: bool) -> None: - self['global.use_flock'] = enable - class Konfig(metaclass=singleton.Singleton): """Manage configuration data for Back In Time. @@ -1142,39 +1166,25 @@ class Konfig(metaclass=singleton.Singleton): """ _DEFAULT_VALUES = { - 'global.hash_collision': 0, '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, buffer: Optional[TextIOWrapper, StringIO] = None): + def __init__(self): """Constructor. - Args: - buffer: An open text-file handle or a string buffer ready to read. - Note: That method is executed only once because `Konfig` is a singleton. """ - if buffer: - self.load(buffer) - else: - self._conf = {} - - # 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() - ) - self._profiles = { - name: int(pid.replace('profile', '').replace('.name', '')) - for pid, name in name_items - } + self._conf = {} + self._profiles = {} + self._unsaved_profiles = [] def __getitem__(self, key: str) -> Any: try: @@ -1192,18 +1202,28 @@ 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 snapshot profile. + 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.""" @@ -1214,11 +1234,94 @@ def profile_ids(self) -> list[int]: """List of numerical profile ids.""" return list(self._profiles.values()) - def load(self, buffer: Union[TextIOWrapper, StringIO]): + 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: An open text-file handle or a string buffer ready to read. + buffer: A path object, an open text-file handle or a string + buffer ready to read. """ self._config_parser = configparser.ConfigParser( @@ -1226,7 +1329,10 @@ def load(self, buffer: Union[TextIOWrapper, StringIO]): defaults={'profile1.name': _('Main profile')}) # raw content - content = buffer.read() + if isinstance(buffer_or_path, Path): + content = buffer_or_path.read_text(encoding='utf-8') + else: + content = buffer_or_path.read() # Add section header to make it a real INI file self._config_parser.read_string( @@ -1235,9 +1341,12 @@ def load(self, buffer: Union[TextIOWrapper, StringIO]): # 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 configuraton to the config file.""" + self._unsaved_profiles = [] raise NotImplementedError('Prevent overwritting real config data.') # tmp_io_buffer = StringIO() @@ -1249,21 +1358,19 @@ def save(self, buffer: TextIOWrapper): # tmp_io_buffer.readline() # handle.write(tmp_io_buffer.read()) - @property - def hash_collision(self) -> int: - """Internal value used to prevent hash collisions on mountpoints. - Do not change this. + 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 - { - 'values': '0-99999', - 'default': 0, - } - """ - return self['global.hash_collision'] + @property + def diff_cmd_and_params(self) -> tuple[str, str]: + """TODO""" + return (self['qt.diff.cmd'], self['qt.diff.params']) - @hash_collision.setter - def hash_collision(self, val: int) -> None: - self['global.hash_collision'] = val + @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: @@ -1298,6 +1405,21 @@ def global_flock(self) -> bool: 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 @@ -1319,19 +1441,6 @@ def decrement_manual_starts_countdown(self): self['internal.manual_starts_countdown'] = val - 1 -def config_file_path() -> Path: - """Return the config file path. - - Could be moved into backintime.py. sys.argv (--config) needs to be - considered. - """ - xdg_config = os.environ.get('XDG_CONFIG_HOME', Path.home() / '.config') - path = Path(xdg_config) / 'backintime' / 'config' - - logger.debug(f'Config path: {path}') - - return path - if __name__ == '__main__': # Empty in-memory config file @@ -1339,15 +1448,19 @@ def config_file_path() -> Path: k = Konfig() print(k) - print(k._conf) # pylint: disable=protected-access + 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 with config_file_path().open('r', encoding='utf-8') as handle: k = Konfig() k.load(handle) print(k) - print(k._conf) # pylint: disable=protected-access + 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=}') diff --git a/common/snapshots.py b/common/snapshots.py index 49a1fad73..ea67d3c97 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()) @@ -3500,8 +3510,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/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/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)}' From 406747312e088af607a71aa48ff1e70f749f8562 Mon Sep 17 00:00:00 2001 From: Christian Buhtz Date: Sat, 27 Jun 2026 14:29:19 +0200 Subject: [PATCH 48/54] x --- common/config.py | 35 +++++++++++++++++++++++++++-------- common/snapshots.py | 16 +++++++++++++--- common/ssh_max_arg.py | 2 ++ 3 files changed, 42 insertions(+), 11 deletions(-) diff --git a/common/config.py b/common/config.py index a2448a3f3..f177b3483 100644 --- a/common/config.py +++ b/common/config.py @@ -49,6 +49,7 @@ import core_events from storagesize import StorageSize, SizeUnit from exceptions import PermissionDeniedByPolicy +from konfig import Konfig class Config: # (configfile.ConfigFileWithProfiles): @@ -568,40 +569,58 @@ def setSshPrivateKeyFile(self, value, profile_id=None): p = self.get_profile(profile_id) p.ssh_private_key_file = value - # --- WEITER WEITER WEITER 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 + # --- WEITER WEITER WEITER 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 diff --git a/common/snapshots.py b/common/snapshots.py index ea67d3c97..ac3fae68e 100644 --- a/common/snapshots.py +++ b/common/snapshots.py @@ -1892,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 @@ -1902,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) @@ -1945,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']) 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 From 50cbc6ed9bc0d9bcf479796b10623b439a93f6a4 Mon Sep 17 00:00:00 2001 From: Christian Buhtz Date: Sat, 27 Jun 2026 20:05:19 +0200 Subject: [PATCH 49/54] x --- common/config.py | 175 +++++++++++++++++++++++++----------- common/konfig.py | 52 ++++++----- common/password.py | 1 - common/sshsetupvalidator.py | 2 +- 4 files changed, 153 insertions(+), 77 deletions(-) diff --git a/common/config.py b/common/config.py index f177b3483..c61c6ae5b 100644 --- a/common/config.py +++ b/common/config.py @@ -602,7 +602,6 @@ def setSshProxyUser(self, value, profile_id=None): p = self.get_profile(profile_id) p.ssh_proxy_user = value - # --- WEITER WEITER WEITER 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 @@ -625,17 +624,25 @@ def setSshMaxArgLength(self, value, profile_id = None): 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 = vlaue 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): """ @@ -747,35 +754,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 - def passwordSave(self, profile_id = None, mode = None): + @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: - mode = self.snapshotsMode(profile_id) + 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) + 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, @@ -783,18 +821,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 @@ -832,6 +881,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) @@ -855,32 +906,40 @@ 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:: @@ -903,14 +962,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) + reutrn 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. @@ -918,16 +983,24 @@ 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)) + # --- WEITER WEITER WEITER def scheduleMode(self, profile_id = None): #?Which schedule used for crontab. The crontab entry will be #?generated with 'backintime check-config'.\n diff --git a/common/konfig.py b/common/konfig.py index fda62d4ab..1d63d923a 100644 --- a/common/konfig.py +++ b/common/konfig.py @@ -57,7 +57,16 @@ class Profile: # pylint: disable=too-many-public-methods 'snapshots.ssh.check_commands': True, 'snapshots.ssh.check_ping': True, 'snapshots.local_encfs.path': '', - 'snapshots.password.save': False, + # 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, @@ -241,8 +250,8 @@ def snapshots_path_user(self, value: str) -> None: self['snapshots.path.user'] = value @property - def snapshots_path_profileid(self) -> str: - """Set Profile-ID for snapshot path + def snapshots_path_profile(self) -> str: + """Set Profile-ID for backup path { 'values': '1-99999', @@ -251,13 +260,14 @@ def snapshots_path_profileid(self) -> str: """ try: return self['snapshots.path.profile'] + except KeyError: # Extract number from field prefix # e.g. "profile1" -> "1" - return self._prefix.replace('profile', '') + return self.profile_id - @snapshots_path_profileid.setter - def snapshots_path_profileid(self, value: str) -> None: + @snapshots_path_profile.setter + def snapshots_path_profile(self, value: str) -> None: self['snapshots.path.profile'] = value @property @@ -411,6 +421,8 @@ def ssh_check_commands(self) -> bool: expected on the remote host. { 'values': 'true|false' } """ + + # Deprecated. See issue #2509 return self['snapshots.ssh.check_commands'] @ssh_check_commands.setter @@ -429,30 +441,27 @@ def ssh_check_ping_host(self, value: bool) -> None: self['snapshots.ssh.check_ping'] = value @property - def local_encfs_path(self) -> Path: - """Where to save snapshots in mode 'local_encfs'. + def local_gocryptfs_path(self) -> Path: + """Where to save snapshots in mode 'local_gocryptfs'. { 'values': 'absolute path' } """ - return self['snapshots.local_encfs.path'] + return self['snapshots.local_gocryptfs.path'] - @local_encfs_path.setter - def local_encfs_path(self, path: Path): - self['snapshots.local_encfs.path'] = str(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' } """ - raise NotImplementedError( - 'Refactor it first to make the field name mode independed. ' - 'profileN.snapshots.password.save') - # return self['snapshots.password.save'] + return self[f'snapshots.{self.mode}.password.save'] @password_save.setter def password_save(self, value: bool) -> None: - self['snapshots.password.save'] = value + self[f'snapshots.{self.mode}.password.save'] = value @property def password_use_cache(self) -> None: @@ -463,16 +472,11 @@ def password_use_cache(self) -> None: 'default': 'see #1855' } """ - raise NotImplementedError( - 'Refactor it first to make the field name mode independed. ' - 'profileN.snapshots.password.use_cache.' - 'See also Issue #1855 about encrypted home dir') - # ??? default = not tools.checkHomeEncrypt() - # return self['snapshots.password.use_cache'] + return self[f'snapshots.{self.mode}.password.use_cache'] @password_use_cache.setter def password_use_cache(self, value: bool) -> None: - self['snapshots.password.use_cache'] = value + 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. 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/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() From 34cfc773e591a7c7d218ae5cc8f0a2b898019db6 Mon Sep 17 00:00:00 2001 From: Christian Buhtz Date: Sun, 28 Jun 2026 13:21:08 +0200 Subject: [PATCH 50/54] x --- common/config.py | 33 ++++++++++++++++++++++++--------- common/konfig.py | 16 +++++++++++++--- 2 files changed, 37 insertions(+), 12 deletions(-) diff --git a/common/config.py b/common/config.py index c61c6ae5b..9429af41e 100644 --- a/common/config.py +++ b/common/config.py @@ -1000,7 +1000,6 @@ def tag(self, profile_id = None): return str(random.randint(100, 999)) - # --- WEITER WEITER WEITER def scheduleMode(self, profile_id = None): #?Which schedule used for crontab. The crontab entry will be #?generated with 'backintime check-config'.\n @@ -1011,26 +1010,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 @@ -1039,17 +1050,21 @@ 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 + # --- WEITER WEITER WEITER def scheduleDay(self, profile_id = None): #?Which day of month the cronjob should run? Only valid for #?\fIprofile.schedule.mode\fR >= 40;1-28 diff --git a/common/konfig.py b/common/konfig.py index 1d63d923a..adf6c7fa6 100644 --- a/common/konfig.py +++ b/common/konfig.py @@ -19,7 +19,7 @@ from io import StringIO, TextIOWrapper import singleton import logger -from bitbase import TimeUnit, StorageSizeUnit +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 @@ -72,6 +72,7 @@ class Profile: # pylint: disable=too-many-public-methods '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, @@ -602,7 +603,7 @@ def exclude_by_size(self, value): self['snapshots.exclude.bysize.value'] = value @property - def schedule_mode(self) -> int: + 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 @@ -616,12 +617,21 @@ def schedule_mode(self) -> int: 'values': '0|1|2|4|7|10|12|14|16|18|19|20|25|27|30|40|80' } """ - return self['schedule.mode'] + 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.""" From 73421ac207401589929b4e8b1a1b98a091b94cc9 Mon Sep 17 00:00:00 2001 From: Christian Buhtz Date: Mon, 29 Jun 2026 19:06:22 +0200 Subject: [PATCH 51/54] x --- common/bitbase.py | 1 + common/config.py | 222 +++++++++++++++++++++++++++++++++------------- common/konfig.py | 32 +++++-- 3 files changed, 187 insertions(+), 68 deletions(-) diff --git a/common/bitbase.py b/common/bitbase.py index dc9653018..9fe54bac2 100644 --- a/common/bitbase.py +++ b/common/bitbase.py @@ -125,6 +125,7 @@ class TimeUnit(Enum): 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.""" diff --git a/common/config.py b/common/config.py index 9429af41e..00e0f5368 100644 --- a/common/config.py +++ b/common/config.py @@ -1064,70 +1064,108 @@ def setScheduleTime(self, value, profile_id = None): p = self.get_profile(profile_id) p.schedule_time = value - # --- WEITER WEITER WEITER 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) @@ -1137,9 +1175,14 @@ 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): p = self.get_profile(profile_id) @@ -1209,35 +1252,54 @@ def setMinFreeSpaceWithStorageSize(self, enabled, value: StorageSize, profile_id 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, @@ -1246,72 +1308,110 @@ 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 + # --- WEITER WEITER WEITER def nocacheOnLocal(self, profile_id = None): #?Run rsync on local machine with 'nocache'. #?This will prevent files from being cached in memory. diff --git a/common/konfig.py b/common/konfig.py index adf6c7fa6..e8b88b438 100644 --- a/common/konfig.py +++ b/common/konfig.py @@ -11,14 +11,13 @@ from __future__ import annotations import configparser import getpass -import os import socket import re -from typing import Union, Any, Optional +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. @@ -223,7 +222,7 @@ def snapshots_path(self) -> str: 'Config.snapshotsFullPath(self, profile_id = None)') @snapshots_path.setter - def snapshots_path(self, path): + def snapshots_path(self, path: str): raise NotImplementedError('see original in Config class.') @property @@ -599,7 +598,7 @@ def exclude_by_size(self) -> int: return self['snapshots.exclude.bysize.value'] @exclude_by_size.setter - def exclude_by_size(self, value): + def exclude_by_size(self, value: int): self['snapshots.exclude.bysize.value'] = value @property @@ -776,7 +775,7 @@ def min_free_space(self) -> StorageSize: self['snapshots.min_free_space.unit'] ) - @min_free_space_value.setter + @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 @@ -1455,11 +1454,30 @@ def decrement_manual_starts_countdown(self): 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 From 95fc32cac9d1eb031847f41c3904c074c8e3b630 Mon Sep 17 00:00:00 2001 From: Christian Buhtz Date: Mon, 29 Jun 2026 22:23:11 +0200 Subject: [PATCH 52/54] [skip ci] --- common/config.py | 63 ++++++++++++++++++++++++++++++++++-------------- common/konfig.py | 8 +++--- 2 files changed, 49 insertions(+), 22 deletions(-) diff --git a/common/config.py b/common/config.py index 00e0f5368..8656031e6 100644 --- a/common/config.py +++ b/common/config.py @@ -1411,61 +1411,88 @@ def setIoniceOnRemote(self, value, profile_id = None): p = self.get_profile(profile_id) p.ionice_on_remote = value - # --- WEITER WEITER WEITER 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) + # self.setProfileBoolValue('snapshots.no_on_battery', value, profile_id) + p = self.get_profile(profile_id) + p.no_backup_on_battery = value + # --- 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. diff --git a/common/konfig.py b/common/konfig.py index e8b88b438..b1c9574c3 100644 --- a/common/konfig.py +++ b/common/konfig.py @@ -1026,12 +1026,12 @@ def bw_limit(self, limit_kb_sec: int) -> None: self['snapshots.bwlimit.value'] = limit_kb_sec @property - def no_snapshot_on_battery(self) -> bool: - """Don't take snapshots if the Computer runs on battery.""" + def no_backup_on_battery(self) -> bool: + """Don't take backups if the computer runs on battery.""" return self['snapshots.no_on_battery'] - @no_snapshot_on_battery.setter - def no_snapshot_on_battery(self, enable: bool) -> None: + @no_backup_on_battery.setter + def no_backup_on_battery(self, enable: bool) -> None: self['snapshots.no_on_battery'] = enable @property From 5d43881e13a0538f58c54084e091b1a73b868ba0 Mon Sep 17 00:00:00 2001 From: Christian Buhtz Date: Tue, 30 Jun 2026 21:57:16 +0200 Subject: [PATCH 53/54] x --- common/backintime.py | 2 +- common/bitbase.py | 2 + common/cli.py | 8 +- common/cliarguments.py | 2 +- common/config.py | 169 +++++++++++++++++++--------- common/guiapplicationinstance.py | 4 +- common/konfig.py | 23 ++-- common/snapshots.py | 2 +- create-manpage-backintime-config.py | 6 +- qt/app.py | 3 +- 10 files changed, 146 insertions(+), 75 deletions(-) diff --git a/common/backintime.py b/common/backintime.py index cc700d75d..59adb606b 100644 --- a/common/backintime.py +++ b/common/backintime.py @@ -137,7 +137,7 @@ def startApp(bin_name: str) -> config.Config | None: # Workaround: For the current time we use "Config" as surrogate, that is # using the real "Konfig" in the back. - return Config() + return config.Config() if __name__ == '__main__': diff --git a/common/bitbase.py b/common/bitbase.py index 9fe54bac2..904c48df8 100644 --- a/common/bitbase.py +++ b/common/bitbase.py @@ -76,6 +76,8 @@ def _(val): 'XDG_DATA_HOME', os.environ.get('HOME') + '/.local/share' )) +# 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' diff --git a/common/cli.py b/common/cli.py index d43ffcf65..256965bb7 100644 --- a/common/cli.py +++ b/common/cli.py @@ -21,7 +21,7 @@ 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__ @@ -329,7 +329,7 @@ def run(self): self.what, self.callback, self.where, - self.force_checksum_use = getattr(kwargs, 'checksum', False), + self.force_checksum_use, **self.kwargs ) print('\nLog saved to %s' % self.logFile) @@ -472,7 +472,7 @@ def detect_remote_host_check_settings(cfg: Konfig) -> tuple[str, str, str]: return result -def _backup_and_remove_encfs_config(cfg: Konfi) -> 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. @@ -481,7 +481,7 @@ def _backup_and_remove_encfs_config(cfg: Konfi) -> bool: names = '' for profile in cfg.iter_profiles(): - if 'encfs' in profile.snapshots_mode.lower(): + if 'encfs' in profile.mode.lower(): name = profile.name pid = profile.profile_id logger.critical( 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/config.py b/common/config.py index 8656031e6..721d1a8ba 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,14 +40,14 @@ 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 @@ -118,8 +118,9 @@ def __init__(self, config_path=None, data_path=None): # 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 / BINARY_NAME_BASE / 'mnt' + self._MOUNT_ROOT = bitbase.XDG_DATA_HOME / bitbase.BINARY_NAME_BASE / 'mnt' self._MOUNT_ROOT = str(self._MOUNT_ROOT) + self._LOCAL_DATA_FOLDER = str(bitbase.BIT_DATA_HOME) # if data_path: # # Deprecated: --share-path was removed @@ -272,7 +273,7 @@ def save(self): Konfig().save() def is_profile_unsaved(self, profile_id: str) -> bool: - return = Konfig().is_profile_unsaved(int(profile_id)) + return Konfig().is_profile_unsaved(int(profile_id)) def is_current_profile_unsaved(self) -> bool: return self.is_profile_unsaved(self.currentProfile()) @@ -297,12 +298,12 @@ def checkConfig(self, profile_id = None): mount_path = mount_manager.path # snapshots_path = one_profile.snapshots_path - # check the backups mountpoint (formaly known as "snapshot_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 errro message! + # Don't like this error message! _('Backup directory is not valid.') ) ) @@ -562,7 +563,7 @@ def sshPrivateKeyFile(self, profile_id=None) -> None | bool | str: def sshPrivateKeyFile_enabled(self, profile_id=None): # return self.sshPrivateKeyFile(profile_id) is not False p = self.get_profile(profile_id) - return p.ssh_private_key_file is not None: + 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) @@ -631,7 +632,7 @@ def sshCheckCommands(self, profile_id = None): def setSshCheckCommands(self, value, profile_id = None): # self.setProfileBoolValue('snapshots.ssh.check_commands', value, profile_id) p = self.get_profile(profile_id) - p.ssh_check_commands = vlaue + p.ssh_check_commands = value def sshCheckPingHost(self, profile_id = None): #?Check if the remote host is available before trying to mount. @@ -964,7 +965,7 @@ def exclude(self, profile_id = None): #?starting with 1;file, folder or pattern (relative or absolute) # return self.profileListValue('snapshots.exclude', 'str:value', [], profile_id) p = self.get_profile(profile_id) - reutrn p.exclude + return p.exclude def setExclude(self, values, profile_id = None): # self.setProfileListValue('snapshots.exclude', 'str:value', values, profile_id) @@ -1487,78 +1488,112 @@ def noSnapshotOnBattery(self, profile_id = None): p = self.get_profile(profile_id) return p.no_backup_on_battery - def setNoSnapshotOnBattery(self, value, profile_id = None): + 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 # --- WEITER WEITER WEITER - def preserveAcl(self, profile_id = None): + 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. @@ -1583,41 +1618,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: @@ -1685,10 +1740,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( @@ -1703,6 +1764,10 @@ def isConfigured(self, profile_id=None) -> bool: 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] @@ -1916,7 +1981,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 @@ -1951,12 +2016,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 index b1c9574c3..2a26bc276 100644 --- a/common/konfig.py +++ b/common/konfig.py @@ -17,6 +17,7 @@ from pathlib import Path from io import StringIO, TextIOWrapper import singleton +import logger from storagesize import SizeUnit, StorageSize from bitbase import TimeUnit, StorageSizeUnit, ScheduleMode @@ -123,7 +124,6 @@ class Profile: # pylint: disable=too-many-public-methods 'snapshots.use_checksum': False, 'snapshots.log_level': 3, 'snapshots.take_snapshot_regardless_of_changes': False, - 'global.use_flock': False, } def __init__(self, profile_id: int, config: Konfig): @@ -1035,15 +1035,15 @@ def no_backup_on_battery(self, enable: bool) -> None: self['snapshots.no_on_battery'] = enable @property - def preserve_alc(self) -> bool: + 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_alc.setter - def preserve_alc(self, preserve: bool) -> None: + @preserve_acl.setter + def preserve_acl(self, preserve: bool) -> None: self['snapshots.preserve_acl'] = preserve @property @@ -1271,7 +1271,7 @@ def _profile_list(self) -> dict: } def iter_profiles(self): - for pid in self.profile_ids(): + for pid in self.profile_ids: yield Profile(profile_id=pid, config=self) def new_profile(self, name: str) -> Profile: @@ -1343,7 +1343,14 @@ def load(self, buffer_or_path: Union[Path, TextIOWrapper, StringIO]): # raw content if isinstance(buffer_or_path, Path): - content = buffer_or_path.read_text(encoding='utf-8') + 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() @@ -1357,10 +1364,10 @@ def load(self, buffer_or_path: Union[Path, TextIOWrapper, StringIO]): self._profiles = self._profile_list() def save(self, buffer: TextIOWrapper): - """Store configuraton to the config file.""" + """Store configuration to the config file.""" self._unsaved_profiles = [] - raise NotImplementedError('Prevent overwritting real config data.') + raise NotImplementedError('Prevent overwriting real config data.') # tmp_io_buffer = StringIO() # self._config_parser.write(tmp_io_buffer) diff --git a/common/snapshots.py b/common/snapshots.py index ac3fae68e..84e94f90c 100644 --- a/common/snapshots.py +++ b/common/snapshots.py @@ -1456,7 +1456,7 @@ def takeSnapshot(self, sid, now, include_folders, force_checksum_use): # rsync prefix & suffix rsync_prefix = tools.rsyncPrefix( self.config, - no_perms=False + no_perms=False, force_checksum_use=force_checksum_use ) diff --git a/create-manpage-backintime-config.py b/create-manpage-backintime-config.py index 0048c48ed..b4e51c750 100755 --- a/create-manpage-backintime-config.py +++ b/create-manpage-backintime-config.py @@ -270,7 +270,7 @@ def inspect_properties(cls: type, name_prefix: str = '') -> dict[str, dict]: 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 annoation of the + 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. @@ -299,7 +299,7 @@ def inspect_properties(cls: type, name_prefix: str = '') -> dict[str, dict]: Returns: A dictionary indexed by the config option field names. """ - # The folloing fields/properties will produce warnings. But this is + # 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 = ( @@ -427,7 +427,7 @@ def main(): ) # WORKAROuND: - # Structure of include/exclude fields can not be easly handled via + # 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. diff --git a/qt/app.py b/qt/app.py index 87d3fa2d9..3aadfce27 100644 --- a/qt/app.py +++ b/qt/app.py @@ -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() From 57d141f9695f34b8b7c740f8972a2ade3eb15eaa Mon Sep 17 00:00:00 2001 From: Christian Buhtz Date: Tue, 30 Jun 2026 22:56:40 +0200 Subject: [PATCH 54/54] [skip ci] --- common/backintime.py | 10 +++++----- common/bitbase.py | 10 +++++++++- common/cli.py | 18 +++--------------- common/config.py | 26 ++++++++++++++++++-------- common/konfig.py | 18 +++++++++--------- common/pluginmanager.py | 3 +++ qt/app.py | 2 +- 7 files changed, 48 insertions(+), 39 deletions(-) diff --git a/common/backintime.py b/common/backintime.py index 59adb606b..3082e5425 100644 --- a/common/backintime.py +++ b/common/backintime.py @@ -69,7 +69,6 @@ def takeSnapshotAsync(cfg, checksum=False): subprocess.Popen(cmd, env=env) - def startApp(bin_name: str) -> config.Config | None: """ Start the requested command or return config. @@ -96,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(): diff --git a/common/bitbase.py b/common/bitbase.py index 904c48df8..4517a2aaf 100644 --- a/common/bitbase.py +++ b/common/bitbase.py @@ -74,14 +74,22 @@ def _(val): 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' diff --git a/common/cli.py b/common/cli.py index 256965bb7..bdbb9b269 100644 --- a/common/cli.py +++ b/common/cli.py @@ -521,20 +521,6 @@ def _backup_and_remove_encfs_config(cfg: Konfig) -> bool: return True -def config_file_path() -> Path: - """Return the config file path. - - Could be moved into backintime.py. sys.argv (--config) needs to be - considered. - """ - xdg_config = os.environ.get('XDG_CONFIG_HOME', Path.home() / '.config') - path = Path(xdg_config) / 'backintime' / 'config' - - logger.debug(f'Config path: {path}') - - return path - - def get_config_and_select_profile( config_path: str, # data_path: str, @@ -559,7 +545,9 @@ def get_config_and_select_profile( """ if config_path is None: - config_path = config_file_path() + 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(): diff --git a/common/config.py b/common/config.py index 721d1a8ba..1c017f692 100644 --- a/common/config.py +++ b/common/config.py @@ -120,6 +120,7 @@ def __init__(self, config_path=None, data_path=None): # 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: @@ -167,6 +168,9 @@ def __init__(self, config_path=None, data_path=None): # Workaround: Maybe into bitbase? self.default_profile_name = _('Main profile') + # Workaround + self.setCurrentProfile('1') + self.SNAPSHOT_MODES = { # mode: ( # , @@ -203,7 +207,8 @@ 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 @@ -448,14 +453,18 @@ 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' @@ -1701,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") @@ -1769,8 +1779,8 @@ def isConfigured(self, profile_id=None) -> bool: 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) diff --git a/common/konfig.py b/common/konfig.py index 2a26bc276..d6bfb8047 100644 --- a/common/konfig.py +++ b/common/konfig.py @@ -47,6 +47,7 @@ class Profile: # pylint: disable=too-many-public-methods _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, @@ -56,7 +57,7 @@ class Profile: # pylint: disable=too-many-public-methods 'snapshots.ssh.max_arg_length': 0, 'snapshots.ssh.check_commands': True, 'snapshots.ssh.check_ping': True, - 'snapshots.local_encfs.path': '', + 'snapshots.local_gocryptfs.path': '', # This is fragil. Why not 'snapshots.password.save' ? 'snapshots.local.password.save': False, 'snapshots.ssh.password.save': False, @@ -197,7 +198,7 @@ def mode(self) -> str: backintime' section 'Modes'. { - 'values': 'local|local_encfs|ssh|ssh_encfs', + 'values': 'local|local_gocryptfs|ssh|ssh_gocryptfs', 'default': 'local', } """ @@ -216,14 +217,11 @@ def snapshots_path(self) -> str: 'values': 'absolute path', } """ - # return self['snapshots.path'] - raise NotImplementedError( - 'see original in Config class. See also ' - 'Config.snapshotsFullPath(self, profile_id = None)') + return self['snapshots.path'] @snapshots_path.setter def snapshots_path(self, path: str): - raise NotImplementedError('see original in Config class.') + self['snapshots.path'] = path @property def snapshots_path_host(self) -> str: @@ -289,7 +287,7 @@ def ssh_snapshots_path(self, path): @property def ssh_host(self) -> str: - """Remote host used for mode 'ssh' and 'ssh_encfs'. + """Remote host used for mode 'ssh' and 'ssh_gocryptfs'. { 'values': 'IP or domain address', @@ -1493,7 +1491,9 @@ def decrement_manual_starts_countdown(self): k.load(Path.home() / '.config' / 'backintime' / 'config') sys.exit() # Regular config file - with config_file_path().open('r', encoding='utf-8') as handle: + import bitbase + cfp = bitbase.CONFIG_FILE_PATH + with cfp.open('r', encoding='utf-8') as handle: k = Konfig() k.load(handle) 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/qt/app.py b/qt/app.py index 3aadfce27..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)