From cec0c06e4f0820c7913d11bd43aef41938c2abdf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vin=C3=ADcius=20Moreira?= Date: Wed, 27 May 2020 11:51:24 -0300 Subject: [PATCH] [feature][arch] supporting multi-threaded download for repository packages --- CHANGELOG.md | 8 + README.md | 1 + bauh/api/abstract/download.py | 7 +- bauh/api/http.py | 5 +- bauh/commons/system.py | 5 + bauh/gems/arch/__init__.py | 1 + bauh/gems/arch/config.py | 3 +- bauh/gems/arch/confirmation.py | 5 +- bauh/gems/arch/controller.py | 92 +++++++++-- bauh/gems/arch/download.py | 155 ++++++++++++++++++ bauh/gems/arch/model.py | 3 +- bauh/gems/arch/output.py | 5 +- bauh/gems/arch/pacman.py | 48 ++++++ bauh/gems/arch/resources/locale/ca | 4 + bauh/gems/arch/resources/locale/de | 4 + bauh/gems/arch/resources/locale/en | 4 + bauh/gems/arch/resources/locale/es | 4 + bauh/gems/arch/resources/locale/it | 4 + bauh/gems/arch/resources/locale/pt | 5 +- bauh/gems/arch/resources/locale/ru | 4 + bauh/gems/arch/resources/locale/tr | 4 + bauh/view/core/downloader.py | 80 ++++++--- bauh/view/resources/locale/en | 2 +- pictures/releases/0.9.4/arch_repo_mthread.png | Bin 0 -> 16255 bytes 24 files changed, 402 insertions(+), 51 deletions(-) create mode 100644 bauh/gems/arch/download.py create mode 100644 pictures/releases/0.9.4/arch_repo_mthread.png diff --git a/CHANGELOG.md b/CHANGELOG.md index c26775b9..27aa801b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

+ +- Arch + - supporting multi-threaded download for repository packages (enabled by default) +

+ +

### Improvements @@ -36,6 +42,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - defining a minimum width and height based on the screen size - info window now has a height limit, a lower bar with Back and Close buttons, and is scrollable - minor changes +- Downloads + - retrieving the downloading file sizes asynchronously ### Fixes - Flatpak diff --git a/README.md b/README.md index e825d213..5b2f37dc 100644 --- a/README.md +++ b/README.md @@ -182,6 +182,7 @@ refresh_mirrors_startup: false # if the package mirrors should be refreshed duri mirrors_sort_limit: 5 # defines the maximum number of mirrors that will be used for speed sorting. Use 0 for no limit or leave it blank to disable sorting. aur: true # allows to manage AUR packages repositories: true # allows to manage packages from the configured repositories +repositories_mthread_download: true # enable multi-threaded download for repository packages if aria2 is installed ``` - Required dependencies: - **pacman** diff --git a/bauh/api/abstract/download.py b/bauh/api/abstract/download.py index 8d91e49d..5a665072 100644 --- a/bauh/api/abstract/download.py +++ b/bauh/api/abstract/download.py @@ -6,12 +6,17 @@ from bauh.api.abstract.handler import ProcessWatcher class FileDownloader(ABC): @abstractmethod - def download(self, file_url: str, watcher: ProcessWatcher, output_path: str, cwd: str) -> bool: + def download(self, file_url: str, watcher: ProcessWatcher, output_path: str, cwd: str, root_password: str = None, substatus_prefix: str = None, display_file_size: bool = True, max_threads: int = None, known_size: int = None) -> bool: """ :param file_url: :param watcher: :param output_path: the downloaded file output path. Leave None for the current directory and the same file name :param cwd: current working directory. Leave None if does not matter. + :param root_password: (if the output directory is protected) + :param substatus_prefix: custom substatus prefix ('prefix downloading xpto') + :param display_file_size: if the file size should be displayed on the substatus + :param max_threads: maximum number of threads (only available for multi-threaded download) + :param known_size: known file size :return: success / failure """ pass diff --git a/bauh/api/http.py b/bauh/api/http.py index 6da9b161..0d04e558 100644 --- a/bauh/api/http.py +++ b/bauh/api/http.py @@ -89,11 +89,12 @@ class HttpClient: if size: return system.get_human_size_str(size) - def exists(self, url: str, session: bool = True) -> bool: - params = {'url': url, 'allow_redirects': True, 'verify': False, 'timeout': 5} + def exists(self, url: str, session: bool = True, timeout: int = 5) -> bool: + params = {'url': url, 'allow_redirects': True, 'verify': False, 'timeout': timeout} if session: res = self.session.head(**params) else: res = self.session.get(**params) return res.status_code in (200, 403) + return False diff --git a/bauh/commons/system.py b/bauh/commons/system.py index 4d05ea45..5655ec7c 100644 --- a/bauh/commons/system.py +++ b/bauh/commons/system.py @@ -272,3 +272,8 @@ def get_human_size_str(size) -> str: if len(size_str.split('.')[0]) < 4: return '{0:.2f}'.format(float(size_str)) + ' ' + m[1] return str(int_size) + + +def run(cmd: List[str], success_code: int = 0) -> Tuple[bool, str]: + p = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + return p.returncode == success_code, p.stdout.decode() diff --git a/bauh/gems/arch/__init__.py b/bauh/gems/arch/__init__.py index 44820a2a..274fa271 100644 --- a/bauh/gems/arch/__init__.py +++ b/bauh/gems/arch/__init__.py @@ -6,6 +6,7 @@ from bauh.commons import resource ROOT_DIR = os.path.dirname(os.path.abspath(__file__)) BUILD_DIR = '{}/arch'.format(TEMP_DIR) +PACKAGE_CACHE_DIR = '{}/pkg_cache'.format(BUILD_DIR) ARCH_CACHE_PATH = CACHE_PATH + '/arch' CATEGORIES_FILE_PATH = ARCH_CACHE_PATH + '/categories.txt' URL_CATEGORIES_FILE = 'https://raw.githubusercontent.com/vinifmor/bauh-files/master/arch/categories.txt' diff --git a/bauh/gems/arch/config.py b/bauh/gems/arch/config.py index 86033cee..c40674d3 100644 --- a/bauh/gems/arch/config.py +++ b/bauh/gems/arch/config.py @@ -10,5 +10,6 @@ def read_config(update_file: bool = False) -> dict: 'repositories': True, "refresh_mirrors_startup": False, "sync_databases_startup": True, - 'mirrors_sort_limit': 5} + 'mirrors_sort_limit': 5, + 'repositories_mthread_download': True} return read(CONFIG_FILE, template, update_file=update_file) diff --git a/bauh/gems/arch/confirmation.py b/bauh/gems/arch/confirmation.py index f2cb0a5f..7965f276 100644 --- a/bauh/gems/arch/confirmation.py +++ b/bauh/gems/arch/confirmation.py @@ -44,7 +44,7 @@ def request_optional_deps(pkgname: str, pkg_repos: dict, watcher: ProcessWatcher return {o.value for o in view_opts.values} -def request_install_missing_deps(pkgname: str, deps: List[Tuple[str, str]], watcher: ProcessWatcher, i18n: I18n) -> bool: +def request_install_missing_deps(pkgname: str, deps: List[Tuple[str, str]], watcher: ProcessWatcher, i18n: I18n, context: "TransactionContext") -> bool: msg = '

