diff --git a/bauh/api/__init__.py b/bauh/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bauh/api/abstract/__init__.py b/bauh/api/abstract/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bauh/api/abstract/cache.py b/bauh/api/abstract/cache.py new file mode 100644 index 00000000..ac3c2534 --- /dev/null +++ b/bauh/api/abstract/cache.py @@ -0,0 +1,50 @@ +from abc import ABC, abstractmethod +from typing import Set + + +class MemoryCache(ABC): + """ + Represents a memory cache. + """ + + @abstractmethod + def is_enabled(self): + pass + + @abstractmethod + def add(self, key: str, val: object): + pass + + @abstractmethod + def add_non_existing(self, key: str, val: object): + pass + + @abstractmethod + def get(self, key: str): + pass + + @abstractmethod + def delete(self, key): + pass + + @abstractmethod + def keys(self) -> Set[str]: + pass + + @abstractmethod + def clean_expired(self): + pass + + +class MemoryCacheFactory(ABC): + """ + Instantiate new memory cache instances. + """ + + @abstractmethod + def new(self, expiration: int) -> MemoryCache: + """ + :param expiration: expiration time for the cache keys in seconds. Use -1 to disable this feature. + :return: + """ + pass diff --git a/bauh/api/abstract/context.py b/bauh/api/abstract/context.py new file mode 100644 index 00000000..6d4b2023 --- /dev/null +++ b/bauh/api/abstract/context.py @@ -0,0 +1,30 @@ +import logging + +from bauh.api.abstract.cache import MemoryCacheFactory +from bauh.api.abstract.disk import DiskCacheLoaderFactory +from bauh.api.http import HttpClient + + +class ApplicationContext: + + def __init__(self, disk_cache: bool, download_icons: bool, http_client: HttpClient, app_root_dir: str, i18n: dict, + cache_factory: MemoryCacheFactory, disk_loader_factory: DiskCacheLoaderFactory, + logger: logging.Logger): + """ + :param disk_cache: if package data should be cached to disk + :param download_icons: if packages icons should be downloaded + :param http_client: a shared instance of http client + :param app_root_dir: GUI root dir + :param i18n: the i18n dictionary keys + :param cache_factory: + :param disk_loader_factory: + :param logger: a logger instance + """ + self.disk_cache = disk_cache + self.download_icons = download_icons + self.http_client = http_client + self.app_root_dir = app_root_dir + self.i18n = i18n + self.cache_factory = cache_factory + self.disk_loader_factory = disk_loader_factory + self.logger = logger diff --git a/bauh/api/abstract/controller.py b/bauh/api/abstract/controller.py new file mode 100644 index 00000000..949bc876 --- /dev/null +++ b/bauh/api/abstract/controller.py @@ -0,0 +1,214 @@ +import json +import os +import shutil +from abc import ABC, abstractmethod +from pathlib import Path +from typing import List, Set, Type + +from bauh.api.abstract.context import ApplicationContext +from bauh.api.abstract.disk import DiskCacheLoader +from bauh.api.abstract.handler import ProcessWatcher +from bauh.api.abstract.model import SoftwarePackage, PackageUpdate, PackageHistory, PackageSuggestion, PackageAction + + +class SearchResult: + + def __init__(self, installed: List[SoftwarePackage], new: List[SoftwarePackage], total: int): + """ + :param installed: already installed packages + :param new: new packages found + :param total: total number of applications actually found + """ + self.installed = installed + self.new = new + self.total = total + + +class SoftwareManager(ABC): + + """ + Base controller class that will be called by the graphical interface to execute operations. + """ + + def __init__(self, context: ApplicationContext): + """ + :param context: + """ + self.context = context + + @abstractmethod + def search(self, words: str, disk_loader: DiskCacheLoader, limit: int) -> SearchResult: + """ + :param words: the words typed by the user + :param disk_loader: a running disk loader thread that loads package data from the disk asynchronously + :param limit: the max number of packages to be retrieved. <= 1 should retrieve everything + :return: + """ + pass + + @abstractmethod + def read_installed(self, disk_loader: DiskCacheLoader, limit: int, only_apps: bool, pkg_types: Set[Type[SoftwarePackage]]) -> SearchResult: + """ + :param disk_loader: a running disk loader thread that loads application data from the disk asynchronously + :param limit: the max number of packages to be retrieved. <= 1 should retrieve everything + :param only_apps: if only application packages should be retrieved + :param pkg_types: use 'None' to bring any or specify some + :return: + """ + pass + + @abstractmethod + def downgrade(self, pkg: SoftwarePackage, root_password: str, handler: ProcessWatcher) -> bool: + """ + downgrades a package version + :param pkg: + :param root_password: the root user password (if required) + :param handler: a subprocess handler + :return: + """ + pass + + def clean_cache_for(self, pkg: SoftwarePackage): + """ + Cleans cached package cached data. This default implementation only cleans the cached data from the heard disk + :param pkg: + :return: + """ + if pkg.supports_disk_cache() and os.path.exists(pkg.get_disk_cache_path()): + shutil.rmtree(pkg.get_disk_cache_path()) + + @abstractmethod + def update(self, pkg: SoftwarePackage, root_password: str, watcher: ProcessWatcher) -> bool: + """ + :param pkg: + :param root_password: the root user password (if required) + :param watcher: + :return: + """ + pass + + @abstractmethod + def uninstall(self, pkg: SoftwarePackage, root_password: str, watcher: ProcessWatcher) -> bool: + """ + :param pkg: + :param root_password: the root user password (if required) + :param watcher: + :return: if the uninstall succeeded + """ + pass + + @abstractmethod + def get_managed_types(self) -> Set[Type[SoftwarePackage]]: + """ + :return: the managed package class type + """ + pass + + @abstractmethod + def get_info(self, pkg: SoftwarePackage) -> dict: + """ + retrieve the package information + :param pkg: + :return: a dictionary with the attributes to be shown + """ + pass + + @abstractmethod + def get_history(self, pkg: SoftwarePackage) -> PackageHistory: + """ + :param pkg: + :return: + """ + pass + + @abstractmethod + def install(self, pkg: SoftwarePackage, root_password: str, watcher: ProcessWatcher) -> bool: + """ + :param pkg: + :param root_password: the root user password (if required) + :param watcher: + :return: if the installation succeeded + """ + pass + + @abstractmethod + def is_enabled(self) -> bool: + """ + :return: if the instance is available to perform actions + """ + pass + + def cache_to_disk(self, pkg: SoftwarePackage, icon_bytes: bytes, only_icon: bool): + """ + Saves the package data to the hard disk. + :param pkg: + :param icon_bytes: + :param only_icon: if only the icon should be saved + :return: + """ + if pkg.supports_disk_cache(): + + if not only_icon: + Path(pkg.get_disk_cache_path()).mkdir(parents=True, exist_ok=True) + data = pkg.get_data_to_cache() + + if data: + with open(pkg.get_disk_data_path(), 'w+') as f: + f.write(json.dumps(data)) + + if icon_bytes: + Path(pkg.get_disk_cache_path()).mkdir(parents=True, exist_ok=True) + + with open(pkg.get_disk_icon_path(), 'wb+') as f: + f.write(icon_bytes) + + @abstractmethod + def requires_root(self, action: str, pkg: SoftwarePackage): + """ + if a given action requires root privileges to be executed. Current actions are: 'install', 'uninstall', 'downgrade', 'search', 'refresh' + :param action: + :param pkg: + :return: + """ + pass + + @abstractmethod + def prepare(self): + """ + It prepares the manager to start working. It will be called by GUI. Do not call it within. + :return: + """ + pass + + @abstractmethod + def list_updates(self) -> List[PackageUpdate]: + """ + :return: available package updates + """ + pass + + @abstractmethod + def list_warnings(self) -> List[str]: + """ + :return: a list of warnings to be shown to the user + """ + pass + + @abstractmethod + def list_suggestions(self, limit: int) -> List[PackageSuggestion]: + """ + :param limit: max suggestions to be returned. If limit < 0, it should not be considered + :return: a list of package suggestions + """ + pass + + def execute_custom_action(self, action: PackageAction, pkg: SoftwarePackage, root_password: str, watcher: ProcessWatcher) -> bool: + """ + At the moment the GUI implements this action. No need to implement it yourself. + :param action: + :param pkg: + :param root_password: + :param watcher: + :return: if the action resulted in success + """ + pass diff --git a/bauh/api/abstract/disk.py b/bauh/api/abstract/disk.py new file mode 100644 index 00000000..02de2707 --- /dev/null +++ b/bauh/api/abstract/disk.py @@ -0,0 +1,42 @@ +from abc import ABC, abstractmethod +from typing import Type + +from bauh.api.abstract.cache import MemoryCache +from bauh.api.abstract.model import SoftwarePackage + + +class DiskCacheLoader: + """ + Reads cached data from the disk and fills package instances. + """ + + def map(self, cache: MemoryCache, pkg_type: Type[SoftwarePackage]): + """ + maps a given cache instance for a given package type + :param cache: + :param pkg_type: + :return: + """ + pass + + def fill(self, pkg: SoftwarePackage): + """ + fill cached data from the disk of a given package instance + If a cache mapping was previously done, then data retrieved will be cached to memory as well. + :param pkg: + :return: + """ + pass + + +class DiskCacheLoaderFactory(ABC): + + @abstractmethod + def map(self, pkg_type: Type[SoftwarePackage], cache: MemoryCache): + """ + Associated a cache instance to instances of a given SoftwarePackage class + :param pkg_type: + :param cache: + :return: + """ + pass diff --git a/bauh/api/abstract/handler.py b/bauh/api/abstract/handler.py new file mode 100644 index 00000000..ffdc52c6 --- /dev/null +++ b/bauh/api/abstract/handler.py @@ -0,0 +1,67 @@ +from typing import List + +from bauh.api.abstract.view import MessageType, ViewComponent + + +class ProcessWatcher: + """ + Represents an view component watching background processes. It's a bridge for ApplicationManager instances notifiy the view of processes progression and also + request any user interaction without the need of knowing any GUI code. + """ + + def print(self, msg: str): + """ + prints a given message to the user. In the current GUI implementation, the message is printed on the terminal window ("Details") + :param msg: + :return: + """ + pass + + def request_confirmation(self, title: str, body: str, components: List[ViewComponent] = None, confirmation_label: str = None, deny_label: str = None)-> bool: + """ + request a user confirmation. In the current GUI implementation, it shows a popup to the user. + :param title: popup title + :param body: popup body + :param components: extra view components that will be rendered to the confirmation popup. + :param confirmation_label: optional confirmation button label (default to 'yes') + :param deny_label: optional deny button label (default to 'no') + :return: if the request was confirmed by the user + """ + pass + + def show_message(self, title: str, body: str, type_: MessageType = MessageType.INFO): + """ + shows a message to the user. In the current GUI implementation, it shows a popup. + :param title: + :param body: + :param type_: determines the icon that will be displayed + :return: + """ + pass + + def change_status(self, msg: str): + """ + Changes the process status. In the current GUI implementation, the process status is displayed above the toolbar. + :param msg: msg + :return: + """ + pass + + def change_substatus(self, msg: str): + """ + Changes the process substatus. In the current GUI implementation, the process substatus is displayed above the progress bar. + :param msg: + :return: + """ + + def change_progress(self, val: int): + """ + Changes the process progress. In the current GUI implementation, the progress is displayed as a bottom bar. + :param val: a val between 0 and 100 + :return: + """ + + def should_stop(self) -> bool: + """ + :return: if the use requested to stop the process. + """ diff --git a/bauh/api/abstract/model.py b/bauh/api/abstract/model.py new file mode 100644 index 00000000..0843da6d --- /dev/null +++ b/bauh/api/abstract/model.py @@ -0,0 +1,222 @@ +from abc import ABC, abstractmethod +from enum import Enum +from typing import List + +from bauh.api.constants import CACHE_PATH + + +class PackageAction: + + def __init__(self, i18_label_key: str, i18n_status_key: str, icon_path: str, manager_method: str, requires_root: bool): + """ + :param i18_label_key: the i18n key that will be used to display the action name + :param i18n_status_key: the i18n key that will be used to display the action name being executed + :param icon_path: the action icon path. Use None for no icon + :param manager_method: the SoftwareManager method name that should be called. The method must has the following parameters: (pkg: SoftwarePackage, root_password: str, watcher: ProcessWatcher) + :param requires_root: + """ + self.id = id + self.i18_label_key = i18_label_key + self.i18n_status_key = i18n_status_key + self.icon_path = icon_path + self.manager_method = manager_method + self.requires_root = requires_root + + +class PackageStatus(Enum): + READY = 1 # when all package data is already filled + LOADING_DATA = 2 # when some package data is already being retrieved asynchronously + + +class SoftwarePackage(ABC): + + def __init__(self, id: str = None, version: str = None, name: str = None, description: str = None, latest_version: str = None, + icon_url: str = None, status: PackageStatus = PackageStatus.READY, installed: bool = False, update: bool = False, + size: int = None, categories: List[str] = None): + """ + :param id: + :param version: + :param name: + :param description: + :param latest_version: + :param icon_url: the path to the package icon. It can be an URL or PATH + :param status: + :param installed: + :param update: if there is an update for package + :param size: package size in BYTES + :param categories: package categories. i.e: video editor, web browser, etc + """ + self.id = id + self.name = name + self.description = description + self.status = status + self.version = version + self.latest_version = latest_version + self.icon_url = icon_url + self.installed = installed + self.update = update + self.size = size + self.categories = categories + + @abstractmethod + def has_history(self): + """ + :return: if the application has a commit history that can be shown to the user + """ + pass + + @abstractmethod + def has_info(self): + """ + :return: if the application has additional information that can be shown to the user + """ + pass + + @abstractmethod + def can_be_downgraded(self): + pass + + def can_be_uninstalled(self): + return self.installed + + def can_be_installed(self): + return not self.installed + + @abstractmethod + def get_type(self): + """ + :return: a string that represents the application type + """ + pass + + @abstractmethod + def get_default_icon_path(self): + """ + :return: the path of a default icon when the application icon could not be retrieved (or will not be retrieved) + """ + pass + + @abstractmethod + def get_type_icon_path(self): + """ + :return: the path of the application type icon + """ + + @abstractmethod + def is_application(self): + """ + :return: if the package is an application + """ + pass + + def supports_disk_cache(self): + """ + :return: if some application data and icon should be cached to the user disk + """ + return self.installed and self.is_application() + + def get_disk_cache_path(self): + """ + :return: base cache path for the specific app type + """ + return CACHE_PATH + '/' + self.get_type() + + def get_disk_icon_path(self): + return '{}/icon.png'.format(self.get_disk_cache_path()) + + def get_disk_data_path(self): + return '{}/data.json'.format(self.get_disk_cache_path()) + + @abstractmethod + def get_data_to_cache(self) -> dict: + """ + :return: the application data that should be cached in disk / memory for quick access + """ + pass + + @abstractmethod + def fill_cached_data(self, data: dict): + """ + sets cached data to the current instance + :param data: + :return: + """ + pass + + @abstractmethod + def can_be_run(self) -> str: + """ + :return: whether the app can be run via the GUI + """ + + @abstractmethod + def get_command(self) -> str: + """ + :return: the command to run the application + """ + pass + + def is_trustable(self) -> bool: + """ + :return: if the package is distributed by a trustable source + """ + return False + + @abstractmethod + def get_publisher(self) -> str: + """ + :return: the package publisher / maintainer + """ + pass + + def get_custom_supported_actions(self) -> List[PackageAction]: + """ + :return: custom supported actions + """ + pass + + def __str__(self): + return '{} (id={}, name={})'.format(self.__class__.__name__, self.id, self.name) + + +class PackageUpdate: + + def __init__(self, pkg_id: str, version: str, pkg_type: str): + """ + :param pkg_id: an unique package identifier + :param version: the new version + :param pkg_type: the package type + """ + self.id = pkg_id + self.version = version + self.type = pkg_type + + def __str__(self): + return '{} (id={}, type={}, new_version={})'.format(self.__class__.__name__, self.id, self.type, self.type) + + +class PackageHistory: + + def __init__(self, pkg: SoftwarePackage, history: List[dict], pkg_status_idx: int): + """ + :param pkg + :param history: a list with the package history. + :param pkg_status_idx: 'history' index in which the application is current found + """ + self.pkg = pkg + self.history = history + self.pkg_status_idx = pkg_status_idx + + +class SuggestionPriority(Enum): + LOW = 0 + MEDIUM = 1 + HIGH = 2 + TOP = 3 + + +class PackageSuggestion: + + def __init__(self, package: SoftwarePackage, priority: SuggestionPriority): + self.package = package + self.priority = priority diff --git a/bauh/api/abstract/view.py b/bauh/api/abstract/view.py new file mode 100644 index 00000000..e6dbbb08 --- /dev/null +++ b/bauh/api/abstract/view.py @@ -0,0 +1,90 @@ +from abc import ABC +from enum import Enum +from typing import List, Set + + +class MessageType: + INFO = 0 + WARNING = 1 + ERROR = 2 + + +class ViewComponent(ABC): + """ + Represents a GUI component + """ + def __init__(self, id_: str): + self.id = id_ + + +class InputViewComponent(ViewComponent): + """ + Represents an component which needs a user interaction to provide its value + """ + + +class InputOption: + """ + Represents a select component option. + """ + + def __init__(self, label: str, value: str, tooltip: str = None, icon_path: str = None): + """ + :param label: the string that will be shown to the user + :param value: the option value (not shown) + :param tooltip: an optional tooltip + :param icon_path: an optional icon path + """ + if not label: + raise Exception("'label' must be a not blank string") + + if not value: + raise Exception("'value' must be a not blank string") + + self.label = label + self.value = value + self.tooltip = tooltip + self.icon_path = icon_path + + def __hash__(self): + return hash(self.label) + hash(self.value) + + +class SelectViewType(Enum): + RADIO = 0 + COMBO = 1 + + +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 + self.value = default_option + self.max_per_line = max_per_line + + +class MultipleSelectComponent(InputViewComponent): + + def __init__(self, label: str, options: List[InputOption], default_options: Set[InputOption] = None, max_per_line: int = 1, id_: str = None): + super(MultipleSelectComponent, self).__init__(id_=id_) + + if not options: + raise Exception("'options' cannot be None or empty") + + self.options = options + self.label = label + self.values = default_options if default_options else set() + self.max_per_line = max_per_line + + +class TextComponent(ViewComponent): + + def __init__(self, html: str, id_: str = None): + super(TextComponent, self).__init__(id_=id_) + self.value = html diff --git a/bauh/api/constants.py b/bauh/api/constants.py new file mode 100644 index 00000000..e216d7f2 --- /dev/null +++ b/bauh/api/constants.py @@ -0,0 +1,4 @@ +from pathlib import Path + +HOME_PATH = Path.home() +CACHE_PATH = '{}/.cache/bauh'.format(HOME_PATH) diff --git a/bauh/api/exception.py b/bauh/api/exception.py new file mode 100644 index 00000000..8d00ac1a --- /dev/null +++ b/bauh/api/exception.py @@ -0,0 +1,3 @@ + +class NoInternetException(Exception): + pass diff --git a/bauh/api/http.py b/bauh/api/http.py new file mode 100644 index 00000000..d01d9cc7 --- /dev/null +++ b/bauh/api/http.py @@ -0,0 +1,40 @@ +import logging +import time +import traceback + +import requests + + +class HttpClient: + + def __init__(self, logger: logging.Logger, max_attempts: int = 2, timeout: int = 30, sleep: float = 0.5): + self.max_attempts = max_attempts + self.session = requests.Session() + self.timeout = timeout + self.sleep = sleep + self.logger = logger + + def get(self, url: str): + cur_attempts = 1 + + while cur_attempts <= self.max_attempts: + cur_attempts += 1 + + try: + res = self.session.get(url, timeout=self.timeout) + + if res.status_code == 200: + return res + + if self.sleep > 0: + time.sleep(self.sleep) + except: + self.logger.error("Could not retrieve data from '{}'".format(url)) + traceback.print_exc() + continue + + self.logger.warning("Could not retrieve data from '{}'".format(url)) + + def get_json(self, url: str): + res = self.get(url) + return res.json() if res else None diff --git a/bauh/app.py b/bauh/app.py index 39652824..ea910e0c 100755 --- a/bauh/app.py +++ b/bauh/app.py @@ -2,15 +2,16 @@ import sys from PyQt5.QtGui import QIcon from PyQt5.QtWidgets import QApplication -from bauh_api.abstract.controller import ApplicationContext -from bauh_api.http import HttpClient from bauh import __version__, __app_name__, app_args, ROOT_DIR -from bauh.core import extensions +from bauh.api.abstract.controller import ApplicationContext +from bauh.api.http import HttpClient +from bauh.core import gems, config from bauh.core.controller import GenericSoftwareManager from bauh.util import util, logs, resource from bauh.util.cache import DefaultMemoryCacheFactory, CacheCleaner from bauh.util.disk import DefaultDiskCacheLoaderFactory +from bauh.view.qt.gem_selector import GemSelectorPanel from bauh.view.qt.systray import TrayIcon from bauh.view.qt.window import ManageWindow @@ -33,37 +34,48 @@ context = ApplicationContext(i18n=i18n, disk_loader_factory=DefaultDiskCacheLoaderFactory(disk_cache_enabled=args.disk_cache), logger=logger) -managers = extensions.load_managers(context=context, locale=args.locale) - - -manager = GenericSoftwareManager(managers, context=context, app_args=args) -manager.prepare() - app = QApplication(sys.argv) app.setApplicationName(__app_name__) app.setApplicationVersion(__version__) app.setWindowIcon(QIcon(resource.get_path('img/logo.svg'))) -manage_window = ManageWindow(i18n=i18n, - manager=manager, - icon_cache=icon_cache, - disk_cache=args.disk_cache, - download_icons=bool(args.download_icons), - screen_size=app.primaryScreen().size(), - suggestions=args.sugs, - display_limit=args.max_displayed) +user_config = config.read() -if args.tray: - trayIcon = TrayIcon(locale_keys=i18n, - manager=manager, - manage_window=manage_window, - check_interval=args.check_interval, - update_notification=bool(args.update_notification)) - manage_window.tray_icon = trayIcon - trayIcon.show() +if not user_config.gems: + managers = gems.load_managers(context=context, locale=args.locale) else: - manage_window.refresh_apps() - manage_window.show() + managers = gems.load_managers(context=context, locale=args.locale, names=user_config.gems) + +enabled_managers = [m for m in managers if m.is_enabled()] + +if not user_config.gems and enabled_managers: + gem_panel = GemSelectorPanel(enabled_managers, i18n, boot=True) + gem_panel.show() +else: + manager = GenericSoftwareManager(enabled_managers, context=context, app_args=args) + manager.prepare() + + manage_window = ManageWindow(i18n=i18n, + manager=manager, + icon_cache=icon_cache, + disk_cache=args.disk_cache, + download_icons=bool(args.download_icons), + screen_size=app.primaryScreen().size(), + suggestions=args.sugs, + display_limit=args.max_displayed) + + if args.tray: + trayIcon = TrayIcon(locale_keys=i18n, + manager=manager, + manage_window=manage_window, + check_interval=args.check_interval, + update_notification=bool(args.update_notification)) + manage_window.tray_icon = trayIcon + trayIcon.show() + else: + manage_window.refresh_apps() + manage_window.show() + + cache_cleaner.start() -cache_cleaner.start() sys.exit(app.exec_()) diff --git a/bauh/commons/__init__.py b/bauh/commons/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bauh/commons/html.py b/bauh/commons/html.py new file mode 100644 index 00000000..7ca8bbbe --- /dev/null +++ b/bauh/commons/html.py @@ -0,0 +1,11 @@ +import re + +HTML_RE = re.compile(r'<[^>]+>') + + +def strip_html(string: str): + return HTML_RE.sub('', string) + + +def bold(text: str) -> str: + return '{}'.format(text) diff --git a/bauh/commons/resource.py b/bauh/commons/resource.py new file mode 100644 index 00000000..2f48b317 --- /dev/null +++ b/bauh/commons/resource.py @@ -0,0 +1,3 @@ + +def get_path(resource_path, root_dir: str): + return root_dir + '/resources/' + resource_path diff --git a/bauh/commons/system.py b/bauh/commons/system.py new file mode 100644 index 00000000..2e9f8a58 --- /dev/null +++ b/bauh/commons/system.py @@ -0,0 +1,135 @@ +import os +import subprocess +from subprocess import PIPE +from typing import List + +# default environment variables for subprocesses. +from bauh.api.abstract.handler import ProcessWatcher + +GLOBAL_INTERPRETER_PATH = ':'.join(os.getenv('PATH').split(':')[1:]) +USE_GLOBAL_INTERPRETER = bool(os.getenv('VIRTUAL_ENV')) + + +def gen_env(global_interpreter: bool) -> dict: + res = {'LANG': 'en'} + + if global_interpreter: # to avoid subprocess calls to the virtualenv python interpreter instead of the global one. + res['PATH'] = GLOBAL_INTERPRETER_PATH + + return res + + +class SystemProcess: + + """ + Represents a system process being executed. + """ + + def __init__(self, subproc: subprocess.Popen, success_phrase: str = None, wrong_error_phrase: str = '[sudo] password for', check_error_output: bool = True): + self.subproc = subproc + self.success_phrase = success_phrase + self.wrong_error_phrase = wrong_error_phrase + self.check_error_output = check_error_output + + def wait(self): + self.subproc.wait() + + +class ProcessHandler: + + """ + It handles a process execution and notifies a specified watcher. + """ + + def __init__(self, watcher: ProcessWatcher = None): + self.watcher = watcher + + def _notify_watcher(self, msg: str): + if self.watcher: + self.watcher.print(msg) + + def handle(self, process: SystemProcess) -> bool: + self._notify_watcher(' '.join(process.subproc.args) + '\n') + + already_succeeded = False + + for output in process.subproc.stdout: + line = output.decode().strip() + if line: + self._notify_watcher(line) + + if process.success_phrase and process.success_phrase in line: + already_succeeded = True + + if already_succeeded: + return True + + for output in process.subproc.stderr: + line = output.decode().strip() + if line: + self._notify_watcher(line) + + if process.check_error_output: + if process.wrong_error_phrase and process.wrong_error_phrase in line: + continue + else: + break + + return process.subproc.returncode is None or process.subproc.returncode == 0 + + +def run_cmd(cmd: str, expected_code: int = 0, ignore_return_code: bool = False, print_error: bool = True, + cwd: str = '.', global_interpreter: bool = USE_GLOBAL_INTERPRETER) -> str: + """ + runs a given command and returns its default output + :param cmd: + :param expected_code: + :param ignore_return_code: + :param print_error: + :param global_interpreter + :return: + """ + args = { + "shell": True, + "stdout": PIPE, + "env": gen_env(global_interpreter), + 'cwd': cwd + } + + if not print_error: + args["stderr"] = subprocess.DEVNULL + + res = subprocess.run(cmd, **args) + return res.stdout.decode() if ignore_return_code or res.returncode == expected_code else None + + +def new_subprocess(cmd: List[str], cwd: str = '.', shell: bool = False, stdin = None, + global_interpreter: bool = USE_GLOBAL_INTERPRETER) -> subprocess.Popen: + args = { + "stdout": PIPE, + "stderr": PIPE, + "cwd": cwd, + "shell": shell, + "env": gen_env(global_interpreter) + } + + if input: + args['stdin'] = stdin + + return subprocess.Popen(cmd, **args) + + +def new_root_subprocess(cmd: List[str], root_password: str, cwd: str = '.', + global_interpreter: bool = USE_GLOBAL_INTERPRETER) -> subprocess.Popen: + pwdin, final_cmd = None, [] + + if root_password is not None: + final_cmd.extend(['sudo', '-S']) + pwdin = new_subprocess(['echo', root_password], global_interpreter=global_interpreter).stdout + + final_cmd.extend(cmd) + return subprocess.Popen(final_cmd, stdin=pwdin, stdout=PIPE, stderr=PIPE, cwd=cwd) + + +def notify_user(msg: str, app_name: str, icon_path: str): + os.system("notify-send -a {} {} '{}'".format(app_name, "-i {}".format(icon_path) if icon_path else '', msg)) diff --git a/bauh/core/config.py b/bauh/core/config.py new file mode 100644 index 00000000..c101cd78 --- /dev/null +++ b/bauh/core/config.py @@ -0,0 +1,33 @@ +import json +import os +from pathlib import Path +from typing import List + +from bauh import __app_name__ +from bauh.api.constants import HOME_PATH + +CONFIG_PATH = '{}/.config/{}'.format(HOME_PATH, __app_name__) +FILE_PATH = '{}/config.json'.format(CONFIG_PATH) + + +class Configuration: + + def __init__(self, gems: List[str]): + self.gems = gems + + +def read() -> Configuration: + if os.path.exists(FILE_PATH): + with open(FILE_PATH) as f: + config_file = f.read() + + return Configuration(**json.loads(config_file)) + + return Configuration(gems=None) + + +def save(config: Configuration): + Path(CONFIG_PATH).mkdir(parents=True, exist_ok=True) + + with open(FILE_PATH, 'w+') as f: + f.write(json.dumps(config.__dict__, indent=2)) diff --git a/bauh/core/controller.py b/bauh/core/controller.py index 316f8227..213f25e6 100755 --- a/bauh/core/controller.py +++ b/bauh/core/controller.py @@ -2,10 +2,10 @@ from argparse import Namespace from threading import Thread from typing import List, Set, Type -from bauh_api.abstract.controller import SoftwareManager, SearchResult, ApplicationContext -from bauh_api.abstract.disk import DiskCacheLoader -from bauh_api.abstract.handler import ProcessWatcher -from bauh_api.abstract.model import SoftwarePackage, PackageUpdate, PackageHistory, PackageSuggestion, PackageAction +from bauh.api.abstract.controller import SoftwareManager, SearchResult, ApplicationContext +from bauh.api.abstract.disk import DiskCacheLoader +from bauh.api.abstract.handler import ProcessWatcher +from bauh.api.abstract.model import SoftwarePackage, PackageUpdate, PackageHistory, PackageSuggestion, PackageAction SUGGESTIONS_LIMIT = 5 diff --git a/bauh/core/extensions.py b/bauh/core/extensions.py deleted file mode 100644 index 1c1ea698..00000000 --- a/bauh/core/extensions.py +++ /dev/null @@ -1,44 +0,0 @@ -import inspect -import os -import pkgutil -from typing import List - -from bauh_api.abstract.controller import SoftwareManager, ApplicationContext - -from bauh import __app_name__ -from bauh.util import util - -ignore_modules = {'{}_api'.format(__app_name__), '{}_commons'.format(__app_name__)} -ext_pattern = '{}_'.format(__app_name__) - - -def find_manager(member): - if not isinstance(member, str): - if inspect.isclass(member) and inspect.getmro(member)[1].__name__ == 'SoftwareManager': - return member - elif inspect.ismodule(member) and member.__name__ not in ignore_modules: - for name, mod in inspect.getmembers(member): - manager_found = find_manager(mod) - if manager_found: - return manager_found - - -def load_managers(locale: str, context: ApplicationContext) -> List[SoftwareManager]: - managers = [] - - for m in pkgutil.iter_modules(): - if m.ispkg and m.name and m.name not in ignore_modules and m.name.startswith(ext_pattern): - module = pkgutil.find_loader(m.name).load_module() - - if hasattr(module, 'controller'): - manager_class = find_manager(module.controller) - - if manager_class: - locale_path = '{}/resources/locale'.format(module.__path__[0]) - - if os.path.exists(locale_path): - context.i18n.update(util.get_locale_keys(locale, locale_path)) - - managers.append(manager_class(context=context)) - - return managers diff --git a/bauh/core/gems.py b/bauh/core/gems.py new file mode 100644 index 00000000..4e5a1f85 --- /dev/null +++ b/bauh/core/gems.py @@ -0,0 +1,40 @@ +import inspect +import os +import pkgutil +from typing import List + +from bauh import ROOT_DIR +from bauh.api.abstract.controller import SoftwareManager, ApplicationContext +from bauh.util import util + + +def find_manager(member): + if not isinstance(member, str): + if inspect.isclass(member) and inspect.getmro(member)[1].__name__ == 'SoftwareManager': + return member + elif inspect.ismodule(member): + for name, mod in inspect.getmembers(member): + manager_found = find_manager(mod) + if manager_found: + return manager_found + + +def load_managers(locale: str, context: ApplicationContext, names: List[str] = None) -> List[SoftwareManager]: + managers = [] + + for f in os.scandir(ROOT_DIR + '/gems'): + if f.is_dir() and f.name != '__pycache__' and (not names or f.name in names): + module = pkgutil.find_loader('bauh.gems.{}.controller'.format(f.name)).load_module() + + manager_class = find_manager(module) + + if manager_class: + locale_path = '{}/resources/locale'.format(f.path) + + if os.path.exists(locale_path): + context.i18n.update(util.get_locale_keys(locale, locale_path)) + + managers.append(manager_class(context=context)) + + return managers + diff --git a/bauh/gems/__init__.py b/bauh/gems/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bauh/gems/arch/__init__.py b/bauh/gems/arch/__init__.py new file mode 100644 index 00000000..3b1cfda6 --- /dev/null +++ b/bauh/gems/arch/__init__.py @@ -0,0 +1,4 @@ +import os +ROOT_DIR = os.path.dirname(os.path.abspath(__file__)) +BUILD_DIR = '/tmp/bauh/aur' + diff --git a/bauh/gems/arch/aur.py b/bauh/gems/arch/aur.py new file mode 100644 index 00000000..884ab71a --- /dev/null +++ b/bauh/gems/arch/aur.py @@ -0,0 +1,28 @@ +import re +from typing import Set, List + +from bauh.api.http import HttpClient + +URL_INFO = 'https://aur.archlinux.org/rpc/?v=5&type=info&' +URL_SEARCH = 'https://aur.archlinux.org/rpc/?v=5&type=search&arg=' + + +def map_pkgbuild(pkgbuild: str) -> dict: + return {attr: val.replace('"', '').replace("'", '').replace('(', '').replace(')', '') for attr, val in re.findall(r'\n(\w+)=(.+)', pkgbuild)} + + +class AURClient: + + def __init__(self, http_client: HttpClient): + self.http_client = http_client + self.names_index = set() + + def search(self, words: str) -> dict: + return self.http_client.get_json(URL_SEARCH + words) + + def get_info(self, names: Set[str]) -> List[dict]: + res = self.http_client.get_json(URL_INFO + self._map_names_as_queries(names)) + return res['results'] if res and res.get('results') else [] + + def _map_names_as_queries(self, names) -> str: + return '&'.join(['arg[{}]={}'.format(i, n) for i, n in enumerate(names)]) diff --git a/bauh/gems/arch/confirmation.py b/bauh/gems/arch/confirmation.py new file mode 100644 index 00000000..4854e777 --- /dev/null +++ b/bauh/gems/arch/confirmation.py @@ -0,0 +1,28 @@ +from typing import Set + +from bauh.api.abstract.handler import ProcessWatcher +from bauh.api.abstract.view import MultipleSelectComponent, InputOption + +from bauh.commons.html import bold + + +def request_optional_deps(pkgname: str, pkg_mirrors: dict, watcher: ProcessWatcher, i18n: dict) -> Set[str]: + view_opts = MultipleSelectComponent(label='', + options=[InputOption('{} ( {} )'.format(p, m.upper()), p) for p, m in + pkg_mirrors.items()]) + install = watcher.request_confirmation(title=i18n['arch.install.optdeps.request.title'], + body='

