diff --git a/.travis.yml b/.travis.yml index c1ec65a13..ea553d96c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -44,7 +44,7 @@ jobs: python: "nightly" install: - - pip install coveralls + - pip install coveralls pyfakefs # 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 "" - cat ~/.ssh/id_rsa.pub >> ~/.ssh/authorized_keys diff --git a/CHANGES b/CHANGES index b971c18b6..98cc82717 100644 --- a/CHANGES +++ b/CHANGES @@ -5,6 +5,7 @@ Upcoming Release * GUI change: define accelerator keys for menu bar and tabs, as well as toolbar shortcuts (#1104) * Desktop integration: update .desktop file to mark Back In Time as a single main window program (#1258) * Bugfix: AttributeError in "Diff Options" dialog (#898) +* Feature: Diagnostic output. * Documentation update: correct description of profile.schedule.time in backintime-config manpage (#1270) * Translation update: Brazilian Portuguese (#1267) * Translation update: Italian (#1110, #1123) diff --git a/common/diagnostics.py b/common/diagnostics.py new file mode 100644 index 000000000..c69322f01 --- /dev/null +++ b/common/diagnostics.py @@ -0,0 +1,307 @@ +"""short doc + +long doc + +""" +import sys +import os +import pathlib +import pwd +import platform +import locale +import subprocess +import json +import re +import config # config.Config.VERSION Refactor after src-layout migration + + +def collect_diagnostics(): + """Collect information's about environment and versions of tools and + packages used by Back In Time. + + The informatinos can be used e.g. for debugging and but reports. + + Returns: + dict: A nested dictionary. + """ + result = {} + + USER_REPLACED = 'UsernameReplaced' + + pwd_struct = pwd.getpwuid(os.getuid()) + + # === BACK IN TIME === + distro_path = _determine_distro_package_folder() + + result['backintime'] = { + 'name': config.Config.APP_NAME, + 'version': config.Config.VERSION, + 'config-version': config.Config.CONFIG_VERSION, + 'distribution-package': str(distro_path), + 'started-from': str(pathlib.Path(config.__file__).parent), + 'running_as_root': pwd_struct.pw_name == 'root', + } + + # Git repo + git_info = get_git_repository_info(distro_path) + + if git_info: + + for key in git_info: + result['backintime'][f'git-{key}'] = git_info[key] + + # == HOST setup === + result['host-setup'] = { + # Kernel & Architecture + 'platform': platform.platform(), + # OS Version (and maybe name) + 'system': '{} {}'.format(platform.system(), platform.version()), + } + + + # content of /etc/os-release + try: + osrelease = platform.freedesktop_os_release() # since Python 3.10 + + except AttributeError: # refactor: when we drop Python 3.9 support + # read and parse the os-release file ourself + fp = pathlib.Path('/etc') / 'os-release' + + try: + with fp.open('r') as handle: + osrelease = handle.read() + + except FileNotFoundError: + osrelease = '(os-release file not found)' + + else: + osrelease = re.findall('PRETTY_NAME=\"(.*)\"', osrelease)[0] + + result['host-setup']['os-release'] = osrelease + + # Display system (X11 or Wayland) + # This doesn't catch all edge cases. + # For more detials see: https://unix.stackexchange.com/q/202891/136851 + result['host-setup']['display-system'] = os.environ.get( + 'XDG_SESSION_TYPE', '($XDG_SESSION_TYPE not set)') + + # locale (system language etc) + result['host-setup']['locale'] = ', '.join(locale.getlocale()) + + # PATH environment variable + result['host-setup']['PATH'] = os.environ.get('PATH', '($PATH unknown)') + + # === PYTHON setup === + python = '{} {} {} {}'.format( + platform.python_version(), + ' '.join(platform.python_build()), + platform.python_implementation(), + platform.python_compiler() + ) + + # Python branch and revision if available + branch = platform.python_branch() + if branch: + python = '{} branch: {}'.format(python, branch) + rev = platform.python_revision() + if rev: + python = '{} rev: {}'.format(python, rev) + + result['python-setup'] = { + 'python': python, + 'sys.path': sys.path, + } + + # Qt + try: + import PyQt5.QtCore + except ImportError: + qt = '(Can not import PyQt5)' + else: + qt = 'PyQt {} / Qt {}'.format(PyQt5.QtCore.PYQT_VERSION_STR, + PyQt5.QtCore.QT_VERSION_STR) + finally: + result['python-setup']['qt'] = qt + + # === EXTERN TOOL === + result['external-programs'] = {} + + # rsync + # rsync >= 3.2.6: -VV return a json + # rsync <= 3.2.5 and > (somewhere near) 3.1.3: -VV return the same as -V + # rsync <= (somewhere near) 3.1.3: -VV doesn't exists + # rsync == 3.1.3 (Ubuntu 20 LTS) doesn't even know '-V' + + # This work when rsync understand -VV and return json or human readable + result['external-programs']['rsync'] = _get_extern_versions( + ['rsync', '-VV'], + r'rsync version (.*) protocol version', + try_json=True, + error_pattern=r'unknown option' + ) + + # When -VV was unknown use -V and parse the human readable output + if not result['external-programs']['rsync']: + # try the old way + result['external-programs']['rsync'] = _get_extern_versions( + ['rsync', '--version'], + r'rsync version (.*) protocol version' + ) + + # ssh + result['external-programs']['ssh'] = _get_extern_versions(['ssh', '-V']) + + # sshfs + result['external-programs']['sshfs'] \ + = _get_extern_versions(['sshfs', '-V'], r'SSHFS version (.*)\n') + + # EncFS + # Using "[Vv]" in the pattern because encfs does translate its output. + # e.g. In German it is "Version" in English "version". + result['external-programs']['encfs'] \ + = _get_extern_versions(['encfs'], r'Build: encfs [Vv]ersion (.*)\n') + + # Shell + SHELL_ERR_MSG = '($SHELL not exists)' + shell = os.environ.get('SHELL', SHELL_ERR_MSG) + result['external-programs']['shell'] = shell + + if shell != SHELL_ERR_MSG: + shell_version = _get_extern_versions([shell, '--version']) + result['external-programs']['shell-version'] = shell_version.split('\n')[0] + + result = json.loads( + json.dumps(result).replace(pwd_struct.pw_name, USER_REPLACED) + ) + + return result + + +def _get_extern_versions(cmd, + pattern=None, + try_json=False, + error_pattern=None): + """Get the version of an external tools using ``subprocess.Popen()``. + + Args: + cmd (list): Commandline arguments that will be passed to `Popen()`. + pattern (str): A regex pattern to extract the version string from the + commands output. + try_json (bool): Interpet the output as json first (default: False). + error_pattern (str): Regex pattern to identify a message in the output + that indicates an error. + + """ + + try: + # as context manager to prevent ResourceWarning's + with subprocess.Popen(cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + universal_newlines=True) as proc: + error_output = proc.stderr.read() + std_output = proc.stdout.read() + + except FileNotFoundError: + result = f'(no {cmd[0]})' + + else: + # Check for errors + if error_pattern: + match = re.findall(error_pattern, error_output) + + if match: + return None + + # some tools use "stderr" for version infos + if not std_output: + result = error_output + else: + result = std_output + + # Expect JSON string + if try_json: + + try: + result = json.loads(result) + + except json.decoder.JSONDecodeError: + # Wasn't a json. Try regex in the next block. + pass + + else: + # No regex parsing because it was json + pattern = None + + # extract version string + if pattern: + result = re.findall(pattern, result)[0] + + return result.strip() + + +def get_git_repository_info(path=None): + """Return the current branch and last commit hash. + + Credits: https://stackoverflow.com/a/51224861/4865723 + + Args: + path (pathlib.Path): Path with '.git' folder in (default is + current working directory). + + Returns: + (dict): Dict with keys "branch" and "hash" if it is a git repo, + otherwise an `None`. + """ + + if not path: + path = pathlib.Path.cwd() + + git_folder = path / '.git' + + if not git_folder.exists(): + return None + + result = {} + + # branch name + with (git_folder / 'HEAD').open('r') as handle: + val = handle.read() + + if val.startswith('ref: '): + result['branch'] = '/'.join(val.split('/')[2:]).strip() + + else: + result['branch'] = '(detached HEAD)' + result['hash'] = val + + return result + + # commit hash + with (git_folder / 'refs' / 'heads' / result['branch']) \ + .open('r') as handle: + result['hash'] = handle.read().strip() + + return result + + +def _determine_distro_package_folder(): + """Return the projects root folder. + + In Python terms it is the "Distribution Package" not the "Modules + Package". + + Development info: The function become obslet when migrating the project + to the "src" layout. + """ + + # "current" folder + path = pathlib.Path(__file__) + + # level of highest folder named "backintime" + bit_idx = path.parts.index('backintime') + + # cut the path to that folder + path = pathlib.Path(*(path.parts[:bit_idx+1])) + + return path diff --git a/common/test/test_diagnostics.py b/common/test/test_diagnostics.py new file mode 100644 index 000000000..f5ad0511b --- /dev/null +++ b/common/test/test_diagnostics.py @@ -0,0 +1,127 @@ +import sys +import pathlib +import unittest +import pyfakefs.fake_filesystem_unittest as pyfakefs_ut + +# This workaround will become obsolet when migrating to src-layout +sys.path.append(str(pathlib.Path(__file__).parent)) +import diagnostics # testing target + + +class Diagnostics(unittest.TestCase): + """ + """ + + def test_minimal(self): + """Minimal set of elements.""" + + result = diagnostics.collect_diagnostics() + + # 1st level keys + self.assertEqual( + sorted(result.keys()), + ['backintime', 'external-programs', 'host-setup', 'python-setup'] + ) + + # 2nd level "backintime" + minimal_keys = ['name', 'version', 'config-version', + 'started-from', 'running_as_root'] + for key in minimal_keys: + self.assertIn(key, result['backintime'], key) + + # 2nd level "host-setup" + minimal_keys = ['platform', 'system', 'display-system', + 'locale', 'PATH'] + for key in minimal_keys: + self.assertIn(key, result['host-setup'], key) + + # 2nd level "python-setup" + self.assertIn('python', result['python-setup'], 'python') + + # 2nd level "external-programs" + minimal_keys = ['rsync', 'shell'] + for key in minimal_keys: + self.assertIn(key, result['external-programs'], key) + + + def test_no_ressource_warning(self): + """No ResourceWarning's. + + Using subprocess.Popen() often cause ResourceWarning's when not used + as a context manaager. + """ + + # an AssertionError must be raised! See next block for explanation. + with self.assertRaises(AssertionError): + + # We expect NO ResourceWarnings. But Python doesn't offer + # assertNoWarns(). + # This will raise an AssertionError bcause no ResourceWarning's + # are raised. + with self.assertWarns(ResourceWarning): + + diagnostics.collect_diagnostics() + + def test_no_extern_version(self): + """Get version from not existing tool. + """ + self.assertEqual( + diagnostics._get_extern_versions(['fooXbar']), + '(no fooXbar)' + ) + + +class Diagnostics_FakeFS(pyfakefs_ut.TestCase): + """Tests using a fake filesystem. + """ + + def setUp(self): + self.setUpPyfakefs(allow_root_user=False) + + def test_distro_package_folder(self): + """Determin the folder of the project. + """ + + # real path + path = pathlib.Path(diagnostics.__file__) + + # replicate that path in the fake fileystem + path.mkdir(parents=True) + path.touch() + + result = diagnostics._determine_distro_package_folder() + + self.assertEqual(result, path.parent.parent) + + def test_git_repo_info(self): + """ + """ + + # not a git repo + self.assertEqual(diagnostics.get_git_repository_info(), None) + + # simulate a git repo + path = pathlib.Path('.git') + path.mkdir() + + # Branch folders and hash containing file + foobar = path / 'refs' / 'heads' / 'fix' / 'foobar' + foobar.parent.mkdir(parents=True) + + with foobar.open('w') as handle: + handle.write('01234') + + # HEAD file + head = path / 'HEAD' + + with head.open('w') as handle: + handle.write('ref: refs/heads/fix/foobar') + + # Test + self.assertEqual( + diagnostics.get_git_repository_info(), + { + 'hash': '01234', + 'branch': 'fix/foobar' + } + )