From 57ac55c53fddfafd071aa774479e49d2157f9c30 Mon Sep 17 00:00:00 2001 From: Vinicius Moreira Date: Thu, 26 May 2022 18:18:09 -0300 Subject: [PATCH] feature: custom software suggestions --- CHANGELOG.md | 5 + README.md | 37 ++++++- bauh/api/abstract/context.py | 15 ++- bauh/cli/app.py | 1 + bauh/commons/custom_types.py | 20 ++++ bauh/commons/suggestions.py | 35 +++++++ bauh/gems/appimage/__init__.py | 3 - bauh/gems/appimage/controller.py | 51 ++++++--- bauh/gems/appimage/worker.py | 165 ++++++++++++++++++++---------- bauh/gems/arch/controller.py | 30 +++--- bauh/gems/arch/suggestions.py | 106 ++++++++++--------- bauh/gems/debian/controller.py | 9 +- bauh/gems/debian/suggestions.py | 117 ++++++++++++--------- bauh/gems/flatpak/__init__.py | 1 - bauh/gems/flatpak/controller.py | 139 +++++++++++++++++++------ bauh/gems/snap/__init__.py | 1 - bauh/gems/snap/controller.py | 132 +++++++++++++++++------- bauh/gems/web/__init__.py | 3 - bauh/gems/web/controller.py | 142 ++++++++++++++++--------- bauh/gems/web/suggestions.py | 90 +++++++++++----- bauh/gems/web/worker.py | 54 ++++++---- bauh/manage.py | 2 + bauh/view/core/controller.py | 1 + bauh/view/core/suggestions.py | 32 ++++++ tests/gems/web/test_controller.py | 17 +++ 25 files changed, 840 insertions(+), 368 deletions(-) create mode 100644 bauh/commons/custom_types.py create mode 100644 bauh/commons/suggestions.py create mode 100644 bauh/view/core/suggestions.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e1db3adb..d4a38c0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Features - General - new parameter `--suggestions`: forces loading software suggestions after the initialization process [#260](https://github.com/vinifmor/bauh/issues/260) + - allowing custom suggestions / curated software to be mapped by Linux distributions (more on [README.md](https://github.com/vinifmor/bauh#suggestions)) [#260](https://github.com/vinifmor/bauh/issues/260) ### Improvements - Arch @@ -46,6 +47,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Flatpak - not all selected runtime partials to upgrade are actually requested to be upgraded +- Web + - not reading from the cached suggestions file after the first request + - not detecting some generated apps as installed + - UI - double suggestions loading call when no app is returned diff --git a/README.md b/README.md index 5da3ba51..c5d3f9d9 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ Key features - [Native Web applications](#type_web) 7. [General settings](#settings) - [Forbidden packaging formats](#forbidden_gems) + - [Custom suggestions / curated software](#suggestions) 8. [Directory structure, caching and logs](#dirs) 9. [Custom themes](#custom_themes) 10. [Tray icons](#tray_icons) @@ -213,7 +214,6 @@ bauh is officially distributed through [PyPi](https://pypi.org/project/bauh) and - Downloaded database files are stored at `~/.cache/bauh/appimage` (or `/var/cache/bauh/appimage` for **root**) as **apps.db** and **releases.db** - Databases are updated during the initialization process if they are considered outdated - The configuration file is located at `~/.config/bauh/appimage.yml` (or `/etc/bauh/appimage.yml` for **root**) and it allows the following customizations: -- Applications with ignored updates are defined at `~/.config/bauh/appimage/updates_ignored.txt` (or `/etc/bauh/appimage/updates_ignored.txt` for **root**) ``` database: @@ -222,6 +222,9 @@ suggestions: expiration: 24 # defines the period (in hours) in which the suggestions stored in disc will be considered up to date. Use 0 if you always want to update them. Default: 24. ``` +- Applications with ignored updates are defined at `~/.config/bauh/appimage/updates_ignored.txt` (or `/etc/bauh/appimage/updates_ignored.txt` for **root**) +- Cached package suggestions: `~/.cache/bauh/web/suggestions.txt` (or `/var/cache/bauh/web/suggestions.yml` for **root**) + ##### Arch packages/AUR @@ -289,6 +292,7 @@ aur_rebuild_detector: true # it checks if packages built with old library versio prefer_repository_provider: true # when there is just one repository provider for a given a dependency and several from AUR, it will be automatically picked. suggestions_exp: 24 # it defines the period (in hours) in which the suggestions stored in disc will be considered up to date. Use 0 if you always want to update them. ``` +- Cached package suggestions: `~/.cache/bauh/arch/suggestions.txt` (or `/var/cache/bauh/arch/suggestions.yml` for **root**) ##### Debian packages - Basic actions supported: **search**, **install**, **uninstall**, **upgrade** @@ -300,7 +304,7 @@ suggestions_exp: 24 # it defines the period (in hours) in which the suggestions - **purge**: removes the packages and all related configuration files - Files: - runnable applications index: `~/.cache/bauh/debian/apps_idx.json` (or `/var/cache/bauh/debian/apps_idx.json` for **root**) - - package suggestions: `~/.cache/bauh/debian/suggestions.txt` (or `/var/cache/bauh/debian/suggestions.txt` for **root**) + - cached package suggestions: `~/.cache/bauh/debian/suggestions.txt` (or `/var/cache/bauh/debian/suggestions.txt` for **root**) - configuration: `~/.config/bauh/debian.yml` or `/etc/bauh/debian.yml` - `index_apps.exp`: time period (**in minutes**) in which the installed applications cache is considered up-to-date during startup (default: `1440` -> 24 hours) - `sync_pkgs.time`: time period (**in minutes**) in which the packages synchronization must be done on startup (default: `1440` -> 24 hours) @@ -317,7 +321,7 @@ installation_level: null # defines a default installation level: "user" or "syst ``` - Custom actions supported: - **Full update**: it completely updates the Flatpak apps and components. Useful if you are having issues with runtime updates. - +- Cached package suggestions: `~/.cache/bauh/flatpak/suggestions.txt` (or `/var/cache/bauh/flatpak/suggestions.txt` for **root**) #### Snap @@ -330,6 +334,7 @@ installation_level: null # defines a default installation level: "user" or "syst install_channel: false # it allows to select an available channel during the application installation. Default: false categories_exp: 24 # It defines the expiration time (in HOURS) of the Snaps categories mapping file stored in disc. Use 0 so that it is always updated during initialization. ``` +- Cached package suggestions: `~/.cache/bauh/snap/suggestions.txt` (or `/var/cache/bauh/snap/suggestions.txt` for **root**) #### Native Web applications @@ -387,7 +392,7 @@ environment: suggestions: cache_exp: 24 # defines the period (in HOURS) in which suggestions stored on the disk are considered up to date during the initialization process. Use 0 so that they are always updated. Default: 24. ``` - +- Cached package suggestions: `~/.cache/bauh/web/suggestions.txt` (or `/var/cache/bauh/web/suggestions.yml` for **root**) #### General settings @@ -463,6 +468,30 @@ appimage # flatpak # 'sharps' can be used to ignore a given line (comment) ``` +##### Custom suggestions / curated software +- The software suggestions are download from [bauh-files](https://github.com/vinifmor/bauh-files) by default + - [appimage](https://github.com/vinifmor/bauh-files/blob/master/appimage/suggestions.txt) + - [arch](https://github.com/vinifmor/bauh-files/blob/master/appimage/suggestions.txt) + - [debian](https://github.com/vinifmor/bauh-files/blob/master/debian/suggestions_v1.txt) + - [flatpak](https://github.com/vinifmor/bauh-files/blob/master/flatpak/suggestions.txt) + - [snap](https://github.com/vinifmor/bauh-files/blob/master/snap/suggestions.txt) + - [web](https://github.com/vinifmor/bauh-files/blob/master/web/env/v2/suggestions.yml) + +- Most of the files follow the pattern: `{priority_number}:${id or name}` + - Priority numbers: 0 (LOW), 1 (MEDIUM), 2 (HIGH), 3 (TOP) + - The priority number is used to sort the retrieved suggestions + +- If Linux distributions want to provide their custom suggestions files: + - Create the file `/etc/bauh/suggestions.conf` + - The content is basically a mapping for each gem to a url or local file (absolute path). + - Example: + ``` + arch=https://mydomain.com/arch/suggestions.txt # remote file + appimage=/etc/bauh/appimage/suggestions.txt # local file (absolute path) + # snap = my mapping # comments with a '#' are allowed + ``` + - If a given gem name is omitted, its suggestions will be downloaded from the default location. + #### Directory structure, caching and logs - `~/.config/bauh` (or `/etc/bauh` for **root**): stores configuration files - `~/.cache/bauh` (or `/var/cache/bauh` for **root**): stores data about your installed applications, databases, indexes, etc. Files are stored here to provide a faster initialization and data recovery. diff --git a/bauh/api/abstract/context.py b/bauh/api/abstract/context.py index 2b41a891..3fe3f901 100644 --- a/bauh/api/abstract/context.py +++ b/bauh/api/abstract/context.py @@ -1,5 +1,6 @@ import logging import sys +from typing import Optional, Dict from bauh.api.abstract.cache import MemoryCacheFactory from bauh.api.abstract.disk import DiskCacheLoaderFactory @@ -15,7 +16,7 @@ class ApplicationContext: cache_factory: MemoryCacheFactory, disk_loader_factory: DiskCacheLoaderFactory, logger: logging.Logger, file_downloader: FileDownloader, distro: str, app_name: str, app_version: str, internet_checker: InternetChecker, root_user: bool, screen_width: int = -1, - screen_height: int = -1): + screen_height: int = -1, suggestions_mapping: Optional[Dict[str, str]] = None): """ :param download_icons: if packages icons should be downloaded :param http_client: a shared instance of http client @@ -31,6 +32,7 @@ class ApplicationContext: :param internet_checker :param screen_width :param screen_height + :param suggestions_mapping """ self.download_icons = download_icons self.http_client = http_client @@ -51,6 +53,7 @@ class ApplicationContext: self.internet_checker = internet_checker self.screen_width = screen_width self.screen_height = screen_height + self._suggestions_mapping = suggestions_mapping def is_system_x86_64(self): return self.arch_x86_64 @@ -60,3 +63,13 @@ class ApplicationContext: def is_internet_available(self) -> bool: return self.internet_checker.is_available() + + def get_suggestion_url(self, module: str, default: Optional[str] = None) -> Optional[str]: + if self._suggestions_mapping: + module_split = module.split(f'{self.app_name}.gems.') + + if len(module_split) > 1: + gem_name = module_split[1].split('.')[0] + return self._suggestions_mapping.get(gem_name, default) + + return default diff --git a/bauh/cli/app.py b/bauh/cli/app.py index 2400d5e6..ad88fb53 100644 --- a/bauh/cli/app.py +++ b/bauh/cli/app.py @@ -52,6 +52,7 @@ def main(): app_name=__app_name__, app_version=__version__, internet_checker=InternetChecker(offline=False), + suggestions_mapping=None, # TODO not needed at the moment root_user=user.is_root()) managers = gems.load_managers(context=context, locale=i18n.current_key, config=app_config, diff --git a/bauh/commons/custom_types.py b/bauh/commons/custom_types.py new file mode 100644 index 00000000..2ee4ef3c --- /dev/null +++ b/bauh/commons/custom_types.py @@ -0,0 +1,20 @@ +from typing import Optional, Any + + +class Value: + + def __init__(self, value: Optional[Any] = None): + self.value = value + + def __repr__(self): + return str(self.value) + + def __str__(self): + return self.__repr__() + + def __eq__(self, other): + if isinstance(other, Value): + return self.value == other.value + + def __hash__(self): + return hash(self.value) diff --git a/bauh/commons/suggestions.py b/bauh/commons/suggestions.py new file mode 100644 index 00000000..c7565e2c --- /dev/null +++ b/bauh/commons/suggestions.py @@ -0,0 +1,35 @@ +from logging import Logger +from typing import Dict, Optional, Tuple + +from bauh.api.abstract.model import SuggestionPriority + + +def parse(suggestions_str: str, logger: Optional[Logger] = None, type_: Optional[str] = None, + splitter: str = '=') \ + -> Dict[str, SuggestionPriority]: + output = dict() + + for line in suggestions_str.split('\n'): + clean_line = line.strip() + + if clean_line: + line_split = clean_line.split(splitter, 1) + + if len(line_split) == 2: + prio_str, name = line_split[0].strip(), line_split[1].strip() + + if prio_str and name: + try: + prio = int(line_split[0]) + except ValueError: + if logger: + logger.warning(f"Could not parse {type_ + ' ' if type_ else ''}suggestion: {line}") + continue + + output[line_split[1]] = SuggestionPriority(prio) + + return output + + +def sort_by_priority(names_prios: Dict[str, SuggestionPriority]) -> Tuple[str, ...]: + return tuple(pair[1] for pair in sorted(((names_prios[n], n) for n in names_prios), reverse=True)) diff --git a/bauh/gems/appimage/__init__.py b/bauh/gems/appimage/__init__.py index 24133d96..4812d5cc 100644 --- a/bauh/gems/appimage/__init__.py +++ b/bauh/gems/appimage/__init__.py @@ -9,7 +9,6 @@ from bauh.commons import resource ROOT_DIR = os.path.dirname(os.path.abspath(__file__)) APPIMAGE_SHARED_DIR = f'{SHARED_FILES_DIR}/appimage' INSTALLATION_DIR = f'{APPIMAGE_SHARED_DIR}/installed' -SUGGESTIONS_FILE = f'https://raw.githubusercontent.com/vinifmor/{__app_name__}-files/master/appimage/suggestions.txt' CONFIG_FILE = f'{CONFIG_DIR}/appimage.yml' APPIMAGE_CONFIG_DIR = f'{CONFIG_DIR}/appimage' UPDATES_IGNORED_FILE = f'{APPIMAGE_CONFIG_DIR}/updates_ignored.txt' @@ -19,8 +18,6 @@ APPIMAGE_CACHE_DIR = f'{CACHE_DIR}/appimage' DATABASE_APPS_FILE = f'{APPIMAGE_CACHE_DIR}/apps.db' DATABASE_RELEASES_FILE = f'{APPIMAGE_CACHE_DIR}/releases.db' DATABASES_TS_FILE = f'{APPIMAGE_CACHE_DIR}/dbs.ts' -SUGGESTIONS_CACHED_FILE = f'{APPIMAGE_CACHE_DIR}/suggestions.txt' -SUGGESTIONS_CACHED_TS_FILE = f'{APPIMAGE_CACHE_DIR}/suggestions.ts' DOWNLOAD_DIR = f'{TEMP_DIR}/appimage/download' diff --git a/bauh/gems/appimage/controller.py b/bauh/gems/appimage/controller.py index 1abc57ae..88ba7f98 100644 --- a/bauh/gems/appimage/controller.py +++ b/bauh/gems/appimage/controller.py @@ -7,7 +7,6 @@ import sqlite3 import subprocess import traceback from datetime import datetime -from math import floor from pathlib import Path from typing import Set, Type, List, Tuple, Optional, Iterable, Generator @@ -83,6 +82,7 @@ class AppImageManager(SoftwareManager, SettingsController): self._action_self_install: Optional[CustomSoftwareAction] = None self._app_github: Optional[str] = None self._search_unfilled_attrs: Optional[Tuple[str, ...]] = None + self._suggestions_downloader: Optional[AppImageSuggestionsDownloader] = None def install_file(self, root_password: Optional[str], watcher: ProcessWatcher) -> bool: max_width = 350 @@ -746,9 +746,11 @@ class AppImageManager(SoftwareManager, SettingsController): create_config=create_config, http_client=self.context.http_client, logger=self.context.logger).start() - AppImageSuggestionsDownloader(taskman=task_manager, i18n=self.context.i18n, - http_client=self.context.http_client, logger=self.context.logger, - create_config=create_config).start() + if not self.suggestions_downloader.is_custom_local_file_mapped(): + self.suggestions_downloader.taskman = task_manager + self.suggestions_downloader.create_config = create_config + self.suggestions_downloader.register_task() + self.suggestions_downloader.start() def list_updates(self, internet_available: bool) -> List[PackageUpdate]: res = self.read_installed(disk_loader=None, internet_available=internet_available) @@ -768,23 +770,24 @@ class AppImageManager(SoftwareManager, SettingsController): return [self.i18n['appimage.warning.missing_db_files'].format(appimage=bold('AppImage'))] def list_suggestions(self, limit: int, filter_installed: bool) -> Optional[List[PackageSuggestion]]: - res = [] + if limit == 0: + return connection = self._get_db_connection(DATABASE_APPS_FILE) if connection: - suggestions = AppImageSuggestionsDownloader(appimage_config=self.configman.get_config(), logger=self.logger, - i18n=self.i18n, http_client=self.http_client, - taskman=TaskManager()).read() + self.suggestions_downloader.taskman = TaskManager() + suggestions = tuple(self.suggestions_downloader.read()) if not suggestions: - self.logger.warning("Could not read suggestions") - return res + self.logger.warning("Could not read AppImage suggestions") + return else: - self.logger.info("Mapping suggestions") + self.logger.info("Mapping AppImage suggestions") try: if filter_installed: - installed = {i.name.lower() for i in self.read_installed(disk_loader=None, connection=connection).installed} + installed = {i.name.lower() for i in self.read_installed(disk_loader=None, + connection=connection).installed} else: installed = None @@ -795,7 +798,7 @@ class AppImageManager(SoftwareManager, SettingsController): name = lsplit[1].strip() - if limit <= 0 or len(sugs_map) < limit: + if limit < 0 or len(sugs_map) < limit: if not installed or not name.lower() in installed: sugs_map[name] = SuggestionPriority(int(lsplit[0])) else: @@ -804,17 +807,18 @@ class AppImageManager(SoftwareManager, SettingsController): cursor = connection.cursor() cursor.execute(query.FIND_APPS_BY_NAME_FULL.format(','.join([f"'{s}'" for s in sugs_map.keys()]))) + res = [] for t in cursor.fetchall(): app = AppImage(*t, i18n=self.i18n) res.append(PackageSuggestion(app, sugs_map[app.name.lower()])) - self.logger.info(f"Mapped {len(res)} suggestions") + + self.logger.info(f"Mapped {len(res)} AppImage suggestions") + return res except: traceback.print_exc() finally: connection.close() - return res - def is_default_enabled(self) -> bool: return True @@ -1080,3 +1084,18 @@ class AppImageManager(SoftwareManager, SettingsController): self._app_github = f'vinifmor/{self.context.app_name}' return self._app_github + + @property + def suggestions_downloader(self) -> AppImageSuggestionsDownloader: + if not self._suggestions_downloader: + file_url = self.context.get_suggestion_url(self.__module__) + self._suggestions_downloader = AppImageSuggestionsDownloader(taskman=TaskManager(), + i18n=self.context.i18n, + http_client=self.context.http_client, + logger=self.context.logger, + file_url=file_url) + + if self._suggestions_downloader.is_custom_local_file_mapped(): + self.logger.info(f"Local AppImage suggestions file mapped: {file_url}") + + return self._suggestions_downloader diff --git a/bauh/gems/appimage/worker.py b/bauh/gems/appimage/worker.py index ea6ede95..95d8a3ae 100644 --- a/bauh/gems/appimage/worker.py +++ b/bauh/gems/appimage/worker.py @@ -8,7 +8,7 @@ import traceback from datetime import datetime, timedelta from pathlib import Path from threading import Thread -from typing import Optional, List +from typing import Optional, Generator import requests @@ -17,8 +17,7 @@ from bauh.api.http import HttpClient from bauh.commons.boot import CreateConfigFile from bauh.commons.html import bold from bauh.gems.appimage import get_icon_path, INSTALLATION_DIR, SYMLINKS_DIR, util, DATABASES_TS_FILE, \ - APPIMAGE_CACHE_DIR, DATABASE_APPS_FILE, DATABASE_RELEASES_FILE, URL_COMPRESSED_DATABASES, SUGGESTIONS_FILE, \ - SUGGESTIONS_CACHED_TS_FILE, SUGGESTIONS_CACHED_FILE + APPIMAGE_CACHE_DIR, DATABASE_APPS_FILE, DATABASE_RELEASES_FILE, URL_COMPRESSED_DATABASES from bauh.gems.appimage.model import AppImage from bauh.view.util.translation import I18n @@ -286,7 +285,9 @@ class SymlinksVerifier(Thread): class AppImageSuggestionsDownloader(Thread): - def __init__(self, logger: logging.Logger, http_client: HttpClient, i18n: I18n, taskman: TaskManager, create_config: Optional[CreateConfigFile] = None, appimage_config: Optional[dict] = None): + def __init__(self, logger: logging.Logger, http_client: HttpClient, i18n: I18n, file_url: Optional[str], + create_config: Optional[CreateConfigFile] = None, appimage_config: Optional[dict] = None, + taskman: Optional[TaskManager] = None): super(AppImageSuggestionsDownloader, self).__init__(daemon=True) self.create_config = create_config self.logger = logger @@ -295,133 +296,189 @@ class AppImageSuggestionsDownloader(Thread): self.taskman = taskman self.config = appimage_config self.task_id = 'appim.suggestions' - self.taskman.register_task(id_=self.task_id, label=i18n['task.download_suggestions'], icon_path=get_icon_path()) + self._cached_file_path: Optional[str] = None + self._cached_ts_file_path: Optional[str] = None + + if file_url: + self._file_url = file_url + else: + self._file_url = f'https://raw.githubusercontent.com/vinifmor/bauh-files/master/appimage/suggestions.txt' + + @property + def cached_file_path(self) -> str: + if not self._cached_file_path: + self._cached_file_path = f'{APPIMAGE_CACHE_DIR}/suggestions.txt' + + return self._cached_file_path + + @property + def cached_ts_file_path(self) -> str: + if not self._cached_ts_file_path: + self._cached_ts_file_path = f'{APPIMAGE_CACHE_DIR}/suggestions.ts' + + return self._cached_ts_file_path + + def register_task(self): + self.taskman.register_task(id_=self.task_id, + label=self.i18n['task.download_suggestions'], icon_path=get_icon_path()) + + def is_custom_local_file_mapped(self) -> bool: + return self._file_url and self._file_url.startswith('/') def should_download(self, appimage_config: dict) -> bool: + if not self._file_url: + self.logger.error("No AppImage suggestions file URL defined") + return False + + if self.is_custom_local_file_mapped(): + return False + try: exp_hours = int(appimage_config['suggestions']['expiration']) except: - self.logger.error("An exception happened while trying to parse 'suggestions.expiration'") + self.logger.error("An exception happened while trying to parse the AppImage 'suggestions.expiration'") traceback.print_exc() return True if exp_hours <= 0: - self.logger.info("Suggestions cache is disabled") + self.logger.info("The AppImage suggestions cache is disabled") return True - if not os.path.exists(SUGGESTIONS_CACHED_FILE): - self.logger.info("'{}' not found. It must be downloaded".format(SUGGESTIONS_CACHED_FILE)) + if not os.path.exists(self.cached_file_path): + self.logger.info(f"File {self.cached_file_path} not found. It must be downloaded") return True - if not os.path.exists(SUGGESTIONS_CACHED_TS_FILE): - self.logger.info("'{}' not found. The suggestions file must be downloaded.") + if not os.path.exists(self.cached_ts_file_path): + self.logger.info(f"File {self.cached_ts_file_path} not found. The suggestions file must be downloaded.") return True - with open(SUGGESTIONS_CACHED_TS_FILE) as f: + with open(self.cached_ts_file_path) as f: timestamp_str = f.read() try: suggestions_timestamp = datetime.fromtimestamp(float(timestamp_str)) except: - self.logger.error('Could not parse the cached suggestions timestamp: {}'.format(timestamp_str)) + self.logger.error(f'Could not parse the cached AppImage suggestions timestamp: {timestamp_str}') traceback.print_exc() return True update = suggestions_timestamp + timedelta(hours=exp_hours) <= datetime.utcnow() return update - def read(self) -> Optional[List[str]]: - self.logger.info("Checking if suggestions should be downloaded") + def read(self) -> Generator[str, None, None]: + if not self._file_url: + self.logger.error("No AppImage suggestions file URL defined") + yield from () + + self.logger.info("Checking if AppImage suggestions should be downloaded") if self.should_download(self.config): suggestions_timestamp = datetime.utcnow().timestamp() suggestions_str = self.download() Thread(target=self.cache_suggestions, args=(suggestions_str, suggestions_timestamp), daemon=True).start() else: - self.logger.info("Reading cached suggestions from '{}'".format(SUGGESTIONS_CACHED_FILE)) - with open(SUGGESTIONS_CACHED_FILE) as f: + if self.is_custom_local_file_mapped(): + file_path, log_ref = self._file_url, 'local' + else: + file_path, log_ref = self.cached_file_path, 'cached' + + self.logger.info(f"Reading {log_ref} AppImage suggestions from {file_path}") + with open(file_path) as f: suggestions_str = f.read() - return self.map_suggestions(suggestions_str) if suggestions_str else None + yield from self.map_suggestions(suggestions_str) if suggestions_str else () def cache_suggestions(self, text: str, timestamp: float): - self.logger.info("Caching suggestions to '{}'".format(SUGGESTIONS_FILE)) + self.logger.info(f"Caching AppImage suggestions to {self.cached_file_path}") - cache_dir = os.path.dirname(SUGGESTIONS_CACHED_FILE) + cache_dir = os.path.dirname(self.cached_file_path) try: Path(cache_dir).mkdir(parents=True, exist_ok=True) cache_dir_ok = True except OSError: - self.logger.error("Could not create cache directory '{}'".format(cache_dir)) + self.logger.error(f"Could not create the caching directory {cache_dir}") traceback.print_exc() cache_dir_ok = False if cache_dir_ok: try: - with open(SUGGESTIONS_CACHED_FILE, 'w+') as f: + with open(self.cached_file_path, 'w+') as f: f.write(text) except: - self.logger.error("An exception happened while writing the file '{}'".format(SUGGESTIONS_FILE)) + self.logger.error(f"An exception happened while writing AppImage suggestions to {self.cached_file_path}") traceback.print_exc() try: - with open(SUGGESTIONS_CACHED_TS_FILE, 'w+') as f: + with open(self.cached_ts_file_path, 'w+') as f: f.write(str(timestamp)) except: - self.logger.error("An exception happened while writing the file '{}'".format(SUGGESTIONS_CACHED_TS_FILE)) + self.logger.error(f"An exception happened while writing the cached AppImage suggestions timestamp " + f"to {self.cached_ts_file_path}") traceback.print_exc() def download(self) -> Optional[str]: - self.logger.info("Downloading suggestions from {}".format(SUGGESTIONS_FILE)) + if not self._file_url: + self.logger.error("No AppImage suggestions file URL defined") + return + + if self.is_custom_local_file_mapped(): + self.logger.warning("Local AppImage suggestions file mapped. Nothing will be downloaded") + return + + self.logger.info(f"Downloading AppImage suggestions from {self._file_url}") try: - res = self.http_client.get(SUGGESTIONS_FILE) + res = self.http_client.get(self._file_url) except requests.exceptions.ConnectionError: - self.logger.warning("Could not download suggestion from '{}'".format(SUGGESTIONS_FILE)) + self.logger.warning(f"Could not download suggestion from {self._file_url}") return if not res: - self.logger.warning("Could not download suggestion from '{}'".format(SUGGESTIONS_FILE)) + self.logger.warning(f"Could not download suggestion from {self._file_url}") return if not res.text: - self.logger.warning("No suggestion found in {}".format(SUGGESTIONS_FILE)) + self.logger.warning(f"No AppImage suggestion found in {self._file_url}") return return res.text - def map_suggestions(self, text: str) -> List[str]: - return [line for line in text.split('\n') if line] + def map_suggestions(self, text: str) -> Generator[str, None, None]: + return (line for line in text.split('\n') if line) def run(self): - if self.create_config: - self.taskman.update_progress(self.task_id, 0, self.i18n['task.waiting_task'].format(bold(self.create_config.task_name))) - self.create_config.join() - self.config = self.create_config.config - ti = time.time() - self.taskman.update_progress(self.task_id, 1, None) - self.logger.info("Checking if suggestions should be downloaded") - should_download = self.should_download(self.config) - self.taskman.update_progress(self.task_id, 30, None) + if not self.is_custom_local_file_mapped(): + if self.create_config: + wait_msg = self.i18n['task.waiting_task'].format(bold(self.create_config.task_name)) + self.taskman.update_progress(self.task_id, 0, wait_msg) + self.create_config.join() + self.config = self.create_config.config - try: - if should_download: - suggestions_timestamp = datetime.utcnow().timestamp() - suggestions_str = self.download() - self.taskman.update_progress(self.task_id, 70, None) + ti = time.time() + self.taskman.update_progress(self.task_id, 1, None) - if suggestions_str: - self.cache_suggestions(suggestions_str, suggestions_timestamp) - else: - self.logger.info("Cached suggestions are up-to-date") - except: - self.logger.error("An unexpected exception happened") - traceback.print_exc() + self.logger.info("Checking if AppImage suggestions should be downloaded") + should_download = self.should_download(self.config) + self.taskman.update_progress(self.task_id, 30, None) + + try: + if should_download: + suggestions_timestamp = datetime.utcnow().timestamp() + suggestions_str = self.download() + self.taskman.update_progress(self.task_id, 70, None) + + if suggestions_str: + self.cache_suggestions(suggestions_str, suggestions_timestamp) + else: + self.logger.info("Cached AppImage suggestions are up-to-date") + except: + self.logger.error("An unexpected exception happened while downloading AppImage suggestions") + traceback.print_exc() self.taskman.update_progress(self.task_id, 100, None) self.taskman.finish_task(self.task_id) tf = time.time() - self.logger.info("Took {0:.9f} seconds to download suggestions".format(tf - ti)) + self.logger.info(f"Took {tf - ti:.9f} seconds to download suggestions") diff --git a/bauh/gems/arch/controller.py b/bauh/gems/arch/controller.py index 22a66838..1506e792 100644 --- a/bauh/gems/arch/controller.py +++ b/bauh/gems/arch/controller.py @@ -31,6 +31,7 @@ from bauh.commons import system from bauh.commons.boot import CreateConfigFile from bauh.commons.category import CategoriesDownloader from bauh.commons.html import bold +from bauh.commons.suggestions import sort_by_priority from bauh.commons.system import SystemProcess, ProcessHandler, new_subprocess, run_cmd, SimpleProcess from bauh.commons.util import datetime_as_milis from bauh.commons.view_utils import new_select @@ -2798,9 +2799,10 @@ class ArchManager(SoftwareManager, SettingsController): self.index_aur = AURIndexUpdater(context=self.context, taskman=task_manager, create_config=create_config) # must always execute to properly determine the installed packages (even that AUR is disabled) self.index_aur.start() - self.suggestions_downloader.create_config = create_config - self.suggestions_downloader.register_task(task_manager) - self.suggestions_downloader.start() + if not self.suggestions_downloader.is_custom_local_file_mapped(): + self.suggestions_downloader.create_config = create_config + self.suggestions_downloader.register_task(task_manager) + self.suggestions_downloader.start() refresh_mirrors = RefreshMirrors(taskman=task_manager, i18n=self.i18n, root_password=root_password, logger=self.logger, create_config=create_config) @@ -3727,7 +3729,7 @@ class ArchManager(SoftwareManager, SettingsController): def list_suggestions(self, limit: int, filter_installed: bool) -> Optional[List[PackageSuggestion]]: if limit == 0: - return [] + return arch_config = self.configman.get_config() @@ -3751,7 +3753,7 @@ class ArchManager(SoftwareManager, SettingsController): if not name_priority: self.logger.info("No Arch package suggestions found") - return [] + return self.logger.info(f"Found {len(name_priority)} named Arch package suggestions") @@ -3760,7 +3762,7 @@ class ArchManager(SoftwareManager, SettingsController): if not available_packages: self.logger.error("No available Arch package found. It will not be possible to return suggestions") - return [] + return fill_ignored.join() @@ -3775,7 +3777,7 @@ class ArchManager(SoftwareManager, SettingsController): if not available_suggestions: self.logger.info("No Arch package suggestion to return") - return [] + return if filter_installed: ignored_updates = set() @@ -3784,11 +3786,9 @@ class ArchManager(SoftwareManager, SettingsController): else: ignored_updates, thread_fill_ignored_updates = None, None - # sorting by priority - suggestion_by_priority = tuple(pair[1] for pair in sorted(((name_priority[n], n) for n in available_suggestions), - reverse=True)) + suggestion_by_priority = sort_by_priority({n: name_priority[n] for n in available_suggestions}) - if 0 < limit < len(available_suggestions): + if available_suggestions and 0 < limit < len(available_suggestions): suggestion_by_priority = suggestion_by_priority[0:limit] self.logger.info(f'Available Arch package suggestions: {len(suggestion_by_priority)}') @@ -3844,8 +3844,14 @@ class ArchManager(SoftwareManager, SettingsController): @property def suggestions_downloader(self) -> RepositorySuggestionsDownloader: if not self._suggestions_downloader: + file_url = self.context.get_suggestion_url(self.__module__) + self._suggestions_downloader = RepositorySuggestionsDownloader(logger=self.logger, http_client=self.http_client, - i18n=self.i18n) + i18n=self.i18n, + file_url=file_url) + + if self._suggestions_downloader.is_custom_local_file_mapped(): + self.logger.info(f"Local Arch suggestions file mapped: {file_url}") return self._suggestions_downloader diff --git a/bauh/gems/arch/suggestions.py b/bauh/gems/arch/suggestions.py index 731c92c9..b87f9acf 100644 --- a/bauh/gems/arch/suggestions.py +++ b/bauh/gems/arch/suggestions.py @@ -12,13 +12,13 @@ from bauh.api.http import HttpClient from bauh.commons.boot import CreateConfigFile from bauh.gems.arch import ARCH_CACHE_DIR, get_icon_path from bauh.view.util.translation import I18n +from bauh.commons.suggestions import parse class RepositorySuggestionsDownloader(Thread): _file_suggestions: Optional[str] = None _file_suggestions_ts: Optional[str] = None - _url_suggestions: Optional[str] = None @classmethod def file_suggestions(cls) -> str: @@ -34,22 +34,16 @@ class RepositorySuggestionsDownloader(Thread): return cls._file_suggestions_ts - @classmethod - def url_suggestions(cls) -> str: - if cls._url_suggestions is None: - cls._url_suggestions = 'https://raw.githubusercontent.com/vinifmor/bauh-files' \ - '/master/arch/suggestions.txt' - - return cls._url_suggestions - def __init__(self, logger: Logger, http_client: HttpClient, i18n: I18n, - create_config: Optional[CreateConfigFile] = None): + create_config: Optional[CreateConfigFile] = None, file_url: Optional[str] = None): super(RepositorySuggestionsDownloader, self).__init__() self._log = logger self.i18n = i18n self.http_client = http_client self._taskman: Optional[TaskManager] = None self.create_config = create_config + self._file_url = file_url if file_url else 'https://raw.githubusercontent.com/vinifmor/bauh-files' \ + '/master/arch/suggestions.txt' self.task_id = 'arch.suggs' def register_task(self, taskman: Optional[TaskManager]): @@ -65,38 +59,50 @@ class RepositorySuggestionsDownloader(Thread): return self._taskman - @classmethod - def should_download(cls, arch_config: dict, logger: Logger, only_positive_exp: bool = False) -> bool: + def should_download(self, arch_config: dict, only_positive_exp: bool = False) -> bool: + if not self._file_url: + self._log.error("No Arch suggestions file URL defined") + return False + + if self._file_url.startswith('/'): + return False + try: exp_hours = int(arch_config['suggestions_exp']) except ValueError: - logger.error(f"The Arch configuration property 'suggestions_exp' has a non int value set: " - f"{arch_config['suggestions']['expiration']}") + self._log.error(f"The Arch configuration property 'suggestions_exp' has a non int value set: " + f"{arch_config['suggestions']['expiration']}") return not only_positive_exp if exp_hours <= 0: - logger.info("Suggestions cache is disabled") + self._log.info("Suggestions cache is disabled") return not only_positive_exp - if not os.path.exists(cls.file_suggestions()): - logger.info(f"'{cls.file_suggestions()}' not found. It must be downloaded") + if not os.path.exists(self.file_suggestions()): + self._log.info(f"'{self.file_suggestions()}' not found. It must be downloaded") return True - if not os.path.exists(cls.file_suggestions()): - logger.info(f"'{cls.file_suggestions()}' not found. The suggestions file must be downloaded.") + if not os.path.exists(self.file_suggestions_timestamp()): + self._log.info(f"'{self.file_suggestions()}' not found. The suggestions file must be downloaded.") return True - with open(cls.file_suggestions_timestamp()) as f: + with open(self.file_suggestions_timestamp()) as f: timestamp_str = f.read() try: suggestions_timestamp = datetime.fromtimestamp(float(timestamp_str)) except: - logger.error(f'Could not parse the Arch cached suggestions timestamp: {timestamp_str}') + self._log.error(f'Could not parse the Arch cached suggestions timestamp: {timestamp_str}') traceback.print_exc() return True update = suggestions_timestamp + timedelta(hours=exp_hours) <= datetime.utcnow() + + if update: + self._log.info("The cached suggestions file is no longer valid") + else: + self._log.info("The cached suggestions file is up-to-date") + return update def _save(self, text: str, timestamp: float): @@ -127,56 +133,42 @@ class RepositorySuggestionsDownloader(Thread): self._log.error(f"An exception happened while writing the file '{self.file_suggestions_timestamp()}'") traceback.print_exc() - def parse_suggestions(self, suggestions_str: str) -> Dict[str, SuggestionPriority]: - output = dict() - for line in suggestions_str.split('\n'): - clean_line = line.strip() + def read_cached(self, custom_file: Optional[str] = None) -> Optional[Dict[str, SuggestionPriority]]: + if custom_file: + file_path, log_ref = custom_file, 'local' + else: + file_path, log_ref = self.file_suggestions(), 'cached' - if clean_line: - line_split = clean_line.split(':', 1) - - if len(line_split) == 2: - try: - prio = int(line_split[0]) - except ValueError: - self._log.warning(f"Could not parse Arch package suggestion: {line}") - continue - - output[line_split[1]] = SuggestionPriority(prio) - - return output - - def read_cached(self) -> Optional[Dict[str, SuggestionPriority]]: - self._log.info(f"Reading cached suggestions file '{self.file_suggestions()}'") + self._log.info(f"Reading {log_ref} Arch suggestions file '{file_path}'") try: - with open(self.file_suggestions()) as f: + with open(file_path) as f: sugs_str = f.read() except FileNotFoundError: - self._log.warning(f"Cached suggestions file does not exist ({self.file_suggestions()})") + self._log.warning(f"{log_ref.capitalize()} suggestions file does not exist ({file_path})") return if not sugs_str: - self._log.warning(f"Cached suggestions file '{self.file_suggestions()}' is empty") + self._log.warning(f"{log_ref.capitalize()} suggestions file '{file_path}' is empty") return - return self.parse_suggestions(sugs_str) + return parse(sugs_str, self._log, 'Arch') def download(self) -> Optional[Dict[str, SuggestionPriority]]: self.taskman.update_progress(self.task_id, progress=1, substatus=None) - self._log.info(f"Downloading suggestions from {self.url_suggestions()}") - res = self.http_client.get(self.url_suggestions()) + self._log.info(f"Downloading suggestions from {self._file_url}") + res = self.http_client.get(self._file_url) suggestions = None if res.status_code == 200 and res.text: self.taskman.update_progress(self.task_id, progress=50, substatus=None) - suggestions = self.parse_suggestions(res.text) + suggestions = parse(res.text, self._log, 'Arch') if suggestions: self._save(text=res.text, timestamp=datetime.utcnow().timestamp()) else: - self._log.warning("No Arch suggestions to cache") + self._log.warning(f"Could not parse any Arch suggestion from {self._file_suggestions_ts}") else: self._log.warning(f"Could not retrieve Arch suggestions. " f"Response (status={res.status_code}, text={res.text})") @@ -186,10 +178,17 @@ class RepositorySuggestionsDownloader(Thread): return suggestions def read(self, arch_config: dict) -> Optional[Dict[str, int]]: - if self.should_download(arch_config=arch_config, logger=self._log): - return self.download() + if self._file_url: + if self.is_custom_local_file_mapped(): + return self.read_cached(custom_file=self._file_url) - return self.read_cached() + if self.should_download(arch_config=arch_config): + return self.download() + + return self.read_cached() + + def is_custom_local_file_mapped(self) -> bool: + return self._file_url and self._file_url.startswith('/') def run(self): if self.create_config: @@ -198,8 +197,7 @@ class RepositorySuggestionsDownloader(Thread): self.i18n['task.waiting_task'].format(self.create_config.task_name)) self.create_config.join() - if not self.should_download(arch_config=self.create_config.config, logger=self._log, - only_positive_exp=False): + if not self.should_download(arch_config=self.create_config.config, only_positive_exp=False): self.taskman.update_progress(self.task_id, 100, self.i18n['task.canceled']) self.taskman.finish_task(self.task_id) return diff --git a/bauh/gems/debian/controller.py b/bauh/gems/debian/controller.py index 8bc8fc64..3e8dbc49 100644 --- a/bauh/gems/debian/controller.py +++ b/bauh/gems/debian/controller.py @@ -508,7 +508,7 @@ class DebianPackageManager(SoftwareManager, SettingsController): root_password=root_password, aptitude=self.aptitude) sync_pkgs.start() - if DebianSuggestionsDownloader.should_download(deb_config, self._log, only_positive_exp=True): + if self.suggestions_downloader.should_download(deb_config, only_positive_exp=True): self.suggestions_downloader.register_task(task_manager) self.suggestions_downloader.start() @@ -887,8 +887,13 @@ class DebianPackageManager(SoftwareManager, SettingsController): @property def suggestions_downloader(self) -> DebianSuggestionsDownloader: if not self._suggestions_downloader: + file_url = self.context.get_suggestion_url(self.__module__) self._suggestions_downloader = DebianSuggestionsDownloader(i18n=self._i18n, logger=self._log, - http_client=self.context.http_client) + http_client=self.context.http_client, + file_url=file_url) + + if self._suggestions_downloader.is_local_suggestions_file(): + self._log.info(f"Local Debian suggestions file mapped: {file_url}") return self._suggestions_downloader diff --git a/bauh/gems/debian/suggestions.py b/bauh/gems/debian/suggestions.py index da361ebf..878a6a54 100644 --- a/bauh/gems/debian/suggestions.py +++ b/bauh/gems/debian/suggestions.py @@ -9,6 +9,7 @@ from typing import Optional, Dict from bauh.api.abstract.handler import TaskManager from bauh.api.abstract.model import SuggestionPriority from bauh.api.http import HttpClient +from bauh.commons.suggestions import parse from bauh.gems.debian import DEBIAN_ICON_PATH, DEBIAN_CACHE_DIR from bauh.view.util.translation import I18n @@ -33,20 +34,18 @@ class DebianSuggestionsDownloader(Thread): return cls._file_suggestions_ts - @classmethod - def url_suggestions(cls) -> str: - if cls._url_suggestions is None: - cls._url_suggestions = 'https://raw.githubusercontent.com/vinifmor/bauh-files' \ - '/master/debian/suggestions.txt' - - return cls._url_suggestions - - def __init__(self, logger: Logger, http_client: HttpClient, i18n: I18n): + def __init__(self, logger: Logger, http_client: HttpClient, i18n: I18n, file_url: Optional[str]): super(DebianSuggestionsDownloader, self).__init__() self._log = logger self.i18n = i18n self.http_client = http_client self._taskman: Optional[TaskManager] = None + + if file_url: + self._file_url = file_url + else: + self._file_url = 'https://raw.githubusercontent.com/vinifmor/bauh-files/master/debian/suggestions_v1.txt' + self.task_id = 'debian.suggs' def register_task(self, taskman: Optional[TaskManager]): @@ -55,6 +54,9 @@ class DebianSuggestionsDownloader(Thread): self._taskman.register_task(id_=self.task_id, label=self.i18n['task.download_suggestions'], icon_path=DEBIAN_ICON_PATH) + def is_local_suggestions_file(self) -> bool: + return self._file_url and self._file_url.startswith('/') + @property def taskman(self) -> TaskManager: if self._taskman is None: @@ -62,34 +64,40 @@ class DebianSuggestionsDownloader(Thread): return self._taskman - @classmethod - def should_download(cls, debian_config: dict, logger: Logger, only_positive_exp: bool = False) -> bool: + def should_download(self, debian_config: dict, only_positive_exp: bool = False) -> bool: + if not self._file_url: + self._log.error("No Debian suggestions file URL defined") + return False + + if self.is_local_suggestions_file(): + return False + try: exp_hours = int(debian_config['suggestions.exp']) except ValueError: - logger.error(f"The Debian configuration property 'suggestions.expiration' has a non int value set: " - f"{debian_config['suggestions']['expiration']}") + self._log.error(f"The Debian configuration property 'suggestions.expiration' has a non int value set: " + f"{debian_config['suggestions']['expiration']}") return not only_positive_exp if exp_hours <= 0: - logger.info("Suggestions cache is disabled") + self._log.info("Suggestions cache is disabled") return not only_positive_exp - if not os.path.exists(cls.file_suggestions()): - logger.info(f"'{cls.file_suggestions()}' not found. It must be downloaded") + if not os.path.exists(self.file_suggestions()): + self._log.info(f"'{self.file_suggestions()}' not found. It must be downloaded") return True - if not os.path.exists(cls.file_suggestions()): - logger.info(f"'{cls.file_suggestions()}' not found. The suggestions file must be downloaded.") + if not os.path.exists(self.file_suggestions()): + self._log.info(f"'{self.file_suggestions()}' not found. The suggestions file must be downloaded.") return True - with open(cls.file_suggestions_timestamp()) as f: + with open(self.file_suggestions_timestamp()) as f: timestamp_str = f.read() try: suggestions_timestamp = datetime.fromtimestamp(float(timestamp_str)) except: - logger.error(f'Could not parse the Debian cached suggestions timestamp: {timestamp_str}') + self._log.error(f'Could not parse the Debian cached suggestions timestamp: {timestamp_str}') traceback.print_exc() return True @@ -97,6 +105,13 @@ class DebianSuggestionsDownloader(Thread): return update def _save(self, text: str, timestamp: float): + if not self._file_url: + self._log.error("No Debian suggestions file URL defined") + return + + if self.is_local_suggestions_file(): + return False + self._log.info(f"Caching suggestions to '{self.file_suggestions()}'") cache_dir = os.path.dirname(self.file_suggestions()) @@ -124,51 +139,49 @@ class DebianSuggestionsDownloader(Thread): self._log.error(f"An exception happened while writing the file '{self.file_suggestions_timestamp()}'") traceback.print_exc() - def parse_suggestions(self, suggestions_str: str) -> Dict[str, SuggestionPriority]: - output = dict() - for line in suggestions_str.split('\n'): - clean_line = line.strip() - - if clean_line: - line_split = clean_line.split(':', 1) - - if len(line_split) == 2: - try: - prio = int(line_split[0]) - except ValueError: - self._log.warning(f"Could not parse Debian package suggestion: {line}") - continue - - output[line_split[1]] = SuggestionPriority(prio) - - return output - def read_cached(self) -> Optional[Dict[str, SuggestionPriority]]: - self._log.info(f"Reading cached suggestions file '{self.file_suggestions()}'") + if not self._file_url: + self._log.error("No Debian suggestions file URL defined") + return + + if self.is_local_suggestions_file(): + file_path, log_ref = self._file_url, 'local' + else: + file_path, log_ref = self.file_suggestions(), 'cached' + + self._log.info(f"Reading {log_ref} suggestions file {file_path}") try: - with open(self.file_suggestions()) as f: + with open(file_path) as f: sugs_str = f.read() except FileNotFoundError: - self._log.warning(f"Cached suggestions file does not exist ({self.file_suggestions()})") + self._log.warning(f"The {log_ref} suggestions file does not exist ({file_path})") + return + except OSError: + self._log.warning(f"Could not read from the {log_ref} suggestions file ({file_path})") + traceback.print_exc() return if not sugs_str: - self._log.warning(f"Cached suggestions file '{self.file_suggestions()}' is empty") + self._log.warning(f"The {log_ref} suggestions file '{file_path}' is empty") return - return self.parse_suggestions(sugs_str) + return parse(sugs_str, self._log, 'Debian') def download(self) -> Optional[Dict[str, SuggestionPriority]]: + if not self._file_url: + self._log.error("No Debian suggestions file URL defined") + return + self.taskman.update_progress(self.task_id, progress=1, substatus=None) - self._log.info(f"Downloading suggestions from {self.url_suggestions()}") - res = self.http_client.get(self.url_suggestions()) + self._log.info(f"Downloading Debian suggestions from {self._file_url}") + res = self.http_client.get(self._file_url) suggestions = None if res.status_code == 200 and res.text: self.taskman.update_progress(self.task_id, progress=50, substatus=None) - suggestions = self.parse_suggestions(res.text) + suggestions = parse(res.text, self._log, 'Debian') if suggestions: self._save(text=res.text, timestamp=datetime.utcnow().timestamp()) @@ -183,10 +196,14 @@ class DebianSuggestionsDownloader(Thread): return suggestions def read(self, debian_config: dict) -> Optional[Dict[str, int]]: - if self.should_download(debian_config=debian_config, logger=self._log): - return self.download() + if not self._file_url: + self._log.error("No Debian suggestions file URL defined") + return - return self.read_cached() + if self.is_local_suggestions_file() or not self.should_download(debian_config=debian_config): + return self.read_cached() + + return self.download() def run(self): self.download() diff --git a/bauh/gems/flatpak/__init__.py b/bauh/gems/flatpak/__init__.py index a775678b..a577ba88 100644 --- a/bauh/gems/flatpak/__init__.py +++ b/bauh/gems/flatpak/__init__.py @@ -9,7 +9,6 @@ from bauh.api.paths import CONFIG_DIR from bauh.commons import resource ROOT_DIR = os.path.dirname(os.path.abspath(__file__)) -SUGGESTIONS_FILE = f'https://raw.githubusercontent.com/vinifmor/{__app_name__}-files/master/flatpak/suggestions.txt' CONFIG_FILE = f'{CONFIG_DIR}/flatpak.yml' FLATPAK_CONFIG_DIR = f'{CONFIG_DIR}/flatpak' UPDATES_IGNORED_FILE = f'{FLATPAK_CONFIG_DIR}/updates_ignored.txt' diff --git a/bauh/gems/flatpak/controller.py b/bauh/gems/flatpak/controller.py index 674aebf0..a2cc9112 100644 --- a/bauh/gems/flatpak/controller.py +++ b/bauh/gems/flatpak/controller.py @@ -2,7 +2,6 @@ import os import re import traceback from datetime import datetime -from math import floor from operator import attrgetter from pathlib import Path from threading import Thread @@ -16,13 +15,14 @@ from bauh.api.abstract.controller import SearchResult, SoftwareManager, Applicat from bauh.api.abstract.disk import DiskCacheLoader from bauh.api.abstract.handler import ProcessWatcher, TaskManager from bauh.api.abstract.model import PackageHistory, PackageUpdate, SoftwarePackage, PackageSuggestion, \ - SuggestionPriority, PackageStatus, CustomSoftwareAction + PackageStatus, CustomSoftwareAction, SuggestionPriority from bauh.api.abstract.view import MessageType, FormComponent, SingleSelectComponent, InputOption, SelectViewType, \ PanelComponent, ViewComponentAlignment +from bauh.commons import suggestions from bauh.commons.boot import CreateConfigFile from bauh.commons.html import strip_html, bold from bauh.commons.system import ProcessHandler -from bauh.gems.flatpak import flatpak, SUGGESTIONS_FILE, CONFIG_FILE, UPDATES_IGNORED_FILE, FLATPAK_CONFIG_DIR, \ +from bauh.gems.flatpak import flatpak, CONFIG_FILE, UPDATES_IGNORED_FILE, FLATPAK_CONFIG_DIR, \ EXPORTS_PATH, \ get_icon_path, VERSION_1_5, VERSION_1_2, VERSION_1_12 from bauh.gems.flatpak.config import FlatpakConfigManager @@ -48,6 +48,7 @@ class FlatpakManager(SoftwareManager, SettingsController): self.logger = context.logger self.configman = FlatpakConfigManager() self._action_full_update: Optional[CustomSoftwareAction] = None + self._suggestions_file_url: Optional[str] = None def get_managed_types(self) -> Set["type"]: return {FlatpakApplication} @@ -583,47 +584,99 @@ class FlatpakManager(SoftwareManager, SettingsController): def list_warnings(self, internet_available: bool) -> Optional[List[str]]: pass - def list_suggestions(self, limit: int, filter_installed: bool) -> Optional[List[PackageSuggestion]]: - cli_version = flatpak.get_version() - res = [] + def _read_local_suggestions_file(self) -> Optional[str]: + try: + with open(self.suggestions_file_url) as f: + suggestions_str = f.read() - self.logger.info("Downloading the suggestions file {}".format(SUGGESTIONS_FILE)) - file = self.http_client.get(SUGGESTIONS_FILE) + return suggestions_str + except FileNotFoundError: + self.logger.error(f"Local Flatpak suggestions file not found: {self.suggestions_file_url}") + except OSError: + self.logger.error(f"Could not read local Flatpak suggestions file: {self.suggestions_file_url}") + traceback.print_exc() - if not file or not file.text: - self.logger.warning("No suggestion found in {}".format(SUGGESTIONS_FILE)) - return res + def _download_remote_suggestions_file(self) -> Optional[str]: + self.logger.info(f"Downloading the Flatpak suggestions from {self.suggestions_file_url}") + file = self.http_client.get(self.suggestions_file_url) + + if file: + return file.text + + def _fill_suggestion(self, appid: str, priority: SuggestionPriority, flatpak_version: Version, remote: str, + output: List[PackageSuggestion]): + app_json = flatpak.search(flatpak_version, appid, remote, app_id=True) + + if app_json: + model = PackageSuggestion(self._map_to_model(app_json[0], False, None)[0], priority) + self.suggestions_cache.add(appid, model) + output.append(model) else: - self.logger.info("Mapping suggestions") - remote_level = self._get_search_remote() - installed = {i.id for i in self.read_installed(disk_loader=None).installed} if filter_installed else None + self.logger.warning(f"Could not find Flatpak suggestions '{appid}'") - for line in file.text.split('\n'): - if line: - if limit <= 0 or len(res) < limit: - sug = line.split('=') - appid = sug[1].strip() + def list_suggestions(self, limit: int, filter_installed: bool) -> Optional[List[PackageSuggestion]]: + if limit == 0: + return - if installed and appid in installed: - continue + if self.is_local_suggestions_file_mapped(): + suggestions_str = self._read_local_suggestions_file() + else: + suggestions_str = self._download_remote_suggestions_file() - priority = SuggestionPriority(int(sug[0])) + if suggestions_str is None: + return - cached_sug = self.suggestions_cache.get(appid) + if not suggestions_str: + self.logger.warning(f"No Flatpak suggestion found in {self.suggestions_file_url}") + return - if cached_sug: - res.append(cached_sug) - else: - app_json = flatpak.search(cli_version, appid, remote_level, app_id=True) + ids_prios = suggestions.parse(suggestions_str, self.logger, 'Flatpak') - if app_json: - model = PackageSuggestion(self._map_to_model(app_json[0], False, None)[0], priority) - self.suggestions_cache.add(appid, model) - res.append(model) - else: - break + if not ids_prios: + self.logger.warning(f"No Flatpak suggestion could be parsed from {self.suggestions_file_url}") + return + + suggestion_by_priority = suggestions.sort_by_priority(ids_prios) + + if filter_installed: + installed = {i.id for i in self.read_installed(disk_loader=None).installed} + + if installed: + suggestion_by_priority = tuple(id_ for id_ in suggestion_by_priority if id_ not in installed) + + if suggestion_by_priority and 0 < limit < len(suggestion_by_priority): + suggestion_by_priority = suggestion_by_priority[0:limit] + + self.logger.info(f'Available Flatpak suggestions: {len(suggestion_by_priority)}') + + if not suggestion_by_priority: + return + + flatpak_version = flatpak.get_version() + remote = self._get_search_remote() + + self.logger.info("Mapping Flatpak suggestions") + res, fill_suggestions = [], [] + cached_count = 0 + + for appid in suggestion_by_priority: + cached_instance = self.suggestions_cache.get(appid) + + if cached_instance: + res.append(cached_instance) + cached_count += 1 + else: + fill = Thread(target=self._fill_suggestion, args=(appid, ids_prios[appid], flatpak_version, + remote, res)) + fill.start() + fill_suggestions.append(fill) + + for fill in fill_suggestions: + fill.join() + + if cached_count > 0: + self.logger.info(f"Returning {cached_count} cached Flatpak suggestions") - res.sort(key=lambda s: s.priority.value, reverse=True) return res def is_default_enabled(self) -> bool: @@ -796,3 +849,21 @@ class FlatpakManager(SoftwareManager, SettingsController): requires_root=False) return self._action_full_update + + @property + def suggestions_file_url(self) -> str: + if self._suggestions_file_url is None: + file_url = self.context.get_suggestion_url(self.__module__) + + if not file_url: + file_url = 'https://raw.githubusercontent.com/vinifmor/bauh-files/master/flatpak/suggestions.txt' + + self._suggestions_file_url = file_url + + if file_url.startswith('/'): + self.logger.info(f"Local Flatpak suggestions file mapped: {file_url}") + + return self._suggestions_file_url + + def is_local_suggestions_file_mapped(self) -> bool: + return self.suggestions_file_url.startswith('/') diff --git a/bauh/gems/snap/__init__.py b/bauh/gems/snap/__init__.py index 582ce071..978c1d33 100644 --- a/bauh/gems/snap/__init__.py +++ b/bauh/gems/snap/__init__.py @@ -8,7 +8,6 @@ SNAP_CACHE_DIR = f'{CACHE_DIR}/snap' CONFIG_FILE = f'{CONFIG_DIR}/snap.yml' CATEGORIES_FILE_PATH = f'{SNAP_CACHE_DIR}/categories.txt' URL_CATEGORIES_FILE = 'https://raw.githubusercontent.com/vinifmor/bauh-files/master/snap/categories.txt' -SUGGESTIONS_FILE = 'https://raw.githubusercontent.com/vinifmor/bauh-files/master/snap/suggestions.txt' def get_icon_path() -> str: diff --git a/bauh/gems/snap/controller.py b/bauh/gems/snap/controller.py index 02d47f83..7cabfd17 100644 --- a/bauh/gems/snap/controller.py +++ b/bauh/gems/snap/controller.py @@ -13,12 +13,13 @@ from bauh.api.abstract.model import SoftwarePackage, PackageHistory, PackageUpda from bauh.api.abstract.view import SingleSelectComponent, SelectViewType, InputOption, PanelComponent, \ FormComponent, TextInputComponent from bauh.api.exception import NoInternetException +from bauh.commons import suggestions from bauh.commons.boot import CreateConfigFile from bauh.commons.category import CategoriesDownloader from bauh.commons.html import bold from bauh.commons.system import SystemProcess, ProcessHandler, new_root_subprocess from bauh.commons.view_utils import new_select, get_human_size_str -from bauh.gems.snap import snap, URL_CATEGORIES_FILE, CATEGORIES_FILE_PATH, SUGGESTIONS_FILE, \ +from bauh.gems.snap import snap, URL_CATEGORIES_FILE, CATEGORIES_FILE_PATH, \ get_icon_path, snapd from bauh.gems.snap.config import SnapConfigManager from bauh.gems.snap.model import SnapApplication @@ -42,6 +43,7 @@ class SnapManager(SoftwareManager, SettingsController): self.suggestions_cache = context.cache_factory.new() self.info_path = None self.configman = SnapConfigManager() + self._suggestions_url: Optional[str] = None def _fill_categories(self, app: SnapApplication): categories = self.categories.get(app.name.lower()) @@ -315,7 +317,8 @@ class SnapManager(SoftwareManager, SettingsController): self.logger.warning(f'It seems Snap API is not available. Search output: {output}') return [self.i18n['snap.notifications.api.unavailable'].format(bold('Snaps'), bold('Snap'))] - def _fill_suggestion(self, name: str, priority: SuggestionPriority, snapd_client: SnapdClient, out: List[PackageSuggestion]): + def _fill_suggestion(self, name: str, priority: SuggestionPriority, snapd_client: SnapdClient, + out: List[PackageSuggestion]): res = snapd_client.find_by_name(name) if res: @@ -361,46 +364,87 @@ class SnapManager(SoftwareManager, SettingsController): app.status = PackageStatus.READY return app + def _read_local_suggestions_file(self) -> Optional[str]: + try: + with open(self.suggestions_url) as f: + suggestions_str = f.read() + + return suggestions_str + except FileNotFoundError: + self.logger.error(f"Local Snap suggestions file not found: {self.suggestions_url}") + except OSError: + self.logger.error(f"Could not read local Snap suggestions file: {self.suggestions_url}") + traceback.print_exc() + + def _download_remote_suggestions_file(self) -> Optional[str]: + self.logger.info(f"Downloading the Snap suggestions from {self.suggestions_url}") + file = self.http_client.get(self.suggestions_url) + + if file: + return file.text + def list_suggestions(self, limit: int, filter_installed: bool) -> Optional[List[PackageSuggestion]]: - res = [] + if limit == 0 or not snapd.is_running(): + return - if snapd.is_running(): - self.logger.info(f'Downloading suggestions file {SUGGESTIONS_FILE}') - file = self.http_client.get(SUGGESTIONS_FILE) + if self.is_local_suggestions_file_mapped(): + suggestions_str = self._read_local_suggestions_file() + else: + suggestions_str = self._download_remote_suggestions_file() - if not file or not file.text: - self.logger.warning(f"No suggestion found in {SUGGESTIONS_FILE}") - return res + if suggestions_str is None: + return + + if not suggestions_str: + self.logger.warning(f"No Snap suggestion found in {self.suggestions_url}") + return + + ids_prios = suggestions.parse(suggestions_str, self.logger, 'Snap') + + if not ids_prios: + self.logger.warning(f"No Snap suggestion could be parsed from {self.suggestions_url}") + return + + suggestion_by_priority = suggestions.sort_by_priority(ids_prios) + snapd_client = SnapdClient(self.logger) + + if filter_installed: + installed = {s['name'].lower() for s in snapd_client.list_all_snaps()} + + if installed: + suggestion_by_priority = tuple(n for n in suggestion_by_priority if n not in installed) + + if suggestion_by_priority and 0 < limit < len(suggestion_by_priority): + suggestion_by_priority = suggestion_by_priority[0:limit] + + self.logger.info(f'Available Snap suggestions: {len(suggestion_by_priority)}') + + if not suggestion_by_priority: + return + + self.logger.info("Mapping Snap suggestions") + + instances, threads = [], [] + + res, cached_count = [], 0 + for name in suggestion_by_priority: + cached_sug = self.suggestions_cache.get(name) + + if cached_sug: + res.append(cached_sug) + cached_count += 1 else: - self.logger.info('Mapping suggestions') + t = Thread(target=self._fill_suggestion, args=(name, ids_prios[name], snapd_client, res)) + t.start() + threads.append(t) + time.sleep(0.001) # to avoid being blocked - suggestions, threads = [], [] - snapd_client = SnapdClient(self.logger) - installed = {s['name'].lower() for s in snapd_client.list_all_snaps()} + for t in threads: + t.join() - for l in file.text.split('\n'): - if l: - if limit <= 0 or len(suggestions) < limit: - sug = l.strip().split('=') - name = sug[1] + if cached_count > 0: + self.logger.info(f"Returning {cached_count} cached Snap suggestions") - if not installed or name not in installed: - cached_sug = self.suggestions_cache.get(name) - - if cached_sug: - res.append(cached_sug) - else: - t = Thread(target=self._fill_suggestion, args=(name, SuggestionPriority(int(sug[0])), snapd_client, res)) - t.start() - threads.append(t) - time.sleep(0.001) # to avoid being blocked - else: - break - - for t in threads: - t.join() - - res.sort(key=lambda s: s.priority.value, reverse=True) return res def is_default_enabled(self) -> bool: @@ -509,3 +553,21 @@ class SnapManager(SoftwareManager, SettingsController): raise Exception('aborted') else: return select.get_selected() + + @property + def suggestions_url(self) -> str: + if not self._suggestions_url: + file_url = self.context.get_suggestion_url(self.__module__) + + if not file_url: + file_url = 'https://raw.githubusercontent.com/vinifmor/bauh-files/master/snap/suggestions.txt' + + self._suggestions_url = file_url + + if file_url.startswith('/'): + self.logger.info(f"Local Snap suggestions file mapped: {file_url}") + + return self._suggestions_url + + def is_local_suggestions_file_mapped(self) -> bool: + return self.suggestions_url.startswith('/') diff --git a/bauh/gems/web/__init__.py b/bauh/gems/web/__init__.py index dbb27a29..28802793 100644 --- a/bauh/gems/web/__init__.py +++ b/bauh/gems/web/__init__.py @@ -22,12 +22,9 @@ URL_ENVIRONMENT_SETTINGS = f'https://raw.githubusercontent.com/vinifmor/bauh-fil DESKTOP_ENTRY_PATH_PATTERN = f'{DESKTOP_ENTRIES_DIR}/{__app_name__}.web.' + '{name}.desktop' URL_FIX_PATTERN = "https://raw.githubusercontent.com/vinifmor/bauh-files/master/web/env/v2/fix/{domain}/{electron_branch}/fix.js" URL_PROPS_PATTERN = "https://raw.githubusercontent.com/vinifmor/bauh-files/master/web/env/v2/fix/{domain}/{electron_branch}/properties" -URL_SUGGESTIONS = "https://raw.githubusercontent.com/vinifmor/bauh-files/master/web/env/v2/suggestions.yml" UA_CHROME = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.110 Safari/537.36' TEMP_PATH = f'{TEMP_DIR}/web' SEARCH_INDEX_FILE = f'{WEB_CACHE_DIR}/index.yml' -SUGGESTIONS_CACHE_FILE = f'{WEB_CACHE_DIR}/suggestions.yml' -SUGGESTIONS_CACHE_TS_FILE = map_timestamp_file(SUGGESTIONS_CACHE_FILE) CONFIG_FILE = f'{CONFIG_DIR}/web.yml' ENVIRONMENT_SETTINGS_CACHED_FILE = f'{WEB_CACHE_DIR}/environment.yml' ENVIRONMENT_SETTINGS_TS_FILE = f'{WEB_CACHE_DIR}/environment.ts' diff --git a/bauh/gems/web/controller.py b/bauh/gems/web/controller.py index 7844df6a..7de6698e 100644 --- a/bauh/gems/web/controller.py +++ b/bauh/gems/web/controller.py @@ -6,10 +6,9 @@ import re import shutil import subprocess import traceback -from math import floor from pathlib import Path from threading import Thread -from typing import List, Type, Set, Tuple, Optional, Dict, Generator, Iterable +from typing import List, Type, Set, Tuple, Optional, Dict, Generator, Iterable, Pattern import requests import yaml @@ -32,9 +31,8 @@ from bauh.commons.boot import CreateConfigFile from bauh.commons.html import bold from bauh.commons.system import ProcessHandler, get_dir_size, SimpleProcess from bauh.commons.view_utils import get_human_size_str -from bauh.gems.web import INSTALLED_PATH, nativefier, DESKTOP_ENTRY_PATH_PATTERN, URL_FIX_PATTERN, ENV_PATH, UA_CHROME, \ - SUGGESTIONS_CACHE_FILE, ROOT_DIR, TEMP_PATH, FIX_FILE_PATH, ELECTRON_CACHE_DIR, \ - get_icon_path, URL_PROPS_PATTERN +from bauh.gems.web import INSTALLED_PATH, nativefier, DESKTOP_ENTRY_PATH_PATTERN, URL_FIX_PATTERN, ENV_PATH, \ + ROOT_DIR, TEMP_PATH, FIX_FILE_PATH, ELECTRON_CACHE_DIR, UA_CHROME, get_icon_path, URL_PROPS_PATTERN from bauh.gems.web.config import WebConfigManager from bauh.gems.web.environment import EnvironmentUpdater, EnvironmentComponent from bauh.gems.web.model import WebApplication @@ -44,18 +42,18 @@ from bauh.gems.web.worker import SuggestionsManager, UpdateEnvironmentSettings, try: from bs4 import BeautifulSoup, SoupStrainer + BS4_AVAILABLE = True except: BS4_AVAILABLE = False - try: import lxml + LXML_AVAILABLE = True except: LXML_AVAILABLE = False -RE_PROTOCOL_STRIP = re.compile(r'[a-zA-Z]+://') RE_SEVERAL_SPACES = re.compile(r'\s+') RE_SYMBOLS_SPLIT = re.compile(r'[\-|_\s:.]') @@ -64,7 +62,7 @@ DEFAULT_LANGUAGE_HEADER = 'en-US, en' class WebApplicationManager(SoftwareManager, SettingsController): - def __init__(self, context: ApplicationContext, suggestions_loader: Optional[SuggestionsLoader] = None): + def __init__(self, context: ApplicationContext): super(WebApplicationManager, self).__init__(context=context) self.http_client = context.http_client self.env_updater = EnvironmentUpdater(logger=context.logger, http_client=context.http_client, @@ -73,11 +71,13 @@ class WebApplicationManager(SoftwareManager, SettingsController): self.i18n = context.i18n self.logger = context.logger self.env_thread = None - self.suggestions_loader = suggestions_loader + self.suggestions_loader: Optional[SuggestionsLoader] = None + self._suggestions_manager: Optional[SuggestionsManager] = None self.suggestions = {} self.configman = WebConfigManager() self.idxman = SearchIndexManager(logger=context.logger) self._custom_actions: Optional[Iterable[CustomSoftwareAction]] = None + self._re_protocol_strip: Optional[Pattern] = None def get_accept_language_header(self) -> str: try: @@ -174,7 +174,7 @@ class WebApplicationManager(SoftwareManager, SettingsController): if icon_url: return icon_url - def _get_app_description(self, url: str, soup: "BeautifulSoup") -> str: + def _get_app_description(self, url: str, soup: "BeautifulSoup") -> str: description = None desc_tag = soup.head.find('meta', attrs={'name': 'description'}) @@ -227,13 +227,17 @@ class WebApplicationManager(SoftwareManager, SettingsController): return props except Exception as e: - self.logger.warning(f"Error when trying to retrieve custom installation properties for {props_url}: {e.__class__.__name__}") + self.logger.warning( + f"Error when trying to retrieve custom installation properties for {props_url}: {e.__class__.__name__}") def _map_electron_branch(self, version: str) -> str: return f"electron_{'_'.join(version.split('.')[0:-1])}_X" - def _strip_url_protocol(self, url: str) -> str: - return RE_PROTOCOL_STRIP.split(url)[1].strip().lower() + def strip_url_protocol(self, url: str) -> str: + if not self._re_protocol_strip: + self._re_protocol_strip = re.compile(r'^[a-zA-Z]+://(www\.)?') + + return self._re_protocol_strip.split(url)[-1].strip().lower() def serialize_to_disk(self, pkg: SoftwarePackage, icon_bytes: Optional[bytes], only_icon: bool): super(WebApplicationManager, self).serialize_to_disk(pkg=pkg, icon_bytes=None, only_icon=False) @@ -242,7 +246,8 @@ class WebApplicationManager(SoftwareManager, SettingsController): headers = {'Accept-language': self.get_accept_language_header(), 'User-Agent': UA_CHROME} try: - return self.http_client.get(url, headers=headers, ignore_ssl=True, single_call=True, session=False, allow_redirects=True) + return self.http_client.get(url, headers=headers, ignore_ssl=True, single_call=True, session=False, + allow_redirects=True) except Exception as e: self.logger.warning(f"Could not GET '{url}'. Exception: {e.__class__.__name__}") @@ -263,9 +268,9 @@ class WebApplicationManager(SoftwareManager, SettingsController): if is_url: url = words[0:-1] if words.endswith('/') else words - url_no_protocol = self._strip_url_protocol(url) + url_no_protocol = self.strip_url_protocol(url) - installed_matches = [app for app in installed if self._strip_url_protocol(app.url) == url_no_protocol] + installed_matches = [app for app in installed if self.strip_url_protocol(app.url) == url_no_protocol] if installed_matches: res.installed.extend(installed_matches) @@ -320,25 +325,28 @@ class WebApplicationManager(SoftwareManager, SettingsController): self.logger.info("Query '{}' was not found in the suggestion's index".format(words)) res.installed.extend(installed_matches) else: - if not os.path.exists(SUGGESTIONS_CACHE_FILE): + cached_file_path = self.suggestions_manager.get_cached_file_path() + + if not os.path.exists(cached_file_path): # if the suggestions cache was not found, it will not be possible to retrieve the matched apps # so only the installed matches will be returned - self.logger.warning("Suggestion cached file {} was not found".format(SUGGESTIONS_CACHE_FILE)) + self.logger.warning(f"Suggestion file {cached_file_path} was not found") res.installed.extend(installed_matches) else: - with open(SUGGESTIONS_CACHE_FILE) as f: + with open(cached_file_path) as f: cached_suggestions = yaml.safe_load(f.read()) if not cached_suggestions: # if no suggestion is found, it will not be possible to retrieve the matched apps # so only the installed matches will be returned - self.logger.warning("No suggestion found in {}".format(SUGGESTIONS_CACHE_FILE)) + self.logger.warning(f"No suggestion found in {cached_file_path}") res.installed.extend(installed_matches) else: - matched_suggestions = [cached_suggestions[key] for key in index_match_keys if cached_suggestions.get(key)] + matched_suggestions = [cached_suggestions[key] for key in index_match_keys if + cached_suggestions.get(key)] if not matched_suggestions: - self.logger.warning("No suggestion found for the search index keys: {}".format(index_match_keys)) + self.logger.warning(f"No suggestion found for the query index keys: {index_match_keys}") res.installed.extend(installed_matches) else: matched_suggestions.sort(key=lambda s: s.get('priority', 0), reverse=True) @@ -376,7 +384,8 @@ class WebApplicationManager(SoftwareManager, SettingsController): return res - def read_installed(self, disk_loader: Optional[DiskCacheLoader], limit: int = -1, only_apps: bool = False, pkg_types: Set[Type[SoftwarePackage]] = None, internet_available: bool = True) -> SearchResult: + def read_installed(self, disk_loader: Optional[DiskCacheLoader], limit: int = -1, only_apps: bool = False, + pkg_types: Set[Type[SoftwarePackage]] = None, internet_available: bool = True) -> SearchResult: res = SearchResult([], [], 0) if os.path.exists(INSTALLED_PATH): @@ -393,12 +402,14 @@ class WebApplicationManager(SoftwareManager, SettingsController): def upgrade(self, requirements: UpgradeRequirements, root_password: Optional[str], watcher: ProcessWatcher) -> bool: pass - def uninstall(self, pkg: WebApplication, root_password: Optional[str], watcher: ProcessWatcher, disk_loader: DiskCacheLoader) -> TransactionResult: + def uninstall(self, pkg: WebApplication, root_password: Optional[str], watcher: ProcessWatcher, + disk_loader: DiskCacheLoader) -> TransactionResult: self.logger.info("Checking if {} installation directory {} exists".format(pkg.name, pkg.installation_dir)) if not os.path.exists(pkg.installation_dir): watcher.show_message(title=self.i18n['error'], - body=self.i18n['web.uninstall.error.install_dir.not_found'].format(bold(pkg.installation_dir)), + body=self.i18n['web.uninstall.error.install_dir.not_found'].format( + bold(pkg.installation_dir)), type_=MessageType.ERROR) return TransactionResult.fail() @@ -464,7 +475,8 @@ class WebApplicationManager(SoftwareManager, SettingsController): def get_info(self, pkg: WebApplication) -> dict: if pkg.installed: - info = {'0{}_{}'.format(idx + 1, att): getattr(pkg, att) for idx, att in enumerate(('url', 'description', 'version', 'categories', 'installation_dir', 'desktop_entry'))} + info = {'0{}_{}'.format(idx + 1, att): getattr(pkg, att) for idx, att in + enumerate(('url', 'description', 'version', 'categories', 'installation_dir', 'desktop_entry'))} info['07_exec_file'] = pkg.get_exec_path() info['08_icon_path'] = pkg.get_disk_icon_path() @@ -481,12 +493,14 @@ class WebApplicationManager(SoftwareManager, SettingsController): return info else: - return {'0{}_{}'.format(idx + 1, att): getattr(pkg, att) for idx, att in enumerate(('url', 'description', 'version', 'categories'))} + return {'0{}_{}'.format(idx + 1, att): getattr(pkg, att) for idx, att in + enumerate(('url', 'description', 'version', 'categories'))} def get_history(self, pkg: SoftwarePackage) -> PackageHistory: pass - def _ask_install_options(self, app: WebApplication, watcher: ProcessWatcher, pre_validated: bool) -> Tuple[bool, List[str]]: + def _ask_install_options(self, app: WebApplication, watcher: ProcessWatcher, pre_validated: bool) -> Tuple[ + bool, List[str]]: watcher.change_substatus(self.i18n['web.install.substatus.options']) max_width = 350 @@ -703,9 +717,11 @@ class WebApplicationManager(SoftwareManager, SettingsController): self.logger.error("An exception has happened when downloading {}".format(pkg.icon_url)) traceback.print_exc() else: - self.logger.warning('Could no retrieve the icon {} defined for the suggestion {}'.format(pkg.icon_url, pkg.name)) + self.logger.warning( + 'Could no retrieve the icon {} defined for the suggestion {}'.format(pkg.icon_url, pkg.name)) except: - self.logger.warning('An exception happened when trying to retrieve the icon {} for the suggestion {}'.format(pkg.icon_url, + self.logger.warning( + 'An exception happened when trying to retrieve the icon {} for the suggestion {}'.format(pkg.icon_url, pkg.name)) traceback.print_exc() @@ -749,7 +765,7 @@ class WebApplicationManager(SoftwareManager, SettingsController): electron_version = str(next((c for c in env_components if c.id == 'electron')).version) - url_domain, electron_branch = self._strip_url_protocol(pkg.url), self._map_electron_branch(electron_version) + url_domain, electron_branch = self.strip_url_protocol(pkg.url), self._map_electron_branch(electron_version) fix = self._get_fix_for(url_domain=url_domain, electron_branch=electron_branch) if fix: @@ -886,7 +902,8 @@ class WebApplicationManager(SoftwareManager, SettingsController): return TransactionResult(success=True, installed=[pkg], removed=[]) - def install(self, pkg: WebApplication, root_password: Optional[str], disk_loader: DiskCacheLoader, watcher: ProcessWatcher) -> TransactionResult: + def install(self, pkg: WebApplication, root_password: Optional[str], disk_loader: DiskCacheLoader, + watcher: ProcessWatcher) -> TransactionResult: continue_install, install_options = self._ask_install_options(pkg, watcher, pre_validated=True) if not continue_install: @@ -967,9 +984,7 @@ class WebApplicationManager(SoftwareManager, SettingsController): create_config=create_config, i18n=self.i18n).start() - self.suggestions_loader = SuggestionsLoader(manager=SuggestionsManager(logger=self.logger, - http_client=self.http_client, - i18n=self.i18n), + self.suggestions_loader = SuggestionsLoader(manager=self.suggestions_manager, logger=self.logger, i18n=self.i18n, taskman=task_manager, @@ -1008,7 +1023,8 @@ class WebApplicationManager(SoftwareManager, SettingsController): app.description = self._get_app_description(app.url, soup) try: - find_url = not app.icon_url or (app.icon_url and not self.http_client.exists(app.icon_url, session=False)) + find_url = not app.icon_url or ( + app.icon_url and not self.http_client.exists(app.icon_url, session=False)) except (requests.exceptions.ConnectionError, requests.exceptions.ConnectTimeout): find_url = None @@ -1049,6 +1065,9 @@ class WebApplicationManager(SoftwareManager, SettingsController): output.update(self.configman.get_config()) def list_suggestions(self, limit: int, filter_installed: bool) -> Optional[List[PackageSuggestion]]: + if limit == 0: + return + web_config = {} thread_config = Thread(target=self._fill_config_async, args=(web_config,)) @@ -1057,10 +1076,19 @@ class WebApplicationManager(SoftwareManager, SettingsController): if self.suggestions: suggestions = self.suggestions elif self.suggestions_loader: - self.suggestions_loader.join(5) + if self.suggestions_loader.is_alive(): + self.suggestions_loader.join(5) + suggestions = self.suggestions + elif self.suggestions_manager.is_custom_local_file_mapped(): + suggestions = self.suggestions_manager.read_cached() else: - suggestions = SuggestionsManager(logger=self.logger, http_client=self.http_client, i18n=self.i18n).download() + thread_config.join() + + if self.suggestions_manager.should_download(web_config): + suggestions = self.suggestions_manager.download() + else: + suggestions = self.suggestions_manager.read_cached() # cleaning memory self.suggestions_loader = None @@ -1071,22 +1099,24 @@ class WebApplicationManager(SoftwareManager, SettingsController): suggestion_list.sort(key=lambda s: s.get('priority', 0), reverse=True) if filter_installed: - installed = {self._strip_url_protocol(i.url) for i in self.read_installed(disk_loader=None).installed} + installed = {self.strip_url_protocol(i.url) for i in self.read_installed(disk_loader=None).installed} else: installed = None env_settings, res = None, [] for s in suggestion_list: - if limit <= 0 or len(res) < limit: + if limit < 0 or len(res) < limit: if installed: - surl = self._strip_url_protocol(s['url']) + surl = self.strip_url_protocol(s['url']) if surl in installed: continue if env_settings is None: # reading settings if not already loaded - thread_config.join() + if thread_config.is_alive(): + thread_config.join() + env_settings = self.env_updater.read_settings(web_config=web_config) res.append(self._map_suggestion(s, env_settings)) @@ -1107,7 +1137,8 @@ class WebApplicationManager(SoftwareManager, SettingsController): return res - def execute_custom_action(self, action: CustomSoftwareAction, pkg: SoftwarePackage, root_password: Optional[str], watcher: ProcessWatcher) -> bool: + def execute_custom_action(self, action: CustomSoftwareAction, pkg: SoftwarePackage, root_password: Optional[str], + watcher: ProcessWatcher) -> bool: pass def is_default_enabled(self) -> bool: @@ -1127,7 +1158,8 @@ class WebApplicationManager(SoftwareManager, SettingsController): print('{}[bauh][web] Directory {} deleted{}'.format(Fore.YELLOW, ENV_PATH, Fore.RESET)) except: if logs: - print('{}[bauh][web] An exception has happened when deleting {}{}'.format(Fore.RED, ENV_PATH, Fore.RESET)) + print('{}[bauh][web] An exception has happened when deleting {}{}'.format(Fore.RED, ENV_PATH, + Fore.RESET)) traceback.print_exc() def get_settings(self) -> Optional[Generator[SettingsView, None, None]]: @@ -1140,13 +1172,16 @@ class WebApplicationManager(SoftwareManager, SettingsController): id_='electron_branch') native_opts = [ - InputOption(label=self.i18n['web.settings.nativefier.env'].capitalize(), value=False, tooltip=self.i18n['web.settings.nativefier.env.tooltip'].format(app=self.context.app_name)), - InputOption(label=self.i18n['web.settings.nativefier.system'].capitalize(), value=True, tooltip=self.i18n['web.settings.nativefier.system.tooltip']) + InputOption(label=self.i18n['web.settings.nativefier.env'].capitalize(), value=False, + tooltip=self.i18n['web.settings.nativefier.env.tooltip'].format(app=self.context.app_name)), + InputOption(label=self.i18n['web.settings.nativefier.system'].capitalize(), value=True, + tooltip=self.i18n['web.settings.nativefier.system.tooltip']) ] select_nativefier = SingleSelectComponent(label="Nativefier", options=native_opts, - default_option=[o for o in native_opts if o.value == web_config['environment']['system']][0], + default_option=[o for o in native_opts if + o.value == web_config['environment']['system']][0], type_=SelectViewType.COMBO, tooltip=self.i18n['web.settings.nativefier.tip'], alignment=ViewComponentAlignment.CENTER, @@ -1155,7 +1190,8 @@ class WebApplicationManager(SoftwareManager, SettingsController): env_settings_exp = TextInputComponent(label=self.i18n['web.settings.cache_exp'], tooltip=self.i18n['web.settings.cache_exp.tip'], capitalize_label=False, - value=int(web_config['environment']['cache_exp']) if isinstance(web_config['environment']['cache_exp'], int) else '', + value=int(web_config['environment']['cache_exp']) if isinstance( + web_config['environment']['cache_exp'], int) else '', only_int=True, id_='web_cache_exp') @@ -1221,3 +1257,13 @@ class WebApplicationManager(SoftwareManager, SettingsController): ) yield from self._custom_actions + + @property + def suggestions_manager(self): + if self._suggestions_manager is None: + self._suggestions_manager = SuggestionsManager(logger=self.logger, + http_client=self.http_client, + i18n=self.i18n, + file_url=self.context.get_suggestion_url(self.__module__)) + + return self._suggestions_manager diff --git a/bauh/gems/web/suggestions.py b/bauh/gems/web/suggestions.py index 5797958c..90256ee1 100644 --- a/bauh/gems/web/suggestions.py +++ b/bauh/gems/web/suggestions.py @@ -3,23 +3,44 @@ import traceback from datetime import datetime, timedelta from logging import Logger from pathlib import Path +from typing import Optional import requests import yaml from bauh.api.http import HttpClient -from bauh.gems.web import URL_SUGGESTIONS, SUGGESTIONS_CACHE_FILE, SUGGESTIONS_CACHE_TS_FILE +from bauh.commons.util import map_timestamp_file +from bauh.gems.web import WEB_CACHE_DIR from bauh.view.util.translation import I18n class SuggestionsManager: - def __init__(self, http_client: HttpClient, logger: Logger, i18n: I18n): + def __init__(self, http_client: HttpClient, logger: Logger, i18n: I18n, file_url: Optional[str]): self.http_client = http_client self.logger = logger self.i18n = i18n + if file_url: + self._file_url = file_url + else: + self._file_url = "https://raw.githubusercontent.com/vinifmor/bauh-files/master/web/env/v2/suggestions.yml" + self._cached_file_path = f'{WEB_CACHE_DIR}/suggestions.yml' + self._cached_file_ts_path = map_timestamp_file(self._cached_file_path) + + @property + def file_url(self) -> Optional[str]: + return self._file_url + + def is_custom_local_file_mapped(self) -> bool: + return self._file_url and self._file_url.startswith('/') def should_download(self, web_config: dict) -> bool: + if not self._file_url: + return False + + if self.is_custom_local_file_mapped(): + return False + exp = web_config['suggestions']['cache_exp'] if web_config['suggestions']['cache_exp'] is None: self.logger.info("No cache expiration defined for suggestions") @@ -28,28 +49,28 @@ class SuggestionsManager: try: exp = int(exp) except ValueError: - self.logger.error("Error while parsing the 'suggestions.cache_exp' ({}) settings property".format(exp)) + self.logger.error(f"Error while parsing the 'suggestions.cache_exp' ({exp}) settings property") return True if exp <= 0: - self.logger.info("No cache expiration defined for suggestions ({})".format(exp)) + self.logger.info(f"No cache expiration defined for suggestions ({exp})") return True - if not os.path.exists(SUGGESTIONS_CACHE_FILE): - self.logger.info("No suggestions cached file found '{}'".format(SUGGESTIONS_CACHE_FILE)) + if not os.path.exists(self._cached_file_path): + self.logger.info(f"No suggestions cached file found '{self._cached_file_path}'") return True - if not os.path.exists(SUGGESTIONS_CACHE_TS_FILE): - self.logger.info("No suggestions cache file timestamp found '{}'".format(SUGGESTIONS_CACHE_TS_FILE)) + if not os.path.exists(self._cached_file_ts_path): + self.logger.info(f"No suggestions cache file timestamp found '{self._cached_file_ts_path}'") return True - with open(SUGGESTIONS_CACHE_TS_FILE) as f: + with open(self._cached_file_ts_path) as f: timestamp_str = f.read() try: sugs_timestamp = datetime.fromtimestamp(float(timestamp_str)) except: - self.logger.error("Could not parse the cached suggestions file timestamp: {}".format(timestamp_str)) + self.logger.error(f"Could not parse the cached suggestions file timestamp: {timestamp_str}") return True expired = sugs_timestamp + timedelta(days=exp) <= datetime.utcnow() @@ -61,17 +82,25 @@ class SuggestionsManager: self.logger.info("Cached suggestions file is up to date") return False + def get_cached_file_path(self) -> str: + return self._file_url if self.is_custom_local_file_mapped() else self._cached_file_path + def read_cached(self, check_file: bool = True) -> dict: - if check_file and not os.path.exists(SUGGESTIONS_CACHE_FILE): - self.logger.warning("Cached suggestions file does not exist ({})".format(SUGGESTIONS_CACHE_FILE)) + if self.is_custom_local_file_mapped(): + file_path, log_ref = self._file_url, 'local' + else: + file_path, log_ref = self._cached_file_path, 'cached' + + if check_file and not os.path.exists(file_path): + self.logger.warning(f"{log_ref.capitalize()} suggestions file does not exist ({file_path})") return {} - self.logger.info("Reading cached suggestions file '{}'".format(SUGGESTIONS_CACHE_FILE)) - with open(SUGGESTIONS_CACHE_FILE) as f: + self.logger.info(f"Reading {log_ref} suggestions file '{file_path}'") + with open(file_path) as f: sugs_str = f.read() if not sugs_str: - self.logger.warning("Cached suggestions file '{}' is empty".format(SUGGESTIONS_CACHE_FILE)) + self.logger.warning(f"{log_ref.capitalize()} suggestions file '{file_path}' is empty") return {} try: @@ -82,14 +111,14 @@ class SuggestionsManager: return {} def download(self) -> dict: - self.logger.info("Reading suggestions from {}".format(URL_SUGGESTIONS)) + self.logger.info(f"Reading suggestions from {self._file_url}") try: - suggestions = self.http_client.get_yaml(URL_SUGGESTIONS, session=False) + suggestions = self.http_client.get_yaml(self._file_url, session=False) if suggestions: - self.logger.info("{} suggestions successfully read".format(len(suggestions))) + self.logger.info(f"{len(suggestions)} suggestions successfully read") else: - self.logger.warning("Could not read suggestions from {}".format(URL_SUGGESTIONS)) + self.logger.warning(f"Could not read suggestions from {self._file_url}") except (requests.exceptions.ConnectionError, requests.exceptions.ConnectTimeout): self.logger.warning("Internet seems to be off: it was not possible to retrieve the suggestions") @@ -102,31 +131,36 @@ class SuggestionsManager: if not suggestions: return - self.logger.info('Caching {} suggestions to the disk'.format(len(suggestions))) - suggestions_file_dir = os.path.dirname(SUGGESTIONS_CACHE_FILE) + if self.is_custom_local_file_mapped(): + return + + self.logger.info(f'Caching {len(suggestions)} suggestions to the disk') + suggestions_file_dir = os.path.dirname(self._cached_file_path) + try: Path(suggestions_file_dir).mkdir(parents=True, exist_ok=True) except OSError: - self.logger.error("Could not generate the directory {}".format(suggestions_file_dir)) + self.logger.error(f"Could not generate the directory {suggestions_file_dir}") traceback.print_exc() return try: - with open(SUGGESTIONS_CACHE_FILE, 'w+') as f: + with open(self._cached_file_path, 'w+') as f: f.write(yaml.safe_dump(suggestions)) except: - self.logger.error("Could write to {}".format(SUGGESTIONS_CACHE_FILE)) + self.logger.error(f"Could write to {self._cached_file_path}") traceback.print_exc() return - self.logger.info("{} suggestions successfully cached to file '{}'".format(len(suggestions), SUGGESTIONS_CACHE_FILE)) + self.logger.info(f"{len(suggestions)} suggestions successfully cached to file '{self._cached_file_path}'") try: - with open(SUGGESTIONS_CACHE_TS_FILE, 'w+') as f: + with open(self._cached_file_ts_path, 'w+') as f: f.write(str(timestamp)) except: - self.logger.error("Could not write to {}".format(SUGGESTIONS_CACHE_TS_FILE)) + self.logger.error(f"Could not write to {self._cached_file_ts_path}") traceback.print_exc() return - self.logger.info("Suggestions cached file timestamp ({}) successfully saved at '{}'".format(timestamp, SUGGESTIONS_CACHE_TS_FILE)) + self.logger.info(f"Suggestions cached file timestamp ({timestamp}) " + f"successfully saved at '{self._cached_file_ts_path}'") diff --git a/bauh/gems/web/worker.py b/bauh/gems/web/worker.py index bcff6488..df0f3916 100644 --- a/bauh/gems/web/worker.py +++ b/bauh/gems/web/worker.py @@ -35,27 +35,33 @@ class SuggestionsLoader(Thread): def run(self): ti = time.time() - self.taskman.update_progress(self.task_id, 0, self.i18n['task.waiting_task'].format(bold(self.create_config.task_name))) - self.create_config.join() - - self.taskman.update_progress(self.task_id, 10, None) - - if not self.internet_connection: - self.logger.warning("No internet connection. Only cached suggestions can be loaded") - self.suggestions = self.manager.read_cached(check_file=True) - elif not self.manager.should_download(self.create_config.config): - self.suggestions = self.manager.read_cached(check_file=False) + if self.manager.is_custom_local_file_mapped(): + self.taskman.update_progress(self.task_id, 50, None) + self.logger.info(f"Local Web suggestions file mapped: {self.manager.file_url}") + self.suggestions = self.manager.read_cached() else: - try: - timestamp = datetime.utcnow().timestamp() - self.suggestions = self.manager.download() + wait_msg = self.i18n['task.waiting_task'].format(bold(self.create_config.task_name)) + self.taskman.update_progress(self.task_id, 0, wait_msg) + self.create_config.join() - if self.suggestions: - self.taskman.update_progress(self.task_id, 50, self.i18n['web.task.suggestions.saving']) - self.manager.save_to_disk(self.suggestions, timestamp) - except: - self.logger.error("Unexpected exception") - traceback.print_exc() + self.taskman.update_progress(self.task_id, 10, None) + + if not self.internet_connection: + self.logger.warning("No internet connection. Only cached suggestions can be loaded") + self.suggestions = self.manager.read_cached() + elif not self.manager.should_download(self.create_config.config): + self.suggestions = self.manager.read_cached(check_file=False) + else: + try: + timestamp = datetime.utcnow().timestamp() + self.suggestions = self.manager.download() + + if self.suggestions: + self.taskman.update_progress(self.task_id, 50, self.i18n['web.task.suggestions.saving']) + self.manager.save_to_disk(self.suggestions, timestamp) + except: + self.logger.error("Unexpected exception") + traceback.print_exc() if self.suggestions_callback: self.taskman.update_progress(self.task_id, 75, None) @@ -73,7 +79,8 @@ class SuggestionsLoader(Thread): class SearchIndexGenerator(Thread): - def __init__(self, taskman: TaskManager, idxman: SearchIndexManager, suggestions_loader: SuggestionsLoader, i18n: I18n, logger: logging.Logger): + def __init__(self, taskman: TaskManager, idxman: SearchIndexManager, suggestions_loader: SuggestionsLoader, + i18n: I18n, logger: logging.Logger): super(SearchIndexGenerator, self).__init__(daemon=True) self.taskman = taskman self.idxman = idxman @@ -85,8 +92,11 @@ class SearchIndexGenerator(Thread): def run(self): ti = time.time() - self.taskman.update_progress(self.task_id, 0, self.i18n['task.waiting_task'].format(bold(self.suggestions_loader.task_name))) - self.suggestions_loader.join() + + if self.suggestions_loader.is_alive(): + wait_msg = self.i18n['task.waiting_task'].format(bold(self.suggestions_loader.task_name)) + self.taskman.update_progress(self.task_id, 0, wait_msg) + self.suggestions_loader.join() if self.suggestions_loader.suggestions: self.taskman.update_progress(self.task_id, 1, None) diff --git a/bauh/manage.py b/bauh/manage.py index 9e9bddf9..789ea3d3 100644 --- a/bauh/manage.py +++ b/bauh/manage.py @@ -13,6 +13,7 @@ from bauh.context import generate_i18n, DEFAULT_I18N_KEY, new_qt_application from bauh.view.core import gems from bauh.view.core.controller import GenericSoftwareManager from bauh.view.core.downloader import AdaptableFileDownloader +from bauh.view.core.suggestions import read_suggestions_mapping from bauh.view.qt.prepare import PreparePanel from bauh.view.qt.settings import SettingsWindow from bauh.view.qt.window import ManageWindow @@ -48,6 +49,7 @@ def new_manage_panel(app_args: Namespace, app_config: dict, logger: logging.Logg app_name=__app_name__, app_version=__version__, internet_checker=InternetChecker(offline=app_args.offline), + suggestions_mapping=read_suggestions_mapping(), root_user=user.is_root()) managers = gems.load_managers(context=context, locale=i18n.current_key, config=app_config, diff --git a/bauh/view/core/controller.py b/bauh/view/core/controller.py index 6a910506..66f9acaa 100755 --- a/bauh/view/core/controller.py +++ b/bauh/view/core/controller.py @@ -1,3 +1,4 @@ +import os import re import shutil import time diff --git a/bauh/view/core/suggestions.py b/bauh/view/core/suggestions.py new file mode 100644 index 00000000..280f3a88 --- /dev/null +++ b/bauh/view/core/suggestions.py @@ -0,0 +1,32 @@ +import os +from typing import Optional, Dict +from bauh import __app_name__ + + +def read_suggestions_mapping() -> Optional[Dict[str, str]]: + file_path = f'/etc/{__app_name__}/suggestions.conf' + + if os.path.isfile(file_path): + try: + with open(file_path) as f: + file_content = f.read() + except FileNotFoundError: + return + + if not file_content: + return + + mapping = {} + for line in file_content.split('\n'): + line_strip = line.strip() + + if not line_strip.startswith('#'): + gem_file = line_strip.split('=') + + if len(gem_file) == 2: + gem_name, file_url = gem_file[0].strip(), gem_file[1].strip() + + if gem_name and file_url: + mapping[gem_name] = file_url + + return mapping if mapping else None diff --git a/tests/gems/web/test_controller.py b/tests/gems/web/test_controller.py index 442ffe24..12dc76eb 100644 --- a/tests/gems/web/test_controller.py +++ b/tests/gems/web/test_controller.py @@ -45,3 +45,20 @@ class WebApplicationManagerTest(TestCase): returned = self.manager.get_accept_language_header() self.assertEqual(f'en-IN, en', returned) getdefaultlocale.assert_called_once() + + def test_strip_url_protocol__http_no_www(self): + res = self.manager.strip_url_protocol('http://test.com') + self.assertEqual('test.com', res) + + def test_strip_url_protocol__http_with_www(self): + res = self.manager.strip_url_protocol('http://www.test.com') + self.assertEqual('test.com', res) + + def test_strip_url_protocol__https_no_www(self): + res = self.manager.strip_url_protocol('https://test.com') + self.assertEqual('test.com', res) + + def test_strip_url_protocol__https_with_www(self): + res = self.manager.strip_url_protocol('https://www.test.com') + self.assertEqual('test.com', res) +