{}

'.format(i18n['arch.install.optdeps.request.body'].format(bold(pkgname)) + ':'), + components=[view_opts], + confirmation_label=i18n['install'], + deny_label=i18n['cancel']) + + if install: + return {o.value for o in view_opts.values} + + +def request_install_missing_deps(pkgname: str, pkg_mirrors: dict, watcher: ProcessWatcher, i18n: dict) -> bool: + deps_str = ''.join(['
- {} ( {} )'.format(d, m.upper()) for d, m in pkg_mirrors.items()]) + msg = '

{}

'.format(i18n['arch.missing_deps.body'].format('{}'.format(pkgname) + ':
' + deps_str)) + msg += i18n['ask.continue'] + + return watcher.request_confirmation(i18n['arch.missing_deps.title'], msg) diff --git a/bauh/gems/arch/controller.py b/bauh/gems/arch/controller.py new file mode 100644 index 00000000..f754fe41 --- /dev/null +++ b/bauh/gems/arch/controller.py @@ -0,0 +1,570 @@ +import os +import re +import shutil +import time +from pathlib import Path +from threading import Thread +from typing import List, Set, Type + +from bauh.api.abstract.controller import SearchResult, SoftwareManager, ApplicationContext +from bauh.api.abstract.disk import DiskCacheLoader +from bauh.api.abstract.handler import ProcessWatcher +from bauh.api.abstract.model import PackageUpdate, PackageHistory, SoftwarePackage, PackageSuggestion +from bauh.api.abstract.view import MessageType +from bauh.commons.system import SystemProcess, ProcessHandler, new_subprocess, run_cmd, new_root_subprocess + +from bauh.gems.arch import BUILD_DIR, aur, pacman, makepkg, pkgbuild, message, confirmation, disk, git, suggestions +from bauh.gems.arch.aur import AURClient +from bauh.gems.arch.mapper import ArchDataMapper +from bauh.gems.arch.model import ArchPackage +from bauh.commons.html import bold +from bauh.gems.arch.worker import AURIndexUpdater, ArchDiskCacheUpdater + +URL_GIT = 'https://aur.archlinux.org/{}.git' +URL_PKG_DOWNLOAD = 'https://aur.archlinux.org/cgit/aur.git/snapshot/{}.tar.gz' +URL_SRC_INFO = 'https://aur.archlinux.org/cgit/aur.git/plain/.SRCINFO?h=' + +RE_SPLIT_VERSION = re.compile(r'(=|>|<)') + + +class ArchManager(SoftwareManager): + + def __init__(self, context: ApplicationContext): + super(ArchManager, self).__init__(context=context) + self.aur_cache = context.cache_factory.new() + # context.disk_loader_factory.map(ArchPackage, self.aur_cache) TODO + + self.mapper = ArchDataMapper(http_client=context.http_client) + self.i18n = context.i18n + self.aur_client = AURClient(context.http_client) + self.names_index = {} + self.aur_index_updater = AURIndexUpdater(context, self) + self.dcache_updater = ArchDiskCacheUpdater(context.logger) + self.logger = context.logger + + 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.downgrade_enabled = downgrade_enabled + + if app.installed: + res.installed.append(app) + + if disk_loader: + disk_loader.fill(app) + else: + res.new.append(app) + + Thread(target=self.mapper.fill_package_build, args=(app,)).start() + + def search(self, words: str, disk_loader: DiskCacheLoader, limit: int = -1) -> SearchResult: + downgrade_enabled = git.is_enabled() + res = SearchResult([], [], 0) + + installed = {} + read_installed = Thread(target=lambda: installed.update(pacman.list_and_map_installed())) + read_installed.start() + + api_res = self.aur_client.search(words) + + if api_res and api_res.get('results'): + read_installed.join() + + for pkgdata in api_res['results']: + self._upgrade_search_result(pkgdata, installed, downgrade_enabled, res, disk_loader) + + else: # if there are no results from the API (it could be because there were too many), tries the names index: + if self.names_index: + + to_query = set() + for norm_name, real_name in self.names_index.items(): + if words in norm_name: + to_query.add(real_name) + + if len(to_query) == 25: + break + + pkgsinfo = self.aur_client.get_info(to_query) + + if pkgsinfo: + read_installed.join() + + for pkgdata in pkgsinfo: + self._upgrade_search_result(pkgdata, installed, res) + + res.total = len(res.installed) + len(res.new) + return res + + def _fill_aur_pkgs(self, not_signed: dict, apps: list, disk_loader: DiskCacheLoader): + downgrade_enabled = git.is_enabled() + pkgsinfo = self.aur_client.get_info(not_signed.keys()) + + if pkgsinfo: + for pkgdata in pkgsinfo: + app = self.mapper.map_api_data(pkgdata, not_signed) + app.downgrade_enabled = downgrade_enabled + if disk_loader: + disk_loader.fill(app) + + apps.append(app) + + def _fill_mirror_pkgs(self, mirrors: dict, apps: list): + # TODO + for name, data in mirrors.items(): + app = ArchPackage(name=name, version=data.get('version'), latest_version=data.get('version'), description=data.get('description')) + app.installed = True + app.mirror = '' # TODO + app.update = False # TODO + apps.append(app) + + def read_installed(self, disk_loader: DiskCacheLoader, limit: int = -1, only_apps: bool = False, pkg_types: Set[Type[SoftwarePackage]] = None) -> SearchResult: + installed = pacman.list_and_map_installed() + + apps = [] + if installed and installed['not_signed']: + self._fill_aur_pkgs(installed['not_signed'], apps, disk_loader) + + return SearchResult(apps, None, len(apps)) + + def downgrade(self, pkg: ArchPackage, root_password: str, watcher: ProcessWatcher) -> bool: + + handler = ProcessHandler(watcher) + app_build_dir = '{}/build_{}'.format(BUILD_DIR, int(time.time())) + watcher.change_progress(5) + + try: + if not os.path.exists(app_build_dir): + build_dir = handler.handle(SystemProcess(new_subprocess(['mkdir', '-p', app_build_dir]))) + + if build_dir: + watcher.change_progress(10) + watcher.change_substatus(self.i18n['arch.clone'].format(bold(pkg.name))) + clone = handler.handle(SystemProcess(subproc=new_subprocess(['git', 'clone', URL_GIT.format(pkg.name)], cwd=app_build_dir), check_error_output=False)) + watcher.change_progress(30) + if clone: + watcher.change_substatus(self.i18n['arch.downgrade.reading_commits']) + clone_path = '{}/{}'.format(app_build_dir, pkg.name) + pkgbuild_path = '{}/PKGBUILD'.format(clone_path) + + commits = run_cmd("git log", cwd=clone_path) + watcher.change_progress(40) + + if commits: + commit_list = re.findall(r'commit (.+)\n', commits) + if commit_list: + if len(commit_list) > 1: + for idx in range(1, len(commit_list)): + commit = commit_list[idx] + with open(pkgbuild_path) as f: + pkgdict = aur.map_pkgbuild(f.read()) + + if not handler.handle(SystemProcess(subproc=new_subprocess(['git', 'reset', '--hard', commit], cwd=clone_path), check_error_output=False)): + watcher.print('Could not downgrade anymore. Aborting...') + return False + + if '{}-{}'.format(pkgdict.get('pkgver'), pkgdict.get('pkgrel')) == pkg.version: + # current version found + watcher.change_substatus(self.i18n['arch.downgrade.version_found']) + break + + watcher.change_substatus(self.i18n['arch.downgrade.install_older']) + return self._make_pkg(pkg.name, root_password, handler, app_build_dir, clone_path, dependency=False, skip_optdeps=True) + else: + watcher.show_message(title=self.i18n['arch.downgrade.error'], + body=self.i18n['arch.downgrade.impossible'].format(pkg.name), + type_=MessageType.ERROR) + return False + + watcher.show_message(title=self.i18n['error'], body=self.i18n['arch.downgrade.no_commits'], type_=MessageType.ERROR) + return False + + finally: + if os.path.exists(app_build_dir): + handler.handle(SystemProcess(subproc=new_subprocess(['rm', '-rf', app_build_dir]))) + + return False + + def clean_cache_for(self, pkg: ArchPackage): + if os.path.exists(pkg.get_disk_cache_path()): + shutil.rmtree(pkg.get_disk_cache_path()) + + def update(self, pkg: ArchPackage, root_password: str, watcher: ProcessWatcher) -> bool: + return self.install(pkg=pkg, root_password=root_password, watcher=watcher, skip_optdeps=True) + + def _uninstall(self, pkg_name: str, root_password: str, handler: ProcessHandler) -> bool: + return handler.handle(SystemProcess(new_root_subprocess(['pacman', '-R', pkg_name, '--noconfirm'], root_password))) + + def uninstall(self, pkg: ArchPackage, root_password: str, watcher: ProcessWatcher) -> bool: + handler = ProcessHandler(watcher) + + watcher.change_progress(10) + info = pacman.get_info_dict(pkg.name) + watcher.change_progress(50) + + if info.get('required by'): + pkname = '"{}"'.format(pkg.name) + msg = '{}:\n\n{}\n\n{}'.format(self.i18n['arch.uninstall.required_by'].format(pkname), info['required by'], self.i18n['arch.uninstall.required_by.advice'].format(pkname)) + watcher.show_message(title=self.i18n['error'], body=msg, type_=MessageType.WARNING) + return False + + uninstalled = self._uninstall(pkg.name, root_password, handler) + watcher.change_progress(100) + return uninstalled + + def get_managed_types(self) -> Set["type"]: + return {ArchPackage} + + def get_info(self, pkg: ArchPackage) -> dict: + if pkg.installed: + t = Thread(target=self.mapper.fill_package_build, args=(pkg,)) + t.start() + + info = pacman.get_info_dict(pkg.name) + + t.join() + + if pkg.pkgbuild: + info['13_pkg_build'] = pkg.pkgbuild + + return info + else: + info = { + '01_id': pkg.id, + '02_name': pkg.name, + '03_version': pkg.version, + '04_popularity': pkg.popularity, + '05_votes': pkg.votes, + '06_package_base': pkg.package_base, + '07_maintainer': pkg.maintainer, + '08_first_submitted': pkg.first_submitted, + '09_last_modified': pkg.last_modified, + '10_url': pkg.url_download, + } + + res_srcinfo = self.context.http_client.get(URL_SRC_INFO + pkg.name) + + if res_srcinfo and res_srcinfo.text: + info['11_dependson'] = ' '.join(pkgbuild.read_depends_on(res_srcinfo.text)) + info['12_optdepends'] = ' '.join(pkgbuild.read_optdeps(res_srcinfo.text)) + + if pkg.pkgbuild: + info['13_pkg_build'] = pkg.pkgbuild + else: + info['11_pkg_build_url'] = pkg.get_pkg_build_url() + + return info + + def get_history(self, pkg: ArchPackage) -> PackageHistory: + temp_dir = '{}/build_{}'.format(BUILD_DIR, int(time.time())) + + try: + Path(temp_dir).mkdir(parents=True) + run_cmd('git clone ' + URL_GIT.format(pkg.name), print_error=False, cwd=temp_dir) + + clone_path = '{}/{}'.format(temp_dir, pkg.name) + pkgbuild_path = '{}/PKGBUILD'.format(clone_path) + + commits = git.list_commits(clone_path) + + if commits: + history, status_idx = [], 0 + + for idx, commit in enumerate(commits): + with open(pkgbuild_path) as f: + pkgdict = aur.map_pkgbuild(f.read()) + + if '{}-{}'.format(pkgdict.get('pkgver'), pkgdict.get('pkgrel')) == pkg.version: + status_idx = idx + + history.append({'1_version': pkgdict['pkgver'], '2_release': pkgdict['pkgrel'], + '3_date': commit['date']}) # the number prefix is to ensure the rendering order + + if idx + 1 < len(commits): + if not run_cmd('git reset --hard ' + commits[idx + 1]['commit'], cwd=clone_path): + break + + return PackageHistory(pkg=pkg, history=history, pkg_status_idx=status_idx) + finally: + if os.path.exists(temp_dir): + shutil.rmtree(temp_dir) + + def _install_deps(self, deps: Set[str], pkg_mirrors: dict, root_password: str, handler: ProcessHandler, change_progress: bool = False) -> str: + """ + :param deps: + :param pkg_mirrors: + :param root_password: + :param handler: + :return: not installed dependency + """ + progress_increment = int(100 / len(deps)) + progress = 0 + self._update_progress(handler.watcher, 1, change_progress) + + for pkgname in deps: + + mirror = pkg_mirrors[pkgname] + handler.watcher.change_substatus(self.i18n['arch.install.dependency.install'].format(bold('{} ()'.format(pkgname, mirror)))) + if mirror == 'aur': + installed = self._install_from_aur(pkgname=pkgname, root_password=root_password, handler=handler, dependency=True, change_progress=False) + else: + installed = self._install(pkgname=pkgname, root_password=root_password, handler=handler, install_file=None, mirror=mirror, change_progress=False) + + if not installed: + return pkgname + + progress += progress_increment + self._update_progress(handler.watcher, progress, change_progress) + + self._update_progress(handler.watcher, 100, change_progress) + + def _map_mirrors(self, pkgnames: Set[str]) -> dict: + pkg_mirrors = pacman.get_mirrors(pkgnames) # getting mirrors set + + if len(pkgnames) != pkg_mirrors: # checking if any dep not found in the distro mirrors are from AUR + nomirrors = {p for p in pkgnames if p not in pkg_mirrors} + for pkginfo in self.aur_client.get_info(nomirrors): + if pkginfo.get('Name') in nomirrors: + pkg_mirrors[pkginfo['Name']] = 'aur' + + return pkg_mirrors + + def _make_pkg(self, pkgname: str, root_password: str, handler: ProcessHandler, build_dir: str, project_dir: str, dependency: bool, skip_optdeps: bool = False, change_progress: bool = True) -> bool: + + self._update_progress(handler.watcher, 50, change_progress) + if not self._install_missings_deps(pkgname, root_password, handler, project_dir): + return False + + # building main package + handler.watcher.change_substatus(self.i18n['arch.building.package'].format(bold(pkgname))) + pkgbuilt = handler.handle(SystemProcess(new_subprocess(['makepkg', '-Acsmf'], cwd=project_dir))) + self._update_progress(handler.watcher, 65, change_progress) + + if pkgbuilt: + gen_file = [fname for root, dirs, files in os.walk(build_dir) for fname in files if re.match(r'^{}-.+\.tar\.xz'.format(pkgname), fname)] + + if not gen_file: + handler.watcher.print('Could not find generated .tar.xz file. Aborting...') + return False + + install_file = '{}/{}'.format(project_dir, gen_file[0]) + + if self._install(pkgname=pkgname, root_password=root_password, mirror='aur', handler=handler, install_file=install_file, pkgdir=project_dir, change_progress=change_progress): + + if dependency or skip_optdeps: + return True + + handler.watcher.change_substatus(self.i18n['arch.optdeps.checking'].format(bold(pkgname))) + + if self._install_optdeps(pkgname, root_password, handler, project_dir, change_progress=change_progress): + return True + + return False + + def _install_missings_deps(self, pkgname: str, root_password: str, handler: ProcessHandler, pkgdir: str) -> bool: + handler.watcher.change_substatus(self.i18n['arch.checking.deps'].format(bold(pkgname))) + missing_deps = makepkg.check_missing_deps(pkgdir, handler.watcher) + + if missing_deps: + depnames = {RE_SPLIT_VERSION.split(dep)[0] for dep in missing_deps} + dep_mirrors = self._map_mirrors(depnames) + + for dep in depnames: # cheking if a dependency could not be found in any mirror + if dep not in dep_mirrors: + message.show_dep_not_found(dep, self.i18n, handler.watcher) + return False + + handler.watcher.change_substatus(self.i18n['arch.missing_deps_found'].format(bold(pkgname))) + + if not confirmation.request_install_missing_deps(pkgname, dep_mirrors, handler.watcher, self.i18n): + handler.watcher.print(self.i18n['action.cancelled']) + return False + + dep_not_installed = self._install_deps(depnames, dep_mirrors, root_password, handler, change_progress=False) + + if dep_not_installed: + message.show_dep_not_installed(handler.watcher, pkgname, dep_not_installed, self.i18n) + return False + + return True + + def _install_optdeps(self, pkgname: str, root_password: str, handler: ProcessHandler, pkgdir: str, change_progress: bool = True) -> bool: + with open('{}/.SRCINFO'.format(pkgdir)) as f: + odeps = pkgbuild.read_optdeps(f.read()) + + if not odeps: + return True + + installed = pacman.list_installed() + to_install = {d for d in odeps if d not in installed} + + if not to_install: + return True + + pkg_mirrors = self._map_mirrors(to_install) + + if pkg_mirrors: + deps_to_install = confirmation.request_optional_deps(pkgname, pkg_mirrors, handler.watcher, self.i18n) + + if not deps_to_install: + return True + else: + dep_not_installed = self._install_deps(deps_to_install, pkg_mirrors, root_password, handler, change_progress=True) + + if dep_not_installed: + message.show_optdep_not_installed(dep_not_installed, handler.watcher, self.i18n) + return False + + return True + + def _install(self, pkgname: str, root_password: str, mirror: str, handler: ProcessHandler, install_file: str = None, pkgdir: str = '.', change_progress: bool = True): + check_install_output = [] + pkgpath = install_file if install_file else pkgname + + handler.watcher.change_substatus(self.i18n['arch.checking.conflicts'].format(bold(pkgname))) + + for check_out in new_root_subprocess(['pacman', '-U', pkgpath], root_password=root_password, cwd=pkgdir).stdout: + if check_out: + check_install_output.append(check_out.decode()) + + self._update_progress(handler.watcher, 70, change_progress) + if check_install_output and 'conflict' in check_install_output[-1]: + conflicting_apps = [w[0] for w in re.findall(r'((\w|\-|\.)+)\s(and|are)', check_install_output[-1])] + conflict_msg = ' {} '.format(self.i18n['and']).join(conflicting_apps) + if not handler.watcher.request_confirmation(title=self.i18n['arch.install.conflict.popup.title'], + body=self.i18n['arch.install.conflict.popup.body'].format(conflict_msg)): + handler.watcher.print(self.i18n['action.cancelled']) + return False + else: # uninstall conflicts + self._update_progress(handler.watcher, 75, change_progress) + to_uninstall = [conflict for conflict in conflicting_apps if conflict != pkgname] + + for conflict in to_uninstall: + handler.watcher.change_substatus(self.i18n['arch.uninstalling.conflict'].format(bold(conflict))) + if not self._uninstall(conflict, root_password, handler): + handler.watcher.show_message(title=self.i18n['error'], + body=self.i18n['arch.uninstalling.conflict.fail'].format('"{}"'.format(conflict)), + type_=MessageType.ERROR) + return False + + handler.watcher.change_substatus(self.i18n['arch.installing.package'].format(bold(pkgname))) + self._update_progress(handler.watcher, 80, change_progress) + installed = handler.handle(pacman.install_as_process(pkgpath=pkgpath, root_password=root_password, aur=install_file is not None, pkgdir=pkgdir)) + self._update_progress(handler.watcher, 95, change_progress) + + if installed and self.context.disk_cache: + handler.watcher.change_substatus(self.i18n['status.caching_data'].format(bold(pkgname))) + disk.save(pkgname, mirror) + self._update_progress(handler.watcher, 100, change_progress) + + return installed + + def _update_progress(self, watcher: ProcessWatcher, val: int, change_progress: bool): + if change_progress: + watcher.change_progress(val) + + def _install_from_aur(self, pkgname: str, root_password: str, handler: ProcessHandler, dependency: bool, skip_optdeps: bool = False, change_progress: bool = True) -> bool: + app_build_dir = '{}/build_{}'.format(BUILD_DIR, int(time.time())) + + try: + if not os.path.exists(app_build_dir): + build_dir = handler.handle(SystemProcess(new_subprocess(['mkdir', '-p', app_build_dir]))) + self._update_progress(handler.watcher, 10, change_progress) + + if build_dir: + file_url = URL_PKG_DOWNLOAD.format(pkgname) + file_name = file_url.split('/')[-1] + handler.watcher.change_substatus('{} {}'.format(self.i18n['arch.downloading.package'], bold(file_name))) + download = handler.handle(SystemProcess(new_subprocess(['wget', file_url], cwd=app_build_dir))) + + if download: + self._update_progress(handler.watcher, 30, change_progress) + handler.watcher.change_substatus('{} {}'.format(self.i18n['arch.uncompressing.package'], bold(file_name))) + uncompress = handler.handle(SystemProcess(new_subprocess(['tar', 'xvzf', '{}.tar.gz'.format(pkgname)], cwd=app_build_dir))) + self._update_progress(handler.watcher, 40, change_progress) + + if uncompress: + uncompress_dir = '{}/{}'.format(app_build_dir, pkgname) + return self._make_pkg(pkgname=pkgname, + root_password=root_password, + handler=handler, + build_dir=app_build_dir, + project_dir=uncompress_dir, + dependency=dependency, + skip_optdeps=skip_optdeps, + change_progress=change_progress) + finally: + if os.path.exists(app_build_dir): + handler.handle(SystemProcess(new_subprocess(['rm', '-rf', app_build_dir]))) + + return False + + def install(self, pkg: ArchPackage, root_password: str, watcher: ProcessWatcher, skip_optdeps: bool = False) -> bool: + return self._install_from_aur(pkg.name, root_password, ProcessHandler(watcher), dependency=False, skip_optdeps=skip_optdeps) + + def _is_wget_available(self): + try: + new_subprocess(['wget', '--version']) + return True + except FileNotFoundError: + return False + + def is_enabled(self) -> bool: + try: + return pacman.is_enabled() and self._is_wget_available() + except FileNotFoundError: + return False + + def is_downgrade_enabled(self) -> bool: + try: + new_subprocess(['git', '--version']) + return True + except FileNotFoundError: + return False + + def cache_to_disk(self, pkg: ArchPackage, icon_bytes: bytes, only_icon: bool): + if self.context.disk_cache and pkg.supports_disk_cache(): + pass + + def requires_root(self, action: str, pkg: ArchPackage): + return action != 'search' + + def prepare(self): + self.dcache_updater.start() + + self.aur_index_updater.start() + + def list_updates(self) -> List[PackageUpdate]: + return [PackageUpdate(app.id, app.latest_version, 'aur') for app in self.read_installed(disk_loader=None).installed if app.update] + + def list_warnings(self) -> List[str]: + warnings = [] + + if not pacman.is_enabled(): + warnings.append(self.i18n['arch.warning.disabled'].format(bold('pacman'))) + + if not self._is_wget_available(): + warnings.append(self.i18n['arch.warning.disabled'].format(bold('wget'))) + + if not git.is_enabled(): + warnings.append(self.i18n['arch.warning.git'].format(bold('git'))) + + return warnings + + def list_suggestions(self, limit: int) -> List[PackageSuggestion]: + res = [] + + sugs = [(i, p) for i, p in suggestions.ALL.items()] + sugs.sort(key=lambda t: t[1].value, reverse=True) + + if limit > 0: + sugs = sugs[0:limit] + + sug_names = {s[0] for s in sugs} + + api_res = self.aur_client.get_info(sug_names) + + if api_res: + 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']))) + + return res diff --git a/bauh/gems/arch/disk.py b/bauh/gems/arch/disk.py new file mode 100644 index 00000000..6febf33a --- /dev/null +++ b/bauh/gems/arch/disk.py @@ -0,0 +1,179 @@ +import json +import os +import re +from pathlib import Path +from typing import Set, List + +from bauh.gems.arch import pacman +from bauh.gems.arch.model import ArchPackage + +RE_DESKTOP_ENTRY = re.compile(r'(Exec|Icon)\s*=\s*(.+)') +RE_CLEAN_NAME = re.compile(r'^(\w+)-?|_?.+') + + +def write(app: ArchPackage): + Path(app.get_disk_cache_path()).mkdir(parents=True, exist_ok=True) + + with open(app.get_disk_data_path(), 'w+') as f: + f.write(json.dumps(app.get_data_to_cache())) + + +def fill_icon_path(app: ArchPackage, icon_paths: List[str], only_exact_match: bool): + ends_with = re.compile(r'.+/{}\.(png|svg)$'.format(app.name), re.IGNORECASE) + + for path in icon_paths: + if ends_with.match(path): + app.icon_path = path + return + + if not only_exact_match: + pkg_icons_path = pacman.list_icon_paths({app.name}) + + if pkg_icons_path: + app.icon_path = pkg_icons_path[0] + + +def set_icon_path(app: ArchPackage, icon_name: str = None): + installed_icons = pacman.list_icon_paths(app.name) + + if installed_icons: + exact_match = re.compile(r'.+/{}\..+$'.format(icon_name.split('.')[0] if icon_name else app.name)) + for icon_path in installed_icons: + if exact_match.match(icon_path): + app.icon_path = icon_path + break + + +def save(pkgname: str, mirror: str): + app = ArchPackage(name=pkgname, mirror=mirror) + desktop_files = pacman.list_desktop_entries({pkgname}) + + if desktop_files: + if len(desktop_files) > 1: + exact_match = [f for f in desktop_files if f.endswith('{}.desktop'.format(pkgname))] + to_parse = exact_match[0] if exact_match else desktop_files[0] + else: + to_parse = desktop_files[0] + + app.desktop_entry = to_parse + + with open(to_parse) as f: + desktop_entry = f.read() + + for field in RE_DESKTOP_ENTRY.findall(desktop_entry): + if field[0] == 'Exec': + app.command = field[1].strip() + elif field[0] == 'Icon': + app.icon_path = field[1].strip() + + if app.icon_path and '/' not in app.icon_path: # if the icon full path is not defined + set_icon_path(app, app.icon_path) + else: + bin_paths = pacman.list_bin_paths({pkgname}) + + if bin_paths: + if len(bin_paths) > 1: + exact_match = [p for p in bin_paths if p.endswith('/' + pkgname)] + app.command = exact_match[0] if exact_match else bin_paths[0] + else: + app.command = bin_paths[0] + + set_icon_path(app) + + Path(app.get_disk_cache_path()).mkdir(parents=True, exist_ok=True) + + with open(app.get_disk_data_path(), 'w+') as f: + f.write(json.dumps(app.get_data_to_cache())) + + +def save_several(pkgnames: Set[str], mirror: str, overwrite: bool = True) -> 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) + + no_desktop_files = {} + + to_write = [] + if desktop_files: + desktop_matches, no_exact_match = {}, set() + for pkg in to_cache: # first try to find exact matches + ends_with = re.compile('.+/{}.desktop$'.format(pkg), re.IGNORECASE) + + for f in desktop_files: + if ends_with.match(f): + desktop_matches[pkg] = f + break + + if pkg not in desktop_matches: + no_exact_match.add(pkg) + + if no_exact_match: # check every not matched app individually + for pkg in no_exact_match: + entries = pacman.list_desktop_entries({pkg}) + + if entries: + desktop_matches[pkg] = entries[0] + + if not desktop_matches: + no_desktop_files = to_cache + else: + if len(desktop_matches) != len(to_cache): + no_desktop_files = {p for p in to_cache if p not in desktop_matches} + + pkgs, apps_icons_noabspath = [], [] + + for pkgname, file in desktop_matches.items(): + p = ArchPackage(name=pkgname, mirror=mirror) + p.desktop_entry = file + + with open(file) as f: + desktop_entry = f.read() + + for field in RE_DESKTOP_ENTRY.findall(desktop_entry): + if field[0] == 'Exec': + p.command = field[1].strip() + elif field[0] == 'Icon': + p.icon_path = field[1].strip() + + if p.icon_path and '/' not in p.icon_path: # if the icon full path is not defined + apps_icons_noabspath.append(p) + + pkgs.append(p) + + if apps_icons_noabspath: + icon_paths = pacman.list_icon_paths({app.name for app in apps_icons_noabspath}) + + if icon_paths: + for p in apps_icons_noabspath: + fill_icon_path(p, icon_paths, False) + + for p in pkgs: + to_write.append(p) + + if no_desktop_files: + pkgs = {ArchPackage(name=n, mirror=mirror) for n in no_desktop_files} + bin_paths = pacman.list_bin_paths(no_desktop_files) + + if bin_paths: + for p in pkgs: + ends_with = re.compile(r'.+/{}$'.format(p.name), re.IGNORECASE) + + for path in bin_paths: + if ends_with.match(path): + p.command = path + break + + icon_paths = pacman.list_icon_paths(no_desktop_files) + + if icon_paths: + for p in pkgs: + fill_icon_path(p, icon_paths, only_exact_match=True) + + for p in pkgs: + to_write.append(p) + + if to_write: + for p in to_write: + write(p) + return len(to_write) + return 0 + diff --git a/bauh/gems/arch/git.py b/bauh/gems/arch/git.py new file mode 100644 index 00000000..c52499e5 --- /dev/null +++ b/bauh/gems/arch/git.py @@ -0,0 +1,29 @@ +from datetime import datetime +from typing import List + +from bauh.commons.system import new_subprocess + + +def is_enabled() -> bool: + try: + new_subprocess(['git', '--version']) + return True + except FileNotFoundError: + return False + + +def list_commits(proj_dir:str) -> List[dict]: + logs = new_subprocess(['git', 'log', '--date=iso'], cwd=proj_dir).stdout + + commits, commit = [], {} + for out in new_subprocess(['grep', '-E', 'commit|Date:'], stdin=logs).stdout: + if out: + line = out.decode() + if line.startswith('commit'): + commit['commit'] = line.split(' ')[1].strip() + elif line.startswith('Date'): + commit['date'] = datetime.fromisoformat(line.split(':')[1].strip()) + commits.append(commit) + commit = {} + + return commits diff --git a/bauh/gems/arch/makepkg.py b/bauh/gems/arch/makepkg.py new file mode 100644 index 00000000..a7993d29 --- /dev/null +++ b/bauh/gems/arch/makepkg.py @@ -0,0 +1,28 @@ +import re +from typing import List + +from bauh.api.abstract.handler import ProcessWatcher +from bauh.commons.system import new_subprocess + +RE_DEPS_PATTERN = re.compile(r'\n?\s+->\s(.+)\n') + + +def check_missing_deps(pkgdir: str, watcher: ProcessWatcher) -> List[str]: + depcheck = new_subprocess(['makepkg', '--check'], cwd=pkgdir) + + for o in depcheck.stdout: + if o: + watcher.print(o.decode()) + + error_lines = [] + for s in depcheck.stderr: + if s: + line = s.decode() + print(line) + error_lines.append(line) + + if error_lines: + error_str = ''.join(error_lines) + + if 'Missing dependencies' in error_str: + return RE_DEPS_PATTERN.findall(error_str) diff --git a/bauh/gems/arch/mapper.py b/bauh/gems/arch/mapper.py new file mode 100644 index 00000000..bdf12594 --- /dev/null +++ b/bauh/gems/arch/mapper.py @@ -0,0 +1,55 @@ +from datetime import datetime + +from bauh.api.http import HttpClient +from bauh.gems.arch.model import ArchPackage + +URL_PKG_DOWNLOAD = 'https://aur.archlinux.org/{}' + + +class ArchDataMapper: + + def __init__(self, http_client: HttpClient): + self.http_client = http_client + + def fill_api_data(self, pkg: ArchPackage, package: dict, fill_version: bool = True): + + version = package.get('Version') + + if version: + version = version.split(':') + version = version[0] if len(version) == 1 else version[1] + + pkg.id = package.get('ID') + pkg.name = package.get('Name') + + if fill_version: + pkg.version = version + + pkg.latest_version = version + pkg.description = package.get('Description') + + pkg.package_base = package.get('PackageBase') + pkg.popularity = package.get('Popularity') + pkg.votes = package.get('NumVotes') + pkg.maintainer = package.get('Maintainer') + 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 = pkg.version and pkg.latest_version and pkg.latest_version > pkg.version + + def fill_package_build(self, pkg: ArchPackage): + res = self.http_client.get(pkg.get_pkg_build_url()) + + if res and res.status_code == 200 and res.text: + pkg.pkgbuild = res.text + + def map_api_data(self, apidata: dict, installed: dict) -> ArchPackage: + data = installed.get(apidata.get('Name')) + app = ArchPackage(name=apidata.get('Name'), installed=bool(data), mirror='aur') + + 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/message.py b/bauh/gems/arch/message.py new file mode 100644 index 00000000..5e92c090 --- /dev/null +++ b/bauh/gems/arch/message.py @@ -0,0 +1,20 @@ +from bauh.api.abstract.handler import ProcessWatcher +from bauh.api.abstract.view import MessageType + + +def show_dep_not_installed(watcher: ProcessWatcher, pkgname: str, depname: str, i18n: dict): + watcher.show_message(title=i18n['error'], + body=i18n['arch.install.dependency.install.error'].format('"{}"'.format(depname), '"{}"'.format(pkgname)), + type_=MessageType.ERROR) + + +def show_dep_not_found(depname: str, i18n: dict, watcher: ProcessWatcher): + watcher.show_message(title=i18n['arch.install.dep_not_found.title'], + body=i18n['arch.install.dep_not_found.body'].format('"{}"'.format(depname)), + type_=MessageType.ERROR) + + +def show_optdep_not_installed(depname: str, watcher: ProcessWatcher, i18n: dict): + watcher.show_message(title=i18n['error'], + body=i18n['arch.install.optdep.error'].format('"{}"'.format(depname)), + type_=MessageType.ERROR) diff --git a/bauh/gems/arch/model.py b/bauh/gems/arch/model.py new file mode 100644 index 00000000..f7ded25c --- /dev/null +++ b/bauh/gems/arch/model.py @@ -0,0 +1,109 @@ +import datetime + +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 + + +class ArchPackage(SoftwarePackage): + + def __init__(self, name: str = None, version: str = None, latest_version: str = None, description: str = None, + package_base: str = None, votes: int = None, popularity: float = None, + first_submitted: datetime.datetime = None, last_modified: datetime.datetime = None, + maintainer: str = None, url_download: str = None, pkgbuild: str = None, mirror: str = None, + desktop_entry: str = None, installed: bool = False): + + super(ArchPackage, self).__init__(name=name, version=version, latest_version=latest_version, description=description, installed=installed) + self.package_base = package_base + self.votes = votes + self.popularity = popularity + self.maintainer = maintainer + self.url_download = url_download + self.first_submitted = first_submitted + self.last_modified = last_modified + self.pkgbuild = pkgbuild + self.mirror = mirror + self.command = None + self.icon_path = None + self.downgrade_enabled = False + self.desktop_entry = desktop_entry + + @staticmethod + def disk_cache_path(pkgname: str, mirror: str): + return CACHE_PATH + '/arch/installed/' + ('aur' if mirror == 'aur' else 'mirror') + '/' + pkgname + + def get_pkg_build_url(self): + if self.package_base: + return 'https://aur.archlinux.org/cgit/aur.git/plain/PKGBUILD?h=' + self.package_base + + def has_history(self): + return self.installed + + def has_info(self): + return True + + def can_be_installed(self): + return super(ArchPackage, self).can_be_installed() and self.url_download + + def can_be_downgraded(self): + return self.installed and self.downgrade_enabled + + def get_type(self): + return 'aur' if self.mirror == 'aur' else 'arch' + + def get_default_icon_path(self) -> str: + return self.get_type_icon_path() + + def get_disk_icon_path(self) -> str: + return self.icon_path + + def get_type_icon_path(self): + return resource.get_path('img/arch.png', ROOT_DIR) # TODO change icon when from mirrors + + def is_application(self): + return True + + def supports_disk_cache(self): + return True + + def get_disk_cache_path(self) -> str: + if self.name: + return self.disk_cache_path(self.name, self.mirror) + + def get_data_to_cache(self) -> dict: + cache = {} + + # required attrs to cache + for a in {'command', 'icon_path', 'mirror'}: + val = getattr(self, a) + + if val: + cache[a] = val + else: + return None + + if self.desktop_entry: + cache['desktop_entry'] = self.desktop_entry + + return cache + + def fill_cached_data(self, data: dict): + for a in {'command', 'icon_path', 'mirror', 'desktop_entry'}: + val = data.get(a) + if val: + setattr(self, a, val) + + if a == 'icon_path': + self.icon_url = val + + def can_be_run(self) -> bool: + # only returns if there is a desktop entry set for the application to avoid running command-line applications + return bool(self.desktop_entry) + + def get_command(self) -> str: + return self.command + + def get_publisher(self): + return self.maintainer diff --git a/bauh/gems/arch/pacman.py b/bauh/gems/arch/pacman.py new file mode 100644 index 00000000..c612f11f --- /dev/null +++ b/bauh/gems/arch/pacman.py @@ -0,0 +1,131 @@ +import re +from typing import List, Set + +from bauh.commons.system import run_cmd, new_subprocess, new_root_subprocess, SystemProcess + + +def is_enabled() -> bool: + try: + new_subprocess(['pacman', '--version']) + return True + except FileNotFoundError: + return False + + +def get_mirrors(pkgs: Set[str]) -> dict: + pkgre = '|'.join(pkgs) + + searchres = new_subprocess(['pacman', '-Ss', pkgre]).stdout + mirrors = {} + + for line in new_subprocess(['grep', '-E', '.+/({}) '.format(pkgre)], stdin=searchres).stdout: + if line: + match = line.decode() + for p in pkgs: + if p in match: + mirrors[p] = match.split('/')[0] + + return mirrors + + +def is_available_from_mirrors(pkg_name: str) -> bool: + return bool(run_cmd('pacman -Ss ' + pkg_name)) + + +def get_info(pkg_name) -> str: + return run_cmd('pacman -Qi ' + pkg_name) + + +def get_info_list(pkg_name: str) -> List[tuple]: + info = get_info(pkg_name) + if info: + return re.findall(r'(\w+\s?\w+)\s+:\s+(.+(\n\s+.+)*)', info) + + +def get_info_dict(pkg_name: str) -> dict: + info_list = get_info_list(pkg_name) + + if info_list: + info_dict = {} + for info_data in info_list: + attr = info_data[0].lower() + info_dict[attr] = info_data[1] if '\n' not in info_data[1] else ' '.join([l.strip() for l in info_data[1].split('\n')]) + + if info_dict[attr] == 'None': + info_dict[attr] = None + + return info_dict + + +def list_installed() -> Set[str]: + return {out.decode().strip() for out in new_subprocess(['pacman', '-Qq']).stdout if out} + + +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 + + 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: + if out: + line = out.decode() + + if line.startswith('Name'): + current_pkg['name'] = line.split(':')[1].strip() + elif line.startswith('Version'): + version = line.split(':') + current_pkg['version'] = version[len(version) - 1].strip() + elif line.startswith('Description'): + current_pkg['description'] = line.split(':')[1].strip() + elif line.startswith('Validated'): + + if line.split(':')[1].strip().lower() == 'none': + pkgs['not_signed'][current_pkg['name']] = {'version': current_pkg['version'], 'description': current_pkg['description']} + + current_pkg = {} + + return pkgs + + +def install_as_process(pkgpath: str, root_password: str, aur: bool, pkgdir: str = '.') -> SystemProcess: + + if aur: + cmd = ['pacman', '-U', pkgpath, '--noconfirm'] # pkgpath = install file path + else: + cmd = ['pacman', '-S', pkgpath, '--noconfirm'] # pkgpath = pkgname + + return SystemProcess(new_root_subprocess(cmd, root_password, cwd=pkgdir)) + + +def list_desktop_entries(pkgnames: Set[str]) -> List[str]: + if pkgnames: + installed_files = new_subprocess(['pacman', '-Qlq', *pkgnames]).stdout + + desktop_files = [] + for out in new_subprocess(['grep', '-E', '.desktop'], stdin=installed_files).stdout: + if out: + desktop_files.append(out.decode().strip()) + + return desktop_files + + +def list_icon_paths(pkgnames: Set[str]) -> List[str]: + installed_files = new_subprocess(['pacman', '-Qlq', *pkgnames]).stdout + + icon_files = [] + for out in new_subprocess(['grep', '-E', '.(png|svg|jpeg)'], stdin=installed_files).stdout: + if out: + icon_files.append(out.decode().strip()) + + return icon_files + + +def list_bin_paths(pkgnames: Set[str]) -> List[str]: + installed_files = new_subprocess(['pacman', '-Qlq', *pkgnames]).stdout + + bin_paths = [] + for out in new_subprocess(['grep', '-E', '^/usr/bin/.+'], stdin=installed_files).stdout: + if out: + bin_paths.append(out.decode().strip()) + + return bin_paths diff --git a/bauh/gems/arch/pkgbuild.py b/bauh/gems/arch/pkgbuild.py new file mode 100644 index 00000000..40d19c29 --- /dev/null +++ b/bauh/gems/arch/pkgbuild.py @@ -0,0 +1,13 @@ +import re +from typing import Set + +RE_PKGBUILD_OPTDEPS = re.compile(r"optdepends = ([^:\s]+)") +RE_PKGBUILD_DEPSON = re.compile(r"depends = ([^:\s]+)") + + +def read_optdeps(srcinfo: str) -> Set[str]: + return set(RE_PKGBUILD_OPTDEPS.findall(srcinfo)) + + +def read_depends_on(srcinfo: str) -> Set[str]: + return set(RE_PKGBUILD_DEPSON.findall(srcinfo)) diff --git a/bauh/gems/arch/resources/img/arch.png b/bauh/gems/arch/resources/img/arch.png new file mode 100755 index 00000000..c1e2fb1f Binary files /dev/null and b/bauh/gems/arch/resources/img/arch.png differ diff --git a/bauh/gems/arch/resources/locale/en b/bauh/gems/arch/resources/locale/en new file mode 100644 index 00000000..34257bec --- /dev/null +++ b/bauh/gems/arch/resources/locale/en @@ -0,0 +1,49 @@ +gem.arch.label=Arch ( AUR ) +arch.install.conflict.popup.title=Conflict detected +arch.install.conflict.popup.body=The applications {} are in conflict. You must uninstall one to install the other. Continue ? +arch.missing_deps.title=Missing dependencies +arch.missing_deps.body=The following dependencies must be installed before the {} installation continues +arch.downgrade.error=Error +arch.downgrade.impossible=It is not possible to downgrade {} +aur.history.1_version=version +aur.history.2_release=release +aur.history.3_date=date +arch.downloading.package=Downloading the package +arch.uncompressing.package=Uncompressing the package +arch.checking.deps=Checking {} dependencies +arch.missing_deps_found=Missing dependencies for {} +arch.building.package=Building package {} +arch.checking.conflicts=Checking any conflicts with {} +arch.installing.package=Installing {} package +arch.uninstalling.conflict=Uninstalling conflicting package {} +arch.uninstalling.conflict.fail=It was not possible to uninstall the conflicting package {} +arch.clone=Cloning the AUR repository {} +arch.downgrade.reading_commits=Reading the repository commits +arch.downgrade.version_found=Current package version found +arch.downgrade.install_older=Installing older version +aur.info.01_id=id +aur.info.02_name=name +aur.info.03_version=version +aur.info.04_popularity=popularity +aur.info.05_votes=votes +aur.info.06_package_base=package base +aur.info.07_maintainer=maintainer +aur.info.08_first_submitted=first submitted +aur.info.09_last_modified=app.last_modified +aur.info.10_url=url download +aur.info.11_pkg_build_url=url pkgbuild +aur.info.13_pkg_build=pkgbuild +aur.info.11_dependson=dependencies +aur.info.12_optdepends=optional dependencies +arch.install.dep_not_found.title=Dependency not found +arch.install.dep_not_found.body=Required dependency {} was not found in AUR nor in default mirrors. Installation cancelled. +arch.install.dependency.install=Installing package dependency {} +arch.install.dependency.install.error=Could not install dependent package {}. Installation of {} aborted. +arch.uninstall.required_by={} cannot be uninstalled because it is necessary for these following packages to work +arch.uninstall.required_by.advice=Uninstall them first before uninstalling {}. +arch.install.optdeps.request.title=Optional dependencies +arch.install.optdeps.request.body={} was succesfully installed ! There are some optional associated packages that you might want to install as well (check those you want) +arch.install.optdep.error=Could not install the optional package {} +arch.optdeps.checking=Checking {} optional dependencies +arch.warning.disabled={} seems not to be installed. It will not be possible to manage Arch / AUR packages. +arch.warning.git={} seems not to be installed. It will not be possible to downgrade AUR packages. \ No newline at end of file diff --git a/bauh/gems/arch/resources/locale/es b/bauh/gems/arch/resources/locale/es new file mode 100644 index 00000000..6ca204d7 --- /dev/null +++ b/bauh/gems/arch/resources/locale/es @@ -0,0 +1,81 @@ +gem.arch.label=Arch ( AUR ) +aur.info.architecture=arquitectura +aur.info.architecture.any=cualquier +aur.info.build date=fecha de construcción +aur.info.depends on=depende +aur.info.description=descripción +aur.info.install date=fecha de instalación +aur.info.install reason=razón da instalación +aur.info.install reason.explicitly installed=instalado explícitamente +aur.info.install script=script de instalación +aur.info.install script.no=ninguno +aur.info.installed size=tamaño da instalación +aur.info.licenses=licencias +aur.info.name=nombre +aur.info.optional deps=dependencias opcionales +aur.info.packager=empaquetador +aur.info.packager.unknown packager=desconocido +aur.info.url=url +aur.info.version=versión +aur.info.arch=arquitectura +aur.info.arch.any=cualquier +aur.info.depends=depende +aur.info.pkgdesc=descripción +aur.info.pkgname=nombre +aur.info.pkgrel=lanzamiento +aur.info.pkgver=versión +aur.info.source=origen +aur.info.optdepends=dependencias opcionales +aur.info.license=licencia +aur.info.validpgpkeys=llaves PGP válidas +aur.info.options=opciones +aur.info.provides=provee +aur.info.conflicts with=conflicta +arch.install.conflict.popup.title=Conflicto detectado +arch.install.conflict.popup.body=Los aplicativos {} estan en conflicto. Debe desinstalar uno para instalar el otro. ¿Continuar? +arch.missing_deps.title=Dependencias faltantes +arch.missing_deps.body=Deben instalarse las siguientes dependencias antes de continuar la instalación de {} +arch.downgrade.error=Error +arch.downgrade.impossible=No es posible revertir la versión de {} +aur.history.1_version=versión +aur.history.2_release=lanzamiento +aur.history.3_date=fecha +arch.downloading.package=Descargando el paquete +arch.uncompressing.package=Descomprimindo el paquete +arch.checking.deps=Verificando las dependencias de {} +arch.missing_deps_found=Dependencias faltantes para {} +arch.building.package=Construyendo el paquete {} +arch.checking.conflicts=Verificando se hay conflictos con {} +arch.installing.package=Instalando el paquete {} +arch.uninstalling.conflict=Eliminando el paquete conflictivo {} +arch.uninstalling.conflict.fail=No fue posible desinstalar el paquete conflictivo {} +arch.clone=Clonando el repositorio {} de AUR +arch.downgrade.reading_commits=Leyendo los commits del repositorio +arch.downgrade.version_found=Version actual del paquete encontrada +arch.downgrade.install_older=Instalando versión anterior +aur.info.01_id=id +aur.info.02_name=nombre +aur.info.03_version=versión +aur.info.04_popularity=popularidad +aur.info.05_votes=votos +aur.info.06_package_base=paquete base +aur.info.07_maintainer=mantenedor +aur.info.08_first_submitted=primero envio +aur.info.09_last_modified=última modificación +aur.info.10_url=url download +aur.info.11_pkg_build_url=url pkgbuild +aur.info.13_pkg_build=pkgbuild +aur.info.11_dependson=dependencias +aur.info.12_optdepends=dependencias opcionales +arch.install.dep_not_found.title=Dependencia no encontrada +arch.install.dep_not_found.body=No se encontró la dependencia requerida {} en AUR ni en los espejos predeterminados. Instalación cancelada. +arch.install.dependency.install=Instalando el paquete dependiente {} +arch.install.dependency.install.error=No se pudo instalar el paquete dependiente {}. Instalación de {} abortada. +arch.uninstall.required_by=No se puede desinstalar {} porque es necesario para que los siguientes paquetes funcionen +arch.uninstall.required_by.advice=Debe desinstalarlos primero antes de desinstalar {} +arch.install.optdeps.request.title=Dependencias opcionales +arch.install.optdeps.request.body=¡{} se instaló correctamente! También hay algunos paquetes opcionales asociados que es posible que desee instalar (marque los que desee) +arch.install.optdep.error=No se pudo instalar el paquete opcional {} +arch.optdeps.checking=Verificando las dependencias opcionales de {} +arch.warning.disabled={} parece no estar instalado. No será posible administrar paquetes Arch / AUR. +arch.warning.git={} parece no estar instalado. No será posible revertir las versiones de paquetes Arch / AUR. \ No newline at end of file diff --git a/bauh/gems/arch/resources/locale/pt b/bauh/gems/arch/resources/locale/pt new file mode 100644 index 00000000..95a8d348 --- /dev/null +++ b/bauh/gems/arch/resources/locale/pt @@ -0,0 +1,81 @@ +gem.arch.label=Arch ( AUR ) +aur.info.architecture=arquitetura +aur.info.architecture.any=qualquer +aur.info.build date=data de construção +aur.info.depends on=depende +aur.info.description=descrição +aur.info.install date=data de instalação +aur.info.install reason=razão da instalação +aur.info.install reason.explicitly installed=instalado explicitamente +aur.info.install script=script de instalação +aur.info.install script.no=nenhum +aur.info.installed size=tamanho da instalação +aur.info.licenses=licenças +aur.info.name=nome +aur.info.optional deps=dependências opcionais +aur.info.packager=empacotador +aur.info.packager.unknown packager=desconhecido +aur.info.url=url +aur.info.version=versão +aur.info.arch=arquitetura +aur.info.arch.any=qualquer +aur.info.depends=depende +aur.info.pkgdesc=descrição +aur.info.pkgname=nome +aur.info.pkgrel=lançamento +aur.info.pkgver=versão +aur.info.source=origem +aur.info.optdepends=dependências opcionais +aur.info.license=licença +aur.info.validpgpkeys=chaves PGP válidas +aur.info.options=opções +aur.info.provides=provê +aur.info.conflicts with=conflita +arch.install.conflict.popup.title=Conflito detectado +arch.install.conflict.popup.body=Os aplicativos {} estão em conflito. Você precisa desinstalar um para instalar o outro. Continuar ? +arch.missing_deps.title=Dependências ausentes +arch.missing_deps.body=As seguintes dependências devem ser instaladas antes de continuar com a instalação de {} +arch.downgrade.error=Erro +arch.downgrade.impossible=Não é possível reverter a versão de {} +aur.history.1_version=versão +aur.history.2_release=lançamento +aur.history.3_date=data +arch.downloading.package=Baixando o pacote +arch.uncompressing.package=Descompactando o pacote +arch.checking.deps=Checando as dependências de {} +arch.missing_deps_found=Dependencias ausentes para {} +arch.building.package=Construindo o pacote {} +arch.checking.conflicts=Verificando se há conflitos com {} +arch.installing.package=Instalando o pacote {} +arch.uninstalling.conflict=Desinstalando o pacote conflitante {} +arch.uninstalling.conflict.fail=Não foi possível desinstalar o pacote conflitante {} +arch.clone=Clonando o repositório {} do AUR +arch.downgrade.reading_commits=Lendo os commits do repositório +arch.downgrade.version_found=Versão atual do pacote encontrada +arch.downgrade.install_older=Instalando versão anterior +aur.info.01_id=id +aur.info.02_name=nome +aur.info.03_version=versão +aur.info.04_popularity=popularidade +aur.info.05_votes=votos +aur.info.06_package_base=pacote base +aur.info.07_maintainer=mantenedor +aur.info.08_first_submitted=primeira submissão +aur.info.09_last_modified=última modificação +aur.info.10_url=url download +aur.info.11_pkg_build_url=url pkgbuild +aur.info.13_pkg_build=pkgbuild +aur.info.11_dependson=dependências +aur.info.12_optdepends=dependências opcionais +arch.install.dep_not_found.title=Dependência não encontrada +arch.install.dep_not_found.body=A dependência {} não foi encontrado no AUR nem nos espelhos padrões. Instalação cancelada. +arch.install.dependency.install=Instalando o pacote dependente {} +arch.install.dependency.install.error=Não foi possível instalar o pacote dependente {}. Instalação de {} abortada. +arch.uninstall.required_by={} não pode ser desinstalado porque ele é necessário para o funcionamento dos seguintes pacotes +arch.uninstall.required_by.advice=Desinstale eles primeiro antes de desinstalar {} +arch.install.optdeps.request.title=Dependências opcionais +arch.install.optdeps.request.body={} foi instalado com sucesso ! Existem alguns pacotes opcionais associados que talvez você também queira instalar (marque os desejados) +arch.install.optdep.error=Não foi possível instalar o pacote opcional {} +arch.optdeps.checking=Verificando as dependências opcionais de {} +arch.warning.disabled={} parece não estar instalado. Não será possível gerenciar pacotes Arch / AUR. +arch.warning.git={} parece não estar instalado. Não será possível reverter versões de pacotes Arch / AUR. \ No newline at end of file diff --git a/bauh/gems/arch/suggestions.py b/bauh/gems/arch/suggestions.py new file mode 100644 index 00000000..393e6b63 --- /dev/null +++ b/bauh/gems/arch/suggestions.py @@ -0,0 +1,3 @@ +from bauh.api.abstract.model import SuggestionPriority + +ALL = {'google-chrome': SuggestionPriority.HIGH} diff --git a/bauh/gems/arch/worker.py b/bauh/gems/arch/worker.py new file mode 100644 index 00000000..5afa4327 --- /dev/null +++ b/bauh/gems/arch/worker.py @@ -0,0 +1,53 @@ +import logging +import os +import time +from multiprocessing import Process +from threading import Thread + +from bauh.api.abstract.context import ApplicationContext +from bauh.api.abstract.controller import SoftwareManager + +from bauh.gems.arch import pacman, disk + +URL_INDEX = 'https://aur.archlinux.org/packages.gz' +URL_INFO = 'https://aur.archlinux.org/rpc/?v=5&type=info&arg={}' + + +class AURIndexUpdater(Thread): + + def __init__(self, context: ApplicationContext, man: SoftwareManager): + super(AURIndexUpdater, self).__init__(daemon=True) + self.http_client = context.http_client + self.logger = context.logger + self.man = man + + def run(self): + + while True: + self.logger.info('Pre-indexing AUR packages in memory') + res = self.http_client.get(URL_INDEX) + + if res and res.text: + self.man.names_index = {n.replace('-', '').replace('_', '').replace('.', ''): n for n in res.text.split('\n') if n and not n.startswith('#')} + 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)) + + time.sleep(5 * 60) # updates every 5 minutes + + +class ArchDiskCacheUpdater(Thread if bool(os.getenv('BAUH_DEBUG', 0)) else Process): + + def __init__(self, logger: logging.Logger): + super(ArchDiskCacheUpdater, self).__init__(daemon=True) + self.logger = logger + + def run(self): + self.logger.info('Pre-caching installed AUR packages data to disk') + installed = pacman.list_and_map_installed() + + saved = 0 + if installed and installed['not_signed']: + saved = disk.save_several({app for app in installed['not_signed']}, 'aur', overwrite=False) + + self.logger.info('Pre-cached data of {} AUR packages to disk'.format(saved)) diff --git a/bauh/gems/flatpak/__init__.py b/bauh/gems/flatpak/__init__.py new file mode 100644 index 00000000..92020dec --- /dev/null +++ b/bauh/gems/flatpak/__init__.py @@ -0,0 +1,3 @@ +import os +ROOT_DIR = os.path.dirname(os.path.abspath(__file__)) + diff --git a/bauh/gems/flatpak/constants.py b/bauh/gems/flatpak/constants.py new file mode 100644 index 00000000..687356af --- /dev/null +++ b/bauh/gems/flatpak/constants.py @@ -0,0 +1,2 @@ +FLATHUB_URL = 'https://flathub.org' +FLATHUB_API_URL = FLATHUB_URL + '/api/v1' diff --git a/bauh/gems/flatpak/controller.py b/bauh/gems/flatpak/controller.py new file mode 100644 index 00000000..a70dde9c --- /dev/null +++ b/bauh/gems/flatpak/controller.py @@ -0,0 +1,208 @@ +from datetime import datetime +from typing import List, Set, Type + +from bauh.api.abstract.controller import SearchResult, SoftwareManager, ApplicationContext +from bauh.api.abstract.disk import DiskCacheLoader +from bauh.api.abstract.handler import ProcessWatcher +from bauh.api.abstract.model import PackageHistory, PackageUpdate, SoftwarePackage, PackageSuggestion +from bauh.api.abstract.view import MessageType +from bauh.commons.html import strip_html +from bauh.commons.system import SystemProcess, ProcessHandler + +from bauh.gems.flatpak import flatpak, suggestions +from bauh.gems.flatpak.model import FlatpakApplication +from bauh.gems.flatpak.worker import FlatpakAsyncDataLoader, FlatpakUpdateLoader + + +class FlatpakManager(SoftwareManager): + + def __init__(self, context: ApplicationContext): + super(FlatpakManager, self).__init__(context=context) + self.i18n = context.i18n + self.api_cache = context.cache_factory.new() + context.disk_loader_factory.map(FlatpakApplication, self.api_cache) + + def get_managed_types(self) -> Set["type"]: + return {FlatpakApplication} + + def _map_to_model(self, app_json: dict, installed: bool, disk_loader: DiskCacheLoader) -> FlatpakApplication: + + app = FlatpakApplication(arch=app_json.get('arch'), + branch=app_json.get('branch'), + origin=app_json.get('origin'), + runtime=app_json.get('runtime'), + ref=app_json.get('ref'), + commit=app_json.get('commit'), + id=app_json.get('id'), + name=app_json.get('name'), + version=app_json.get('version'), + latest_version=app_json.get('latest_version')) + + app.installed = installed + + api_data = self.api_cache.get(app_json['id']) + + expired_data = api_data and api_data.get('expires_at') and api_data['expires_at'] <= datetime.utcnow() + + if not api_data or expired_data: + if not app_json['runtime']: + if disk_loader: + disk_loader.fill(app) # preloading cached disk data + FlatpakAsyncDataLoader(app=app, api_cache=self.api_cache, manager=self, context=self.context).start() + + else: + app.fill_cached_data(api_data) + + return app + + def search(self, words: str, disk_loader: DiskCacheLoader, limit: int = -1) -> SearchResult: + + res = SearchResult([], [], 0) + apps_found = flatpak.search(words) + + if apps_found: + already_read = set() + installed_apps = self.read_installed(disk_loader=disk_loader).installed + + if installed_apps: + for app_found in apps_found: + for installed_app in installed_apps: + if app_found['id'] == installed_app.id: + res.installed.append(installed_app) + already_read.add(app_found['id']) + + if len(apps_found) > len(already_read): + for app_found in apps_found: + if app_found['id'] not in already_read: + res.new.append(self._map_to_model(app_found, False, disk_loader)) + + res.total = len(res.installed) + len(res.new) + return res + + def read_installed(self, disk_loader: DiskCacheLoader, limit: int = -1, only_apps: bool = False, pkg_types: Set[Type[SoftwarePackage]] = None) -> SearchResult: + installed = flatpak.list_installed() + models = [] + + if installed: + + available_updates = flatpak.list_updates_as_str() + + for app_json in installed: + model = self._map_to_model(app_json=app_json, installed=True, disk_loader=disk_loader) + model.update = app_json['id'] in available_updates + models.append(model) + + return SearchResult(models, None, len(models)) + + def downgrade(self, pkg: FlatpakApplication, root_password: str, watcher: ProcessWatcher) -> bool: + watcher.change_progress(10) + watcher.change_substatus(self.i18n['flatpak.downgrade.commits']) + commits = flatpak.get_app_commits(pkg.ref, pkg.origin) + + commit_idx = commits.index(pkg.commit) + + # downgrade is not possible if the app current commit in the first one: + if commit_idx == len(commits) - 1: + watcher.show_message(self.i18n['flatpak.downgrade.impossible.title'], self.i18n['flatpak.downgrade.impossible.body'], MessageType.WARNING) + return False + + commit = commits[commit_idx + 1] + watcher.change_substatus(self.i18n['flatpak.downgrade.reverting']) + watcher.change_progress(50) + success = ProcessHandler(watcher).handle(SystemProcess(subproc=flatpak.downgrade(pkg.ref, commit, root_password), success_phrase='Updates complete.')) + watcher.change_progress(100) + return success + + def clean_cache_for(self, pkg: FlatpakApplication): + super(FlatpakManager, self).clean_cache_for(pkg) + self.api_cache.delete(pkg.id) + + def update(self, pkg: FlatpakApplication, root_password: str, watcher: ProcessWatcher) -> bool: + return ProcessHandler(watcher).handle(SystemProcess(subproc=flatpak.update(pkg.ref))) + + def uninstall(self, pkg: FlatpakApplication, root_password: str, watcher: ProcessWatcher) -> bool: + return ProcessHandler(watcher).handle(SystemProcess(subproc=flatpak.uninstall(pkg.ref))) + + def get_info(self, app: FlatpakApplication) -> dict: + app_info = flatpak.get_app_info_fields(app.id, app.branch) + app_info['name'] = app.name + app_info['type'] = 'runtime' if app.runtime else 'app' + app_info['description'] = strip_html(app.description) + return app_info + + def get_history(self, pkg: FlatpakApplication) -> PackageHistory: + commits = flatpak.get_app_commits_data(pkg.ref, pkg.origin) + status_idx = 0 + + for idx, data in enumerate(commits): + if data['commit'] == pkg.commit: + status_idx = idx + break + + return PackageHistory(pkg=pkg, history=commits, pkg_status_idx=status_idx) + + def install(self, pkg: FlatpakApplication, root_password: str, watcher: ProcessWatcher) -> bool: + return ProcessHandler(watcher).handle(SystemProcess(subproc=flatpak.install(pkg.id, pkg.origin))) + + def is_enabled(self): + return flatpak.is_installed() + + def requires_root(self, action: str, pkg: FlatpakApplication): + return action == 'downgrade' + + def prepare(self): + pass + + def list_updates(self) -> List[PackageUpdate]: + updates = [] + installed = flatpak.list_installed(extra_fields=False) + + if installed: + available_updates = flatpak.list_updates_as_str() + + if available_updates: + loaders = None + + for app_json in installed: + if app_json['id'] in available_updates: + loader = FlatpakUpdateLoader(app=app_json, http_client=self.context.http_client) + loader.start() + + if loaders is None: + loaders = [] + + loaders.append(loader) + + if loaders: + for loader in loaders: + loader.join() + app = loader.app + updates.append(PackageUpdate(pkg_id='{}:{}'.format(app['id'], app['branch']), + pkg_type='flatpak', + version=app.get('version'))) + + return updates + + def list_warnings(self) -> List[str]: + if flatpak.is_installed(): + if not flatpak.has_remotes_set(): + return [self.i18n['flatpak.notification.no_remotes']] + + def list_suggestions(self, limit: int) -> List[PackageSuggestion]: + + res = [] + + sugs = [(i, p) for i, p in suggestions.ALL.items()] + sugs.sort(key=lambda t: t[1].value, reverse=True) + + for sug in sugs: + + if limit <= 0 or len(res) < limit: + app_json = flatpak.search(sug[0], app_id=True) + + if app_json: + res.append(PackageSuggestion(self._map_to_model(app_json[0], False, None), sug[1])) + else: + break + + return res diff --git a/bauh/gems/flatpak/flatpak.py b/bauh/gems/flatpak/flatpak.py new file mode 100755 index 00000000..132cb091 --- /dev/null +++ b/bauh/gems/flatpak/flatpak.py @@ -0,0 +1,245 @@ +import re +import subprocess +from typing import List + +from bauh.api.exception import NoInternetException +from bauh.commons.system import new_subprocess, run_cmd, new_root_subprocess + +BASE_CMD = 'flatpak' + + +def app_str_to_json(line: str, version: str, extra_fields: bool = True) -> dict: + + app_array = line.split('\t') + + if version >= '1.3.0': + app = {'name': app_array[0], + 'id': app_array[1], + 'version': app_array[2], + 'branch': app_array[3]} + + elif '1.0' <= version < '1.1': + + ref_data = app_array[0].split('/') + + app = {'id': ref_data[0], + 'arch': ref_data[1], + 'name': ref_data[0].split('.')[-1], + 'ref': app_array[0], + 'options': app_array[1], + 'branch': ref_data[2], + 'version': None} + elif '1.2' <= version < '1.3': + app = {'id': app_array[1], + 'name': app_array[1].strip().split('.')[-1], + 'version': app_array[2], + 'branch': app_array[3], + 'arch': app_array[4], + 'origin': app_array[5]} + else: + raise Exception('Unsupported version') + + if extra_fields: + extra_fields = get_app_info_fields(app['id'], app['branch'], ['origin', 'arch', 'ref', 'commit'], check_runtime=True) + app.update(extra_fields) + + return app + + +def get_app_info_fields(app_id: str, branch: str, fields: List[str] = [], check_runtime: bool = False): + info = re.findall(r'\w+:\s.+', get_app_info(app_id, branch)) + data = {} + fields_to_retrieve = len(fields) + (1 if check_runtime and 'ref' not in fields else 0) + + for field in info: + + if fields and fields_to_retrieve == 0: + break + + field_val = field.split(':') + field_name = field_val[0].lower() + + if not fields or field_name in fields or (check_runtime and field_name == 'ref'): + data[field_name] = field_val[1].strip() + + if fields: + fields_to_retrieve -= 1 + + if check_runtime and field_name == 'ref': + data['runtime'] = data['ref'].startswith('runtime/') + + return data + + +def is_installed(): + version = get_version() + return False if version is None else True + + +def get_version(): + res = run_cmd('{} --version'.format(BASE_CMD), print_error=False) + return res.split(' ')[1].strip() if res else None + + +def get_app_info(app_id: str, branch: str): + return run_cmd('{} info {} {}'.format(BASE_CMD, app_id, branch)) + + +def list_installed(extra_fields: bool = True) -> List[dict]: + apps_str = run_cmd('{} list'.format(BASE_CMD)) + + if apps_str: + version = get_version() + app_lines = apps_str.split('\n') + return [app_str_to_json(line, version, extra_fields=extra_fields) for line in app_lines if line] + + return [] + + +def update(app_ref: str): + """ + Updates the app reference + :param app_ref: + :return: + """ + return new_subprocess([BASE_CMD, 'update', '-y', app_ref]) + + +def uninstall(app_ref: str): + """ + Removes the app by its reference + :param app_ref: + :return: + """ + return new_subprocess([BASE_CMD, 'uninstall', app_ref, '-y']) + + +def list_updates_as_str(): + return run_cmd('{} update'.format(BASE_CMD), ignore_return_code=True) + + +def downgrade(app_ref: str, commit: str, root_password: str) -> subprocess.Popen: + return new_root_subprocess([BASE_CMD, 'update', '--commit={}'.format(commit), app_ref, '-y'], root_password) + + +def get_app_commits(app_ref: str, origin: str) -> List[str]: + log = run_cmd('{} remote-info --log {} {}'.format(BASE_CMD, origin, app_ref)) + + if log: + return re.findall(r'Commit+:\s(.+)', log) + else: + raise NoInternetException() + + +def get_app_commits_data(app_ref: str, origin: str) -> List[dict]: + log = run_cmd('{} remote-info --log {} {}'.format(BASE_CMD, origin, app_ref)) + + if not log: + raise NoInternetException() + + res = re.findall(r'(Commit|Subject|Date):\s(.+)', log) + + commits = [] + + commit = {} + + for idx, data in enumerate(res): + commit[data[0].strip().lower()] = data[1].strip() + + if (idx + 1) % 3 == 0: + commits.append(commit) + commit = {} + + return commits + + +def search(word: str, app_id: bool = False) -> List[dict]: + cli_version = get_version() + + res = run_cmd('{} search {}'.format(BASE_CMD, word)) + + found = [] + + split_res = res.split('\n') + + if split_res and split_res[0].lower() != 'no matches found': + for info in split_res: + if info: + info_list = info.split('\t') + if cli_version >= '1.3.0': + id_ = info_list[2].strip() + + if app_id and id_ != word: + continue + + version = info_list[3].strip() + app = { + 'name': info_list[0].strip(), + 'description': info_list[1].strip(), + 'id': id_, + 'version': version, + 'latest_version': version, + 'branch': info_list[4].strip(), + 'origin': info_list[5].strip(), + 'runtime': False, + 'arch': None, # unknown at this moment, + 'ref': None # unknown at this moment + } + elif cli_version >= '1.2.0': + id_ = info_list[1].strip() + + if app_id and id_ != word: + continue + + desc = info_list[0].split('-') + version = info_list[2].strip() + app = { + 'name': desc[0].strip(), + 'description': desc[1].strip(), + 'id': id_, + 'version': version, + 'latest_version': version, + 'branch': info_list[3].strip(), + 'origin': info_list[4].strip(), + 'runtime': False, + 'arch': None, # unknown at this moment, + 'ref': None # unknown at this moment + } + else: + id_ = info_list[0].strip() + + if app_id and id_ != word: + continue + + version = info_list[1].strip() + app = { + 'name': '', + 'description': info_list[4].strip(), + 'id': id_, + 'version': version, + 'latest_version': version, + 'branch': info_list[2].strip(), + 'origin': info_list[3].strip(), + 'runtime': False, + 'arch': None, # unknown at this moment, + 'ref': None # unknown at this moment + } + + found.append(app) + + if app_id and len(found) > 0: + break + + return found + + +def install(app_id: str, origin: str): + return new_subprocess([BASE_CMD, 'install', origin, app_id, '-y']) + + +def set_default_remotes(): + run_cmd('{} remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo'.format(BASE_CMD)) + + +def has_remotes_set() -> bool: + return bool(run_cmd('{} remotes'.format(BASE_CMD)).strip()) diff --git a/bauh/gems/flatpak/model.py b/bauh/gems/flatpak/model.py new file mode 100644 index 00000000..3ace0f0c --- /dev/null +++ b/bauh/gems/flatpak/model.py @@ -0,0 +1,69 @@ +from bauh.api.abstract.model import SoftwarePackage + +from bauh.commons import resource +from bauh.gems.flatpak import ROOT_DIR + + +class FlatpakApplication(SoftwarePackage): + + def __init__(self, id: str = None, name: str = None, version: str = None, latest_version: str = None, description: str = None, + branch: str = None, arch: str = None, origin: str = None, runtime: bool = False, ref: str = str, commit: str = str): + super(FlatpakApplication, self).__init__(id=id, name=name, version=version, + latest_version=latest_version, description=description) + self.ref = ref + self.branch = branch + self.arch = arch + self.origin = origin + self.runtime = runtime + self.commit = commit + + def is_incomplete(self): + return self.description is None and self.icon_url + + def has_history(self): + return self.installed + + def has_info(self): + return self.installed + + def can_be_downgraded(self): + return self.installed + + def get_type(self): + return 'flatpak' + + def get_default_icon_path(self): + return resource.get_path('img/flathub.svg', ROOT_DIR) + + def get_type_icon_path(self): + return self.get_default_icon_path() + + def is_application(self): + return not self.runtime + + def get_disk_cache_path(self): + return super(FlatpakApplication, self).get_disk_cache_path() + '/installed/' + self.id + + def get_data_to_cache(self): + return { + 'description': self.description, + 'icon_url': self.icon_url, + 'latest_version': self.latest_version, + 'version': self.version, + 'name': self.name, + 'categories': self.categories + } + + def fill_cached_data(self, data: dict): + for attr in self.get_data_to_cache().keys(): + if data.get(attr) and not getattr(self, attr): + setattr(self, attr, data[attr]) + + def get_command(self) -> str: + return "flatpak run {}".format(self.id) + + def can_be_run(self) -> bool: + return self.installed and not self.runtime + + def get_publisher(self): + return 'flathub' diff --git a/bauh/gems/flatpak/resources/img/flathub.svg b/bauh/gems/flatpak/resources/img/flathub.svg new file mode 100755 index 00000000..1b44a5d4 --- /dev/null +++ b/bauh/gems/flatpak/resources/img/flathub.svg @@ -0,0 +1,179 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/bauh/gems/flatpak/resources/img/flatpak.png b/bauh/gems/flatpak/resources/img/flatpak.png new file mode 100755 index 00000000..4fa5bb94 Binary files /dev/null and b/bauh/gems/flatpak/resources/img/flatpak.png differ diff --git a/bauh/gems/flatpak/resources/locale/en b/bauh/gems/flatpak/resources/locale/en new file mode 100644 index 00000000..a801fdfb --- /dev/null +++ b/bauh/gems/flatpak/resources/locale/en @@ -0,0 +1,5 @@ +flatpak.notification.no_remotes=No Flatpak remotes set. It will not be possible to search for Flatpak apps. +flatpak.downgrade.impossible.title=Error +flatpak.downgrade.impossible.body=Impossible to downgrade: the app is in its first version +flatpak.downgrade.commits=Reading package commits +flatpak.downgrade.reverting=Reverting version \ No newline at end of file diff --git a/bauh/gems/flatpak/resources/locale/es b/bauh/gems/flatpak/resources/locale/es new file mode 100644 index 00000000..7db5ad27 --- /dev/null +++ b/bauh/gems/flatpak/resources/locale/es @@ -0,0 +1,27 @@ +flatpak.info.arch=arquitectura +flatpak.info.branch=rama +flatpak.info.collection=colección +flatpak.info.commit=commit +flatpak.info.date=fecha +flatpak.info.description=descripción +flatpak.info.id=id +flatpak.info.installation=instalación +flatpak.info.installed=instalado +flatpak.info.license=licencia +flatpak.info.name=nombre +flatpak.info.origin=origen +flatpak.info.parent=padre +flatpak.info.ref=ref +flatpak.info.runtime=runtime +flatpak.info.sdk=sdk +flatpak.info.subject=tema +flatpak.info.type=tipo +flatpak.info.version=versión +flatpak.notification.no_remotes=No hay repositorios (remotes) Flatpak configurados. No será posible buscar aplicativos Flatpak. +flatpak.downgrade.impossible.title=Error +flatpak.downgrade.impossible.body=Imposible revertir la versión: el aplicativo está en su primera versión +flatpak.history.date=fecha +flatpak.history.commit=commit +flatpak.history.subject=tema +flatpak.downgrade.commits=Leyendo commits del paquete +flatpak.downgrade.reverting=Revirtiendo la versión \ No newline at end of file diff --git a/bauh/gems/flatpak/resources/locale/pt b/bauh/gems/flatpak/resources/locale/pt new file mode 100644 index 00000000..be69f0fb --- /dev/null +++ b/bauh/gems/flatpak/resources/locale/pt @@ -0,0 +1,27 @@ +flatpak.info.arch=arquitetura +flatpak.info.branch=ramo +flatpak.info.collection=coleção +flatpak.info.commit=commit +flatpak.info.date=data +flatpak.info.description=descrição +flatpak.info.id=id +flatpak.info.installation=instalação +flatpak.info.installed=instalado +flatpak.info.license=licença +flatpak.info.name=nome +flatpak.info.origin=origem +flatpak.info.parent=pai +flatpak.info.ref=ref +flatpak.info.runtime=runtime +flatpak.info.sdk=sdk +flatpak.info.subject=assunto +flatpak.info.type=tipo +flatpak.info.version=versão +flatpak.notification.no_remotes=Não há repositórios (remotes) Flatpak configurados. Não será possível buscar aplicativos Flatpak. +flatpak.downgrade.impossible.title=Erro +flatpak.downgrade.impossible.body=Impossível reverter a versão: o aplicativo está na sua primeira versão +flatpak.history.date=data +flatpak.history.commit=commit +flatpak.history.subject=assunto +flatpak.downgrade.commits=Lendo os commits do pacote +flatpak.downgrade.reverting=Revertendo a versão \ No newline at end of file diff --git a/bauh/gems/flatpak/suggestions.py b/bauh/gems/flatpak/suggestions.py new file mode 100644 index 00000000..169edae6 --- /dev/null +++ b/bauh/gems/flatpak/suggestions.py @@ -0,0 +1,15 @@ +from bauh.api.abstract.model import SuggestionPriority + +ALL = { + 'com.spotify.Client': SuggestionPriority.TOP, + 'com.skype.Client': SuggestionPriority.HIGH, + 'com.dropbox.Client': SuggestionPriority.MEDIUM, + 'us.zoom.Zoom': SuggestionPriority.MEDIUM, + 'org.telegram.desktop': SuggestionPriority.MEDIUM, + 'com.visualstudio.code': SuggestionPriority.LOW, + 'org.inkscape.Inkscape': SuggestionPriority.LOW, + 'org.libretro.RetroArch': SuggestionPriority.LOW, + 'org.kde.kdenlive': SuggestionPriority.LOW, + 'org.videolan.VLC': SuggestionPriority.LOW +} + diff --git a/bauh/gems/flatpak/worker.py b/bauh/gems/flatpak/worker.py new file mode 100644 index 00000000..ef59f0f1 --- /dev/null +++ b/bauh/gems/flatpak/worker.py @@ -0,0 +1,96 @@ +import traceback +from threading import Thread + +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.flatpak import flatpak +from bauh.gems.flatpak.constants import FLATHUB_API_URL, FLATHUB_URL +from bauh.gems.flatpak.model import FlatpakApplication + + +class FlatpakAsyncDataLoader(Thread): + + def __init__(self, app: FlatpakApplication, manager: SoftwareManager, context: ApplicationContext, api_cache: MemoryCache): + super(FlatpakAsyncDataLoader, self).__init__(daemon=True) + self.app = app + self.manager = manager + self.http_client = context.http_client + self.api_cache = api_cache + self.persist = False + self.logger = context.logger + + def run(self): + if self.app: + self.app.status = PackageStatus.LOADING_DATA + + try: + res = self.http_client.get('{}/apps/{}'.format(FLATHUB_API_URL, self.app.id)) + + if res.status_code == 200 and res.text: + data = res.json() + + if not self.app.version: + self.app.version = data.get('version') + + if not self.app.name: + self.app.name = data.get('name') + + self.app.description = data.get('description', data.get('summary', None)) + self.app.icon_url = data.get('iconMobileUrl', None) + self.app.latest_version = data.get('currentReleaseVersion', self.app.version) + + if not self.app.version and self.app.latest_version: + self.app.version = self.app.latest_version + + if not self.app.installed and self.app.latest_version: + self.app.version = self.app.latest_version + + if self.app.icon_url and self.app.icon_url.startswith('/'): + self.app.icon_url = FLATHUB_URL + self.app.icon_url + + if data.get('categories'): + self.app.categories = [c['name'] for c in data['categories']] + + loaded_data = self.app.get_data_to_cache() + + self.api_cache.add(self.app.id, loaded_data) + self.persist = self.app.supports_disk_cache() + else: + self.logger.warning("Could not retrieve app data for id '{}'. Server response: {}. Body: {}".format(self.app.id, res.status_code, res.content.decode())) + except: + self.logger.error("Could not retrieve app data for id '{}'".format(self.app.id)) + traceback.print_exc() + + self.app.status = PackageStatus.READY + + if self.persist: + self.manager.cache_to_disk(pkg=self.app, icon_bytes=None, only_icon=False) + + +class FlatpakUpdateLoader(Thread): + + def __init__(self, app: dict, http_client: HttpClient): + super(FlatpakUpdateLoader, self).__init__(daemon=True) + self.app = app + self.http_client = http_client + + def run(self): + + if self.app.get('ref') is None: + self.app.update(flatpak.get_app_info_fields(self.app['id'], self.app['branch'], fields=['ref'], check_runtime=True)) + else: + self.app['runtime'] = self.app['ref'].startswith('runtime/') + + if not self.app['runtime']: + + try: + data = self.http_client.get_json('{}/apps/{}'.format(FLATHUB_API_URL, self.app['id'])) + + if data and data.get('currentReleaseVersion'): + self.app['version'] = data['currentReleaseVersion'] + except: + traceback.print_exc() diff --git a/bauh/gems/snap/__init__.py b/bauh/gems/snap/__init__.py new file mode 100644 index 00000000..8824c2d4 --- /dev/null +++ b/bauh/gems/snap/__init__.py @@ -0,0 +1,2 @@ +import os +ROOT_DIR = os.path.dirname(os.path.abspath(__file__)) diff --git a/bauh/gems/snap/constants.py b/bauh/gems/snap/constants.py new file mode 100644 index 00000000..099a232e --- /dev/null +++ b/bauh/gems/snap/constants.py @@ -0,0 +1 @@ +SNAP_API_URL = 'https://search.apps.ubuntu.com/api/v1' diff --git a/bauh/gems/snap/controller.py b/bauh/gems/snap/controller.py new file mode 100644 index 00000000..7fdd8c7c --- /dev/null +++ b/bauh/gems/snap/controller.py @@ -0,0 +1,145 @@ +from datetime import datetime +from typing import List, Set, Type + +from bauh.api.abstract.controller import SoftwareManager, SearchResult, ApplicationContext +from bauh.api.abstract.disk import DiskCacheLoader +from bauh.api.abstract.handler import ProcessWatcher +from bauh.api.abstract.model import SoftwarePackage, PackageHistory, PackageUpdate, PackageSuggestion +from bauh.commons.system import SystemProcess, ProcessHandler + +from bauh.gems.snap import snap, suggestions +from bauh.gems.snap.model import SnapApplication +from bauh.gems.snap.worker import SnapAsyncDataLoader + + +class SnapManager(SoftwareManager): + + def __init__(self, context: ApplicationContext): + super(SnapManager, self).__init__(context=context) + self.i18n = context.i18n + self.api_cache = context.cache_factory.new() + context.disk_loader_factory.map(SnapApplication, self.api_cache) + + def map_json(self, app_json: dict, installed: bool, disk_loader: DiskCacheLoader) -> SnapApplication: + app = SnapApplication(publisher=app_json.get('publisher'), + rev=app_json.get('rev'), + notes=app_json.get('notes'), + app_type=app_json.get('type'), + id=app_json.get('name'), + name=app_json.get('name'), + version=app_json.get('version'), + latest_version=app_json.get('version'), + description=app_json.get('description')) + + if app.publisher: + app.publisher = app.publisher.replace('*', '') + + app.installed = installed + + 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() + + if (not api_data or expired_data) and app.is_application(): + if disk_loader and app.installed: + disk_loader.fill(app) + + SnapAsyncDataLoader(app=app, api_cache=self.api_cache, manager=self, context=self.context).start() + else: + app.fill_cached_data(api_data) + + return app + + def search(self, words: str, disk_loader: DiskCacheLoader, limit: int = -1) -> SearchResult: + installed = self.read_installed(disk_loader).installed + + res = SearchResult([], [], 0) + + for app_json in snap.search(words): + + already_installed = None + + if installed: + already_installed = [i for i in installed if i.id == app_json.get('name')] + already_installed = already_installed[0] if already_installed else None + + if already_installed: + res.installed.append(already_installed) + else: + res.new.append(self.map_json(app_json, installed=False, disk_loader=disk_loader)) + + res.total = len(res.installed) + len(res.new) + return res + + def read_installed(self, disk_loader: DiskCacheLoader, limit: int = -1, only_apps: bool = False, pkg_types: Set[Type[SoftwarePackage]] = None) -> SearchResult: + installed = [self.map_json(app_json, installed=True, disk_loader=disk_loader) for app_json in snap.read_installed()] + return SearchResult(installed, None, len(installed)) + + def downgrade(self, pkg: SnapApplication, root_password: str, watcher: ProcessWatcher) -> bool: + return ProcessHandler(watcher).handle(SystemProcess(subproc=snap.downgrade_and_stream(pkg.name, root_password), wrong_error_phrase=None)) + + def update(self, pkg: SnapApplication, root_password: str, watcher: ProcessWatcher) -> SystemProcess: + raise Exception("'update' is not supported by {}".format(pkg.__class__.__name__)) + + def uninstall(self, pkg: SnapApplication, root_password: str, watcher: ProcessWatcher) -> bool: + return ProcessHandler(watcher).handle(SystemProcess(subproc=snap.uninstall_and_stream(pkg.name, root_password))) + + def get_managed_types(self) -> Set[Type[SoftwarePackage]]: + return {SnapApplication} + + def clean_cache_for(self, pkg: SnapApplication): + super(SnapManager, self).clean_cache_for(pkg) + self.api_cache.delete(pkg.id) + + def get_info(self, pkg: SnapApplication) -> dict: + info = snap.get_info(pkg.name, attrs=('license', 'contact', 'commands', 'snap-id', 'tracking', 'installed')) + info['description'] = pkg.description + info['publisher'] = pkg.publisher + info['revision'] = pkg.rev + info['name'] = pkg.name + + if info.get('commands'): + info['commands'] = ' '.join(info['commands']) + + return info + + def get_history(self, pkg: SnapApplication) -> PackageHistory: + raise Exception("'get_history' is not supported by {}".format(pkg.__class__.__name__)) + + def install(self, pkg: SnapApplication, root_password: str, watcher: ProcessWatcher) -> bool: + return ProcessHandler(watcher).handle(SystemProcess(subproc=snap.install_and_stream(pkg.name, pkg.confinement, root_password))) + + def is_enabled(self) -> bool: + return snap.is_installed() + + def requires_root(self, action: str, pkg: SnapApplication): + return action != 'search' + + def refresh(self, pkg: SnapApplication, root_password: str, watcher: ProcessWatcher) -> bool: + return ProcessHandler(watcher).handle(SystemProcess(subproc=snap.refresh_and_stream(pkg.name, root_password))) + + def prepare(self): + pass + + def list_updates(self) -> List[PackageUpdate]: + pass + + def list_warnings(self) -> List[str]: + if snap.get_snapd_version() == 'unavailable': + return [self.i18n['snap.notification.snapd_unavailable']] + + def list_suggestions(self, limit: int) -> List[PackageSuggestion]: + res = [] + + sugs = [(i, p) for i, p in suggestions.ALL.items()] + sugs.sort(key=lambda t: t[1].value, reverse=True) + + for sug in sugs: + + if limit <= 0 or len(res) < limit: + found = snap.search(sug[0], exact_name=True) + if found: + res.append(PackageSuggestion(self.map_json(found[0], installed=False, disk_loader=None), sug[1])) + else: + break + + return res diff --git a/bauh/gems/snap/model.py b/bauh/gems/snap/model.py new file mode 100644 index 00000000..4ca9bf76 --- /dev/null +++ b/bauh/gems/snap/model.py @@ -0,0 +1,88 @@ +from typing import List + +from bauh.api.abstract.model import SoftwarePackage, PackageAction +from bauh.commons import resource + +from bauh.gems.snap import ROOT_DIR + +EXTRA_INSTALLED_ACTIONS = [ + PackageAction(i18n_status_key='snap.action.refresh.status', + i18_label_key='snap.action.refresh.label', + icon_path=resource.get_path('img/refresh.svg', ROOT_DIR), + manager_method='refresh', + requires_root=True) +] + + +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, + app_type: str = None, confinement: str = None): + super(SnapApplication, self).__init__(id=id, name=name, version=version, + latest_version=latest_version, description=description) + self.publisher = publisher + self.rev = rev + self.notes = notes + self.type = app_type + self.confinement = confinement + + def has_history(self): + return False + + def has_info(self): + return True + + def can_be_downgraded(self): + return self.installed + + def get_type(self): + return 'snap' + + def get_default_icon_path(self): + return resource.get_path('img/snap.png', ROOT_DIR) + + def get_type_icon_path(self): + return self.get_default_icon_path() + + def is_application(self): + return not self.type and (self.type not in ('core', 'base', 'snapd') and not self._name_starts_with(('gtk-', 'gnome-', 'kde-', 'gtk2-'))) + + def _name_starts_with(self, words: tuple): + for word in words: + if self.name.startswith(word): + return True + + return False + + def get_disk_cache_path(self): + return super(SnapApplication, self).get_disk_cache_path() + '/installed/' + self.name + + def get_data_to_cache(self): + return { + "icon_url": self.icon_url, + 'confinement': self.confinement, + 'description': self.description + } + + def fill_cached_data(self, data: dict): + if data: + for base_attr in ('icon_url', 'description'): + if data.get(base_attr): + setattr(self, base_attr, data[base_attr]) + + if data.get('confinement'): + self.confinement = data['confinement'] + + def get_command(self) -> str: + return "snap run " + self.name + + def can_be_run(self) -> bool: + return self.installed and self.is_application() + + def get_publisher(self): + return self.publisher + + def get_custom_supported_actions(self) -> List[PackageAction]: + if self.installed: + return EXTRA_INSTALLED_ACTIONS diff --git a/bauh/gems/snap/resources/img/refresh.svg b/bauh/gems/snap/resources/img/refresh.svg new file mode 100755 index 00000000..16952792 --- /dev/null +++ b/bauh/gems/snap/resources/img/refresh.svg @@ -0,0 +1,146 @@ + + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/bauh/gems/snap/resources/img/snap.png b/bauh/gems/snap/resources/img/snap.png new file mode 100755 index 00000000..c0b792bf Binary files /dev/null and b/bauh/gems/snap/resources/img/snap.png differ diff --git a/bauh/gems/snap/resources/locale/en b/bauh/gems/snap/resources/locale/en new file mode 100644 index 00000000..f2d56736 --- /dev/null +++ b/bauh/gems/snap/resources/locale/en @@ -0,0 +1,3 @@ +snap.notification.snapd_unavailable=snapd seems not to be enabled. snap packages will not be available. +snap.action.refresh.status=Refreshing +snap.action.refresh.label=Refresh \ No newline at end of file diff --git a/bauh/gems/snap/resources/locale/es b/bauh/gems/snap/resources/locale/es new file mode 100644 index 00000000..84d60632 --- /dev/null +++ b/bauh/gems/snap/resources/locale/es @@ -0,0 +1,12 @@ +snap.info.commands=comandos +snap.info.contact=contacto +snap.info.description=descripción +snap.info.license=licencia +snap.info.license.unset=no está definida +snap.info.revision=revisión +snap.info.tracking=tracking +snap.info.installed=instalado +snap.info.publisher=publicador +snap.notification.snapd_unavailable=snapd no parece estar habilitado. los paquetes snap estarán indisponibles. +snap.action.refresh.status=Actualizando +snap.action.refresh.label=Actualizar \ No newline at end of file diff --git a/bauh/gems/snap/resources/locale/pt b/bauh/gems/snap/resources/locale/pt new file mode 100644 index 00000000..4e743a93 --- /dev/null +++ b/bauh/gems/snap/resources/locale/pt @@ -0,0 +1,12 @@ +snap.info.commands=comandos +snap.info.contact=contato +snap.info.description=descrição +snap.info.license=licença +snap.info.license.unset=não definida +snap.info.revision=revisão +snap.info.tracking=tracking +snap.info.installed=instalado +snap.info.publisher=publicador +snap.notification.snapd_unavailable=snapd não parece estar habilitado. os pacotes snap estarão indisponíveis. +snap.action.refresh.status=Atualizando +snap.action.refresh.label=Atualizar \ No newline at end of file diff --git a/bauh/gems/snap/snap.py b/bauh/gems/snap/snap.py new file mode 100644 index 00000000..36cab302 --- /dev/null +++ b/bauh/gems/snap/snap.py @@ -0,0 +1,141 @@ +import re +import subprocess +from typing import List + +from bauh.commons.system import new_root_subprocess, run_cmd + +BASE_CMD = 'snap' + + +def is_installed(): + version = get_snapd_version() + return False if version is None else True + + +def get_version(): + res = run_cmd('{} --version'.format(BASE_CMD), print_error=False) + return res.split('\n')[0].split(' ')[-1].strip() if res else None + + +def get_snapd_version(): + res = run_cmd('{} --version'.format(BASE_CMD), print_error=False) + + if not res: + return None + else: + lines = res.split('\n') + + if lines and len(lines) >= 2: + version = lines[1].split(' ')[-1].strip() + return version if version and version.lower() != 'unavailable' else None + else: + return None + + +def app_str_to_json(app: str) -> dict: + app_data = [word for word in app.split(' ') if word] + app_json = { + 'name': app_data[0], + 'version': app_data[1], + 'rev': app_data[2], + 'tracking': app_data[3], + 'publisher': app_data[4] if len(app_data) >= 5 else None, + 'notes': app_data[5] if len(app_data) >= 6 else None + } + + app_json.update(get_info(app_json['name'], ('summary', 'type', 'description'))) + return app_json + + +def get_info(app_name: str, attrs: tuple = None): + full_info_lines = run_cmd('{} info {}'.format(BASE_CMD, app_name)) + + data = {} + + if full_info_lines: + re_attrs = r'\w+' if not attrs else '|'.join(attrs) + info_map = re.findall(r'({}):\s+(.+)'.format(re_attrs), full_info_lines) + + for info in info_map: + data[info[0]] = info[1].strip() + + if not attrs or 'description' in attrs: + desc = re.findall(r'\|\n+((\s+.+\n+)+)', full_info_lines) + data['description'] = ''.join([w.strip() for w in desc[0][0].strip().split('\n')]).replace('.', '.\n') if desc else None + + if not attrs or 'commands' in attrs: + commands = re.findall(r'commands:\s*\n*((\s+-\s.+\s*\n)+)', full_info_lines) + data['commands'] = commands[0][0].replace('-', '').strip().split('\n') if commands else None + + return data + + +def read_installed() -> List[dict]: + res = run_cmd('{} list'.format(BASE_CMD), print_error=False) + + apps = [] + + if res and len(res) > 0: + lines = res.split('\n') + + if not lines[0].startswith('error'): + for idx, app_str in enumerate(lines): + if idx > 0 and app_str: + apps.append(app_str_to_json(app_str)) + + return apps + + +def search(word: str, exact_name: bool = False) -> List[dict]: + apps = [] + + res = run_cmd('{} find "{}"'.format(BASE_CMD, word), print_error=False) + + if res: + res = res.split('\n') + + if not res[0].startswith('No matching'): + for idx, app_str in enumerate(res): + if idx > 0 and app_str: + app_data = [word for word in app_str.split(' ') if word] + + if exact_name and app_data[0] != word: + continue + + apps.append({ + 'name': app_data[0], + 'version': app_data[1], + 'publisher': app_data[2], + 'notes': app_data[3] if app_data[3] != '-' else None, + 'summary': app_data[4] if len(app_data) == 5 else '', + 'rev': None, + 'tracking': None, + 'type': None + }) + + if exact_name and len(apps) > 0: + break + + return apps + + +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: + + install_cmd = [BASE_CMD, 'install', app_name] # default + + if confinement == 'classic': + install_cmd.append('--classic') + + return new_root_subprocess(install_cmd, root_password) + + +def downgrade_and_stream(app_name: str, root_password: str) -> subprocess.Popen: + return new_root_subprocess([BASE_CMD, 'revert', app_name], root_password) + + +def refresh_and_stream(app_name: str, root_password: str) -> subprocess.Popen: + return new_root_subprocess([BASE_CMD, 'refresh', app_name], root_password) diff --git a/bauh/gems/snap/suggestions.py b/bauh/gems/snap/suggestions.py new file mode 100644 index 00000000..f96e9c9d --- /dev/null +++ b/bauh/gems/snap/suggestions.py @@ -0,0 +1,13 @@ +from bauh.api.abstract.model import SuggestionPriority + +ALL = { + 'whatsdesk': SuggestionPriority.TOP, + 'slack': SuggestionPriority.HIGH, + 'discord': SuggestionPriority.HIGH, + 'yakyak': SuggestionPriority.MEDIUM, + 'instagraph': SuggestionPriority.MEDIUM, + 'gimp': SuggestionPriority.MEDIUM, + 'pycharm-professional': SuggestionPriority.LOW, + 'eclipse': SuggestionPriority.LOW, + 'supertuxkart': SuggestionPriority.LOW +} \ No newline at end of file diff --git a/bauh/gems/snap/worker.py b/bauh/gems/snap/worker.py new file mode 100644 index 00000000..fce9e339 --- /dev/null +++ b/bauh/gems/snap/worker.py @@ -0,0 +1,71 @@ +import traceback +from threading import Thread + +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.gems.snap import snap +from bauh.gems.snap.constants import SNAP_API_URL +from bauh.gems.snap.model import SnapApplication + + +class SnapAsyncDataLoader(Thread): + + def __init__(self, app: SnapApplication, manager: SoftwareManager, api_cache: MemoryCache, + context: ApplicationContext): + super(SnapAsyncDataLoader, self).__init__(daemon=True) + self.app = app + self.id_ = '{}#{}'.format(self.__class__.__name__, id(self)) + self.manager = manager + self.http_client = context.http_client + self.api_cache = api_cache + self.persist = False + self.download_icons = context.download_icons + self.logger = context.logger + + def run(self): + if self.app: + self.app.status = PackageStatus.LOADING_DATA + + try: + res = self.http_client.session.get('{}/search?q={}'.format(SNAP_API_URL, self.app.name)) + + if res: + try: + snap_list = res.json()['_embedded']['clickindex:package'] + except: + self.logger.warning('Snap API response responded differently from expected for app: {}'.format(self.app.name)) + return + + if not snap_list: + self.logger.warning("Could not retrieve app data for id '{}'. Server response: {}. Body: {}".format(self.app.id, res.status_code, res.content.decode())) + else: + snap_data = snap_list[0] + + api_data = { + 'confinement': snap_data.get('confinement'), + 'description': snap_data.get('description'), + 'icon_url': snap_data.get('icon_url') if self.download_icons else None + } + + self.api_cache.add(self.app.id, api_data) + self.app.confinement = api_data['confinement'] + self.app.icon_url = api_data['icon_url'] + + if not api_data.get('description'): + api_data['description'] = snap.get_info(self.app.name, ('description',)).get('description') + + self.app.description = api_data['description'] + self.persist = self.app.supports_disk_cache() + else: + self.logger.warning("Could not retrieve app data for id '{}'. Server response: {}. Body: {}".format(self.app.id, res.status_code, res.content.decode())) + except: + self.logger.error("Could not retrieve app data for id '{}'".format(self.app.id)) + traceback.print_exc() + + self.app.status = PackageStatus.READY + + if self.persist: + self.manager.cache_to_disk(pkg=self.app, icon_bytes=None, only_icon=False) diff --git a/bauh/util/cache.py b/bauh/util/cache.py index 5683a1e6..7caffef9 100644 --- a/bauh/util/cache.py +++ b/bauh/util/cache.py @@ -2,7 +2,7 @@ import datetime import time from threading import Lock, Thread -from bauh_api.abstract.cache import MemoryCache, MemoryCacheFactory +from bauh.api.abstract.cache import MemoryCache, MemoryCacheFactory class DefaultMemoryCache(MemoryCache): diff --git a/bauh/util/disk.py b/bauh/util/disk.py index 3bdb3a98..a25d386a 100644 --- a/bauh/util/disk.py +++ b/bauh/util/disk.py @@ -3,9 +3,9 @@ import os from threading import Thread, Lock from typing import Type, Dict -from bauh_api.abstract.cache import MemoryCache -from bauh_api.abstract.disk import DiskCacheLoader, DiskCacheLoaderFactory -from bauh_api.abstract.model import SoftwarePackage +from bauh.api.abstract.cache import MemoryCache +from bauh.api.abstract.disk import DiskCacheLoader, DiskCacheLoaderFactory +from bauh.api.abstract.model import SoftwarePackage class AsyncDiskCacheLoader(Thread, DiskCacheLoader): diff --git a/bauh/view/qt/apps_table.py b/bauh/view/qt/apps_table.py index 056ff15b..b5831762 100644 --- a/bauh/view/qt/apps_table.py +++ b/bauh/view/qt/apps_table.py @@ -7,8 +7,8 @@ from PyQt5.QtGui import QPixmap, QIcon, QCursor from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkRequest from PyQt5.QtWidgets import QTableWidget, QTableView, QMenu, QAction, QTableWidgetItem, QToolButton, QWidget, \ QHeaderView, QLabel, QHBoxLayout, QPushButton, QToolBar -from bauh_api.abstract.cache import MemoryCache -from bauh_api.abstract.model import PackageStatus +from bauh.api.abstract.cache import MemoryCache +from bauh.api.abstract.model import PackageStatus from bauh.util import resource from bauh.util.html import strip_html diff --git a/bauh/view/qt/components.py b/bauh/view/qt/components.py index 01cbb498..143ce1d2 100644 --- a/bauh/view/qt/components.py +++ b/bauh/view/qt/components.py @@ -1,7 +1,10 @@ -from PyQt5.QtCore import QEvent, Qt +from PyQt5.QtGui import QIcon from PyQt5.QtWidgets import QRadioButton, QGroupBox, QCheckBox, QComboBox, QGridLayout, QWidget, \ QLabel, QSizePolicy, QLineEdit -from bauh_api.abstract.view import SingleSelectComponent, InputOption, MultipleSelectComponent, SelectViewType + +from bauh import ROOT_DIR +from bauh.api.abstract.view import SingleSelectComponent, InputOption, MultipleSelectComponent, SelectViewType +from bauh.commons import resource class RadioButtonQt(QRadioButton): @@ -19,19 +22,30 @@ class RadioButtonQt(QRadioButton): class CheckboxQt(QCheckBox): - def __init__(self, model: InputOption, model_parent: MultipleSelectComponent): + def __init__(self, model: InputOption, model_parent: MultipleSelectComponent, callback): super(CheckboxQt, self).__init__() self.model = model self.model_parent = model_parent self.stateChanged.connect(self._set_checked) + self.callback = callback + self.setText(model.label) + self.setToolTip(model.tooltip) + + if model.icon_path: + self.setIcon(QIcon(model.icon_path)) def _set_checked(self, state): - if state == 2: + checked = state == 2 + + if checked: self.model_parent.values.add(self.model) else: if self.model in self.model_parent.values: self.model_parent.values.remove(self.model) + if self.callback: + self.callback(self.model, checked) + class ComboBoxQt(QComboBox): @@ -101,8 +115,8 @@ class ComboSelectQt(QGroupBox): class MultipleSelectQt(QGroupBox): - def __init__(self, model: MultipleSelectComponent): - super(MultipleSelectQt, self).__init__(model.label + ' :' if model.label else None) + def __init__(self, model: MultipleSelectComponent, callback): + super(MultipleSelectQt, self).__init__(model.label if model.label else None) self.setStyleSheet("QGroupBox { font-weight: bold }") self.model = model self._layout = QGridLayout() @@ -110,9 +124,7 @@ class MultipleSelectQt(QGroupBox): line, col = 0, 0 for op in model.options: - comp = CheckboxQt(op, model) - comp.setText(op.label) - comp.setToolTip(op.tooltip) + comp = CheckboxQt(op, model, callback) if model.values and op in model.values: self.value = comp diff --git a/bauh/view/qt/confirmation.py b/bauh/view/qt/confirmation.py index 7edcbc13..91c9f036 100644 --- a/bauh/view/qt/confirmation.py +++ b/bauh/view/qt/confirmation.py @@ -1,7 +1,7 @@ from typing import List from PyQt5.QtWidgets import QMessageBox, QVBoxLayout, QLabel, QWidget -from bauh_api.abstract.view import ViewComponent, SingleSelectComponent, MultipleSelectComponent +from bauh.api.abstract.view import ViewComponent, SingleSelectComponent, MultipleSelectComponent from bauh.view.qt.components import MultipleSelectQt, new_single_select @@ -29,7 +29,7 @@ class ConfirmationDialog(QMessageBox): if isinstance(comp, SingleSelectComponent): inst = new_single_select(comp) elif isinstance(comp, MultipleSelectComponent): - inst = MultipleSelectQt(comp) + inst = MultipleSelectQt(comp, None) else: raise Exception("Cannot render instances of " + comp.__class__.__name__) diff --git a/bauh/view/qt/dialog.py b/bauh/view/qt/dialog.py index 1f2f9e9f..00326ccc 100644 --- a/bauh/view/qt/dialog.py +++ b/bauh/view/qt/dialog.py @@ -2,7 +2,7 @@ from typing import List from PyQt5.QtGui import QIcon from PyQt5.QtWidgets import QMessageBox, QLabel, QWidget, QHBoxLayout -from bauh_api.abstract.view import MessageType +from bauh.api.abstract.view import MessageType from bauh.util import resource diff --git a/bauh/view/qt/gem_selector.py b/bauh/view/qt/gem_selector.py new file mode 100644 index 00000000..d457856a --- /dev/null +++ b/bauh/view/qt/gem_selector.py @@ -0,0 +1,69 @@ +import subprocess +import sys +from typing import List + +from PyQt5.QtCore import Qt, QCoreApplication +from PyQt5.QtGui import QIcon +from PyQt5.QtWidgets import QWidget, QLabel, QGridLayout, QPushButton + +from bauh import ROOT_DIR +from bauh.api.abstract.controller import SoftwareManager +from bauh.api.abstract.view import MultipleSelectComponent, InputOption +from bauh.commons import resource +from bauh.core.config import Configuration, save +from bauh.view.qt.components import MultipleSelectQt, CheckboxQt, new_spacer + + +class GemSelectorPanel(QWidget): + + def __init__(self, managers: List[SoftwareManager], i18n: dict, boot: bool): + super(GemSelectorPanel, self).__init__() + self.managers = managers + self.setLayout(QGridLayout()) + self.setWindowIcon(QIcon(resource.get_path('img/logo.svg', ROOT_DIR))) + self.setWindowTitle('Welcome' if boot else 'Supported types') + self.resize(400, 400) + + self.label_question = QLabel('What types of applications do you want to find here ?') + self.label_question.setStyleSheet('QLabel { font-weight: bold}') + self.layout().addWidget(self.label_question, 0, 1, Qt.AlignHCenter) + + self.bt_proceed = QPushButton('Proceed') + self.bt_proceed.setStyleSheet('QPushButton { background: green; color: white; font-weight: bold}') + self.bt_proceed.setEnabled(True) + self.bt_proceed.clicked.connect(self.save) + + self.bt_exit = QPushButton('Exit') + self.bt_exit.setStyleSheet('QPushButton { background: red; color: white; font-weight: bold}') + self.bt_exit.clicked.connect(lambda: QCoreApplication.exit()) + + gem_options = [] + + for m in managers: + modname = m.__module__.split('.')[-2] + gem_options.append(InputOption(label=i18n.get('gem.{}.label'.format(modname), modname.capitalize()), + value=modname, + icon_path='{r}/gems/{n}/resources/img/{n}.png'.format(r=ROOT_DIR, n=modname))) + + self.gem_select_model = MultipleSelectComponent(label='', options=gem_options, default_options=set(gem_options), max_per_line=3) + + self.gem_select = MultipleSelectQt(self.gem_select_model, self.check_state) + self.layout().addWidget(self.gem_select, 1, 1) + + self.layout().addWidget(new_spacer(), 2, 1) + + self.layout().addWidget(self.bt_proceed, 3, 1, Qt.AlignRight) + self.layout().addWidget(self.bt_exit, 3, 1, Qt.AlignLeft) + + self.adjustSize() + self.setFixedSize(self.size()) + + def check_state(self, model: CheckboxQt, checked: bool): + self.bt_proceed.setEnabled(bool(self.gem_select_model.values)) + + def save(self): + config = Configuration(gems=[o.value for o in self.gem_select_model.values]) + save(config) + subprocess.Popen([sys.executable, *sys.argv]) + self.close() + diff --git a/bauh/view/qt/history.py b/bauh/view/qt/history.py index 35de496f..504bfa90 100644 --- a/bauh/view/qt/history.py +++ b/bauh/view/qt/history.py @@ -4,8 +4,8 @@ from functools import reduce from PyQt5.QtCore import Qt from PyQt5.QtGui import QColor from PyQt5.QtWidgets import QDialog, QVBoxLayout, QTableWidget, QTableWidgetItem, QHeaderView -from bauh_api.abstract.cache import MemoryCache -from bauh_api.abstract.model import PackageHistory +from bauh.api.abstract.cache import MemoryCache +from bauh.api.abstract.model import PackageHistory class HistoryDialog(QDialog): diff --git a/bauh/view/qt/info.py b/bauh/view/qt/info.py index 4459f539..1f8adf16 100644 --- a/bauh/view/qt/info.py +++ b/bauh/view/qt/info.py @@ -2,7 +2,7 @@ from PyQt5.QtCore import QSize from PyQt5.QtGui import QIcon from PyQt5.QtWidgets import QDialog, QVBoxLayout, QGroupBox, \ QLineEdit, QLabel, QGridLayout, QPushButton, QPlainTextEdit, QToolBar -from bauh_api.abstract.cache import MemoryCache +from bauh.api.abstract.cache import MemoryCache from bauh.util.html import strip_html diff --git a/bauh/view/qt/root.py b/bauh/view/qt/root.py index bf05c7c9..3dffb749 100644 --- a/bauh/view/qt/root.py +++ b/bauh/view/qt/root.py @@ -3,7 +3,7 @@ import os import subprocess from PyQt5.QtWidgets import QInputDialog, QLineEdit -from bauh_api.abstract.view import MessageType +from bauh.api.abstract.view import MessageType from bauh.view.qt.dialog import show_message diff --git a/bauh/view/qt/systray.py b/bauh/view/qt/systray.py index 979d2968..72f3e967 100755 --- a/bauh/view/qt/systray.py +++ b/bauh/view/qt/systray.py @@ -5,10 +5,10 @@ from typing import List from PyQt5.QtCore import QThread, pyqtSignal, QCoreApplication, Qt from PyQt5.QtGui import QIcon from PyQt5.QtWidgets import QSystemTrayIcon, QMenu -from bauh_api.abstract.model import PackageUpdate +from bauh.api.abstract.model import PackageUpdate from bauh import __app_name__ -from bauh_api.abstract.controller import SoftwareManager +from bauh.api.abstract.controller import SoftwareManager from bauh.util import util, resource from bauh.view.qt.about import AboutDialog from bauh.view.qt.window import ManageWindow diff --git a/bauh/view/qt/thread.py b/bauh/view/qt/thread.py index 6ed1485e..0480759b 100644 --- a/bauh/view/qt/thread.py +++ b/bauh/view/qt/thread.py @@ -5,12 +5,12 @@ from typing import List, Type, Set import requests from PyQt5.QtCore import QThread, pyqtSignal -from bauh_api.abstract.cache import MemoryCache -from bauh_api.abstract.controller import SoftwareManager -from bauh_api.abstract.handler import ProcessWatcher -from bauh_api.abstract.model import PackageStatus, SoftwarePackage, PackageAction -from bauh_api.abstract.view import InputViewComponent, MessageType -from bauh_api.exception import NoInternetException +from bauh.api.abstract.cache import MemoryCache +from bauh.api.abstract.controller import SoftwareManager +from bauh.api.abstract.handler import ProcessWatcher +from bauh.api.abstract.model import PackageStatus, SoftwarePackage, PackageAction +from bauh.api.abstract.view import InputViewComponent, MessageType +from bauh.api.exception import NoInternetException from bauh.view.qt import commons from bauh.view.qt.view_model import PackageView diff --git a/bauh/view/qt/view_model.py b/bauh/view/qt/view_model.py index 44ec9ec2..45c7dd0b 100644 --- a/bauh/view/qt/view_model.py +++ b/bauh/view/qt/view_model.py @@ -1,6 +1,6 @@ from enum import Enum -from bauh_api.abstract.model import SoftwarePackage +from bauh.api.abstract.model import SoftwarePackage class PackageViewStatus(Enum): diff --git a/bauh/view/qt/window.py b/bauh/view/qt/window.py index 3383b499..d9f8b8fc 100755 --- a/bauh/view/qt/window.py +++ b/bauh/view/qt/window.py @@ -6,10 +6,10 @@ from PyQt5.QtCore import QEvent, Qt, QSize, pyqtSignal from PyQt5.QtGui import QIcon, QWindowStateChangeEvent, QPixmap from PyQt5.QtWidgets import QWidget, QVBoxLayout, QApplication, QCheckBox, QHeaderView, QToolButton, QToolBar, \ QLabel, QPlainTextEdit, QLineEdit, QProgressBar, QPushButton, QComboBox -from bauh_api.abstract.cache import MemoryCache -from bauh_api.abstract.controller import SoftwareManager -from bauh_api.abstract.model import SoftwarePackage, PackageAction -from bauh_api.abstract.view import MessageType +from bauh.api.abstract.cache import MemoryCache +from bauh.api.abstract.controller import SoftwareManager +from bauh.api.abstract.model import SoftwarePackage, PackageAction +from bauh.api.abstract.view import MessageType from bauh.util import util, resource from bauh.view.qt import dialog, commons diff --git a/requirements.txt b/requirements.txt index 1a1074da..d7d761f6 100755 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ pyqt5>=5.12 -bauh_api>=0.1.0<0.2 \ No newline at end of file +requests>=2.22 +colorama>=0.4.1