From 585e7fb7abc4050421396dbf3288fc0e32449da7 Mon Sep 17 00:00:00 2001 From: Christian Buhtz Date: Thu, 15 Aug 2024 10:35:27 +0200 Subject: [PATCH 01/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] [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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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