diff --git a/CHANGELOG.md b/CHANGELOG.md index ee298e32..266c6266 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,49 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## [0.7.1] 2019-10-25 +### Features +- Snap: + - if the **stable** channel is not available while an application is being installed, a popup is displayed allowing the user to choose a different one ( e.g: dbeaver-ce ) + +### Improvements +- 3 password attempts for root authentication +- not changing the table applied filters after a uninstall +- cleaning the progress bar substatus after each upgrade +- sorted categories +- AppImage: + - showing an error popup when **AppImageLauncher** messes up with an application installation +- Flatpak: + - Runtimes now are categorized as "runtime" + - Formatting the API categories to the same format provided by the other packaging technologies +- AUR: + - showing a "user-friendly" popup when there are integrity issues with the source-files of a building package + - not waiting for the categories file to be retrieved from the cloud during application boot ( reduces boot time ) + - caching cloud categories to the disk so they can be used in scenarios when it is not possible to retrieve them ( e.g: internet is off ) + - mapping known search key words to the specific package name ( e.g:"google chrome" will become "google-chrome" ) +- Snap: + - not waiting for the categories file to be retrieved from the cloud during application boot ( reduces boot time ) + - caching cloud categories to the disk so they can be used in scenarios when it is not possible to retrieve them ( e.g: internet is off ) + - showing a warning popup when the Snap API is out + - Snaps not treated as applications with be categorized as "runtime" at least +- minor thread improvements + +### UI +- Screenshots panel: + - "downloading" label replaced by a progress bar + +### Fixes +- application not initializing when there is no internet connection +- not loading application icons after some filters are applied to the table results +- not reloading the available categories after asynchronous data is fetched +- not keeping the update toggle-button state after a filter is applied +- AUR: + - update-checking for some scenarios + - not respecting **ignorepkg** settings in **pacman.conf** + - not able to handle **missing dependencies with symbols** ( e.g: libpng++ ) + - not able to work with **.xpm** icons + - not mapping categories to the search results + ## [0.7.0] 2019-10-18 ### Features - AppImage support ( see below ) @@ -34,7 +77,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### AppImage support - Search, install, uninstall, downgrade, launch and retrieve the applications history - Supported sources: [AppImageHub](https://appimage.github.io) ( **applications with no releases published to GitHub are currently not available** ) -- Adds desktop entries ( menu shortcuts ) for the installed applications ( **~/.local/share/applications **) +- Adds desktop entries ( menu shortcuts ) for the installed applications ( **~/.local/share/applications**) ## [0.6.4] 2019-10-13 ### Fixes diff --git a/MANIFEST.in b/MANIFEST.in index e2d034a9..7df4aa32 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -2,3 +2,4 @@ include LICENSE CHANGELOG.md README.md include requirements.txt recursive-include bauh/view/resources * recursive-include bauh/gems/*/resources * +recursive-include tests * diff --git a/aur/bauh-tray b/aur/bauh-tray new file mode 100644 index 00000000..c42e0db0 --- /dev/null +++ b/aur/bauh-tray @@ -0,0 +1,2 @@ +#!/bin/bash +/usr/bin/bauh --tray=1 diff --git a/aur/bauh.desktop b/aur/bauh.desktop new file mode 100644 index 00000000..d4cabde6 --- /dev/null +++ b/aur/bauh.desktop @@ -0,0 +1,9 @@ +[Desktop Entry] +Type=Application +Name=bauh +Categories=System; +Comment=Manage your Flatpak / Snap / AppImage / AUR applications +Comment[pt]=Gerencie seus aplicativos Flatpak / Snap / AppImage / AUR +Comment[es]=Administre sus aplicaciones Flatpak / Snap / AppImage / AUR +Exec=/usr/bin/bauh +Icon=bauh \ No newline at end of file diff --git a/aur/bauh_tray.desktop b/aur/bauh_tray.desktop new file mode 100644 index 00000000..3b7abd19 --- /dev/null +++ b/aur/bauh_tray.desktop @@ -0,0 +1,11 @@ +[Desktop Entry] +Type=Application +Name=bauh ( tray ) +Name[pt]=bauh ( bandeja ) +Name[es]=bauh ( bandeja ) +Categories=System; +Comment=Manage your Flatpak / Snap / AppImage / AUR applications +Comment[pt]=Gerencie seus aplicativos Flatpak / Snap / AppImage / AUR +Comment[es]=Administre sus aplicaciones Flatpak / Snap / AppImage / AUR +Exec=/usr/bin/bauh --tray=1 +Icon=bauh \ No newline at end of file diff --git a/aur/copy_icons.py b/aur/copy_icons.py new file mode 100644 index 00000000..6c684f71 --- /dev/null +++ b/aur/copy_icons.py @@ -0,0 +1,14 @@ +import glob +import shutil +import sys +from pathlib import Path + +output_base = sys.argv[2] + +for f in glob.glob(sys.argv[1] + '/*.png'): + res = f.split('/')[-1].split('.')[0] + dest_dir = output_base + '/' + res + '/apps' + + Path(dest_dir).mkdir(parents=True, exist_ok=True) + + shutil.copy(f, dest_dir + '/bauh.png') diff --git a/aur/icons/128x128.png b/aur/icons/128x128.png new file mode 100644 index 00000000..53088ba3 Binary files /dev/null and b/aur/icons/128x128.png differ diff --git a/aur/icons/12x12.png b/aur/icons/12x12.png new file mode 100644 index 00000000..8502307c Binary files /dev/null and b/aur/icons/12x12.png differ diff --git a/aur/icons/16x16.png b/aur/icons/16x16.png new file mode 100644 index 00000000..b8b476ca Binary files /dev/null and b/aur/icons/16x16.png differ diff --git a/aur/icons/24x24.png b/aur/icons/24x24.png new file mode 100644 index 00000000..2f4eb525 Binary files /dev/null and b/aur/icons/24x24.png differ diff --git a/aur/icons/256x256.png b/aur/icons/256x256.png new file mode 100644 index 00000000..df7936d7 Binary files /dev/null and b/aur/icons/256x256.png differ diff --git a/aur/icons/32x32.png b/aur/icons/32x32.png new file mode 100644 index 00000000..c84ecae9 Binary files /dev/null and b/aur/icons/32x32.png differ diff --git a/aur/icons/48x48.png b/aur/icons/48x48.png new file mode 100644 index 00000000..0d80ac7a Binary files /dev/null and b/aur/icons/48x48.png differ diff --git a/aur/icons/512x512.png b/aur/icons/512x512.png new file mode 100644 index 00000000..38b946cd Binary files /dev/null and b/aur/icons/512x512.png differ diff --git a/aur/icons/64x64.png b/aur/icons/64x64.png new file mode 100644 index 00000000..01de69e7 Binary files /dev/null and b/aur/icons/64x64.png differ diff --git a/aur/icons/8x8.png b/aur/icons/8x8.png new file mode 100644 index 00000000..e73905c5 Binary files /dev/null and b/aur/icons/8x8.png differ diff --git a/aur/icons/96x96.png b/aur/icons/96x96.png new file mode 100644 index 00000000..9de5638d Binary files /dev/null and b/aur/icons/96x96.png differ diff --git a/aur/panel_entry.py b/aur/panel_entry.py deleted file mode 100644 index 3a37336d..00000000 --- a/aur/panel_entry.py +++ /dev/null @@ -1,24 +0,0 @@ -# Generates a .desktop file based on the current python version. Used for AUR installation -import os -import sys - -desktop_file = """ -[Desktop Entry] -Type=Application -Name=bauh -Categories=System; -Comment=Manage your Flatpak / Snap / AppImage / AUR applications -Comment[pt]=Gerencie seus aplicativos Flatpak / Snap / AppImage / AUR -Comment[es]=Administre sus aplicaciones Flatpak / Snap / AppImage / AUR -Exec = {path} -Icon = {lib_path}/python{version}/site-packages/bauh/view/resources/img/logo.svg -""" - -py_version = "{}.{}".format(sys.version_info.major, sys.version_info.minor) - -app_cmd = os.getenv('BAUH_PATH', '/usr/bin/bauh') - -with open('bauh.desktop', 'w+') as f: - f.write(desktop_file.format(lib_path=os.getenv('BAUH_LIB_PATH', '/usr/lib'), - version=py_version, - path=app_cmd)) diff --git a/aur/tray_entry.py b/aur/tray_entry.py deleted file mode 100644 index 109bc597..00000000 --- a/aur/tray_entry.py +++ /dev/null @@ -1,31 +0,0 @@ -# Generates a .desktop file based on the current python version. Used for AUR installation -import os -import sys - - -desktop_file = """ -[Desktop Entry] -Type = Application -Name = bauh ( tray ) -Name[pt] = bauh ( bandeja ) -Name[es] = bauh ( bandeja ) -Categories = System; -Comment=Manage your Flatpak / Snap / AppImage / AUR applications -Comment[pt]=Gerencie seus aplicativos Flatpak / Snap / AppImage / AUR -Comment[es]=Administre sus aplicaciones Flatpak / Snap / AppImage / AUR -Exec = {path} -Icon = {lib_path}/python{version}/site-packages/bauh/view/resources/img/logo.svg -""" - -py_version = "{}.{}".format(sys.version_info.major, sys.version_info.minor) - -app_cmd = os.getenv('BAUH_PATH', '/usr/bin/bauh') + ' --tray=1' - -with open('bauh_tray.desktop', 'w+') as f: - f.write(desktop_file.format(lib_path=os.getenv('BAUH_LIB_PATH', '/usr/lib'), - version=py_version, - path=app_cmd)) - - -with open('bauh-tray', 'w') as f: - f.write(app_cmd) diff --git a/bauh/__init__.py b/bauh/__init__.py index e1ab2566..3fdb7b7e 100644 --- a/bauh/__init__.py +++ b/bauh/__init__.py @@ -1,4 +1,4 @@ -__version__ = '0.7.0' +__version__ = '0.7.1' __app_name__ = 'bauh' import os diff --git a/bauh/api/abstract/controller.py b/bauh/api/abstract/controller.py index 2a00373a..41d9abd1 100644 --- a/bauh/api/abstract/controller.py +++ b/bauh/api/abstract/controller.py @@ -213,8 +213,9 @@ class SoftwareManager(ABC): pass @abstractmethod - def list_warnings(self) -> List[str]: + def list_warnings(self, internet_available: bool) -> List[str]: """ + :param internet_available :return: a list of warnings to be shown to the user """ pass diff --git a/bauh/api/abstract/view.py b/bauh/api/abstract/view.py index 64136a4f..3caf1dc1 100644 --- a/bauh/api/abstract/view.py +++ b/bauh/api/abstract/view.py @@ -59,9 +59,6 @@ class SingleSelectComponent(InputViewComponent): def __init__(self, type_: SelectViewType, label: str, options: List[InputOption], default_option: InputOption = None, max_per_line: int = 1, id_: str = None): super(SingleSelectComponent, self).__init__(id_=id_) - if options is None or len(options) < 2: - raise Exception("'options' must be a list with at least 2 elements") - self.type = type_ self.label = label self.options = options diff --git a/bauh/commons/category.py b/bauh/commons/category.py new file mode 100644 index 00000000..bbf4bc29 --- /dev/null +++ b/bauh/commons/category.py @@ -0,0 +1,108 @@ +import logging +import os +import traceback +from pathlib import Path +from threading import Thread +from typing import Dict, List + +import requests + +from bauh.api.abstract.controller import SoftwareManager +from bauh.api.http import HttpClient + + +class CategoriesDownloader(Thread): + + def __init__(self, id_: str, http_client: HttpClient, logger: logging.Logger, manager: SoftwareManager, + disk_cache: bool, url_categories_file: str, disk_cache_dir: str, categories_path: str): + super(CategoriesDownloader, self).__init__(daemon=True) + self.id_ = id_ + self.http_client = http_client + self.logger = logger + self.manager = manager + self.disk_cache = disk_cache + self.url_categories_file = url_categories_file + self.disk_cache_dir = disk_cache_dir + self.categories_path = categories_path + + def _msg(self, msg: str): + return '{}({}): {}'.format(self.__class__.__name__, self.id_, msg) + + def _read_categories_from_disk(self) -> Dict[str, List[str]]: + if self.disk_cache and os.path.exists(self.categories_path): + self.logger.info(self._msg("Reading cached categories from the disk")) + + with open(self.categories_path) as f: + categories = f.read() + + return self._map_categories(categories) + + return {} + + def _map_categories(self, categories: str) -> Dict[str, List[str]]: + categories_map = {} + for l in categories.split('\n'): + if l: + data = l.split('=') + categories_map[data[0]] = [c.strip() for c in data[1].split(',') if c] + + return categories_map + + def _cache_categories_to_disk(self, categories: str): + self.logger.info(self._msg('Caching categories to the disk')) + + try: + Path(self.disk_cache_dir).mkdir(parents=True, exist_ok=True) + + with open(self.categories_path, 'w+') as f: + f.write(categories) + + self.logger.info(self._msg("Categories cached to the disk as '{}'".format(self.categories_path))) + except: + self.logger.error(self._msg("Could not cache categories to the disk as '{}'".format(self.categories_path))) + traceback.print_exc() + + def download_categories(self) -> Dict[str, List[str]]: + self.logger.info(self._msg('Downloading category definitions from {}'.format(self.url_categories_file))) + + try: + res = self.http_client.get(self.url_categories_file) + + if res: + try: + categories = self._map_categories(res.text) + self.logger.info(self._msg('Loaded categories for {} applications'.format(len(categories)))) + + if self.disk_cache and categories: + Thread(target=self._cache_categories_to_disk, args=(res.text,), daemon=True).start() + + return categories + except: + self.logger.error(self._msg("Could not parse categories definitions")) + traceback.print_exc() + else: + self.logger.info(self._msg('Could not download {}'.format(self.url_categories_file))) + + except requests.exceptions.ConnectionError: + self.logger.warning(self._msg('The internet connection seems to be off.')) + + return {} + + def _set_categories(self, categories: dict): + if categories: + self.logger.info(self._msg("Settings {} categories to {}".format(len(categories), self.manager.__class__.__name__))) + self.manager.categories = categories + + def _download_and_set(self): + self._set_categories(self.download_categories()) + + def run(self): + cached = self._read_categories_from_disk() + + if cached: + self._set_categories(cached) + Thread(target=self._download_and_set, daemon=True).start() + else: + self._download_and_set() + + self.logger.info(self._msg('Finished')) diff --git a/bauh/commons/system.py b/bauh/commons/system.py index ab37903f..6a555fbb 100644 --- a/bauh/commons/system.py +++ b/bauh/commons/system.py @@ -100,7 +100,7 @@ class ProcessHandler: if self.watcher: self.watcher.print(msg) - def handle(self, process: SystemProcess) -> bool: + def handle(self, process: SystemProcess, error_output: StringIO = None) -> bool: self._notify_watcher(' '.join(process.subproc.args) + '\n') already_succeeded = False @@ -126,6 +126,9 @@ class ProcessHandler: if line: self._notify_watcher(line) + if error_output is not None: + error_output.write(line) + if process.check_error_output: if process.wrong_error_phrase and process.wrong_error_phrase in line: continue diff --git a/bauh/gems/appimage/controller.py b/bauh/gems/appimage/controller.py index 51ba3827..1f5ab7f9 100644 --- a/bauh/gems/appimage/controller.py +++ b/bauh/gems/appimage/controller.py @@ -17,7 +17,7 @@ from bauh.api.abstract.model import SoftwarePackage, PackageHistory, PackageUpda from bauh.api.abstract.view import MessageType from bauh.api.constants import HOME_PATH from bauh.commons.html import bold -from bauh.commons.system import SystemProcess, new_subprocess, ProcessHandler, run_cmd +from bauh.commons.system import SystemProcess, new_subprocess, ProcessHandler, run_cmd, SimpleProcess from bauh.gems.appimage import query, INSTALLATION_PATH, suggestions, db from bauh.gems.appimage.model import AppImage from bauh.gems.appimage.worker import DatabaseUpdater @@ -298,12 +298,20 @@ class AppImageManager(SoftwareManager): watcher.change_substatus(self.i18n['appimage.install.extract'].format(bold(file_name))) try: - handler.handle(SystemProcess(new_subprocess([file_path, '--appimage-extract'], cwd=out_dir))) + res, output = handler.handle_simple(SimpleProcess([file_path, '--appimage-extract'], cwd=out_dir)) + + if 'Error: Failed to register AppImage in AppImageLauncherFS' in output: + watcher.show_message(title=self.i18n['error'], + body=self.i18n['appimage.install.appimagelauncher.error'].format(appimgl=bold('AppImageLauncher'), app=bold(pkg.name)), + type_=MessageType.ERROR) + handler.handle(SystemProcess(new_subprocess(['rm', '-rf', out_dir]))) + return False except: watcher.show_message(title=self.i18n['error'], body=traceback.format_exc(), type_=MessageType.ERROR) traceback.print_exc() + handler.handle(SystemProcess(new_subprocess(['rm', '-rf', out_dir]))) return False watcher.change_substatus(self.i18n['appimage.install.desktop_entry']) @@ -388,7 +396,7 @@ class AppImageManager(SoftwareManager): return updates - def list_warnings(self) -> List[str]: + def list_warnings(self, internet_available: bool) -> List[str]: pass def list_suggestions(self, limit: int) -> List[PackageSuggestion]: diff --git a/bauh/gems/appimage/resources/locale/en b/bauh/gems/appimage/resources/locale/en index e8a04789..0a4c5a07 100644 --- a/bauh/gems/appimage/resources/locale/en +++ b/bauh/gems/appimage/resources/locale/en @@ -15,4 +15,5 @@ appimage.downgrade.unknown_version.body=It was not possible to identify {} curre appimage.downgrade.first_version={} is in its first published version appimage.error.uninstall_current_version=It was not possible to uninstall the current version of {} appimage.downgrade.install_version=It was not possible to install the version {} ({}) -appimage.install.download.error=It was not possible to download the file {}. The file server can be down. \ No newline at end of file +appimage.install.download.error=It was not possible to download the file {}. The file server can be down. +appimage.install.appimagelauncher.error={appimgl} is not allowing {app} to be installed. Uninstall {appimgl}, reboot your system and try to install {app} again. \ No newline at end of file diff --git a/bauh/gems/appimage/resources/locale/es b/bauh/gems/appimage/resources/locale/es index ab80dbbe..932e3fa1 100644 --- a/bauh/gems/appimage/resources/locale/es +++ b/bauh/gems/appimage/resources/locale/es @@ -15,4 +15,5 @@ appimage.downgrade.unknown_version.body=No fue posible identificar la versión a appimage.downgrade.first_version={} está en su primera versión publicada appimage.downgrade.uninstall_current_version=No fue posible desinstalar la versión actual de {} appimage.downgrade.install_version=No fue posible instalar la versión {} ({}) -appimage.install.download.error=No fue posible descargar el archivo {}. El servidor del archivo puede estar inactivo. \ No newline at end of file +appimage.install.download.error=No fue posible descargar el archivo {}. El servidor del archivo puede estar inactivo. +appimage.install.appimagelauncher.error = {appimgl} no permite la instalación de {app}. Desinstale {appimgl}, reinicie su sistema e intente instalar {app} nuevamente. \ No newline at end of file diff --git a/bauh/gems/appimage/resources/locale/pt b/bauh/gems/appimage/resources/locale/pt index 1f41b7dc..43ae6917 100644 --- a/bauh/gems/appimage/resources/locale/pt +++ b/bauh/gems/appimage/resources/locale/pt @@ -15,4 +15,5 @@ appimage.downgrade.unknown_version.body=Não foi possível identificar a versão appimage.downgrade.first_version={} se encontra em sua primeira versão publicada appimage.downgrade.uninstall_current_version=Não foi possível desinstalar a versão atual de {} appimage.downgrade.install_version=Não foi possivel instalar a versão {} ({}) -appimage.install.download.error=Não foi possível baixar o arquivo {}. O servidor do arquivo pode estar fora do ar. \ No newline at end of file +appimage.install.download.error=Não foi possível baixar o arquivo {}. O servidor do arquivo pode estar fora do ar. +appimage.install.appimagelauncher.error={appimgl} não está permitindo que o {app} seja instalado. Desinstale o {appimgl}, reinicie o sistema e tente instalar o {app} novamente. \ No newline at end of file diff --git a/bauh/gems/appimage/worker.py b/bauh/gems/appimage/worker.py index fcc6929e..e9449cbe 100644 --- a/bauh/gems/appimage/worker.py +++ b/bauh/gems/appimage/worker.py @@ -8,6 +8,8 @@ from multiprocessing import Process from pathlib import Path from threading import Thread +import requests + from bauh.api.http import HttpClient from bauh.gems.appimage import LOCAL_PATH, db @@ -68,7 +70,11 @@ class DatabaseUpdater(Thread if bool(int(os.getenv('BAUH_DEBUG', 0))) else Proce def run(self): if self.enabled: while True: - self._download_databases() + try: + self._download_databases() + except requests.exceptions.ConnectionError: + self.logger.warning('The internet connection seems to be off.') + self.logger.info('Sleeping') time.sleep(self.sleep) else: diff --git a/bauh/gems/arch/__init__.py b/bauh/gems/arch/__init__.py index 3b1cfda6..5bd71924 100644 --- a/bauh/gems/arch/__init__.py +++ b/bauh/gems/arch/__init__.py @@ -1,4 +1,10 @@ import os + +from bauh.api.constants import CACHE_PATH + ROOT_DIR = os.path.dirname(os.path.abspath(__file__)) BUILD_DIR = '/tmp/bauh/aur' - +ARCH_CACHE_PATH = CACHE_PATH + '/arch' +CATEGORIES_CACHE_DIR = ARCH_CACHE_PATH + '/categories' +CATEGORIES_FILE_PATH = CATEGORIES_CACHE_DIR + '/aur.txt' +URL_CATEGORIES_FILE = 'https://raw.githubusercontent.com/vinifmor/bauh-files/master/aur/categories.txt' diff --git a/bauh/gems/arch/aur.py b/bauh/gems/arch/aur.py index ce01364f..857c4be2 100644 --- a/bauh/gems/arch/aur.py +++ b/bauh/gems/arch/aur.py @@ -1,9 +1,8 @@ import re from typing import Set, List -import requests - from bauh.api.http import HttpClient +import urllib.parse URL_INFO = 'https://aur.archlinux.org/rpc/?v=5&type=info&' URL_SRC_INFO = 'https://aur.archlinux.org/cgit/aur.git/plain/.SRCINFO?h=' @@ -32,7 +31,7 @@ class AURClient: return res['results'] if res and res.get('results') else [] def get_src_info(self, name: str) -> dict: - res = self.http_client.get(URL_SRC_INFO + name) + res = self.http_client.get(URL_SRC_INFO + urllib.parse.quote(name)) if res and res.text: info = {} @@ -48,4 +47,4 @@ class AURClient: return info def _map_names_as_queries(self, names) -> str: - return '&'.join(['arg[{}]={}'.format(i, n) for i, n in enumerate(names)]) + return '&'.join(['arg[{}]={}'.format(i, urllib.parse.quote(n)) for i, n in enumerate(names)]) diff --git a/bauh/gems/arch/controller.py b/bauh/gems/arch/controller.py index a62b97a2..62e826f5 100644 --- a/bauh/gems/arch/controller.py +++ b/bauh/gems/arch/controller.py @@ -15,14 +15,16 @@ from bauh.api.abstract.disk import DiskCacheLoader from bauh.api.abstract.handler import ProcessWatcher from bauh.api.abstract.model import PackageUpdate, PackageHistory, SoftwarePackage, PackageSuggestion, PackageStatus from bauh.api.abstract.view import MessageType +from bauh.commons.category import CategoriesDownloader from bauh.commons.html import bold from bauh.commons.system import SystemProcess, ProcessHandler, new_subprocess, run_cmd, new_root_subprocess, \ SimpleProcess -from bauh.gems.arch import BUILD_DIR, aur, pacman, makepkg, pkgbuild, message, confirmation, disk, git, suggestions, gpg +from bauh.gems.arch import BUILD_DIR, aur, pacman, makepkg, pkgbuild, message, confirmation, disk, git, suggestions, \ + gpg, URL_CATEGORIES_FILE, CATEGORIES_CACHE_DIR, CATEGORIES_FILE_PATH from bauh.gems.arch.aur import AURClient from bauh.gems.arch.mapper import ArchDataMapper from bauh.gems.arch.model import ArchPackage -from bauh.gems.arch.worker import AURIndexUpdater, ArchDiskCacheUpdater, ArchCompilationOptimizer, CategoriesDownloader +from bauh.gems.arch.worker import AURIndexUpdater, ArchDiskCacheUpdater, ArchCompilationOptimizer URL_GIT = 'https://aur.archlinux.org/{}.git' URL_PKG_DOWNLOAD = 'https://aur.archlinux.org/cgit/aur.git/snapshot/{}.tar.gz' @@ -33,6 +35,12 @@ RE_SPLIT_VERSION = re.compile(r'(=|>|<)') SOURCE_FIELDS = ('source', 'source_x86_64') RE_PRE_DOWNLOADABLE_FILES = re.compile(r'(https?|ftp)://.+\.\w+[^gpg|git]$') +SEARCH_OPTIMIZED_MAP = { + 'google chrome': 'google-chrome', + 'chrome google': 'google-chrome', + 'googlechrome': 'google-chrome' +} + class ArchManager(SoftwareManager): @@ -51,11 +59,12 @@ class ArchManager(SoftwareManager): self.logger = context.logger self.enabled = True self.arch_distro = context.distro == 'arch' - self.categories_downloader = CategoriesDownloader(context.http_client, context.logger) - self.categories_map = {} + self.categories_mapper = CategoriesDownloader('AUR', context.http_client, context.logger, self, self.context.disk_cache, + URL_CATEGORIES_FILE, CATEGORIES_CACHE_DIR, CATEGORIES_FILE_PATH) + self.categories = {} def _upgrade_search_result(self, apidata: dict, installed_pkgs: dict, downgrade_enabled: bool, res: SearchResult, disk_loader: DiskCacheLoader): - app = self.mapper.map_api_data(apidata, installed_pkgs['not_signed']) + app = self.mapper.map_api_data(apidata, installed_pkgs['not_signed'], self.categories) app.downgrade_enabled = downgrade_enabled if app.installed: @@ -66,7 +75,7 @@ class ArchManager(SoftwareManager): else: res.new.append(app) - Thread(target=self.mapper.fill_package_build, args=(app,)).start() + Thread(target=self.mapper.fill_package_build, args=(app,), daemon=True).start() def search(self, words: str, disk_loader: DiskCacheLoader, limit: int = -1) -> SearchResult: self.comp_optimizer.join() @@ -75,10 +84,12 @@ class ArchManager(SoftwareManager): res = SearchResult([], [], 0) installed = {} - read_installed = Thread(target=lambda: installed.update(pacman.list_and_map_installed())) + read_installed = Thread(target=lambda: installed.update(pacman.list_and_map_installed()), daemon=True) read_installed.start() - api_res = self.aur_client.search(words) + mapped_words = SEARCH_OPTIMIZED_MAP.get(words) + + api_res = self.aur_client.search(mapped_words if mapped_words else words) if api_res and api_res.get('results'): read_installed.join() @@ -117,9 +128,8 @@ class ArchManager(SoftwareManager): if pkgsinfo: for pkgdata in pkgsinfo: - pkg = self.mapper.map_api_data(pkgdata, not_signed) + pkg = self.mapper.map_api_data(pkgdata, not_signed, self.categories) pkg.downgrade_enabled = downgrade_enabled - pkg.categories = self.categories_map.get(pkg.name) if disk_loader: disk_loader.fill(pkg) @@ -137,7 +147,7 @@ class ArchManager(SoftwareManager): latest_version=data.get('version'), description=data.get('description'), installed=True, mirror='aur') - pkg.categories = self.categories_map.get(pkg.name) + pkg.categories = self.categories.get(pkg.name) pkg.downgrade_enabled = downgrade_enabled if disk_loader: @@ -161,6 +171,7 @@ class ArchManager(SoftwareManager): apps = [] if installed and installed['not_signed']: self.dcache_updater.join() + self.categories_mapper.join() self._fill_aur_pkgs(installed['not_signed'], apps, disk_loader, internet_available) @@ -490,6 +501,12 @@ class ArchManager(SoftwareManager): handler.watcher.print(self.i18n['action.cancelled']) return False + if check_res.get('validity_check'): + handler.watcher.show_message(title=self.i18n['arch.aur.install.validity_check.title'], + body=self.i18n['arch.aur.install.validity_check.body'].format(bold(pkgname)), + type_=MessageType.ERROR) + return False + return True def _install_optdeps(self, pkgname: str, root_password: str, handler: ProcessHandler, pkgdir: str, change_progress: bool = True) -> bool: @@ -559,7 +576,7 @@ class ArchManager(SoftwareManager): if installed and self.context.disk_cache: handler.watcher.change_substatus(self.i18n['status.caching_data'].format(bold(pkgname))) if self.context.disk_cache: - disk.save_several({pkgname}, mirror=mirror, maintainer=maintainer, overwrite=True) + disk.save_several({pkgname}, mirror=mirror, maintainer=maintainer, overwrite=True, categories=self.categories) self._update_progress(handler.watcher, 100, change_progress) @@ -678,7 +695,7 @@ class ArchManager(SoftwareManager): return False def cache_to_disk(self, pkg: ArchPackage, icon_bytes: bytes, only_icon: bool): - pass + pass def requires_root(self, action: str, pkg: ArchPackage): return action != 'search' @@ -687,16 +704,13 @@ class ArchManager(SoftwareManager): self.dcache_updater.start() self.comp_optimizer.start() self.aur_index_updater.start() - categories = self.categories_downloader.get_categories() - - if categories: - self.categories_map = categories + self.categories_mapper.start() def list_updates(self, internet_available: bool) -> List[PackageUpdate]: installed = self.read_installed(disk_loader=None, internet_available=internet_available).installed return [PackageUpdate(p.id, p.latest_version, 'aur') for p in installed if p.update] - def list_warnings(self) -> List[str]: + def list_warnings(self, internet_available: bool) -> List[str]: warnings = [] if self.arch_distro: @@ -725,9 +739,10 @@ class ArchManager(SoftwareManager): api_res = self.aur_client.get_info(sug_names) if api_res: + self.categories_mapper.join() for pkg in api_res: if pkg.get('Name') in sug_names: - res.append(PackageSuggestion(self.mapper.map_api_data(pkg, {}), suggestions.ALL.get(pkg['Name']))) + res.append(PackageSuggestion(self.mapper.map_api_data(pkg, {}, self.categories), suggestions.ALL.get(pkg['Name']))) return res diff --git a/bauh/gems/arch/disk.py b/bauh/gems/arch/disk.py index 507288e9..124a6a66 100644 --- a/bauh/gems/arch/disk.py +++ b/bauh/gems/arch/disk.py @@ -22,7 +22,7 @@ def write(app: ArchPackage): def fill_icon_path(app: ArchPackage, icon_paths: List[str], only_exact_match: bool): - ends_with = re.compile(r'.+/{}\.(png|svg)$'.format(app.icon_path if app.icon_path else app.name), re.IGNORECASE) + ends_with = re.compile(r'.+/{}\.(png|svg|xpm)$'.format(app.icon_path if app.icon_path else app.name), re.IGNORECASE) for path in icon_paths: if ends_with.match(path): @@ -47,7 +47,7 @@ def set_icon_path(app: ArchPackage, icon_name: str = None): break -def save_several(pkgnames: Set[str], mirror: str, overwrite: bool = True, maintainer: str = None) -> int: +def save_several(pkgnames: Set[str], mirror: str, overwrite: bool = True, maintainer: str = None, categories: dict = None) -> int: to_cache = {n for n in pkgnames if overwrite or not os.path.exists(ArchPackage.disk_cache_path(n, mirror))} desktop_files = pacman.list_desktop_entries(to_cache) @@ -138,8 +138,10 @@ def save_several(pkgnames: Set[str], mirror: str, overwrite: bool = True, mainta if to_write: for p in to_write: + if categories: + p.categories = categories.get(p.name) + p.maintainer = maintainer write(p) return len(to_write) return 0 - diff --git a/bauh/gems/arch/makepkg.py b/bauh/gems/arch/makepkg.py index 2255dd12..3a72b002 100644 --- a/bauh/gems/arch/makepkg.py +++ b/bauh/gems/arch/makepkg.py @@ -18,4 +18,7 @@ def check(pkgdir: str, handler: ProcessHandler) -> dict: if gpg_keys: res['gpg_key'] = gpg_keys[0] + if 'One or more files did not pass the validity check' in output: + res['validity_check'] = True + return res diff --git a/bauh/gems/arch/mapper.py b/bauh/gems/arch/mapper.py index ffee5a99..55e05d16 100644 --- a/bauh/gems/arch/mapper.py +++ b/bauh/gems/arch/mapper.py @@ -7,9 +7,9 @@ from bauh.gems.arch.model import ArchPackage URL_PKG_DOWNLOAD = 'https://aur.archlinux.org/{}' RE_LETTERS = re.compile(r'\.([a-zA-Z]+)-\d+$') -RE_ANY_LETTER = re.compile(r'[a-zA-Z]') -RE_VERSION_DELS = re.compile(r'[.:#@\-_]') +RE_VERSION_SPLIT = re.compile(r'[a-zA-Z]+|\d+|[\.\-_@#]+') +BAUH_PACKAGES = {'bauh', 'bauh-staging'} RE_SFX = ('r', 're', 'release') GA_SFX = ('ga', 'ge') RC_SFX = ('rc',) @@ -49,46 +49,47 @@ class ArchDataMapper: pkg.url_download = URL_PKG_DOWNLOAD.format(package['URLPath']) if package.get('URLPath') else None pkg.first_submitted = datetime.fromtimestamp(package['FirstSubmitted']) if package.get('FirstSubmitted') else None pkg.last_modified = datetime.fromtimestamp(package['LastModified']) if package.get('LastModified') else None - pkg.update = self.check_update(pkg.version, pkg.latest_version) + pkg.update = self.check_update(pkg.version, pkg.latest_version, check_suffix=pkg.name in BAUH_PACKAGES) @staticmethod - def check_update(version: str, latest_version: str) -> bool: + def check_update(version: str, latest_version: str, check_suffix: bool = False) -> bool: if version and latest_version: - current_sfx = RE_LETTERS.findall(version) - latest_sf = RE_LETTERS.findall(latest_version) - if latest_sf and current_sfx: - current_sfx = current_sfx[0] - latest_sf = latest_sf[0] + if check_suffix: + current_sfx = RE_LETTERS.findall(version) + latest_sf = RE_LETTERS.findall(latest_version) - current_sfx_data = V_SUFFIX_MAP.get(current_sfx.lower()) - latest_sfx_data = V_SUFFIX_MAP.get(latest_sf.lower()) + if latest_sf and current_sfx: + current_sfx = current_sfx[0] + latest_sf = latest_sf[0] - if current_sfx_data and latest_sfx_data: - nversion = version.split(current_sfx)[0] - nlatest = latest_version.split(latest_sf)[0] + current_sfx_data = V_SUFFIX_MAP.get(current_sfx.lower()) + latest_sfx_data = V_SUFFIX_MAP.get(latest_sf.lower()) - if nversion == nlatest: - if current_sfx_data['c'] != latest_sfx_data['c']: - return latest_sfx_data['p'] < current_sfx_data['p'] - else: - return ''.join(latest_version.split(latest_sf)) > ''.join(version.split(current_sfx)) + if current_sfx_data and latest_sfx_data: + nversion = version.split(current_sfx)[0] + nlatest = latest_version.split(latest_sf)[0] - return nlatest > nversion + if nversion == nlatest: + if current_sfx_data['c'] != latest_sfx_data['c']: + return latest_sfx_data['p'] < current_sfx_data['p'] + else: + return ''.join(latest_version.split(latest_sf)) > ''.join(version.split(current_sfx)) - latest_split = RE_VERSION_DELS.split(latest_version) - version_split = RE_VERSION_DELS.split(version) + return nlatest > nversion + + latest_split = RE_VERSION_SPLIT.findall(latest_version) + current_split = RE_VERSION_SPLIT.findall(version) for idx in range(len(latest_split)): - if idx < len(version_split): + if idx < len(current_split): latest_part = latest_split[idx] - version_part = version_split[idx] + current_part = current_split[idx] - if latest_part != version_part: - if RE_ANY_LETTER.findall(latest_part) or RE_ANY_LETTER.findall(version_part): - return latest_part > version_part - else: - dif = int(latest_part) - int(version_part) + if latest_part != current_part: + + try: + dif = int(latest_part) - int(current_part) if dif > 0: return True @@ -96,6 +97,14 @@ class ArchDataMapper: return False else: continue + + except ValueError: + if latest_part.isdigit(): + return True + elif current_part.isdigit(): + return False + else: + return latest_part > current_part return False def fill_package_build(self, pkg: ArchPackage): @@ -104,14 +113,18 @@ class ArchDataMapper: if res and res.status_code == 200 and res.text: pkg.pkgbuild = res.text - def map_api_data(self, apidata: dict, installed: dict) -> ArchPackage: + def map_api_data(self, apidata: dict, installed: dict, categories: dict) -> ArchPackage: data = installed.get(apidata.get('Name')) app = ArchPackage(name=apidata.get('Name'), installed=bool(data), mirror='aur') app.status = PackageStatus.LOADING_DATA + if categories: + app.categories = categories.get(app.name) + if data: app.version = data.get('version') app.description = data.get('description') self.fill_api_data(app, apidata, fill_version=not data) + return app diff --git a/bauh/gems/arch/model.py b/bauh/gems/arch/model.py index 9003f983..62d6d5fb 100644 --- a/bauh/gems/arch/model.py +++ b/bauh/gems/arch/model.py @@ -2,12 +2,10 @@ import datetime from typing import List from bauh.api.abstract.model import SoftwarePackage -from bauh.api.constants import CACHE_PATH from bauh.commons import resource +from bauh.gems.arch import ROOT_DIR, ARCH_CACHE_PATH -from bauh.gems.arch import ROOT_DIR - -CACHED_ATTRS = {'command', 'icon_path', 'mirror', 'maintainer', 'desktop_entry'} +CACHED_ATTRS = {'command', 'icon_path', 'mirror', 'maintainer', 'desktop_entry', 'categories'} class ArchPackage(SoftwarePackage): @@ -35,7 +33,7 @@ class ArchPackage(SoftwarePackage): @staticmethod def disk_cache_path(pkgname: str, mirror: str): - return CACHE_PATH + '/arch/installed/' + ('aur' if mirror == 'aur' else 'mirror') + '/' + pkgname + return ARCH_CACHE_PATH + '/installed/' + ('aur' if mirror == 'aur' else 'mirror') + '/' + pkgname def get_pkg_build_url(self): if self.package_base: diff --git a/bauh/gems/arch/pacman.py b/bauh/gems/arch/pacman.py index 7681e2b2..9e8e8d4d 100644 --- a/bauh/gems/arch/pacman.py +++ b/bauh/gems/arch/pacman.py @@ -1,4 +1,5 @@ import re +from threading import Thread from typing import List, Set from bauh.commons.system import run_cmd, new_subprocess, new_root_subprocess, SystemProcess @@ -13,7 +14,7 @@ def is_enabled() -> bool: def get_mirrors(pkgs: Set[str]) -> dict: - pkgre = '|'.join(pkgs) + pkgre = '|'.join(pkgs).replace('+', r'\+').replace('.', r'\.') searchres = new_subprocess(['pacman', '-Ss', pkgre]).stdout mirrors = {} @@ -71,10 +72,18 @@ def check_installed(pkg: str) -> bool: return bool(res) +def _fill_ignored(res: dict): + res['pkgs'] = list_ignored_packages() + + def list_and_map_installed() -> dict: # returns a dict with with package names as keys and versions as values installed = new_subprocess(['pacman', '-Qq']).stdout # retrieving all installed package names allinfo = new_subprocess(['pacman', '-Qi'], stdin=installed).stdout # retrieving all installed packages info + ignored = {} + thread_ignored = Thread(target=_fill_ignored, args=(ignored,)) + thread_ignored.start() + pkgs, current_pkg = {'mirrors': {}, 'not_signed': {}}, {} for out in new_subprocess(['grep', '-E', '(Name|Description|Version|Validated By)'], stdin=allinfo).stdout: # filtering only the Name and Validated By fields: @@ -96,6 +105,18 @@ def list_and_map_installed() -> dict: # returns a dict with with package names current_pkg = {} + if pkgs and pkgs.get('not_signed'): + thread_ignored.join() + + if ignored['pkgs']: + to_del = set() + for pkg in pkgs['not_signed'].keys(): + if pkg in ignored['pkgs']: + to_del.add(pkg) + + for pkg in to_del: + del pkgs['not_signed'][pkg] + return pkgs @@ -124,7 +145,7 @@ def list_icon_paths(pkgnames: Set[str]) -> List[str]: installed_files = new_subprocess(['pacman', '-Qlq', *pkgnames]) icon_files = [] - for out in new_subprocess(['grep', '-E', '.(png|svg)$'], stdin=installed_files.stdout).stdout: + for out in new_subprocess(['grep', '-E', '.(png|svg|xpm)$'], stdin=installed_files.stdout).stdout: if out: line = out.decode().strip() if line: @@ -179,3 +200,19 @@ def receive_key(key: str, root_password: str) -> SystemProcess: def sign_key(key: str, root_password: str) -> SystemProcess: return SystemProcess(new_root_subprocess(['pacman-key', '--lsign-key', key], root_password=root_password), check_error_output=False) + +def list_ignored_packages(config_path: str = '/etc/pacman.conf') -> Set[str]: + pacman_conf = new_subprocess(['cat', config_path]) + + ignored = set() + grep = new_subprocess(['grep', '-Eo', r'\s*#*\s*ignorepkg\s*=\s*.+'], stdin=pacman_conf.stdout) + for o in grep.stdout: + if o: + line = o.decode().strip() + + if not line.startswith('#'): + ignored.add(line.split('=')[1].strip()) + + pacman_conf.terminate() + grep.terminate() + return ignored diff --git a/bauh/gems/arch/resources/locale/en b/bauh/gems/arch/resources/locale/en index bb4deba5..31816086 100644 --- a/bauh/gems/arch/resources/locale/en +++ b/bauh/gems/arch/resources/locale/en @@ -60,4 +60,6 @@ arch.aur.install.pgp.receive_fail=Could not receive PGP key {} arch.aur.install.unknown_key.title=Public key required arch.install.aur.unknown_key.body=To continue {} installation is necessary to trust the following public key {} arch.aur.install.unknown_key.status=Receiving public key {} -arch.aur.install.unknown_key.receive_error=Could not receive public key {} \ No newline at end of file +arch.aur.install.unknown_key.receive_error=Could not receive public key {} +arch.aur.install.validity_check.title=Integrity issues +arch.aur.install.validity_check.body=Some of the source-files needed for {} installation are not healthy. The installation will be cancelled to prevent damage to your system. \ No newline at end of file diff --git a/bauh/gems/arch/resources/locale/es b/bauh/gems/arch/resources/locale/es index d97b21b0..d8a54105 100644 --- a/bauh/gems/arch/resources/locale/es +++ b/bauh/gems/arch/resources/locale/es @@ -92,4 +92,6 @@ arch.aur.install.pgp.sign_fail=No fue posible recibir la clave PGP {} arch.aur.install.unknown_key.title=Clave pública necesaria arch.aur.install.unknown_key.status=Recibiendo la clave pública {} arch.aur.install.unknown_key.receive_error=No fue posible recibir la clave pública {} -arch.install.aur.unknown_key.body=Para continuar la instalación de {} es necesario confiar en la siguiente clave pública {} \ No newline at end of file +arch.install.aur.unknown_key.body=Para continuar la instalación de {} es necesario confiar en la siguiente clave pública {} +arch.aur.install.validity_check.title=Problemas de integridad +arch.aur.install.validity_check.body=Algunos de los archivos fuente necesarios para la instalación de {} no están en buen estado. La instalación se cancelará para evitar daños a su sistema. \ No newline at end of file diff --git a/bauh/gems/arch/resources/locale/pt b/bauh/gems/arch/resources/locale/pt index 5b521483..a5d82578 100644 --- a/bauh/gems/arch/resources/locale/pt +++ b/bauh/gems/arch/resources/locale/pt @@ -92,4 +92,6 @@ arch.aur.install.pgp.receive_fail=Não foi possível receber a chave PGP {} arch.aur.install.unknown_key.title=Chave pública necessária arch.install.aur.unknown_key.body=Para continuar a instalação de {} é ncessário confiar na seguinte chave pública {} arch.aur.install.unknown_key.status=Recebendo a chave pública {} -arch.aur.install.unknown_key.receive_error=Não fui possível receber a chave pública {} \ No newline at end of file +arch.aur.install.unknown_key.receive_error=Não fui possível receber a chave pública {} +arch.aur.install.validity_check.title=Problemas de integridade +arch.aur.install.validity_check.body=Alguns dos arquivos-fonte necessários para instalação de {} não estão íntegros. A instalação será cancelada para evitar danos no seu sistema. \ No newline at end of file diff --git a/bauh/gems/arch/worker.py b/bauh/gems/arch/worker.py index 50910c9c..b7f9245f 100644 --- a/bauh/gems/arch/worker.py +++ b/bauh/gems/arch/worker.py @@ -2,18 +2,15 @@ import logging import os import re import time -import traceback from math import ceil from multiprocessing import Process from threading import Thread -from typing import Dict, List import requests from bauh.api.abstract.context import ApplicationContext from bauh.api.abstract.controller import SoftwareManager from bauh.api.constants import HOME_PATH -from bauh.api.http import HttpClient from bauh.gems.arch import pacman, disk URL_INDEX = 'https://aur.archlinux.org/packages.gz' @@ -47,7 +44,7 @@ class AURIndexUpdater(Thread): self.logger.info('Pre-indexed {} AUR package names in memory'.format(len(self.man.names_index))) else: self.logger.warning('No data returned from: {}'.format(URL_INDEX)) - except ConnectionError: + except requests.exceptions.ConnectionError: self.logger.warning('No internet connection: could not pre-index packages') time.sleep(60 * 20) # updates every 20 minutes @@ -137,34 +134,3 @@ class ArchCompilationOptimizer(Thread if bool(os.getenv('BAUH_DEBUG', 0)) else P self.logger.info("A custom optimized 'makepkg.conf' was generated at '{}'".format(HOME_PATH)) else: self.logger.warning("A custom 'makepkg.conf' is already defined at '{}'".format(HOME_PATH)) - - -class CategoriesDownloader: - - URL_CATEGORIES_FILE = 'https://raw.githubusercontent.com/vinifmor/bauh-files/master/aur/categories.txt' - - def __init__(self, http_client: HttpClient, logger: logging.Logger): - self.http_client = http_client - self.logger = logger - - def get_categories(self) -> Dict[str, List[str]]: - self.logger.info('Downloading AUR category definitions from {}'.format(self.URL_CATEGORIES_FILE)) - - res = self.http_client.get(self.URL_CATEGORIES_FILE) - - if res: - try: - categories_map = {} - for l in res.text.split('\n'): - if l: - data = l.split('=') - categories_map[data[0]] = [c.strip() for c in data[1].split(',') if c] - - self.logger.info('Loaded categories for {} AUR packages'.format(len(categories_map))) - return categories_map - except: - self.logger.error("Could not parse AUR categories definitions") - traceback.print_exc() - return {} - else: - self.logger.info('Could not download {}'.format(self.URL_CATEGORIES_FILE)) diff --git a/bauh/gems/flatpak/controller.py b/bauh/gems/flatpak/controller.py index a431f4c3..0da44b1c 100644 --- a/bauh/gems/flatpak/controller.py +++ b/bauh/gems/flatpak/controller.py @@ -1,3 +1,4 @@ +import json import traceback from datetime import datetime from threading import Thread @@ -24,6 +25,7 @@ class FlatpakManager(SoftwareManager): super(FlatpakManager, self).__init__(context=context) self.i18n = context.i18n self.api_cache = context.cache_factory.new() + self.category_cache = context.cache_factory.new() context.disk_loader_factory.map(FlatpakApplication, self.api_cache) self.enabled = True self.http_client = context.http_client @@ -45,7 +47,8 @@ class FlatpakManager(SoftwareManager): disk_loader.fill(app) # preloading cached disk data if internet: - FlatpakAsyncDataLoader(app=app, api_cache=self.api_cache, manager=self, context=self.context).start() + FlatpakAsyncDataLoader(app=app, api_cache=self.api_cache, manager=self, + context=self.context, category_cache=self.category_cache).start() else: app.fill_cached_data(api_data) @@ -247,7 +250,7 @@ class FlatpakManager(SoftwareManager): return updates - def list_warnings(self) -> List[str]: + def list_warnings(self, internet_available: bool) -> List[str]: if flatpak.is_installed(): if not flatpak.has_remotes_set(): return [self.i18n['flatpak.notification.no_remotes']] @@ -279,11 +282,20 @@ class FlatpakManager(SoftwareManager): flatpak.run(str(pkg.id)) def get_screenshots(self, pkg: SoftwarePackage) -> List[str]: - res = self.http_client.get_json('{}/apps/{}'.format(FLATHUB_API_URL, pkg.id)) + screenshots_url = '{}/apps/{}'.format(FLATHUB_API_URL, pkg.id) urls = [] - if res and res.get('screenshots'): - for s in res['screenshots']: - if s.get('imgDesktopUrl'): - urls.append(s['imgDesktopUrl']) + try: + res = self.http_client.get_json(screenshots_url) + + if res and res.get('screenshots'): + for s in res['screenshots']: + if s.get('imgDesktopUrl'): + urls.append(s['imgDesktopUrl']) + + except Exception as e: + if e.__class__.__name__ == 'JSONDecodeError': + self.context.logger.error("Could not decode json from '{}'".format(screenshots_url)) + else: + traceback.print_exc() return urls diff --git a/bauh/gems/flatpak/model.py b/bauh/gems/flatpak/model.py index bbddd94c..50e70665 100644 --- a/bauh/gems/flatpak/model.py +++ b/bauh/gems/flatpak/model.py @@ -17,6 +17,9 @@ class FlatpakApplication(SoftwarePackage): self.runtime = runtime self.commit = commit + if runtime: + self.categories = ['runtime'] + def is_incomplete(self): return self.description is None and self.icon_url diff --git a/bauh/gems/flatpak/worker.py b/bauh/gems/flatpak/worker.py index dcb5e933..5f581146 100644 --- a/bauh/gems/flatpak/worker.py +++ b/bauh/gems/flatpak/worker.py @@ -1,4 +1,5 @@ import traceback +from io import StringIO from threading import Thread from bauh.api.abstract.cache import MemoryCache @@ -12,7 +13,7 @@ from bauh.gems.flatpak.model import FlatpakApplication class FlatpakAsyncDataLoader(Thread): - def __init__(self, app: FlatpakApplication, manager: SoftwareManager, context: ApplicationContext, api_cache: MemoryCache): + def __init__(self, app: FlatpakApplication, manager: SoftwareManager, context: ApplicationContext, api_cache: MemoryCache, category_cache: MemoryCache): super(FlatpakAsyncDataLoader, self).__init__(daemon=True) self.app = app self.manager = manager @@ -20,6 +21,21 @@ class FlatpakAsyncDataLoader(Thread): self.api_cache = api_cache self.persist = False self.logger = context.logger + self.category_cache = category_cache + + @staticmethod + def format_category(category: str) -> str: + word = StringIO() + last_l = None + for idx, l in enumerate(category): + if idx != 0 and last_l != ' ' and l.isupper() and idx + 1 < len(category) and category[idx + 1].islower(): + word.write(' ') + + last_l = l.lower() + word.write(last_l) + + word.seek(0) + return word.read() def run(self): if self.app: @@ -51,7 +67,17 @@ class FlatpakAsyncDataLoader(Thread): self.app.icon_url = FLATHUB_URL + self.app.icon_url if data.get('categories'): - self.app.categories = [c['name'] for c in data['categories']] + cats = [] + for c in data['categories']: + cached = self.category_cache.get(c['name']) + + if not cached: + cached = self.format_category(c['name']) + self.category_cache.add_non_existing(c['name'], cached) + + cats.append(cached) + + self.app.categories = cats loaded_data = self.app.get_data_to_cache() diff --git a/bauh/gems/snap/__init__.py b/bauh/gems/snap/__init__.py index 8824c2d4..bd219398 100644 --- a/bauh/gems/snap/__init__.py +++ b/bauh/gems/snap/__init__.py @@ -1,2 +1,8 @@ import os + +from bauh.api.constants import CACHE_PATH + ROOT_DIR = os.path.dirname(os.path.abspath(__file__)) +SNAP_CACHE_PATH = CACHE_PATH + '/snap' +CATEGORIES_FILE_PATH = SNAP_CACHE_PATH + '/categories.txt' +URL_CATEGORIES_FILE = 'https://raw.githubusercontent.com/vinifmor/bauh-files/master/snap/categories.txt' diff --git a/bauh/gems/snap/controller.py b/bauh/gems/snap/controller.py index 220b90e3..7ce13691 100644 --- a/bauh/gems/snap/controller.py +++ b/bauh/gems/snap/controller.py @@ -1,3 +1,4 @@ +import re import time from datetime import datetime from threading import Thread @@ -8,12 +9,16 @@ from bauh.api.abstract.disk import DiskCacheLoader from bauh.api.abstract.handler import ProcessWatcher from bauh.api.abstract.model import SoftwarePackage, PackageHistory, PackageUpdate, PackageSuggestion, \ SuggestionPriority +from bauh.api.abstract.view import SingleSelectComponent, SelectViewType, InputOption +from bauh.commons.category import CategoriesDownloader from bauh.commons.html import bold -from bauh.commons.system import SystemProcess, ProcessHandler -from bauh.gems.snap import snap, suggestions +from bauh.commons.system import SystemProcess, ProcessHandler, new_root_subprocess +from bauh.gems.snap import snap, suggestions, URL_CATEGORIES_FILE, SNAP_CACHE_PATH, CATEGORIES_FILE_PATH from bauh.gems.snap.constants import SNAP_API_URL from bauh.gems.snap.model import SnapApplication -from bauh.gems.snap.worker import SnapAsyncDataLoader, CategoriesDownloader +from bauh.gems.snap.worker import SnapAsyncDataLoader + +RE_AVAILABLE_CHANNELS = re.compile(re.compile(r'(\w+)\s+(snap install.+)')) class SnapManager(SoftwareManager): @@ -28,7 +33,8 @@ class SnapManager(SoftwareManager): self.logger = context.logger self.ubuntu_distro = context.distro == 'ubuntu' self.categories = {} - self.categories_downloader = CategoriesDownloader(self.http_client, self.logger) + self.categories_downloader = CategoriesDownloader('snap', self.http_client, self.logger, self, context.disk_cache, + URL_CATEGORIES_FILE, SNAP_CACHE_PATH, CATEGORIES_FILE_PATH) def map_json(self, app_json: dict, installed: bool, disk_loader: DiskCacheLoader, internet: bool = True) -> SnapApplication: app = SnapApplication(publisher=app_json.get('publisher'), @@ -41,13 +47,27 @@ class SnapManager(SoftwareManager): latest_version=app_json.get('version'), description=app_json.get('description', app_json.get('summary'))) - if app.publisher: + if app.publisher and app.publisher.endswith('*'): + app.verified_publisher = True app.publisher = app.publisher.replace('*', '') - app.categories = self.categories.get(app.name.lower()) + categories = self.categories.get(app.name.lower()) + + if categories: + app.categories = categories app.installed = installed + if not app.is_application(): + categories = app.categories + + if categories is None: + categories = [] + app.categories = categories + + if 'runtime' not in categories: + categories.append('runtime') + api_data = self.api_cache.get(app_json['name']) expired_data = api_data and api_data.get('expires_at') and api_data['expires_at'] <= datetime.utcnow() @@ -88,6 +108,7 @@ class SnapManager(SoftwareManager): def read_installed(self, disk_loader: DiskCacheLoader, limit: int = -1, only_apps: bool = False, pkg_types: Set[Type[SoftwarePackage]] = None, internet_available: bool = None) -> SearchResult: if snap.is_snapd_running(): + self.categories_downloader.join() installed = [self.map_json(app_json, installed=True, disk_loader=disk_loader, internet=internet_available) for app_json in snap.read_installed(self.ubuntu_distro)] return SearchResult(installed, None, len(installed)) else: @@ -125,9 +146,31 @@ class SnapManager(SoftwareManager): raise Exception("'get_history' is not supported by {}".format(pkg.__class__.__name__)) def install(self, pkg: SnapApplication, root_password: str, watcher: ProcessWatcher) -> bool: - res = ProcessHandler(watcher).handle(SystemProcess(subproc=snap.install_and_stream(pkg.name, pkg.confinement, root_password))) + res, output = ProcessHandler(watcher).handle_simple(snap.install_and_stream(pkg.name, pkg.confinement, root_password)) - if res: + if 'error:' in output: + res = False + if 'not available on stable' in output: + channels = RE_AVAILABLE_CHANNELS.findall(output) + + if channels: + opts = [InputOption(label=c[0], value=c[1]) for c in channels] + channel_select = SingleSelectComponent(type_=SelectViewType.RADIO, label='', options=opts, default_option=opts[0]) + if watcher.request_confirmation(title=self.i18n['snap.install.available_channels.title'], + body=self.i18n['snap.install.available_channels.body'].format(bold(self.i18n['stable']), bold(pkg.name)) + ':', + components=[channel_select], + confirmation_label=self.i18n['continue'], + deny_label=self.i18n['cancel']): + self.logger.info("Installing '{}' with the custom command '{}'".format(pkg.name, channel_select.value)) + res = ProcessHandler(watcher).handle(SystemProcess(new_root_subprocess(channel_select.value.value.split(' '), root_password=root_password))) + + if res: + pkg.has_apps_field = snap.has_apps_field(pkg.name, self.ubuntu_distro) + + return res + else: + self.logger.error("Could not find available channels in the installation output: {}".format(output)) + else: pkg.has_apps_field = snap.has_apps_field(pkg.name, self.ubuntu_distro) return res @@ -148,20 +191,24 @@ class SnapManager(SoftwareManager): return ProcessHandler(watcher).handle(SystemProcess(subproc=snap.refresh_and_stream(pkg.name, root_password))) def prepare(self): - categories = self.categories_downloader.get_categories() - - if categories: - self.categories = categories + self.categories_downloader.start() def list_updates(self, internet_available: bool) -> List[PackageUpdate]: pass - def list_warnings(self) -> List[str]: + def list_warnings(self, internet_available: bool) -> List[str]: if snap.is_installed() and not snap.is_snapd_running(): snap_bold = bold('Snap') return [self.i18n['snap.notification.snapd_unavailable'].format(bold('snapd'), snap_bold), self.i18n['snap.notification.snap.disable'].format(snap_bold, bold(self.i18n['manage_window.settings.gems']))] + if internet_available: + available, output = snap.is_api_available() + + if not available: + self.logger.warning('It seems Snap API is not available. Search output: {}'.format(output)) + return [self.i18n['snap.notifications.api.unavailable'].format(bold('Snaps'), bold('Snap'))] + def _fill_suggestion(self, pkg_name: str, priority: SuggestionPriority, out: List[PackageSuggestion]): res = self.http_client.get_json(SNAP_API_URL + '/search?q=package_name:{}'.format(pkg_name)) @@ -182,6 +229,8 @@ class SnapManager(SoftwareManager): sugs.sort(key=lambda t: t[1].value, reverse=True) threads = [] + self.categories_downloader.join() + for sug in sugs: if limit <= 0 or len(res) < limit: diff --git a/bauh/gems/snap/model.py b/bauh/gems/snap/model.py index 711d67e4..6a48928d 100644 --- a/bauh/gems/snap/model.py +++ b/bauh/gems/snap/model.py @@ -19,7 +19,7 @@ class SnapApplication(SoftwarePackage): def __init__(self, id: str = None, name: str = None, version: str = None, latest_version: str = None, description: str = None, publisher: str = None, rev: str = None, notes: str = None, - confinement: str = None, has_apps_field: bool = None): + confinement: str = None, has_apps_field: bool = None, verified_publisher: bool = False): super(SnapApplication, self).__init__(id=id, name=name, version=version, latest_version=latest_version, description=description) self.publisher = publisher @@ -27,6 +27,7 @@ class SnapApplication(SoftwarePackage): self.notes = notes self.confinement = confinement self.has_apps_field = has_apps_field + self.verified_publisher = verified_publisher def supports_disk_cache(self): return self.installed diff --git a/bauh/gems/snap/resources/locale/en b/bauh/gems/snap/resources/locale/en index 3426cdc3..cfe914b8 100644 --- a/bauh/gems/snap/resources/locale/en +++ b/bauh/gems/snap/resources/locale/en @@ -1,5 +1,8 @@ gem.snap.info=Applications published at https://snapcraft.io/store snap.notification.snapd_unavailable={} seems not to be installed or enabled. {} packages will not be available. snap.notification.snap.disable=If you do not want to use Snap applications, uncheck {} in {} +snap.notifications.api.unavailable=It seems the {} API is unavailable at the moment. It will not be possible to search for new {} applications. snap.action.refresh.status=Refreshing -snap.action.refresh.label=Refresh \ No newline at end of file +snap.action.refresh.label=Refresh +snap.install.available_channels.title=Available channels +snap.install.available_channels.body=There is no {} channel available for {}. But there are these below ( select one if you want to continue ) \ No newline at end of file diff --git a/bauh/gems/snap/resources/locale/es b/bauh/gems/snap/resources/locale/es index 0d2f1a11..030edd69 100644 --- a/bauh/gems/snap/resources/locale/es +++ b/bauh/gems/snap/resources/locale/es @@ -12,5 +12,8 @@ snap.info.version=versión snap.info.size=tamaño snap.notification.snapd_unavailable={} no parece estar instalado o habilitado. Los paquetes {} estarán indisponibles. snap.notification.snap.disable=Si no desea usar aplicativos Snap, desmarque {} en {} +snap.notifications.api.unavailable=Parece que la API de {} no está disponible en este momento. No será posible buscar nuevos aplicativos {}. snap.action.refresh.status=Actualizando -snap.action.refresh.label=Actualizar \ No newline at end of file +snap.action.refresh.label=Actualizar +snap.install.available_channels.title=Canales disponibles +snap.install.available_channels.body=No hay un canal {} ( stable ) disponible para {}. Pero hay estos otros abajo ( seleccione uno si desea continuar ) \ No newline at end of file diff --git a/bauh/gems/snap/resources/locale/pt b/bauh/gems/snap/resources/locale/pt index 0828ae2d..d705e1f3 100644 --- a/bauh/gems/snap/resources/locale/pt +++ b/bauh/gems/snap/resources/locale/pt @@ -12,5 +12,8 @@ snap.info.version=versão snap.info.size=tamanho snap.notification.snapd_unavailable={} não parece estar instalado ou habilitado. Os pacotes {} estarão indisponíveis. snap.notification.snap.disable=Se não deseja usar aplicativos Snap, desmarque {} em {} +snap.notifications.api.unavailable=Parece que a API de {} está indisponível no momento. Não será possível buscar novos aplicativos {}. snap.action.refresh.status=Atualizando -snap.action.refresh.label=Atualizar \ No newline at end of file +snap.action.refresh.label=Atualizar +snap.install.available_channels.title=Canais disponíveis +snap.install.available_channels.body=Não há um canal {} ( stable ) disponível para {}. Porém existem estes outros abaixo ( selecione algum se quiser continuar ) \ No newline at end of file diff --git a/bauh/gems/snap/snap.py b/bauh/gems/snap/snap.py index 77384628..ad73dcc1 100644 --- a/bauh/gems/snap/snap.py +++ b/bauh/gems/snap/snap.py @@ -1,9 +1,10 @@ import logging import re import subprocess -from typing import List +from io import StringIO +from typing import List, Tuple -from bauh.commons.system import new_root_subprocess, run_cmd, new_subprocess +from bauh.commons.system import new_root_subprocess, run_cmd, new_subprocess, SimpleProcess from bauh.gems.snap.model import SnapApplication BASE_CMD = 'snap' @@ -176,14 +177,15 @@ def uninstall_and_stream(app_name: str, root_password: str): return new_root_subprocess([BASE_CMD, 'remove', app_name], root_password) -def install_and_stream(app_name: str, confinement: str, root_password: str) -> subprocess.Popen: +def install_and_stream(app_name: str, confinement: str, root_password: str) -> SimpleProcess: install_cmd = [BASE_CMD, 'install', app_name] # default if confinement == 'classic': install_cmd.append('--classic') - return new_root_subprocess(install_cmd, root_password) + # return new_root_subprocess(install_cmd, root_password) + return SimpleProcess(install_cmd, root_password=root_password) def downgrade_and_stream(app_name: str, root_password: str) -> subprocess.Popen: @@ -226,3 +228,14 @@ def run(app: SnapApplication, logger: logging.Logger): logger.error("No valid command found for '{}'".format(app_name)) else: logger.error("No command found for '{}'".format(app_name)) + + +def is_api_available() -> Tuple[bool, str]: + output = StringIO() + for o in SimpleProcess(['snap', 'search']).instance.stdout: + if o: + output.write(o.decode()) + + output.seek(0) + output = output.read() + return 'error:' not in output, output diff --git a/bauh/gems/snap/worker.py b/bauh/gems/snap/worker.py index 6bfd9c08..72dd76c2 100644 --- a/bauh/gems/snap/worker.py +++ b/bauh/gems/snap/worker.py @@ -1,14 +1,10 @@ -import logging -import os import traceback from threading import Thread -from typing import Dict, List from bauh.api.abstract.cache import MemoryCache from bauh.api.abstract.context import ApplicationContext from bauh.api.abstract.controller import SoftwareManager from bauh.api.abstract.model import PackageStatus -from bauh.api.http import HttpClient from bauh.gems.snap import snap from bauh.gems.snap.constants import SNAP_API_URL from bauh.gems.snap.model import SnapApplication @@ -72,34 +68,3 @@ class SnapAsyncDataLoader(Thread): if self.persist: self.manager.cache_to_disk(pkg=self.app, icon_bytes=None, only_icon=False) - - -class CategoriesDownloader: - - URL_CATEGORIES_FILE = 'https://raw.githubusercontent.com/vinifmor/bauh-files/master/snap/categories.txt' - - def __init__(self, http_client: HttpClient, logger: logging.Logger): - self.http_client = http_client - self.logger = logger - - def get_categories(self) -> Dict[str, List[str]]: - self.logger.info('Downloading Snap category definitions from {}'.format(self.URL_CATEGORIES_FILE)) - - res = self.http_client.get(self.URL_CATEGORIES_FILE) - - if res: - try: - categories_map = {} - for l in res.text.split('\n'): - if l: - data = l.split('=') - categories_map[data[0]] = [c.strip() for c in data[1].split(',') if c] - - self.logger.info('Loaded categories for {} Snap applications'.format(len(categories_map))) - return categories_map - except: - self.logger.error("Could not parse categories definitions") - traceback.print_exc() - return {} - else: - self.logger.info('Could not download {}'.format(self.URL_CATEGORIES_FILE)) diff --git a/bauh/view/core/controller.py b/bauh/view/core/controller.py index 9769c109..e0f65f4a 100755 --- a/bauh/view/core/controller.py +++ b/bauh/view/core/controller.py @@ -307,13 +307,15 @@ class GenericSoftwareManager(SoftwareManager): return updates - def list_warnings(self) -> List[str]: + def list_warnings(self, internet_available: bool = None) -> List[str]: warnings = [] if self.managers: + int_available = internet.is_available(self.context.http_client, self.context.logger) + for man in self.managers: if man.is_enabled(): - man_warnings = man.list_warnings() + man_warnings = man.list_warnings(internet_available=int_available) if man_warnings: if warnings is None: diff --git a/bauh/view/qt/about.py b/bauh/view/qt/about.py index 1d2f0679..7f3971a3 100644 --- a/bauh/view/qt/about.py +++ b/bauh/view/qt/about.py @@ -55,8 +55,8 @@ class AboutDialog(QDialog): layout.addWidget(gems_widget) layout.addWidget(QLabel('')) - label_version = QLabel(locale_keys['version'].lower() + ': ' + __version__) - label_version.setStyleSheet('QLabel { font-size: 10px; font-weight: bold }') + label_version = QLabel(locale_keys['version'].lower() + ' ' + __version__) + label_version.setStyleSheet('QLabel { font-size: 11px; font-weight: bold }') label_version.setAlignment(Qt.AlignCenter) layout.addWidget(label_version) diff --git a/bauh/view/qt/apps_table.py b/bauh/view/qt/apps_table.py index 9fb31327..c4c39a4a 100644 --- a/bauh/view/qt/apps_table.py +++ b/bauh/view/qt/apps_table.py @@ -231,7 +231,7 @@ class AppsTable(QTableWidget): col_update = None if update_check_enabled and pkg.model.update: - col_update = UpdateToggleButton(pkg, self.window, self.i18n, pkg.model.update) + col_update = UpdateToggleButton(pkg, self.window, self.i18n, pkg.update_checked) self.setCellWidget(pkg.table_index, 7, col_update) diff --git a/bauh/view/qt/commons.py b/bauh/view/qt/commons.py index 6ba6f90b..4d652109 100644 --- a/bauh/view/qt/commons.py +++ b/bauh/view/qt/commons.py @@ -31,7 +31,8 @@ def update_info(pkgv: PackageView, pkgs_info: dict): pkgs_info['updates'] += 1 if pkgv.model.categories: - pkgs_info['categories'].update([s.lower() for s in pkgv.model.categories]) + for c in pkgv.model.categories: + pkgs_info['categories'].add(c.lower()) pkgs_info['pkgs'].append(pkgv) pkgs_info['not_installed'] += 1 if not pkgv.model.installed else 0 diff --git a/bauh/view/qt/components.py b/bauh/view/qt/components.py index 0942b49d..7175d8a7 100644 --- a/bauh/view/qt/components.py +++ b/bauh/view/qt/components.py @@ -67,7 +67,7 @@ class ComboBoxQt(QComboBox): class RadioSelectQt(QGroupBox): def __init__(self, model: SingleSelectComponent): - super(RadioSelectQt, self).__init__(model.label + ' :') + super(RadioSelectQt, self).__init__(model.label + ' :' if model.label else None) self.model = model self.setStyleSheet("QGroupBox { font-weight: bold }") diff --git a/bauh/view/qt/root.py b/bauh/view/qt/root.py index 890c487f..d4f16cb0 100644 --- a/bauh/view/qt/root.py +++ b/bauh/view/qt/root.py @@ -13,28 +13,41 @@ def is_root(): return os.getuid() == 0 -def ask_root_password(locale_keys: dict): - +def ask_root_password(i18n: dict): diag = QInputDialog() diag.setStyleSheet("""QLineEdit { border-radius: 5px; font-size: 16px }""") diag.setInputMode(QInputDialog.TextInput) diag.setTextEchoMode(QLineEdit.Password) diag.setWindowIcon(QIcon(resource.get_path('img/lock.png'))) - diag.setWindowTitle(locale_keys['popup.root.title']) + diag.setWindowTitle(i18n['popup.root.title']) diag.setLabelText('') - diag.setCancelButtonText(locale_keys['popup.button.cancel']) + diag.setCancelButtonText(i18n['popup.button.cancel']) diag.resize(400, 200) - ok = diag.exec_() + for attempt in range(3): - if ok: - if not validate_password(diag.textValue()): - show_message(title=locale_keys['popup.root.bad_password.title'], - body=locale_keys['popup.root.bad_password.body'], - type_=MessageType.ERROR) - ok = False + ok = diag.exec_() - return diag.textValue(), ok + if ok: + if not validate_password(diag.textValue()): + + body = i18n['popup.root.bad_password.body'] + + if attempt == 2: + body += '. ' + i18n['popup.root.bad_password.last_try'] + + show_message(title=i18n['popup.root.bad_password.title'], + body=body, + type_=MessageType.ERROR) + ok = False + diag.setTextValue('') + + if ok: + return diag.textValue(), ok + else: + break + + return '', False def validate_password(password: str) -> bool: diff --git a/bauh/view/qt/screenshots.py b/bauh/view/qt/screenshots.py index 8f8dea43..56bd13fc 100644 --- a/bauh/view/qt/screenshots.py +++ b/bauh/view/qt/screenshots.py @@ -4,12 +4,13 @@ from typing import List from PyQt5.QtCore import Qt from PyQt5.QtGui import QIcon, QPixmap -from PyQt5.QtWidgets import QDialog, QLabel, QPushButton, QToolBar, QVBoxLayout +from PyQt5.QtWidgets import QDialog, QLabel, QPushButton, QToolBar, QVBoxLayout, QProgressBar, QApplication from bauh.api.abstract.cache import MemoryCache from bauh.api.http import HttpClient from bauh.view.qt import qt_utils from bauh.view.qt.components import new_spacer +from bauh.view.qt.thread import AnimateProgress from bauh.view.qt.view_model import PackageView @@ -28,6 +29,12 @@ class ScreenshotsDialog(QDialog): self.resize(1280, 720) self.i18n = i18n self.http_client = http_client + self.progress_bar = QProgressBar() + self.progress_bar.setMaximumHeight(10 if QApplication.instance().style().objectName().lower() == 'windows' else 6) + self.progress_bar.setTextVisible(False) + self.thread_progress = AnimateProgress() + self.thread_progress.signal_change.connect(self._update_progress) + self.thread_progress.start() # THERE ARE CRASHES WITH SOME RARE ICONS ( like insomnia ). IT CAN BE A QT BUG. IN THE MEANTIME, ONLY THE TYPE ICON WILL BE RENDERED # @@ -52,7 +59,9 @@ class ScreenshotsDialog(QDialog): self.img_label = QLabel() self.img_label.setStyleSheet('QLabel { font-weight: bold; text-align: center }') - self.bottom_bar.addWidget(self.img_label) + self.ref_img_label = self.bottom_bar.addWidget(self.img_label) + self.ref_img_label.setVisible(False) + self.ref_progress_bar = self.bottom_bar.addWidget(self.progress_bar) self.bottom_bar.addWidget(new_spacer(50)) self.bt_next = QPushButton(self.i18n['screenshots.bt_next.label'].capitalize()) @@ -64,11 +73,14 @@ class ScreenshotsDialog(QDialog): self.img_idx = 0 for idx, s in enumerate(self.screenshots): - t = Thread(target=self._download_img, args=(idx, s)) + t = Thread(target=self._download_img, args=(idx, s), daemon=True) t.start() self._load_img() + def _update_progress(self, val: int): + self.progress_bar.setValue(val) + def _load_img(self): if len(self.loaded_imgs) > self.img_idx: img = self.loaded_imgs[self.img_idx] @@ -79,9 +91,15 @@ class ScreenshotsDialog(QDialog): else: self.img_label.setText(img) self.img.setPixmap(QPixmap()) + + self.thread_progress.stop = True + self.ref_progress_bar.setVisible(False) + self.ref_img_label.setVisible(True) else: self.img.setPixmap(QPixmap()) - self.img_label.setText('...{}...'.format(self.i18n['screenshots,download.running'])) + self.ref_img_label.setVisible(False) + self.ref_progress_bar.setVisible(True) + self.thread_progress.start() if len(self.screenshots) == 1: self.ref_bt_back.setVisible(False) diff --git a/bauh/view/qt/thread.py b/bauh/view/qt/thread.py index 75a95e12..f3be47e0 100644 --- a/bauh/view/qt/thread.py +++ b/bauh/view/qt/thread.py @@ -64,8 +64,7 @@ class AsyncAction(QThread, ProcessWatcher): self.signal_status.emit(status) def change_substatus(self, substatus: str): - if substatus: - self.signal_substatus.emit(substatus) + self.signal_substatus.emit(substatus) def change_progress(self, val: int): if val is not None: @@ -96,6 +95,7 @@ class UpdateSelectedApps(AsyncAction): self.change_status('{} {} {}...'.format(self.locale_keys['manage_window.status.upgrading'], name, app.model.version)) success = bool(self.manager.update(app.model, self.root_password, self)) + self.change_substatus('') if not success: break @@ -352,6 +352,7 @@ class VerifyModels(QThread): def __init__(self, apps: List[PackageView] = None): super(VerifyModels, self).__init__() self.apps = apps + self.work = True def run(self): @@ -361,6 +362,10 @@ class VerifyModels(QThread): last_ready = 0 while True: + + if not self.work: + break + current_ready = 0 for app in self.apps: @@ -379,6 +384,7 @@ class VerifyModels(QThread): time.sleep(0.1) + self.work = True self.apps = None diff --git a/bauh/view/qt/window.py b/bauh/view/qt/window.py index cafe6280..a4272147 100755 --- a/bauh/view/qt/window.py +++ b/bauh/view/qt/window.py @@ -19,8 +19,6 @@ from bauh.api.http import HttpClient from bauh.commons.html import bold from bauh.view.core.config import Configuration from bauh.view.core.controller import GenericSoftwareManager -from bauh.view.qt.screenshots import ScreenshotsDialog -from bauh.view.util import util, resource from bauh.view.qt import dialog, commons, qt_utils from bauh.view.qt.about import AboutDialog from bauh.view.qt.apps_table import AppsTable, UpdateToggleButton @@ -30,12 +28,14 @@ from bauh.view.qt.gem_selector import GemSelectorPanel from bauh.view.qt.history import HistoryDialog from bauh.view.qt.info import InfoDialog from bauh.view.qt.root import is_root, ask_root_password +from bauh.view.qt.screenshots import ScreenshotsDialog from bauh.view.qt.styles import StylesComboBox from bauh.view.qt.thread import UpdateSelectedApps, RefreshApps, UninstallApp, DowngradeApp, GetAppInfo, \ GetAppHistory, SearchPackages, InstallPackage, AnimateProgress, VerifyModels, FindSuggestions, ListWarnings, \ AsyncAction, LaunchApp, ApplyFilters, CustomAction, GetScreenshots from bauh.view.qt.view_model import PackageView from bauh.view.qt.view_utils import load_icon +from bauh.view.util import util, resource DARK_ORANGE = '#FF4500' @@ -283,6 +283,7 @@ class ManageWindow(QWidget): self._maximized = False self.progress_controll_enabled = True self.recent_installation = False + self.recent_uninstall = False self.dialog_about = None self.first_refresh = suggestions @@ -314,6 +315,13 @@ class ManageWindow(QWidget): self._update_table(pkgs_info=pkgs_info, signal=True) self.update_bt_upgrade(pkgs_info) + if self.pkgs_available: + self._notify_model_data_change() + self.thread_verify_models.work = False + self.thread_verify_models.wait(50) + self.thread_verify_models.apps = self.pkgs_available + self.thread_verify_models.start() + def _finish_apply_filters_async(self, success: bool): self.label_status.setText('') self.ref_toolbar_search.setVisible(True) @@ -400,6 +408,21 @@ class ManageWindow(QWidget): def _notify_model_data_change(self): self.table_apps.fill_async_data() + if not self.recent_installation: + self._reload_categories() + + def _reload_categories(self): + + categories = set() + + for p in self.pkgs_available: + if p.model.categories: + for c in p.model.categories: + categories.add(c.lower()) + + if categories: + self._update_categories(categories, keep_selected=True) + def changeEvent(self, e: QEvent): if isinstance(e, QWindowStateChangeEvent): self._maximized = self.isMaximized() @@ -431,7 +454,6 @@ class ManageWindow(QWidget): def refresh_apps(self, keep_console: bool = True, top_app: PackageView = None, pkg_types: Set[Type[SoftwarePackage]] = None): self.recent_installation = False - self.type_filter = None self.input_search.clear() if not keep_console: @@ -439,7 +461,7 @@ class ManageWindow(QWidget): self.ref_checkbox_updates.setVisible(False) self.ref_checkbox_only_apps.setVisible(False) - self._begin_action(self.i18n['manage_window.status.refreshing'], keep_bt_installed=False, clear_filters=True) + self._begin_action(self.i18n['manage_window.status.refreshing'], keep_bt_installed=False, clear_filters=not self.recent_uninstall) self.thread_refresh.app = top_app # the app will be on top when refresh happens self.thread_refresh.pkg_types = pkg_types @@ -449,8 +471,9 @@ class ManageWindow(QWidget): self.finish_action() self.ref_checkbox_only_apps.setVisible(bool(res['installed'])) self.ref_bt_upgrade.setVisible(True) - self.update_pkgs(res['installed'], as_installed=as_installed, types=res['types']) + self.update_pkgs(res['installed'], as_installed=as_installed, types=res['types'], keep_filters=self.recent_uninstall and res['types']) self.first_refresh = False + self.recent_uninstall = False self._hide_fields_after_recent_installation() def uninstall_app(self, app: PackageView): @@ -464,7 +487,7 @@ class ManageWindow(QWidget): return self._handle_console_option(True) - self._begin_action('{} {}'.format(self.i18n['manage_window.status.uninstalling'], app.model.name)) + self._begin_action('{} {}'.format(self.i18n['manage_window.status.uninstalling'], app.model.name), clear_filters=False) self.thread_uninstall.app = app self.thread_uninstall.root_password = pwd @@ -483,6 +506,7 @@ class ManageWindow(QWidget): util.notify_user('{} ({}) {}'.format(pkgv.model.name, pkgv.model.get_type(), self.i18n['uninstalled'])) only_pkg_type = len([p for p in self.pkgs if p.model.get_type() == pkgv.model.get_type()]) >= 2 + self.recent_uninstall = True self.refresh_apps(pkg_types={pkgv.model.__class__} if only_pkg_type else None) else: if self._can_notify_user(): @@ -547,7 +571,7 @@ class ManageWindow(QWidget): self.ref_bt_upgrade.setVisible(show_bt_upgrade) - def change_update_state(self, pkgs_info: dict, trigger_filters: bool = True): + def change_update_state(self, pkgs_info: dict, trigger_filters: bool = True, keep_selected: bool = False): self.update_bt_upgrade(pkgs_info) if pkgs_info['updates'] > 0: @@ -556,13 +580,14 @@ class ManageWindow(QWidget): if not self.ref_checkbox_updates.isVisible(): self.ref_checkbox_updates.setVisible(True) - if not self.filter_updates: + if not self.filter_updates and not keep_selected: self._change_checkbox(self.checkbox_updates, True, 'filter_updates', trigger_filters) - if pkgs_info['napp_updates'] > 0 and self.filter_only_apps: + if pkgs_info['napp_updates'] > 0 and self.filter_only_apps and not keep_selected: self._change_checkbox(self.checkbox_only_apps, False, 'filter_only_apps', trigger_filters) else: - self._change_checkbox(self.checkbox_updates, False, 'filter_updates', trigger_filters) + if not keep_selected: + self._change_checkbox(self.checkbox_updates, False, 'filter_updates', trigger_filters) self.ref_checkbox_updates.setVisible(False) @@ -586,7 +611,7 @@ class ManageWindow(QWidget): 'display_limit': self.display_limit if updates <= 0 else None } - def update_pkgs(self, new_pkgs: List[SoftwarePackage], as_installed: bool, types: Set[type] = None, ignore_updates: bool = False): + def update_pkgs(self, new_pkgs: List[SoftwarePackage], as_installed: bool, types: Set[type] = None, ignore_updates: bool = False, keep_filters: bool = False): self.input_name_filter.setText('') pkgs_info = commons.new_pkgs_info() filters = self._gen_filters(ignore_updates) @@ -620,15 +645,19 @@ class ManageWindow(QWidget): self.thread_suggestions.start() return else: - self._change_checkbox(self.checkbox_only_apps, False, 'filter_only_apps', trigger=False) - self.checkbox_only_apps.setCheckable(False) + if not keep_filters: + self._change_checkbox(self.checkbox_only_apps, False, 'filter_only_apps', trigger=False) + self.checkbox_only_apps.setCheckable(False) else: - self.checkbox_only_apps.setCheckable(True) - self._change_checkbox(self.checkbox_only_apps, True, 'filter_only_apps', trigger=False) + if not keep_filters: + self.checkbox_only_apps.setCheckable(True) + self._change_checkbox(self.checkbox_only_apps, True, 'filter_only_apps', trigger=False) - self.change_update_state(pkgs_info=pkgs_info, trigger_filters=False) + self.change_update_state(pkgs_info=pkgs_info, trigger_filters=False, keep_selected=keep_filters and bool(pkgs_info['pkgs_displayed'])) + self._update_categories(pkgs_info['categories'], keep_selected=keep_filters and bool(pkgs_info['pkgs_displayed'])) + self._update_type_filters(pkgs_info['available_types'], keep_selected=keep_filters and bool(pkgs_info['pkgs_displayed'])) self._apply_filters(pkgs_info, ignore_updates=ignore_updates) - self.change_update_state(pkgs_info=pkgs_info, trigger_filters=False) + self.change_update_state(pkgs_info=pkgs_info, trigger_filters=False, keep_selected=keep_filters and bool(pkgs_info['pkgs_displayed'])) self.pkgs_available = pkgs_info['pkgs'] @@ -640,9 +669,6 @@ class ManageWindow(QWidget): if self.pkgs: self.ref_input_name_filter.setVisible(True) - self._update_type_filters(pkgs_info['available_types']) - self._update_categories(pkgs_info['categories']) - self._update_table(pkgs_info=pkgs_info) if new_pkgs: @@ -660,19 +686,25 @@ class ManageWindow(QWidget): for pkgv in pkgs_info['pkgs']: commons.apply_filters(pkgv, filters, pkgs_info) - def _update_type_filters(self, available_types: dict = None): + def _update_type_filters(self, available_types: dict = None, keep_selected: bool = False): if available_types is None: self.ref_combo_filter_type.setVisible(self.combo_filter_type.count() > 1) else: - self.type_filter = self.any_type_filter + keeping_selected = keep_selected and available_types and self.type_filter in available_types + + if not keeping_selected: + self.type_filter = self.any_type_filter if available_types and len(available_types) > 1: if self.combo_filter_type.count() > 1: for _ in range(self.combo_filter_type.count() - 1): self.combo_filter_type.removeItem(1) - for app_type, icon_path in available_types.items(): + sel_type = -1 + for idx, item in enumerate(available_types.items()): + app_type, icon_path = item[0], item[1] + icon = self.cache_type_filter_icons.get(app_type) if not icon: @@ -681,28 +713,48 @@ class ManageWindow(QWidget): self.combo_filter_type.addItem(icon, app_type.capitalize(), app_type) + if keeping_selected and app_type == self.type_filter: + sel_type = idx + 1 + + self.combo_filter_type.blockSignals(True) + self.combo_filter_type.setCurrentIndex(sel_type if sel_type > -1 else 0) + self.combo_filter_type.blockSignals(False) self.ref_combo_filter_type.setVisible(True) else: self.ref_combo_filter_type.setVisible(False) - def _update_categories(self, categories: Set[str] = None): + def _update_categories(self, categories: Set[str] = None, keep_selected: bool = False): if categories is None: self.ref_combo_categories.setVisible(self.combo_categories.count() > 1) else: - self.category_filter = self.any_category_filter + keeping_selected = keep_selected and categories and self.category_filter in categories + + if not keeping_selected: + self.category_filter = self.any_category_filter if categories: if self.combo_categories.count() > 1: for _ in range(self.combo_categories.count() - 1): self.combo_categories.removeItem(1) - for c in categories: + selected_cat = -1 + cat_list = list(categories) + cat_list.sort() + + for idx, c in enumerate(cat_list): i18n_cat = self.i18n.get(c) cat_label = i18n_cat if i18n_cat else c self.combo_categories.addItem(cat_label.capitalize(), c) + if keeping_selected and c == self.category_filter: + selected_cat = idx + 1 + + self.combo_categories.blockSignals(True) + self.combo_categories.setCurrentIndex(selected_cat if selected_cat > -1 else 0) + self.combo_categories.blockSignals(False) self.ref_combo_categories.setVisible(True) + else: self.ref_combo_categories.setVisible(False) @@ -809,7 +861,7 @@ class ManageWindow(QWidget): else: self.combo_filter_type.setEnabled(False) - def finish_action(self): + def finish_action(self, keep_filters: bool = False): self.ref_combo_styles.setVisible(True) self.thread_animate_progress.stop = True self.thread_animate_progress.wait(msecs=1000) @@ -835,8 +887,8 @@ class ManageWindow(QWidget): if self.pkgs: self.ref_input_name_filter.setVisible(True) self.update_bt_upgrade() - self._update_type_filters() - self._update_categories() + self._update_type_filters(keep_selected=keep_filters) + self._update_categories(keep_selected=keep_filters) if self.ref_bt_installed.isVisible(): self.ref_bt_installed.setEnabled(True) diff --git a/bauh/view/resources/locale/en b/bauh/view/resources/locale/en index 029a4b4c..9e34ffbe 100644 --- a/bauh/view/resources/locale/en +++ b/bauh/view/resources/locale/en @@ -38,6 +38,7 @@ manage_window.checkbox.show_details=Show details popup.root.title=Requires your password to continue popup.root.bad_password.title=Error popup.root.bad_password.body=Wrong password +popup.root.bad_password.last_try=Attempts finished. Action cancelled. popup.history.title=History popup.history.selected.tooltip=Current version popup.button.yes=Yes @@ -140,6 +141,9 @@ filetransfer=file transfer packagemanager=package manager sportsgame=sports game arcadegame=arcade game +player=player screenshots,download.running=downloading image screenshots.download.no_content=No content to display -screenshots.download.no_response=Image not found \ No newline at end of file +screenshots.download.no_response=Image not found +continue=continue +stable=stable \ No newline at end of file diff --git a/bauh/view/resources/locale/es b/bauh/view/resources/locale/es index dfb29385..dbb9f951 100644 --- a/bauh/view/resources/locale/es +++ b/bauh/view/resources/locale/es @@ -39,6 +39,7 @@ manage_window.checkbox.show_details=Mostrar detalles popup.root.title=Requiere tu contraseña para continuar popup.root.bad_password.title=Error popup.root.bad_password.body=Contraseña incorrecta +popup.root.bad_password.last_try=Intentos finalizados. Acción cancelada. popup.history.title=Historia popup.history.selected.tooltip=Versión actual popup.button.yes=Sí @@ -181,8 +182,12 @@ filetransfer=transferencia de archivos packagemanager=administrador de paquetes sportsgame=juego de deportes arcadegame=juego arcade +player=jugador +fonts=fuentes screenshots.bt_next.label=próxima screenshots.bt_back.label=anterior screenshots,download.running=descargando imagen screenshots.download.no_content=No hay contenido para mostrar -screenshots.download.no_response=Imagen no encontrada \ No newline at end of file +screenshots.download.no_response=Imagen no encontrada +continue=continuar +stable=estable \ No newline at end of file diff --git a/bauh/view/resources/locale/pt b/bauh/view/resources/locale/pt index 728ad2f2..eeb3c40e 100644 --- a/bauh/view/resources/locale/pt +++ b/bauh/view/resources/locale/pt @@ -39,6 +39,7 @@ manage_window.checkbox.show_details=Mostrar detalhes popup.root.title=Requer sua senha para prosseguir popup.root.bad_password.title=Erro popup.root.bad_password.body=Senha incorreta +popup.root.bad_password.last_try=Tentativas finalizadas. Ação cancelada. popup.history.title=Histórico popup.history.selected.tooltip=Versão atual popup.button.yes=Sim @@ -184,8 +185,12 @@ viewer=visualizador packagemanager=gerenciador de pacotes sportsgame=jogo de esportes arcadegame=jogo arcade +player=jogador +fonts=fontes screenshots.bt_next.label=próxima screenshots.bt_back.label=anterior screenshots,download.running=baixando imagem screenshots.download.no_content=Sem conteúdo para exibir -screenshots.download.no_response=Imagem não encontrada \ No newline at end of file +screenshots.download.no_response=Imagem não encontrada +continue=continuar +stable=estável \ No newline at end of file diff --git a/setup.py b/setup.py index a0de0e07..d9ac265b 100644 --- a/setup.py +++ b/setup.py @@ -3,7 +3,7 @@ import os from setuptools import setup, find_packages DESCRIPTION = ( - "Graphical user interface to manage Flatpak / Snap applications." + "Graphical interface to manage Flatpak, Snap, AppImage and AUR packages" ) AUTHOR = "Vinicius Moreira" diff --git a/tests/gems/arch/arch_data_mapper_test.py b/tests/gems/arch/arch_data_mapper_test.py index 3aaf4a4a..fdc17d5a 100644 --- a/tests/gems/arch/arch_data_mapper_test.py +++ b/tests/gems/arch/arch_data_mapper_test.py @@ -24,6 +24,9 @@ class ArchDataMapperTest(TestCase): self.assertFalse(ArchDataMapper.check_update('77.0.3865.120-1', '77.0.3865.90-1')) self.assertTrue(ArchDataMapper.check_update('77.0.3865.a-1', '77.0.3865.b-1')) self.assertFalse(ArchDataMapper.check_update('77.0.b.0-1', '77.0.a.1-1')) + self.assertFalse(ArchDataMapper.check_update('r25.e22697c-1', 'r8.19fe011-1')) + self.assertFalse(ArchDataMapper.check_update('1.1.0.r11.caacf30-1', 'r65.4c7144a-1')) + self.assertFalse(ArchDataMapper.check_update('1.2.16.r688.8b2c199-1', 'r2105.e91f0e9-3')) def test_check_update_no_suffix_3_x_2_digits(self): self.assertTrue(ArchDataMapper.check_update('1.0.0-1', '1.1-1')) @@ -35,97 +38,97 @@ class ArchDataMapperTest(TestCase): def test_check_update_release(self): # RE - self.assertTrue(ArchDataMapper.check_update('1.0.0.R-1', '1.0.0.R-2')) - self.assertFalse(ArchDataMapper.check_update('1.0.0.R-2', '1.0.0.R-1')) - self.assertFalse(ArchDataMapper.check_update('1.0.1.R-1', '1.0.0.R-5')) - self.assertTrue(ArchDataMapper.check_update('1.0.1.R-1', '1.0.1.R-2')) - self.assertFalse(ArchDataMapper.check_update('1.0.2.R-1', '1.0.1.R-7')) + self.assertTrue(ArchDataMapper.check_update('1.0.0.R-1', '1.0.0.R-2', check_suffix=True)) + self.assertFalse(ArchDataMapper.check_update('1.0.0.R-2', '1.0.0.R-1', check_suffix=True)) + self.assertFalse(ArchDataMapper.check_update('1.0.1.R-1', '1.0.0.R-5', check_suffix=True)) + self.assertTrue(ArchDataMapper.check_update('1.0.1.R-1', '1.0.1.R-2', check_suffix=True)) + self.assertFalse(ArchDataMapper.check_update('1.0.2.R-1', '1.0.1.R-7', check_suffix=True)) - self.assertTrue(ArchDataMapper.check_update('1.0.0.RE-1', '1.0.0.R-2')) - self.assertTrue(ArchDataMapper.check_update('1.0.0.RELEASE-2', '1.0.0.R-5')) - self.assertFalse(ArchDataMapper.check_update('1.0.1.R-1', '1.0.0.RELEASE-5')) - self.assertFalse(ArchDataMapper.check_update('1.0.1.R-1', '1.0.0.RE-1')) + self.assertTrue(ArchDataMapper.check_update('1.0.0.RE-1', '1.0.0.R-2', check_suffix=True)) + self.assertTrue(ArchDataMapper.check_update('1.0.0.RELEASE-2', '1.0.0.R-5', check_suffix=True)) + self.assertFalse(ArchDataMapper.check_update('1.0.1.R-1', '1.0.0.RELEASE-5', check_suffix=True)) + self.assertFalse(ArchDataMapper.check_update('1.0.1.R-1', '1.0.0.RE-1', check_suffix=True)) # GA - self.assertFalse(ArchDataMapper.check_update('1.0.0.R-1', '1.0.0.Ge-1')) - self.assertFalse(ArchDataMapper.check_update('1.0.0.RE-1', '1.0.0.GA-1')) - self.assertFalse(ArchDataMapper.check_update('1.0.0.RELEASE-1', '1.0.0.Ge-1')) - self.assertFalse(ArchDataMapper.check_update('1.0.0.R-1', '1.0.0.GE-1')) - self.assertFalse(ArchDataMapper.check_update('1.0.0.RE-1', '1.0.0.GE-1')) - self.assertFalse(ArchDataMapper.check_update('1.0.0.RELEASE-1', '1.0.0.GE-1')) - self.assertFalse(ArchDataMapper.check_update('1.0.0.R-1', '1.0.0.GA-1')) - self.assertFalse(ArchDataMapper.check_update('1.0.0.RE-1', '1.0.0.GA-1')) - self.assertFalse(ArchDataMapper.check_update('1.0.0.RELEASE-1', '1.0.0.GA-1')) + self.assertFalse(ArchDataMapper.check_update('1.0.0.R-1', '1.0.0.Ge-1', check_suffix=True)) + self.assertFalse(ArchDataMapper.check_update('1.0.0.RE-1', '1.0.0.GA-1', check_suffix=True)) + self.assertFalse(ArchDataMapper.check_update('1.0.0.RELEASE-1', '1.0.0.Ge-1', check_suffix=True)) + self.assertFalse(ArchDataMapper.check_update('1.0.0.R-1', '1.0.0.GE-1', check_suffix=True)) + self.assertFalse(ArchDataMapper.check_update('1.0.0.RE-1', '1.0.0.GE-1', check_suffix=True)) + self.assertFalse(ArchDataMapper.check_update('1.0.0.RELEASE-1', '1.0.0.GE-1', check_suffix=True)) + self.assertFalse(ArchDataMapper.check_update('1.0.0.R-1', '1.0.0.GA-1', check_suffix=True)) + self.assertFalse(ArchDataMapper.check_update('1.0.0.RE-1', '1.0.0.GA-1', check_suffix=True)) + self.assertFalse(ArchDataMapper.check_update('1.0.0.RELEASE-1', '1.0.0.GA-1', check_suffix=True)) - self.assertTrue(ArchDataMapper.check_update('1.0.0.R-5', '1.0.1.GA-1')) + self.assertTrue(ArchDataMapper.check_update('1.0.0.R-5', '1.0.1.GA-1', check_suffix=True)) # RCS - self.assertFalse(ArchDataMapper.check_update('1.0.0.R-1', '1.0.0.RC-1')) - self.assertFalse(ArchDataMapper.check_update('1.0.0.RE-1', '1.0.0.RC-1')) - self.assertFalse(ArchDataMapper.check_update('1.0.0.RELEASE-1', '1.0.0.RC-1')) + self.assertFalse(ArchDataMapper.check_update('1.0.0.R-1', '1.0.0.RC-1', check_suffix=True)) + self.assertFalse(ArchDataMapper.check_update('1.0.0.RE-1', '1.0.0.RC-1', check_suffix=True)) + self.assertFalse(ArchDataMapper.check_update('1.0.0.RELEASE-1', '1.0.0.RC-1', check_suffix=True)) - self.assertTrue(ArchDataMapper.check_update('1.0.0.R-5', '1.0.1.RC-1')) + self.assertTrue(ArchDataMapper.check_update('1.0.0.R-5', '1.0.1.RC-1', check_suffix=True)) # BETA - self.assertFalse(ArchDataMapper.check_update('1.0.0.R-1', '1.0.0.B-2')) - self.assertFalse(ArchDataMapper.check_update('1.0.0.RE-1', '1.0.0.B-2')) - self.assertFalse(ArchDataMapper.check_update('1.0.0.RELEASE-1', '1.0.0.B-2')) - self.assertFalse(ArchDataMapper.check_update('1.0.0.R-1', '1.0.0.BETA-2')) - self.assertFalse(ArchDataMapper.check_update('1.0.0.RE-1', '1.0.0.BETA-2')) - self.assertFalse(ArchDataMapper.check_update('1.0.0.RELEASE-1', '1.0.0.BETA-2')) + self.assertFalse(ArchDataMapper.check_update('1.0.0.R-1', '1.0.0.B-2', check_suffix=True)) + self.assertFalse(ArchDataMapper.check_update('1.0.0.RE-1', '1.0.0.B-2', check_suffix=True)) + self.assertFalse(ArchDataMapper.check_update('1.0.0.RELEASE-1', '1.0.0.B-2', check_suffix=True)) + self.assertFalse(ArchDataMapper.check_update('1.0.0.R-1', '1.0.0.BETA-2', check_suffix=True)) + self.assertFalse(ArchDataMapper.check_update('1.0.0.RE-1', '1.0.0.BETA-2', check_suffix=True)) + self.assertFalse(ArchDataMapper.check_update('1.0.0.RELEASE-1', '1.0.0.BETA-2', check_suffix=True)) - self.assertTrue(ArchDataMapper.check_update('1.0.0.R-5', '1.0.1.BETA-1')) + self.assertTrue(ArchDataMapper.check_update('1.0.0.R-5', '1.0.1.BETA-1', check_suffix=True)) # ALPHA - self.assertFalse(ArchDataMapper.check_update('1.0.0.R-1', '1.0.0.Alpha-2')) - self.assertFalse(ArchDataMapper.check_update('1.0.0.RE-1', '1.0.0.ALPHA-2')) - self.assertFalse(ArchDataMapper.check_update('1.0.0.RELEASE-1', '1.0.0.ALPHA-2')) - self.assertFalse(ArchDataMapper.check_update('1.0.0.R-1', '1.0.0.ALPHA-2')) - self.assertFalse(ArchDataMapper.check_update('1.0.0.RE-1', '1.0.0.ALPHA-2')) - self.assertFalse(ArchDataMapper.check_update('1.0.0.RELEASE-1', '1.0.0.ALPHA-2')) - self.assertFalse(ArchDataMapper.check_update('1.0.0.R-1', '1.0.0.ALFA-2')) - self.assertFalse(ArchDataMapper.check_update('1.0.0.RE-1', '1.0.0.ALFA-2')) - self.assertFalse(ArchDataMapper.check_update('1.0.0.RELEASE-1', '1.0.0.ALFA-2')) + self.assertFalse(ArchDataMapper.check_update('1.0.0.R-1', '1.0.0.Alpha-2', check_suffix=True)) + self.assertFalse(ArchDataMapper.check_update('1.0.0.RE-1', '1.0.0.ALPHA-2', check_suffix=True)) + self.assertFalse(ArchDataMapper.check_update('1.0.0.RELEASE-1', '1.0.0.ALPHA-2', check_suffix=True)) + self.assertFalse(ArchDataMapper.check_update('1.0.0.R-1', '1.0.0.ALPHA-2', check_suffix=True)) + self.assertFalse(ArchDataMapper.check_update('1.0.0.RE-1', '1.0.0.ALPHA-2', check_suffix=True)) + self.assertFalse(ArchDataMapper.check_update('1.0.0.RELEASE-1', '1.0.0.ALPHA-2', check_suffix=True)) + self.assertFalse(ArchDataMapper.check_update('1.0.0.R-1', '1.0.0.ALFA-2', check_suffix=True)) + self.assertFalse(ArchDataMapper.check_update('1.0.0.RE-1', '1.0.0.ALFA-2', check_suffix=True)) + self.assertFalse(ArchDataMapper.check_update('1.0.0.RELEASE-1', '1.0.0.ALFA-2', check_suffix=True)) - self.assertTrue(ArchDataMapper.check_update('1.0.0.R-5', '1.0.1.Alfa-1')) + self.assertTrue(ArchDataMapper.check_update('1.0.0.R-5', '1.0.1.Alfa-1', check_suffix=True)) # DEV - self.assertFalse(ArchDataMapper.check_update('1.0.0.R-1', '1.0.0.DeV-2')) - self.assertFalse(ArchDataMapper.check_update('1.0.0.RE-1', '1.0.0.DEV-2')) - self.assertFalse(ArchDataMapper.check_update('1.0.0.RELEASE-1', '1.0.0.DEVEL-2')) - self.assertFalse(ArchDataMapper.check_update('1.0.0.R-1', '1.0.0.DEV-2')) - self.assertFalse(ArchDataMapper.check_update('1.0.0.RE-1', '1.0.0.DEV-2')) - self.assertFalse(ArchDataMapper.check_update('1.0.0.RELEASE-1', '1.0.0.DEV-2')) + self.assertFalse(ArchDataMapper.check_update('1.0.0.R-1', '1.0.0.DeV-2', check_suffix=True)) + self.assertFalse(ArchDataMapper.check_update('1.0.0.RE-1', '1.0.0.DEV-2', check_suffix=True)) + self.assertFalse(ArchDataMapper.check_update('1.0.0.RELEASE-1', '1.0.0.DEVEL-2', check_suffix=True)) + self.assertFalse(ArchDataMapper.check_update('1.0.0.R-1', '1.0.0.DEV-2', check_suffix=True)) + self.assertFalse(ArchDataMapper.check_update('1.0.0.RE-1', '1.0.0.DEV-2', check_suffix=True)) + self.assertFalse(ArchDataMapper.check_update('1.0.0.RELEASE-1', '1.0.0.DEV-2', check_suffix=True)) - self.assertTrue(ArchDataMapper.check_update('1.0.0.R-5', '1.0.1.DEV-1')) + self.assertTrue(ArchDataMapper.check_update('1.0.0.R-5', '1.0.1.DEV-1', check_suffix=True)) def test_check_update_ga(self): # GA - self.assertFalse(ArchDataMapper.check_update('1.0.0.GA-1', '1.0.0.GE-1')) - self.assertTrue(ArchDataMapper.check_update('1.0.0.GE-1', '1.0.0.GA-2')) - self.assertTrue(ArchDataMapper.check_update('1.0.0.GA-2', '1.0.1.GA-1')) - self.assertFalse(ArchDataMapper.check_update('1.1.0.GE-3', '1.0.6.GE-10')) + self.assertFalse(ArchDataMapper.check_update('1.0.0.GA-1', '1.0.0.GE-1', check_suffix=True)) + self.assertTrue(ArchDataMapper.check_update('1.0.0.GE-1', '1.0.0.GA-2', check_suffix=True)) + self.assertTrue(ArchDataMapper.check_update('1.0.0.GA-2', '1.0.1.GA-1', check_suffix=True)) + self.assertFalse(ArchDataMapper.check_update('1.1.0.GE-3', '1.0.6.GE-10', check_suffix=True)) # RC - self.assertFalse(ArchDataMapper.check_update('1.0.0.GA-1', '1.0.0.RC-1')) - self.assertFalse(ArchDataMapper.check_update('1.0.0.GE-1', '1.0.0.RC-1')) - self.assertFalse(ArchDataMapper.check_update('1.0.0.GA-1', '1.0.0.RC-1')) + self.assertFalse(ArchDataMapper.check_update('1.0.0.GA-1', '1.0.0.RC-1', check_suffix=True)) + self.assertFalse(ArchDataMapper.check_update('1.0.0.GE-1', '1.0.0.RC-1', check_suffix=True)) + self.assertFalse(ArchDataMapper.check_update('1.0.0.GA-1', '1.0.0.RC-1', check_suffix=True)) - self.assertTrue(ArchDataMapper.check_update('1.0.0.GE-1', '1.0.1.RC-1')) - self.assertTrue(ArchDataMapper.check_update('1.0.10.GA-10', '1.1.0.RC-1')) - self.assertFalse(ArchDataMapper.check_update('1.1.0.GE-1', '1.0.1.RC-1')) + self.assertTrue(ArchDataMapper.check_update('1.0.0.GE-1', '1.0.1.RC-1', check_suffix=True)) + self.assertTrue(ArchDataMapper.check_update('1.0.10.GA-10', '1.1.0.RC-1', check_suffix=True)) + self.assertFalse(ArchDataMapper.check_update('1.1.0.GE-1', '1.0.1.RC-1', check_suffix=True)) # BETA - self.assertFalse(ArchDataMapper.check_update('1.0.0.GA-1', '1.0.0.BETA-1')) - self.assertTrue(ArchDataMapper.check_update('1.0.0.GE-1', '1.0.1.BETA-1')) + self.assertFalse(ArchDataMapper.check_update('1.0.0.GA-1', '1.0.0.BETA-1', check_suffix=True)) + self.assertTrue(ArchDataMapper.check_update('1.0.0.GE-1', '1.0.1.BETA-1', check_suffix=True)) # ALPHA - self.assertFalse(ArchDataMapper.check_update('1.0.0.GE-1', '1.0.0.ALPHA-1')) - self.assertTrue(ArchDataMapper.check_update('1.0.0.GA-1', '1.0.1.ALPHA-1')) + self.assertFalse(ArchDataMapper.check_update('1.0.0.GE-1', '1.0.0.ALPHA-1', check_suffix=True)) + self.assertTrue(ArchDataMapper.check_update('1.0.0.GA-1', '1.0.1.ALPHA-1', check_suffix=True)) # DEV - self.assertFalse(ArchDataMapper.check_update('1.0.0.GA-1', '1.0.0.DEV-1')) - self.assertTrue(ArchDataMapper.check_update('1.0.0.GE-1', '1.0.1.DEV-1')) + self.assertFalse(ArchDataMapper.check_update('1.0.0.GA-1', '1.0.0.DEV-1', check_suffix=True)) + self.assertTrue(ArchDataMapper.check_update('1.0.0.GE-1', '1.0.1.DEV-1', check_suffix=True)) def test_check_update_rc(self): # RC @@ -148,38 +151,38 @@ class ArchDataMapperTest(TestCase): def test_check_update_beta(self): # BETA - self.assertFalse(ArchDataMapper.check_update('1.0.0.BETA-1', '1.0.0.BETA-1')) - self.assertTrue(ArchDataMapper.check_update('1.0.0.BETA-1', '1.0.0.BETA-2')) - self.assertTrue(ArchDataMapper.check_update('1.0.0.BETA-2', '1.0.1.B-1')) - self.assertFalse(ArchDataMapper.check_update('1.1.0.B-3', '1.0.6.BETA-10')) + self.assertFalse(ArchDataMapper.check_update('1.0.0.BETA-1', '1.0.0.BETA-1', check_suffix=True)) + self.assertTrue(ArchDataMapper.check_update('1.0.0.BETA-1', '1.0.0.BETA-2', check_suffix=True)) + self.assertTrue(ArchDataMapper.check_update('1.0.0.BETA-2', '1.0.1.B-1', check_suffix=True)) + self.assertFalse(ArchDataMapper.check_update('1.1.0.B-3', '1.0.6.BETA-10', check_suffix=True)) # ALPHA - self.assertFalse(ArchDataMapper.check_update('1.0.0.BETA-1', '1.0.0.ALPHA-1')) - self.assertTrue(ArchDataMapper.check_update('1.0.0.B-1', '1.0.1.ALPHA-1')) + self.assertFalse(ArchDataMapper.check_update('1.0.0.BETA-1', '1.0.0.ALPHA-1', check_suffix=True)) + self.assertTrue(ArchDataMapper.check_update('1.0.0.B-1', '1.0.1.ALPHA-1', check_suffix=True)) # DEV - self.assertFalse(ArchDataMapper.check_update('1.0.0.BETA-1', '1.0.0.DEV-1')) - self.assertTrue(ArchDataMapper.check_update('1.0.0.B-1', '1.0.1.DEV-1')) + self.assertFalse(ArchDataMapper.check_update('1.0.0.BETA-1', '1.0.0.DEV-1', check_suffix=True)) + self.assertTrue(ArchDataMapper.check_update('1.0.0.B-1', '1.0.1.DEV-1', check_suffix=True)) def test_check_update_alpha(self): # ALPHA - self.assertFalse(ArchDataMapper.check_update('1.0.0.ALPHA-1', '1.0.0.ALPHA-1')) - self.assertFalse(ArchDataMapper.check_update('1.0.0.ALPHA-1', '1.0.0.ALFA-1')) - self.assertTrue(ArchDataMapper.check_update('1.0.0.ALFA-1', '1.0.0.ALPHA-2')) - self.assertTrue(ArchDataMapper.check_update('1.0.0.ALPHA-2', '1.0.1.ALFA-1')) - self.assertFalse(ArchDataMapper.check_update('1.1.0.ALFA-3', '1.0.6.ALFA-10')) + self.assertFalse(ArchDataMapper.check_update('1.0.0.ALPHA-1', '1.0.0.ALPHA-1', check_suffix=True)) + self.assertFalse(ArchDataMapper.check_update('1.0.0.ALPHA-1', '1.0.0.ALFA-1', check_suffix=True)) + self.assertTrue(ArchDataMapper.check_update('1.0.0.ALFA-1', '1.0.0.ALPHA-2', check_suffix=True)) + self.assertTrue(ArchDataMapper.check_update('1.0.0.ALPHA-2', '1.0.1.ALFA-1', check_suffix=True)) + self.assertFalse(ArchDataMapper.check_update('1.1.0.ALFA-3', '1.0.6.ALFA-10', check_suffix=True)) # DEV - self.assertFalse(ArchDataMapper.check_update('1.0.0.ALFA-1', '1.0.0.DEV-1')) - self.assertTrue(ArchDataMapper.check_update('1.0.0.ALPHA-1', '1.0.1.DEV-1')) + self.assertFalse(ArchDataMapper.check_update('1.0.0.ALFA-1', '1.0.0.DEV-1', check_suffix=True)) + self.assertTrue(ArchDataMapper.check_update('1.0.0.ALPHA-1', '1.0.1.DEV-1', check_suffix=True)) def test_check_update_dev(self): - self.assertFalse(ArchDataMapper.check_update('1.0.0.DEV-1', '1.0.0.DEV-1')) - self.assertFalse(ArchDataMapper.check_update('1.0.0.DEV-1', '1.0.0.DEVEL-1')) - self.assertFalse(ArchDataMapper.check_update('1.0.0.DEVEL-1', '1.0.0.DEVELOPMENT-1')) - self.assertTrue(ArchDataMapper.check_update('1.0.0.DEV-1', '1.0.0.DEVEL-2')) - self.assertTrue(ArchDataMapper.check_update('1.0.0.DEVEL-2', '1.0.1.DEV-1')) - self.assertFalse(ArchDataMapper.check_update('1.1.0.DEV-3', '1.0.6.DEVELOPMENT-10')) + self.assertFalse(ArchDataMapper.check_update('1.0.0.DEV-1', '1.0.0.DEV-1', check_suffix=True)) + self.assertFalse(ArchDataMapper.check_update('1.0.0.DEV-1', '1.0.0.DEVEL-1', check_suffix=True)) + self.assertFalse(ArchDataMapper.check_update('1.0.0.DEVEL-1', '1.0.0.DEVELOPMENT-1', check_suffix=True)) + self.assertTrue(ArchDataMapper.check_update('1.0.0.DEV-1', '1.0.0.DEVEL-2', check_suffix=True)) + self.assertTrue(ArchDataMapper.check_update('1.0.0.DEVEL-2', '1.0.1.DEV-1', check_suffix=True)) + self.assertFalse(ArchDataMapper.check_update('1.1.0.DEV-3', '1.0.6.DEVELOPMENT-10', check_suffix=True)) def test_check_update_unknown_suffix(self): self.assertTrue(ArchDataMapper.check_update('1.0.0.BALL-1', '1.0.0.TAR-1')) diff --git a/tests/gems/arch/pacman_test.py b/tests/gems/arch/pacman_test.py new file mode 100644 index 00000000..ea7e2c25 --- /dev/null +++ b/tests/gems/arch/pacman_test.py @@ -0,0 +1,23 @@ +import os +from unittest import TestCase + +from bauh.gems.arch import pacman + +FILE_DIR = os.path.dirname(os.path.abspath(__file__)) + + +class PacmanTest(TestCase): + + def test_list_ignored_packages(self): + ignored = pacman.list_ignored_packages(FILE_DIR + '/resources/pacman_ign_pkgs.conf') + + self.assertIsNotNone(ignored) + self.assertEqual(2, len(ignored)) + self.assertIn('google-chrome', ignored) + self.assertIn('firefox', ignored) + + def test_list_ignored_packages__no_ignored_packages(self): + ignored = pacman.list_ignored_packages(FILE_DIR + '/resources/pacman.conf') + + self.assertIsNotNone(ignored) + self.assertEqual(0, len(ignored)) diff --git a/tests/gems/arch/resources/pacman.conf b/tests/gems/arch/resources/pacman.conf new file mode 100644 index 00000000..0649ebfc --- /dev/null +++ b/tests/gems/arch/resources/pacman.conf @@ -0,0 +1,98 @@ +# +# /etc/pacman.conf +# +# See the pacman.conf(5) manpage for option and repository directives + +# +# GENERAL OPTIONS +# +[options] +# The following paths are commented out with their default values listed. +# If you wish to use different paths, uncomment and update the paths. +#RootDir = / +#DBPath = /var/lib/pacman/ +CacheDir = /var/cache/pacman/pkg/ +#LogFile = /var/log/pacman.log +#GPGDir = /etc/pacman.d/gnupg/ +#HookDir = /etc/pacman.d/hooks/ +HoldPkg = pacman glibc manjaro-system +# If upgrades are available for these packages they will be asked for first +SyncFirst = manjaro-system archlinux-keyring manjaro-keyring +#XferCommand = /usr/bin/curl -C - -f %u > %o +#XferCommand = /usr/bin/wget --passive-ftp -c -O %o %u +#CleanMethod = KeepInstalled +#UseDelta = 0.7 +Architecture = auto + +# Pacman won't upgrade packages listed in IgnorePkg and members of IgnoreGroup +#IgnorePkg = +#IgnoreGroup = + +#NoUpgrade = +#NoExtract = + +# Misc options +#UseSyslog +#Color +#TotalDownload +# We cannot check disk space from within a chroot environment +CheckSpace +#VerbosePkgLists + +# By default, pacman accepts packages signed by keys that its local keyring +# trusts (see pacman-key and its man page), as well as unsigned packages. +SigLevel = Required DatabaseOptional +LocalFileSigLevel = Optional +#RemoteFileSigLevel = Required + +# NOTE: You must run `pacman-key --init` before first using pacman; the local +# keyring can then be populated with the keys of all official Manjaro Linux +# packagers with `pacman-key --populate archlinux manjaro`. + +# +# REPOSITORIES +# - can be defined here or included from another file +# - pacman will search repositories in the order defined here +# - local/custom mirrors can be added here or in separate files +# - repositories listed first will take precedence when packages +# have identical names, regardless of version number +# - URLs will have $repo replaced by the name of the current repo +# - URLs will have $arch replaced by the name of the architecture +# +# Repository entries are of the format: +# [repo-name] +# Server = ServerName +# Include = IncludePath +# +# The header [repo-name] is crucial - it must be present and +# uncommented to enable the repo. +# + +# The testing repositories are disabled by default. To enable, uncomment the +# repo name header and Include lines. You can add preferred servers immediately +# after the header, and they will be used before the default mirrors. + +[core] +SigLevel = PackageRequired +Include = /etc/pacman.d/mirrorlist + +[extra] +SigLevel = PackageRequired +Include = /etc/pacman.d/mirrorlist + +[community] +SigLevel = PackageRequired +Include = /etc/pacman.d/mirrorlist + +# If you want to run 32 bit applications on your x86_64 system, +# enable the multilib repositories as required here. + +[multilib] +SigLevel = PackageRequired +Include = /etc/pacman.d/mirrorlist + +# An example of a custom package repository. See the pacman manpage for +# tips on creating your own repositories. +#[custom] +#SigLevel = Optional TrustAll +#Server = file:///home/custompkgs \ No newline at end of file diff --git a/tests/gems/arch/resources/pacman_ign_pkgs.conf b/tests/gems/arch/resources/pacman_ign_pkgs.conf new file mode 100644 index 00000000..6e502ab0 --- /dev/null +++ b/tests/gems/arch/resources/pacman_ign_pkgs.conf @@ -0,0 +1,101 @@ +# +# /etc/pacman.conf +# +# See the pacman.conf(5) manpage for option and repository directives + +# +# GENERAL OPTIONS +# +[options] +# The following paths are commented out with their default values listed. +# If you wish to use different paths, uncomment and update the paths. +#RootDir = / +#DBPath = /var/lib/pacman/ +CacheDir = /var/cache/pacman/pkg/ +#LogFile = /var/log/pacman.log +#GPGDir = /etc/pacman.d/gnupg/ +#HookDir = /etc/pacman.d/hooks/ +HoldPkg = pacman glibc manjaro-system +# If upgrades are available for these packages they will be asked for first +SyncFirst = manjaro-system archlinux-keyring manjaro-keyring +#XferCommand = /usr/bin/curl -C - -f %u > %o +#XferCommand = /usr/bin/wget --passive-ftp -c -O %o %u +#CleanMethod = KeepInstalled +#UseDelta = 0.7 +Architecture = auto + +# Pacman won't upgrade packages listed in IgnorePkg and members of IgnoreGroup +#IgnorePkg = +#IgnoreGroup = + +#NoUpgrade = +#NoExtract = + +# Misc options +#UseSyslog +#Color +#TotalDownload +# We cannot check disk space from within a chroot environment +CheckSpace +#VerbosePkgLists + +# By default, pacman accepts packages signed by keys that its local keyring +# trusts (see pacman-key and its man page), as well as unsigned packages. +SigLevel = Required DatabaseOptional +LocalFileSigLevel = Optional +#RemoteFileSigLevel = Required + +# NOTE: You must run `pacman-key --init` before first using pacman; the local +# keyring can then be populated with the keys of all official Manjaro Linux +# packagers with `pacman-key --populate archlinux manjaro`. + +# +# REPOSITORIES +# - can be defined here or included from another file +# - pacman will search repositories in the order defined here +# - local/custom mirrors can be added here or in separate files +# - repositories listed first will take precedence when packages +# have identical names, regardless of version number +# - URLs will have $repo replaced by the name of the current repo +# - URLs will have $arch replaced by the name of the architecture +# +# Repository entries are of the format: +# [repo-name] +# Server = ServerName +# Include = IncludePath +# +# The header [repo-name] is crucial - it must be present and +# uncommented to enable the repo. +# + +# The testing repositories are disabled by default. To enable, uncomment the +# repo name header and Include lines. You can add preferred servers immediately +# after the header, and they will be used before the default mirrors. + +[core] +SigLevel = PackageRequired +Include = /etc/pacman.d/mirrorlist + +[extra] +SigLevel = PackageRequired +Include = /etc/pacman.d/mirrorlist + +[community] +SigLevel = PackageRequired +Include = /etc/pacman.d/mirrorlist + +# If you want to run 32 bit applications on your x86_64 system, +# enable the multilib repositories as required here. + +[multilib] +SigLevel = PackageRequired +Include = /etc/pacman.d/mirrorlist + +# An example of a custom package repository. See the pacman manpage for +# tips on creating your own repositories. +#[custom] +#SigLevel = Optional TrustAll +#Server = file:///home/custompkgs +ignorepkg=google-chrome +# ignorepkg=abc +ignorepkg = firefox \ No newline at end of file diff --git a/tests/gems/flatpak/__init__.py b/tests/gems/flatpak/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/gems/flatpak/worker_test.py b/tests/gems/flatpak/worker_test.py new file mode 100644 index 00000000..cdc7886d --- /dev/null +++ b/tests/gems/flatpak/worker_test.py @@ -0,0 +1,20 @@ +from unittest import TestCase + +from bauh.gems.flatpak.worker import FlatpakAsyncDataLoader + + +class FlatpakAsyncDataLoaderTest(TestCase): + + def test_format_category(self): + self.assertEqual('irc client', FlatpakAsyncDataLoader.format_category('IRCClient')) + self.assertEqual('text editor', FlatpakAsyncDataLoader.format_category('TextEditor')) + self.assertEqual('text editor', FlatpakAsyncDataLoader.format_category('Text Editor')) + self.assertEqual('text editor', FlatpakAsyncDataLoader.format_category('text editor')) + self.assertEqual('text editor', FlatpakAsyncDataLoader.format_category('Text editor')) + self.assertEqual('text editor', FlatpakAsyncDataLoader.format_category('text Editor')) + self.assertEqual('text editor', FlatpakAsyncDataLoader.format_category('textEditor')) + self.assertEqual('ide', FlatpakAsyncDataLoader.format_category('IDE')) + self.assertEqual('faster irc client', FlatpakAsyncDataLoader.format_category('Faster IRCClient')) + self.assertEqual('3d graphics', FlatpakAsyncDataLoader.format_category('3DGraphics')) + self.assertEqual('32d graphics', FlatpakAsyncDataLoader.format_category('32DGraphics')) + self.assertEqual('d32 graphics', FlatpakAsyncDataLoader.format_category('D32Graphics'))