{}

'.format(i18n['arch.missing_deps.body'].format(name=bold(pkgname) if pkgname else '', deps=bold(str(len(deps))))) opts = [] @@ -52,6 +52,9 @@ def request_install_missing_deps(pkgname: str, deps: List[Tuple[str, str]], watc repo_deps = [d[0] for d in deps if d[1] != 'aur'] sizes = pacman.get_update_size(repo_deps) if repo_deps else {} + if context and sizes: + context.pkg_sizes.update(sizes) + for dep in deps: size = sizes.get(dep[0]) op = InputOption('{} ({}: {}) - {}: {}'.format(dep[0], diff --git a/bauh/gems/arch/controller.py b/bauh/gems/arch/controller.py index 86dcb1f0..7b6bb9ca 100644 --- a/bauh/gems/arch/controller.py +++ b/bauh/gems/arch/controller.py @@ -35,6 +35,7 @@ from bauh.gems.arch import BUILD_DIR, aur, pacman, makepkg, message, confirmatio from bauh.gems.arch.aur import AURClient from bauh.gems.arch.config import read_config from bauh.gems.arch.dependencies import DependenciesAnalyser +from bauh.gems.arch.download import MultithreadedDownloadService, ArchDownloadException from bauh.gems.arch.exceptions import PackageNotFoundException from bauh.gems.arch.mapper import ArchDataMapper from bauh.gems.arch.model import ArchPackage @@ -62,7 +63,7 @@ class TransactionContext: install_file: str = None, repository: str = None, pkg: ArchPackage = None, remote_repo_map: Dict[str, str] = None, provided_map: Dict[str, Set[str]] = None, remote_provided_map: Dict[str, Set[str]] = None, aur_idx: Set[str] = None, - missing_deps: List[Tuple[str, str]] = None): + missing_deps: List[Tuple[str, str]] = None, pkg_sizes: Dict[str, int] = {}): self.name = name self.base = base self.maintainer = maintainer @@ -83,6 +84,7 @@ class TransactionContext: self.remote_provided_map = remote_provided_map self.aur_idx = aur_idx self.missing_deps = missing_deps + self.pkg_sizes = pkg_sizes @classmethod def gen_context_from(cls, pkg: ArchPackage, arch_config: dict, root_password: str, handler: ProcessHandler) -> "TransactionContext": @@ -747,13 +749,23 @@ class ArchManager(SoftwareManager): return files - def _upgrade_repo_pkgs(self, pkgs: List[str], handler: ProcessHandler, root_password: str, overwrite_files: bool = False, - status_handler: TransactionStatusHandler = None) -> bool: + def _upgrade_repo_pkgs(self, pkgs: List[str], handler: ProcessHandler, root_password: str, arch_config: dict, overwrite_files: bool = False, + status_handler: TransactionStatusHandler = None, already_downloaded: bool = False, sizes: Dict[str, int] = None) -> bool: + + downloaded = 0 + + if not already_downloaded: + if self._should_download_packages(arch_config): + try: + downloaded = self._download_packages(pkgs, handler, root_password, sizes) + except ArchDownloadException: + return False + try: if status_handler: output_handler = status_handler else: - output_handler = TransactionStatusHandler(handler.watcher, self.i18n, len(pkgs), self.logger) + output_handler = TransactionStatusHandler(handler.watcher, self.i18n, len(pkgs), self.logger, downloading=downloaded) output_handler.start() success, upgrade_output = handler.handle_simple(pacman.upgrade_several(pkgnames=pkgs, @@ -791,7 +803,10 @@ class ArchManager(SoftwareManager): handler=handler, root_password=root_password, overwrite_files=True, - status_handler=output_handler) + status_handler=output_handler, + already_downloaded=True, + arch_config=arch_config, + sizes=sizes) else: output_handler.stop_working() output_handler.join() @@ -819,7 +834,7 @@ class ArchManager(SoftwareManager): watcher.change_substatus('') return False - aur_pkgs, repo_pkgs = [], [] + aur_pkgs, repo_pkgs, pkg_sizes = [], [], {} for req in (*requirements.to_install, *requirements.to_upgrade): if req.pkg.repository == 'aur': @@ -827,6 +842,8 @@ class ArchManager(SoftwareManager): else: repo_pkgs.append(req.pkg) + pkg_sizes[req.pkg.name] = req.required_size + if aur_pkgs and not self._check_action_allowed(aur_pkgs[0], watcher): return False @@ -852,7 +869,11 @@ class ArchManager(SoftwareManager): self.logger.info("Upgrading {} repository packages: {}".format(len(repo_pkgs_names), ', '.join(repo_pkgs_names))) - if not self._upgrade_repo_pkgs(pkgs=repo_pkgs_names, handler=handler, root_password=root_password): + if not self._upgrade_repo_pkgs(pkgs=repo_pkgs_names, + handler=handler, + root_password=root_password, + arch_config=arch_config, + sizes=pkg_sizes): return False if aur_pkgs: @@ -1263,7 +1284,7 @@ class ArchManager(SoftwareManager): return True - def _install_deps(self, context: TransactionContext, deps: List[Tuple[str, str]]) -> Iterable[str]: + def _install_deps(self, context: TransactionContext, deps: List[Tuple[str, str]]) -> Iterable[str]: """ :param pkgs_repos: :param root_password: @@ -1305,7 +1326,15 @@ class ArchManager(SoftwareManager): if not self._request_conflict_resolution(dep, conflict_pkg , context): return {dep} - status_handler = TransactionStatusHandler(context.watcher, self.i18n, len(repo_dep_names), self.logger, percentage=len(repo_deps) > 1) + downloaded = 0 + if self._should_download_packages(context.config): + try: + downloaded = self._download_packages(repo_dep_names, context.handler, context.root_password, context.pkg_sizes) + except ArchDownloadException: + return False + + status_handler = TransactionStatusHandler(watcher=context.watcher, i18n=self.i18n, npkgs=len(repo_dep_names), + logger=self.logger, percentage=len(repo_deps) > 1, downloading=downloaded) status_handler.start() installed, _ = context.handler.handle_simple(pacman.install_as_process(pkgpaths=repo_dep_names, root_password=context.root_password, @@ -1426,7 +1455,7 @@ class ArchManager(SoftwareManager): def _ask_and_install_missing_deps(self, context: TransactionContext, missing_deps: List[Tuple[str, str]]) -> bool: context.watcher.change_substatus(self.i18n['arch.missing_deps_found'].format(bold(context.name))) - if not confirmation.request_install_missing_deps(context.name, missing_deps, context.watcher, self.i18n): + if not confirmation.request_install_missing_deps(context.name, missing_deps, context.watcher, self.i18n, context): context.watcher.print(self.i18n['action.cancelled']) return False @@ -1583,7 +1612,7 @@ class ArchManager(SoftwareManager): sorted_deps = sorting.sort(to_sort, {**deps_data, **subdeps_data}, provided_map) - if display_deps_dialog and not confirmation.request_install_missing_deps(None, sorted_deps, context.watcher, self.i18n): + if display_deps_dialog and not confirmation.request_install_missing_deps(None, sorted_deps, context.watcher, self.i18n, context): context.watcher.print(self.i18n['action.cancelled']) return True # because the main package installation was successful @@ -1597,7 +1626,21 @@ class ArchManager(SoftwareManager): return True - def _install(self, context: TransactionContext): + def _should_download_packages(self, arch_config: dict) -> bool: + return bool(arch_config['repositories_mthread_download']) and self.context.file_downloader.is_multithreaded() + + def _download_packages(self, pkgnames: List[str], handler: ProcessHandler, root_password: str, sizes: Dict[str, int] = None) -> int: + download_service = MultithreadedDownloadService(file_downloader=self.context.file_downloader, + http_client=self.http_client, + logger=self.context.logger, + i18n=self.i18n) + self.logger.info("Initializing multi-threaded download for {} package(s)".format(len(pkgnames))) + return download_service.download_packages(pkgs=pkgnames, + handler=handler, + sizes=sizes, + root_password=root_password) + + def _install(self, context: TransactionContext) -> bool: check_install_output = [] pkgpath = context.get_package_path() @@ -1635,7 +1678,6 @@ class ArchManager(SoftwareManager): else: self.logger.info("No conflict detected for '{}'".format(context.name)) - context.watcher.change_substatus(self.i18n['arch.installing.package'].format(bold(context.name))) self._update_progress(context, 80) to_install = [] @@ -1645,12 +1687,25 @@ class ArchManager(SoftwareManager): to_install.append(pkgpath) + downloaded = 0 + if self._should_download_packages(context.config): + to_download = [p for p in to_install if not p.startswith('/')] + + if to_download: + try: + downloaded = self._download_packages(to_download, context.handler, context.root_password, context.pkg_sizes) + except ArchDownloadException: + return False + if not context.dependency: - status_handler = TransactionStatusHandler(context.watcher, self.i18n, len(to_install), self.logger, percentage=len(to_install) > 1) if not context.dependency else None + status_handler = TransactionStatusHandler(context.watcher, self.i18n, len(to_install), self.logger, + percentage=len(to_install) > 1, + downloading=downloaded) if not context.dependency else None status_handler.start() else: status_handler = None + context.watcher.change_substatus(self.i18n['arch.installing.package'].format(bold(context.name))) installed, _ = context.handler.handle_simple(pacman.install_as_process(pkgpaths=to_install, root_password=context.root_password, file=context.has_install_file(), @@ -1848,7 +1903,7 @@ class ArchManager(SoftwareManager): context.missing_deps = missing_deps context.watcher.change_substatus(self.i18n['arch.missing_deps_found'].format(bold(context.name))) - if not confirmation.request_install_missing_deps(context.name, missing_deps, context.watcher, self.i18n): + if not confirmation.request_install_missing_deps(context.name, missing_deps, context.watcher, self.i18n, context): context.watcher.print(self.i18n['action.cancelled']) return False @@ -2043,6 +2098,12 @@ class ArchManager(SoftwareManager): tooltip_key='arch.config.optimize.tip', value=bool(local_config['optimize']), max_width=max_width), + self._gen_bool_selector(id_='mthread_download', + label_key='arch.config.pacman_mthread_download', + tooltip_key='arch.config.pacman_mthread_download.tip', + value=local_config['repositories_mthread_download'], + max_width=max_width, + capitalize_label=True), self._gen_bool_selector(id_='sync_dbs', label_key='arch.config.sync_dbs', tooltip_key='arch.config.sync_dbs.tip', @@ -2081,6 +2142,7 @@ class ArchManager(SoftwareManager): config['clean_cached'] = form_install.get_component('clean_cached').get_selected() config['refresh_mirrors_startup'] = form_install.get_component('ref_mirs').get_selected() config['mirrors_sort_limit'] = form_install.get_component('mirrors_sort_limit').get_int_value() + config['repositories_mthread_download'] = form_install.get_component('mthread_download').get_selected() try: save_config(config, CONFIG_FILE) diff --git a/bauh/gems/arch/download.py b/bauh/gems/arch/download.py new file mode 100644 index 00000000..889f9b2f --- /dev/null +++ b/bauh/gems/arch/download.py @@ -0,0 +1,155 @@ +import glob +import logging +import os +import time +import traceback +from math import floor +from typing import List, Iterable, Dict + +from bauh.api.abstract.download import FileDownloader +from bauh.api.abstract.handler import ProcessWatcher +from bauh.api.abstract.view import MessageType +from bauh.api.http import HttpClient +from bauh.commons.html import bold +from bauh.commons.system import ProcessHandler, SimpleProcess +from bauh.gems.arch import pacman +from bauh.view.util.translation import I18n + + +class ArchDownloadException(Exception): + pass + + +class CacheDirCreationException(ArchDownloadException): + pass + + +class MultiThreadedDownloader: + + def __init__(self, file_downloader: FileDownloader, http_client: HttpClient, mirrors_available: Iterable[str], + mirrors_branch: str, cache_dir: str, logger: logging.Logger): + self.downloader = file_downloader + self.http_client = http_client + self.mirrors = mirrors_available + self.branch = mirrors_branch + self.extensions = ['.tar.zst', '.tar.xz'] + self.cache_dir = cache_dir + self.logger = logger + + def download_package(self, pkg: Dict[str, str], root_password: str, substatus_prefix: str, watcher: ProcessWatcher, size: int) -> bool: + if self.mirrors and self.branch: + pkgname = '{}-{}{}.pkg'.format(pkg['n'], pkg['v'], ('-{}'.format(pkg['a']) if pkg['a'] else '')) + + if {f for f in glob.glob(self.cache_dir + '/*') if f.split('/')[-1].startswith(pkgname)}: + watcher.print("{} ({}) file found o cache dir {}. Skipping download.".format(pkg['n'], pkg['v'], self.cache_dir)) + return True + + arch = pkg['a'] if pkg.get('a') and pkg['a'] != 'any' else 'x86_64' + + url_base = '{}/{}/{}/{}'.format(self.branch, pkg['r'], arch, pkgname) + base_output_path = '{}/{}'.format(self.cache_dir, pkgname) + + for mirror in self.mirrors: + for ext in self.extensions: + url = '{}{}{}'.format(mirror, url_base, ext) + output_path = base_output_path + ext + + watcher.print("Downloading '{}' from mirror '{}'".format(pkgname, mirror)) + + pkg_downloaded = self.downloader.download(file_url=url, watcher=watcher, output_path=output_path, + cwd='.', root_password=root_password, display_file_size=True, + substatus_prefix=substatus_prefix, + known_size=size) + if not pkg_downloaded: + watcher.print("Could not download '{}' from mirror '{}'".format(pkgname, mirror)) + else: + self.logger.info("Package '{}' successfully downloaded".format(pkg['n'])) + self.logger.info("Downloading package '{}' signature".format(pkg['n'])) + + sig_downloaded = self.downloader.download(file_url=url + '.sig', watcher=watcher, + output_path=output_path + '.sig', + cwd='.', root_password=root_password, + display_file_size=False, + substatus_prefix=substatus_prefix, + max_threads=1) + + if not sig_downloaded: + self.logger.warning("Could not download package '{}' signature".format(pkg['n'])) + else: + self.logger.info("Package '{}' signature successfully downloaded".format(pkg['n'])) + return True + return False + + +class MultithreadedDownloadService: + + def __init__(self, file_downloader: FileDownloader, http_client: HttpClient, logger: logging.Logger, i18n: I18n): + self.file_downloader = file_downloader + self.http_client = http_client + self.logger = logger + self.i18n = i18n + + def download_packages(self, pkgs: List[str], handler: ProcessHandler, root_password: str, sizes: Dict[str, int] = None) -> int: + ti = time.time() + watcher = handler.watcher + mirrors = pacman.list_available_mirrors() + + if not mirrors: + self.logger.warning('repository mirrors seem to be not reachable') + watcher.print('[warning] repository mirrors seem to be not reachable') + watcher.print('[warning] multi-threaded download cancelled') + return 0 + + branch = pacman.get_mirrors_branch() + + if not branch: + self.logger.warning('no default repository branch found') + watcher.print('[warning] no default repository branch found') + watcher.print('[warning] multi-threaded download cancelled') + return 0 + + cache_dir = pacman.get_cache_dir() + + if not os.path.exists(cache_dir): + success, _ = handler.handle_simple(SimpleProcess(['mkdir', '-p', cache_dir], root_password=root_password)) + + if not success: + msg = "could not create cache dir '{}'".format(cache_dir) + self.logger.warning(msg) + watcher.print("[warning] {}".format(cache_dir)) + watcher.show_message(title=self.i18n['warning'].capitalize(), + body=self.i18n['arch.mthread_downloaded.error.cache_dir'].format(bold(cache_dir)), + type_=MessageType.WARNING) + raise CacheDirCreationException() + + downloader = MultiThreadedDownloader(file_downloader=self.file_downloader, + mirrors_available=mirrors, + mirrors_branch=branch, + http_client=self.http_client, + logger=self.logger, + cache_dir=cache_dir) + + downloaded = 0 + pkgs_data = pacman.list_download_data(pkgs) + + for pkg in pkgs_data: + self.logger.info('Preparing to download package: {} ({})'.format(pkg['n'], pkg['v'])) + try: + perc = '({0:.2f}%)'.format((downloaded / (2 * len(pkgs))) * 100) + status_prefix = '{} [{}/{}]'.format(perc, downloaded + 1, len(pkgs)) + + if downloader.download_package(pkg=pkg, + root_password=root_password, + watcher=handler.watcher, + substatus_prefix=status_prefix, + size=sizes.get(pkg['n']) if sizes else None): + downloaded += 1 + except: + traceback.print_exc() + watcher.show_message(title=self.i18n['error'].capitalize(), + body=self.i18n['arch.mthread_downloaded.error.cancelled'], + type_=MessageType.ERROR) + raise ArchDownloadException() + tf = time.time() + self.logger.info("Download time: {0:.2f} seconds".format(tf - ti)) + return downloaded diff --git a/bauh/gems/arch/model.py b/bauh/gems/arch/model.py index 410950c2..5e4a941d 100644 --- a/bauh/gems/arch/model.py +++ b/bauh/gems/arch/model.py @@ -16,7 +16,7 @@ class ArchPackage(SoftwarePackage): first_submitted: datetime.datetime = None, last_modified: datetime.datetime = None, maintainer: str = None, url_download: str = None, pkgbuild: str = None, repository: str = None, desktop_entry: str = None, installed: bool = False, srcinfo: dict = None, dependencies: Set[str] = None, - categories: List[str] = None, i18n: I18n = None, update_ignored: bool = False): + categories: List[str] = None, i18n: I18n = None, update_ignored: bool = False, arch: str = None): super(ArchPackage, self).__init__(name=name, version=version, latest_version=latest_version, description=description, installed=installed, categories=categories) @@ -35,6 +35,7 @@ class ArchPackage(SoftwarePackage): self.desktop_entry = desktop_entry self.src_info = srcinfo self.dependencies = dependencies + self.arch = arch self.i18n = i18n self.update_ignored = update_ignored diff --git a/bauh/gems/arch/output.py b/bauh/gems/arch/output.py index e52d3a44..6df509ad 100644 --- a/bauh/gems/arch/output.py +++ b/bauh/gems/arch/output.py @@ -8,12 +8,13 @@ from bauh.view.util.translation import I18n class TransactionStatusHandler(Thread): - def __init__(self, watcher: ProcessWatcher, i18n: I18n, npkgs: int, logger: logging.Logger, percentage: bool = True): + def __init__(self, watcher: ProcessWatcher, i18n: I18n, npkgs: int, logger: logging.Logger, + percentage: bool = True, downloading: int = 0): super(TransactionStatusHandler, self).__init__(daemon=True) self.watcher = watcher self.i18n = i18n self.npkgs = npkgs - self.downloading = 0 + self.downloading = downloading self.upgrading = 0 self.installing = 0 self.outputs = [] diff --git a/bauh/gems/arch/pacman.py b/bauh/gems/arch/pacman.py index 7d11e4d3..1eaf2d22 100644 --- a/bauh/gems/arch/pacman.py +++ b/bauh/gems/arch/pacman.py @@ -3,6 +3,7 @@ import re from threading import Thread from typing import List, Set, Tuple, Dict, Iterable +from bauh.commons import system from bauh.commons.system import run_cmd, new_subprocess, new_root_subprocess, SystemProcess, SimpleProcess, \ ProcessHandler from bauh.commons.util import size_to_byte @@ -16,6 +17,7 @@ RE_INSTALLED_FIELDS = re.compile(r'(Name|Description|Version|Validated By)\s*:\s RE_INSTALLED_SIZE = re.compile(r'Installed Size\s*:\s*([0-9,\.]+)\s(\w+)\n?', re.IGNORECASE) RE_UPDATE_REQUIRED_FIELDS = re.compile(r'(\bProvides\b|\bInstalled Size\b|\bConflicts With\b)\s*:\s(.+)\n') RE_REMOVE_TRANSITIVE_DEPS = re.compile(r'removing\s([\w\-_]+)\s.+required\sby\s([\w\-_]+)\n?') +RE_AVAILABLE_MIRRORS = re.compile(r'.+\s+OK\s+.+\s+(\d+:\d+)\s+.+(http.+)') def is_available() -> bool: @@ -599,6 +601,36 @@ def map_provided(remote: bool = False, pkgs: Iterable[str] = None) -> Dict[str, return provided_map +def list_download_data(pkgs: Iterable[str]) -> List[Dict[str, str]]: + _, output = system.run(['pacman', '-Si', *pkgs]) + + if output: + res = [] + data = {'a': None, 'v': None, 'r': None, 'n': None} + + for l in output.split('\n'): + if l: + if l[0] != ' ': + line = l.strip() + field_sep_idx = line.index(':') + field = line[0:field_sep_idx].strip() + val = line[field_sep_idx + 1:].strip() + + if field == 'Repository': + data['r'] = val + elif field == 'Name': + data['n'] = val + elif field == 'Version': + data['v'] = val.split('=')[0] + elif field == 'Architecture': + data['a'] = val + elif data.get('a'): + res.append(data) + data = {'a': None, 'v': None, 'r': None, 'n': None} + + return res + + def map_updates_data(pkgs: Iterable[str], files: bool = False) -> dict: if files: output = run_cmd('pacman -Qi -p {}'.format(' '.join(pkgs))) @@ -1021,3 +1053,19 @@ def list_installed_names() -> Set[str]: output = run_cmd('pacman -Qq', print_error=False) return {name.strip() for name in output.split('\n') if name} if output else set() + +def list_available_mirrors() -> List[str]: + _, output = system.run(['pacman-mirrors', '--status', '--no-color']) + + if output: + mirrors = RE_AVAILABLE_MIRRORS.findall(output) + + if mirrors: + mirrors.sort(key=lambda o: o[0]) + return [m[1] for m in mirrors] + + +def get_mirrors_branch() -> str: + _, output = system.run(['pacman-mirrors', '-G']) + return output.strip() + diff --git a/bauh/gems/arch/resources/locale/ca b/bauh/gems/arch/resources/locale/ca index 5cd03adb..3fc38bdd 100644 --- a/bauh/gems/arch/resources/locale/ca +++ b/bauh/gems/arch/resources/locale/ca @@ -28,6 +28,8 @@ arch.config.mirrors_sort_limit=Mirrors sort limit arch.config.mirrors_sort_limit.tip=Defines the maximum number of mirrors that will be used for speed sorting. Use 0 for no limit or leave it blank to disable sorting. arch.config.optimize=optimize arch.config.optimize.tip=La configuració optimitzada s'utilitzarà per fer més ràpida la instal·lació, actualització i reversió dels paquets, en cas contrari s'utilitzarà la configuració del sistema. +arch.config.pacman_mthread_download=Multithreaded download (repositories) +arch.config.pacman_mthread_download.tip=Whether the repository packages should be downloaded with a tool that works with threads (it can be faster). At the moment this setting will only work if the aria2 package is installed arch.config.refresh_mirrors=Refresh mirrors on startup arch.config.refresh_mirrors.tip=Refresh the package mirrors once a day on startup ( or after a device reboot ) arch.config.repos=Repositories packages @@ -153,6 +155,8 @@ arch.makepkg.optimizing=Optimitzant la recopilació arch.missing_deps.body=S’han d’instal·lar les {deps} dependències següents abans de continuar amb la instal·lació de {name} arch.missing_deps.title=Dependències mancants arch.missing_deps_found=Dependències mancants per a {} +arch.mthread_downloaded.error.cache_dir=It was not possible to create the cache directory {} +arch.mthread_downloaded.error.cancelled=Operation cancelled arch.optdeps.checking=S’estan comprovant les dependències opcionals de {} arch.providers=providers arch.substatus.conflicts=Checking for conflicts diff --git a/bauh/gems/arch/resources/locale/de b/bauh/gems/arch/resources/locale/de index 84b5f629..9f7ccfa7 100644 --- a/bauh/gems/arch/resources/locale/de +++ b/bauh/gems/arch/resources/locale/de @@ -28,6 +28,8 @@ arch.config.mirrors_sort_limit=Mirrors sort limit arch.config.mirrors_sort_limit.tip=Defines the maximum number of mirrors that will be used for speed sorting. Use 0 for no limit or leave it blank to disable sorting. arch.config.optimize=optimize arch.config.optimize.tip=Optimized settings will be used in order to make the packages installation, upgrading and downgrading faster, otherwise the system settings will be used +arch.config.pacman_mthread_download=Multithreaded download (repositories) +arch.config.pacman_mthread_download.tip=Whether the repository packages should be downloaded with a tool that works with threads (it can be faster). At the moment this setting will only work if the aria2 package is installed arch.config.refresh_mirrors=Refresh mirrors on startup arch.config.refresh_mirrors.tip=Refresh the package mirrors once a day on startup ( or after a device reboot ) arch.config.repos=Repositories packages @@ -153,6 +155,8 @@ arch.makepkg.optimizing=Optimiert die Zusammenstellung arch.missing_deps.body=Die folgenden {deps} Abhängigkeiten müssten installiert sein, bevor mit der {name} Installation fortgefahren werden kann arch.missing_deps.title=Fehlende Abhängigkeiten arch.missing_deps_found=Fehlende Abhängigkeiten für {} +arch.mthread_downloaded.error.cache_dir=It was not possible to create the cache directory {} +arch.mthread_downloaded.error.cancelled=Operation cancelled arch.optdeps.checking={} optionale Abhängigkeiten überprüfen arch.providers=providers arch.substatus.conflicts=Checking for conflicts diff --git a/bauh/gems/arch/resources/locale/en b/bauh/gems/arch/resources/locale/en index 6ea75a36..2f6abf95 100644 --- a/bauh/gems/arch/resources/locale/en +++ b/bauh/gems/arch/resources/locale/en @@ -28,6 +28,8 @@ arch.config.mirrors_sort_limit=Mirrors sort limit arch.config.mirrors_sort_limit.tip=Defines the maximum number of mirrors that will be used for speed sorting. Use 0 for no limit or leave it blank to disable sorting. arch.config.optimize=optimize arch.config.optimize.tip=Optimized settings will be used in order to make the packages installation, upgrading and downgrading faster, otherwise the system settings will be used +arch.config.pacman_mthread_download=Multi-threaded download (repositories) +arch.config.pacman_mthread_download.tip=Whether the repository packages should be downloaded with a tool that works with threads (it can be faster). At the moment this setting will only work if the aria2 package is installed arch.config.refresh_mirrors=Refresh mirrors on startup arch.config.refresh_mirrors.tip=Refresh the package mirrors once a day on startup ( or after a device reboot ) arch.config.repos=Repositories packages @@ -153,6 +155,8 @@ arch.makepkg.optimizing=Optimizing the compilation arch.missing_deps.body=The following {deps} dependencies must be installed so the {name} installation can continue arch.missing_deps.title=Missing dependencies arch.missing_deps_found=Missing dependencies for {} +arch.mthread_downloaded.error.cache_dir=It was not possible to create the cache directory {} +arch.mthread_downloaded.error.cancelled=Operation cancelled arch.optdeps.checking=Checking {} optional dependencies arch.providers=providers arch.substatus.conflicts=Checking for conflicts diff --git a/bauh/gems/arch/resources/locale/es b/bauh/gems/arch/resources/locale/es index c496269c..155f82a3 100644 --- a/bauh/gems/arch/resources/locale/es +++ b/bauh/gems/arch/resources/locale/es @@ -28,6 +28,8 @@ arch.config.mirrors_sort_limit=Límite de ordenación de espejos arch.config.mirrors_sort_limit.tip=Define el número máximo de espejos que se utilizarán para la ordenación por velocidad. Use 0 para no limitar o déjelo en blanco para deshabilitar la clasificación. arch.config.optimize=optimizar arch.config.optimize.tip=Se usará la configuración optimizada para que la instalación, actualización y reversión de los paquetes sean más rápidas, de lo contrario se usará la configuración del sistema +arch.config.pacman_mthread_download=Descarga segmentada (repositorios) +arch.config.pacman_mthread_download.tip=Si los paquetes de los repositorios deben descargarse con una herramienta que usa segmentación/threads (puede ser más rápido). Por el momento, esta configuración solo funcionará si el paquete aria2 esté instalado arch.config.refresh_mirrors=Actualizar espejos al iniciar arch.config.refresh_mirrors.tip=Actualiza los espejos de paquetes una vez al día al iniciar ( o después de reiniciar el dispositivo ) arch.config.repos=Paquetes de repositorios @@ -153,6 +155,8 @@ arch.makepkg.optimizing=Optimizing the compilation arch.missing_deps.body=Deben instalarse las siguientes {deps} dependencias para que la instalación de {name} pueda continuar arch.missing_deps.title=Dependencias faltantes arch.missing_deps_found=Dependencias faltantes para {} +arch.mthread_downloaded.error.cache_dir=No fue posible crear el directorio de caché {} +arch.mthread_downloaded.error.cancelled=Operación cancelada arch.optdeps.checking=Verificando las dependencias opcionales de {} arch.providers=proveedores arch.substatus.conflicts=Verificando conflictos diff --git a/bauh/gems/arch/resources/locale/it b/bauh/gems/arch/resources/locale/it index 4a2b4bf4..95c3ae33 100644 --- a/bauh/gems/arch/resources/locale/it +++ b/bauh/gems/arch/resources/locale/it @@ -28,6 +28,8 @@ arch.config.mirrors_sort_limit=Mirrors sort limit arch.config.mirrors_sort_limit.tip=Defines the maximum number of mirrors that will be used for speed sorting. Use 0 for no limit or leave it blank to disable sorting. arch.config.optimize=optimize arch.config.optimize.tip=Verranno utilizzate le impostazioni ottimizzate per velocizzare l'installazione, l'aggiornamento e l'inversione dei pacchetti, altrimenti verranno utilizzate le impostazioni di sistema +arch.config.pacman_mthread_download=Multithreaded download (repositories) +arch.config.pacman_mthread_download.tip=Whether the repository packages should be downloaded with a tool that works with threads (it can be faster). At the moment this setting will only work if the aria2 package is installed arch.config.refresh_mirrors=Refresh mirrors on startup arch.config.refresh_mirrors.tip=Refresh the package mirrors once a day on startup ( or after a device reboot ) arch.config.repos=Repositories packages @@ -153,6 +155,8 @@ arch.makepkg.optimizing=Ottimizzando la compilazione arch.missing_deps.body=Le seguenti {deps} dipendenze devono essere installate prima che l'installazione di {name} continui arch.missing_deps.title=Dipendenze mancanti arch.missing_deps_found=Dipendenze mancanti per {} +arch.mthread_downloaded.error.cache_dir=It was not possible to create the cache directory {} +arch.mthread_downloaded.error.cancelled=Operation cancelled arch.optdeps.checking=Verifica di {} dipendenze opzionali arch.providers=providers arch.substatus.conflicts=Checking for conflicts diff --git a/bauh/gems/arch/resources/locale/pt b/bauh/gems/arch/resources/locale/pt index 072f6db7..1d7b1824 100644 --- a/bauh/gems/arch/resources/locale/pt +++ b/bauh/gems/arch/resources/locale/pt @@ -27,7 +27,8 @@ arch.config.clean_cache.tip=Se versões antigas de um pacote armazenadas em disc arch.config.mirrors_sort_limit=Limite de ordenação de espelhos arch.config.mirrors_sort_limit.tip=Define o número máximo de espelhos que serão utilizados para a ordenação por velocidade. Use 0 para não limitar ou deixe em branco para desabilitar a ordenação. arch.config.optimize=Otimizar -arch.config.optimize.tip=Utilizará configurações otimizadas para que a instalação, atualização e reversão de pacotes sejam mais rápida, caso contrário utilizará a do sistema +arch.config.pacman_mthread_download=Download segmentado (repositórios) +arch.config.pacman_mthread_download.tip=Se os pacotes dos repositórios devem baixados através de uma ferramenta que trabalha com segmentação/threads (pode ser mais rápido). No momento esta propriedade somente funcionará se o pacote aria2 estiver instalado. arch.config.refresh_mirrors=Atualizar espelhos ao iniciar arch.config.refresh_mirrors.tip=Atualiza os espelhos de pacotes uma vez ao dia na inicialização arch.config.repos=Pacotes de repositórios @@ -153,6 +154,8 @@ arch.makepkg.optimizing=Otimizando a compilação arch.missing_deps.body=As seguintes {deps} dependências devem ser instaladas para que a instalação de {name} continue arch.missing_deps.title=Dependências ausentes arch.missing_deps_found=Dependencias ausentes para {} +arch.mthread_downloaded.error.cache_dir=Não foi possível criar o diretório para cache {} +arch.mthread_downloaded.error.cancelled=Operação cancelada arch.optdeps.checking=Verificando as dependências opcionais de {} arch.providers=provedores arch.substatus.conflicts=Verificando conflitos diff --git a/bauh/gems/arch/resources/locale/ru b/bauh/gems/arch/resources/locale/ru index 902ce41d..8c9f7ae1 100644 --- a/bauh/gems/arch/resources/locale/ru +++ b/bauh/gems/arch/resources/locale/ru @@ -28,6 +28,8 @@ arch.config.mirrors_sort_limit=Ограничение сортировки зе arch.config.mirrors_sort_limit.tip=Определяет максимальное количество зеркал, которые будут использоваться для сортировки по скорости. Используйте 0 для No limit или оставьте его пустым, чтобы отключить сортировку. arch.config.optimize=Оптимизация arch.config.optimize.tip=Оптимизированные настройки будут использоваться для ускорения установки пакетов, в противном случае будут использоваться системные настройки +arch.config.pacman_mthread_download=Multithreaded download (repositories) +arch.config.pacman_mthread_download.tip=Whether the repository packages should be downloaded with a tool that works with threads (it can be faster). At the moment this setting will only work if the aria2 package is installed arch.config.refresh_mirrors=Обновить зеркала при запуске arch.config.refresh_mirrors.tip=Обновляйте зеркала пакета один раз в день при запуске (или после перезагрузки устройства) arch.config.repos=Пакеты репозиториев @@ -153,6 +155,8 @@ arch.makepkg.optimizing=Оптимизация компиляции arch.missing_deps.body=Необходимо установить следующие зависимости , чтобы продолжить установку {name} arch.missing_deps.title=Отсутствующие зависимости arch.missing_deps_found=Отсутствуют зависимости для {} +arch.mthread_downloaded.error.cache_dir=It was not possible to create the cache directory {} +arch.mthread_downloaded.error.cancelled=Operation cancelled arch.optdeps.checking=Проверка необязательных обязательных зависимостей {} arch.providers=providers arch.substatus.conflicts=Checking for conflicts diff --git a/bauh/gems/arch/resources/locale/tr b/bauh/gems/arch/resources/locale/tr index e13a8fba..7139d3f3 100644 --- a/bauh/gems/arch/resources/locale/tr +++ b/bauh/gems/arch/resources/locale/tr @@ -28,6 +28,8 @@ arch.config.mirrors_sort_limit=Yansı sıralama sınırı arch.config.mirrors_sort_limit.tip=Hız sıralama için kullanılacak maksimum yansı sayısını tanımlar. Sınırsız olması için 0 kullanın veya sıralamayı devre dışı bırakmak için boş bırakın. arch.config.optimize=optimize arch.config.optimize.tip=Paketlerin kurulumunu, yükseltilmesini ve indirilmesini hızlandırmak için optimize edilmiş ayarlar kullanılacak, aksi takdirde sistem ayarları kullanılacak +arch.config.pacman_mthread_download=Multithreaded download (repositories) +arch.config.pacman_mthread_download.tip=Whether the repository packages should be downloaded with a tool that works with threads (it can be faster). At the moment this setting will only work if the aria2 package is installed arch.config.refresh_mirrors=Başlangıçta yansıları yenile arch.config.refresh_mirrors.tip=Paket yansılarını başlangıçta günde bir kez (veya cihaz yeniden başlatıldıktan sonra) yenileyin arch.config.repos=Depo paketleri @@ -153,6 +155,8 @@ arch.makepkg.optimizing=Derlemeyi optimize et arch.missing_deps.body={name} kurulumunun devam edebilmesi için aşağıdaki {deps} bağımlılık kurulmalıdır arch.missing_deps.title=Eksik bağımlılıklar arch.missing_deps_found={} için eksik bağımlılıklar +arch.mthread_downloaded.error.cache_dir=It was not possible to create the cache directory {} +arch.mthread_downloaded.error.cancelled=Operation cancelled arch.optdeps.checking={} İsteğe bağlı bağımlılıkları kontrol et arch.providers=sağlayıcılar arch.substatus.conflicts=Checking for conflicts diff --git a/bauh/view/core/downloader.py b/bauh/view/core/downloader.py index ed31827d..1dfbf7b0 100644 --- a/bauh/view/core/downloader.py +++ b/bauh/view/core/downloader.py @@ -3,12 +3,14 @@ import os import re import time import traceback +from math import floor +from threading import Thread from bauh.api.abstract.download import FileDownloader from bauh.api.abstract.handler import ProcessWatcher from bauh.api.http import HttpClient from bauh.commons.html import bold -from bauh.commons.system import run_cmd, new_subprocess, ProcessHandler, SystemProcess, SimpleProcess +from bauh.commons.system import run_cmd, ProcessHandler, SimpleProcess, get_human_size_str from bauh.view.util.translation import I18n RE_HAS_EXTENSION = re.compile(r'.+\.\w+$') @@ -25,11 +27,21 @@ class AdaptableFileDownloader(FileDownloader): def is_aria2c_available(self) -> bool: return bool(run_cmd('which aria2c', print_error=False)) - def _get_aria2c_process(self, url: str, output_path: str, cwd: str) -> SystemProcess: + def _get_aria2c_process(self, url: str, output_path: str, cwd: str, root_password: str, max_threads: int, known_size: int) -> SimpleProcess: + + if max_threads and max_threads > 0: + threads = max_threads + elif known_size: + threads = 16 if known_size >= 16000000 else floor(known_size / 1000000) + + if threads <= 0: + threads = 1 + else: + threads = 16 + cmd = ['aria2c', url, '--no-conf', - '--max-connection-per-server=16', - '--split=16', + '--max-connection-per-server={}'.format(threads), '--enable-color=false', '--stderr=true', '--summary-interval=0', @@ -39,36 +51,46 @@ class AdaptableFileDownloader(FileDownloader): '--continue=true', '--timeout=5', '--max-file-not-found=3', + '--file-allocation=falloc', '--remote-time=true'] + if threads > 1: + cmd.append('--split={}'.format(threads)) + if output_path: output_split = output_path.split('/') cmd.append('--dir=' + '/'.join(output_split[:-1])) cmd.append('--out=' + output_split[-1]) - return SystemProcess(new_subprocess(cmd=cmd, cwd=cwd), - skip_stdout=True, - check_error_output=False, - success_phrases=['download completed'], - output_delay=0.001) + return SimpleProcess(cmd=cmd, root_password=root_password, cwd=cwd) - def _get_wget_process(self, url: str, output_path: str, cwd: str) -> SimpleProcess: + def _get_wget_process(self, url: str, output_path: str, cwd: str, root_password: str) -> SimpleProcess: cmd = ['wget', url, '--continue', '--retry-connrefused', '--tries=10', '--no-config'] if output_path: cmd.append('-O') cmd.append(output_path) - return SimpleProcess(cmd=cmd, cwd=cwd) + return SimpleProcess(cmd=cmd, cwd=cwd, root_password=root_password) - def _rm_bad_file(self, file_name: str, output_path: str, cwd): + def _rm_bad_file(self, file_name: str, output_path: str, cwd, handler: ProcessHandler, root_password: str): to_delete = output_path if output_path else '{}/{}'.format(cwd, file_name) if to_delete and os.path.exists(to_delete): self.logger.info('Removing downloaded file {}'.format(to_delete)) - os.remove(to_delete) + success, _ = handler.handle_simple(SimpleProcess(['rm', '-rf',to_delete], root_password=root_password)) + return success - def download(self, file_url: str, watcher: ProcessWatcher, output_path: str = None, cwd: str = None) -> bool: + def _display_file_size(self, file_url: str, base_substatus, watcher: ProcessWatcher): + try: + size = self.http_client.get_content_length(file_url) + + if size: + watcher.change_substatus(base_substatus + ' ( {} )'.format(size)) + except: + pass + + def download(self, file_url: str, watcher: ProcessWatcher, output_path: str = None, cwd: str = None, root_password: str = None, substatus_prefix: str = None, display_file_size: bool = True, max_threads: int = None, known_size: int = None) -> bool: self.logger.info('Downloading {}'.format(file_url)) handler = ProcessHandler(watcher) file_name = file_url.split('/')[-1] @@ -85,39 +107,45 @@ class AdaptableFileDownloader(FileDownloader): if self.is_multithreaded(): ti = time.time() - process = self._get_aria2c_process(file_url, output_path, final_cwd) - downloader = 'aria2c' + process = self._get_aria2c_process(file_url, output_path, final_cwd, root_password, max_threads, known_size) + downloader = 'aria2' else: ti = time.time() - process = self._get_wget_process(file_url, output_path, final_cwd) + process = self._get_wget_process(file_url, output_path, final_cwd, root_password) downloader = 'wget' - file_size = self.http_client.get_content_length(file_url) - name = file_url.split('/')[-1] if output_path and not RE_HAS_EXTENSION.match(name) and RE_HAS_EXTENSION.match(output_path): name = output_path.split('/')[-1] - msg = bold('[{}] ').format(downloader) + self.i18n['downloading'] + ' ' + bold(name) + (' ( {} )'.format(file_size) if file_size else '') + if substatus_prefix: + msg = substatus_prefix + ' ' + else: + msg = '' + + msg += bold('[{}] ').format(downloader) + self.i18n['downloading'] + ' ' + bold(name) if watcher: watcher.change_substatus(msg) - if isinstance(process, SimpleProcess): - success = handler.handle_simple(process) - else: - success = handler.handle(process) + if display_file_size: + if known_size: + watcher.change_substatus(msg + ' ( {} )'.format(get_human_size_str(known_size))) + else: + Thread(target=self._display_file_size, args=(file_url, msg, watcher)).start() + + success, _ = handler.handle_simple(process) except: traceback.print_exc() - self._rm_bad_file(file_name, output_path, final_cwd) + self._rm_bad_file(file_name, output_path, final_cwd, handler, root_password) tf = time.time() self.logger.info(file_name + ' download took {0:.2f} minutes'.format((tf - ti) / 60)) if not success: self.logger.error("Could not download '{}'".format(file_name)) - self._rm_bad_file(file_name, output_path, final_cwd) + self._rm_bad_file(file_name, output_path, final_cwd, handler, root_password) return success diff --git a/bauh/view/resources/locale/en b/bauh/view/resources/locale/en index d96ecd7c..36ab76f8 100644 --- a/bauh/view/resources/locale/en +++ b/bauh/view/resources/locale/en @@ -191,7 +191,7 @@ core.config.backup=Enabled core.config.download.icons.tip=If the application icons should be downloaded and displayed on the table core.config.download.icons=Download icons core.config.download.multithreaded.tip=Whether applications, packages and files should be downloaded with a tool that works with threads ( faster ). At the moment this setting will only work if the aria2 package is installed -core.config.download.multithreaded=Multithreaded download +core.config.download.multithreaded=Multi-threaded download core.config.downloads=downloads core.config.locale.label=language core.config.mem_cache.data_exp.tip=Defines the in-memory data lifetime ( in SECONDS ) diff --git a/pictures/releases/0.9.4/arch_repo_mthread.png b/pictures/releases/0.9.4/arch_repo_mthread.png new file mode 100644 index 0000000000000000000000000000000000000000..503211ffdcb4c29a4a23ed764919e7a4d07182a6 GIT binary patch literal 16255 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