From 77dd64c0757c0191b276e65c24ee9874959790c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Frings-F=C3=BCrst?= Date: Tue, 25 Jul 2017 06:17:26 +0200 Subject: New upstream version 0.9.1 --- install.py | 1153 +++++++++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 917 insertions(+), 236 deletions(-) (limited to 'install.py') diff --git a/install.py b/install.py index 32d7eaf..7649cb0 100755 --- a/install.py +++ b/install.py @@ -18,46 +18,42 @@ # along with Rapid Photo Downloader. If not, # see . -""" -Install script for Rapid Photo Downloader. - -Do not run as root - it will refuse to run if you try. - -The primary purpose of this installation script is to install packages that are required -for Rapid Photo Downloader to run. Specifically, these packages are: - -1. Non-python programs, e.g. exiv2, ExifTool. -2. Python packages that are unavailable on Python's PyPi service, namely - python3 gobject introspection modules. -3. Although PyQt 5.6 and above is available on PyPi, bundled with Qt 5.6, it's easier - to use the Linux distro's PyQt packages, particularly in the case of Ubuntu, whose - custom scrollbar implementation does not work with stock Qt without a special environment - variable being set that disables the custom scrollbars. - -Once these dependencies are satisfied, Python's pip is used to install Rapid Photo Downloader -itself, along with several Python packages from PyPi, found in requirements.txt. - -The secondary purpose of this install script is to give the option to the user of installing man -pages in the system's standard man page location, and for Debian/Ubuntu/openSUSE distros, -to create a link in ~/bin to the rapid-photo-downloader executable. -""" - __author__ = 'Damon Lynch' __copyright__ = "Copyright 2016-2017, Damon Lynch" -import tarfile -import os import sys +import os +from enum import Enum +from distutils.version import StrictVersion +import pkg_resources +import hashlib import tempfile import argparse import shlex import subprocess +import platform +import math +import threading +import time from subprocess import Popen, PIPE import shutil -from distutils.version import StrictVersion -from enum import Enum +import tarfile +import re +import random +import string + + +__version__ = '0.1.0' +__title__ = 'Rapid Photo Downloader installer' +__description__ = "Download and install latest version of Rapid Photo Downloader." +try: + import requests + have_requests = True +except ImportError: + have_requests = False + try: import apt have_apt = True @@ -70,6 +66,20 @@ try: except ImportError: have_dnf = False +try: + import pip + have_pip = True + pip_version = StrictVersion(pip.__version__) +except ImportError: + have_pip = False + pip_version = None + +try: + import pyprind + have_pyprind_progressbar = True +except ImportError: + have_pyprind_progressbar = False + os_release = '/etc/os-release' @@ -111,66 +121,11 @@ installer_cmds = { } -def check_packages_on_other_systems() -> None: +def get_distro() -> Distro: """ - Check to see if some (but not all) application dependencies are - installed on systems that we are not explicitly analyzing. + Determine the Linux distribution using /etc/os-release """ - import_msgs = [] - - try: - import PyQt5 - except ImportError: - import_msgs.append('python3 variant of PyQt5') - try: - import gi - have_gi = True - except ImportError: - import_msgs.append('python3 variant of gobject introspection') - have_gi = False - if have_gi: - try: - gi.require_version('GUdev', '1.0') - except ValueError: - import_msgs.append('GUdev 1.0 from gi.repository') - try: - gi.require_version('UDisks', '2.0') - except ValueError: - import_msgs.append('UDisks 2.0 from gi.repository') - try: - gi.require_version('GLib', '2.0') - except ValueError: - import_msgs.append('GLib 2.0 from gi.repository') - try: - gi.require_version('GExiv2', '0.10') - except ValueError: - import_msgs.append('GExiv2 0.10 from gi.repository') - try: - gi.require_version('Gst', '1.0') - except ValueError: - import_msgs.append('Gst 1.0 from gi.repository') - try: - gi.require_version('Notify', '0.7') - except ValueError: - import_msgs.append('Notify 0.7 from gi.repository') - if shutil.which('exiftool') is None: - import_msgs.append('ExifTool') - if len(import_msgs): - install_error_message = "This program requires:\n{}\nPlease install them " \ - "using your distribution's standard installation tools.\n" - sys.stderr.write(install_error_message.format('\n'.join(s for s in import_msgs))) - sys.exit(1) - - -def get_distro_id(id_or_id_like: str) -> Distro: - try: - return Distro[id_or_id_like.strip()] - except KeyError: - return Distro.unknown - - -def get_distro() -> Distro: if os.path.isfile(os_release): with open(os_release, 'r') as f: for line in f: @@ -183,7 +138,26 @@ def get_distro() -> Distro: return Distro.unknown +def get_distro_id(id_or_id_like: str) -> Distro: + """ + Determine the Linux distribution using an ID or ID_LIKE line from + /etc/os-release + :param id_or_id_like: the line from /etc/os-release + """ + + try: + return Distro[id_or_id_like.strip()] + except KeyError: + return Distro.unknown + + def get_distro_version(distro: Distro) -> float: + """ + Get the numeric version of the Linux distribution, if it exists + :param distro: the already determine Linux distribution + :return version in floating point format, if found, else 0.0 + """ + remove_quotemark = False if distro == Distro.fedora: version_string = 'REDHAT_BUGZILLA_PRODUCT_VERSION=' @@ -211,19 +185,168 @@ def get_distro_version(distro: Distro) -> float: return 0.0 -def run_cmd(command_line: str, restart=False, exit_on_failure=True, shell=False) -> None: +def is_debian_testing_or_unstable() -> bool: + """ + :return: True if Debian distribution is testing or unstable, else False. + """ + + with open(os_release, 'r') as f: + for line in f: + if line.startswith('PRETTY_NAME'): + return 'buster' in line or 'sid' in line + return False + + +def pypi_pyqt5_capable() -> bool: + """ + :return: True if the platform supports running PyQt5 directly from Python's Pypi, + else False. + """ + + return platform.machine() == 'x86_64' and platform.python_version_tuple()[1] in ('5', '6') + + +def make_pip_command(args: str, split: bool=True): + """ + Construct a call to python's pip + :param args: arguments to pass to the command + :param split: whether to split the result into a list or not using shlex + :return: command line in string or list format + """ + + cmd_line = '{} -m pip --disable-pip-version-check {}'.format(sys.executable, args) + if split: + return shlex.split(cmd_line) + else: + return cmd_line + + +def make_distro_packager_commmand(distro_family: Distro, + packages: str, + interactive: bool, + command: str='install', + sudo: bool=True) -> str: + """ + Construct a call to the Linux distribution's packaging command + + :param distro_family: the Linux distribution + :param packages: packages to query / install / remove + :param interactive: whether the command should require user intervention + :param command: the command the packaging program should run + :param sudo: whehter to prefix the call with sudo + :return: the command line in string format + """ + + installer = installer_cmds[distro_family] + cmd = shutil.which(installer) + + if interactive: + automatic = '' + else: + automatic = '-y' + + if sudo: + super = 'sudo ' + else: + super = '' + + if distro_family != Distro.opensuse: + return '{}{} {} {} {}'.format(super, cmd, automatic, command, packages) + else: + return '{}{} {} {} {}'.format(super, cmd, command, automatic, packages) + + +def custom_python() -> bool: + """ + :return: True if the python executable is a custom version of python, rather + than a standard distro version. + """ + + return not sys.executable.startswith('/usr/bin/python') + + +def user_pip() -> bool: + """ + :return: True if the version of pip has been installed from Pypi + for this user, False if pip has been installed system wide. + """ + + args = make_pip_command('--version') + try: + v = subprocess.check_output(args, universal_newlines=True) + return os.path.expanduser('~/.local/lib/python3') in v + except Exception: + return False + + +def pip_package(package: str, local_pip: bool) -> str: + """ + Helper function to construct installing core pythong packages + :param package: the python package + :param local_pip: if True, install the package using pip and Pypi, + else install using the Linux distribution package tools. + :return: string of package names + """ + + return package if local_pip else 'python3-{}'.format(package) + + +def get_yes_no(response: str) -> bool: + """ + :param response: the user's response + :return: True if user response is yes or empty, else False + """ + + return response.lower() in ('y', 'yes', '') + + + +def generate_random_file_name(length = 5) -> str: + """ + Generate a random file name + :param length: file name length + :return: the file name + """ + + filename_characters = list(string.ascii_letters + string.digits) + try: + r = random.SystemRandom() + return ''.join(r.sample(filename_characters, length)) + except NotImplementedError: + return ''.join(random.sample(filename_characters, length)) + + +def run_cmd(command_line: str, + restart=False, + exit_on_failure=True, + shell=False, + interactive=False) -> None: + """ + Run command using subprocess.check_call, and then restart if requested. + + :param command_line: the command to run with args + :param restart: if True, restart this script using the same command line + arguments as it was called with + :param exit_on_failure: if True, exit if the subprocess call fails + :param shell: if True, run the subprocess using a shell + :param interactive: if True, the user should be prompted to confirm + the command + """ + + print("The following command will be run:\n") print(command_line) if command_line.startswith('sudo'): print("\nsudo may prompt you for the sudo password.") print() - args = shlex.split(command_line) - answer = input('Would you like to run the command now? (If you do, type yes and hit enter): ') + if interactive: + answer = input('Would you like to run the command now? [Y/n]: ') + if not get_yes_no(answer): + print('Answer is not yes, exiting.') + sys.exit(0) - if answer != 'yes': - print('Answer is not yes, exiting.') - sys.exit(0) + args = shlex.split(command_line) print() @@ -236,80 +359,272 @@ def run_cmd(command_line: str, restart=False, exit_on_failure=True, shell=False) sys.exit(1) else: if restart: - if len(sys.argv) == 2: - sys.stdout.flush() - sys.stderr.flush() - # restart the script - os.execl(sys.executable, sys.executable, *sys.argv) - else: - print("Rerun this script, passing the path to the tarfile\n") - sys.exit(0) + sys.stdout.flush() + sys.stderr.flush() + # restart the script + os.execl(sys.executable, sys.executable, *sys.argv) + + +def enable_universe(interactive: bool) -> None: + """ + Enable the universe repository on Ubuntu + :param interactive: if True, the user should be prompted to confirm + the command + """ -def enable_universe(): try: repos = subprocess.check_output(['apt-cache', 'policy'], universal_newlines=True) version = subprocess.check_output(['lsb_release', '-sc'], universal_newlines=True).strip() if not '{}/universe'.format(version) in repos and version not in ( 'sarah', 'serena', 'sonya'): - print("The Universe repository must be enabled. Do you want do that now?\n") - run_cmd(command_line='sudo add-apt-repository universe', restart=False) - run_cmd(command_line='sudo apt update', restart=True) + print("The Universe repository must be enabled.\n") + run_cmd( + command_line='sudo add-apt-repository universe', restart=False, + interactive=interactive + ) + run_cmd(command_line='sudo apt update', restart=True, interactive=interactive) except Exception: pass -def check_package_import_requirements(distro: Distro, version: float) -> None: - if distro in debian_like: - if not have_apt: - print('To continue, the package python3-apt must be installed.\n') - cmd = shutil.which('apt-get') - command_line = 'sudo {} install python3-apt'.format(cmd) - run_cmd(command_line, restart=True) + +def query_uninstall(interactive: bool) -> bool: + """ + Query the user whether to uninstall the previous version of Rapid Photo Downloader + that was packaged using a Linux distribution package manager. + + :param interactive: if False, the user will not be queried + :return: + """ + if not interactive: + return True + + answer = input( + '\nDo you want to to uninstall the previous version of Rapid Photo Downloader: [Y/n]' + ) + return get_yes_no(answer) + + +def opensuse_missing_packages(packages: str): + """ + Return which of the packages have not already been installed on openSUSE. + + :param packages: the packages to to check, in a string separated by white space + :return: list of packages + """ + + command_line = make_distro_packager_commmand(Distro.opensuse, packages, True, 'se', False) + args = shlex.split(command_line) + output = subprocess.check_output(args, universal_newlines=True) + return [package for package in packages.split() if '\ni | {}'.format(package) not in output] + + +def opensuse_package_installed(package) -> bool: + """ + :param package: package to check + :return: True if the package is installed in the openSUSE distribution, else False + """ + + return not opensuse_missing_packages(package) + + +def uninstall_old_version(distro_family: Distro, interactive: bool) -> None: + """ + Uninstall old version of Rapid Photo Downloader that was installed using the + distribution package manager and also with pip + + :param distro_family: the Linux distro family that this distro is in + :param interactive: if True, the user should be prompted to confirm + the commands + """ + + pkg_name = 'rapid-photo-downloader' + + if distro_family == Distro.debian: + try: + cache = apt.Cache() + pkg = cache[pkg_name] + if pkg.is_installed and query_uninstall(interactive): + run_cmd(make_distro_packager_commmand(distro, pkg_name, interactive, 'remove')) + except Exception: + pass + + elif distro_family == Distro.fedora: + print("Querying package system to see if an older version of Rapid Photo Downloader is " + "installed (this may take a while)...") + with dnf.Base() as base: + base.read_all_repos() + try: + base.fill_sack() + except dnf.exceptions.RepoError as e: + print("Unable to query package system. Please check your Internet connection and " + "try again") + sys.exit(1) + + q = base.sack.query() + q_inst = q.installed() + i = q_inst.filter(name=pkg_name) + if len(list(i)) and query_uninstall(interactive): + run_cmd(make_distro_packager_commmand(distro, pkg_name, interactive, 'remove')) + + elif distro_family == Distro.opensuse: + print("Querying package system to see if an older version of Rapid Photo Downloader is " + "installed (this may take a while)...") + + if opensuse_package_installed('rapid-photo-downloader') and query_uninstall(interactive): + run_cmd(make_distro_packager_commmand(distro, pkg_name, interactive, 'rm')) + + # Explicitly uninstall any previous version installed with pip + # Loop through to see if multiple versions need to be removed (as can happen with + # the Debian / Ubuntu pip) + + print("Checking if previous version installed with pip...") + l_command_line = 'list --user --disable-pip-version-check' + if pip_version >= StrictVersion('9.0.0'): + # pip 9.0 issues a red warning if format not specified + l_command_line = '{} --format=columns'.format(l_command_line) + l_args = make_pip_command(l_command_line) + + u_command_line = 'uninstall --disable-pip-version-check -y rapid-photo-downloader' + u_args = make_pip_command(u_command_line) + while True: + try: + output = subprocess.check_output(l_args, universal_newlines=True) + if 'rapid-photo-downloader' in output: + try: + subprocess.check_call(u_args) + except subprocess.CalledProcessError: + print("Encountered an error uninstalling previous version installed with pip") + break + else: + break + except Exception: + break + + +def check_packages_on_other_systems() -> None: + """ + Check to see if some (but not all) application dependencies are + installed on systems that we are not explicitly analyzing. + """ + + import_msgs = [] + + if not pypi_pyqt5_capable(): + try: + import PyQt5 + except ImportError: + import_msgs.append('python3 variant of PyQt5') + try: + import gi + have_gi = True + except ImportError: + import_msgs.append('python3 variant of gobject introspection') + have_gi = False + if have_gi: + try: + gi.require_version('GUdev', '1.0') + except ValueError: + import_msgs.append('GUdev 1.0 from gi.repository') + try: + gi.require_version('UDisks', '2.0') + except ValueError: + import_msgs.append('UDisks 2.0 from gi.repository') + try: + gi.require_version('GLib', '2.0') + except ValueError: + import_msgs.append('GLib 2.0 from gi.repository') + try: + gi.require_version('GExiv2', '0.10') + except ValueError: + import_msgs.append('GExiv2 0.10 from gi.repository') + try: + gi.require_version('Gst', '1.0') + except ValueError: + import_msgs.append('Gst 1.0 from gi.repository') + try: + gi.require_version('Notify', '0.7') + except ValueError: + import_msgs.append('Notify 0.7 from gi.repository') + if shutil.which('exiftool') is None: + import_msgs.append('ExifTool') + if len(import_msgs): + install_error_message = "This program requires:\n{}\nPlease install them " \ + "using your distribution's standard installation tools.\n" + sys.stderr.write(install_error_message.format('\n'.join(s for s in import_msgs))) + sys.exit(1) + + +def install_required_distro_packages(distro: Distro, + distro_family: Distro, + version: float, + interactive: bool) -> None: + """ + Install packages supplied by the Linux distribution + :param distro: the specific Linux distribution + :param distro_family: the family of distros the Linux distribution belongs too + :param version: the Linux distribution's version + :param interactive: if True, the user should be prompted to confirm + the commands + """ + + if distro_family == Distro.debian: cache = apt.Cache() missing_packages = [] - packages = 'libimage-exiftool-perl python3-pyqt5 python3-dev ' \ + packages = 'gstreamer1.0-libav gstreamer1.0-plugins-good ' \ + 'libimage-exiftool-perl python3-dev ' \ 'intltool gir1.2-gexiv2-0.10 python3-gi gir1.2-gudev-1.0 ' \ 'gir1.2-udisks-2.0 gir1.2-notify-0.7 gir1.2-glib-2.0 gir1.2-gstreamer-1.0 '\ 'libgphoto2-dev python3-arrow python3-psutil g++ libmediainfo0v5 '\ - 'qt5-image-formats-plugins python3-zmq exiv2 python3-colorlog libraw-bin ' \ - 'python3-easygui python3-sortedcontainers python3-wheel python3-requests'.split() + 'python3-zmq exiv2 python3-colorlog libraw-bin ' \ + 'python3-easygui python3-sortedcontainers' - for package in packages: + if not pypi_pyqt5_capable(): + packages = 'qt5-image-formats-plugins python3-pyqt5 {}'.format(packages) + + if not have_requests: + packages = 'python3-requests {}'.format(packages) + + for package in packages.split(): try: if not cache[package].is_installed: missing_packages.append(package) except KeyError: - print('The following package is unknown on your system: {}\n'.format( - package)) - sys.exit(1) + print( + 'The following package is unknown on your system: {}\n'.format(package) + ) + sys.exit(1) if missing_packages: - cmd = shutil.which('apt-get') - command_line = 'sudo {} install {}'.format(cmd, ' '.join(missing_packages)) print("To continue, some packages required to run the application will be " "installed.\n") - run_cmd(command_line) + run_cmd( + make_distro_packager_commmand( + distro_family, ' '.join(missing_packages), interactive + ), interactive=interactive + ) - elif distro in fedora_like: - if not have_dnf: - print('To continue, the package python3-dnf must be installed.\n') - cmd = shutil.which('dnf') - command_line = 'sudo {} install python3-dnf'.format(cmd) - run_cmd(command_line, restart=True) + elif distro_family == Distro.fedora: missing_packages = [] - packages = 'python3-qt5 gobject-introspection python3-gobject ' \ + packages = 'gstreamer1-libav gstreamer1-plugins-good ' \ + 'gobject-introspection python3-gobject ' \ 'libgphoto2-devel zeromq-devel exiv2 perl-Image-ExifTool LibRaw-devel gcc-c++ ' \ 'rpm-build python3-devel intltool ' \ - 'python3-easygui qt5-qtimageformats python3-psutil libmediainfo ' \ - 'python3-requests'.split() + 'python3-easygui python3-psutil libmediainfo ' + + if not pypi_pyqt5_capable(): + packages = 'qt5-qtimageformats python3-qt5 {}'.format(packages) + + if not have_requests: + packages = 'python3-requests {}'.format(packages) - if 0.0 < version <= 24.0: - packages.append('libgexiv2-python3') + if distro == Distro.fedora and 0.0 < version <= 24.0: + packages = 'libgexiv2-python3 {}'.format(packages) else: - packages.append('python3-gexiv2') + packages = 'python3-gexiv2 {}'.format(packages) print("Querying installed and available packages (this may take a while)") @@ -332,110 +647,411 @@ def check_package_import_requirements(distro: Distro, version: float) -> None: installed = [pkg.name for pkg in q_inst.run()] available = [pkg.name for pkg in q_avail.run()] - for package in packages: + for package in packages.split(): if package not in installed: if package in available: missing_packages.append(package) + elif package == 'gstreamer1-libav': + print( + bcolors.BOLD + "\nTo be able to generate thumbnails for a wider range " + "of video formats, install gstreamer1-libav after having first added " + "an appropriate software repository such as rpmfusion.org." + + bcolors.ENDC + ) else: sys.stderr.write( 'The following package is unavailable on your system: {}\n'.format( - package)) + package + ) + ) sys.exit(1) if missing_packages: - cmd = shutil.which('dnf') - command_line = 'sudo {} install {}'.format(cmd, ' '.join(missing_packages)) print("To continue, some packages required to run the application will be " "installed.\n") - run_cmd(command_line) + run_cmd( + make_distro_packager_commmand( + distro_family, ' '.join(missing_packages), interactive + ), interactive=interactive + ) - elif distro == Distro.opensuse: - cmd = shutil.which('zypper') - packages = 'python3-qt5 girepository-1_0 python3-gobject ' \ + elif distro_family == Distro.opensuse: + + packages = 'girepository-1_0 python3-gobject ' \ 'zeromq-devel exiv2 exiftool python3-devel ' \ 'libgphoto2-devel libraw-devel gcc-c++ rpm-build intltool ' \ - 'libqt5-qtimageformats python3-requests python3-psutil ' \ + 'python3-psutil ' \ 'typelib-1_0-GExiv2-0_10 typelib-1_0-UDisks-2_0 typelib-1_0-Notify-0_7 ' \ 'typelib-1_0-Gst-1_0 typelib-1_0-GUdev-1_0' - command_line = 'sudo {} in {}'.format(cmd, packages) - print("To continue, some packages required to run the application will be checked or " - "installed.\n") - - run_cmd(command_line) #TODO libmediainfo - not a default openSUSE package, sadly + + if not pypi_pyqt5_capable(): + packages = 'python3-qt5 libqt5-qtimageformats {}'.format(packages) + + if not have_requests: + packages = 'python3-requests {}'.format(packages) + + print("Querying zypper to see if any required packages are already installed (this may " + "take a while)... ") + missing_packages = opensuse_missing_packages(packages) + + if missing_packages: + print("To continue, some packages required to run the application will be installed.\n") + run_cmd( + make_distro_packager_commmand( + distro_family, ' '.join(missing_packages), interactive + ), interactive=interactive + ) else: check_packages_on_other_systems() -def query_uninstall() -> bool: - return input('\nType yes and hit enter if you want to to uninstall the previous version of ' - 'Rapid Photo Downloader: ') == 'yes' +def parser_options(formatter_class=argparse.HelpFormatter) -> argparse.ArgumentParser: + """ + Construct the command line arguments for the script + :return: the parser + """ -def uninstall_old_version(distro: Distro) -> None: - pkg_name = 'rapid-photo-downloader' + parser = argparse.ArgumentParser( + prog=__title__, formatter_class=formatter_class, description=__description__ + ) + + parser.add_argument( + '--version', action='version', version='%(prog)s {}'.format(__version__), + help="Show program's version number and exit." + ) + parser.add_argument( + "-i", "--interactive", action="store_true", dest="interactive", default=False, + help="Query to confirm action at each step." + ) + parser.add_argument( + '--devel', action="store_true", dest="devel", default=False, + help="When downloading the latest version, install the development version if it is " + "newer than the stable version." + ) + + parser.add_argument( + 'tarfile', action='store', nargs='?', + help="Optional tar.gz Rapid Photo Downloader installer archive. If not specified, " + "the latest version is downloaded from the Internet." + ) + + parser.add_argument( + '--delete-install-script-and-containing-dir', action='store_true', + dest='delete_install_script', help=argparse.SUPPRESS + ) + + parser.add_argument( + '--delete-tar-and-containing-dir', action='store_true', dest='delete_tar_and_dir', + help=argparse.SUPPRESS + ) + + parser.add_argument( + '--force-this-installer-version', action='store_true', dest='force_this_version', + help="Do not run the installer in the tar.gz Rapid Photo Downloader installer archive if " + "it is newer than this version ({}). The default is to run whichever installer is " + "newer.".format(__version__) + ) + + return parser + + +def verify_download(downloaded_tar: str, md5_url: str) -> bool: + """ + Verifies downloaded tarball against the launchpad generated md5sum file. - if distro in debian_like: - try: - cache = apt.Cache() - pkg = cache[pkg_name] - if pkg.is_installed and query_uninstall(): - cmd = shutil.which('apt-get') - command_line = 'sudo {} remove {}'.format(cmd, pkg_name) - run_cmd(command_line) - except Exception: - pass + Exceptions not caught. - elif distro in fedora_like: - print("Querying package system to see if an older version of Rapid Photo Downloader is " - "installed (this may take a while)...") - with dnf.Base() as base: - base.read_all_repos() + :param downloaded_tar: local file + :param md5_url: remote md5sum file for the download + :return: True if md5sum matches, False otherwise, + """ + + if not md5_url: + return True + + r = requests.get(md5_url) + assert r.status_code == 200 + remote_md5 = r.text.split()[0] + with open(downloaded_tar, 'rb') as tar: + m = hashlib.md5() + m.update(tar.read()) + return m.hexdigest() == remote_md5 + + +def get_installer_url_md5(devel: bool): + remote_versions_file = 'https://www.damonlynch.net/rapid/version.json' + + try: + r = requests.get(remote_versions_file) + except: + print("Failed to download versions file", remote_versions_file) + else: + status_code = r.status_code + if status_code != 200: + print("Got error code {} while accessing versions file".format(status_code)) + else: try: - base.fill_sack() - except dnf.exceptions.RepoError as e: - print("Unable to query package system. Please check your internet connection and " - "try again") - sys.exit(1) + version = r.json() + except: + print("Error %d accessing versions JSON file") + else: + stable = version['stable'] + dev = version['dev'] + + if devel and pkg_resources.parse_version(dev['version']) > \ + pkg_resources.parse_version(stable['version']): + tarball_url = dev['url'] + md5 = dev['md5'] + else: + tarball_url = stable['url'] + md5 = stable['md5'] + + return tarball_url, md5 + return '', '' + + +def format_size_for_user(size_in_bytes: int, + zero_string: str='', + no_decimals: int=2) -> str: + r""" + Humanize display of bytes. + + Uses Microsoft style i.e. 1000 Bytes = 1 KB + + :param size: size in bytes + :param zero_string: string to use if size == 0 + + >>> format_size_for_user(0) + '' + >>> format_size_for_user(1) + '1 B' + >>> format_size_for_user(123) + '123 B' + >>> format_size_for_user(1000) + '1 KB' + >>> format_size_for_user(1024) + '1.02 KB' + >>> format_size_for_user(1024, no_decimals=0) + '1 KB' + >>> format_size_for_user(1100, no_decimals=2) + '1.1 KB' + >>> format_size_for_user(1000000, no_decimals=2) + '1 MB' + >>> format_size_for_user(1000001, no_decimals=2) + '1 MB' + >>> format_size_for_user(1020001, no_decimals=2) + '1.02 MB' + """ - q = base.sack.query() - q_inst = q.installed() - i = q_inst.filter(name=pkg_name) - if len(list(i)) and query_uninstall(): - cmd = shutil.which('dnf') - command_line = 'sudo {} remove {}'.format(cmd, pkg_name) - run_cmd(command_line) + suffixes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] - elif distro == Distro.opensuse: - print("Querying package system to see if an older version of Rapid Photo Downloader is " - "installed (this may take a while)...") - zypper = shutil.which('zypper') - command_line = '{} se rapid-photo-downloader'.format(zypper) - args = shlex.split(command_line) - output = subprocess.check_output(args, universal_newlines=True) - if '\ni | rapid-photo-downloader' in output and query_uninstall(): - command_line = 'sudo {} rm rapid-photo-downloader'.format(zypper) - run_cmd(command_line) + if size_in_bytes == 0: return zero_string + i = 0 + while size_in_bytes >= 1000 and i < len(suffixes)-1: + size_in_bytes /= 1000 + i += 1 + + if no_decimals: + s = '{:.{prec}f}'.format(size_in_bytes, prec=no_decimals).rstrip('0').rstrip('.') + else: + s = '{:.0f}'.format(size_in_bytes) + return s + ' ' + suffixes[i] + + +def delete_installer_and_its_temp_dir(full_file_name) -> None: + temp_dir = os.path.dirname(full_file_name) + if temp_dir: + # print("Removing directory {}".format(temp_dir)) + shutil.rmtree(temp_dir, ignore_errors=True) + + +class progress_bar_scanning(threading.Thread): + # Adapted from http://thelivingpearl.com/2012/12/31/ + # creating-progress-bars-with-python/ + def run(self): + print('Downloading.... ', end='', flush=True) + i = 0 + while stop_pbs != True: + if (i%4) == 0: + sys.stdout.write('\b/') + elif (i%4) == 1: + sys.stdout.write('\b-') + elif (i%4) == 2: + sys.stdout.write('\b\\') + elif (i%4) == 3: + sys.stdout.write('\b|') + + sys.stdout.flush() + time.sleep(0.2) + i+=1 + + if kill_pbs == True: + print('\b\b\b\b ABORT!', flush=True) + else: + print('\b\b done!', flush=True) + + +def download_installer(devel): + tarball_url, md5_url = get_installer_url_md5(devel) + if not tarball_url: + sys.stderr.write("\nSorry, could not locate installer. Please check your Internet " + "connection and verify if you can reach " + "https://www.damonlynch.net\n\nExiting.\n") + sys.exit(1) + + temp_dir = tempfile.mkdtemp() + + try: + r = requests.get(tarball_url, stream=True) + local_file = os.path.join(temp_dir, tarball_url.split('/')[-1]) + chunk_size = 1024 + total_size = int(r.headers['content-length']) + size_human = format_size_for_user(total_size) + no_iterations = int(math.ceil(total_size / chunk_size)) + pbar_title = "Downloading {} ({})".format(tarball_url, size_human) + + global stop_pbs + global kill_pbs + + stop_pbs = kill_pbs = False + if have_pyprind_progressbar: + bar = pyprind.ProgBar( + iterations=no_iterations, track_time=True, title=pbar_title + ) + else: + print(pbar_title) + pbs = progress_bar_scanning() + pbs.start() + with open(local_file, 'wb') as f: + for chunk in r.iter_content(chunk_size=chunk_size): + if chunk: # filter out keep-alive new chunks + f.write(chunk) + if have_pyprind_progressbar: + bar.update() -def make_pip_command(args: str): - return shlex.split('{} -m pip {}'.format(sys.executable, args)) + if not have_pyprind_progressbar: + stop_pbs = True + pbs.join() + except Exception: + sys.stderr.write("Failed to download {}\n".format(tarball_url)) + sys.exit(1) -def main(installer: str, distro: Distro, distro_version: float) -> None: + try: + if verify_download(local_file, md5_url): + return local_file + else: + sys.stderr.write("Tar file MD5 mismatch\n") + delete_installer_and_its_temp_dir(local_file) + sys.exit(1) + except Exception: + sys.stderr.write("There was a problem verifying the download. Exiting\n") + delete_installer_and_its_temp_dir(local_file) + sys.exit(1) - uninstall_old_version(distro) - check_package_import_requirements(distro, distro_version) +def tarfile_content_name(installer: str, file_name: str) -> str: + """ + Construct a path into a tar file to be able to extract a single file + :param installer: the tar file + :param file_name: the file wanted + :return: the path including file name + """ name = os.path.basename(installer) name = name[:len('.tar.gz') * -1] + return os.path.join(name, file_name) + + +def run_latest_install(installer: str, delete_installer: bool) -> None: + """ + If the install script is newer than this script (as determined by + the version number at the head of this script), run that newer + script instead. + + :param installer: the tar.gz installer + :param delete_installer: whether to delete the tar.gz archive + """ + + install_script = '' + v = '' + with tarfile.open(installer) as tar: + with tar.extractfile(tarfile_content_name(installer, 'install.py')) as install_py: + raw_lines = install_py.read() + lines = raw_lines.decode() + r = re.search(r"""^__version__\s*=\s*[\'\"](.+)[\'\"]""", lines, re.MULTILINE) + if r: + v = r.group(1) + if pkg_resources.parse_version(__version__) < \ + pkg_resources.parse_version(v): + temp_dir = tempfile.mkdtemp() + install_script = os.path.join(temp_dir, generate_random_file_name(10)) + with open(install_script, 'w') as new_install_py: + new_install_py.write(lines) + if install_script: + print("Loading new installer script version {}".format(v)) + sys.stdout.flush() + sys.stderr.flush() + + new_args = [install_script, '--delete-install-script-and-containing-dir'] + if delete_installer: + new_args.append('--delete-tar-and-containing-dir') + new_args = new_args + sys.argv[1:] + # restart the script + os.execl(sys.executable, sys.executable, *new_args) + + +def main(installer: str, + distro: Distro, + distro_family: Distro, + distro_version: float, + interactive: bool, + devel: bool, + delete_install_script: bool, + delete_tar_and_dir: bool, + force_this_version: bool) -> None: + """ + + :param installer: the tar.gz installer archive (optional) + :param distro: specific Linux distribution + :param distro_family: the family of distros the specific distro is part of + :param distro_version: the distributions version, if it exists + :param interactive: whether to prompt to confirm commands + :param devel: download and install latest development version + :param delete_install_script: hidden command line option to delete the + install.py script and its containing directory, which is assumed to be + a temporary directory + :param delete_tar_and_dir: hidden command line option to delete the + tar.gz installer archive and its containing directory, which is assumed to be + a temporary directory + :param force_this_version: do not attempt to run a newer version of this script + """ + + if installer is None: + delete_installer = True + installer = download_installer(devel) + elif delete_tar_and_dir: + delete_installer = True + else: + delete_installer = False + + if not force_this_version: + run_latest_install(installer, delete_installer) + + uninstall_old_version(distro_family, interactive) + + install_required_distro_packages(distro, distro_family, distro_version, interactive) - rpath = os.path.join(name, 'requirements.txt') with tarfile.open(installer) as tar: - with tar.extractfile(rpath) as requirements: + with tar.extractfile(tarfile_content_name(installer, 'requirements.txt')) as requirements: reqbytes = requirements.read() + if pypi_pyqt5_capable(): + reqbytes = reqbytes.rstrip() + b'\nPyQt5' + with tempfile.NamedTemporaryFile(delete=False) as temp_requirements: temp_requirements.write(reqbytes) temp_requirements_name = temp_requirements.name @@ -443,7 +1059,9 @@ def main(installer: str, distro: Distro, distro_version: float) -> None: print("\nInstalling application requirements...\n") # Don't call pip directly - there is no API, and its developers say not to - cmd = make_pip_command('install --user -r {}'.format(temp_requirements.name)) + cmd = make_pip_command( + 'install --user --disable-pip-version-check -r {}'.format(temp_requirements.name) + ) with Popen(cmd, stdout=PIPE, stderr=PIPE, bufsize=1, universal_newlines=True) as p: for line in p.stdout: print(line, end='') @@ -451,17 +1069,23 @@ def main(installer: str, distro: Distro, distro_version: float) -> None: i = p.returncode os.remove(temp_requirements_name) if i != 0: + if delete_installer: + delete_installer_and_its_temp_dir(installer) sys.stderr.write("Failed to install application requirements: exiting\n") sys.exit(1) print("\nInstalling application...\n") - cmd = make_pip_command('install --user --upgrade --no-deps {}'.format(installer)) + cmd = make_pip_command( + 'install --user --disable-pip-version-check --no-deps {}'.format(installer) + ) with Popen(cmd, stdout=PIPE, stderr=PIPE, bufsize=1, universal_newlines=True) as p: for line in p.stdout: print(line, end='') p.wait() i = p.returncode if i != 0: + if delete_installer: + delete_installer_and_its_temp_dir(installer) sys.stderr.write("Failed to install application: exiting\n") sys.exit(1) @@ -491,12 +1115,20 @@ def main(installer: str, distro: Distro, distro_version: float) -> None: sys.stderr.write("Add {} to your PATH to be able to launch it.\n".format(install_path)) man_dir = '/usr/local/share/man/man1' - print("\nDo you want to install the application's man pages?") - print("They will be installed into {}".format(man_dir)) - print("If you uninstall the application, remove these manpages yourself.") - print("sudo may prompt you for the sudo password.") - answer = input('Type yes and hit enter if you do want to install the man pages: ') - if answer == 'yes': + + if interactive: + print("\nDo you want to install the application's man pages?") + print("They will be installed into {}".format(man_dir)) + print("If you uninstall the application, remove these manpages yourself.") + print("sudo may prompt you for the sudo password.") + answer = input('Do want to install the man pages? [Y/n] ') + else: + print("\nInstalling man pages into {}".format(man_dir)) + print("If you uninstall the application, remove these manpages yourself.") + print("sudo may prompt you for the sudo password.\n") + answer = 'y' + + if get_yes_no(answer): if not os.path.isdir(man_dir): cmd = shutil.which('mkdir') command_line = 'sudo {} -p {}'.format(cmd, man_dir) @@ -505,6 +1137,8 @@ def main(installer: str, distro: Distro, distro_version: float) -> None: try: subprocess.check_call(args) except subprocess.CalledProcessError: + if delete_installer: + delete_installer_and_its_temp_dir(installer) sys.stderr.write("Failed to create man page directory: exiting\n") sys.exit(1) cmd = shutil.which('cp') @@ -517,27 +1151,49 @@ def main(installer: str, distro: Distro, distro_version: float) -> None: try: subprocess.check_call(args) except subprocess.CalledProcessError: - sys.stderr.write("Failed to copy man page: exiting\n") - sys.exit(1) + sys.stderr.write("Failed to copy man page.") + + if delete_installer: + delete_installer_and_its_temp_dir(installer) + + if delete_install_script: + delete_installer_and_its_temp_dir(sys.argv[0]) if __name__ == '__main__': + """ + Setup core Python modules if needed: pip, setuptools, wheel, and requests + Setup repositories if needed. + Then call main install logic. + """ if os.getuid() == 0: sys.stderr.write("Do not run this installer script as sudo / root user.\nRun it using the " "user who will run the program.\n") sys.exit(1) + parser = parser_options() + + args = parser.parse_args() + + if args.devel and args.tarfile: + print("Ignoring command line option --devel because a tar.gz archive is specified.\n") + distro = get_distro() if distro != Distro.unknown: distro_version = get_distro_version(distro) else: distro_version = 0.0 - if distro == Distro.debian and distro_version <= 8.0: - sys.stderr.write("Sorry, Debian Jessie is too old to be able to run this version of Rapid " - "Photo Downloader.\n") - sys.exit(1) + if distro == Distro.debian: + if distro_version == 0.0: + if not is_debian_testing_or_unstable(): + print('Warning: this version of Debian may not work with Rapid Photo Downloader.') + elif distro_version <= 8.0: + sys.stderr.write("Sorry, Debian Jessie is too old to be able to run this version of " + "Rapid Photo Downloader.\n") + sys.exit(1) + elif distro in fedora_like and 0.0 > distro_version <= 23.0: sys.stderr.write("Sorry, Fedora 23 is no longer supported by Rapid Photo Downloader.\n") sys.exit(1) @@ -548,30 +1204,48 @@ if __name__ == '__main__': sys.exit(0) if distro == Distro.ubuntu: - enable_universe() + enable_universe(args.interactive) if distro in debian_like: distro_family = Distro.debian + if not have_apt: + if not custom_python(): + print('To continue, the package python3-apt must be installed.\n') + command_line = make_distro_packager_commmand( + distro_family, 'python3-apt', args.interactive + ) + run_cmd(command_line, restart=True, interactive=args.interactive) + else: + sys.stderr.write("Sorry, this installer does not support a custom python " + "installation.\nExiting\n") + sys.exit(1) + elif distro in fedora_like: distro_family = Distro.fedora + if custom_python(): + sys.stderr.write("Sorry, this installer does not support a custom python " + "installation.\nExiting\n") + sys.exit(1) else: distro_family = distro packages = [] - try: - import pip - except ImportError: + + if have_pip: + local_pip = custom_python() or user_pip() + else: packages.append('python3-pip') + local_pip = False try: import setuptools except ImportError: - packages.append('python3-setuptools') + packages.append(pip_package('setuptools', local_pip)) try: import wheel except: - packages.append('python3-wheel') + packages.append(pip_package('wheel', local_pip)) if packages: packages = ' '.join(packages) @@ -583,38 +1257,45 @@ if __name__ == '__main__': sys.stderr.write(packages + '\n') sys.exit(1) - print("To run this program, you must first install some programs to assist " - "Python 3 and its package management.\n") + print("To run this program, programs to assist Python 3 and its package management must " + "be installed.\n") - installer = installer_cmds[distro_family] - command_line = 'sudo {} install '.format(installer) + packages - run_cmd(command_line, restart=True) + if not local_pip: + command_line = make_distro_packager_commmand(distro_family, packages, args.interactive) + else: + command_line = make_pip_command('install --user ' + packages, split=False) - # Can now assume that both pip and wheel have been installed + run_cmd(command_line, restart=True, interactive=args.interactive) - if StrictVersion(pip.__version__) < StrictVersion('8.1'): + # Can now assume that both pip, setuptools and wheel have been installed + if pip_version < StrictVersion('8.1'): print("\nPython 3's pip and setuptools must be upgraded for your user.\n") - print("Caution: upgrading pip and setuptools for your user could potentially " - "negatively affect the installation of other, older Python packages by your user.\n") - print("However the risk is very small and is normally nothing to worry about.\n") + command_line = make_pip_command( + 'install --user --upgrade pip setuptools wheel', split=False + ) - command_line = '{} -m pip install --user --upgrade pip setuptools'.format(sys.executable) + run_cmd(command_line, restart=True, interactive=args.interactive) - run_cmd(command_line, restart=True) + installer = args.tarfile - parser = argparse.ArgumentParser(description='Install Rapid Photo Downloader') - parser.add_argument('tarfile', action='store', help="tar.gz Rapid Photo Downloader " - "installer archive") - args = parser.parse_args() - installer = args.tarfile # type: str - if not os.path.exists(installer): + if installer is None: + if have_requests is False: + print("Installing python requests") + command_line = make_pip_command( + 'install --user requests', split=False + ) + run_cmd(command_line, restart=True, interactive=args.interactive) + elif not os.path.exists(installer): print("Installer not found:", installer) - print("Include the name of the tar.gz Rapid Photo Downloader installer archive") sys.exit(1) elif not installer.endswith('.tar.gz'): print("Installer not in tar.gz format:", installer) sys.exit(1) - main(installer, distro, distro_version) - + main( + installer=installer, distro=distro, distro_family=distro_family, + distro_version=distro_version, interactive=args.interactive, devel=args.devel, + delete_install_script=args.delete_install_script, + delete_tar_and_dir=args.delete_tar_and_dir, force_this_version=args.force_this_version + ) -- cgit v1.2.3