diff --git a/CHANGELOG.md b/CHANGELOG.md index 45f391fef..be3f9a9dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,62 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## [0.10.1] 2022-03-31 + +### Features +- Flatpak + - new custom action "Full update": fully updates all installed Flatpak apps and components (useful if you are having issues with runtime updates) + +

+ +

+ + +### Improvements +- General + - code refactoring + - backup: + - single mode: now supports two remove methods [#244](https://github.com/vinifmor/bauh/issues/244) + - self: it removes only self generated backups/snapshots (default) + - all: it removes all existing backup/snapshots on the disc + +

+ +

+ +- AppImage + - Limiting the UI components width of the file installation and upgrade windows + +- Arch + - text length of some popups reduced + +- Snap + - allowing the actions output locale to be decided by the Snap client + +- UI + - only displaying the "Installed" filter when installed packages are available on the table + - settings: margin between components reduced [#241](https://github.com/vinifmor/bauh/issues/241) + - "close" button added to the screenshots window (some distributions hide the default "x" on the dialog frame) [#246](https://github.com/vinifmor/bauh/issues/246) + +### Fixes +- Arch + - regression: not displaying ignored updates + - dependency size: display a '?' instead of '0' ('?' should only be displayed when the size is unknown) + +- Debian + - packages descriptions are not displayed on the system's default language (when available) + +- Flatpak: + - executed commands are not displayed on the system default language and encoding (requires Flatpak >= 1.12) [#242](https://github.com/vinifmor/bauh/issues/242) + - applications and runtimes descriptions are not displayed on the system default language (when available) [#242](https://github.com/vinifmor/bauh/issues/242) + +- Web + - using the wrong locale format for the Accept-Language header + +- UI: + - rare crash when updating table items + - some action errors not being displayed on the details component when they are concatenated with sudo output + ## [0.10.0] 2022-03-14 ### Features diff --git a/README.md b/README.md index 9b554c81b..faa24bac4 100644 --- a/README.md +++ b/README.md @@ -313,6 +313,8 @@ prefer_repository_provider: true # when there is just one repository provider f ``` installation_level: null # defines a default installation level: "user" or "system". (null will display a popup asking the level) ``` +- Custom actions supported: + - **Full update**: it completely updates the Flatpak apps and components. Useful if you are having issues with runtime updates. #### Snap @@ -436,12 +438,13 @@ disk: after_upgrade: false # it trims the disk after a successful packages upgrade (`fstrim -a -v`). 'true' will automatically perform the trim and 'null' will display a confirmation dialog backup: enabled: true # generate timeshift snapshots before an action (if timeshift is installed on the system) - mode: 'incremental' # incremental=generates a new snapshot based on another pre-exising one. 'only_one'=deletes all pre-existing snapshots and generates a fresh one. + mode: 'incremental' # incremental=generates a new snapshot based on another pre-exising one. 'only_one'=deletes all pre-existing self created snapshots and generates a fresh one. install: null # defines if the backup should be performed before installing a package. Allowed values: null (a dialog will be displayed asking if a snapshot should be generated), true: generates the backup without asking. false: disables the backup for this operation uninstall: null # defines if the backup should be performed before uninstalling a package. Allowed values: null (a dialog will be displayed asking if a snapshot should be generated), true: generates the backup without asking. false: disables the backup for this operation upgrade: null # defines if the backup should be performed before upgrading a package. Allowed values: null (a dialog will be displayed asking if a snapshot should be generated), true: generates the backup without asking. false: disables the backup for this operation downgrade: null # defines if the backup should be performed before downgrading a package. Allowed values: null (a dialog will be displayed asking if a snapshot should be generated), true: generates the backup without asking. false: disables the backup for this operation type: rsync # defines the Timeshift backup mode -> 'rsync' (default) or 'btrfs' + remove_method: self # define which backups should be removed in the 'only_one' mode. 'self': only self generated copies. 'all': all existing backups on the disc. boot: load_apps: true # if the installed applications or suggestions should be loaded on the management panel after the initialization process. Default: true. ``` diff --git a/bauh/__init__.py b/bauh/__init__.py index f9b956585..91a5a6a54 100644 --- a/bauh/__init__.py +++ b/bauh/__init__.py @@ -1,4 +1,4 @@ -__version__ = '0.10.0' +__version__ = '0.10.1' __app_name__ = 'bauh' import os diff --git a/bauh/api/abstract/handler.py b/bauh/api/abstract/handler.py index ef766d429..393ede9c2 100644 --- a/bauh/api/abstract/handler.py +++ b/bauh/api/abstract/handler.py @@ -21,7 +21,8 @@ def request_confirmation(self, title: str, body: Optional[str], components: List confirmation_label: str = None, deny_label: str = None, deny_button: bool = True, window_cancel: bool = False, confirmation_button: bool = True, min_width: Optional[int] = None, - min_height: Optional[int] = None) -> bool: + min_height: Optional[int] = None, + max_width: Optional[int] = None) -> bool: """ request a user confirmation. In the current GUI implementation, it shows a popup to the user. :param title: popup title @@ -34,6 +35,7 @@ def request_confirmation(self, title: str, body: Optional[str], components: List :param confirmation_button: if the confirmation button should be displayed :param min_width: minimum width for the confirmation dialog :param min_height: minimum height for the confirmation dialog + :param max_width: maximum width for the confirmation dialog :return: if the request was confirmed by the user """ pass diff --git a/bauh/commons/system.py b/bauh/commons/system.py index f9513b54f..795d85a0d 100644 --- a/bauh/commons/system.py +++ b/bauh/commons/system.py @@ -1,10 +1,11 @@ import os +import re import subprocess import sys import time from io import StringIO from subprocess import PIPE -from typing import List, Tuple, Set, Dict, Optional, Iterable +from typing import List, Tuple, Set, Dict, Optional, Iterable, Union, IO, Any # default environment variables for subprocesses. from bauh.api.abstract.handler import ProcessWatcher @@ -13,7 +14,7 @@ GLOBAL_PY_LIBS = '/usr/lib/python{}'.format(PY_VERSION) PATH = os.getenv('PATH') -DEFAULT_LANG = 'en' +DEFAULT_LANG = '' GLOBAL_INTERPRETER_PATH = ':'.join(PATH.split(':')[1:]) @@ -22,8 +23,10 @@ USE_GLOBAL_INTERPRETER = bool(os.getenv('VIRTUAL_ENV')) +RE_SUDO_OUTPUT = re.compile(r'[sudo]\s*[\w\s]+:\s*') -def gen_env(global_interpreter: bool, lang: str = DEFAULT_LANG, extra_paths: Optional[Set[str]] = None) -> dict: + +def gen_env(global_interpreter: bool, lang: Optional[str] = DEFAULT_LANG, extra_paths: Optional[Set[str]] = None) -> dict: custom_env = dict(os.environ) if lang is not None: @@ -63,7 +66,7 @@ def wait(self): class SimpleProcess: def __init__(self, cmd: Iterable[str], cwd: str = '.', expected_code: int = 0, - global_interpreter: bool = USE_GLOBAL_INTERPRETER, lang: str = DEFAULT_LANG, root_password: Optional[str] = None, + global_interpreter: bool = USE_GLOBAL_INTERPRETER, lang: Optional[str] = DEFAULT_LANG, root_password: Optional[str] = None, extra_paths: Set[str] = None, error_phrases: Set[str] = None, wrong_error_phrases: Set[str] = None, shell: bool = False, success_phrases: Set[str] = None, extra_env: Optional[Dict[str, str]] = None, custom_user: Optional[str] = None): @@ -79,16 +82,17 @@ def __init__(self, cmd: Iterable[str], cwd: str = '.', expected_code: int = 0, final_cmd.extend(cmd) - self.instance = self._new(final_cmd, cwd, global_interpreter, lang, stdin=pwdin, extra_paths=extra_paths, extra_env=extra_env) + self.instance = self._new(final_cmd, cwd, global_interpreter, lang=lang, stdin=pwdin, + extra_paths=extra_paths, extra_env=extra_env) self.expected_code = expected_code self.error_phrases = error_phrases self.wrong_error_phrases = wrong_error_phrases self.success_phrases = success_phrases - def _new(self, cmd: List[str], cwd: str, global_interpreter: bool, lang: str, stdin = None, + def _new(self, cmd: List[str], cwd: str, global_interpreter: bool, lang: Optional[str], stdin = None, extra_paths: Set[str] = None, extra_env: Optional[Dict[str, str]] = None) -> subprocess.Popen: - env = gen_env(global_interpreter, lang, extra_paths=extra_paths) + env = gen_env(global_interpreter=global_interpreter, lang=lang, extra_paths=extra_paths) if extra_env: for var, val in extra_env.items(): @@ -197,8 +201,8 @@ def handle_simple(self, proc: SimpleProcess, output_handler=None, notify_watcher except UnicodeDecodeError: continue - if line.startswith('[sudo] password'): - continue + if line.startswith('[sudo]'): + line = RE_SUDO_OUTPUT.split(line)[1] output.write(line) @@ -240,7 +244,7 @@ def handle_simple(self, proc: SimpleProcess, output_handler=None, notify_watcher def run_cmd(cmd: str, expected_code: int = 0, ignore_return_code: bool = False, print_error: bool = True, cwd: str = '.', global_interpreter: bool = USE_GLOBAL_INTERPRETER, extra_paths: Set[str] = None, - custom_user: Optional[str] = None) -> Optional[str]: + custom_user: Optional[str] = None, lang: Optional[str] = DEFAULT_LANG) -> Optional[str]: """ runs a given command and returns its default output :return: @@ -248,7 +252,7 @@ def run_cmd(cmd: str, expected_code: int = 0, ignore_return_code: bool = False, args = { "shell": True, "stdout": PIPE, - "env": gen_env(global_interpreter, extra_paths=extra_paths), + "env": gen_env(global_interpreter=global_interpreter, lang=lang, extra_paths=extra_paths), 'cwd': cwd } @@ -265,8 +269,8 @@ def run_cmd(cmd: str, expected_code: int = 0, ignore_return_code: bool = False, pass -def new_subprocess(cmd: List[str], cwd: str = '.', shell: bool = False, stdin = None, - global_interpreter: bool = USE_GLOBAL_INTERPRETER, lang: str = DEFAULT_LANG, +def new_subprocess(cmd: Iterable[str], cwd: str = '.', shell: bool = False, stdin: Optional[Union[None, int, IO[Any]]] = None, + global_interpreter: bool = USE_GLOBAL_INTERPRETER, lang: Optional[str] = DEFAULT_LANG, extra_paths: Set[str] = None, custom_user: Optional[str] = None) -> subprocess.Popen: args = { "stdout": PIPE, @@ -281,10 +285,10 @@ def new_subprocess(cmd: List[str], cwd: str = '.', shell: bool = False, stdin = return subprocess.Popen(final_cmd, **args) -def new_root_subprocess(cmd: List[str], root_password: Optional[str], cwd: str = '.', +def new_root_subprocess(cmd: Iterable[str], root_password: Optional[str], cwd: str = '.', global_interpreter: bool = USE_GLOBAL_INTERPRETER, lang: str = DEFAULT_LANG, - extra_paths: Set[str] = None) -> subprocess.Popen: - pwdin, final_cmd = None, [] + extra_paths: Set[str] = None, shell: bool = False) -> subprocess.Popen: + pwdin, final_cmd = subprocess.DEVNULL, [] if isinstance(root_password, str): final_cmd.extend(['sudo', '-S']) @@ -292,7 +296,11 @@ def new_root_subprocess(cmd: List[str], root_password: Optional[str], cwd: str = final_cmd.extend(cmd) - return subprocess.Popen(final_cmd, stdin=pwdin, stdout=PIPE, stderr=PIPE, cwd=cwd, env=gen_env(global_interpreter, lang, extra_paths)) + if shell: + final_cmd = ' '.join(final_cmd) + + return subprocess.Popen(final_cmd, stdin=pwdin, stdout=PIPE, stderr=PIPE, cwd=cwd, + env=gen_env(global_interpreter, lang, extra_paths), shell=shell) def notify_user(msg: str, app_name: str, icon_path: str): diff --git a/bauh/commons/view_utils.py b/bauh/commons/view_utils.py index 1ade21ae2..3d0791f0c 100644 --- a/bauh/commons/view_utils.py +++ b/bauh/commons/view_utils.py @@ -1,13 +1,12 @@ -from typing import List, Tuple, Optional +from typing import Tuple, Optional, Iterable from bauh.api.abstract.view import SelectViewType, InputOption, SingleSelectComponent - SIZE_UNITS = ((1, 'B'), (1024, 'Kb'), (1048576, 'Mb'), (1073741824, 'Gb'), (1099511627776, 'Tb'), (1125899906842624, 'Pb')) -def new_select(label: str, tip: str, id_: str, opts: List[Tuple[Optional[str], object, Optional[str]]], value: object, max_width: int, +def new_select(label: str, tip: Optional[str], id_: str, opts: Iterable[Tuple[Optional[str], object, Optional[str]]], value: object, max_width: int, type_: SelectViewType = SelectViewType.RADIO, capitalize_label: bool = True): inp_opts = [InputOption(label=o[0].capitalize(), value=o[1], tooltip=o[2]) for o in opts] def_opt = [o for o in inp_opts if o.value == value] diff --git a/bauh/gems/appimage/controller.py b/bauh/gems/appimage/controller.py index aa946ca34..7fe48172e 100644 --- a/bauh/gems/appimage/controller.py +++ b/bauh/gems/appimage/controller.py @@ -85,27 +85,31 @@ def __init__(self, context: ApplicationContext): self._search_unfilled_attrs: Optional[Tuple[str, ...]] = None def install_file(self, root_password: Optional[str], watcher: ProcessWatcher) -> bool: + max_width = 350 file_chooser = FileChooserComponent(label=self.i18n['file'].capitalize(), allowed_extensions={'AppImage', '*'}, - search_path=get_default_manual_installation_file_dir()) - input_name = TextInputComponent(label=self.i18n['name'].capitalize()) - input_version = TextInputComponent(label=self.i18n['version'].capitalize()) + search_path=get_default_manual_installation_file_dir(), + max_width=max_width) + input_name = TextInputComponent(label=self.i18n['name'].capitalize(), max_width=max_width) + input_version = TextInputComponent(label=self.i18n['version'].capitalize(), max_width=max_width) file_chooser.observers.append(ManualInstallationFileObserver(input_name, input_version)) - input_description = TextInputComponent(label=self.i18n['description'].capitalize()) + input_description = TextInputComponent(label=self.i18n['description'].capitalize(), max_width=max_width) cat_ops = [InputOption(label=self.i18n['category.none'].capitalize(), value=0)] cat_ops.extend([InputOption(label=self.i18n.get(f'category.{c.lower()}', c.lower()).capitalize(), value=c) for c in self.context.default_categories]) inp_cat = SingleSelectComponent(label=self.i18n['category'], type_=SelectViewType.COMBO, options=cat_ops, - default_option=cat_ops[0]) + default_option=cat_ops[0], max_width=max_width) - form = FormComponent(label='', components=[file_chooser, input_name, input_version, input_description, inp_cat], spaces=False) + form = FormComponent(label='', components=[file_chooser, input_name, input_version, input_description, inp_cat], + spaces=False) while True: if watcher.request_confirmation(title=self.i18n['appimage.custom_action.install_file.details'], body=None, components=[form], confirmation_label=self.i18n['proceed'].capitalize(), - deny_label=self.i18n['cancel'].capitalize()): + deny_label=self.i18n['cancel'].capitalize(), + min_height=100, max_width=max_width + 150): if not file_chooser.file_path or not os.path.isfile(file_chooser.file_path) or not file_chooser.file_path.lower().strip().endswith('.appimage'): watcher.request_confirmation(title=self.i18n['error'].capitalize(), body=self.i18n['appimage.custom_action.install_file.invalid_file'], @@ -139,17 +143,20 @@ def install_file(self, root_password: Optional[str], watcher: ProcessWatcher) -> return res def update_file(self, pkg: AppImage, root_password: Optional[str], watcher: ProcessWatcher): + max_width = 350 file_chooser = FileChooserComponent(label=self.i18n['file'].capitalize(), allowed_extensions={'AppImage', '*'}, - search_path=get_default_manual_installation_file_dir()) - input_version = TextInputComponent(label=self.i18n['version'].capitalize()) + search_path=get_default_manual_installation_file_dir(), + max_width=max_width) + input_version = TextInputComponent(label=self.i18n['version'].capitalize(), max_width=max_width) file_chooser.observers.append(ManualInstallationFileObserver(None, input_version)) while True: if watcher.request_confirmation(title=self.i18n['appimage.custom_action.manual_update.details'], body=None, components=[FormComponent(label='', components=[file_chooser, input_version], spaces=False)], confirmation_label=self.i18n['proceed'].capitalize(), - deny_label=self.i18n['cancel'].capitalize()): + deny_label=self.i18n['cancel'].capitalize(), + min_height=100, max_width=max_width + 150): if not file_chooser.file_path or not os.path.isfile(file_chooser.file_path) or not file_chooser.file_path.lower().strip().endswith('.appimage'): watcher.request_confirmation(title=self.i18n['error'].capitalize(), diff --git a/bauh/gems/arch/confirmation.py b/bauh/gems/arch/confirmation.py index b461f999f..616be095d 100644 --- a/bauh/gems/arch/confirmation.py +++ b/bauh/gems/arch/confirmation.py @@ -1,4 +1,4 @@ -from typing import Set, List, Tuple, Dict, Optional +from typing import Set, Tuple, Dict, Collection from bauh.api.abstract.handler import ProcessWatcher from bauh.api.abstract.view import MultipleSelectComponent, InputOption, FormComponent, SingleSelectComponent, \ @@ -26,7 +26,7 @@ def request_optional_deps(pkgname: str, pkg_repos: dict, watcher: ProcessWatcher i18n['repository'], d['repository'].lower(), i18n['size'].capitalize(), - get_human_size_str(size) if size else '?'), p) + get_human_size_str(size) if size is not None else '?'), p) op.icon_path = _get_repo_icon(d['repository']) opts.append(op) @@ -44,8 +44,8 @@ def request_optional_deps(pkgname: str, pkg_repos: dict, watcher: ProcessWatcher return {o.value for o in view_opts.values} -def request_install_missing_deps(pkgname: Optional[str], deps: List[Tuple[str, str]], watcher: ProcessWatcher, i18n: I18n) -> bool: - msg = '

{}

'.format(i18n['arch.missing_deps.body'].format(name=bold(pkgname) if pkgname else '', deps=bold(str(len(deps))))) +def request_install_missing_deps(deps: Collection[Tuple[str, str]], watcher: ProcessWatcher, i18n: I18n) -> bool: + msg = f"

{i18n['arch.missing_deps.body'].format(deps=bold(str(len(deps))))}:

" opts = [] @@ -58,13 +58,14 @@ def request_install_missing_deps(pkgname: Optional[str], deps: List[Tuple[str, s i18n['repository'], dep[1].lower(), i18n['size'].capitalize(), - get_human_size_str(size) if size else '?'), dep[0]) + get_human_size_str(size) if size is not None else '?'), dep[0]) op.read_only = True op.icon_path = _get_repo_icon(dep[1]) opts.append(op) comp = MultipleSelectComponent(label='', options=opts, default_options=set(opts)) - return watcher.request_confirmation(i18n['arch.missing_deps.title'], msg, [comp], confirmation_label=i18n['continue'].capitalize(), deny_label=i18n['cancel'].capitalize()) + return watcher.request_confirmation(i18n['arch.missing_deps.title'], msg, [comp], confirmation_label=i18n['continue'].capitalize(), deny_label=i18n['cancel'].capitalize(), + min_width=600) def request_providers(providers_map: Dict[str, Set[str]], repo_map: Dict[str, str], watcher: ProcessWatcher, i18n: I18n) -> Set[str]: diff --git a/bauh/gems/arch/controller.py b/bauh/gems/arch/controller.py index d638d9e88..76cfea3d3 100644 --- a/bauh/gems/arch/controller.py +++ b/bauh/gems/arch/controller.py @@ -635,7 +635,7 @@ def read_installed(self, disk_loader: Optional[DiskCacheLoader], limit: int = -1 if ignored: for p in pkgs: if p.name in ignored: - p.updates_ignored = True + p.update_ignored = True return SearchResult(pkgs, None, len(pkgs)) @@ -2075,7 +2075,7 @@ def _save_pkgbuild(self, context: TransactionContext): def _ask_and_install_missing_deps(self, context: TransactionContext, missing_deps: List[Tuple[str, str]]) -> bool: context.watcher.change_substatus(self.i18n['arch.missing_deps_found'].format(bold(context.name))) - if not confirmation.request_install_missing_deps(context.name, missing_deps, context.watcher, self.i18n): + if not confirmation.request_install_missing_deps(missing_deps, context.watcher, self.i18n): context.watcher.print(self.i18n['action.cancelled']) return False @@ -2265,7 +2265,8 @@ def _install_optdeps(self, context: TransactionContext) -> bool: sorted_deps = sorting.sort(to_sort, {**deps_data, **subdeps_data}, provided_map) - if display_deps_dialog and not confirmation.request_install_missing_deps(None, sorted_deps, context.watcher, self.i18n): + if display_deps_dialog and not confirmation.request_install_missing_deps(sorted_deps, context.watcher, + self.i18n): context.watcher.print(self.i18n['action.cancelled']) return True # because the main package installation was successful diff --git a/bauh/gems/arch/pacman.py b/bauh/gems/arch/pacman.py index f7382f4ac..24200df7d 100644 --- a/bauh/gems/arch/pacman.py +++ b/bauh/gems/arch/pacman.py @@ -55,10 +55,6 @@ def get_repositories(pkgs: Iterable[str]) -> dict: return repositories -def is_available_in_repositories(pkg_name: str) -> bool: - return bool(run_cmd('pacman -Ss ' + pkg_name)) - - def get_info(pkg_name, remote: bool = False) -> str: return run_cmd('pacman -{}i {}'.format('Q' if not remote else 'S', pkg_name), print_error=False) @@ -397,32 +393,6 @@ def list_repository_updates() -> Dict[str, str]: return res -def map_sorting_data(pkgnames: List[str]) -> Dict[str, dict]: - allinfo = new_subprocess(['pacman', '-Qi', *pkgnames]).stdout - - pkgs, current_pkg = {}, {} - mapped_attrs = 0 - for out in new_subprocess(["grep", "-Po", "(Name|Provides|Depends On)\s*:\s*\K(.+)"], stdin=allinfo).stdout: - if out: - line = out.decode().strip() - - if line: - if mapped_attrs == 0: - current_pkg['name'] = line - elif mapped_attrs == 1: - provides = set() if line == 'None' else set(line.split(' ')) - provides.add(current_pkg['name']) - current_pkg['provides'] = provides - elif mapped_attrs == 2: - current_pkg['depends'] = line.split(':')[1].strip() - pkgs[current_pkg['name']] = current_pkg - del current_pkg['name'] - - mapped_attrs = 0 - current_pkg = {} - return pkgs - - def get_build_date(pkgname: str) -> str: output = run_cmd('pacman -Qi {}'.format(pkgname)) @@ -508,7 +478,7 @@ def map_update_sizes(pkgs: List[str]) -> Dict[str, int]: # bytes: output = run_cmd('pacman -Si {}'.format(' '.join(pkgs))) if output: - return {pkgs[idx]: size_to_byte(float(size[0]), size[1]) for idx, size in enumerate(RE_INSTALLED_SIZE.findall(output))} + return {pkgs[idx]: size_to_byte(float(size[0].replace(',', '.')), size[1]) for idx, size in enumerate(RE_INSTALLED_SIZE.findall(output))} return {} @@ -517,7 +487,7 @@ def map_download_sizes(pkgs: List[str]) -> Dict[str, int]: # bytes: output = run_cmd('pacman -Si {}'.format(' '.join(pkgs))) if output: - return {pkgs[idx]: size_to_byte(float(size[0]), size[1]) for idx, size in enumerate(RE_DOWNLOAD_SIZE.findall(output))} + return {pkgs[idx]: size_to_byte(float(size[0].replace(',', '.')), size[1]) for idx, size in enumerate(RE_DOWNLOAD_SIZE.findall(output))} return {} @@ -526,7 +496,7 @@ def get_installed_size(pkgs: List[str]) -> Dict[str, int]: # bytes output = run_cmd('pacman -Qi {}'.format(' '.join(pkgs))) if output: - return {pkgs[idx]: size_to_byte(float(size[0]), size[1]) for idx, size in enumerate(RE_INSTALLED_SIZE.findall(output))} + return {pkgs[idx]: size_to_byte(float(size[0].replace(',', '.')), size[1]) for idx, size in enumerate(RE_INSTALLED_SIZE.findall(output))} return {} @@ -688,11 +658,11 @@ def map_updates_data(pkgs: Iterable[str], files: bool = False) -> dict: latest_field = 'c' elif field == 'Download Size': size = val.split(' ') - data['ds'] = size_to_byte(float(size[0]), size[1]) + data['ds'] = size_to_byte(float(size[0].replace(',', '.')), size[1]) latest_field = 'ds' elif field == 'Installed Size': size = val.split(' ') - data['s'] = size_to_byte(float(size[0]), size[1]) + data['s'] = size_to_byte(float(size[0].replace(',', '.')), size[1]) latest_field = 's' elif latest_name and latest_field == 's': res[latest_name] = data @@ -801,67 +771,6 @@ def map_optional_deps(names: Iterable[str], remote: bool, not_installed: bool = return res -def map_all_deps(names: Iterable[str], only_installed: bool = False) -> Dict[str, Set[str]]: - output = run_cmd('pacman -Qi {}'.format(' '.join(names))) - - if output: - res = {} - deps_fields = {'Depends On', 'Optional Deps'} - latest_name, deps, latest_field = None, None, None - - for l in output.split('\n'): - if l: - if l[0] != ' ': - line = l.strip() - field_sep_idx = line.index(':') - field = line[0:field_sep_idx].strip() - - if field == 'Name': - latest_field = field - val = line[field_sep_idx + 1:].strip() - latest_name = val - deps = None - elif field in deps_fields: - latest_field = field - val = line[field_sep_idx + 1:].strip() - opt_deps = latest_field == 'Optional Deps' - - if deps is None: - deps = set() - - if val != 'None': - if ':' in val: - dep_info = val.split(':') - desc = dep_info[1].strip() - - if desc and opt_deps and only_installed and '[installed]' not in desc: - continue - - deps.add(dep_info[0].strip()) - else: - deps.update({dep.strip() for dep in val.split(' ') if dep}) - - elif latest_name and deps is not None: - res[latest_name] = deps - latest_name, deps, latest_field = None, None, None - - elif latest_name and deps is not None: - opt_deps = latest_field == 'Optional Deps' - - if ':' in l: - dep_info = l.split(':') - desc = dep_info[1].strip() - - if desc and opt_deps and only_installed and '[installed]' not in desc: - continue - - deps.add(dep_info[0].strip()) - else: - deps.update({dep.strip() for dep in l.split(' ') if dep}) - - return res - - def map_required_dependencies(*names: str) -> Dict[str, Set[str]]: output = run_cmd('pacman -Qi {}'.format(' '.join(names) if names else '')) @@ -1035,114 +944,6 @@ def map_replaces(names: Iterable[str], remote: bool = False) -> Dict[str, Set[st return res -def _list_unnecessary_deps(pkgs: Iterable[str], already_checked: Set[str], all_provided: Dict[str, Set[str]], recursive: bool = False) -> Set[str]: - output = run_cmd('pacman -Qi {}'.format(' '.join(pkgs))) - - if output: - res = set() - deps_field = False - - for l in output.split('\n'): - if l: - if l[0] != ' ': - line = l.strip() - field_sep_idx = line.index(':') - field = line[0:field_sep_idx].strip() - - if field == 'Depends On': - deps_field = True - val = line[field_sep_idx + 1:].strip() - - if val != 'None': - if ':' in val: - dep_info = val.split(':') - - real_deps = all_provided.get(dep_info[0].strip()) - - if real_deps: - res.update(real_deps) - else: - for dep in val.split(' '): - if dep: - real_deps = all_provided.get(dep.strip()) - - if real_deps: - res.update(real_deps) - - elif deps_field: - latest_field = False - - elif deps_field: - if ':' in l: - dep_info = l.split(':') - - real_deps = all_provided.get(dep_info[0].strip()) - - if real_deps: - res.update(real_deps) - else: - for dep in l.split(' '): - if dep: - real_deps = all_provided.get(dep.strip()) - - if real_deps: - res.update(real_deps) - - if res: - res = {dep for dep in res if dep not in already_checked} - already_checked.update(res) - - if recursive and res: - subdeps = _list_unnecessary_deps(res, already_checked, all_provided) - - if subdeps: - res.update(subdeps) - - return res - - -def list_unnecessary_deps(pkgs: Iterable[str], all_provided: Dict[str, Set[str]] = None) -> Set[str]: - all_checked = set(pkgs) - all_deps = _list_unnecessary_deps(pkgs, all_checked, map_provided(remote=False) if not all_provided else all_provided, recursive=True) - - unnecessary = set(pkgs) - if all_deps: - requirements_map = map_required_by(all_deps) - - to_clean = set() - for dep, required_by in requirements_map.items(): - if not required_by or not required_by.difference(unnecessary): - unnecessary.add(dep) - to_clean.add(dep) - elif required_by.difference(all_checked): # checking if there are requirements outside the context - to_clean.add(dep) - - if to_clean: - for dep in to_clean: - del requirements_map[dep] - - if requirements_map: - while True: - to_clean = set() - for dep, required_by in requirements_map.items(): - if not required_by.difference(unnecessary): - unnecessary.add(dep) - to_clean.add(dep) - - if to_clean: - for dep in to_clean: - del requirements_map[dep] - else: - break - - if requirements_map: # if it reaches this points it is possible to exist mutual dependent packages - for dep, required_by in requirements_map.items(): - if not required_by.difference({*requirements_map.keys(), *unnecessary}): - unnecessary.add(dep) - - return unnecessary.difference(pkgs) - - def list_installed_names() -> Set[str]: output = run_cmd('pacman -Qq', print_error=False) return {name.strip() for name in output.split('\n') if name} if output else set() diff --git a/bauh/gems/arch/resources/locale/ca b/bauh/gems/arch/resources/locale/ca index 4a90cbb26..c551cb668 100644 --- a/bauh/gems/arch/resources/locale/ca +++ b/bauh/gems/arch/resources/locale/ca @@ -230,7 +230,7 @@ arch.install.optdeps.request.title=Dependències opcionals arch.installing.package=S’està instal·lant el paquet {} arch.checking_unnecessary_deps=Checking if there are packages no longer needed arch.makepkg.optimizing=Optimitzant la recopilació -arch.missing_deps.body=S’han d’instal·lar les {deps} dependències següents abans de continuar amb la instal·lació de {name} +arch.missing_deps.body=The following dependencies ({deps}) will be installed arch.missing_deps.title=Dependències mancants arch.missing_deps_found=Dependències mancants per a {} arch.mthread_downloaded.error.cache_dir=It was not possible to create the cache directory {} diff --git a/bauh/gems/arch/resources/locale/de b/bauh/gems/arch/resources/locale/de index 37e579bd7..19ada1a5a 100644 --- a/bauh/gems/arch/resources/locale/de +++ b/bauh/gems/arch/resources/locale/de @@ -230,7 +230,7 @@ arch.install.optdeps.request.title=Optionale Abhängigkeiten arch.installing.package=Paket {} installieren arch.checking_unnecessary_deps=Checking if there are packages no longer needed arch.makepkg.optimizing=Optimiert die Zusammenstellung -arch.missing_deps.body=Die folgenden {deps} Abhängigkeiten müssten installiert sein, bevor mit der {name} Installation fortgefahren werden kann +arch.missing_deps.body=The following dependencies ({deps}) will be installed arch.missing_deps.title=Fehlende Abhängigkeiten arch.missing_deps_found=Fehlende Abhängigkeiten für {} arch.mthread_downloaded.error.cache_dir=It was not possible to create the cache directory {} diff --git a/bauh/gems/arch/resources/locale/en b/bauh/gems/arch/resources/locale/en index 9a139db8f..0a7ad9c6b 100644 --- a/bauh/gems/arch/resources/locale/en +++ b/bauh/gems/arch/resources/locale/en @@ -230,7 +230,7 @@ arch.install.optdeps.request.title=Optional dependencies arch.installing.package=Installing {} package arch.checking_unnecessary_deps=Checking if there are packages no longer needed arch.makepkg.optimizing=Optimizing the compilation -arch.missing_deps.body=The following {deps} dependencies must be installed so the {name} installation can continue +arch.missing_deps.body=The following dependencies ({deps}) will be installed arch.missing_deps.title=Missing dependencies arch.missing_deps_found=Missing dependencies for {} arch.mthread_downloaded.error.cache_dir=It was not possible to create the cache directory {} diff --git a/bauh/gems/arch/resources/locale/es b/bauh/gems/arch/resources/locale/es index 7b794f1ac..9035ab68c 100644 --- a/bauh/gems/arch/resources/locale/es +++ b/bauh/gems/arch/resources/locale/es @@ -230,7 +230,7 @@ arch.install.optdeps.request.title=Dependencias opcionales arch.installing.package=Instalando el paquete {} arch.checking_unnecessary_deps=Verificando se hay paquetes innecesarios arch.makepkg.optimizing=Optimizing the compilation -arch.missing_deps.body=Deben instalarse las siguientes {deps} dependencias para que la instalación de {name} pueda continuar +arch.missing_deps.body=Las siguientes dependencias ({deps}) serán instaladas arch.missing_deps.title=Dependencias faltantes arch.missing_deps_found=Dependencias faltantes para {} arch.mthread_downloaded.error.cache_dir=No fue posible crear el directorio de caché {} diff --git a/bauh/gems/arch/resources/locale/fr b/bauh/gems/arch/resources/locale/fr index e76087336..ebdbd0091 100644 --- a/bauh/gems/arch/resources/locale/fr +++ b/bauh/gems/arch/resources/locale/fr @@ -230,7 +230,7 @@ arch.install.optdeps.request.title=Dépendances optionnelles arch.installing.package=Installation du paquet {} arch.checking_unnecessary_deps=Calcul des paquets devenus inutiles arch.makepkg.optimizing=Optimisation de la compilation -arch.missing_deps.body=Les dependances {deps} doivent être installées pour que l'installation de {name} se poursuive +arch.missing_deps.body=The following dependencies ({deps}) will be installed arch.missing_deps.title=Dépendances manquantes arch.missing_deps_found=Dépendances manquantes pour {} arch.mthread_downloaded.error.cache_dir=Impossible de créer le dossier de cache {} diff --git a/bauh/gems/arch/resources/locale/it b/bauh/gems/arch/resources/locale/it index f842df16b..1d4fd5a0a 100644 --- a/bauh/gems/arch/resources/locale/it +++ b/bauh/gems/arch/resources/locale/it @@ -230,7 +230,7 @@ arch.install.optdeps.request.title=Dipendenze opzionali arch.installing.package=Installazione del pacchetto {} arch.checking_unnecessary_deps=Checking if there are packages no longer needed arch.makepkg.optimizing=Ottimizzando la compilazione -arch.missing_deps.body=Le seguenti {deps} dipendenze devono essere installate prima che l'installazione di {name} continui +arch.missing_deps.body=The following dependencies ({deps}) will be installed arch.missing_deps.title=Dipendenze mancanti arch.missing_deps_found=Dipendenze mancanti per {} arch.mthread_downloaded.error.cache_dir=It was not possible to create the cache directory {} diff --git a/bauh/gems/arch/resources/locale/pt b/bauh/gems/arch/resources/locale/pt index 434eb9f94..899415e21 100644 --- a/bauh/gems/arch/resources/locale/pt +++ b/bauh/gems/arch/resources/locale/pt @@ -229,7 +229,7 @@ arch.install.optdeps.request.title=Dependências opcionais arch.installing.package=Instalando o pacote {} arch.checking_unnecessary_deps=Verificando se há pacotes não mais necessários arch.makepkg.optimizing=Otimizando a compilação -arch.missing_deps.body=As seguintes {deps} dependências devem ser instaladas para que a instalação de {name} continue +arch.missing_deps.body=As seguintes dependências ({deps}) serão instaladas arch.missing_deps.title=Dependências ausentes arch.missing_deps_found=Dependencias ausentes para {} arch.mthread_downloaded.error.cache_dir=Não foi possível criar o diretório para cache {} diff --git a/bauh/gems/arch/resources/locale/ru b/bauh/gems/arch/resources/locale/ru index a3f4c91ec..e755aa405 100644 --- a/bauh/gems/arch/resources/locale/ru +++ b/bauh/gems/arch/resources/locale/ru @@ -230,7 +230,7 @@ arch.install.optdeps.request.title=Необязательные зависимо arch.installing.package=Установка пакета {} arch.checking_unnecessary_deps=Checking if there are packages no longer needed arch.makepkg.optimizing=Оптимизация компиляции -arch.missing_deps.body=Необходимо установить следующие зависимости , чтобы продолжить установку {name} +arch.missing_deps.body=The following dependencies ({deps}) will be installed arch.missing_deps.title=Отсутствующие зависимости arch.missing_deps_found=Отсутствуют зависимости для {} arch.mthread_downloaded.error.cache_dir=It was not possible to create the cache directory {} diff --git a/bauh/gems/arch/resources/locale/tr b/bauh/gems/arch/resources/locale/tr index 116b6aa53..8d4731bf4 100644 --- a/bauh/gems/arch/resources/locale/tr +++ b/bauh/gems/arch/resources/locale/tr @@ -230,7 +230,7 @@ arch.install.optdeps.request.title=İsteğe bağlı bağımlılıklar arch.installing.package={} Paketi yükleniyor arch.checking_unnecessary_deps=Artık gerekli olmayan paketler olup olmadığını kontrol et arch.makepkg.optimizing=Derlemeyi optimize et -arch.missing_deps.body={name} kurulumunun devam edebilmesi için aşağıdaki {deps} bağımlılık kurulmalıdır +arch.missing_deps.body=The following dependencies ({deps}) will be installed arch.missing_deps.title=Eksik bağımlılıklar arch.missing_deps_found={} için eksik bağımlılıklar arch.mthread_downloaded.error.cache_dir=It was not possible to create the cache directory {} diff --git a/bauh/gems/arch/updates.py b/bauh/gems/arch/updates.py index 888e38338..e4c558441 100644 --- a/bauh/gems/arch/updates.py +++ b/bauh/gems/arch/updates.py @@ -335,7 +335,7 @@ def _map_requirement(self, pkg: ArchPackage, context: UpdateRequirementsContext, current_size = installed_sizes.get(pkg.name) if installed_sizes else None - if current_size is not None and pkgdata['s']: + if current_size is not None and pkgdata['s'] is not None: requirement.extra_size = pkgdata['s'] - current_size required_by = set() diff --git a/bauh/gems/debian/aptitude.py b/bauh/gems/debian/aptitude.py index 76b82cf8c..3a7d18e31 100644 --- a/bauh/gems/debian/aptitude.py +++ b/bauh/gems/debian/aptitude.py @@ -46,6 +46,7 @@ def __init__(self, logger: Logger): self._size_attrs: Optional[Tuple[str]] = None self._default_lang = '' self._ignored_fields: Optional[Set[str]] = None + self._re_none: Optional[Pattern] = None def show(self, pkgs: Iterable[str], attrs: Optional[Collection[str]] = None, verbose: bool = False) \ -> Optional[Dict[str, Dict[str, object]]]: @@ -135,11 +136,10 @@ def simulate_upgrade(self, packages: Iterable[str]) -> DebianTransaction: def upgrade(self, packages: Iterable[str], root_password: Optional[str]) -> SimpleProcess: cmd = self.gen_transaction_cmd('upgrade', packages).split(' ') - return SimpleProcess(cmd=cmd, shell=True, lang=self._default_lang, - root_password=root_password) + return SimpleProcess(cmd=cmd, shell=True, root_password=root_password) def update(self, root_password: Optional[str]) -> SimpleProcess: - return SimpleProcess(('aptitude', 'update'), root_password=root_password, shell=True, lang=self._default_lang) + return SimpleProcess(('aptitude', 'update'), root_password=root_password, shell=True) def simulate_installation(self, packages: Iterable[str]) -> Optional[DebianTransaction]: code, output = system.execute(self.gen_transaction_cmd('install', packages, simulate=True), @@ -150,7 +150,7 @@ def simulate_installation(self, packages: Iterable[str]) -> Optional[DebianTrans def install(self, packages: Iterable[str], root_password: Optional[str]) -> SimpleProcess: cmd = self.gen_transaction_cmd('install', packages).split(' ') - return SimpleProcess(cmd=cmd, shell=True, lang=self._default_lang, root_password=root_password) + return SimpleProcess(cmd=cmd, shell=True, root_password=root_password) def read_installed(self) -> Generator[DebianPackage, None, None]: yield from self.search(query='~i') @@ -169,9 +169,7 @@ def read_updates(self) -> Generator[Tuple[str, str], None, None]: def search(self, query: str, fill_size: bool = False) -> Generator[DebianPackage, None, None]: attrs = f"%p^%v^%V^%m^%s^{'%I^' if fill_size else ''}%d" - _, output = system.execute(f"aptitude search {query} -q -F '{attrs}' --disable-columns", - shell=True, - custom_env=self.env) + _, output = system.execute(f"aptitude search {query} -q -F '{attrs}' --disable-columns", shell=True) if output: no_attrs = 7 if fill_size else 6 @@ -180,7 +178,7 @@ def search(self, query: str, fill_size: bool = False) -> Generator[DebianPackage line_split = line.strip().split('^', maxsplit=no_attrs - 1) if len(line_split) == no_attrs: - latest_version = line_split[2] if line_split[2] != '' else None + latest_version = line_split[2] if not self.re_none.match(line_split[2]) else None size = None @@ -195,7 +193,7 @@ def search(self, query: str, fill_size: bool = False) -> Generator[DebianPackage traceback.print_exc() if latest_version is not None: - installed_version = line_split[1] if line_split[1] != '' else None + installed_version = line_split[1] if not self.re_none.match(line_split[1]) else None section = strip_section(line_split[4]) yield DebianPackage(name=line_split[0], @@ -214,7 +212,7 @@ def search_by_name(self, names: Iterable[str], fill_size: bool = False) -> Gener def remove(self, packages: Iterable[str], root_password: Optional[str], purge: bool = False) -> SimpleProcess: return SimpleProcess(cmd=self.gen_remove_cmd(packages, purge).split(' '), shell=True, - lang=self._default_lang, root_password=root_password) + root_password=root_password) def read_installed_names(self) -> Generator[str, None, None]: code, output = system.execute("aptitude search ~i -q -F '%p' --disable-columns", @@ -236,8 +234,7 @@ def re_show_attr(self) -> Pattern: @property def env(self) -> Dict[str, str]: if self._env is None: - self._env = system.gen_env(global_interpreter=system.USE_GLOBAL_INTERPRETER, - lang=self._default_lang) + self._env = system.gen_env(global_interpreter=system.USE_GLOBAL_INTERPRETER) return self._env @@ -282,6 +279,13 @@ def gen_transaction_cmd(type_: str, packages: Iterable[str], simulate: bool = Fa f" -o Aptitude::ProblemResolver::EssentialRemoveScore=9999999" \ f"{' -V -s -Z' if simulate else ''}" + @property + def re_none(self) -> Pattern: + if self._re_none is None: + self._re_none = re.compile(r'^<\w+>$') + + return self._re_none + class AptitudeOutputHandler(Thread): diff --git a/bauh/gems/flatpak/__init__.py b/bauh/gems/flatpak/__init__.py index 0f6ec4c3a..a775678ba 100644 --- a/bauh/gems/flatpak/__init__.py +++ b/bauh/gems/flatpak/__init__.py @@ -18,6 +18,7 @@ VERSION_1_3 = parse_version('1.3') VERSION_1_4 = parse_version('1.4') VERSION_1_5 = parse_version('1.5') +VERSION_1_12 = parse_version('1.12') def get_icon_path() -> str: diff --git a/bauh/gems/flatpak/controller.py b/bauh/gems/flatpak/controller.py index 3a11f1a57..eae5c8d5a 100644 --- a/bauh/gems/flatpak/controller.py +++ b/bauh/gems/flatpak/controller.py @@ -15,7 +15,7 @@ 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 + SuggestionPriority, PackageStatus, CustomSoftwareAction from bauh.api.abstract.view import MessageType, FormComponent, SingleSelectComponent, InputOption, SelectViewType, \ ViewComponent, PanelComponent from bauh.commons.boot import CreateConfigFile @@ -46,6 +46,7 @@ def __init__(self, context: ApplicationContext): self.suggestions_cache = context.cache_factory.new(None) self.logger = context.logger self.configman = FlatpakConfigManager() + self._action_full_update: Optional[CustomSoftwareAction] = None def get_managed_types(self) -> Set["type"]: return {FlatpakApplication} @@ -224,10 +225,11 @@ def downgrade(self, pkg: FlatpakApplication, root_password: Optional[str], watch commit = history.history[history.pkg_status_idx + 1]['commit'] watcher.change_substatus(self.i18n['flatpak.downgrade.reverting']) watcher.change_progress(50) - success, _ = ProcessHandler(watcher).handle_simple(flatpak.downgrade(pkg.ref, - commit, - pkg.installation, - root_password)) + success, _ = ProcessHandler(watcher).handle_simple(flatpak.downgrade(app_ref=pkg.ref, + commit=commit, + installation=pkg.installation, + root_password=root_password, + version=flatpak.get_version())) watcher.change_progress(100) return success @@ -254,13 +256,15 @@ def upgrade(self, requirements: UpgradeRequirements, root_password: Optional[str if req.pkg.update_component: res, _ = ProcessHandler(watcher).handle_simple(flatpak.install(app_id=ref, installation=req.pkg.installation, - origin=req.pkg.origin)) + origin=req.pkg.origin, + version=flatpak_version)) else: res, _ = ProcessHandler(watcher).handle_simple(flatpak.update(app_ref=ref, installation=req.pkg.installation, related=related, - deps=deps)) + deps=deps, + version=flatpak_version)) watcher.change_substatus('') if not res: @@ -280,7 +284,9 @@ def uninstall(self, pkg: FlatpakApplication, root_password: Optional[str], watch if not self._make_exports_dir(watcher): return TransactionResult.fail() - uninstalled, _ = ProcessHandler(watcher).handle_simple(flatpak.uninstall(pkg.ref, pkg.installation)) + flatpak_version = flatpak.get_version() + uninstalled, _ = ProcessHandler(watcher).handle_simple(flatpak.uninstall(pkg.ref, pkg.installation, + flatpak_version)) if uninstalled: if self.suggestions_cache: @@ -445,7 +451,8 @@ def install(self, pkg: FlatpakApplication, root_password: Optional[str], disk_lo if not self._make_exports_dir(handler.watcher): return TransactionResult(success=False, installed=[], removed=[]) - installed, output = handler.handle_simple(flatpak.install(str(pkg.id), pkg.origin, pkg.installation)) + installed, output = handler.handle_simple(flatpak.install(str(pkg.id), pkg.origin, pkg.installation, + flatpak_version)) if not installed and 'error: No ref chosen to resolve matches' in output: ref_opts = RE_INSTALL_REFS.findall(output) @@ -459,7 +466,8 @@ def install(self, pkg: FlatpakApplication, root_password: Optional[str], disk_lo confirmation_label=self.i18n['proceed'].capitalize(), deny_label=self.i18n['cancel'].capitalize()): ref = ref_select.get_selected() - installed, output = handler.handle_simple(flatpak.install(ref, pkg.origin, pkg.installation)) + installed, output = handler.handle_simple(flatpak.install(ref, pkg.origin, pkg.installation, + flatpak_version)) pkg.ref = ref pkg.runtime = 'runtime' in ref else: @@ -727,3 +735,25 @@ def revert_ignored_update(self, pkg: FlatpakApplication): self._write_ignored_updates(ignored_keys) pkg.updates_ignored = False + + def gen_custom_actions(self) -> Generator[CustomSoftwareAction, None, None]: + yield self.action_full_update + + def full_update(self, root_password: Optional[str], watcher: ProcessWatcher) -> bool: + handler = ProcessHandler(watcher) + return handler.handle_simple(flatpak.full_update(flatpak.get_version()))[0] + + @property + def action_full_update(self) -> CustomSoftwareAction: + if self._action_full_update is None: + self._action_full_update = CustomSoftwareAction(i18n_label_key='flatpak.action.full_update', + i18n_description_key='flatpak.action.full_update.description', + i18n_status_key='flatpak.action.full_update.status', + backup=True, + manager=self, + requires_internet=True, + icon_path=get_icon_path(), + manager_method='full_update', + requires_root=False) + + return self._action_full_update diff --git a/bauh/gems/flatpak/flatpak.py b/bauh/gems/flatpak/flatpak.py index 1882e7f95..bd2741387 100755 --- a/bauh/gems/flatpak/flatpak.py +++ b/bauh/gems/flatpak/flatpak.py @@ -9,9 +9,10 @@ from packaging.version import parse as parse_version from bauh.api.exception import NoInternetException -from bauh.commons.system import new_subprocess, run_cmd, SimpleProcess, ProcessHandler +from bauh.commons.system import new_subprocess, run_cmd, SimpleProcess, ProcessHandler, DEFAULT_LANG from bauh.commons.util import size_to_byte -from bauh.gems.flatpak import EXPORTS_PATH, VERSION_1_3, VERSION_1_2, VERSION_1_5 +from bauh.gems.flatpak import EXPORTS_PATH, VERSION_1_3, VERSION_1_2, VERSION_1_5, VERSION_1_12 +from bauh.gems.flatpak.constants import FLATHUB_URL RE_SEVERAL_SPACES = re.compile(r'\s+') RE_COMMIT = re.compile(r'(Latest commit|Commit)\s*:\s*(.+)') @@ -57,7 +58,7 @@ def get_fields(app_id: str, branch: str, fields: List[str]) -> List[str]: info = new_subprocess(cmd).stdout res = [] - for o in new_subprocess(['grep', '-E', '({}):.+'.format('|'.join(fields)), '-o'], stdin=info).stdout: + for o in new_subprocess(('grep', '-E', '({}):.+'.format('|'.join(fields)), '-o'), stdin=info).stdout: if o: res.append(o.decode().split(':')[-1].strip()) @@ -70,20 +71,20 @@ def is_installed(): def get_version() -> Optional[Version]: - res = run_cmd('{} --version'.format('flatpak'), print_error=False) + res = run_cmd('flatpak --version', print_error=False) return parse_version(res.split(' ')[1].strip()) if res else None -def get_app_info(app_id: str, branch: str, installation: str): +def get_app_info(app_id: str, branch: str, installation: str) -> Optional[str]: try: - return run_cmd('{} info {} {}'.format('flatpak', app_id, branch, '--{}'.format(installation))) + return run_cmd(f'flatpak info {app_id} {branch} --{installation}') except: traceback.print_exc() return '' def get_commit(app_id: str, branch: str, installation: str) -> Optional[str]: - info = run_cmd('flatpak info {} {} --{}'.format(app_id, branch, installation)) + info = run_cmd(f'flatpak info {app_id} {branch} --{installation}') if info: commits = RE_COMMIT.findall(info) @@ -95,7 +96,7 @@ def list_installed(version: Version) -> List[dict]: apps = [] if version < VERSION_1_2: - app_list = new_subprocess(['flatpak', 'list', '-d']) + app_list = new_subprocess(('flatpak', 'list', '-d'), lang=None) for o in app_list.stdout: if o: @@ -117,8 +118,9 @@ def list_installed(version: Version) -> List[dict]: }) else: - cols = 'application,ref,arch,branch,description,origin,options,{}version'.format('' if version < VERSION_1_3 else 'name,') - app_list = new_subprocess(['flatpak', 'list', '--columns=' + cols]) + name_col = '' if version < VERSION_1_3 else 'name,' + cols = f'application,ref,arch,branch,description,origin,options,{name_col}version' + app_list = new_subprocess(('flatpak', 'list', f'--columns={cols}'), lang=None) for o in app_list.stdout: if o: @@ -157,8 +159,8 @@ def list_installed(version: Version) -> List[dict]: return apps -def update(app_ref: str, installation: str, related: bool = False, deps: bool = False) -> SimpleProcess: - cmd = ['flatpak', 'update', '-y', app_ref, '--{}'.format(installation)] +def update(app_ref: str, installation: str, version: Version, related: bool = False, deps: bool = False) -> SimpleProcess: + cmd = ['flatpak', 'update', '-y', app_ref, f'--{installation}'] if not related: cmd.append('--no-related') @@ -166,12 +168,19 @@ def update(app_ref: str, installation: str, related: bool = False, deps: bool = if not deps: cmd.append('--no-deps') - return SimpleProcess(cmd=cmd, extra_paths={EXPORTS_PATH}, shell=True) + return SimpleProcess(cmd=cmd, extra_paths={EXPORTS_PATH}, shell=True, + lang=DEFAULT_LANG if version < VERSION_1_12 else None) -def uninstall(app_ref: str, installation: str) -> SimpleProcess: - return SimpleProcess(cmd=['flatpak', 'uninstall', app_ref, '-y', '--{}'.format(installation)], +def full_update(version: VERSION_1_12) -> SimpleProcess: + return SimpleProcess(cmd=('flatpak', 'update', '-y'), extra_paths={EXPORTS_PATH}, shell=True, + lang=DEFAULT_LANG if version < VERSION_1_12 else None) + + +def uninstall(app_ref: str, installation: str, version: Version) -> SimpleProcess: + return SimpleProcess(cmd=('flatpak', 'uninstall', app_ref, '-y', f'--{installation}'), extra_paths={EXPORTS_PATH}, + lang=DEFAULT_LANG if version < VERSION_1_12 else None, shell=True) @@ -189,21 +198,21 @@ def read_updates(version: Version, installation: str) -> Dict[str, set]: res = {'partial': set(), 'full': set()} if version < VERSION_1_2: try: - output = run_cmd('{} update --no-related --no-deps --{}'.format('flatpak', installation), ignore_return_code=True) + output = run_cmd(f'flatpak update --no-related --no-deps --{installation}', ignore_return_code=True) - if 'Updating in {}'.format(installation) in output: - for line in output.split('Updating in {}:\n'.format(installation))[1].split('\n'): + if f'Updating in {installation}' in output: + for line in output.split(f'Updating in {installation}:\n')[1].split('\n'): if not line.startswith('Is this ok'): res['full'].add('{}/{}'.format(installation, line.split('\t')[0].strip())) except: traceback.print_exc() else: - updates = new_subprocess(['flatpak', 'update', '--{}'.format(installation)]).stdout + updates = new_subprocess(('flatpak', 'update', f'--{installation}')).stdout reg = r'[0-9]+\.\s+.+' try: - for o in new_subprocess(['grep', '-E', reg, '-o', '--color=never'], stdin=updates).stdout: + for o in new_subprocess(('grep', '-E', reg, '-o', '--color=never'), stdin=updates).stdout: if o: line_split = o.decode().strip().split('\t') @@ -213,7 +222,7 @@ def read_updates(version: Version, installation: str) -> Dict[str, set]: elif version >= VERSION_1_2: update_id = f'{line_split[2]}/{line_split[4]}/{installation}/{line_split[5]}' else: - update_id = '{}/{}/{}'.format(line_split[2], line_split[4], installation) + update_id = f'{line_split[2]}/{line_split[4]}/{installation}' if version >= VERSION_1_3 and len(line_split) >= 6: if line_split[4].strip().lower() in OPERATION_UPDATE_SYMBOLS: @@ -229,19 +238,20 @@ def read_updates(version: Version, installation: str) -> Dict[str, set]: return res -def downgrade(app_ref: str, commit: str, installation: str, root_password: Optional[str]) -> SimpleProcess: - cmd = ['flatpak', 'update', '--no-related', '--no-deps', '--commit={}'.format(commit), app_ref, '-y', '--{}'.format(installation)] +def downgrade(app_ref: str, commit: str, installation: str, root_password: Optional[str], version: Version) -> SimpleProcess: + cmd = ('flatpak', 'update', '--no-related', '--no-deps', f'--commit={commit}', app_ref, '-y', f'--{installation}') return SimpleProcess(cmd=cmd, - root_password=root_password if installation=='system' else None, + root_password=root_password if installation == 'system' else None, extra_paths={EXPORTS_PATH}, - success_phrases={'Changes complete.', 'Updates complete.'}, - wrong_error_phrases={'Warning'}) + lang=DEFAULT_LANG if version < VERSION_1_12 else None, + success_phrases={'Changes complete.', 'Updates complete.'} if version < VERSION_1_12 else None, + wrong_error_phrases={'Warning'} if version < VERSION_1_12 else None) def get_app_commits(app_ref: str, origin: str, installation: str, handler: ProcessHandler) -> Optional[List[str]]: try: - p = SimpleProcess(['flatpak', 'remote-info', '--log', origin, app_ref, '--{}'.format(installation)]) + p = SimpleProcess(('flatpak', 'remote-info', '--log', origin, app_ref, f'--{installation}')) success, output = handler.handle_simple(p) if output.startswith('error:'): return @@ -252,7 +262,7 @@ def get_app_commits(app_ref: str, origin: str, installation: str, handler: Proce def get_app_commits_data(app_ref: str, origin: str, installation: str, full_str: bool = True) -> List[dict]: - log = run_cmd('{} remote-info --log {} {} --{}'.format('flatpak', origin, app_ref, installation)) + log = run_cmd(f'flatpak remote-info --log {origin} {app_ref} --{installation}') if not log: raise NoInternetException() @@ -282,13 +292,13 @@ def get_app_commits_data(app_ref: str, origin: str, installation: str, full_str: def search(version: Version, word: str, installation: str, app_id: bool = False) -> List[dict]: - res = run_cmd('{} search {} --{}'.format('flatpak', word, installation)) + res = run_cmd(f'flatpak search {word} --{installation}', lang=None) found = [] - split_res = res.split('\n') + split_res = res.strip().split('\n') - if split_res and split_res[0].lower() != 'no matches found': + if split_res and '\t' in split_res[0]: for info in split_res: if info: info_list = info.split('\t') @@ -359,25 +369,28 @@ def search(version: Version, word: str, installation: str, app_id: bool = False) return found -def install(app_id: str, origin: str, installation: str) -> SimpleProcess: - return SimpleProcess(cmd=['flatpak', 'install', origin, app_id, '-y', '--{}'.format(installation)], +def install(app_id: str, origin: str, installation: str, version: Version) -> SimpleProcess: + return SimpleProcess(cmd=('flatpak', 'install', origin, app_id, '-y', f'--{installation}'), extra_paths={EXPORTS_PATH}, - wrong_error_phrases={'Warning'}, + lang=DEFAULT_LANG if version < VERSION_1_12 else None, + wrong_error_phrases={'Warning'} if version < VERSION_1_12 else None, shell=True) def set_default_remotes(installation: str, root_password: Optional[str] = None) -> SimpleProcess: - cmd = ['flatpak', 'remote-add', '--if-not-exists', 'flathub', 'https://flathub.org/repo/flathub.flatpakrepo', '--{}'.format(installation)] + cmd = ('flatpak', 'remote-add', '--if-not-exists', 'flathub', f'{FLATHUB_URL}/repo/flathub.flatpakrepo', + f'--{installation}') + return SimpleProcess(cmd, root_password=root_password) def has_remotes_set() -> bool: - return bool(run_cmd('{} remotes'.format('flatpak')).strip()) + return bool(run_cmd('flatpak remotes').strip()) def list_remotes() -> Dict[str, Set[str]]: res = {'system': set(), 'user': set()} - output = run_cmd('{} remotes'.format('flatpak')).strip() + output = run_cmd('flatpak remotes').strip() if output: lines = output.split('\n') @@ -394,11 +407,11 @@ def list_remotes() -> Dict[str, Set[str]]: def run(app_id: str): - subprocess.Popen(['flatpak run {}'.format(app_id)], shell=True, env={**os.environ}) + subprocess.Popen((f'flatpak run {app_id}',), shell=True, env={**os.environ}) def map_update_download_size(app_ids: Iterable[str], installation: str, version: Version) -> Dict[str, int]: - success, output = ProcessHandler().handle_simple(SimpleProcess(['flatpak', 'update', '--{}'.format(installation)])) + success, output = ProcessHandler().handle_simple(SimpleProcess(('flatpak', 'update', f'--{installation}'))) if version >= VERSION_1_2: res = {} p = re.compile(r'^\d+.\t') @@ -425,12 +438,12 @@ def map_update_download_size(app_ids: Iterable[str], installation: str, version: if size and len(size) > 1: try: - res[related_id[0].strip()] = size_to_byte(float(size[0]), size[1].strip()) + res[related_id[0].strip()] = size_to_byte(float(size[0].replace(',', '.')), size[1].strip()) except: traceback.print_exc() else: try: - res[related_id[0].strip()] = size_to_byte(float(size_tuple[0]), size_tuple[1].strip()) + res[related_id[0].strip()] = size_to_byte(float(size_tuple[0].replace(',', '.')), size_tuple[1].strip()) except: traceback.print_exc() return res diff --git a/bauh/gems/flatpak/resources/locale/ca b/bauh/gems/flatpak/resources/locale/ca index 41c104205..8a66e7312 100644 --- a/bauh/gems/flatpak/resources/locale/ca +++ b/bauh/gems/flatpak/resources/locale/ca @@ -1,3 +1,6 @@ +flatpak.action.full_update=Full update +flatpak.action.full_update.description=It completely updates the Flatpak apps and components. Useful if you are having issues with runtime updates. +flatpak.action.full_update.status=Updating Flatpak apps and components flatpak.config.install_level=level flatpak.config.install_level.ask.tip={app} will ask the level that should be applied during the app installation flatpak.config.install_level.system=system diff --git a/bauh/gems/flatpak/resources/locale/de b/bauh/gems/flatpak/resources/locale/de index afd6ce473..46ae49459 100644 --- a/bauh/gems/flatpak/resources/locale/de +++ b/bauh/gems/flatpak/resources/locale/de @@ -1,3 +1,6 @@ +flatpak.action.full_update=Full update +flatpak.action.full_update.description=It completely updates the Flatpak apps and components. Useful if you are having issues with runtime updates. +flatpak.action.full_update.status=Updating Flatpak apps and components flatpak.config.install_level=level flatpak.config.install_level.ask.tip={app} will ask the level that should be applied during the app installation flatpak.config.install_level.system=system diff --git a/bauh/gems/flatpak/resources/locale/en b/bauh/gems/flatpak/resources/locale/en index 726d62140..aafc924d3 100644 --- a/bauh/gems/flatpak/resources/locale/en +++ b/bauh/gems/flatpak/resources/locale/en @@ -1,3 +1,6 @@ +flatpak.action.full_update=Full update +flatpak.action.full_update.description=It completely updates the Flatpak apps and components. Useful if you are having issues with runtime updates. +flatpak.action.full_update.status=Updating Flatpak apps and components flatpak.config.install_level=level flatpak.config.install_level.ask.tip={app} will ask the level that should be applied during the app installation flatpak.config.install_level.system=system diff --git a/bauh/gems/flatpak/resources/locale/es b/bauh/gems/flatpak/resources/locale/es index 24e88dff5..16d1d37fd 100644 --- a/bauh/gems/flatpak/resources/locale/es +++ b/bauh/gems/flatpak/resources/locale/es @@ -1,3 +1,6 @@ +flatpak.action.full_update=Actualización completa +flatpak.action.full_update.description=Actualiza completamente las aplicaciones y componentes Flatpak. Útil si hay problemas con las actualizaciones de runtimes. +flatpak.action.full_update.status=Actualizando las aplicaciones y componentes Flatpak flatpak.config.install_level=nivel flatpak.config.install_level.ask.tip={app} preguntará el nivel que debe aplicarse durante la instalación de la aplicación flatpak.config.install_level.system=sistema diff --git a/bauh/gems/flatpak/resources/locale/fr b/bauh/gems/flatpak/resources/locale/fr index df08b4c02..549ddcb58 100644 --- a/bauh/gems/flatpak/resources/locale/fr +++ b/bauh/gems/flatpak/resources/locale/fr @@ -1,3 +1,6 @@ +flatpak.action.full_update=Full update +flatpak.action.full_update.description=It completely updates the Flatpak apps and components. Useful if you are having issues with runtime updates. +flatpak.action.full_update.status=Updating Flatpak apps and components flatpak.config.install_level=niveau flatpak.config.install_level.ask.tip={app} va demander le niveau à appliquer durant l'installation flatpak.config.install_level.system=système diff --git a/bauh/gems/flatpak/resources/locale/it b/bauh/gems/flatpak/resources/locale/it index 76ae88d75..54012fc57 100644 --- a/bauh/gems/flatpak/resources/locale/it +++ b/bauh/gems/flatpak/resources/locale/it @@ -1,3 +1,6 @@ +flatpak.action.full_update=Full update +flatpak.action.full_update.description=It completely updates the Flatpak apps and components. Useful if you are having issues with runtime updates. +flatpak.action.full_update.status=Updating Flatpak apps and components flatpak.config.install_level=level flatpak.config.install_level.ask.tip={app} will ask the level that should be applied during the app installation flatpak.config.install_level.system=system diff --git a/bauh/gems/flatpak/resources/locale/pt b/bauh/gems/flatpak/resources/locale/pt index 724bcb8ac..f4af8a3a6 100644 --- a/bauh/gems/flatpak/resources/locale/pt +++ b/bauh/gems/flatpak/resources/locale/pt @@ -1,3 +1,6 @@ +flatpak.action.full_update=Atualização completa +flatpak.action.full_update.description=Atualiza completamente aplicações e componentes Flatpak instalados. Útil se você estiver com problemas de atualização dos runtimes. +flatpak.action.full_update.status=Atualizando aplicações e componentes Flatpak flatpak.config.install_level=nível flatpak.config.install_level.ask.tip=O {app} perguntará qual o nível deverá ser aplicado durante a instalação do aplicativo flatpak.config.install_level.system=sistema diff --git a/bauh/gems/flatpak/resources/locale/ru b/bauh/gems/flatpak/resources/locale/ru index 80016c54c..6e4e7bca9 100644 --- a/bauh/gems/flatpak/resources/locale/ru +++ b/bauh/gems/flatpak/resources/locale/ru @@ -1,3 +1,6 @@ +flatpak.action.full_update=Full update +flatpak.action.full_update.description=It completely updates the Flatpak apps and components. Useful if you are having issues with runtime updates. +flatpak.action.full_update.status=Updating Flatpak apps and components flatpak.config.install_level=Уровень flatpak.config.install_level.ask.tip={app} запросит уровень, который должен быть применен во время установки приложения flatpak.config.install_level.system=Система diff --git a/bauh/gems/flatpak/resources/locale/tr b/bauh/gems/flatpak/resources/locale/tr index 1901e4475..95c170bb5 100644 --- a/bauh/gems/flatpak/resources/locale/tr +++ b/bauh/gems/flatpak/resources/locale/tr @@ -1,3 +1,6 @@ +flatpak.action.full_update=Full update +flatpak.action.full_update.description=It completely updates the Flatpak apps and components. Useful if you are having issues with runtime updates. +flatpak.action.full_update.status=Updating Flatpak apps and components flatpak.config.install_level=seviye flatpak.config.install_level.ask.tip={app} uygulama kurulumu sırasında uygulanması gereken seviyeyi soracak flatpak.config.install_level.system=sistem diff --git a/bauh/gems/flatpak/worker.py b/bauh/gems/flatpak/worker.py index 529c4afc4..e16df1bbd 100644 --- a/bauh/gems/flatpak/worker.py +++ b/bauh/gems/flatpak/worker.py @@ -55,7 +55,9 @@ def run(self): if not self.app.name: self.app.name = data.get('name') - self.app.description = data.get('description', data.get('summary', None)) + if not self.app.description: + self.app.description = data.get('description', data.get('summary', None)) + self.app.icon_url = data.get('iconMobileUrl', None) self.app.latest_version = data.get('currentReleaseVersion', self.app.version) diff --git a/bauh/gems/snap/snap.py b/bauh/gems/snap/snap.py index 002383db1..f3d4f2ed3 100644 --- a/bauh/gems/snap/snap.py +++ b/bauh/gems/snap/snap.py @@ -6,22 +6,21 @@ from bauh.commons.system import SimpleProcess -BASE_CMD = 'snap' - def is_installed() -> bool: - return bool(shutil.which(BASE_CMD)) + return bool(shutil.which('snap')) def uninstall_and_stream(app_name: str, root_password: Optional[str]) -> SimpleProcess: - return SimpleProcess(cmd=[BASE_CMD, 'remove', app_name], + return SimpleProcess(cmd=('snap', 'remove', app_name), root_password=root_password, + lang=None, shell=True) def install_and_stream(app_name: str, confinement: str, root_password: Optional[str], channel: Optional[str] = None) -> SimpleProcess: - install_cmd = [BASE_CMD, 'install', app_name] # default + install_cmd = ['snap', 'install', app_name] # default if confinement == 'classic': install_cmd.append('--classic') @@ -29,34 +28,35 @@ def install_and_stream(app_name: str, confinement: str, root_password: Optional[ if channel: install_cmd.append(f'--channel={channel}') - return SimpleProcess(install_cmd, root_password=root_password, shell=True) + return SimpleProcess(install_cmd, root_password=root_password, shell=True, lang=None) def downgrade_and_stream(app_name: str, root_password: Optional[str]) -> SimpleProcess: - return SimpleProcess(cmd=[BASE_CMD, 'revert', app_name], + return SimpleProcess(cmd=('snap', 'revert', app_name), root_password=root_password, - shell=True) + shell=True, + lang=None) def refresh_and_stream(app_name: str, root_password: Optional[str], channel: Optional[str] = None) -> SimpleProcess: - cmd = [BASE_CMD, 'refresh', app_name] + cmd = ['snap', 'refresh', app_name] if channel: cmd.append(f'--channel={channel}') return SimpleProcess(cmd=cmd, root_password=root_password, - error_phrases={'no updates available'}, + lang=None, shell=True) def run(cmd: str): - subprocess.Popen([f'{BASE_CMD} run {cmd}'], shell=True, env={**os.environ}) + subprocess.Popen((f'snap run {cmd}',), shell=True, env={**os.environ}) def is_api_available() -> Tuple[bool, str]: output = StringIO() - for o in SimpleProcess([BASE_CMD, 'search']).instance.stdout: + for o in SimpleProcess(('snap', 'search'), lang=None).instance.stdout: if o: output.write(o.decode()) diff --git a/bauh/gems/web/controller.py b/bauh/gems/web/controller.py index 615359a6a..629b99169 100644 --- a/bauh/gems/web/controller.py +++ b/bauh/gems/web/controller.py @@ -59,6 +59,8 @@ RE_SEVERAL_SPACES = re.compile(r'\s+') RE_SYMBOLS_SPLIT = re.compile(r'[\-|_\s:.]') +DEFAULT_LANGUAGE_HEADER = 'en-US, en' + class WebApplicationManager(SoftwareManager): @@ -77,12 +79,28 @@ def __init__(self, context: ApplicationContext, suggestions_loader: Optional[Sug self.idxman = SearchIndexManager(logger=context.logger) self._custom_actions: Optional[Iterable[CustomSoftwareAction]] = None - def _get_lang_header(self) -> str: + def get_accept_language_header(self) -> str: try: - system_locale = locale.getdefaultlocale() - return system_locale[0] if system_locale else 'en_US' + syslocale = locale.getdefaultlocale() + + if syslocale: + locale_split = syslocale[0].split('_') + + if len(locale_split) == 2: + sys_lang = f'{locale_split[0]}-{locale_split[1]}, {locale_split[0]}' + + if DEFAULT_LANGUAGE_HEADER.split(',')[1].strip() not in sys_lang: + return f'{sys_lang}, {DEFAULT_LANGUAGE_HEADER}' + + return sys_lang + + else: + return f'{syslocale[0]}, {DEFAULT_LANGUAGE_HEADER}' + else: + return DEFAULT_LANGUAGE_HEADER + except: - return 'en_US' + return DEFAULT_LANGUAGE_HEADER def clean_environment(self, root_password: Optional[str], watcher: ProcessWatcher) -> bool: handler = ProcessHandler(watcher) @@ -221,7 +239,7 @@ def serialize_to_disk(self, pkg: SoftwarePackage, icon_bytes: Optional[bytes], o super(WebApplicationManager, self).serialize_to_disk(pkg=pkg, icon_bytes=None, only_icon=False) def _request_url(self, url: str) -> Optional[Response]: - headers = {'Accept-language': self._get_lang_header(), 'User-Agent': UA_CHROME} + 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) diff --git a/bauh/view/core/config.py b/bauh/view/core/config.py index a9fa7e919..e9c7c5d81 100644 --- a/bauh/view/core/config.py +++ b/bauh/view/core/config.py @@ -3,6 +3,9 @@ FILE_PATH = f'{CONFIG_DIR}/config.yml' +BACKUP_DEFAULT_REMOVE_METHOD = 'self' +BACKUP_REMOVE_METHODS = {BACKUP_DEFAULT_REMOVE_METHOD, 'all'} + class CoreConfigManager(YAMLConfigManager): @@ -63,7 +66,8 @@ def get_default_config(self) -> dict: 'downgrade': None, 'upgrade': None, 'mode': 'incremental', - 'type': 'rsync' + 'type': 'rsync', + 'remove_method': 'self' }, 'boot': { 'load_apps': True diff --git a/bauh/view/core/settings.py b/bauh/view/core/settings.py index 88820a550..561bf12cb 100644 --- a/bauh/view/core/settings.py +++ b/bauh/view/core/settings.py @@ -16,7 +16,7 @@ FileChooserComponent, RangeInputComponent from bauh.commons.view_utils import new_select from bauh.view.core import timeshift -from bauh.view.core.config import CoreConfigManager +from bauh.view.core.config import CoreConfigManager, BACKUP_REMOVE_METHODS, BACKUP_DEFAULT_REMOVE_METHOD from bauh.view.core.downloader import AdaptableFileDownloader from bauh.view.util import translation from bauh.view.util.translation import I18n @@ -327,7 +327,7 @@ def _gen_general_settings(self, core_config: dict, screen_width: int, screen_hei sub_comps = [FormComponent([select_locale, select_store_pwd, select_sysnotify, select_load_apps, select_sugs, inp_sugs, inp_reboot], spaces=False)] return TabComponent(self.i18n['core.config.tab.general'].capitalize(), PanelComponent(sub_comps), None, 'core.gen') - def _gen_bool_component(self, label: str, tooltip: str, value: bool, id_: str, max_width: int = 200) -> SingleSelectComponent: + def _gen_bool_component(self, label: str, tooltip: Optional[str], value: bool, id_: str, max_width: int = 200) -> SingleSelectComponent: opts = [InputOption(label=self.i18n['yes'].capitalize(), value=True), InputOption(label=self.i18n['no'].capitalize(), value=False)] @@ -397,6 +397,7 @@ def _save_settings(self, general: PanelComponent, core_config['backup']['enabled'] = bkp_form.get_component('enabled').get_selected() core_config['backup']['mode'] = bkp_form.get_component('mode').get_selected() core_config['backup']['type'] = bkp_form.get_component('type').get_selected() + core_config['backup']['remove_method'] = bkp_form.get_component('remove_method').get_selected() core_config['backup']['install'] = bkp_form.get_component('install').get_selected() core_config['backup']['uninstall'] = bkp_form.get_component('uninstall').get_selected() core_config['backup']['upgrade'] = bkp_form.get_component('upgrade').get_selected() @@ -580,5 +581,25 @@ def _gen_backup_settings(self, core_config: dict, screen_width: int, screen_heig max_width=default_width, id_='type') - sub_comps = [FormComponent([enabled_opt, mode, type_, install_mode, uninstall_mode, upgrade_mode, downgrade_mode], spaces=False)] + remove_method = core_config['backup']['remove_method'] + + if not remove_method or remove_method not in BACKUP_REMOVE_METHODS: + remove_method = BACKUP_DEFAULT_REMOVE_METHOD + + remove_i18n = 'core.config.backup.remove_method' + remove_opts = ((self.i18n[f'{remove_i18n}.{m}'], m, self.i18n[f'{remove_i18n}.{m}.tip']) + for m in sorted(BACKUP_REMOVE_METHODS)) + + remove_label = f'{self.i18n[remove_i18n]} ({self.i18n["core.config.backup.mode"]} ' \ + f'"{self.i18n["core.config.backup.mode.only_one"].capitalize()}")' + + sel_remove = new_select(label=remove_label, + tip=None, + value=remove_method, + opts=remove_opts, + max_width=default_width, + capitalize_label=False, + id_='remove_method') + + sub_comps = [FormComponent([enabled_opt, type_, mode, sel_remove, install_mode, uninstall_mode, upgrade_mode, downgrade_mode], spaces=False)] return TabComponent(self.i18n['core.config.tab.backup'].capitalize(), PanelComponent(sub_comps), None, 'core.bkp') diff --git a/bauh/view/core/timeshift.py b/bauh/view/core/timeshift.py index e2d4ad73f..2ed1f99ae 100644 --- a/bauh/view/core/timeshift.py +++ b/bauh/view/core/timeshift.py @@ -1,7 +1,11 @@ +import re import shutil -from typing import Optional +from typing import Optional, Generator -from bauh.commons.system import SimpleProcess +from bauh import __app_name__ +from bauh.commons.system import SimpleProcess, new_root_subprocess + +RE_SNAPSHOTS = re.compile(r'\d+\s+>\s+([\w\-_]+)\s+.+<{}>'.format(__app_name__)) def is_available() -> bool: @@ -9,8 +13,26 @@ def is_available() -> bool: def delete_all_snapshots(root_password: Optional[str]) -> SimpleProcess: - return SimpleProcess(['timeshift', '--delete-all', '--scripted'], root_password=root_password) + return SimpleProcess(('timeshift', '--delete-all', '--scripted'), root_password=root_password) + + +def delete(snapshot_name: str, root_password: Optional[str]) -> SimpleProcess: + return SimpleProcess(('timeshift', '--delete', '--snapshot', snapshot_name), + shell=True, root_password=root_password) def create_snapshot(root_password: Optional[str], mode: str) -> SimpleProcess: - return SimpleProcess(['timeshift', '--create', '--scripted', '--{}'.format(mode)], root_password=root_password) + return SimpleProcess(('timeshift', '--create', '--scripted', f'--{mode}', '--comments', f'<{__app_name__}>'), + root_password=root_password) + + +def read_created_snapshots(root_password: Optional[str]) -> Generator[str, None, None]: + proc = new_root_subprocess(cmd=('timeshift', '--list'), root_password=root_password, shell=True) + proc.wait() + + if proc.returncode == 0: + output = '\n'.join((o.decode() for o in proc.stdout)) + + if output: + for name in RE_SNAPSHOTS.findall(output): + yield name diff --git a/bauh/view/qt/commons.py b/bauh/view/qt/commons.py index 8ba0a2210..e5384a13c 100644 --- a/bauh/view/qt/commons.py +++ b/bauh/view/qt/commons.py @@ -13,6 +13,7 @@ def new_pkgs_info() -> dict: 'napp_updates': 0, 'pkgs_displayed': [], 'not_installed': 0, + 'installed': 0, 'categories': set(), 'pkgs': []} # total packages @@ -41,7 +42,11 @@ def update_info(pkgv: PackageView, pkgs_info: dict): pkgs_info['categories'].add(cat) pkgs_info['pkgs'].append(pkgv) - pkgs_info['not_installed'] += 1 if not pkgv.model.installed else 0 + + if pkgv.model.installed: + pkgs_info['installed'] += 1 + else: + pkgs_info['not_installed'] += 1 def apply_filters(pkg: PackageView, filters: dict, info: dict, limit: bool = True): diff --git a/bauh/view/qt/components.py b/bauh/view/qt/components.py index a3fbdd23d..8170f5596 100644 --- a/bauh/view/qt/components.py +++ b/bauh/view/qt/components.py @@ -865,6 +865,7 @@ def _update_value(): def _wrap(self, comp: QWidget, model: ViewComponent) -> QWidget: field_container = QWidget() field_container.setLayout(QHBoxLayout()) + field_container.layout().setContentsMargins(0, 0, 0, 0) if model.max_width > 0: field_container.setMaximumWidth(int(model.max_width)) diff --git a/bauh/view/qt/dialog.py b/bauh/view/qt/dialog.py index ae867a0f7..13ffc0f56 100644 --- a/bauh/view/qt/dialog.py +++ b/bauh/view/qt/dialog.py @@ -35,7 +35,7 @@ def __init__(self, title: str, body: Optional[str], i18n: I18n, icon: QIcon = QI widgets: Optional[List[QWidget]] = None, confirmation_button: bool = True, deny_button: bool = True, window_cancel: bool = False, confirmation_label: Optional[str] = None, deny_label: Optional[str] = None, confirmation_icon: bool = True, min_width: Optional[int] = None, - min_height: Optional[int] = None): + min_height: Optional[int] = None, max_width: Optional[int] = None): super(ConfirmationDialog, self).__init__() if not window_cancel: @@ -46,6 +46,9 @@ def __init__(self, title: str, body: Optional[str], i18n: I18n, icon: QIcon = QI self.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Preferred) self.setMinimumWidth(min_width if min_width and min_width > 0 else 250) + if max_width is not None and max_width > 0: + self.setMaximumWidth(max_width) + if isinstance(min_height, int) and min_height > 0: self.setMinimumHeight(min_height) diff --git a/bauh/view/qt/screenshots.py b/bauh/view/qt/screenshots.py index cc3b719ec..5d52aaf77 100644 --- a/bauh/view/qt/screenshots.py +++ b/bauh/view/qt/screenshots.py @@ -47,7 +47,22 @@ def __init__(self, pkg: PackageView, http_client: HttpClient, icon_cache: Memory self.setWindowIcon(QIcon(pkg.model.get_type_icon_path())) self.setLayout(QVBoxLayout()) + self.bt_close = QPushButton(self.i18n['screenshots.bt_close']) + self.bt_close.setObjectName('close') + self.bt_close.clicked.connect(self.close) + self.bt_close.setCursor(QCursor(Qt.PointingHandCursor)) + + self.upper_buttons = QWidget() + self.upper_buttons.setObjectName('upper_buttons') + self.upper_buttons.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Fixed) + self.upper_buttons.setContentsMargins(0, 0, 0, 0) + self.upper_buttons.setLayout(QHBoxLayout()) + self.upper_buttons.layout().setAlignment(Qt.AlignRight) + self.upper_buttons.layout().addWidget(self.bt_close) + self.layout().addWidget(self.upper_buttons) + self.layout().addWidget(new_spacer()) + self.img = QLabel() self.img.setObjectName('image') self.layout().addWidget(self.img) diff --git a/bauh/view/qt/thread.py b/bauh/view/qt/thread.py index a692d5174..adece32f8 100644 --- a/bauh/view/qt/thread.py +++ b/bauh/view/qt/thread.py @@ -5,7 +5,7 @@ from datetime import datetime, timedelta from io import StringIO from pathlib import Path -from typing import List, Type, Set, Tuple, Optional, Iterable +from typing import List, Type, Set, Tuple, Optional import requests from PyQt5.QtCore import QThread, pyqtSignal, QObject @@ -26,7 +26,7 @@ from bauh.commons.system import ProcessHandler, SimpleProcess from bauh.commons.view_utils import get_human_size_str from bauh.view.core import timeshift -from bauh.view.core.config import CoreConfigManager +from bauh.view.core.config import CoreConfigManager, BACKUP_REMOVE_METHODS, BACKUP_DEFAULT_REMOVE_METHOD from bauh.view.qt import commons from bauh.view.qt.commons import sort_packages from bauh.view.qt.view_model import PackageView, PackageViewStatus @@ -47,8 +47,9 @@ class AsyncAction(QThread, ProcessWatcher): signal_root_password = pyqtSignal() signal_progress_control = pyqtSignal(bool) - def __init__(self, root_password: Optional[Tuple[bool, str]] = None): + def __init__(self, i18n: I18n, root_password: Optional[Tuple[bool, str]] = None): super(AsyncAction, self).__init__() + self.i18n = i18n self.wait_confirmation = False self.confirmation_res = None self.root_password = root_password @@ -59,13 +60,14 @@ def request_confirmation(self, title: str, body: str, components: List[ViewCompo window_cancel: bool = False, confirmation_button: bool = True, min_width: Optional[int] = None, - min_height: Optional[int] = None) -> bool: + min_height: Optional[int] = None, + max_width: Optional[int] = None) -> bool: self.wait_confirmation = True self.signal_confirmation.emit({'title': title, 'body': body, 'components': components, 'confirmation_label': confirmation_label, 'deny_label': deny_label, 'deny_button': deny_button, 'window_cancel': window_cancel, 'confirmation_button': confirmation_button, 'min_width': min_width, - 'min_height': min_height}) + 'min_height': min_height, 'max_width': max_width}) self.wait_user() return self.confirmation_res @@ -129,7 +131,7 @@ def request_reboot(self, msg: str) -> bool: return False - def _generate_backup(self, app_config: dict, i18n: I18n, root_password: Optional[str]) -> bool: + def _generate_backup(self, app_config: dict, root_password: Optional[str]) -> bool: if app_config['backup']['mode'] not in ('only_one', 'incremental'): self.show_message(title=self.i18n['error'].capitalize(), body='{}: {}'.format(self.i18n['action.backup.invalid_mode'],bold(app_config['backup']['mode'])), @@ -139,25 +141,45 @@ def _generate_backup(self, app_config: dict, i18n: I18n, root_password: Optional handler = ProcessHandler(self) if app_config['backup']['mode'] == 'only_one': - self.change_substatus('[{}] {}'.format(i18n['core.config.tab.backup'].lower(), i18n['action.backup.substatus.delete'])) - deleted, _ = handler.handle_simple(timeshift.delete_all_snapshots(root_password)) - - if not deleted and not self.request_confirmation(title=i18n['core.config.tab.backup'], - body='{}. {}'.format(i18n['action.backup.error.delete'], - i18n['action.backup.error.proceed']), - confirmation_label=i18n['yes'].capitalize(), - deny_label=i18n['no'].capitalize()): + remove_method = app_config['backup']['remove_method'] + + if remove_method not in BACKUP_REMOVE_METHODS: + remove_method = BACKUP_DEFAULT_REMOVE_METHOD + + delete_failed = False + + if remove_method == 'self': + previous_snapshots = tuple(timeshift.read_created_snapshots(root_password)) + + if previous_snapshots: + substatus = f"[{self.i18n['core.config.tab.backup'].lower()}] {self.i18n['action.backup.substatus.delete']}" + self.change_substatus(substatus) + + for snapshot in reversed(previous_snapshots): + deleted, _ = handler.handle_simple(timeshift.delete(snapshot, root_password)) + + if not deleted: + delete_failed = True + else: + deleted, _ = handler.handle_simple(timeshift.delete_all_snapshots(root_password)) + delete_failed = not deleted + + if delete_failed and not self.request_confirmation(title=self.i18n['core.config.tab.backup'], + body=f"{self.i18n['action.backup.error.delete']}. " + f"{self.i18n['action.backup.error.proceed']}", + confirmation_label=self.i18n['yes'].capitalize(), + deny_label=self.i18n['no'].capitalize()): self.change_substatus('') return False - self.change_substatus('[{}] {}'.format(i18n['core.config.tab.backup'].lower(), i18n['action.backup.substatus.create'])) + self.change_substatus('[{}] {}'.format(self.i18n['core.config.tab.backup'].lower(), self.i18n['action.backup.substatus.create'])) created, _ = handler.handle_simple(timeshift.create_snapshot(root_password, app_config['backup']['type'])) - if not created and not self.request_confirmation(title=i18n['core.config.tab.backup'], - body='{}. {}'.format(i18n['action.backup.error.create'], - i18n['action.backup.error.proceed']), - confirmation_label=i18n['yes'].capitalize(), - deny_label=i18n['no'].capitalize()): + if not created and not self.request_confirmation(title=self.i18n['core.config.tab.backup'], + body='{}. {}'.format(self.i18n['action.backup.error.create'], + self.i18n['action.backup.error.proceed']), + confirmation_label=self.i18n['yes'].capitalize(), + deny_label=self.i18n['no'].capitalize()): self.change_substatus('') return False @@ -173,24 +195,24 @@ def _check_backup_requirements(self, app_config: dict, pkg: Optional[PackageView return bool(app_config['backup']['enabled']) and timeshift.is_available() - def _should_backup(self, action_key: str, app_config: dict, i18n: I18n) -> bool: + def _should_backup(self, action_key: str, app_config: dict) -> bool: # backup -> true: do not ask, only execute | false: do not ask or execute | None: ask backup = app_config['backup'][action_key] if action_key else None if backup is None: - return self.request_confirmation(title=i18n['core.config.tab.backup'], - body=i18n['action.backup.msg'], - confirmation_label=i18n['yes'].capitalize(), - deny_label=i18n['no'].capitalize()) + return self.request_confirmation(title=self.i18n['core.config.tab.backup'], + body=self.i18n['action.backup.msg'], + confirmation_label=self.i18n['yes'].capitalize(), + deny_label=self.i18n['no'].capitalize()) else: return backup - def request_backup(self, action_key: Optional[str], pkg: Optional[PackageView], i18n: I18n, app_config: dict, root_password: Optional[str], backup_only: bool = False) -> Tuple[bool, Optional[str]]: + def request_backup(self, action_key: Optional[str], pkg: Optional[PackageView], app_config: dict, root_password: Optional[str], backup_only: bool = False) -> Tuple[bool, Optional[str]]: if not backup_only: if not self._check_backup_requirements(app_config=app_config, pkg=pkg, action_key=action_key): return True, root_password - if not self._should_backup(action_key=action_key, app_config=app_config, i18n=i18n): + if not self._should_backup(action_key=action_key, app_config=app_config): return True, root_password pwd = root_password @@ -200,7 +222,7 @@ def request_backup(self, action_key: Optional[str], pkg: Optional[PackageView], if not valid_password: return False, None - return self._generate_backup(app_config=app_config, i18n=i18n, root_password=pwd), pwd + return self._generate_backup(app_config=app_config, root_password=pwd), pwd class UpgradeSelected(AsyncAction): @@ -210,10 +232,9 @@ class UpgradeSelected(AsyncAction): def __init__(self, manager: SoftwareManager, internet_checker: InternetChecker, i18n: I18n, screen_width: int, pkgs: List[PackageView] = None): - super(UpgradeSelected, self).__init__() + super(UpgradeSelected, self).__init__(i18n=i18n) self.pkgs = pkgs self.manager = manager - self.i18n = i18n self.internet_checker = internet_checker self.screen_width = screen_width @@ -491,7 +512,7 @@ def run(self): # backup dialog ( if enabled, supported and accepted ) should_backup = bkp_supported should_backup = should_backup and self._check_backup_requirements(app_config=app_config, pkg=None, action_key='upgrade') - should_backup = should_backup and self._should_backup(action_key='upgrade', app_config=app_config, i18n=self.i18n) + should_backup = should_backup and self._should_backup(action_key='upgrade', app_config=app_config) # trim dialog ( if enabled and accepted ) if app_config['disk']['trim']['after_upgrade'] is not False: @@ -511,7 +532,6 @@ def run(self): if should_backup: proceed, root_password = self.request_backup(action_key='upgrade', app_config=app_config, - i18n=self.i18n, root_password=root_password, pkg=None, backup_only=True) @@ -550,8 +570,8 @@ def run(self): class RefreshApps(AsyncAction): - def __init__(self, manager: SoftwareManager, pkg_types: Set[Type[SoftwarePackage]] = None): - super(RefreshApps, self).__init__() + def __init__(self, i18n: I18n, manager: SoftwareManager, pkg_types: Set[Type[SoftwarePackage]] = None): + super(RefreshApps, self).__init__(i18n=i18n) self.manager = manager self.pkg_types = pkg_types @@ -580,18 +600,16 @@ def run(self): class UninstallPackage(AsyncAction): def __init__(self, manager: SoftwareManager, icon_cache: MemoryCache, i18n: I18n, pkg: PackageView = None): - super(UninstallPackage, self).__init__() + super(UninstallPackage, self).__init__(i18n=i18n) self.pkg = pkg self.manager = manager self.icon_cache = icon_cache self.root_pwd = None - self.i18n = i18n def run(self): if self.pkg: proceed, _ = self.request_backup(action_key='uninstall', pkg=self.pkg, - i18n=self.i18n, root_password=self.root_pwd, app_config=CoreConfigManager().get_config()) if not proceed: @@ -622,10 +640,9 @@ def run(self): class DowngradePackage(AsyncAction): def __init__(self, manager: SoftwareManager, i18n: I18n, pkg: PackageView = None): - super(DowngradePackage, self).__init__() + super(DowngradePackage, self).__init__(i18n=i18n) self.manager = manager self.pkg = pkg - self.i18n = i18n self.root_pwd = None def run(self): @@ -634,7 +651,6 @@ def run(self): proceed, _ = self.request_backup(action_key='downgrade', pkg=self.pkg, - i18n=self.i18n, root_password=self.root_pwd, app_config=CoreConfigManager().get_config()) @@ -657,8 +673,8 @@ def run(self): class ShowPackageInfo(AsyncAction): - def __init__(self, manager: SoftwareManager, pkg: PackageView = None): - super(ShowPackageInfo, self).__init__() + def __init__(self, i18n: I18n, manager: SoftwareManager, pkg: PackageView = None): + super(ShowPackageInfo, self).__init__(i18n=i18n) self.pkg = pkg self.manager = manager @@ -678,10 +694,9 @@ def run(self): class ShowPackageHistory(AsyncAction): def __init__(self, manager: SoftwareManager, i18n: I18n, pkg: PackageView = None): - super(ShowPackageHistory, self).__init__() + super(ShowPackageHistory, self).__init__(i18n=i18n) self.pkg = pkg self.manager = manager - self.i18n = i18n def run(self): if self.pkg: @@ -695,8 +710,8 @@ def run(self): class SearchPackages(AsyncAction): - def __init__(self, manager: SoftwareManager): - super(SearchPackages, self).__init__() + def __init__(self, i18n: I18n, manager: SoftwareManager): + super(SearchPackages, self).__init__(i18n=i18n) self.word = None self.manager = manager @@ -717,11 +732,10 @@ def run(self): class InstallPackage(AsyncAction): def __init__(self, manager: SoftwareManager, icon_cache: MemoryCache, i18n: I18n, pkg: PackageView = None): - super(InstallPackage, self).__init__() + super(InstallPackage, self).__init__(i18n=i18n) self.pkg = pkg self.manager = manager self.icon_cache = icon_cache - self.i18n = i18n self.root_pwd = None def run(self): @@ -730,7 +744,6 @@ def run(self): proceed, _ = self.request_backup(action_key='install', pkg=self.pkg, - i18n=self.i18n, root_password=self.root_pwd, app_config=CoreConfigManager().get_config()) @@ -895,8 +908,8 @@ def run(self): class FindSuggestions(AsyncAction): - def __init__(self, man: SoftwareManager): - super(FindSuggestions, self).__init__() + def __init__(self, i18n: I18n, man: SoftwareManager): + super(FindSuggestions, self).__init__(i18n=i18n) self.man = man self.filter_installed = False @@ -922,8 +935,8 @@ def run(self): class LaunchPackage(AsyncAction): - def __init__(self, manager: SoftwareManager, pkg: PackageView = None): - super(LaunchPackage, self).__init__() + def __init__(self, i18n: I18n, manager: SoftwareManager, pkg: PackageView = None): + super(LaunchPackage, self).__init__(i18n=i18n) self.pkg = pkg self.manager = manager @@ -943,8 +956,8 @@ class ApplyFilters(AsyncAction): signal_table = pyqtSignal(object) - def __init__(self, filters: dict = None, pkgs: List[PackageView] = None): - super(ApplyFilters, self).__init__() + def __init__(self, i18n: I18n, filters: dict = None, pkgs: List[PackageView] = None): + super(ApplyFilters, self).__init__(i18n=i18n) self.pkgs = pkgs self.filters = filters self.wait_table_update = False @@ -979,12 +992,11 @@ def run(self): class CustomAction(AsyncAction): def __init__(self, manager: SoftwareManager, i18n: I18n, custom_action: CustomSoftwareAction = None, pkg: PackageView = None, root_password: Optional[str] = None): - super(CustomAction, self).__init__() + super(CustomAction, self).__init__(i18n=i18n) self.manager = manager self.pkg = pkg self.custom_action = custom_action self.root_pwd = root_password - self.i18n = i18n def run(self): res = {'success': False, 'pkg': self.pkg, 'action': self.custom_action, 'error': None, 'error_type': MessageType.ERROR} @@ -992,7 +1004,6 @@ def run(self): if self.custom_action.backup: proceed, _ = self.request_backup(app_config=CoreConfigManager().get_config(), action_key=None, - i18n=self.i18n, root_password=self.root_pwd, pkg=self.pkg) if not proceed: @@ -1021,8 +1032,8 @@ def run(self): class ShowScreenshots(AsyncAction): - def __init__(self, manager: SoftwareManager, pkg: PackageView = None): - super(ShowScreenshots, self).__init__() + def __init__(self, i18n: I18n, manager: SoftwareManager, pkg: PackageView = None): + super(ShowScreenshots, self).__init__(i18n=i18n) self.pkg = pkg self.manager = manager @@ -1035,8 +1046,8 @@ def run(self): class IgnorePackageUpdates(AsyncAction): - def __init__(self, manager: SoftwareManager, pkg: PackageView = None): - super(IgnorePackageUpdates, self).__init__() + def __init__(self, i18n: I18n, manager: SoftwareManager, pkg: PackageView = None): + super(IgnorePackageUpdates, self).__init__(i18n=i18n) self.pkg = pkg self.manager = manager diff --git a/bauh/view/qt/window.py b/bauh/view/qt/window.py index 3847934bf..1b3273e3b 100755 --- a/bauh/view/qt/window.py +++ b/bauh/view/qt/window.py @@ -331,18 +331,18 @@ def __init__(self, i18n: I18n, icon_cache: MemoryCache, manager: SoftwareManager internet_checker=context.internet_checker, screen_width=screen_size.width()), finished_call=self._finish_upgrade_selected) - self.thread_refresh = self._bind_async_action(RefreshApps(self.manager), finished_call=self._finish_refresh_packages, only_finished=True) + self.thread_refresh = self._bind_async_action(RefreshApps(i18n, self.manager), finished_call=self._finish_refresh_packages, only_finished=True) self.thread_uninstall = self._bind_async_action(UninstallPackage(self.manager, self.icon_cache, self.i18n), finished_call=self._finish_uninstall) - self.thread_show_info = self._bind_async_action(ShowPackageInfo(self.manager), finished_call=self._finish_show_info) + self.thread_show_info = self._bind_async_action(ShowPackageInfo(i18n, self.manager), finished_call=self._finish_show_info) self.thread_show_history = self._bind_async_action(ShowPackageHistory(self.manager, self.i18n), finished_call=self._finish_show_history) - self.thread_search = self._bind_async_action(SearchPackages(self.manager), finished_call=self._finish_search, only_finished=True) + self.thread_search = self._bind_async_action(SearchPackages(i18n, self.manager), finished_call=self._finish_search, only_finished=True) self.thread_downgrade = self._bind_async_action(DowngradePackage(self.manager, self.i18n), finished_call=self._finish_downgrade) - self.thread_suggestions = self._bind_async_action(FindSuggestions(man=self.manager), finished_call=self._finish_load_suggestions, only_finished=True) - self.thread_launch = self._bind_async_action(LaunchPackage(self.manager), finished_call=self._finish_launch_package, only_finished=False) + self.thread_suggestions = self._bind_async_action(FindSuggestions(i18n=i18n, man=self.manager), finished_call=self._finish_load_suggestions, only_finished=True) + self.thread_launch = self._bind_async_action(LaunchPackage(i18n, self.manager), finished_call=self._finish_launch_package, only_finished=False) self.thread_custom_action = self._bind_async_action(CustomAction(manager=self.manager, i18n=self.i18n), finished_call=self._finish_execute_custom_action) - self.thread_screenshots = self._bind_async_action(ShowScreenshots(self.manager), finished_call=self._finish_show_screenshots) + self.thread_screenshots = self._bind_async_action(ShowScreenshots(i18n, self.manager), finished_call=self._finish_show_screenshots) - self.thread_apply_filters = ApplyFilters() + self.thread_apply_filters = ApplyFilters(i18n) self.thread_apply_filters.signal_finished.connect(self._finish_apply_filters) self.thread_apply_filters.signal_table.connect(self._update_table_and_upgrades) self.signal_table_update.connect(self.thread_apply_filters.stop_waiting) @@ -358,7 +358,7 @@ def __init__(self, i18n: I18n, icon_cache: MemoryCache, manager: SoftwareManager self.thread_notify_pkgs_ready.signal_finished.connect(self._update_state_when_pkgs_ready) self.signal_stop_notifying.connect(self.thread_notify_pkgs_ready.stop_working) - self.thread_ignore_updates = IgnorePackageUpdates(manager=self.manager) + self.thread_ignore_updates = IgnorePackageUpdates(i18n=i18n, manager=self.manager) self._bind_async_action(self.thread_ignore_updates, finished_call=self.finish_ignore_updates) self.thread_reload = StartAsyncAction(delay_in_milis=5) @@ -469,7 +469,7 @@ def _register_groups(self): *common_filters) self.comp_manager.register_group(GROUP_UPPER_BAR, False, - CHECK_APPS, CHECK_UPDATES, COMBO_CATEGORIES, COMBO_TYPES, INP_NAME, + CHECK_APPS, CHECK_UPDATES, CHECK_INSTALLED, COMBO_CATEGORIES, COMBO_TYPES, INP_NAME, BT_INSTALLED, BT_SUGGESTIONS, BT_REFRESH, BT_UPGRADE) self.comp_manager.register_group(GROUP_LOWER_BTS, False, BT_SUGGESTIONS, BT_THEMES, BT_CUSTOM_ACTIONS, BT_SETTINGS, BT_ABOUT) @@ -559,7 +559,8 @@ def _ask_confirmation(self, msg: dict): window_cancel=msg['window_cancel'], confirmation_button=msg.get('confirmation_button', True), min_width=msg.get('min_width'), - min_height=msg.get('min_height')) + min_height=msg.get('min_height'), + max_width=msg.get('max_width')) diag.ask() res = diag.confirmed self.thread_animate_progress.animate() @@ -659,7 +660,7 @@ def _update_state_when_pkgs_ready(self): self._reorganize() def _update_package_data(self, idx: int): - if self.table_apps.isEnabled(): + if self.table_apps.isEnabled() and self.pkgs is not None and 0 <= idx < len(self.pkgs): pkg = self.pkgs[idx] pkg.status = PackageViewStatus.READY self.table_apps.update_package(pkg) @@ -805,6 +806,7 @@ def _finish_uninstall(self, res: dict): self.update_custom_actions() self._show_console_checkbox_if_output() + self._update_installed_filter() self.begin_apply_filters() notify_tray() else: @@ -932,9 +934,6 @@ def update_pkgs(self, new_pkgs: Optional[List[SoftwarePackage]], as_installed: b pkgs_info = commons.new_pkgs_info() filters = self._gen_filters(ignore_updates=ignore_updates) - if not keep_filters: - self._change_checkbox(self.check_installed, False, 'filter_installed', trigger=False) - if new_pkgs is not None: old_installed = None @@ -988,6 +987,9 @@ def update_pkgs(self, new_pkgs: Optional[List[SoftwarePackage]], as_installed: b self.pkgs_installed = pkgs_info['pkgs'] self.pkgs = pkgs_info['pkgs_displayed'] + self._update_installed_filter(installed_available=pkgs_info['installed'] > 0, + keep_state=keep_filters, + hide=as_installed) self._update_table(pkgs_info=pkgs_info) if new_pkgs: @@ -1007,6 +1009,27 @@ def update_pkgs(self, new_pkgs: Optional[List[SoftwarePackage]], as_installed: b return True + def _update_installed_filter(self, keep_state: bool = True, hide: bool = False, installed_available: Optional[bool] = None): + if installed_available is not None: + has_installed = installed_available + elif self.pkgs_available == self.pkgs_installed: # it means the "installed" view is loaded + has_installed = False + else: + has_installed = False + if self.pkgs_available: + for p in self.pkgs_available: + if p.model.installed: + has_installed = True + break + + if not keep_state or not has_installed: + self._change_checkbox(self.check_installed, False, 'filter_installed', trigger=False) + + if hide: + self.comp_manager.set_component_visible(CHECK_INSTALLED, False) + else: + self.comp_manager.set_component_visible(CHECK_INSTALLED, has_installed) + def _apply_filters(self, pkgs_info: dict, ignore_updates: bool): pkgs_info['pkgs_displayed'] = [] filters = self._gen_filters(ignore_updates=ignore_updates) @@ -1438,6 +1461,7 @@ def _finish_install(self, res: dict): self.pkgs_installed.insert(idx, PackageView(model, self.i18n)) self.update_custom_actions() + self._update_installed_filter(installed_available=True, keep_state=True) self.table_apps.change_headers_policy(policy=QHeaderView.Stretch, maximized=self._maximized) self.table_apps.change_headers_policy(policy=QHeaderView.ResizeToContents, maximized=self._maximized) self._resize(accept_lower_width=False) diff --git a/bauh/view/resources/locale/ca b/bauh/view/resources/locale/ca index 87b6267e8..53ce2f99d 100644 --- a/bauh/view/resources/locale/ca +++ b/bauh/view/resources/locale/ca @@ -45,7 +45,7 @@ Ukraine=Ukraine United Kingdom=United Kingdom United States=United States action.backup.error.create=It was not possible to generate a new system copy -action.backup.error.delete=It was not possible to delete the old system copies +action.backup.error.delete=It was not possible to delete all the old system copies action.backup.error.proceed=Proceed anyway ? action.backup.invalid_mode=Invalid backup mode action.backup.msg=Do you want to generate a system copy before proceeding ? @@ -211,6 +211,11 @@ core.config.backup.mode.incremental=Incremental core.config.backup.mode.incremental.tip=A new system backup will be generated containing only the changed files since the latest copy. core.config.backup.mode.only_one=Single core.config.backup.mode.only_one.tip=Only one system backup will be kept. Pre-existing backups will be erased. +core.config.backup.remove_method=Remove +core.config.backup.remove_method.self=Only generated +core.config.backup.remove_method.self.tip=It removes only the self generated backups +core.config.backup.remove_method.all=All +core.config.backup.remove_method.all.tip=It removes all existing backups on the disc core.config.backup.uninstall=Before uninstalling core.config.backup.upgrade=Before upgrading core.config.boot.load_apps=Load apps after startup @@ -406,6 +411,7 @@ publisher=proveïdor publisher.verified=verificat repository=dipòsit screenshots.bt_back.label=anterior +screenshots.bt_close=Tancar screenshots.bt_next.label=següent screenshots.download.no_content=No hi ha contingut a mostrar screenshots.image.loading=Loading diff --git a/bauh/view/resources/locale/de b/bauh/view/resources/locale/de index aac43a58d..2b414ca2c 100644 --- a/bauh/view/resources/locale/de +++ b/bauh/view/resources/locale/de @@ -45,7 +45,7 @@ Ukraine=Ukraine United Kingdom=United Kingdom United States=United States action.backup.error.create=It was not possible to generate a new system copy -action.backup.error.delete=It was not possible to delete the old system copies +action.backup.error.delete=It was not possible to delete all the old system copies action.backup.error.proceed=Proceed anyway ? action.backup.invalid_mode=Invalid backup mode action.backup.msg=Do you want to generate a system copy before proceeding ? @@ -210,6 +210,11 @@ core.config.backup.mode.incremental=Incremental core.config.backup.mode.incremental.tip=A new system backup will be generated containing only the changed files since the latest copy. core.config.backup.mode.only_one=Single core.config.backup.mode.only_one.tip=Only one system backup will be kept. Pre-existing backups will be erased. +core.config.backup.remove_method=Remove +core.config.backup.remove_method.self=Only generated +core.config.backup.remove_method.self.tip=It removes only the self generated backups +core.config.backup.remove_method.all=All +core.config.backup.remove_method.all.tip=It removes all existing backups on the disc core.config.backup.uninstall=Before uninstalling core.config.backup.upgrade=Before upgrading core.config.boot.load_apps=Load apps after startup @@ -405,6 +410,7 @@ publisher=Herausgeber publisher.verified=Geprüft repository=Repository screenshots.bt_back.label=Zurück +screenshots.bt_close=Schließen screenshots.bt_next.label=Fortfahren screenshots.download.no_content=Kein Inhalt screenshots.download.no_response=Screenshot nicht gefunden diff --git a/bauh/view/resources/locale/en b/bauh/view/resources/locale/en index bb4e98935..5c04ac3d7 100644 --- a/bauh/view/resources/locale/en +++ b/bauh/view/resources/locale/en @@ -45,7 +45,7 @@ Ukraine=Ukraine United Kingdom=United Kingdom United States=United States action.backup.error.create=It was not possible to generate a new system copy -action.backup.error.delete=It was not possible to delete the old system copies +action.backup.error.delete=It was not possible to delete all the old system copies action.backup.error.proceed=Proceed anyway ? action.backup.invalid_mode=Invalid backup mode action.backup.msg=Do you want to generate a system copy before proceeding ? @@ -211,6 +211,11 @@ core.config.backup.mode.incremental=Incremental core.config.backup.mode.only_one.tip=Only one system backup will be kept. Pre-existing backups will be erased. core.config.backup.mode.only_one=Single core.config.backup.mode=Mode +core.config.backup.remove_method=Remove +core.config.backup.remove_method.self=Only generated +core.config.backup.remove_method.self.tip=It removes only the self generated backups +core.config.backup.remove_method.all=All +core.config.backup.remove_method.all.tip=It removes all existing backups on the disc core.config.backup.uninstall=Before uninstalling core.config.backup.upgrade=Before upgrading core.config.backup=Enabled @@ -408,6 +413,7 @@ publisher.verified=verified publisher=publisher repository=repository screenshots.bt_back.label=previous +screenshots.bt_close=Close screenshots.bt_next.label=next screenshots.download.no_content=No content to display screenshots.download.no_response=Image not found diff --git a/bauh/view/resources/locale/es b/bauh/view/resources/locale/es index 6278a5423..87ddf223b 100644 --- a/bauh/view/resources/locale/es +++ b/bauh/view/resources/locale/es @@ -45,7 +45,7 @@ Ukraine=Ucrania United Kingdom=Reino Unido United States=Estados Unidos action.backup.error.create=No fue posible generar una nueva copia de seguridad -action.backup.error.delete=No fue posible eliminar las copias de seguridad antiguas del sistema +action.backup.error.delete=No fue posible eliminar todas las copias de seguridad antiguas del sistema action.backup.error.proceed=¿Continuar de todos modos? action.backup.invalid_mode=Modo de copia de seguridad inválido action.backup.msg=¿Desea generar una copia de seguridad del sistema antes de continuar? @@ -211,6 +211,11 @@ core.config.backup.mode.incremental=Incremental core.config.backup.mode.incremental.tip=Se generará una nueva copia de seguridad del sistema que contenga solo los archivos modificados desde la última copia. core.config.backup.mode.only_one=Única core.config.backup.mode.only_one.tip=Solo se guardará una copia de seguridad del sistema. Las copias preexistentes serán borradas. +core.config.backup.remove_method=Eliminar +core.config.backup.remove_method.self=Solo generadas +core.config.backup.remove_method.self.tip=Elimina solo las copias generadas por la aplicación +core.config.backup.remove_method.all=Todas +core.config.backup.remove_method.all.tip=Elimina todas las copias de seguridad existentes en el disco core.config.backup.uninstall=Antes de desinstalar core.config.backup.upgrade=Antes de actualizar core.config.boot.load_apps=Cargar aplicaciones al inicio @@ -408,6 +413,7 @@ publisher.verified=verificado removing=removing repository=repositorio screenshots.bt_back.label=anterior +screenshots.bt_close=Cerrar screenshots.bt_next.label=siguiente screenshots.download.no_content=No hay contenido para mostrar screenshots.download.no_response=No se encontró la imagen diff --git a/bauh/view/resources/locale/fr b/bauh/view/resources/locale/fr index 1aa701fea..9525573f5 100644 --- a/bauh/view/resources/locale/fr +++ b/bauh/view/resources/locale/fr @@ -209,6 +209,11 @@ core.config.backup.mode.incremental=Incrémental core.config.backup.mode.only_one.tip=Une seule sauvegarde sera conservée. Les précédantes seront écrasées core.config.backup.mode.only_one=Seul core.config.backup.mode=Mode +core.config.backup.remove_method=Remove +core.config.backup.remove_method.self=Only generated +core.config.backup.remove_method.self.tip=It removes only the self generated backups +core.config.backup.remove_method.all=All +core.config.backup.remove_method.all.tip=It removes all existing backups on the disc core.config.backup.uninstall=Avant de désinstaller core.config.backup.upgrade=Avant de mettre à jour core.config.backup=Activé @@ -402,6 +407,7 @@ publisher.verified=vérifié publisher=publier repository=dépôt screenshots.bt_back.label=précédant +screenshots.bt_close=Fermer screenshots.bt_next.label=suivant screenshots.download.no_content=Rien à afficher screenshots.download.no_response=Pas d'image diff --git a/bauh/view/resources/locale/it b/bauh/view/resources/locale/it index be84a0d18..db6a75c85 100644 --- a/bauh/view/resources/locale/it +++ b/bauh/view/resources/locale/it @@ -45,7 +45,7 @@ Ukraine=Ukraine United Kingdom=United Kingdom United States=United States action.backup.error.create=It was not possible to generate a new system copy -action.backup.error.delete=It was not possible to delete the old system copies +action.backup.error.delete=It was not possible to delete all the old system copies action.backup.error.proceed=Proceed anyway ? action.backup.invalid_mode=Invalid backup mode action.backup.msg=Do you want to generate a system copy before proceeding ? @@ -210,6 +210,11 @@ core.config.backup.mode.incremental=Incremental core.config.backup.mode.incremental.tip=A new system backup will be generated containing only the changed files since the latest copy. core.config.backup.mode.only_one=Single core.config.backup.mode.only_one.tip=Only one system backup will be kept. Pre-existing backups will be erased. +core.config.backup.remove_method=Remove +core.config.backup.remove_method.self=Only generated +core.config.backup.remove_method.self.tip=It removes only the self generated backups +core.config.backup.remove_method.all=All +core.config.backup.remove_method.all.tip=It removes all existing backups on the disc core.config.backup.uninstall=Before uninstalling core.config.backup.upgrade=Before upgrading core.config.boot.load_apps=Load apps after startup @@ -408,6 +413,7 @@ publisher=editore publisher.verified=verificato repository=deposito screenshots.bt_back.label=precedente +screenshots.bt_close=Chiudere screenshots.bt_next.label=prossimo screenshots.download.no_content=Nessun contenuto da visualizzare screenshots.download.no_response=Immagine non trovata diff --git a/bauh/view/resources/locale/pt b/bauh/view/resources/locale/pt index 2ff8566ad..d48a94037 100644 --- a/bauh/view/resources/locale/pt +++ b/bauh/view/resources/locale/pt @@ -45,7 +45,7 @@ Ukraine=Ucrânia United Kingdom=Reino Unido United States=Estados Unidos action.backup.error.create=Não foi possível gerar uma nova cópia de segurança do sistema -action.backup.error.delete=Não foi possível remover as cópias de segurança antigas do sistema +action.backup.error.delete=Não foi possível remover todas as cópias de segurança antigas do sistema action.backup.error.proceed=Continuar mesmo assim ? action.backup.invalid_mode=Modo de cópia de segurança inválido action.backup.msg=Você deseja gerar uma cópia de segurança do sistema antes de proceder ? @@ -209,6 +209,11 @@ core.config.backup.mode.incremental=Incremental core.config.backup.mode.only_one.tip=Somente uma cópia de segurança do sistema será mantida. Pré-existentes serão apagadas core.config.backup.mode.only_one=Única core.config.backup.mode=Modo +core.config.backup.remove_method=Remover +core.config.backup.remove_method.self=Somente geradas +core.config.backup.remove_method.self.tip=Remove somente as cópia geradas pela aplicação +core.config.backup.remove_method.all=Todas +core.config.backup.remove_method.all.tip=Remove todas as cópias existentes no disco core.config.backup.uninstall=Antes de desinstalar core.config.backup.upgrade=Antes de atualizar core.config.backup=Habilitada @@ -406,6 +411,7 @@ publisher.verified=verificado publisher=publicador repository=repositório screenshots.bt_back.label=anterior +screenshots.bt_close=Fechar screenshots.bt_next.label=próxima screenshots.download.no_content=Sem conteúdo para exibir screenshots.download.no_response=Imagem não encontrada diff --git a/bauh/view/resources/locale/ru b/bauh/view/resources/locale/ru index d9a834e7d..b975dfc49 100644 --- a/bauh/view/resources/locale/ru +++ b/bauh/view/resources/locale/ru @@ -45,7 +45,7 @@ Ukraine=Украина United Kingdom=Великобритания United States=Соединённые Штаты Америки action.backup.error.create=It was not possible to generate a new system copy -action.backup.error.delete=It was not possible to delete the old system copies +action.backup.error.delete=It was not possible to delete all the old system copies action.backup.error.proceed=Proceed anyway ? action.backup.invalid_mode=Invalid backup mode action.backup.msg=Do you want to generate a system copy before proceeding ? @@ -210,6 +210,11 @@ core.config.backup.mode.incremental=Incremental core.config.backup.mode.incremental.tip=A new system backup will be generated containing only the changed files since the latest copy. core.config.backup.mode.only_one=Single core.config.backup.mode.only_one.tip=Only one system backup will be kept. Pre-existing backups will be erased. +core.config.backup.remove_method=Remove +core.config.backup.remove_method.self=Only generated +core.config.backup.remove_method.self.tip=It removes only the self generated backups +core.config.backup.remove_method.all=All +core.config.backup.remove_method.all.tip=It removes all existing backups on the disc core.config.backup.uninstall=Before uninstalling core.config.backup.upgrade=Before upgrading core.config.boot.load_apps=Load apps after startup @@ -405,6 +410,7 @@ publisher=издатель publisher.verified=Проверенный repository=Репозиторий screenshots.bt_back.label=Предыдущий +screenshots.bt_close=закрыть screenshots.bt_next.label=Следующий screenshots.download.no_content=Нет материалов для отображени screenshots.download.no_response=Изображение не найдено diff --git a/bauh/view/resources/locale/tr b/bauh/view/resources/locale/tr index f8e92e3a5..1dce6f53b 100644 --- a/bauh/view/resources/locale/tr +++ b/bauh/view/resources/locale/tr @@ -209,6 +209,11 @@ core.config.backup.mode.incremental=Artan core.config.backup.mode.only_one.tip=Yalnızca bir sistem yedeklemesi tutulur. Önceden var olan yedeklemeler silinecek. core.config.backup.mode.only_one=Tekil core.config.backup.mode=Mod +core.config.backup.remove_method=Remove +core.config.backup.remove_method.self=Only generated +core.config.backup.remove_method.self.tip=It removes only the self generated backups +core.config.backup.remove_method.all=All +core.config.backup.remove_method.all.tip=It removes all existing backups on the disc core.config.backup.uninstall=Önce kaldırılıyor core.config.backup.upgrade=Önce yükseltiliyor core.config.backup=Etkin @@ -405,6 +410,7 @@ publisher.verified=doğrula publisher=yayımcı repository=depo screenshots.bt_back.label=önceki +screenshots.bt_close=Kapatmak screenshots.bt_next.label=sonraki screenshots.download.no_content=Görüntülenecek içerik yok screenshots.download.no_response=İmaj bulunamadı diff --git a/bauh/view/resources/style/default/default.qss b/bauh/view/resources/style/default/default.qss index 0f2cb3130..6c71d578b 100644 --- a/bauh/view/resources/style/default/default.qss +++ b/bauh/view/resources/style/default/default.qss @@ -391,6 +391,10 @@ ScreenshotsDialog QAbstractButton[control = "true"] { min-width: 100px; } +ScreenshotsDialog QPushButton#close { + min-width: 25px; +} + SettingsWindow TabGroupQt#settings { min-width: 400px; } diff --git a/tests/gems/debian/test_aptitude.py b/tests/gems/debian/test_aptitude.py index 37ba53854..a8a5a93a3 100644 --- a/tests/gems/debian/test_aptitude.py +++ b/tests/gems/debian/test_aptitude.py @@ -35,8 +35,7 @@ def test_search__must_return_installed_and_not_installed_packages_with_updates(s query = 'gimp' res = [p for p in self.aptitude.search(query=query)] - execute.assert_called_once_with(f"aptitude search {query} -q -F '%p^%v^%V^%m^%s^%d' --disable-columns", - shell=True, custom_env=system.gen_env(USE_GLOBAL_INTERPRETER, lang='')) + execute.assert_called_once_with(f"aptitude search {query} -q -F '%p^%v^%V^%m^%s^%d' --disable-columns", shell=True) exp = [ DebianPackage(name='gimp-cbmplugs', version='1.2.2-1build1', latest_version='1.2.2-1build1', diff --git a/tests/gems/web/__init__.py b/tests/gems/web/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/gems/web/test_controller.py b/tests/gems/web/test_controller.py new file mode 100644 index 000000000..442ffe248 --- /dev/null +++ b/tests/gems/web/test_controller.py @@ -0,0 +1,47 @@ +from unittest import TestCase +from unittest.mock import Mock, patch + +from bauh.gems.web.controller import DEFAULT_LANGUAGE_HEADER +from bauh.gems.web.controller import WebApplicationManager + + +class ControllerTest(TestCase): + + def test_DEFAULT_LANGUAGE_HEADER(self): + self.assertEqual('en-US, en', DEFAULT_LANGUAGE_HEADER) + + +class WebApplicationManagerTest(TestCase): + + def setUp(self): + self.manager = WebApplicationManager(context=Mock()) + + @patch('locale.getdefaultlocale', side_effect=Exception) + def test_get_accept_language_header__must_return_default_locale_when_exception_raised(self, getdefaultlocale: Mock): + returned = self.manager.get_accept_language_header() + self.assertEqual(DEFAULT_LANGUAGE_HEADER, returned) + getdefaultlocale.assert_called_once() + + @patch('locale.getdefaultlocale', return_value=None) + def test_get_accept_language_header__must_return_default_locale_when_no_locale_is_returned(self, getdefaultlocale: Mock): + returned = self.manager.get_accept_language_header() + self.assertEqual(DEFAULT_LANGUAGE_HEADER, returned) + getdefaultlocale.assert_called_once() + + @patch('locale.getdefaultlocale', return_value=['es_AR']) + def test_get_accept_language_header__must_return_the_system_locale_without_underscore_plus_default_locale(self, getdefaultlocale: Mock): + returned = self.manager.get_accept_language_header() + self.assertEqual(f'es-AR, es, {DEFAULT_LANGUAGE_HEADER}', returned) + getdefaultlocale.assert_called_once() + + @patch('locale.getdefaultlocale', return_value=['es']) + def test_get_accept_language_header__must_return_the_simple_system_locale_plus_default_locale(self, getdefaultlocale: Mock): + returned = self.manager.get_accept_language_header() + self.assertEqual(f'es, {DEFAULT_LANGUAGE_HEADER}', returned) + getdefaultlocale.assert_called_once() + + @patch('locale.getdefaultlocale', return_value=['en_IN']) + def test_get_accept_language_header__must_not_concatenate_default_locale_if_system_locale_has_it(self, getdefaultlocale: Mock): + returned = self.manager.get_accept_language_header() + self.assertEqual(f'en-IN, en', returned) + getdefaultlocale.assert_called_once()