Skip to content

Commit

Permalink
First implementation of diagnostics.py (#1300 by @buhtzz)
Browse files Browse the repository at this point in the history
* collects diagnostic information
* replaces username in paths
  • Loading branch information
buhtz authored Sep 20, 2022
1 parent 7c26db7 commit be3c256
Show file tree
Hide file tree
Showing 4 changed files with 436 additions and 1 deletion.
2 changes: 1 addition & 1 deletion .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions CHANGES
Original file line number Diff line number Diff line change
Expand Up @@ -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<N>.schedule.time in backintime-config manpage (#1270)
* Translation update: Brazilian Portuguese (#1267)
* Translation update: Italian (#1110, #1123)
Expand Down
307 changes: 307 additions & 0 deletions common/diagnostics.py
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit be3c256

Please sign in to comment.