From 00b0ae7ba439a5a420095175b3bedd52c569db51 Mon Sep 17 00:00:00 2001 From: Daniel Girtler Date: Wed, 19 Apr 2023 20:55:42 +1000 Subject: PyParted and a large rewrite of the underlying partitioning (#1604) * Invert mypy files * Add optional pre-commit hooks * New profile structure * Serialize profiles * Use profile instead of classmethod * Custom profile setup * Separator between back * Support profile import via url * Move profiles module * Refactor files * Remove symlink * Add user to docker group * Update schema description * Handle list services * mypy fixes * mypy fixes * Rename profilesv2 to profiles * flake8 * mypy again * Support selecting DM * Fix mypy * Cleanup * Update greeter setting * Update schema * Revert toml changes * Poc external dependencies * Dependency support * New encryption menu * flake8 * Mypy and flake8 * Unify lsblk command * Update bootloader configuration * Git hooks * Fix import * Pyparted * Remove custom font setting * flake8 * Remove default preview * Manual partitioning menu * Update structure * Disk configuration * Update filesystem * luks2 encryption * Everything works until installation * Btrfsutil * Btrfs handling * Update btrfs * Save encryption config * Fix pipewire issue * Update mypy version * Update all pre-commit * Update package versions * Revert audio/pipewire * Merge master PRs * Add master changes * Merge master changes * Small renaming * Pull master changes * Reset disk enc after disk config change * Generate locals * Update naming * Fix imports * Fix broken sync * Fix pre selection on table menu * Profile menu * Update profile * Fix post_install * Added python-pyparted to PKGBUILD, this requires [testing] to be enabled in order to run makepkg. Package still works via python -m build etc. * Swaped around some setuptools logic in pyproject Since we define `package-data` and `packages` there should be no need for: ``` [tool.setuptools.packages.find] where = ["archinstall", "archinstall.*"] ``` * Removed pyproject collisions. Duplicate definitions. * Made sure pyproject.toml includes languages * Add example and update README * Fix pyproject issues * Generate locale * Refactor imports * Simplify imports * Add profile description and package examples * Align code * Fix mypy * Simplify imports * Fix saving config * Fix wrong luks merge * Refactor installation * Fix cdrom device loading * Fix wrongly merged code * Fix imports and greeter * Don't terminate on partprobe error * Use specific path on partprobe from luks * Update archinstall/lib/disk/device_model.py Co-authored-by: codefiles <11915375+codefiles@users.noreply.github.com> * Update archinstall/lib/disk/device_model.py Co-authored-by: codefiles <11915375+codefiles@users.noreply.github.com> * Update github workflow to test archinstall installation * Update sway merge * Generate locales * Update workflow --------- Co-authored-by: Daniel Girtler Co-authored-by: Anton Hvornum Co-authored-by: Anton Hvornum Co-authored-by: codefiles <11915375+codefiles@users.noreply.github.com> --- archinstall/lib/menu/__init__.py | 11 +- archinstall/lib/menu/abstract_menu.py | 200 +-- archinstall/lib/menu/global_menu.py | 429 ------ archinstall/lib/menu/list_manager.py | 46 +- archinstall/lib/menu/menu.py | 114 +- archinstall/lib/menu/simple_menu.py | 2002 -------------------------- archinstall/lib/menu/table_selection_menu.py | 68 +- 7 files changed, 272 insertions(+), 2598 deletions(-) delete mode 100644 archinstall/lib/menu/global_menu.py delete mode 100644 archinstall/lib/menu/simple_menu.py (limited to 'archinstall/lib/menu') diff --git a/archinstall/lib/menu/__init__.py b/archinstall/lib/menu/__init__.py index 9b0adb8b..9c86faf5 100644 --- a/archinstall/lib/menu/__init__.py +++ b/archinstall/lib/menu/__init__.py @@ -1,2 +1,9 @@ -from .menu import Menu as Menu -from .global_menu import GlobalMenu as GlobalMenu \ No newline at end of file +from .abstract_menu import Selector, AbstractMenu, AbstractSubMenu +from .list_manager import ListManager +from .menu import ( + MenuSelectionType, + MenuSelection, + Menu, +) +from .table_selection_menu import TableMenu +from .text_input import TextInput diff --git a/archinstall/lib/menu/abstract_menu.py b/archinstall/lib/menu/abstract_menu.py index d659d709..53816655 100644 --- a/archinstall/lib/menu/abstract_menu.py +++ b/archinstall/lib/menu/abstract_menu.py @@ -7,7 +7,6 @@ from .menu import Menu, MenuSelectionType from ..locale_helpers import set_keyboard_language from ..output import log from ..translationhandler import TranslationHandler, Language -from ..user_interaction.general_conf import select_archinstall_language if TYPE_CHECKING: _: Any @@ -16,17 +15,17 @@ if TYPE_CHECKING: class Selector: def __init__( self, - description :str, - func :Optional[Callable] = None, - display_func :Optional[Callable] = None, - default :Any = None, - enabled :bool = False, - dependencies :List = [], - dependencies_not :List = [], - exec_func :Optional[Callable] = None, - preview_func :Optional[Callable] = None, - mandatory :bool = False, - no_store :bool = False + description: str, + func: Optional[Callable[[str], Any]] = None, + display_func: Optional[Callable] = None, + default: Optional[Any] = None, + enabled: bool = False, + dependencies: List = [], + dependencies_not: List = [], + exec_func: Optional[Callable] = None, + preview_func: Optional[Callable] = None, + mandatory: bool = False, + no_store: bool = False ): """ Create a new menu selection entry @@ -82,6 +81,11 @@ class Selector: self._preview_func = preview_func self.mandatory = mandatory self._no_store = no_store + self._default = default + + @property + def default(self) -> Any: + return self._default @property def description(self) -> str: @@ -96,7 +100,7 @@ class Selector: return self._dependencies_not @property - def current_selection(self): + def current_selection(self) -> Optional[Any]: return self._current_selection @property @@ -106,14 +110,14 @@ class Selector: def do_store(self) -> bool: return self._no_store is False - def set_enabled(self, status :bool = True): + def set_enabled(self, status: bool = True): self.enabled = status - def update_description(self, description :str): + def update_description(self, description: str): self._description = description def menu_text(self, padding: int = 0) -> str: - if self._description == '': # special menu option for __separator__ + if self._description == '': # special menu option for __separator__ return '' current = '' @@ -134,7 +138,7 @@ class Selector: return f'{description} {current}' - def set_current_selection(self, current :Optional[str]): + def set_current_selection(self, current: Optional[Any]): self._current_selection = current def has_selection(self) -> bool: @@ -158,14 +162,17 @@ class Selector: def is_mandatory(self) -> bool: return self.mandatory - def set_mandatory(self, status :bool = True): - self.mandatory = status - if status and not self.is_enabled(): - self.set_enabled(True) + def set_mandatory(self, value: bool): + self.mandatory = value class AbstractMenu: - def __init__(self, data_store: Optional[Dict[str, Any]] = None, auto_cursor=False, preview_size :float = 0.2): + def __init__( + self, + data_store: Dict[str, Any] = {}, + auto_cursor: bool = False, + preview_size: float = 0.2 + ): """ Create a new selection menu. @@ -179,25 +186,28 @@ class AbstractMenu: ;type preview_size: float (range 0..1) """ - self._enabled_order :List[str] = [] + self._enabled_order: List[str] = [] self._translation_handler = TranslationHandler() self.is_context_mgr = False - self._data_store = data_store if data_store is not None else {} + self._data_store = data_store self.auto_cursor = auto_cursor self._menu_options: Dict[str, Selector] = {} - self._setup_selection_menu_options() self.preview_size = preview_size self._last_choice = None + self.setup_selection_menu_options() + self._sync_all() + self._populate_default_values() + @property def last_choice(self): return self._last_choice - def __enter__(self, *args :Any, **kwargs :Any) -> AbstractMenu: + def __enter__(self, *args: Any, **kwargs: Any) -> AbstractMenu: self.is_context_mgr = True return self - def __exit__(self, *args :Any, **kwargs :Any) -> None: + def __exit__(self, *args: Any, **kwargs: Any) -> None: # TODO: https://stackoverflow.com/questions/28157929/how-to-safely-handle-an-exception-inside-a-context-manager # TODO: skip processing when it comes from a planified exit if len(args) >= 2 and args[1]: @@ -216,7 +226,50 @@ class AbstractMenu: def translation_handler(self) -> TranslationHandler: return self._translation_handler - def _setup_selection_menu_options(self): + def _populate_default_values(self): + for config_key, selector in self._menu_options.items(): + if selector.default is not None and config_key not in self._data_store: + self._data_store[config_key] = selector.default + + def _sync_all(self): + for key in self._menu_options.keys(): + self._sync(key) + + def _sync(self, selector_name: str): + value = self._data_store.get(selector_name, None) + selector = self._menu_options.get(selector_name, None) + + if value is not None: + self._menu_options[selector_name].set_current_selection(value) + elif selector is not None and selector.has_selection(): + self._data_store[selector_name] = selector.current_selection + + def _missing_configs(self) -> List[str]: + def check(s): + return self._menu_options.get(s).has_selection() + + def has_superuser() -> bool: + sel = self._menu_options['!users'] + if sel.current_selection: + return any([u.sudo for u in sel.current_selection]) + return False + + mandatory_fields = dict(filter(lambda x: x[1].is_mandatory(), self._menu_options.items())) + missing = set() + + for key, selector in mandatory_fields.items(): + if key in ['!root-password', '!users']: + if not check('!root-password') and not has_superuser(): + missing.add( + str(_('Either root-password or at least 1 user with sudo privileges must be specified')) + ) + elif key == 'disk_config': + if not check('disk_config'): + missing.add(self._menu_options['disk_config'].description) + + return list(missing) + + def setup_selection_menu_options(self): """ Define the menu options. Menu options can be defined here in a subclass or done per program calling self.set_option() """ @@ -226,7 +279,7 @@ class AbstractMenu: """ will be called before each action in the menu """ return - def post_callback(self, selection_name: Optional[str] = None, value: Any = None): + def post_callback(self, selection_name: str, value: Any): """ will be called after each action in the menu """ return True @@ -234,31 +287,16 @@ class AbstractMenu: """ will be called at the end of the processing of the menu """ return - def synch(self, selector_name :str, omit_if_set :bool = False,omit_if_disabled :bool = False): - """ loads menu options with data_store value """ - arg = self._data_store.get(selector_name, None) - # don't display the menu option if it was defined already - if arg is not None and omit_if_set: - return - - if not self.option(selector_name).is_enabled() and omit_if_disabled: - return - - if arg is not None: - self._menu_options[selector_name].set_current_selection(arg) - def _update_enabled_order(self, selector_name: str): self._enabled_order.append(selector_name) - def enable(self, selector_name :str, omit_if_set :bool = False , mandatory :bool = False): + def enable(self, selector_name: str, mandatory: bool = False): """ activates menu options """ if self._menu_options.get(selector_name, None): self._menu_options[selector_name].set_enabled(True) self._update_enabled_order(selector_name) - - if mandatory: - self._menu_options[selector_name].set_mandatory(True) - self.synch(selector_name,omit_if_set) + self._menu_options[selector_name].set_mandatory(mandatory) + self._sync(selector_name) else: raise ValueError(f'No selector found: {selector_name}') @@ -274,7 +312,11 @@ class AbstractMenu: def _find_selection(self, selection_name: str) -> Tuple[str, Selector]: enabled_menus = self._menus_to_enable() padding = self._get_menu_text_padding(list(enabled_menus.values())) - option = [(k, v) for k, v in self._menu_options.items() if v.menu_text(padding).strip() == selection_name.strip()] + + option = [] + for k, v in self._menu_options.items(): + if v.menu_text(padding).strip() == selection_name.strip(): + option.append((k, v)) if len(option) != 1: raise ValueError(f'Selection not found: {selection_name}') @@ -283,12 +325,7 @@ class AbstractMenu: return config_name, selector def run(self, allow_reset: bool = False): - """ Calls the Menu framework""" - # we synch all the options just in case - for item in self.list_options(): - self.synch(item) - - self.post_callback() # as all the values can vary i have to exec this callback + self._sync_all() cursor_pos = None while True: @@ -341,13 +378,13 @@ class AbstractMenu: break # we get the last action key - actions = {str(v.description):k for k,v in self._menu_options.items()} + actions = {str(v.description): k for k, v in self._menu_options.items()} self._last_choice = actions[selection.value.strip()] # type: ignore if not self.is_context_mgr: self.__exit__() - def _process_selection(self, selection_name :str) -> bool: + def _process_selection(self, selection_name: str) -> bool: """ determines and executes the selection y Can / Should be extended to handle specific selection issues Returns true if the menu shall continue, False if it has ended @@ -356,7 +393,7 @@ class AbstractMenu: config_name, selector = self._find_selection(selection_name) return self.exec_option(config_name, selector) - def exec_option(self, config_name :str, p_selector :Optional[Selector] = None) -> bool: + def exec_option(self, config_name: str, p_selector: Optional[Selector] = None) -> bool: """ processes the execution of a given menu entry - pre process callback - selection function @@ -372,17 +409,21 @@ class AbstractMenu: self.pre_callback(config_name) result = None + if selector.func is not None: presel_val = self.option(config_name).get_selection() result = selector.func(presel_val) self._menu_options[config_name].set_current_selection(result) if selector.do_store(): self._data_store[config_name] = result - exec_ret_val = selector.exec_func(config_name,result) if selector.exec_func is not None else False - self.post_callback(config_name,result) - if exec_ret_val and self._check_mandatory_status(): + exec_ret_val = selector.exec_func(config_name, result) if selector.exec_func else False + + self.post_callback(config_name, result) + + if exec_ret_val: return False + return True def _set_kb_language(self): @@ -392,7 +433,7 @@ class AbstractMenu: if self._data_store.get('keyboard-layout', None) and len(self._data_store['keyboard-layout']): set_keyboard_language(self._data_store['keyboard-layout']) - def _verify_selection_enabled(self, selection_name :str) -> bool: + def _verify_selection_enabled(self, selection_name: str) -> bool: """ general """ if selection := self._menu_options.get(selection_name, None): if not selection.enabled: @@ -429,16 +470,10 @@ class AbstractMenu: return ordered_menus - def option(self,name :str) -> Selector: + def option(self, name: str) -> Selector: # TODO check inexistent name return self._menu_options[name] - def list_options(self) -> Iterator: - """ Iterator to retrieve the enabled menu option names - """ - for item in self._menu_options: - yield item - def list_enabled_options(self) -> Iterator: """ Iterator to retrieve the enabled menu options at a given time. The results are dynamic (if between calls to the iterator some elements -still not retrieved- are (de)activated @@ -447,44 +482,21 @@ class AbstractMenu: if item in self._menus_to_enable(): yield item - def set_option(self, name :str, selector :Selector): - self._menu_options[name] = selector - self.synch(name) - - def _check_mandatory_status(self) -> bool: - for field in self._menu_options: - option = self._menu_options[field] - if option.is_mandatory() and not option.has_selection(): - return False - return True - - def set_mandatory(self, field :str, status :bool): - self.option(field).set_mandatory(status) - - def mandatory_overview(self) -> Tuple[int, int]: - mandatory_fields = 0 - mandatory_waiting = 0 - for field, option in self._menu_options.items(): - if option.is_mandatory(): - mandatory_fields += 1 - if not option.has_selection(): - mandatory_waiting += 1 - return mandatory_fields, mandatory_waiting - def _select_archinstall_language(self, preset_value: Language) -> Language: + from ..user_interaction.general_conf import select_archinstall_language language = select_archinstall_language(self.translation_handler.translated_languages, preset_value) self._translation_handler.activate(language) return language class AbstractSubMenu(AbstractMenu): - def __init__(self, data_store: Optional[Dict[str, Any]] = None): + def __init__(self, data_store: Dict[str, Any] = {}): super().__init__(data_store=data_store) self._menu_options['__separator__'] = Selector('') self._menu_options['back'] = \ Selector( - _('Back'), + Menu.back(), no_store=True, enabled=True, exec_func=lambda n, v: True, diff --git a/archinstall/lib/menu/global_menu.py b/archinstall/lib/menu/global_menu.py deleted file mode 100644 index 7c5b153e..00000000 --- a/archinstall/lib/menu/global_menu.py +++ /dev/null @@ -1,429 +0,0 @@ -from __future__ import annotations - -from typing import Any, List, Optional, Union, Dict, TYPE_CHECKING - -import archinstall -from ..disk.encryption import DiskEncryptionMenu -from ..general import SysCommand, secret -from ..hardware import has_uefi -from ..menu import Menu -from ..menu.abstract_menu import Selector, AbstractMenu -from ..models import NetworkConfiguration -from ..models.disk_encryption import DiskEncryption, EncryptionType -from ..models.users import User -from ..output import FormattedOutput -from ..profiles import is_desktop_profile, Profile -from ..storage import storage -from ..user_interaction import add_number_of_parrallel_downloads -from ..user_interaction import ask_additional_packages_to_install -from ..user_interaction import ask_for_additional_users -from ..user_interaction import ask_for_audio_selection -from ..user_interaction import ask_for_bootloader -from ..user_interaction import ask_for_swap -from ..user_interaction import ask_hostname -from ..user_interaction import ask_ntp -from ..user_interaction import ask_to_configure_network -from ..user_interaction import get_password, ask_for_a_timezone, save_config -from ..user_interaction import select_additional_repositories -from ..user_interaction import select_disk_layout -from ..user_interaction import select_harddrives -from ..user_interaction import select_kernel -from ..user_interaction import select_language -from ..user_interaction import select_locale_enc -from ..user_interaction import select_locale_lang -from ..user_interaction import select_mirror_regions -from ..user_interaction import select_profile -from ..user_interaction.partitioning_conf import current_partition_layout - -if TYPE_CHECKING: - _: Any - - -class GlobalMenu(AbstractMenu): - def __init__(self,data_store): - self._disk_check = True - super().__init__(data_store=data_store, auto_cursor=True, preview_size=0.3) - - def _setup_selection_menu_options(self): - # archinstall.Language will not use preset values - self._menu_options['archinstall-language'] = \ - Selector( - _('Archinstall language'), - lambda x: self._select_archinstall_language(x), - display_func=lambda x: x.display_name, - default=self.translation_handler.get_language_by_abbr('en')) - self._menu_options['keyboard-layout'] = \ - Selector( - _('Keyboard layout'), - lambda preset: select_language(preset), - default='us') - self._menu_options['mirror-region'] = \ - Selector( - _('Mirror region'), - lambda preset: select_mirror_regions(preset), - display_func=lambda x: list(x.keys()) if x else '[]', - default={}) - self._menu_options['sys-language'] = \ - Selector( - _('Locale language'), - lambda preset: select_locale_lang(preset), - default='en_US') - self._menu_options['sys-encoding'] = \ - Selector( - _('Locale encoding'), - lambda preset: select_locale_enc(preset), - default='UTF-8') - self._menu_options['harddrives'] = \ - Selector( - _('Drive(s)'), - lambda preset: self._select_harddrives(preset), - display_func=lambda x: f'{len(x)} ' + str(_('Drive(s)')) if x is not None and len(x) > 0 else '', - preview_func=self._prev_harddrives, - ) - self._menu_options['disk_layouts'] = \ - Selector( - _('Disk layout'), - lambda preset: select_disk_layout( - preset, - storage['arguments'].get('harddrives', []), - storage['arguments'].get('advanced', False) - ), - preview_func=self._prev_disk_layouts, - display_func=lambda x: self._display_disk_layout(x), - dependencies=['harddrives']) - self._menu_options['disk_encryption'] = \ - Selector( - _('Disk encryption'), - lambda preset: self._disk_encryption(preset), - preview_func=self._prev_disk_encryption, - display_func=lambda x: self._display_disk_encryption(x), - dependencies=['disk_layouts']) - self._menu_options['swap'] = \ - Selector( - _('Swap'), - lambda preset: ask_for_swap(preset), - default=True) - self._menu_options['bootloader'] = \ - Selector( - _('Bootloader'), - lambda preset: ask_for_bootloader(storage['arguments'].get('advanced', False),preset), - default="systemd-bootctl" if has_uefi() else "grub-install") - self._menu_options['hostname'] = \ - Selector( - _('Hostname'), - ask_hostname, - default='archlinux') - # root password won't have preset value - self._menu_options['!root-password'] = \ - Selector( - _('Root password'), - lambda preset:self._set_root_password(), - display_func=lambda x: secret(x) if x else 'None') - self._menu_options['!users'] = \ - Selector( - _('User account'), - lambda x: self._create_user_account(x), - default={}, - display_func=lambda x: f'{len(x)} {_("User(s)")}' if len(x) > 0 else None, - preview_func=self._prev_users) - self._menu_options['profile'] = \ - Selector( - _('Profile'), - lambda preset: self._select_profile(preset), - display_func=lambda x: x if x else 'None' - ) - self._menu_options['audio'] = \ - Selector( - _('Audio'), - lambda preset: ask_for_audio_selection(is_desktop_profile(storage['arguments'].get('profile', None)),preset), - display_func=lambda x: x if x else 'None', - default=None - ) - - self._menu_options['parallel downloads'] = \ - Selector( - _('Parallel Downloads'), - add_number_of_parrallel_downloads, - display_func=lambda x: x if x else '0', - default=0 - ) - - self._menu_options['kernels'] = \ - Selector( - _('Kernels'), - lambda preset: select_kernel(preset), - default=['linux']) - self._menu_options['packages'] = \ - Selector( - _('Additional packages'), - # lambda x: ask_additional_packages_to_install(storage['arguments'].get('packages', None)), - ask_additional_packages_to_install, - default=[]) - self._menu_options['additional-repositories'] = \ - Selector( - _('Optional repositories'), - select_additional_repositories, - default=[]) - self._menu_options['nic'] = \ - Selector( - _('Network configuration'), - ask_to_configure_network, - display_func=lambda x: self._display_network_conf(x), - preview_func=self._prev_network_config, - default={}) - self._menu_options['timezone'] = \ - Selector( - _('Timezone'), - lambda preset: ask_for_a_timezone(preset), - default='UTC') - self._menu_options['ntp'] = \ - Selector( - _('Automatic time sync (NTP)'), - lambda preset: self._select_ntp(preset), - default=True) - self._menu_options['__separator__'] = \ - Selector('') - self._menu_options['save_config'] = \ - Selector( - _('Save configuration'), - lambda preset: save_config(self._data_store), - no_store=True) - self._menu_options['install'] = \ - Selector( - self._install_text(), - exec_func=lambda n,v: True if len(self._missing_configs()) == 0 else False, - preview_func=self._prev_install_missing_config, - no_store=True) - - self._menu_options['abort'] = Selector(_('Abort'), exec_func=lambda n,v:exit(1)) - - def _update_install_text(self, name :Optional[str] = None, result :Any = None): - text = self._install_text() - self._menu_options['install'].update_description(text) - - def post_callback(self,name :Optional[str] = None ,result :Any = None): - self._update_install_text(name, result) - - def _install_text(self): - missing = len(self._missing_configs()) - if missing > 0: - return _('Install ({} config(s) missing)').format(missing) - return _('Install') - - def _display_network_conf(self, cur_value: Union[NetworkConfiguration, List[NetworkConfiguration]]) -> str: - if not cur_value: - return _('Not configured, unavailable unless setup manually') - else: - if isinstance(cur_value, list): - return str(_('Configured {} interfaces')).format(len(cur_value)) - else: - return str(cur_value) - - def _disk_encryption(self, preset: Optional[DiskEncryption]) -> Optional[DiskEncryption]: - data_store: Dict[str, Any] = {} - - selector = self._menu_options['disk_layouts'] - - if selector.has_selection(): - layouts: Dict[str, Dict[str, Any]] = selector.current_selection - else: - # this should not happen as the encryption menu has the disk layout as dependency - raise ValueError('No disk layout specified') - - disk_encryption = DiskEncryptionMenu(data_store, preset, layouts).run() - return disk_encryption - - def _prev_network_config(self) -> Optional[str]: - selector = self._menu_options['nic'] - if selector.has_selection(): - ifaces = selector.current_selection - if isinstance(ifaces, list): - return FormattedOutput.as_table(ifaces) - return None - - def _prev_harddrives(self) -> Optional[str]: - selector = self._menu_options['harddrives'] - if selector.has_selection(): - drives = selector.current_selection - return FormattedOutput.as_table(drives) - return None - - def _prev_disk_layouts(self) -> Optional[str]: - selector = self._menu_options['disk_layouts'] - if selector.has_selection(): - layouts: Dict[str, Dict[str, Any]] = selector.current_selection - - output = '' - for device, layout in layouts.items(): - output += f'{_("Device")}: {device}\n\n' - output += current_partition_layout(layout['partitions'], with_title=False) - output += '\n\n' - - return output.rstrip() - - return None - - def _display_disk_layout(self, current_value: Optional[Dict[str, Any]]) -> str: - if current_value: - total_partitions = [entry['partitions'] for entry in current_value.values()] - total_nr = sum([len(p) for p in total_partitions]) - return f'{total_nr} {_("Partitions")}' - return '' - - def _prev_disk_encryption(self) -> Optional[str]: - selector = self._menu_options['disk_encryption'] - if selector.has_selection(): - encryption: DiskEncryption = selector.current_selection - - enc_type = EncryptionType.type_to_text(encryption.encryption_type) - output = str(_('Encryption type')) + f': {enc_type}\n' - output += str(_('Password')) + f': {secret(encryption.encryption_password)}\n' - - if encryption.all_partitions: - output += 'Partitions: {} selected'.format(len(encryption.all_partitions)) + '\n' - - if encryption.hsm_device: - output += f'HSM: {encryption.hsm_device.manufacturer}' - - return output - - return None - - def _display_disk_encryption(self, current_value: Optional[DiskEncryption]) -> str: - if current_value: - return EncryptionType.type_to_text(current_value.encryption_type) - return '' - - def _prev_install_missing_config(self) -> Optional[str]: - if missing := self._missing_configs(): - text = str(_('Missing configurations:\n')) - for m in missing: - text += f'- {m}\n' - return text[:-1] # remove last new line - return None - - def _prev_users(self) -> Optional[str]: - selector = self._menu_options['!users'] - if selector.has_selection(): - users: List[User] = selector.current_selection - return FormattedOutput.as_table(users) - return None - - def _missing_configs(self) -> List[str]: - def check(s): - return self._menu_options.get(s).has_selection() - - def has_superuser() -> bool: - users = self._menu_options['!users'].current_selection - return any([u.sudo for u in users]) - - missing = [] - if not check('bootloader'): - missing += ['Bootloader'] - if not check('hostname'): - missing += ['Hostname'] - if not check('!root-password') and not has_superuser(): - missing += [str(_('Either root-password or at least 1 user with sudo privileges must be specified'))] - if self._disk_check: - if not check('harddrives'): - missing += [str(_('Drive(s)'))] - if check('harddrives'): - if not self._menu_options['harddrives'].is_empty() and not check('disk_layouts'): - missing += [str(_('Disk layout'))] - - return missing - - def _set_root_password(self) -> Optional[str]: - prompt = str(_('Enter root password (leave blank to disable root): ')) - password = get_password(prompt=prompt) - return password - - # def _select_encrypted_password(self) -> Optional[str]: - # if passwd := get_password(prompt=str(_('Enter disk encryption password (leave blank for no encryption): '))): - # return passwd - # return None - - def _select_ntp(self, preset :bool = True) -> bool: - ntp = ask_ntp(preset) - - value = str(ntp).lower() - SysCommand(f'timedatectl set-ntp {value}') - - return ntp - - def _select_harddrives(self, old_harddrives: List[str] = []) -> List: - harddrives = select_harddrives(old_harddrives) - - if harddrives is not None: - if len(harddrives) == 0: - prompt = _( - "You decided to skip harddrive selection\nand will use whatever drive-setup is mounted at {} (experimental)\n" - "WARNING: Archinstall won't check the suitability of this setup\n" - "Do you wish to continue?" - ).format(storage['MOUNT_POINT']) - - choice = Menu(prompt, Menu.yes_no(), default_option=Menu.yes(), skip=False).run() - - if choice.value == Menu.no(): - self._disk_check = True - return self._select_harddrives(old_harddrives) - else: - self._disk_check = False - - # in case the harddrives got changed we have to reset the disk layout as well - if old_harddrives != harddrives: - self._menu_options['disk_layouts'].set_current_selection(None) - storage['arguments']['disk_layouts'] = {} - - return harddrives - - def _select_profile(self, preset) -> Optional[Profile]: - ret: Optional[Profile] = None - profile = select_profile(preset) - - if profile is None: - if any([ - archinstall.storage.get('profile_minimal', False), - archinstall.storage.get('_selected_servers', None), - archinstall.storage.get('_desktop_profile', None), - archinstall.arguments.get('desktop-environment', None), - archinstall.arguments.get('gfx_driver_packages', None) - ]): - return preset - else: # ctrl+c was actioned and all profile settings have been reset - return None - - servers = archinstall.storage.get('_selected_servers', []) - desktop = archinstall.storage.get('_desktop_profile', None) - desktop_env = archinstall.arguments.get('desktop-environment', None) - gfx_driver = archinstall.arguments.get('gfx_driver_packages', None) - - # Check the potentially selected profiles preparations to get early checks if some additional questions are needed. - if profile and profile.has_prep_function(): - namespace = f'{profile.namespace}.py' - with profile.load_instructions(namespace=namespace) as imported: - if imported._prep_function(servers=servers, desktop=desktop, desktop_env=desktop_env, gfx_driver=gfx_driver): - ret = profile - - match ret.name: - case 'minimal': - reset = ['_selected_servers', '_desktop_profile', 'desktop-environment', 'gfx_driver_packages'] - case 'server': - reset = ['_desktop_profile', 'desktop-environment'] - case 'desktop': - reset = ['_selected_servers'] - case 'xorg': - reset = ['_selected_servers', '_desktop_profile', 'desktop-environment'] - - for r in reset: - archinstall.storage[r] = None - else: - return self._select_profile(preset) - elif profile: - ret = profile - - return ret - - def _create_user_account(self, defined_users: List[User]) -> List[User]: - users = ask_for_additional_users(defined_users=defined_users) - return users diff --git a/archinstall/lib/menu/list_manager.py b/archinstall/lib/menu/list_manager.py index 1e09d987..be31fdf0 100644 --- a/archinstall/lib/menu/list_manager.py +++ b/archinstall/lib/menu/list_manager.py @@ -34,7 +34,7 @@ class ListManager: self._data = copy.deepcopy(entries) explainer = str(_('\n Choose an object from the list, and select one of the available actions for it to execute')) - self._prompt = prompt + explainer if prompt else explainer + self._prompt = prompt if prompt else explainer self._separator = '' self._confirm_action = str(_('Confirm and exit')) @@ -44,13 +44,18 @@ class ListManager: self._base_actions = base_actions self._sub_menu_actions = sub_menu_actions - self._last_choice = None + self._last_choice: Optional[str] = None @property - def last_choice(self): + def last_choice(self) -> Optional[str]: return self._last_choice - def run(self): + def is_last_choice_cancel(self) -> bool: + if self._last_choice is not None: + return self._last_choice == self._cancel_action + return False + + def run(self) -> List[Any]: while True: # this will return a dictionary with the key as the menu entry to be displayed # and the value is the original value from the self._data container @@ -76,10 +81,11 @@ class ListManager: elif choice.value in self._terminate_actions: break else: # an entry of the existing selection was choosen - selected_entry = data_formatted[choice.value] + selected_entry = data_formatted[choice.value] # type: ignore self._run_actions_on_entry(selected_entry) - self._last_choice = choice + self._last_choice = choice.value # type: ignore + if choice.value == self._cancel_action: return self._original_data # return the original list else: @@ -122,21 +128,29 @@ class ListManager: self._data = self.handle_action(choice.value, entry, self._data) def selected_action_display(self, selection: Any) -> str: - # this will return the value to be displayed in the - # "Select an action for '{}'" string + """ + this will return the value to be displayed in the + "Select an action for '{}'" string + """ raise NotImplementedError('Please implement me in the child class') - def reformat(self, data: List[Any]) -> Dict[str, Any]: - # this should return a dictionary of display string to actual data entry - # mapping; if the value for a given display string is None it will be used - # in the header value (useful when displaying tables) + def reformat(self, data: List[Any]) -> Dict[str, Optional[Any]]: + """ + this should return a dictionary of display string to actual data entry + mapping; if the value for a given display string is None it will be used + in the header value (useful when displaying tables) + """ raise NotImplementedError('Please implement me in the child class') def handle_action(self, action: Any, entry: Optional[Any], data: List[Any]) -> List[Any]: - # this function is called when a base action or - # a specific action for an entry is triggered + """ + this function is called when a base action or + a specific action for an entry is triggered + """ raise NotImplementedError('Please implement me in the child class') - def filter_options(self, selection :Any, options :List[str]) -> List[str]: - # filter which actions to show for an specific selection + def filter_options(self, selection: Any, options: List[str]) -> List[str]: + """ + filter which actions to show for an specific selection + """ return options diff --git a/archinstall/lib/menu/menu.py b/archinstall/lib/menu/menu.py index 09685c55..44ac33a6 100644 --- a/archinstall/lib/menu/menu.py +++ b/archinstall/lib/menu/menu.py @@ -3,7 +3,7 @@ from enum import Enum, auto from os import system from typing import Dict, List, Union, Any, TYPE_CHECKING, Optional, Callable -from .simple_menu import TerminalMenu +from simple_term_menu import TerminalMenu from ..exceptions import RequirementError from ..output import log @@ -27,42 +27,56 @@ class MenuSelection: type_: MenuSelectionType value: Optional[Union[str, List[str]]] = None + @property + def single_value(self) -> Any: + return self.value + + @property + def multi_value(self) -> List[Any]: + return self.value + class Menu(TerminalMenu): @classmethod - def yes(cls): + def back(cls) -> str: + return str(_('← Back')) + + @classmethod + def yes(cls) -> str: return str(_('yes')) @classmethod - def no(cls): + def no(cls) -> str: return str(_('no')) @classmethod - def yes_no(cls): + def yes_no(cls) -> List[str]: return [cls.yes(), cls.no()] def __init__( self, - title :str, - p_options :Union[List[str], Dict[str, Any]], - skip :bool = True, - multi :bool = False, - default_option : Optional[str] = None, - sort :bool = True, - preset_values :Union[str, List[str]] = None, - cursor_index : Optional[int] = None, + title: str, + p_options: Union[List[str], Dict[str, Any]], + skip: bool = True, + multi: bool = False, + default_option: Optional[str] = None, + sort: bool = True, + preset_values: Optional[Union[str, List[str]]] = None, + cursor_index: Optional[int] = None, preview_command: Optional[Callable] = None, preview_size: float = 0.0, preview_title: str = 'Info', - header :Union[List[str],str] = None, - allow_reset :bool = False, - allow_reset_warning_msg :str = '', + header: Union[List[str],str] = None, + allow_reset: bool = False, + allow_reset_warning_msg: Optional[str] = None, clear_screen: bool = True, show_search_hint: bool = True, cycle_cursor: bool = True, clear_menu_on_exit: bool = True, - skip_empty_entries: bool = False + skip_empty_entries: bool = False, + display_back_option: bool = False, + extra_bottom_space: bool = False ): """ Creates a new menu @@ -72,7 +86,7 @@ class Menu(TerminalMenu): :param p_options: Options to be displayed in the menu to chose from; if dict is specified then the keys of such will be used as options - :type options: list, dict + :type p_options: list, dict :param skip: Indicate if the selection is not mandatory and can be skipped :type skip: bool @@ -101,16 +115,17 @@ class Menu(TerminalMenu): :param preview_title: Title of the preview window :type preview_title: str - param header: one or more header lines for the menu - type param: string or list + :param header: one or more header lines for the menu + :type header: string or list - param raise_error_on_interrupt: This will explicitly handle a ctrl+c instead and return that specific state - type param: bool + :param allow_reset: This will explicitly handle a ctrl+c instead and return that specific state + :type allow_reset: bool - param raise_error_warning_msg: If raise_error_on_interrupt is True and this is non-empty, there will be a warning with a user confirmation displayed - type param: str + param allow_reset_warning_msg: If raise_error_on_interrupt is True the warnign is set, a user confirmation is displayed + type allow_reset_warning_msg: str - :param kwargs : any SimpleTerminal parameter + :param extra_bottom_space: Add an extra empty line at the end of the menu + :type extra_bottom_space: bool """ # we guarantee the inmutability of the options outside the class. # an unknown number of iterables (.keys(),.values(),generator,...) can't be directly copied, in this case @@ -152,7 +167,6 @@ class Menu(TerminalMenu): self._multi = multi self._raise_error_on_interrupt = allow_reset self._raise_error_warning_msg = allow_reset_warning_msg - self._preview_command = preview_command action_info = '' if skip: @@ -182,6 +196,14 @@ class Menu(TerminalMenu): default = f'{default_option} {self._default_str}' self._menu_options = [default] + [o for o in self._menu_options if default_option != o] + if display_back_option and not multi and skip: + skip_empty_entries = True + self._menu_options += ['', self.back()] + + if extra_bottom_space: + skip_empty_entries = True + self._menu_options += [''] + self._preselection(preset_values,cursor_index) cursor = "> " @@ -194,13 +216,10 @@ class Menu(TerminalMenu): menu_cursor=cursor, menu_cursor_style=main_menu_cursor_style, menu_highlight_style=main_menu_style, - # cycle_cursor=True, - # clear_screen=True, multi_select=multi, - # show_search_hint=True, preselected_entries=self.preset_values, cursor_index=self.cursor_index, - preview_command=lambda x: self._preview_wrapper(preview_command, x), + preview_command=lambda x: self._show_preview(preview_command, x), preview_size=preview_size, preview_title=preview_title, raise_error_on_interrupt=self._raise_error_on_interrupt, @@ -212,6 +231,17 @@ class Menu(TerminalMenu): skip_empty_entries=skip_empty_entries ) + def _show_preview(self, preview_command: Optional[Callable], selection: str) -> Optional[str]: + if selection == self.back(): + return None + + if preview_command: + if self._default_option is not None and f'{self._default_option} {self._default_str}' == selection: + selection = self._default_option + return preview_command(selection) + + return None + def _show(self) -> MenuSelection: try: idx = self.show() @@ -225,39 +255,37 @@ class Menu(TerminalMenu): return elem if idx is not None: - if isinstance(idx, (list, tuple)): + if isinstance(idx, (list, tuple)): # on multi selection results = [] for i in idx: option = check_default(self._menu_options[i]) results.append(option) return MenuSelection(type_=MenuSelectionType.Selection, value=results) - else: + else: # on single selection result = check_default(self._menu_options[idx]) return MenuSelection(type_=MenuSelectionType.Selection, value=result) else: return MenuSelection(type_=MenuSelectionType.Skip) - def _preview_wrapper(self, preview_command: Optional[Callable], current_selection: str) -> Optional[str]: - if preview_command: - if self._default_option is not None and f'{self._default_option} {self._default_str}' == current_selection: - current_selection = self._default_option - return preview_command(current_selection) - return None - def run(self) -> MenuSelection: - ret = self._show() + selection = self._show() - if ret.type_ == MenuSelectionType.Reset: - if self._raise_error_on_interrupt and len(self._raise_error_warning_msg) > 0: + if selection.type_ == MenuSelectionType.Reset: + if self._raise_error_on_interrupt and self._raise_error_warning_msg is not None: response = Menu(self._raise_error_warning_msg, Menu.yes_no(), skip=False).run() if response.value == Menu.no(): return self.run() - elif ret.type_ is MenuSelectionType.Skip: + elif selection.type_ is MenuSelectionType.Skip: if not self._skip: system('clear') return self.run() - return ret + if selection.type_ == MenuSelectionType.Selection: + if selection.value == self.back(): + selection.type_ = MenuSelectionType.Skip + selection.value = None + + return selection def set_cursor_pos(self,pos :int): if pos and 0 < pos < len(self._menu_entries): diff --git a/archinstall/lib/menu/simple_menu.py b/archinstall/lib/menu/simple_menu.py deleted file mode 100644 index 1980e2ce..00000000 --- a/archinstall/lib/menu/simple_menu.py +++ /dev/null @@ -1,2002 +0,0 @@ -""" -This file is copied over from the simple-term-menu project -(https://github.com/IngoMeyer441/simple-term-menu) -In order to comply with installation methods of Arch Linux. -We here by copy the MIT license attached to the project at the time of copy: - -Copyright 2021 Forschungszentrum Jülich GmbH - -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in -the Software without restriction, including without limitation the rights to -use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software is furnished to do so, -subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -""" -import argparse -import copy -import ctypes -import io -import locale -import os -import platform -import re -import shlex -import signal -import string -import subprocess -import sys -from locale import getlocale -from types import FrameType -from typing import ( - Any, - Callable, - Dict, - Iterable, - Iterator, - List, - Match, - Optional, - Pattern, - Sequence, - Set, - TextIO, - Tuple, - Union, - cast, -) - -try: - import termios -except ImportError as e: - raise NotImplementedError('"{}" is currently not supported.'.format(platform.system())) from e - -__author__ = "Ingo Meyer" -__email__ = "i.meyer@fz-juelich.de" -__copyright__ = "Copyright © 2021 Forschungszentrum Jülich GmbH. All rights reserved." -__license__ = "MIT" -__version_info__ = (1, 5, 0) -__version__ = ".".join(map(str, __version_info__)) - - -DEFAULT_ACCEPT_KEYS = ("enter",) -DEFAULT_CLEAR_MENU_ON_EXIT = True -DEFAULT_CLEAR_SCREEN = False -DEFAULT_CYCLE_CURSOR = True -DEFAULT_EXIT_ON_SHORTCUT = True -DEFAULT_MENU_CURSOR = "> " -DEFAULT_MENU_CURSOR_STYLE = ("fg_red", "bold") -DEFAULT_MENU_HIGHLIGHT_STYLE = ("standout",) -DEFAULT_MULTI_SELECT = False -DEFAULT_MULTI_SELECT_CURSOR = "[*] " -DEFAULT_MULTI_SELECT_CURSOR_BRACKETS_STYLE = ("fg_gray",) -DEFAULT_MULTI_SELECT_CURSOR_STYLE = ("fg_yellow", "bold") -DEFAULT_MULTI_SELECT_KEYS = (" ", "tab") -DEFAULT_MULTI_SELECT_SELECT_ON_ACCEPT = True -DEFAULT_PREVIEW_BORDER = True -DEFAULT_PREVIEW_SIZE = 0.25 -DEFAULT_PREVIEW_TITLE = "preview" -DEFAULT_QUIT_KEYS = ("escape", "q") -DEFAULT_SEARCH_CASE_SENSITIVE = False -DEFAULT_SEARCH_HIGHLIGHT_STYLE = ("fg_black", "bg_yellow", "bold") -DEFAULT_SEARCH_KEY = "/" -DEFAULT_SHORTCUT_BRACKETS_HIGHLIGHT_STYLE = ("fg_gray",) -DEFAULT_SHORTCUT_KEY_HIGHLIGHT_STYLE = ("fg_blue",) -DEFAULT_SHOW_MULTI_SELECT_HINT = False -DEFAULT_SHOW_SEARCH_HINT = False -DEFAULT_SHOW_SHORTCUT_HINTS = False -DEFAULT_SHOW_SHORTCUT_HINTS_IN_STATUS_BAR = True -DEFAULT_STATUS_BAR_BELOW_PREVIEW = False -DEFAULT_STATUS_BAR_STYLE = ("fg_yellow", "bg_black") -MIN_VISIBLE_MENU_ENTRIES_COUNT = 3 - - -class InvalidParameterCombinationError(Exception): - pass - - -class InvalidStyleError(Exception): - pass - - -class NoMenuEntriesError(Exception): - pass - - -class PreviewCommandFailedError(Exception): - pass - - -class UnknownMenuEntryError(Exception): - pass - - -def get_locale() -> str: - user_locale = locale.getlocale()[1] - if user_locale is None: - return "ascii" - else: - return user_locale.lower() - - -def wcswidth(text: str) -> int: - if not hasattr(wcswidth, "libc"): - if platform.system() == "Darwin": - wcswidth.libc = ctypes.cdll.LoadLibrary("libSystem.dylib") # type: ignore - else: - wcswidth.libc = ctypes.cdll.LoadLibrary("libc.so.6") # type: ignore - user_locale = get_locale() - # First replace any null characters with the unicode replacement character (U+FFFD) since they cannot be passed - # in a `c_wchar_p` - encoded_text = text.replace("\0", "\uFFFD").encode(encoding=user_locale, errors="replace") - return wcswidth.libc.wcswidth( # type: ignore - ctypes.c_wchar_p(encoded_text.decode(encoding=user_locale)), len(encoded_text) - ) - - -def static_variables(**variables: Any) -> Callable[[Callable[..., Any]], Callable[..., Any]]: - def decorator(f: Callable[..., Any]) -> Callable[..., Any]: - for key, value in variables.items(): - setattr(f, key, value) - return f - - return decorator - - -class BoxDrawingCharacters: - if getlocale()[1] == "UTF-8": - # Unicode box characters - horizontal = "─" - vertical = "│" - upper_left = "┌" - upper_right = "┐" - lower_left = "└" - lower_right = "┘" - else: - # ASCII box characters - horizontal = "-" - vertical = "|" - upper_left = "+" - upper_right = "+" - lower_left = "+" - lower_right = "+" - - -class TerminalMenu: - class Search: - def __init__( - self, - menu_entries: Iterable[str], - search_text: Optional[str] = None, - case_senitive: bool = False, - show_search_hint: bool = False, - ): - self._menu_entries = menu_entries - self._case_sensitive = case_senitive - self._show_search_hint = show_search_hint - self._matches = [] # type: List[Tuple[int, Match[str]]] - self._search_regex = None # type: Optional[Pattern[str]] - self._change_callback = None # type: Optional[Callable[[], None]] - # Use the property setter since it has some more logic - self.search_text = search_text - - def _update_matches(self) -> None: - if self._search_regex is None: - self._matches = [] - else: - matches = [] - for i, menu_entry in enumerate(self._menu_entries): - match_obj = self._search_regex.search(menu_entry) - if match_obj: - matches.append((i, match_obj)) - self._matches = matches - - @property - def matches(self) -> List[Tuple[int, Match[str]]]: - return list(self._matches) - - @property - def search_regex(self) -> Optional[Pattern[str]]: - return self._search_regex - - @property - def search_text(self) -> Optional[str]: - return self._search_text - - @search_text.setter - def search_text(self, text: Optional[str]) -> None: - self._search_text = text - search_text = self._search_text - self._search_regex = None - while search_text and self._search_regex is None: - try: - self._search_regex = re.compile(search_text, flags=re.IGNORECASE if not self._case_sensitive else 0) - except re.error: - search_text = search_text[:-1] - self._update_matches() - if self._change_callback: - self._change_callback() - - @property - def change_callback(self) -> Optional[Callable[[], None]]: - return self._change_callback - - @change_callback.setter - def change_callback(self, callback: Optional[Callable[[], None]]) -> None: - self._change_callback = callback - - @property - def occupied_lines_count(self) -> int: - if not self and not self._show_search_hint: - return 0 - else: - return 1 - - def __bool__(self) -> bool: - return self._search_text is not None - - def __contains__(self, menu_index: int) -> bool: - return any(i == menu_index for i, _ in self._matches) - - def __len__(self) -> int: - return wcswidth(self._search_text) if self._search_text is not None else 0 - - class Selection: - def __init__(self, num_menu_entries: int, preselected_indices: Optional[Iterable[int]] = None): - self._num_menu_entries = num_menu_entries - self._selected_menu_indices = set(preselected_indices) if preselected_indices is not None else set() - - def clear(self) -> None: - self._selected_menu_indices.clear() - - def add(self, menu_index: int) -> None: - self[menu_index] = True - - def remove(self, menu_index: int) -> None: - self[menu_index] = False - - def toggle(self, menu_index: int) -> bool: - self[menu_index] = menu_index not in self._selected_menu_indices - return self[menu_index] - - def __bool__(self) -> bool: - return bool(self._selected_menu_indices) - - def __contains__(self, menu_index: int) -> bool: - return menu_index in self._selected_menu_indices - - def __getitem__(self, menu_index: int) -> bool: - return menu_index in self._selected_menu_indices - - def __setitem__(self, menu_index: int, is_selected: bool) -> None: - if is_selected: - self._selected_menu_indices.add(menu_index) - else: - self._selected_menu_indices.remove(menu_index) - - def __iter__(self) -> Iterator[int]: - return iter(self._selected_menu_indices) - - @property - def selected_menu_indices(self) -> Tuple[int, ...]: - return tuple(sorted(self._selected_menu_indices)) - - class View: - def __init__( - self, - menu_entries: Iterable[str], - search: "TerminalMenu.Search", - selection: "TerminalMenu.Selection", - viewport: "TerminalMenu.Viewport", - cycle_cursor: bool = True, - skip_indices: List[int] = [], - ): - self._menu_entries = list(menu_entries) - self._search = search - self._selection = selection - self._viewport = viewport - self._cycle_cursor = cycle_cursor - self._active_displayed_index = None # type: Optional[int] - self._skip_indices = skip_indices - self.update_view() - - def update_view(self) -> None: - if self._search and self._search.search_text != "": - self._displayed_index_to_menu_index = tuple(i for i, match_obj in self._search.matches) - else: - self._displayed_index_to_menu_index = tuple(range(len(self._menu_entries))) - self._menu_index_to_displayed_index = { - menu_index: displayed_index - for displayed_index, menu_index in enumerate(self._displayed_index_to_menu_index) - } - self._active_displayed_index = 0 if self._displayed_index_to_menu_index else None - self._viewport.search_lines_count = self._search.occupied_lines_count - self._viewport.keep_visible(self._active_displayed_index) - - def increment_active_index(self) -> None: - if self._active_displayed_index is not None: - if self._active_displayed_index + 1 < len(self._displayed_index_to_menu_index): - self._active_displayed_index += 1 - elif self._cycle_cursor: - self._active_displayed_index = 0 - self._viewport.keep_visible(self._active_displayed_index) - - if self._active_displayed_index in self._skip_indices: - self.increment_active_index() - - def decrement_active_index(self) -> None: - if self._active_displayed_index is not None: - if self._active_displayed_index > 0: - self._active_displayed_index -= 1 - elif self._cycle_cursor: - self._active_displayed_index = len(self._displayed_index_to_menu_index) - 1 - self._viewport.keep_visible(self._active_displayed_index) - - if self._active_displayed_index in self._skip_indices: - self.decrement_active_index() - - def is_visible(self, menu_index: int) -> bool: - return menu_index in self._menu_index_to_displayed_index and ( - self._viewport.lower_index - <= self._menu_index_to_displayed_index[menu_index] - <= self._viewport.upper_index - ) - - def convert_menu_index_to_displayed_index(self, menu_index: int) -> Optional[int]: - if menu_index in self._menu_index_to_displayed_index: - return self._menu_index_to_displayed_index[menu_index] - else: - return None - - def convert_displayed_index_to_menu_index(self, displayed_index: int) -> int: - return self._displayed_index_to_menu_index[displayed_index] - - @property - def active_menu_index(self) -> Optional[int]: - if self._active_displayed_index is not None: - return self._displayed_index_to_menu_index[self._active_displayed_index] - else: - return None - - @active_menu_index.setter - def active_menu_index(self, value: int) -> None: - self._selected_index = value - self._active_displayed_index = [ - displayed_index - for displayed_index, menu_index in enumerate(self._displayed_index_to_menu_index) - if menu_index == value - ][0] - self._viewport.keep_visible(self._active_displayed_index) - - @property - def active_displayed_index(self) -> Optional[int]: - return self._active_displayed_index - - @property - def displayed_selected_indices(self) -> List[int]: - return [ - self._menu_index_to_displayed_index[selected_index] - for selected_index in self._selection - if selected_index in self._menu_index_to_displayed_index - ] - - def __bool__(self) -> bool: - return self._active_displayed_index is not None - - def __iter__(self) -> Iterator[Tuple[int, int, str]]: - for displayed_index, menu_index in enumerate(self._displayed_index_to_menu_index): - if self._viewport.lower_index <= displayed_index <= self._viewport.upper_index: - yield (displayed_index, menu_index, self._menu_entries[menu_index]) - - class Viewport: - def __init__( - self, - num_menu_entries: int, - title_lines_count: int, - status_bar_lines_count: int, - preview_lines_count: int, - search_lines_count: int, - ): - self._num_menu_entries = num_menu_entries - self._title_lines_count = title_lines_count - self._status_bar_lines_count = status_bar_lines_count - # Use the property setter since it has some more logic - self.preview_lines_count = preview_lines_count - self.search_lines_count = search_lines_count - self._num_lines = self._calculate_num_lines() - self._viewport = (0, min(self._num_menu_entries, self._num_lines) - 1) - self.keep_visible(cursor_position=None, refresh_terminal_size=False) - - def _calculate_num_lines(self) -> int: - return ( - TerminalMenu._num_lines() - - self._title_lines_count - - self._status_bar_lines_count - - self._preview_lines_count - - self._search_lines_count - ) - - def keep_visible(self, cursor_position: Optional[int], refresh_terminal_size: bool = True) -> None: - # Treat `cursor_position=None` like `cursor_position=0` - if cursor_position is None: - cursor_position = 0 - if refresh_terminal_size: - self.update_terminal_size() - if self._viewport[0] <= cursor_position <= self._viewport[1]: - # Cursor is already visible - return - if cursor_position < self._viewport[0]: - scroll_num = cursor_position - self._viewport[0] - else: - scroll_num = cursor_position - self._viewport[1] - self._viewport = (self._viewport[0] + scroll_num, self._viewport[1] + scroll_num) - - def update_terminal_size(self) -> None: - num_lines = self._calculate_num_lines() - if num_lines != self._num_lines: - # First let the upper index grow or shrink - upper_index = min(num_lines, self._num_menu_entries) - 1 - # Then, use as much space as possible for the `lower_index` - lower_index = max(0, upper_index - num_lines) - self._viewport = (lower_index, upper_index) - self._num_lines = num_lines - - @property - def lower_index(self) -> int: - return self._viewport[0] - - @property - def upper_index(self) -> int: - return self._viewport[1] - - @property - def viewport(self) -> Tuple[int, int]: - return self._viewport - - @property - def size(self) -> int: - return self._viewport[1] - self._viewport[0] + 1 - - @property - def num_menu_entries(self) -> int: - return self._num_menu_entries - - @property - def title_lines_count(self) -> int: - return self._title_lines_count - - @property - def status_bar_lines_count(self) -> int: - return self._status_bar_lines_count - - @status_bar_lines_count.setter - def status_bar_lines_count(self, value: int) -> None: - self._status_bar_lines_count = value - - @property - def preview_lines_count(self) -> int: - return self._preview_lines_count - - @preview_lines_count.setter - def preview_lines_count(self, value: int) -> None: - self._preview_lines_count = min( - value if value >= 3 else 0, - TerminalMenu._num_lines() - - self._title_lines_count - - self._status_bar_lines_count - - MIN_VISIBLE_MENU_ENTRIES_COUNT, - ) - - @property - def search_lines_count(self) -> int: - return self._search_lines_count - - @search_lines_count.setter - def search_lines_count(self, value: int) -> None: - self._search_lines_count = value - - @property - def must_scroll(self) -> bool: - return self._num_menu_entries > self._num_lines - - _codename_to_capname = { - "bg_black": "setab 0", - "bg_blue": "setab 4", - "bg_cyan": "setab 6", - "bg_gray": "setab 7", - "bg_green": "setab 2", - "bg_purple": "setab 5", - "bg_red": "setab 1", - "bg_yellow": "setab 3", - "bold": "bold", - "clear": "clear", - "colors": "colors", - "cursor_down": "cud1", - "cursor_invisible": "civis", - "cursor_left": "cub1", - "cursor_right": "cuf1", - "cursor_up": "cuu1", - "cursor_visible": "cnorm", - "delete_line": "dl1", - "down": "kcud1", - "enter_application_mode": "smkx", - "exit_application_mode": "rmkx", - "fg_black": "setaf 0", - "fg_blue": "setaf 4", - "fg_cyan": "setaf 6", - "fg_gray": "setaf 7", - "fg_green": "setaf 2", - "fg_purple": "setaf 5", - "fg_red": "setaf 1", - "fg_yellow": "setaf 3", - "italics": "sitm", - "reset_attributes": "sgr0", - "standout": "smso", - "underline": "smul", - "up": "kcuu1", - } - _name_to_control_character = { - "backspace": "", # Is assigned later in `self._init_backspace_control_character` - "ctrl-j": "\012", - "ctrl-k": "\013", - "enter": "\015", - "escape": "\033", - "tab": "\t", - } - _codenames = tuple(_codename_to_capname.keys()) - _codename_to_terminal_code = None # type: Optional[Dict[str, str]] - _terminal_code_to_codename = None # type: Optional[Dict[str, str]] - - def __init__( - self, - menu_entries: Iterable[str], - *, - accept_keys: Iterable[str] = DEFAULT_ACCEPT_KEYS, - clear_menu_on_exit: bool = DEFAULT_CLEAR_MENU_ON_EXIT, - clear_screen: bool = DEFAULT_CLEAR_SCREEN, - cursor_index: Optional[int] = None, - cycle_cursor: bool = DEFAULT_CYCLE_CURSOR, - exit_on_shortcut: bool = DEFAULT_EXIT_ON_SHORTCUT, - menu_cursor: Optional[str] = DEFAULT_MENU_CURSOR, - menu_cursor_style: Optional[Iterable[str]] = DEFAULT_MENU_CURSOR_STYLE, - menu_highlight_style: Optional[Iterable[str]] = DEFAULT_MENU_HIGHLIGHT_STYLE, - multi_select: bool = DEFAULT_MULTI_SELECT, - multi_select_cursor: str = DEFAULT_MULTI_SELECT_CURSOR, - multi_select_cursor_brackets_style: Optional[Iterable[str]] = DEFAULT_MULTI_SELECT_CURSOR_BRACKETS_STYLE, - multi_select_cursor_style: Optional[Iterable[str]] = DEFAULT_MULTI_SELECT_CURSOR_STYLE, - multi_select_empty_ok: bool = False, - multi_select_keys: Optional[Iterable[str]] = DEFAULT_MULTI_SELECT_KEYS, - multi_select_select_on_accept: bool = DEFAULT_MULTI_SELECT_SELECT_ON_ACCEPT, - preselected_entries: Optional[Iterable[Union[str, int]]] = None, - preview_border: bool = DEFAULT_PREVIEW_BORDER, - preview_command: Optional[Union[str, Callable[[str], str]]] = None, - preview_size: float = DEFAULT_PREVIEW_SIZE, - preview_title: str = DEFAULT_PREVIEW_TITLE, - quit_keys: Iterable[str] = DEFAULT_QUIT_KEYS, - raise_error_on_interrupt: bool = False, - search_case_sensitive: bool = DEFAULT_SEARCH_CASE_SENSITIVE, - search_highlight_style: Optional[Iterable[str]] = DEFAULT_SEARCH_HIGHLIGHT_STYLE, - search_key: Optional[str] = DEFAULT_SEARCH_KEY, - shortcut_brackets_highlight_style: Optional[Iterable[str]] = DEFAULT_SHORTCUT_BRACKETS_HIGHLIGHT_STYLE, - shortcut_key_highlight_style: Optional[Iterable[str]] = DEFAULT_SHORTCUT_KEY_HIGHLIGHT_STYLE, - show_multi_select_hint: bool = DEFAULT_SHOW_MULTI_SELECT_HINT, - show_multi_select_hint_text: Optional[str] = None, - show_search_hint: bool = DEFAULT_SHOW_SEARCH_HINT, - show_search_hint_text: Optional[str] = None, - show_shortcut_hints: bool = DEFAULT_SHOW_SHORTCUT_HINTS, - show_shortcut_hints_in_status_bar: bool = DEFAULT_SHOW_SHORTCUT_HINTS_IN_STATUS_BAR, - skip_empty_entries: bool = False, - status_bar: Optional[Union[str, Iterable[str], Callable[[str], str]]] = None, - status_bar_below_preview: bool = DEFAULT_STATUS_BAR_BELOW_PREVIEW, - status_bar_style: Optional[Iterable[str]] = DEFAULT_STATUS_BAR_STYLE, - title: Optional[Union[str, Iterable[str]]] = None - ): - def extract_shortcuts_menu_entries_and_preview_arguments( - entries: Iterable[str], - ) -> Tuple[List[str], List[Optional[str]], List[Optional[str]], List[int]]: - separator_pattern = re.compile(r"([^\\])\|") - escaped_separator_pattern = re.compile(r"\\\|") - menu_entry_pattern = re.compile(r"^(?:\[(\S)\]\s*)?([^\x1F]+)(?:\x1F([^\x1F]*))?") - shortcut_keys = [] # type: List[Optional[str]] - menu_entries = [] # type: List[str] - preview_arguments = [] # type: List[Optional[str]] - skip_indices = [] # type: List[int] - - for idx, entry in enumerate(entries): - if entry is None or (entry == "" and skip_empty_entries): - shortcut_keys.append(None) - menu_entries.append("") - preview_arguments.append(None) - skip_indices.append(idx) - else: - unit_separated_entry = escaped_separator_pattern.sub("|", separator_pattern.sub("\\1\x1F", entry)) - match_obj = menu_entry_pattern.match(unit_separated_entry) - # this is none in case the entry was an emtpy string which - # will be interpreted as a separator - assert match_obj is not None - shortcut_key = match_obj.group(1) - display_text = match_obj.group(2) - preview_argument = match_obj.group(3) - shortcut_keys.append(shortcut_key) - menu_entries.append(display_text) - preview_arguments.append(preview_argument) - - return menu_entries, shortcut_keys, preview_arguments, skip_indices - - def convert_preselected_entries_to_indices( - preselected_indices_or_entries: Iterable[Union[str, int]] - ) -> Set[int]: - menu_entry_to_indices = {} # type: Dict[str, Set[int]] - for menu_index, menu_entry in enumerate(self._menu_entries): - menu_entry_to_indices.setdefault(menu_entry, set()) - menu_entry_to_indices[menu_entry].add(menu_index) - preselected_indices = set() - for item in preselected_indices_or_entries: - if isinstance(item, int): - if 0 <= item < len(self._menu_entries): - preselected_indices.add(item) - else: - raise IndexError( - "Error: {} is outside the allowable range of 0..{}.".format( - item, len(self._menu_entries) - 1 - ) - ) - elif isinstance(item, str): - try: - preselected_indices.update(menu_entry_to_indices[item]) - except KeyError as e: - raise UnknownMenuEntryError('Pre-selection "{}" is not a valid menu entry.'.format(item)) from e - else: - raise ValueError('"preselected_entries" must either contain integers or strings.') - return preselected_indices - - def setup_title_or_status_bar_lines( - title_or_status_bar: Optional[Union[str, Iterable[str]]], - show_shortcut_hints: bool, - menu_entries: Iterable[str], - shortcut_keys: Iterable[Optional[str]], - shortcut_hints_in_parentheses: bool, - ) -> Tuple[str, ...]: - if title_or_status_bar is None: - lines = [] # type: List[str] - elif isinstance(title_or_status_bar, str): - lines = title_or_status_bar.split("\n") - else: - lines = list(title_or_status_bar) - if show_shortcut_hints: - shortcut_hints_line = self._get_shortcut_hints_line( - menu_entries, shortcut_keys, shortcut_hints_in_parentheses - ) - if shortcut_hints_line is not None: - lines.append(shortcut_hints_line) - return tuple(lines) - - ( - self._menu_entries, - self._shortcut_keys, - self._preview_arguments, - self._skip_indices, - ) = extract_shortcuts_menu_entries_and_preview_arguments(menu_entries) - self._shortcuts_defined = any(key is not None for key in self._shortcut_keys) - self._accept_keys = tuple(accept_keys) - self._clear_menu_on_exit = clear_menu_on_exit - self._clear_screen = clear_screen - self._cycle_cursor = cycle_cursor - self._multi_select_empty_ok = multi_select_empty_ok - self._exit_on_shortcut = exit_on_shortcut - self._menu_cursor = menu_cursor if menu_cursor is not None else "" - self._menu_cursor_style = tuple(menu_cursor_style) if menu_cursor_style is not None else () - self._menu_highlight_style = tuple(menu_highlight_style) if menu_highlight_style is not None else () - self._multi_select = multi_select - self._multi_select_cursor = multi_select_cursor - self._multi_select_cursor_brackets_style = ( - tuple(multi_select_cursor_brackets_style) if multi_select_cursor_brackets_style is not None else () - ) - self._multi_select_cursor_style = ( - tuple(multi_select_cursor_style) if multi_select_cursor_style is not None else () - ) - self._multi_select_keys = tuple(multi_select_keys) if multi_select_keys is not None else () - self._multi_select_select_on_accept = multi_select_select_on_accept - if preselected_entries and not self._multi_select: - raise InvalidParameterCombinationError( - "Multi-select mode must be enabled when preselected entries are given." - ) - self._preselected_indices = ( - convert_preselected_entries_to_indices(preselected_entries) if preselected_entries is not None else None - ) - self._preview_border = preview_border - self._preview_command = preview_command - self._preview_size = preview_size - self._preview_title = preview_title - self._quit_keys = tuple(quit_keys) - self._raise_error_on_interrupt = raise_error_on_interrupt - self._search_case_sensitive = search_case_sensitive - self._search_highlight_style = tuple(search_highlight_style) if search_highlight_style is not None else () - self._search_key = search_key - self._shortcut_brackets_highlight_style = ( - tuple(shortcut_brackets_highlight_style) if shortcut_brackets_highlight_style is not None else () - ) - self._shortcut_key_highlight_style = ( - tuple(shortcut_key_highlight_style) if shortcut_key_highlight_style is not None else () - ) - self._show_search_hint = show_search_hint - self._show_search_hint_text = show_search_hint_text - self._show_shortcut_hints = show_shortcut_hints - self._show_shortcut_hints_in_status_bar = show_shortcut_hints_in_status_bar - self._status_bar_func = None # type: Optional[Callable[[str], str]] - self._status_bar_lines = None # type: Optional[Tuple[str, ...]] - if callable(status_bar): - self._status_bar_func = status_bar - else: - self._status_bar_lines = setup_title_or_status_bar_lines( - status_bar, - show_shortcut_hints and show_shortcut_hints_in_status_bar, - self._menu_entries, - self._shortcut_keys, - False, - ) - self._status_bar_below_preview = status_bar_below_preview - self._status_bar_style = tuple(status_bar_style) if status_bar_style is not None else () - self._title_lines = setup_title_or_status_bar_lines( - title, - show_shortcut_hints and not show_shortcut_hints_in_status_bar, - self._menu_entries, - self._shortcut_keys, - True, - ) - self._show_multi_select_hint = show_multi_select_hint - self._show_multi_select_hint_text = show_multi_select_hint_text - self._chosen_accept_key = None # type: Optional[str] - self._chosen_menu_index = None # type: Optional[int] - self._chosen_menu_indices = None # type: Optional[Tuple[int, ...]] - self._paint_before_next_read = False - self._previous_displayed_menu_height = None # type: Optional[int] - self._reading_next_key = False - self._search = self.Search( - self._menu_entries, - case_senitive=self._search_case_sensitive, - show_search_hint=self._show_search_hint, - ) - self._selection = self.Selection(len(self._menu_entries), self._preselected_indices) - self._viewport = self.Viewport( - len(self._menu_entries), - len(self._title_lines), - len(self._status_bar_lines) if self._status_bar_lines is not None else 0, - 0, - 0, - ) - self._view = self.View( - self._menu_entries, self._search, self._selection, self._viewport, self._cycle_cursor, self._skip_indices - ) - if cursor_index and 0 < cursor_index < len(self._menu_entries): - self._view.active_menu_index = cursor_index - self._search.change_callback = self._view.update_view - self._old_term = None # type: Optional[List[Union[int, List[bytes]]]] - self._new_term = None # type: Optional[List[Union[int, List[bytes]]]] - self._tty_in = None # type: Optional[TextIO] - self._tty_out = None # type: Optional[TextIO] - self._user_locale = get_locale() - self._check_for_valid_styles() - # backspace can be queried from the terminal database but is unreliable, query the terminal directly instead - self._init_backspace_control_character() - self._add_missing_control_characters_for_keys(self._accept_keys) - self._add_missing_control_characters_for_keys(self._quit_keys) - self._init_terminal_codes() - - @staticmethod - def _get_shortcut_hints_line( - menu_entries: Iterable[str], - shortcut_keys: Iterable[Optional[str]], - shortcut_hints_in_parentheses: bool, - ) -> Optional[str]: - shortcut_hints_line = ", ".join( - "[{}]: {}".format(shortcut_key, menu_entry) - for shortcut_key, menu_entry in zip(shortcut_keys, menu_entries) - if shortcut_key is not None - ) - if shortcut_hints_line != "": - if shortcut_hints_in_parentheses: - return "(" + shortcut_hints_line + ")" - else: - return shortcut_hints_line - return None - - @staticmethod - def _get_keycode_for_key(key: str) -> str: - if len(key) == 1: - # One letter keys represent themselves - return key - alt_modified_regex = re.compile(r"[Aa]lt-(\S)") - ctrl_modified_regex = re.compile(r"[Cc]trl-(\S)") - match_obj = alt_modified_regex.match(key) - if match_obj: - return "\033" + match_obj.group(1) - match_obj = ctrl_modified_regex.match(key) - if match_obj: - # Ctrl + key is interpreted by terminals as the ascii code of that key minus 64 - ctrl_code_ascii = ord(match_obj.group(1).upper()) - 64 - if ctrl_code_ascii < 0: - # Interpret negative ascii codes as unsigned 7-Bit integers - ctrl_code_ascii = ctrl_code_ascii & 0x80 - 1 - return chr(ctrl_code_ascii) - raise ValueError('Cannot interpret the given key "{}".'.format(key)) - - @classmethod - def _init_backspace_control_character(self) -> None: - try: - with open("/dev/tty", "r") as tty: - stty_output = subprocess.check_output(["stty", "-a"], universal_newlines=True, stdin=tty) - name_to_keycode_regex = re.compile(r"^\s*(\S+)\s*=\s*\^(\S+)\s*$") - for field in stty_output.split(";"): - match_obj = name_to_keycode_regex.match(field) - if not match_obj: - continue - name, ctrl_code = match_obj.group(1), match_obj.group(2) - if name != "erase": - continue - self._name_to_control_character["backspace"] = self._get_keycode_for_key("ctrl-" + ctrl_code) - return - except subprocess.CalledProcessError: - pass - # Backspace control character could not be queried, assume `` (is most often used) - self._name_to_control_character["backspace"] = "\177" - - @classmethod - def _add_missing_control_characters_for_keys(cls, keys: Iterable[str]) -> None: - for key in keys: - if key not in cls._name_to_control_character and key not in string.ascii_letters: - cls._name_to_control_character[key] = cls._get_keycode_for_key(key) - - @classmethod - def _init_terminal_codes(cls) -> None: - if cls._codename_to_terminal_code is not None: - return - supported_colors = int(cls._query_terminfo_database("colors")) - cls._codename_to_terminal_code = { - codename: cls._query_terminfo_database(codename) - if not (codename.startswith("bg_") or codename.startswith("fg_")) or supported_colors >= 8 - else "" - for codename in cls._codenames - } - cls._codename_to_terminal_code.update(cls._name_to_control_character) - cls._terminal_code_to_codename = { - terminal_code: codename for codename, terminal_code in cls._codename_to_terminal_code.items() - } - - @classmethod - def _query_terminfo_database(cls, codename: str) -> str: - if codename in cls._codename_to_capname: - capname = cls._codename_to_capname[codename] - else: - capname = codename - try: - return subprocess.check_output(["tput"] + capname.split(), universal_newlines=True) - except subprocess.CalledProcessError as e: - # The return code 1 indicates a missing terminal capability - if e.returncode == 1: - return "" - raise e - - @classmethod - def _num_lines(self) -> int: - return int(self._query_terminfo_database("lines")) - - @classmethod - def _num_cols(self) -> int: - return int(self._query_terminfo_database("cols")) - - def _check_for_valid_styles(self) -> None: - invalid_styles = [] - for style_tuple in ( - self._menu_cursor_style, - self._menu_highlight_style, - self._search_highlight_style, - self._shortcut_key_highlight_style, - self._shortcut_brackets_highlight_style, - self._status_bar_style, - self._multi_select_cursor_brackets_style, - self._multi_select_cursor_style, - ): - for style in style_tuple: - if style not in self._codename_to_capname: - invalid_styles.append(style) - if invalid_styles: - if len(invalid_styles) == 1: - raise InvalidStyleError('The style "{}" does not exist.'.format(invalid_styles[0])) - else: - raise InvalidStyleError('The styles ("{}") do not exist.'.format('", "'.join(invalid_styles))) - - def _init_term(self) -> None: - # pylint: disable=unsubscriptable-object - assert self._codename_to_terminal_code is not None - self._tty_in = open("/dev/tty", "r", encoding=self._user_locale) - self._tty_out = open("/dev/tty", "w", encoding=self._user_locale, errors="replace") - self._old_term = termios.tcgetattr(self._tty_in.fileno()) - self._new_term = termios.tcgetattr(self._tty_in.fileno()) - # set the terminal to: unbuffered, no echo and no to translation (so sends instead of - # and since generates ) - self._new_term[3] = cast(int, self._new_term[3]) & ~termios.ICANON & ~termios.ECHO & ~termios.ICRNL - self._new_term[0] = cast(int, self._new_term[0]) & ~termios.ICRNL - termios.tcsetattr( - self._tty_in.fileno(), termios.TCSAFLUSH, cast(List[Union[int, List[Union[bytes, int]]]], self._new_term) - ) - # Enter terminal application mode to get expected escape codes for arrow keys - self._tty_out.write(self._codename_to_terminal_code["enter_application_mode"]) - self._tty_out.write(self._codename_to_terminal_code["cursor_invisible"]) - if self._clear_screen: - self._tty_out.write(self._codename_to_terminal_code["clear"]) - - def _reset_term(self) -> None: - # pylint: disable=unsubscriptable-object - assert self._codename_to_terminal_code is not None - assert self._tty_in is not None - assert self._tty_out is not None - assert self._old_term is not None - termios.tcsetattr( - self._tty_out.fileno(), termios.TCSAFLUSH, cast(List[Union[int, List[Union[bytes, int]]]], self._old_term) - ) - self._tty_out.write(self._codename_to_terminal_code["cursor_visible"]) - self._tty_out.write(self._codename_to_terminal_code["exit_application_mode"]) - if self._clear_screen: - self._tty_out.write(self._codename_to_terminal_code["clear"]) - self._tty_in.close() - self._tty_out.close() - - def _paint_menu(self) -> None: - def get_status_bar_lines() -> Tuple[str, ...]: - def get_multi_select_hint() -> str: - def get_string_from_keys(keys: Sequence[str]) -> str: - string_to_key = { - " ": "space", - } - keys_string = ", ".join( - "<" + string_to_key.get(accept_key, accept_key) + ">" for accept_key in keys - ) - return keys_string - - accept_keys_string = get_string_from_keys(self._accept_keys) - multi_select_keys_string = get_string_from_keys(self._multi_select_keys) - if self._show_multi_select_hint_text is not None: - return self._show_multi_select_hint_text.format( - multi_select_keys=multi_select_keys_string, accept_keys=accept_keys_string - ) - else: - return "Press {} for multi-selection and {} to {}accept".format( - multi_select_keys_string, - accept_keys_string, - "select and " if self._multi_select_select_on_accept else "", - ) - - if self._status_bar_func is not None and self._view.active_menu_index is not None: - status_bar_lines = tuple( - self._status_bar_func(self._menu_entries[self._view.active_menu_index]).strip().split("\n") - ) - if self._show_shortcut_hints and self._show_shortcut_hints_in_status_bar: - shortcut_hints_line = self._get_shortcut_hints_line(self._menu_entries, self._shortcut_keys, False) - if shortcut_hints_line is not None: - status_bar_lines += (shortcut_hints_line,) - elif self._status_bar_lines is not None: - status_bar_lines = self._status_bar_lines - else: - status_bar_lines = tuple() - if self._multi_select and self._show_multi_select_hint: - status_bar_lines += (get_multi_select_hint(),) - return status_bar_lines - - def apply_style( - style_iterable: Optional[Iterable[str]] = None, reset: bool = True, file: Optional[TextIO] = None - ) -> None: - # pylint: disable=unsubscriptable-object - assert self._codename_to_terminal_code is not None - assert self._tty_out is not None - if file is None: - file = self._tty_out - if reset or style_iterable is None: - file.write(self._codename_to_terminal_code["reset_attributes"]) - if style_iterable is not None: - for style in style_iterable: - file.write(self._codename_to_terminal_code[style]) - - def print_menu_entries() -> int: - # pylint: disable=unsubscriptable-object - assert self._codename_to_terminal_code is not None - assert self._tty_out is not None - all_cursors_width = wcswidth(self._menu_cursor) + ( - wcswidth(self._multi_select_cursor) if self._multi_select else 0 - ) - current_menu_block_displayed_height = 0 # sum all written lines - num_cols = self._num_cols() - if self._title_lines: - self._tty_out.write( - len(self._title_lines) * self._codename_to_terminal_code["cursor_up"] - + "\r" - + "\n".join( - (title_line[:num_cols] + (num_cols - wcswidth(title_line)) * " ") - for title_line in self._title_lines - ) - + "\n" - ) - shortcut_string_len = 4 if self._shortcuts_defined else 0 - displayed_index = -1 - for displayed_index, menu_index, menu_entry in self._view: - current_shortcut_key = self._shortcut_keys[menu_index] - self._tty_out.write(all_cursors_width * self._codename_to_terminal_code["cursor_right"]) - if self._shortcuts_defined: - if current_shortcut_key is not None: - apply_style(self._shortcut_brackets_highlight_style) - self._tty_out.write("[") - apply_style(self._shortcut_key_highlight_style) - self._tty_out.write(current_shortcut_key) - apply_style(self._shortcut_brackets_highlight_style) - self._tty_out.write("]") - apply_style() - else: - self._tty_out.write(3 * " ") - self._tty_out.write(" ") - if menu_index == self._view.active_menu_index: - apply_style(self._menu_highlight_style) - if self._search and self._search.search_text != "": - match_obj = self._search.matches[displayed_index][1] - self._tty_out.write( - menu_entry[: min(match_obj.start(), num_cols - all_cursors_width - shortcut_string_len)] - ) - apply_style(self._search_highlight_style) - self._tty_out.write( - menu_entry[ - match_obj.start() : min(match_obj.end(), num_cols - all_cursors_width - shortcut_string_len) - ] - ) - apply_style() - if menu_index == self._view.active_menu_index: - apply_style(self._menu_highlight_style) - self._tty_out.write( - menu_entry[match_obj.end() : num_cols - all_cursors_width - shortcut_string_len] - ) - else: - self._tty_out.write(menu_entry[: num_cols - all_cursors_width - shortcut_string_len]) - if menu_index == self._view.active_menu_index: - apply_style() - self._tty_out.write((num_cols - wcswidth(menu_entry) - all_cursors_width - shortcut_string_len) * " ") - if displayed_index < self._viewport.upper_index: - self._tty_out.write("\n") - empty_menu_lines = self._viewport.upper_index - displayed_index - self._tty_out.write( - max(0, empty_menu_lines - 1) * (num_cols * " " + "\n") + min(1, empty_menu_lines) * (num_cols * " ") - ) - self._tty_out.write("\r" + (self._viewport.size - 1) * self._codename_to_terminal_code["cursor_up"]) - current_menu_block_displayed_height += self._viewport.size - 1 # sum all written lines - return current_menu_block_displayed_height - - def print_search_line(current_menu_height: int) -> int: - # pylint: disable=unsubscriptable-object - assert self._codename_to_terminal_code is not None - assert self._tty_out is not None - current_menu_block_displayed_height = 0 - num_cols = self._num_cols() - if self._search or self._show_search_hint: - self._tty_out.write((current_menu_height + 1) * self._codename_to_terminal_code["cursor_down"]) - if self._search: - assert self._search.search_text is not None - self._tty_out.write( - ( - (self._search_key if self._search_key is not None else DEFAULT_SEARCH_KEY) - + self._search.search_text - )[:num_cols] - ) - self._tty_out.write((num_cols - len(self._search) - 1) * " ") - elif self._show_search_hint: - if self._show_search_hint_text is not None: - search_hint = self._show_search_hint_text.format(key=self._search_key)[:num_cols] - elif self._search_key is not None: - search_hint = '(Press "{key}" to search)'.format(key=self._search_key)[:num_cols] - else: - search_hint = "(Press any letter key to search)"[:num_cols] - self._tty_out.write(search_hint) - self._tty_out.write((num_cols - wcswidth(search_hint)) * " ") - if self._search or self._show_search_hint: - self._tty_out.write("\r" + (current_menu_height + 1) * self._codename_to_terminal_code["cursor_up"]) - current_menu_block_displayed_height = 1 - return current_menu_block_displayed_height - - def print_status_bar(current_menu_height: int, status_bar_lines: Tuple[str, ...]) -> int: - # pylint: disable=unsubscriptable-object - assert self._codename_to_terminal_code is not None - assert self._tty_out is not None - current_menu_block_displayed_height = 0 # sum all written lines - num_cols = self._num_cols() - if status_bar_lines: - self._tty_out.write((current_menu_height + 1) * self._codename_to_terminal_code["cursor_down"]) - apply_style(self._status_bar_style) - self._tty_out.write( - "\r" - + "\n".join( - (status_bar_line[:num_cols] + (num_cols - wcswidth(status_bar_line)) * " ") - for status_bar_line in status_bar_lines - ) - + "\r" - ) - apply_style() - self._tty_out.write( - (current_menu_height + len(status_bar_lines)) * self._codename_to_terminal_code["cursor_up"] - ) - current_menu_block_displayed_height += len(status_bar_lines) - return current_menu_block_displayed_height - - def print_preview(current_menu_height: int, preview_max_num_lines: int) -> int: - # pylint: disable=unsubscriptable-object - assert self._codename_to_terminal_code is not None - assert self._tty_out is not None - if self._preview_command is None or preview_max_num_lines < 3: - return 0 - - def get_preview_string() -> Optional[str]: - assert self._preview_command is not None - if self._view.active_menu_index is None: - return None - preview_argument = ( - self._preview_arguments[self._view.active_menu_index] - if self._preview_arguments[self._view.active_menu_index] is not None - else self._menu_entries[self._view.active_menu_index] - ) - if preview_argument == "": - return None - if isinstance(self._preview_command, str): - try: - preview_process = subprocess.Popen( - [cmd_part.format(preview_argument) for cmd_part in shlex.split(self._preview_command)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - assert preview_process.stdout is not None - preview_string = ( - io.TextIOWrapper(preview_process.stdout, encoding=self._user_locale, errors="replace") - .read() - .strip() - ) - except subprocess.CalledProcessError as e: - raise PreviewCommandFailedError( - e.stderr.decode(encoding=self._user_locale, errors="replace").strip() - ) from e - else: - preview_string = self._preview_command(preview_argument) if preview_argument is not None else "" - return preview_string - - @static_variables( - # Regex taken from https://stackoverflow.com/a/14693789/5958465 - ansi_escape_regex=re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])"), - # Modified version of https://stackoverflow.com/a/2188410/5958465 - ansi_sgr_regex=re.compile(r"\x1B\[[;\d]*m"), - ) - def strip_ansi_codes_except_styling(string: str) -> str: - stripped_string = strip_ansi_codes_except_styling.ansi_escape_regex.sub( # type: ignore - lambda match_obj: match_obj.group(0) - if strip_ansi_codes_except_styling.ansi_sgr_regex.match(match_obj.group(0)) # type: ignore - else "", - string, - ) - return cast(str, stripped_string) - - @static_variables( - regular_text_regex=re.compile(r"([^\x1B]+)(.*)"), - ansi_escape_regex=re.compile(r"(\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]))(.*)"), - ) - def limit_string_with_escape_codes(string: str, max_len: int) -> Tuple[str, int]: - if max_len <= 0: - return "", 0 - string_parts = [] - string_len = 0 - while string: - regular_text_match = limit_string_with_escape_codes.regular_text_regex.match(string) # type: ignore - if regular_text_match is not None: - regular_text = regular_text_match.group(1) - regular_text_len = wcswidth(regular_text) - if string_len + regular_text_len > max_len: - string_parts.append(regular_text[: max_len - string_len]) - string_len = max_len - break - string_parts.append(regular_text) - string_len += regular_text_len - string = regular_text_match.group(2) - else: - ansi_escape_match = limit_string_with_escape_codes.ansi_escape_regex.match( # type: ignore - string - ) - if ansi_escape_match is not None: - # Adopt the ansi escape code but do not count its length - ansi_escape_code_text = ansi_escape_match.group(1) - string_parts.append(ansi_escape_code_text) - string = ansi_escape_match.group(2) - else: - # It looks like an escape code (starts with escape), but it is something else - # -> skip the escape character and continue the loop - string_parts.append("\x1B") - string = string[1:] - return "".join(string_parts), string_len - - num_cols = self._num_cols() - try: - preview_string = get_preview_string() - if preview_string is not None: - preview_string = strip_ansi_codes_except_styling(preview_string) - except PreviewCommandFailedError as e: - preview_string = "The preview command failed with error message:\n\n" + str(e) - self._tty_out.write(current_menu_height * self._codename_to_terminal_code["cursor_down"]) - if preview_string is not None: - self._tty_out.write(self._codename_to_terminal_code["cursor_down"] + "\r") - if self._preview_border: - self._tty_out.write( - ( - BoxDrawingCharacters.upper_left - + (2 * BoxDrawingCharacters.horizontal + " " + self._preview_title)[: num_cols - 3] - + " " - + (num_cols - len(self._preview_title) - 6) * BoxDrawingCharacters.horizontal - + BoxDrawingCharacters.upper_right - )[:num_cols] - + "\n" - ) - # `finditer` can be used as a generator version of `str.join` - for i, line in enumerate( - match.group(0) for match in re.finditer(r"^.*$", preview_string, re.MULTILINE) - ): - if i >= preview_max_num_lines - (2 if self._preview_border else 0): - preview_num_lines = preview_max_num_lines - break - limited_line, limited_line_len = limit_string_with_escape_codes( - line, num_cols - (3 if self._preview_border else 0) - ) - self._tty_out.write( - ( - ((BoxDrawingCharacters.vertical + " ") if self._preview_border else "") - + limited_line - + self._codename_to_terminal_code["reset_attributes"] - + max(num_cols - limited_line_len - (3 if self._preview_border else 0), 0) * " " - + (BoxDrawingCharacters.vertical if self._preview_border else "") - ) - ) - else: - preview_num_lines = i + (3 if self._preview_border else 1) - if self._preview_border: - self._tty_out.write( - "\n" - + ( - BoxDrawingCharacters.lower_left - + (num_cols - 2) * BoxDrawingCharacters.horizontal - + BoxDrawingCharacters.lower_right - )[:num_cols] - ) - self._tty_out.write("\r") - else: - preview_num_lines = 0 - self._tty_out.write( - (current_menu_height + preview_num_lines) * self._codename_to_terminal_code["cursor_up"] - ) - return preview_num_lines - - def delete_old_menu_lines(displayed_menu_height: int) -> None: - # pylint: disable=unsubscriptable-object - assert self._codename_to_terminal_code is not None - assert self._tty_out is not None - if ( - self._previous_displayed_menu_height is not None - and self._previous_displayed_menu_height > displayed_menu_height - ): - self._tty_out.write((displayed_menu_height + 1) * self._codename_to_terminal_code["cursor_down"]) - self._tty_out.write( - (self._previous_displayed_menu_height - displayed_menu_height) - * self._codename_to_terminal_code["delete_line"] - ) - self._tty_out.write((displayed_menu_height + 1) * self._codename_to_terminal_code["cursor_up"]) - - def position_cursor() -> None: - # pylint: disable=unsubscriptable-object - assert self._codename_to_terminal_code is not None - assert self._tty_out is not None - if self._view.active_displayed_index is None: - return - - cursor_width = wcswidth(self._menu_cursor) - for displayed_index in range(self._viewport.lower_index, self._viewport.upper_index + 1): - if displayed_index == self._view.active_displayed_index: - apply_style(self._menu_cursor_style) - self._tty_out.write(self._menu_cursor) - apply_style() - else: - self._tty_out.write(cursor_width * " ") - self._tty_out.write("\r") - if displayed_index < self._viewport.upper_index: - self._tty_out.write(self._codename_to_terminal_code["cursor_down"]) - self._tty_out.write((self._viewport.size - 1) * self._codename_to_terminal_code["cursor_up"]) - - def print_multi_select_column() -> None: - # pylint: disable=unsubscriptable-object - assert self._codename_to_terminal_code is not None - assert self._tty_out is not None - if not self._multi_select: - return - - def prepare_multi_select_cursors() -> Tuple[str, str]: - bracket_characters = "([{<)]}>" - bracket_style_escape_codes_io = io.StringIO() - multi_select_cursor_style_escape_codes_io = io.StringIO() - reset_codes_io = io.StringIO() - apply_style(self._multi_select_cursor_brackets_style, file=bracket_style_escape_codes_io) - apply_style(self._multi_select_cursor_style, file=multi_select_cursor_style_escape_codes_io) - apply_style(file=reset_codes_io) - bracket_style_escape_codes = bracket_style_escape_codes_io.getvalue() - multi_select_cursor_style_escape_codes = multi_select_cursor_style_escape_codes_io.getvalue() - reset_codes = reset_codes_io.getvalue() - - cursor_with_brackets_only = re.sub( - r"[^{}]".format(re.escape(bracket_characters)), " ", self._multi_select_cursor - ) - cursor_with_brackets_only_styled = re.sub( - r"[{}]+".format(re.escape(bracket_characters)), - lambda match_obj: bracket_style_escape_codes + match_obj.group(0) + reset_codes, - cursor_with_brackets_only, - ) - cursor_styled = re.sub( - r"[{brackets}]+|[^{brackets}\s]+".format(brackets=re.escape(bracket_characters)), - lambda match_obj: ( - bracket_style_escape_codes - if match_obj.group(0)[0] in bracket_characters - else multi_select_cursor_style_escape_codes - ) - + match_obj.group(0) - + reset_codes, - self._multi_select_cursor, - ) - return cursor_styled, cursor_with_brackets_only_styled - - if not self._view: - return - checked_multi_select_cursor, unchecked_multi_select_cursor = prepare_multi_select_cursors() - cursor_width = wcswidth(self._menu_cursor) - displayed_selected_indices = self._view.displayed_selected_indices - displayed_index = 0 - for displayed_index, _, _ in self._view: - self._tty_out.write("\r" + cursor_width * self._codename_to_terminal_code["cursor_right"]) - if displayed_index in self._skip_indices: - self._tty_out.write("") - elif displayed_index in displayed_selected_indices: - self._tty_out.write(checked_multi_select_cursor) - else: - self._tty_out.write(unchecked_multi_select_cursor) - if displayed_index < self._viewport.upper_index: - self._tty_out.write(self._codename_to_terminal_code["cursor_down"]) - self._tty_out.write("\r") - self._tty_out.write( - (displayed_index + (1 if displayed_index < self._viewport.upper_index else 0)) - * self._codename_to_terminal_code["cursor_up"] - ) - - # pylint: disable=unsubscriptable-object - assert self._codename_to_terminal_code is not None - assert self._tty_out is not None - displayed_menu_height = 0 # sum all written lines - status_bar_lines = get_status_bar_lines() - self._viewport.status_bar_lines_count = len(status_bar_lines) - if self._preview_command is not None: - self._viewport.preview_lines_count = int(self._preview_size * self._num_lines()) - preview_max_num_lines = self._viewport.preview_lines_count - self._viewport.keep_visible(self._view.active_displayed_index) - displayed_menu_height += print_menu_entries() - displayed_menu_height += print_search_line(displayed_menu_height) - if not self._status_bar_below_preview: - displayed_menu_height += print_status_bar(displayed_menu_height, status_bar_lines) - if self._preview_command is not None: - displayed_menu_height += print_preview(displayed_menu_height, preview_max_num_lines) - if self._status_bar_below_preview: - displayed_menu_height += print_status_bar(displayed_menu_height, status_bar_lines) - delete_old_menu_lines(displayed_menu_height) - position_cursor() - if self._multi_select: - print_multi_select_column() - self._previous_displayed_menu_height = displayed_menu_height - self._tty_out.flush() - - def _clear_menu(self) -> None: - # pylint: disable=unsubscriptable-object - assert self._codename_to_terminal_code is not None - assert self._previous_displayed_menu_height is not None - assert self._tty_out is not None - if self._clear_menu_on_exit: - if self._title_lines: - self._tty_out.write(len(self._title_lines) * self._codename_to_terminal_code["cursor_up"]) - self._tty_out.write(len(self._title_lines) * self._codename_to_terminal_code["delete_line"]) - self._tty_out.write( - (self._previous_displayed_menu_height + 1) * self._codename_to_terminal_code["delete_line"] - ) - else: - self._tty_out.write( - (self._previous_displayed_menu_height + 1) * self._codename_to_terminal_code["cursor_down"] - ) - self._tty_out.flush() - - def _read_next_key(self, ignore_case: bool = True) -> str: - # pylint: disable=unsubscriptable-object,unsupported-membership-test - assert self._terminal_code_to_codename is not None - assert self._tty_in is not None - # Needed for asynchronous handling of terminal resize events - self._reading_next_key = True - if self._paint_before_next_read: - self._paint_menu() - self._paint_before_next_read = False - # blocks until any amount of bytes is available - code = os.read(self._tty_in.fileno(), 80).decode("ascii", errors="ignore") - self._reading_next_key = False - if code in self._terminal_code_to_codename: - return self._terminal_code_to_codename[code] - elif ignore_case: - return code.lower() - else: - return code - - def show(self) -> Optional[Union[int, Tuple[int, ...]]]: - def init_signal_handling() -> None: - # `SIGWINCH` is send on terminal resizes - def handle_sigwinch(signum: signal.Signals, frame: FrameType) -> None: - # pylint: disable=unused-argument - if self._reading_next_key: - self._paint_menu() - else: - self._paint_before_next_read = True - - signal.signal(signal.SIGWINCH, handle_sigwinch) - - def reset_signal_handling() -> None: - signal.signal(signal.SIGWINCH, signal.SIG_DFL) - - def remove_letter_keys(menu_action_to_keys: Dict[str, Set[Optional[str]]]) -> None: - letter_keys = frozenset(string.ascii_lowercase) | frozenset(" ") - for keys in menu_action_to_keys.values(): - keys -= letter_keys - - # pylint: disable=unsubscriptable-object - assert self._codename_to_terminal_code is not None - self._init_term() - if self._preselected_indices is None: - self._selection.clear() - self._chosen_accept_key = None - self._chosen_menu_indices = None - self._chosen_menu_index = None - assert self._tty_out is not None - if self._title_lines: - # `print_menu` expects the cursor on the first menu item -> reserve one line for the title - self._tty_out.write(len(self._title_lines) * self._codename_to_terminal_code["cursor_down"]) - menu_was_interrupted = False - try: - init_signal_handling() - menu_action_to_keys = { - "menu_up": set(("up", "ctrl-k", "k")), - "menu_down": set(("down", "ctrl-j", "j")), - "accept": set(self._accept_keys), - "multi_select": set(self._multi_select_keys), - "quit": set(self._quit_keys), - "search_start": set((self._search_key,)), - "backspace": set(("backspace",)), - } # type: Dict[str, Set[Optional[str]]] - while True: - self._paint_menu() - current_menu_action_to_keys = copy.deepcopy(menu_action_to_keys) - next_key = self._read_next_key(ignore_case=False) - if self._search or self._search_key is None: - remove_letter_keys(current_menu_action_to_keys) - else: - next_key = next_key.lower() - if self._search_key is not None and not self._search and next_key in self._shortcut_keys: - shortcut_menu_index = self._shortcut_keys.index(next_key) - if self._exit_on_shortcut: - self._selection.add(shortcut_menu_index) - break - else: - if self._multi_select: - self._selection.toggle(shortcut_menu_index) - else: - self._view.active_menu_index = shortcut_menu_index - elif next_key in current_menu_action_to_keys["menu_up"]: - self._view.decrement_active_index() - elif next_key in current_menu_action_to_keys["menu_down"]: - self._view.increment_active_index() - elif self._multi_select and next_key in current_menu_action_to_keys["multi_select"]: - if self._view.active_menu_index is not None: - self._selection.toggle(self._view.active_menu_index) - elif next_key in current_menu_action_to_keys["accept"]: - if self._view.active_menu_index is not None: - if self._multi_select_select_on_accept or ( - not self._selection and self._multi_select_empty_ok is False - ): - self._selection.add(self._view.active_menu_index) - self._chosen_accept_key = next_key - break - elif next_key in current_menu_action_to_keys["quit"]: - if not self._search: - menu_was_interrupted = True - break - else: - self._search.search_text = None - elif not self._search: - if next_key in current_menu_action_to_keys["search_start"] or ( - self._search_key is None and next_key == DEFAULT_SEARCH_KEY - ): - self._search.search_text = "" - elif self._search_key is None: - self._search.search_text = next_key - else: - assert self._search.search_text is not None - if next_key in ("backspace",): - if self._search.search_text != "": - self._search.search_text = self._search.search_text[:-1] - else: - self._search.search_text = None - elif wcswidth(next_key) >= 0 and not ( - next_key in current_menu_action_to_keys["search_start"] and self._search.search_text == "" - ): - # Only append `next_key` if it is a printable character and the first character is not the - # `search_start` key - self._search.search_text += next_key - except KeyboardInterrupt as e: - if self._raise_error_on_interrupt: - raise e - menu_was_interrupted = True - finally: - reset_signal_handling() - self._clear_menu() - self._reset_term() - if not menu_was_interrupted: - chosen_menu_indices = self._selection.selected_menu_indices - if chosen_menu_indices: - if self._multi_select: - self._chosen_menu_indices = chosen_menu_indices - else: - self._chosen_menu_index = chosen_menu_indices[0] - return self._chosen_menu_indices if self._multi_select else self._chosen_menu_index - - @property - def chosen_accept_key(self) -> Optional[str]: - return self._chosen_accept_key - - @property - def chosen_menu_entry(self) -> Optional[str]: - return self._menu_entries[self._chosen_menu_index] if self._chosen_menu_index is not None else None - - @property - def chosen_menu_entries(self) -> Optional[Tuple[str, ...]]: - return ( - tuple(self._menu_entries[menu_index] for menu_index in self._chosen_menu_indices) - if self._chosen_menu_indices is not None - else None - ) - - @property - def chosen_menu_index(self) -> Optional[int]: - return self._chosen_menu_index - - @property - def chosen_menu_indices(self) -> Optional[Tuple[int, ...]]: - return self._chosen_menu_indices - - -class AttributeDict(dict): # type: ignore - def __getattr__(self, attr: str) -> Any: - return self[attr] - - def __setattr__(self, attr: str, value: Any) -> None: - self[attr] = value - - -def get_argumentparser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser( - formatter_class=argparse.RawDescriptionHelpFormatter, - description=""" -%(prog)s creates simple interactive menus in the terminal and returns the selected entry as exit code. -""", - ) - parser.add_argument( - "-s", "--case-sensitive", action="store_true", dest="case_sensitive", help="searches are case sensitive" - ) - parser.add_argument( - "-X", - "--no-clear-menu-on-exit", - action="store_false", - dest="clear_menu_on_exit", - help="do not clear the menu on exit", - ) - parser.add_argument( - "-l", - "--clear-screen", - action="store_true", - dest="clear_screen", - help="clear the screen before the menu is shown", - ) - parser.add_argument( - "--cursor", - action="store", - dest="cursor", - default=DEFAULT_MENU_CURSOR, - help='menu cursor (default: "%(default)s")', - ) - parser.add_argument( - "-i", - "--cursor-index", - action="store", - dest="cursor_index", - type=int, - default=0, - help="initially selected item index", - ) - parser.add_argument( - "--cursor-style", - action="store", - dest="cursor_style", - default=",".join(DEFAULT_MENU_CURSOR_STYLE), - help='style for the menu cursor as comma separated list (default: "%(default)s")', - ) - parser.add_argument("-C", "--no-cycle", action="store_false", dest="cycle", help="do not cycle the menu selection") - parser.add_argument( - "-E", - "--no-exit-on-shortcut", - action="store_false", - dest="exit_on_shortcut", - help="do not exit on shortcut keys", - ) - parser.add_argument( - "--highlight-style", - action="store", - dest="highlight_style", - default=",".join(DEFAULT_MENU_HIGHLIGHT_STYLE), - help='style for the selected menu entry as comma separated list (default: "%(default)s")', - ) - parser.add_argument( - "-m", - "--multi-select", - action="store_true", - dest="multi_select", - help="Allow the selection of multiple entries (implies `--stdout`)", - ) - parser.add_argument( - "--multi-select-cursor", - action="store", - dest="multi_select_cursor", - default=DEFAULT_MULTI_SELECT_CURSOR, - help='multi-select menu cursor (default: "%(default)s")', - ) - parser.add_argument( - "--multi-select-cursor-brackets-style", - action="store", - dest="multi_select_cursor_brackets_style", - default=",".join(DEFAULT_MULTI_SELECT_CURSOR_BRACKETS_STYLE), - help='style for brackets of the multi-select menu cursor as comma separated list (default: "%(default)s")', - ) - parser.add_argument( - "--multi-select-cursor-style", - action="store", - dest="multi_select_cursor_style", - default=",".join(DEFAULT_MULTI_SELECT_CURSOR_STYLE), - help='style for the multi-select menu cursor as comma separated list (default: "%(default)s")', - ) - parser.add_argument( - "--multi-select-keys", - action="store", - dest="multi_select_keys", - default=",".join(DEFAULT_MULTI_SELECT_KEYS), - help=('key for toggling a selected item in a multi-selection (default: "%(default)s", '), - ) - parser.add_argument( - "--multi-select-no-select-on-accept", - action="store_false", - dest="multi_select_select_on_accept", - help=( - "do not select the currently highlighted menu item when the accept key is pressed " - "(it is still selected if no other item was selected before)" - ), - ) - parser.add_argument( - "--multi-select-empty-ok", - action="store_true", - dest="multi_select_empty_ok", - help=("when used together with --multi-select-no-select-on-accept allows returning no selection at all"), - ) - parser.add_argument( - "-p", - "--preview", - action="store", - dest="preview_command", - help=( - "Command to generate a preview for the selected menu entry. " - '"{}" can be used as placeholder for the menu text. ' - 'If the menu entry has a data component (separated by "|"), this is used instead.' - ), - ) - parser.add_argument( - "--no-preview-border", - action="store_false", - dest="preview_border", - help="do not draw a border around the preview window", - ) - parser.add_argument( - "--preview-size", - action="store", - dest="preview_size", - type=float, - default=DEFAULT_PREVIEW_SIZE, - help='maximum height of the preview window in fractions of the terminal height (default: "%(default)s")', - ) - parser.add_argument( - "--preview-title", - action="store", - dest="preview_title", - default=DEFAULT_PREVIEW_TITLE, - help='title of the preview window (default: "%(default)s")', - ) - parser.add_argument( - "--search-highlight-style", - action="store", - dest="search_highlight_style", - default=",".join(DEFAULT_SEARCH_HIGHLIGHT_STYLE), - help='style of matched search patterns (default: "%(default)s")', - ) - parser.add_argument( - "--search-key", - action="store", - dest="search_key", - default=DEFAULT_SEARCH_KEY, - help=( - 'key to start a search (default: "%(default)s", ' - '"none" is treated a special value which activates the search on any letter key)' - ), - ) - parser.add_argument( - "--shortcut-brackets-highlight-style", - action="store", - dest="shortcut_brackets_highlight_style", - default=",".join(DEFAULT_SHORTCUT_BRACKETS_HIGHLIGHT_STYLE), - help='style of brackets enclosing shortcut keys (default: "%(default)s")', - ) - parser.add_argument( - "--shortcut-key-highlight-style", - action="store", - dest="shortcut_key_highlight_style", - default=",".join(DEFAULT_SHORTCUT_KEY_HIGHLIGHT_STYLE), - help='style of shortcut keys (default: "%(default)s")', - ) - parser.add_argument( - "--show-multi-select-hint", - action="store_true", - dest="show_multi_select_hint", - help="show a multi-select hint in the status bar", - ) - parser.add_argument( - "--show-multi-select-hint-text", - action="store", - dest="show_multi_select_hint_text", - help=( - "Custom text which will be shown as multi-select hint. Use the placeholders {multi_select_keys} and " - "{accept_keys} if appropriately." - ), - ) - parser.add_argument( - "--show-search-hint", - action="store_true", - dest="show_search_hint", - help="show a search hint in the search line", - ) - parser.add_argument( - "--show-search-hint-text", - action="store", - dest="show_search_hint_text", - help=( - "Custom text which will be shown as search hint. Use the placeholders {key} for the search key " - "if appropriately." - ), - ) - parser.add_argument( - "--show-shortcut-hints", - action="store_true", - dest="show_shortcut_hints", - help="show shortcut hints in the status bar", - ) - parser.add_argument( - "--show-shortcut-hints-in-title", - action="store_false", - dest="show_shortcut_hints_in_status_bar", - default=True, - help="show shortcut hints in the menu title", - ) - parser.add_argument( - "--skip-empty-entries", - action="store_true", - dest="skip_empty_entries", - help="Interpret an empty string in menu entries as an empty menu entry", - ) - parser.add_argument( - "-b", - "--status-bar", - action="store", - dest="status_bar", - help="status bar text", - ) - parser.add_argument( - "-d", - "--status-bar-below-preview", - action="store_true", - dest="status_bar_below_preview", - help="show the status bar below the preview window if any", - ) - parser.add_argument( - "--status-bar-style", - action="store", - dest="status_bar_style", - default=",".join(DEFAULT_STATUS_BAR_STYLE), - help='style of the status bar lines (default: "%(default)s")', - ) - parser.add_argument( - "--stdout", - action="store_true", - dest="stdout", - help=( - "Print the selected menu index or indices to stdout (in addition to the exit status). " - 'Multiple indices are separated by ";".' - ), - ) - parser.add_argument("-t", "--title", action="store", dest="title", help="menu title") - parser.add_argument( - "-V", "--version", action="store_true", dest="print_version", help="print the version number and exit" - ) - parser.add_argument("entries", action="store", nargs="*", help="the menu entries to show") - group = parser.add_mutually_exclusive_group() - group.add_argument( - "-r", - "--preselected_entries", - action="store", - dest="preselected_entries", - help="Comma separated list of strings matching menu items to start pre-selected in a multi-select menu.", - ) - group.add_argument( - "-R", - "--preselected_indices", - action="store", - dest="preselected_indices", - help="Comma separated list of numeric indexes of menu items to start pre-selected in a multi-select menu.", - ) - return parser - - -def parse_arguments() -> AttributeDict: - parser = get_argumentparser() - args = AttributeDict({key: value for key, value in vars(parser.parse_args()).items()}) - if not args.print_version and not args.entries: - raise NoMenuEntriesError("No menu entries given!") - if args.skip_empty_entries: - args.entries = [entry if entry != "None" else None for entry in args.entries] - if args.cursor_style != "": - args.cursor_style = tuple(args.cursor_style.split(",")) - else: - args.cursor_style = None - if args.highlight_style != "": - args.highlight_style = tuple(args.highlight_style.split(",")) - else: - args.highlight_style = None - if args.search_highlight_style != "": - args.search_highlight_style = tuple(args.search_highlight_style.split(",")) - else: - args.search_highlight_style = None - if args.shortcut_key_highlight_style != "": - args.shortcut_key_highlight_style = tuple(args.shortcut_key_highlight_style.split(",")) - else: - args.shortcut_key_highlight_style = None - if args.shortcut_brackets_highlight_style != "": - args.shortcut_brackets_highlight_style = tuple(args.shortcut_brackets_highlight_style.split(",")) - else: - args.shortcut_brackets_highlight_style = None - if args.status_bar_style != "": - args.status_bar_style = tuple(args.status_bar_style.split(",")) - else: - args.status_bar_style = None - if args.multi_select_cursor_brackets_style != "": - args.multi_select_cursor_brackets_style = tuple(args.multi_select_cursor_brackets_style.split(",")) - else: - args.multi_select_cursor_brackets_style = None - if args.multi_select_cursor_style != "": - args.multi_select_cursor_style = tuple(args.multi_select_cursor_style.split(",")) - else: - args.multi_select_cursor_style = None - if args.multi_select_keys != "": - args.multi_select_keys = tuple(args.multi_select_keys.split(",")) - else: - args.multi_select_keys = None - if args.search_key.lower() == "none": - args.search_key = None - if args.show_shortcut_hints_in_status_bar: - args.show_shortcut_hints = True - if args.multi_select: - args.stdout = True - if args.preselected_entries is not None: - args.preselected = list(args.preselected_entries.split(",")) - elif args.preselected_indices is not None: - args.preselected = list(map(int, args.preselected_indices.split(","))) - else: - args.preselected = None - return args - - -def main() -> None: - try: - args = parse_arguments() - except SystemExit: - sys.exit(0) # Error code 0 is the error case in this program - except NoMenuEntriesError as e: - print(str(e), file=sys.stderr) - sys.exit(0) - if args.print_version: - print("{}, version {}".format(os.path.basename(sys.argv[0]), __version__)) - sys.exit(0) - try: - terminal_menu = TerminalMenu( - menu_entries=args.entries, - clear_menu_on_exit=args.clear_menu_on_exit, - clear_screen=args.clear_screen, - cursor_index=args.cursor_index, - cycle_cursor=args.cycle, - exit_on_shortcut=args.exit_on_shortcut, - menu_cursor=args.cursor, - menu_cursor_style=args.cursor_style, - menu_highlight_style=args.highlight_style, - multi_select=args.multi_select, - multi_select_cursor=args.multi_select_cursor, - multi_select_cursor_brackets_style=args.multi_select_cursor_brackets_style, - multi_select_cursor_style=args.multi_select_cursor_style, - multi_select_empty_ok=args.multi_select_empty_ok, - multi_select_keys=args.multi_select_keys, - multi_select_select_on_accept=args.multi_select_select_on_accept, - preselected_entries=args.preselected, - preview_border=args.preview_border, - preview_command=args.preview_command, - preview_size=args.preview_size, - preview_title=args.preview_title, - search_case_sensitive=args.case_sensitive, - search_highlight_style=args.search_highlight_style, - search_key=args.search_key, - shortcut_brackets_highlight_style=args.shortcut_brackets_highlight_style, - shortcut_key_highlight_style=args.shortcut_key_highlight_style, - show_multi_select_hint=args.show_multi_select_hint, - show_multi_select_hint_text=args.show_multi_select_hint_text, - show_search_hint=args.show_search_hint, - show_search_hint_text=args.show_search_hint_text, - show_shortcut_hints=args.show_shortcut_hints, - show_shortcut_hints_in_status_bar=args.show_shortcut_hints_in_status_bar, - skip_empty_entries=args.skip_empty_entries, - status_bar=args.status_bar, - status_bar_below_preview=args.status_bar_below_preview, - status_bar_style=args.status_bar_style, - title=args.title, - ) - except (InvalidParameterCombinationError, InvalidStyleError, UnknownMenuEntryError) as e: - print(str(e), file=sys.stderr) - sys.exit(0) - chosen_entries = terminal_menu.show() - if chosen_entries is None: - sys.exit(0) - else: - if isinstance(chosen_entries, Iterable): - if args.stdout: - print(",".join(str(entry + 1) for entry in chosen_entries)) - sys.exit(chosen_entries[0] + 1) - else: - chosen_entry = chosen_entries - if args.stdout: - print(chosen_entry + 1) - sys.exit(chosen_entry + 1) - - -if __name__ == "__main__": - main() diff --git a/archinstall/lib/menu/table_selection_menu.py b/archinstall/lib/menu/table_selection_menu.py index 09cd6ee2..4cff7216 100644 --- a/archinstall/lib/menu/table_selection_menu.py +++ b/archinstall/lib/menu/table_selection_menu.py @@ -1,19 +1,24 @@ -from typing import Any, Tuple, List, Dict, Optional +from typing import Any, Tuple, List, Dict, Optional, Callable -from .menu import MenuSelectionType, MenuSelection +from .menu import MenuSelectionType, MenuSelection, Menu from ..output import FormattedOutput -from ..menu import Menu class TableMenu(Menu): def __init__( self, title: str, - data: List[Any] = [], + data: Optional[List[Any]] = None, table_data: Optional[Tuple[List[Any], str]] = None, + preset: List[Any] = [], custom_menu_options: List[str] = [], default: Any = None, - multi: bool = False + multi: bool = False, + preview_command: Optional[Callable] = None, + preview_title: str = 'Info', + preview_size: float = 0.0, + allow_reset: bool = True, + allow_reset_warning_msg: Optional[str] = None, ): """ param title: Text that will be displayed above the menu @@ -29,10 +34,10 @@ class TableMenu(Menu): param custom_options: List of custom options that will be displayed under the table :type custom_menu_options: List - """ - if not data and not table_data: - raise ValueError('Either "data" or "table_data" must be provided') + :param preview_command: A function that should return a string that will be displayed in a preview window when a menu selection item is in focus + :type preview_command: Callable + """ self._custom_options = custom_menu_options self._multi = multi @@ -41,7 +46,7 @@ class TableMenu(Menu): else: header_padding = 2 - if len(data): + if data is not None: table_text = FormattedOutput.as_table(data) rows = table_text.split('\n') table = self._create_table(data, rows, header_padding=header_padding) @@ -53,20 +58,53 @@ class TableMenu(Menu): data = table_data[0] rows = table_data[1].split('\n') table = self._create_table(data, rows, header_padding=header_padding) + else: + raise ValueError('Either "data" or "table_data" must be provided') self._options, header = self._prepare_selection(table) + preset_values = self._preset_values(preset) + + extra_bottom_space = True if preview_command else False + super().__init__( title, self._options, + preset_values=preset_values, header=header, skip_empty_entries=True, show_search_hint=False, - allow_reset=True, multi=multi, - default_option=default + default_option=default, + preview_command=lambda x: self._table_show_preview(preview_command, x), + preview_size=preview_size, + preview_title=preview_title, + extra_bottom_space=extra_bottom_space, + allow_reset=allow_reset, + allow_reset_warning_msg=allow_reset_warning_msg ) + def _preset_values(self, preset: List[Any]) -> List[str]: + # when we create the table of just the preset values it will + # be formatted a bit different due to spacing, so to determine + # correct rows lets remove all the spaces and compare apples with apples + preset_table = FormattedOutput.as_table(preset).strip() + data_rows = preset_table.split('\n')[2:] # get all data rows + pure_data_rows = [self._escape_row(row.replace(' ', '')) for row in data_rows] + + # the actual preset value has to be in non-escaped form + pure_option_rows = {o.replace(' ', ''): self._unescape_row(o) for o in self._options.keys()} + preset_rows = [row for pure, row in pure_option_rows.items() if pure in pure_data_rows] + + return preset_rows + + def _table_show_preview(self, preview_command: Optional[Callable], selection: Any) -> Optional[str]: + if preview_command: + row = self._escape_row(selection) + obj = self._options[row] + return preview_command(obj) + return None + def run(self) -> MenuSelection: choice = super().run() @@ -79,6 +117,12 @@ class TableMenu(Menu): return choice + def _escape_row(self, row: str) -> str: + return row.replace('|', '\\|') + + def _unescape_row(self, row: str) -> str: + return row.replace('\\|', '|') + def _create_table(self, data: List[Any], rows: List[str], header_padding: int = 2) -> Dict[str, Any]: # these are the header rows of the table and do not map to any data obviously # we're adding 2 spaces as prefix because the menu selector '> ' will be put before @@ -87,7 +131,7 @@ class TableMenu(Menu): display_data = {f'{padding}{rows[0]}': None, f'{padding}{rows[1]}': None} for row, entry in zip(rows[2:], data): - row = row.replace('|', '\\|') + row = self._escape_row(row) display_data[row] = entry return display_data -- cgit v1.2.3-70-g09d2 From ec4ecbcb7a839ab06b739f01ce42bfd18376c620 Mon Sep 17 00:00:00 2001 From: Daniel Girtler Date: Thu, 4 May 2023 00:36:46 +1000 Subject: Full mypy compliance and small fixes (#1777) * Fix mypy compliance --------- Co-authored-by: Daniel Girtler --- .github/workflows/mypy.yaml | 2 +- archinstall/__init__.py | 3 +- archinstall/lib/disk/device_handler.py | 18 +- archinstall/lib/disk/device_model.py | 2 +- archinstall/lib/disk/fido.py | 9 +- archinstall/lib/general.py | 55 +++--- archinstall/lib/hardware.py | 55 +++--- archinstall/lib/installer.py | 239 +++++++++++------------ archinstall/lib/menu/abstract_menu.py | 4 +- archinstall/lib/menu/menu.py | 104 ++++++---- archinstall/lib/mirrors.py | 46 +++-- archinstall/lib/models/network_configuration.py | 78 ++++---- archinstall/lib/plugins.py | 66 ++++--- archinstall/lib/profile/profiles_handler.py | 16 +- archinstall/lib/systemd.py | 71 ++----- archinstall/lib/user_interaction/general_conf.py | 68 ++++--- archinstall/lib/user_interaction/locale_conf.py | 22 ++- archinstall/scripts/guided.py | 2 +- archinstall/scripts/swiss.py | 4 +- examples/interactive_installation.py | 4 +- mypy.ini | 14 -- pyproject.toml | 1 + 22 files changed, 454 insertions(+), 429 deletions(-) delete mode 100644 mypy.ini (limited to 'archinstall/lib/menu') diff --git a/.github/workflows/mypy.yaml b/.github/workflows/mypy.yaml index 8689570f..e0db6f06 100644 --- a/.github/workflows/mypy.yaml +++ b/.github/workflows/mypy.yaml @@ -15,4 +15,4 @@ jobs: # one day this will be enabled # run: mypy --strict --module archinstall || exit 0 - name: run mypy - run: mypy --config-file mypy.ini + run: mypy --config-file pyproject.toml diff --git a/archinstall/__init__.py b/archinstall/__init__.py index 3d0768a5..29b70b7a 100644 --- a/archinstall/__init__.py +++ b/archinstall/__init__.py @@ -233,7 +233,8 @@ def post_process_arguments(arguments): log(f"Warning: --debug mode will write certain credentials to {storage['LOG_PATH']}/{storage['LOG_FILE']}!", fg="red", level=logging.WARNING) if arguments.get('plugin', None): - load_plugin(arguments['plugin']) + path = arguments['plugin'] + load_plugin(path) load_config() diff --git a/archinstall/lib/disk/device_handler.py b/archinstall/lib/disk/device_handler.py index ba325cda..8f92cf3b 100644 --- a/archinstall/lib/disk/device_handler.py +++ b/archinstall/lib/disk/device_handler.py @@ -269,13 +269,13 @@ class DeviceHandler(object): # partition will be encrypted if enc_conf is not None and part_mod in enc_conf.partitions: self._perform_enc_formatting( - part_mod.real_dev_path, + part_mod.safe_dev_path, part_mod.mapper_name, part_mod.fs_type, enc_conf ) else: - self._perform_formatting(part_mod.fs_type, part_mod.real_dev_path) + self._perform_formatting(part_mod.fs_type, part_mod.safe_dev_path) def _perform_partitioning( self, @@ -287,11 +287,11 @@ class DeviceHandler(object): # when we require a delete and the partition to be (re)created # already exists then we have to delete it first if requires_delete and part_mod.status in [ModificationStatus.Modify, ModificationStatus.Delete]: - log(f'Delete existing partition: {part_mod.real_dev_path}', level=logging.INFO) - part_info = self.find_partition(part_mod.real_dev_path) + log(f'Delete existing partition: {part_mod.safe_dev_path}', level=logging.INFO) + part_info = self.find_partition(part_mod.safe_dev_path) if not part_info: - raise DiskError(f'No partition for dev path found: {part_mod.real_dev_path}') + raise DiskError(f'No partition for dev path found: {part_mod.safe_dev_path}') disk.deletePartition(part_info.partition) disk.commit() @@ -375,7 +375,7 @@ class DeviceHandler(object): part_mod: PartitionModification, enc_conf: Optional['DiskEncryption'] = None ): - log(f'Creating subvolumes: {part_mod.real_dev_path}', level=logging.INFO) + log(f'Creating subvolumes: {part_mod.safe_dev_path}', level=logging.INFO) luks_handler = None @@ -385,7 +385,7 @@ class DeviceHandler(object): raise ValueError('No device path specified for modification') luks_handler = self.unlock_luks2_dev( - part_mod.real_dev_path, + part_mod.safe_dev_path, part_mod.mapper_name, enc_conf.encryption_password ) @@ -395,7 +395,7 @@ class DeviceHandler(object): self.mount(luks_handler.mapper_dev, self._TMP_BTRFS_MOUNT, create_target_mountpoint=True) else: - self.mount(part_mod.real_dev_path, self._TMP_BTRFS_MOUNT, create_target_mountpoint=True) + self.mount(part_mod.safe_dev_path, self._TMP_BTRFS_MOUNT, create_target_mountpoint=True) for sub_vol in part_mod.btrfs_subvols: log(f'Creating subvolume: {sub_vol.name}', level=logging.DEBUG) @@ -419,7 +419,7 @@ class DeviceHandler(object): self.umount(luks_handler.mapper_dev) luks_handler.lock() else: - self.umount(part_mod.real_dev_path) + self.umount(part_mod.safe_dev_path) def unlock_luks2_dev(self, dev_path: Path, mapper_name: str, enc_password: str) -> Luks2: luks_handler = Luks2(dev_path, mapper_name=mapper_name, password=enc_password) diff --git a/archinstall/lib/disk/device_model.py b/archinstall/lib/disk/device_model.py index 0270a4dd..987a1e8a 100644 --- a/archinstall/lib/disk/device_model.py +++ b/archinstall/lib/disk/device_model.py @@ -603,7 +603,7 @@ class PartitionModification: return '' @property - def real_dev_path(self) -> Path: + def safe_dev_path(self) -> Path: if self.dev_path is None: raise ValueError('Device path was not set') return self.dev_path diff --git a/archinstall/lib/disk/fido.py b/archinstall/lib/disk/fido.py index 436be4d4..2a53b551 100644 --- a/archinstall/lib/disk/fido.py +++ b/archinstall/lib/disk/fido.py @@ -2,7 +2,8 @@ from __future__ import annotations import getpass import logging -from typing import List +from pathlib import Path +from typing import List, Optional from .device_model import PartitionModification, Fido2Device from ..general import SysCommand, SysCommandWorker, clear_vt100_escape_codes @@ -36,12 +37,12 @@ class Fido2: # to prevent continous reloading which will slow # down moving the cursor in the menu if not cls._loaded or reload: - ret = SysCommand(f"systemd-cryptenroll --fido2-device=list").decode('UTF-8') + ret: Optional[str] = SysCommand(f"systemd-cryptenroll --fido2-device=list").decode('UTF-8') if not ret: log('Unable to retrieve fido2 devices', level=logging.ERROR) return [] - fido_devices = clear_vt100_escape_codes(ret) + fido_devices: str = clear_vt100_escape_codes(ret) # type: ignore manufacturer_pos = 0 product_pos = 0 @@ -58,7 +59,7 @@ class Fido2: product = line[product_pos:] devices.append( - Fido2Device(path, manufacturer, product) + Fido2Device(Path(path), manufacturer, product) ) cls._loaded = True diff --git a/archinstall/lib/general.py b/archinstall/lib/general.py index 57f13288..997b7d67 100644 --- a/archinstall/lib/general.py +++ b/archinstall/lib/general.py @@ -19,9 +19,15 @@ import pathlib from datetime import datetime, date from typing import Callable, Optional, Dict, Any, List, Union, Iterator, TYPE_CHECKING +from .exceptions import RequirementError, SysCallError +from .output import log +from .storage import storage + + if TYPE_CHECKING: from .installer import Installer + if sys.platform == 'linux': from select import epoll, EPOLLIN, EPOLLHUP else: @@ -53,30 +59,15 @@ else: except OSError: return [] -from .exceptions import RequirementError, SysCallError -from .output import log -from .storage import storage def gen_uid(entropy_length :int = 256) -> str: return hashlib.sha512(os.urandom(entropy_length)).hexdigest() + def generate_password(length :int = 64) -> str: haystack = string.printable # digits, ascii_letters, punctiation (!"#$[] etc) and whitespace return ''.join(secrets.choice(haystack) for i in range(length)) -def multisplit(s :str, splitters :List[str]) -> str: - s = [s, ] - for key in splitters: - ns = [] - for obj in s: - x = obj.split(key) - for index, part in enumerate(x): - if len(part): - ns.append(part) - if index < len(x) - 1: - ns.append(key) - s = ns - return s def locate_binary(name :str) -> str: for PATH in os.environ['PATH'].split(':'): @@ -88,20 +79,20 @@ def locate_binary(name :str) -> str: raise RequirementError(f"Binary {name} does not exist.") -def clear_vt100_escape_codes(data :Union[bytes, str]): + +def clear_vt100_escape_codes(data :Union[bytes, str]) -> Union[bytes, str]: # https://stackoverflow.com/a/43627833/929999 if type(data) == bytes: - vt100_escape_regex = bytes(r'\x1B\[[?0-9;]*[a-zA-Z]', 'UTF-8') - else: + byte_vt100_escape_regex = bytes(r'\x1B\[[?0-9;]*[a-zA-Z]', 'UTF-8') + data = re.sub(byte_vt100_escape_regex, b'', data) + elif type(data) == str: vt100_escape_regex = r'\x1B\[[?0-9;]*[a-zA-Z]' - - for match in re.findall(vt100_escape_regex, data, re.IGNORECASE): - data = data.replace(match, '' if type(data) == str else b'') + data = re.sub(vt100_escape_regex, '', data) + else: + raise ValueError(f'Unsupported data type: {type(data)}') return data -def json_dumps(*args :str, **kwargs :str) -> str: - return json.dumps(*args, **{**kwargs, 'cls': JSON}) class JsonEncoder: @staticmethod @@ -245,10 +236,12 @@ class SysCommandWorker: def __iter__(self, *args :str, **kwargs :Dict[str, Any]) -> Iterator[bytes]: for line in self._trace_log[self._trace_log_pos:self._trace_log.rfind(b'\n')].split(b'\n'): if line: + escaped_line: bytes = line + if self.remove_vt100_escape_codes_from_lines: - line = clear_vt100_escape_codes(line) + escaped_line = clear_vt100_escape_codes(line) # type: ignore - yield line + b'\n' + yield escaped_line + b'\n' self._trace_log_pos = self._trace_log.rfind(b'\n') @@ -279,7 +272,11 @@ class SysCommandWorker: log(args[1], level=logging.DEBUG, fg='red') if self.exit_code != 0: - raise SysCallError(f"{self.cmd} exited with abnormal exit code [{self.exit_code}]: {self._trace_log[-500:]}", self.exit_code, worker=self) + raise SysCallError( + f"{self.cmd} exited with abnormal exit code [{self.exit_code}]: {str(self._trace_log[-500:])}", + self.exit_code, + worker=self + ) def is_alive(self) -> bool: self.poll() @@ -328,7 +325,7 @@ class SysCommandWorker: change_perm = True with peak_logfile.open("a") as peek_output_log: - peek_output_log.write(output) + peek_output_log.write(str(output)) if change_perm: os.chmod(str(peak_logfile), stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP) @@ -497,7 +494,7 @@ class SysCommand: clears any printed output if ``.peek_output=True``. """ if self.session: - return self.session + return True with SysCommandWorker( self.cmd, diff --git a/archinstall/lib/hardware.py b/archinstall/lib/hardware.py index 9660ea95..3759725f 100644 --- a/archinstall/lib/hardware.py +++ b/archinstall/lib/hardware.py @@ -2,7 +2,7 @@ import os import logging from functools import partial from pathlib import Path -from typing import Iterator, Optional, Union +from typing import Iterator, Optional, Dict from .general import SysCommand from .networking import list_interfaces, enrich_iface_types @@ -61,15 +61,15 @@ AVAILABLE_GFX_DRIVERS = { "VMware / VirtualBox (open-source)": ["mesa", "xf86-video-vmware"], } -CPUINFO = Path("/proc/cpuinfo") -MEMINFO = Path("/proc/meminfo") - def cpuinfo() -> Iterator[dict[str, str]]: - """Yields information about the CPUs of the system.""" - cpu = {} + """ + Yields information about the CPUs of the system + """ + cpu_info_path = Path("/proc/cpuinfo") + cpu: Dict[str, str] = {} - with CPUINFO.open() as file: + with cpu_info_path.open() as file: for line in file: if not (line := line.strip()): yield cpu @@ -80,24 +80,31 @@ def cpuinfo() -> Iterator[dict[str, str]]: cpu[key.strip()] = value.strip() -def meminfo(key: Optional[str] = None) -> Union[dict[str, int], Optional[int]]: - """Returns a dict with memory info if called with no args +def all_meminfo() -> Dict[str, int]: + """ + Returns a dict with memory info if called with no args or the value of the given key of said dict. """ - with MEMINFO.open() as file: - mem_info = { - (columns := line.strip().split())[0].rstrip(':'): int(columns[1]) - for line in file - } + mem_info_path = Path("/proc/meminfo") + mem_info: Dict[str, int] = {} - if key is None: - return mem_info + with mem_info_path.open() as file: + for line in file: + key, value = line.strip().split(':') + num = value.split()[0] + mem_info[key] = int(num) + + return mem_info - return mem_info.get(key) + +def meminfo_for_key(key: str) -> int: + info = all_meminfo() + return info[key] def has_wifi() -> bool: - return 'WIRELESS' in enrich_iface_types(list_interfaces().values()).values() + ifaces = list(list_interfaces().values()) + return 'WIRELESS' in enrich_iface_types(ifaces).values() def has_cpu_vendor(vendor_id: str) -> bool: @@ -160,15 +167,15 @@ def product_name() -> Optional[str]: def mem_available() -> Optional[int]: - return meminfo('MemAvailable') + return meminfo_for_key('MemAvailable') def mem_free() -> Optional[int]: - return meminfo('MemFree') + return meminfo_for_key('MemFree') def mem_total() -> Optional[int]: - return meminfo('MemTotal') + return meminfo_for_key('MemTotal') def virtualization() -> Optional[str]: @@ -182,9 +189,9 @@ def virtualization() -> Optional[str]: def is_vm() -> bool: try: - return b"none" not in b"".join(SysCommand("systemd-detect-virt")).lower() + result = SysCommand("systemd-detect-virt") + return b"none" not in b"".join(result).lower() except SysCallError as error: log(f"System is not running in a VM: {error}", level=logging.DEBUG) - return None -# TODO: Add more identifiers + return False diff --git a/archinstall/lib/installer.py b/archinstall/lib/installer.py index ddbcc2f2..b6eaa797 100644 --- a/archinstall/lib/installer.py +++ b/archinstall/lib/installer.py @@ -7,7 +7,7 @@ import shutil import subprocess import time from pathlib import Path -from typing import Any, Iterator, List, Mapping, Optional, TYPE_CHECKING, Union, Dict +from typing import Any, List, Optional, TYPE_CHECKING, Union, Dict, Callable, Iterable from . import disk from .exceptions import DiskError, ServiceException, RequirementError, HardwareIncompatibilityError, SysCallError @@ -36,32 +36,6 @@ __packages__ = ["base", "base-devel", "linux-firmware", "linux", "linux-lts", "l __accessibility_packages__ = ["brltty", "espeakup", "alsa-utils"] -class InstallationFile: - def __init__(self, installation :'Installer', filename :str, owner :str, mode :str = "w"): - self.installation = installation - self.filename = filename - self.owner = owner - self.mode = mode - self.fh = None - - def __enter__(self) -> 'InstallationFile': - self.fh = open(self.filename, self.mode) - return self - - def __exit__(self, *args :str) -> None: - self.fh.close() - self.installation.chown(self.owner, self.filename) - - def write(self, data: Union[str, bytes]) -> int: - return self.fh.write(data) - - def read(self, *args) -> Union[str, bytes]: - return self.fh.read(*args) - -# def poll(self, *args) -> bool: -# return self.fh.poll(*args) - - def accessibility_tools_in_use() -> bool: return os.system('systemctl is-active --quiet espeakup.service') == 0 @@ -106,15 +80,17 @@ class Installer: self.kernels = kernels self._disk_config = disk_config - self._disk_encryption = disk_encryption - if self._disk_encryption is None: + if disk_encryption is None: self._disk_encryption = disk.DiskEncryption(disk.EncryptionType.NoEncryption) + else: + self._disk_encryption = disk_encryption + + self.target: Path = target - self.target = target self.init_time = time.strftime('%Y-%m-%d_%H-%M-%S') self.milliseconds = int(str(time.time()).split('.')[1]) - self.helper_flags = {'base': False, 'bootloader': False} + self.helper_flags: Dict[str, Any] = {'base': False, 'bootloader': None} self.base_packages = base_packages for kernel in self.kernels: @@ -124,31 +100,33 @@ class Installer: if accessibility_tools_in_use(): self.base_packages.extend(__accessibility_packages__) - self.post_base_install = [] + self.post_base_install: List[Callable] = [] # TODO: Figure out which one of these two we'll use.. But currently we're mixing them.. storage['session'] = self storage['installation_session'] = self - self.MODULES = [] - self.BINARIES = [] - self.FILES = [] + self.modules: List[str] = [] + self._binaries: List[str] = [] + self._files: List[str] = [] + # systemd, sd-vconsole and sd-encrypt will be replaced by udev, keymap and encrypt # if HSM is not used to encrypt the root volume. Check mkinitcpio() function for that override. - self.HOOKS = ["base", "systemd", "autodetect", "keyboard", "sd-vconsole", "modconf", "block", "filesystems", "fsck"] - self.KERNEL_PARAMS = [] - self.FSTAB_ENTRIES = [] + self._hooks: List[str] = [ + "base", "systemd", "autodetect", "keyboard", + "sd-vconsole", "modconf", "block", "filesystems", "fsck" + ] + self._kernel_params: List[str] = [] + self._fstab_entries: List[str] = [] self._zram_enabled = False - def __enter__(self, *args: str, **kwargs: str) -> 'Installer': + def __enter__(self) -> 'Installer': return self - def __exit__(self, *args :str, **kwargs :str) -> bool: - # TODO: https://stackoverflow.com/questions/28157929/how-to-safely-handle-an-exception-inside-a-context-manager - - if len(args) >= 2 and args[1]: - self.log(args[1], level=logging.ERROR, fg='red') + def __exit__(self, exc_type, exc_val, exc_tb): + if exc_type is not None: + log(exc_val, fg='red', level=logging.ERROR) self.sync_log_to_install_medium() @@ -156,7 +134,7 @@ class Installer: # and then reboot, and a identical log file will be found in the ISO medium anyway. print(_("[!] A log file has been created here: {}").format(os.path.join(storage['LOG_PATH'], storage['LOG_FILE']))) print(_(" Please submit this issue (and file) to https://github.com/archlinux/archinstall/issues")) - raise args[1] + raise exc_val if not (missing_steps := self.post_install_check()): self.log('Installation completed without any errors. You may now reboot.', fg='green', level=logging.INFO) @@ -164,6 +142,7 @@ class Installer: return True else: self.log('Some required steps were not successfully installed/configured before leaving the installer:', fg='red', level=logging.WARNING) + for step in missing_steps: self.log(f' - {step}', fg='red', level=logging.WARNING) @@ -247,31 +226,32 @@ class Installer: luks_handlers = {} for part_mod in partitions: - luks_handler = disk.device_handler.unlock_luks2_dev( - part_mod.dev_path, - part_mod.mapper_name, - self._disk_encryption.encryption_password - ) - luks_handlers[part_mod] = luks_handler + if part_mod.mapper_name and part_mod.dev_path: + luks_handler = disk.device_handler.unlock_luks2_dev( + part_mod.dev_path, + part_mod.mapper_name, + self._disk_encryption.encryption_password + ) + luks_handlers[part_mod] = luks_handler return luks_handlers def _mount_partition(self, part_mod: disk.PartitionModification): # it would be none if it's btrfs as the subvolumes will have the mountpoints defined - if part_mod.mountpoint is not None: + if part_mod.mountpoint and part_mod.dev_path: target = self.target / part_mod.relative_mountpoint disk.device_handler.mount(part_mod.dev_path, target, options=part_mod.mount_options) - if part_mod.fs_type == disk.FilesystemType.Btrfs: + if part_mod.fs_type == disk.FilesystemType.Btrfs and part_mod.dev_path: self._mount_btrfs_subvol(part_mod.dev_path, part_mod.btrfs_subvols) def _mount_luks_partiton(self, part_mod: disk.PartitionModification, luks_handler: Luks2): # it would be none if it's btrfs as the subvolumes will have the mountpoints defined - if part_mod.mountpoint is not None: + if part_mod.mountpoint and luks_handler.mapper_dev: target = self.target / part_mod.relative_mountpoint disk.device_handler.mount(luks_handler.mapper_dev, target, options=part_mod.mount_options) - if part_mod.fs_type == disk.FilesystemType.Btrfs: + if part_mod.fs_type == disk.FilesystemType.Btrfs and luks_handler.mapper_dev: self._mount_btrfs_subvol(luks_handler.mapper_dev, part_mod.btrfs_subvols) def _mount_btrfs_subvol(self, dev_path: Path, subvolumes: List[disk.SubvolumeModification]): @@ -346,15 +326,15 @@ class Installer: SysCommand(f'chmod 0600 {self.target}{file}') SysCommand(f'mkswap {self.target}{file}') - self.FSTAB_ENTRIES.append(f'{file} none swap defaults 0 0') + self._fstab_entries.append(f'{file} none swap defaults 0 0') if enable_resume: resume_uuid = SysCommand(f'findmnt -no UUID -T {self.target}{file}').decode('UTF-8').strip() resume_offset = SysCommand(f'/usr/bin/filefrag -v {self.target}{file}').decode('UTF-8').split('0:', 1)[1].split(":", 1)[1].split("..", 1)[0].strip() - self.HOOKS.append('resume') - self.KERNEL_PARAMS.append(f'resume=UUID={resume_uuid}') - self.KERNEL_PARAMS.append(f'resume_offset={resume_offset}') + self._hooks.append('resume') + self._kernel_params.append(f'resume=UUID={resume_uuid}') + self._kernel_params.append(f'resume_offset={resume_offset}') def post_install_check(self, *args :str, **kwargs :str) -> List[str]: return [step for step, flag in self.helper_flags.items() if flag is False] @@ -411,7 +391,7 @@ class Installer: else: pacman_conf.write(line) - def pacstrap(self, *packages: Union[str, List[str]], **kwargs :str) -> bool: + def _pacstrap(self, packages: Union[str, List[str]]) -> bool: if type(packages[0]) in (list, tuple): packages = packages[0] @@ -430,9 +410,9 @@ class Installer: if storage['arguments'].get('silent', False) is False: if input('Would you like to re-try this download? (Y/n): ').lower().strip() in ('', 'y'): - return self.pacstrap(*packages, **kwargs) + return self._pacstrap(packages) - raise RequirementError(f'Could not sync mirrors: {error}', level=logging.ERROR, fg="red") + raise RequirementError(f'Could not sync mirrors: {error}') try: SysCommand(f'/usr/bin/pacstrap -C /etc/pacman.conf -K {self.target} {" ".join(packages)} --noconfirm', peek_output=True) @@ -442,40 +422,44 @@ class Installer: if storage['arguments'].get('silent', False) is False: if input('Would you like to re-try this download? (Y/n): ').lower().strip() in ('', 'y'): - return self.pacstrap(*packages, **kwargs) + return self._pacstrap(packages) raise RequirementError("Pacstrap failed. See /var/log/archinstall/install.log or above message for error details.") - def set_mirrors(self, mirrors :Mapping[str, Iterator[str]]) -> None: + def set_mirrors(self, mirrors: Dict[str, Iterable[str]]): for plugin in plugins.values(): if hasattr(plugin, 'on_mirrors'): if result := plugin.on_mirrors(mirrors): mirrors = result - return use_mirrors(mirrors, destination=f'{self.target}/etc/pacman.d/mirrorlist') + destination = f'{self.target}/etc/pacman.d/mirrorlist' + use_mirrors(mirrors, destination=destination) def genfstab(self, flags :str = '-pU'): self.log(f"Updating {self.target}/etc/fstab", level=logging.INFO) try: - fstab = SysCommand(f'/usr/bin/genfstab {flags} {self.target}') + gen_fstab = SysCommand(f'/usr/bin/genfstab {flags} {self.target}').decode() except SysCallError as error: raise RequirementError(f'Could not generate fstab, strapping in packages most likely failed (disk out of space?)\n Error: {error}') - with open(f"{self.target}/etc/fstab", 'a') as fstab_fh: - fstab_fh.write(fstab.decode()) + if not gen_fstab: + raise RequirementError(f'Genrating fstab returned empty value') + + with open(f"{self.target}/etc/fstab", 'a') as fp: + fp.write(gen_fstab) if not os.path.isfile(f'{self.target}/etc/fstab'): - raise RequirementError(f'Could not generate fstab, strapping in packages most likely failed (disk out of space?)\n Error: {fstab}') + raise RequirementError(f'Could not create fstab file') for plugin in plugins.values(): if hasattr(plugin, 'on_genfstab'): if plugin.on_genfstab(self) is True: break - with open(f"{self.target}/etc/fstab", 'a') as fstab_fh: - for entry in self.FSTAB_ENTRIES: - fstab_fh.write(f'{entry}\n') + with open(f"{self.target}/etc/fstab", 'a') as fp: + for entry in self._fstab_entries: + fp.write(f'{entry}\n') for mod in self._disk_config.device_modifications: for part_mod in mod.partitions: @@ -583,7 +567,7 @@ class Installer: # fstrim is owned by util-linux, a dependency of both base and systemd. self.enable_service("fstrim.timer") - def enable_service(self, *services: Union[str, List[str]]) -> None: + def enable_service(self, services: Union[str, List[str]]) -> None: if type(services[0]) in (list, tuple): services = services[0] @@ -611,19 +595,7 @@ class Installer: subprocess.check_call(f"/usr/bin/arch-chroot {self.target}", shell=True) def configure_nic(self, network_config: NetworkConfiguration) -> None: - from .systemd import Networkd - - if network_config.dhcp: - conf = Networkd(Match={"Name": network_config.iface}, Network={"DHCP": "yes"}) - else: - network = {"Address": network_config.ip} - if network_config.gateway: - network["Gateway"] = network_config.gateway - if network_config.dns: - dns = network_config.dns - network["DNS"] = dns if isinstance(dns, list) else [dns] - - conf = Networkd(Match={"Name": network_config.iface}, Network=network) + conf = network_config.as_systemd_config() for plugin in plugins.values(): if hasattr(plugin, 'on_configure_nic'): @@ -663,7 +635,7 @@ class Installer: # Otherwise, we can go ahead and add the required package # and enable it's service: else: - self.pacstrap('iwd') + self._pacstrap('iwd') self.enable_service('iwd') for psk in psk_files: @@ -682,12 +654,12 @@ class Installer: if self.helper_flags.get('base', False) is False: def post_install_enable_networkd_resolved(*args :str, **kwargs :str): - self.enable_service('systemd-networkd', 'systemd-resolved') + self.enable_service(['systemd-networkd', 'systemd-resolved']) self.post_base_install.append(post_install_enable_networkd_resolved) # Otherwise, we can go ahead and enable the services else: - self.enable_service('systemd-networkd', 'systemd-resolved') + self.enable_service(['systemd-networkd', 'systemd-resolved']) return True @@ -704,9 +676,9 @@ class Installer: fh.write(f"KEYMAP={storage['arguments']['keyboard-layout']}\n") with open(f'{self.target}/etc/mkinitcpio.conf', 'w') as mkinit: - mkinit.write(f"MODULES=({' '.join(self.MODULES)})\n") - mkinit.write(f"BINARIES=({' '.join(self.BINARIES)})\n") - mkinit.write(f"FILES=({' '.join(self.FILES)})\n") + mkinit.write(f"MODULES=({' '.join(self.modules)})\n") + mkinit.write(f"BINARIES=({' '.join(self._binaries)})\n") + mkinit.write(f"FILES=({' '.join(self._files)})\n") if not self._disk_encryption.hsm_device: # For now, if we don't use HSM we revert to the old @@ -714,9 +686,9 @@ class Installer: # This is purely for stability reasons, we're going away from this. # * systemd -> udev # * sd-vconsole -> keymap - self.HOOKS = [hook.replace('systemd', 'udev').replace('sd-vconsole', 'keymap') for hook in self.HOOKS] + self._hooks = [hook.replace('systemd', 'udev').replace('sd-vconsole', 'keymap') for hook in self._hooks] - mkinit.write(f"HOOKS=({' '.join(self.HOOKS)})\n") + mkinit.write(f"HOOKS=({' '.join(self._hooks)})\n") try: SysCommand(f'/usr/bin/arch-chroot {self.target} mkinitcpio {" ".join(flags)}') @@ -736,25 +708,25 @@ class Installer: if (pkg := part.fs_type.installation_pkg) is not None: self.base_packages.append(pkg) if (module := part.fs_type.installation_module) is not None: - self.MODULES.append(module) + self.modules.append(module) if (binary := part.fs_type.installation_binary) is not None: - self.BINARIES.append(binary) + self._binaries.append(binary) # There is not yet an fsck tool for NTFS. If it's being used for the root filesystem, the hook should be removed. if part.fs_type.fs_type_mount == 'ntfs3' and part.mountpoint == self.target: - if 'fsck' in self.HOOKS: - self.HOOKS.remove('fsck') + if 'fsck' in self._hooks: + self._hooks.remove('fsck') if part in self._disk_encryption.partitions: if self._disk_encryption.hsm_device: # Required bby mkinitcpio to add support for fido2-device options - self.pacstrap('libfido2') + self._pacstrap('libfido2') - if 'sd-encrypt' not in self.HOOKS: - self.HOOKS.insert(self.HOOKS.index('filesystems'), 'sd-encrypt') + if 'sd-encrypt' not in self._hooks: + self._hooks.insert(self._hooks.index('filesystems'), 'sd-encrypt') else: - if 'encrypt' not in self.HOOKS: - self.HOOKS.insert(self.HOOKS.index('filesystems'), 'encrypt') + if 'encrypt' not in self._hooks: + self._hooks.insert(self._hooks.index('filesystems'), 'encrypt') if not has_uefi(): self.base_packages.append('grub') @@ -786,7 +758,7 @@ class Installer: else: self.log("The testing flag is not set. This system will be installed without testing repositories enabled.") - self.pacstrap(self.base_packages) + self._pacstrap(self.base_packages) self.helper_flags['base-strapped'] = True # This handles making sure that the repositories we enabled persist on the installed system @@ -826,7 +798,7 @@ class Installer: def setup_swap(self, kind :str = 'zram'): if kind == 'zram': self.log(f"Setting up swap on zram") - self.pacstrap('zram-generator') + self._pacstrap('zram-generator') # We could use the default example below, but maybe not the best idea: https://github.com/archlinux/archinstall/pull/678#issuecomment-962124813 # zram_example_location = '/usr/share/doc/zram-generator/zram-generator.conf.example' @@ -853,7 +825,7 @@ class Installer: return None def _add_systemd_bootloader(self, root_partition: disk.PartitionModification): - self.pacstrap('efibootmgr') + self._pacstrap('efibootmgr') if not has_uefi(): raise HardwareIncompatibilityError @@ -919,7 +891,7 @@ class Installer: # blkid doesn't trigger on loopback devices really well, # so we'll use the old manual method until we get that sorted out. - options_entry = f'rw rootfstype={root_partition.fs_type.fs_type_mount} {" ".join(self.KERNEL_PARAMS)}\n' + options_entry = f'rw rootfstype={root_partition.fs_type.fs_type_mount} {" ".join(self._kernel_params)}\n' for sub_vol in root_partition.btrfs_subvols: if sub_vol.is_root(): @@ -958,7 +930,7 @@ class Installer: boot_partition: disk.PartitionModification, root_partition: disk.PartitionModification ): - self.pacstrap('grub') # no need? + self._pacstrap('grub') # no need? _file = "/etc/default/grub" @@ -977,7 +949,7 @@ class Installer: log(f"GRUB boot partition: {boot_partition.dev_path}", level=logging.INFO) if has_uefi(): - self.pacstrap('efibootmgr') # TODO: Do we need? Yes, but remove from minimal_installation() instead? + self._pacstrap('efibootmgr') # TODO: Do we need? Yes, but remove from minimal_installation() instead? try: SysCommand(f'/usr/bin/arch-chroot {self.target} grub-install --debug --target=x86_64-efi --efi-directory=/boot --bootloader-id=GRUB --removable', peek_output=True) @@ -987,8 +959,20 @@ class Installer: except SysCallError as error: raise DiskError(f"Could not install GRUB to {self.target}/boot: {error}") else: + device = disk.device_handler.get_device_by_partition_path(boot_partition.safe_dev_path) + + if not device: + raise ValueError(f'Can not find block device: {boot_partition.safe_dev_path}') + try: - SysCommand(f'/usr/bin/arch-chroot {self.target} grub-install --debug --target=i386-pc --recheck {boot_partition.parent}', peek_output=True) + cmd = f'/usr/bin/arch-chroot' \ + f' {self.target}' \ + f' grub-install' \ + f' --debug' \ + f' --target=i386-pc' \ + f' --recheck {device.device_info.path}' + + SysCommand(cmd, peek_output=True) except SysCallError as error: raise DiskError(f"Failed to install GRUB boot on {boot_partition.dev_path}: {error}") @@ -1004,7 +988,7 @@ class Installer: boot_partition: disk.PartitionModification, root_partition: disk.PartitionModification ): - self.pacstrap('efibootmgr') + self._pacstrap('efibootmgr') if not has_uefi(): raise HardwareIncompatibilityError @@ -1038,17 +1022,30 @@ class Installer: # TODO: We need to detect if the encrypted device is a whole disk encryption, # or simply a partition encryption. Right now we assume it's a partition (and we always have) log(f'Identifying root partition by PARTUUID: {root_partition.partuuid}', level=logging.DEBUG) - kernel_parameters.append(f'cryptdevice=PARTUUID={root_partition.partuuid}:luksdev root=/dev/mapper/luksdev rw rootfstype={root_partition.fs_type.value} {" ".join(self.KERNEL_PARAMS)}') + kernel_parameters.append(f'cryptdevice=PARTUUID={root_partition.partuuid}:luksdev root=/dev/mapper/luksdev rw rootfstype={root_partition.fs_type.value} {" ".join(self._kernel_params)}') else: log(f'Root partition is an encrypted device identifying by PARTUUID: {root_partition.partuuid}', level=logging.DEBUG) - kernel_parameters.append(f'root=PARTUUID={root_partition.partuuid} rw rootfstype={root_partition.fs_type.value} {" ".join(self.KERNEL_PARAMS)}') + kernel_parameters.append(f'root=PARTUUID={root_partition.partuuid} rw rootfstype={root_partition.fs_type.value} {" ".join(self._kernel_params)}') + + device = disk.device_handler.get_device_by_partition_path(boot_partition.safe_dev_path) - device = disk.device_handler.get_device_by_partition_path(boot_partition.dev_path) - SysCommand(f'efibootmgr --disk {device.path} --part {device.path} --create --label "{label}" --loader {loader} --unicode \'{" ".join(kernel_parameters)}\' --verbose') + if not device: + raise ValueError(f'Unable to find block device: {boot_partition.safe_dev_path}') + + cmd = f'efibootmgr ' \ + f'--disk {device.device_info.path} ' \ + f'--part {boot_partition.safe_dev_path} ' \ + f'--create ' \ + f'--label "{label}" ' \ + f'--loader {loader} ' \ + f'--unicode \'{" ".join(kernel_parameters)}\' ' \ + f'--verbose' + + SysCommand(cmd) self.helper_flags['bootloader'] = "efistub" - def add_bootloader(self, bootloader: Bootloader) -> bool: + def add_bootloader(self, bootloader: Bootloader): """ Adds a bootloader to the installation instance. Archinstall supports one of three types: @@ -1056,8 +1053,7 @@ class Installer: * grub * efistub (beta) - :param bootloader: Can be one of the three strings - 'systemd-bootctl', 'grub' or 'efistub' (beta) + :param bootloader: Type of bootloader to be added """ for plugin in plugins.values(): @@ -1089,8 +1085,8 @@ class Installer: case Bootloader.Efistub: self._add_efistub_bootloader(boot_partition, root_partition) - def add_additional_packages(self, *packages: Union[str, List[str]]) -> bool: - return self.pacstrap(*packages) + def add_additional_packages(self, packages: Union[str, List[str]]) -> bool: + return self._pacstrap(packages) def _enable_users(self, service: str, users: List[User]): for user in users: @@ -1201,9 +1197,6 @@ class Installer: except SysCallError: return False - def create_file(self, filename :str, owner :Optional[str] = None) -> InstallationFile: - return InstallationFile(self, filename, owner) - def set_keyboard_language(self, language: str) -> bool: log(f"Setting keyboard language to {language}", level=logging.INFO) if len(language.strip()): diff --git a/archinstall/lib/menu/abstract_menu.py b/archinstall/lib/menu/abstract_menu.py index 53816655..e44d65a4 100644 --- a/archinstall/lib/menu/abstract_menu.py +++ b/archinstall/lib/menu/abstract_menu.py @@ -482,9 +482,9 @@ class AbstractMenu: if item in self._menus_to_enable(): yield item - def _select_archinstall_language(self, preset_value: Language) -> Language: + def _select_archinstall_language(self, preset: Language) -> Language: from ..user_interaction.general_conf import select_archinstall_language - language = select_archinstall_language(self.translation_handler.translated_languages, preset_value) + language = select_archinstall_language(self.translation_handler.translated_languages, preset) self._translation_handler.activate(language) return language diff --git a/archinstall/lib/menu/menu.py b/archinstall/lib/menu/menu.py index 44ac33a6..12dbf1f5 100644 --- a/archinstall/lib/menu/menu.py +++ b/archinstall/lib/menu/menu.py @@ -3,7 +3,7 @@ from enum import Enum, auto from os import system from typing import Dict, List, Union, Any, TYPE_CHECKING, Optional, Callable -from simple_term_menu import TerminalMenu +from simple_term_menu import TerminalMenu # type: ignore from ..exceptions import RequirementError from ..output import log @@ -29,11 +29,11 @@ class MenuSelection: @property def single_value(self) -> Any: - return self.value + return self.value # type: ignore @property def multi_value(self) -> List[Any]: - return self.value + return self.value # type: ignore class Menu(TerminalMenu): @@ -67,7 +67,7 @@ class Menu(TerminalMenu): preview_command: Optional[Callable] = None, preview_size: float = 0.0, preview_title: str = 'Info', - header: Union[List[str],str] = None, + header: Union[List[str], str] = [], allow_reset: bool = False, allow_reset_warning_msg: Optional[str] = None, clear_screen: bool = True, @@ -141,8 +141,6 @@ class Menu(TerminalMenu): log(f"invalid parameter at Menu() call was at <{sys._getframe(1).f_code.co_name}>",level=logging.WARNING) raise RequirementError("Menu() requires an iterable as option.") - self._default_str = str(_('(default)')) - if isinstance(p_options,dict): options = list(p_options.keys()) else: @@ -193,8 +191,7 @@ class Menu(TerminalMenu): if default_option: # if a default value was specified we move that one # to the top of the list and mark it as default as well - default = f'{default_option} {self._default_str}' - self._menu_options = [default] + [o for o in self._menu_options if default_option != o] + self._menu_options = [self._default_menu_value] + [o for o in self._menu_options if default_option != o] if display_back_option and not multi and skip: skip_empty_entries = True @@ -204,7 +201,18 @@ class Menu(TerminalMenu): skip_empty_entries = True self._menu_options += [''] - self._preselection(preset_values,cursor_index) + preset_list: Optional[List[str]] = None + + if preset_values and isinstance(preset_values, str): + preset_list = [preset_values] + + calc_cursor_idx = self._determine_cursor_pos(preset_list, cursor_index) + + # when we're not in multi selection mode we don't care about + # passing the pre-selection list to the menu as the position + # of the cursor is the one determining the pre-selection + if not self._multi: + preset_values = None cursor = "> " main_menu_cursor_style = ("fg_cyan", "bold") @@ -217,8 +225,8 @@ class Menu(TerminalMenu): menu_cursor_style=main_menu_cursor_style, menu_highlight_style=main_menu_style, multi_select=multi, - preselected_entries=self.preset_values, - cursor_index=self.cursor_index, + preselected_entries=preset_values, + cursor_index=calc_cursor_idx, preview_command=lambda x: self._show_preview(preview_command, x), preview_size=preview_size, preview_title=preview_title, @@ -231,12 +239,17 @@ class Menu(TerminalMenu): skip_empty_entries=skip_empty_entries ) + @property + def _default_menu_value(self) -> str: + default_str = str(_('(default)')) + return f'{self._default_option} {default_str}' + def _show_preview(self, preview_command: Optional[Callable], selection: str) -> Optional[str]: if selection == self.back(): return None if preview_command: - if self._default_option is not None and f'{self._default_option} {self._default_str}' == selection: + if self._default_option is not None and self._default_menu_value == selection: selection = self._default_option return preview_command(selection) @@ -249,7 +262,7 @@ class Menu(TerminalMenu): return MenuSelection(type_=MenuSelectionType.Reset) def check_default(elem): - if self._default_option is not None and f'{self._default_option} {self._default_str}' in elem: + if self._default_option is not None and self._default_menu_value in elem: return self._default_option else: return elem @@ -297,31 +310,44 @@ class Menu(TerminalMenu): pos = self._menu_entries.index(value) self.set_cursor_pos(pos) - def _preselection(self,preset_values :Union[str, List[str]] = [], cursor_index : Optional[int] = None): - def from_preset_to_cursor(): - if preset_values: - # if the value is not extant return 0 as cursor index + def _determine_cursor_pos( + self, + preset: Optional[List[str]] = None, + cursor_index: Optional[int] = None + ) -> Optional[int]: + """ + The priority order to determine the cursor position is: + 1. A static cursor position was provided + 2. Preset values have been provided so the cursor will be + positioned on those + 3. A default value for a selection is given so the cursor + will be placed on such + """ + if cursor_index: + return cursor_index + + if preset: + indexes = [] + + for p in preset: try: - if isinstance(preset_values,str): - self.cursor_index = self._menu_options.index(self.preset_values) - else: # should return an error, but this is smoother - self.cursor_index = self._menu_options.index(self.preset_values[0]) - except ValueError: - self.cursor_index = 0 - - self.cursor_index = cursor_index - if not preset_values: - self.preset_values = None - return - - self.preset_values = preset_values + # the options of the table selection menu + # are already escaped so we have to escape + # the preset values as well for the comparison + if '|' in p: + p = p.replace('|', '\\|') + + idx = self._menu_options.index(p) + indexes.append(idx) + except (IndexError, ValueError): + log(f'Error finding index of {p}: {self._menu_options}', level=logging.DEBUG) + + if len(indexes) == 0: + indexes.append(0) + + return indexes[0] + if self._default_option: - if isinstance(preset_values,str) and self._default_option == preset_values: - self.preset_values = f"{preset_values} {self._default_str}" - elif isinstance(preset_values,(list,tuple)) and self._default_option in preset_values: - idx = preset_values.index(self._default_option) - self.preset_values[idx] = f"{preset_values[idx]} {self._default_str}" - if cursor_index is None or not self._multi: - from_preset_to_cursor() - if not self._multi: # Not supported by the infraestructure - self.preset_values = None + return self._menu_options.index(self._default_menu_value) + + return None diff --git a/archinstall/lib/mirrors.py b/archinstall/lib/mirrors.py index 4bae6d8b..15d0fd6b 100644 --- a/archinstall/lib/mirrors.py +++ b/archinstall/lib/mirrors.py @@ -3,12 +3,22 @@ import pathlib import urllib.error import urllib.request from typing import Union, Iterable, Dict, Any, List +from dataclasses import dataclass from .general import SysCommand from .output import log from .storage import storage -def sort_mirrorlist(raw_data :bytes, sort_order=["https", "http"]) -> bytes: + +@dataclass +class CustomMirror: + url: str + signcheck: str + signoptions: str + name: str + + +def sort_mirrorlist(raw_data :bytes, sort_order: List[str] = ['https', 'http']) -> bytes: """ This function can sort /etc/pacman.d/mirrorlist according to the mirror's URL prefix. By default places HTTPS before HTTP but it also @@ -28,8 +38,9 @@ def sort_mirrorlist(raw_data :bytes, sort_order=["https", "http"]) -> bytes: from server url definitions (commented or uncommented). """ comments_and_whitespaces = b"" + sort_order += ['Unknown'] + categories: Dict[str, List] = {key: [] for key in sort_order} - categories = {key: [] for key in sort_order + ["Unknown"]} for line in raw_data.split(b"\n"): if line[0:2] in (b'##', b''): comments_and_whitespaces += line + b'\n' @@ -82,18 +93,18 @@ def filter_mirrors_by_region(regions :str, return new_list.decode('UTF-8') -def add_custom_mirrors(mirrors: List[str], *args :str, **kwargs :str) -> bool: +def add_custom_mirrors(mirrors: List[CustomMirror]) -> bool: """ This will append custom mirror definitions in pacman.conf - :param mirrors: A list of mirror data according to: `{'url': 'http://url.com', 'signcheck': 'Optional', 'signoptions': 'TrustAll', 'name': 'testmirror'}` - :type mirrors: dict + :param mirrors: A list of custom mirrors + :type mirrors: List[CustomMirror] """ with open('/etc/pacman.conf', 'a') as pacman: for mirror in mirrors: - pacman.write(f"[{mirror['name']}]\n") - pacman.write(f"SigLevel = {mirror['signcheck']} {mirror['signoptions']}\n") - pacman.write(f"Server = {mirror['url']}\n") + pacman.write(f"[{mirror.name}]\n") + pacman.write(f"SigLevel = {mirror.signcheck} {mirror.signoptions}\n") + pacman.write(f"Server = {mirror.url}\n") return True @@ -123,7 +134,7 @@ def insert_mirrors(mirrors :Dict[str, Any], *args :str, **kwargs :str) -> bool: def use_mirrors( regions: Dict[str, Iterable[str]], destination: str = '/etc/pacman.d/mirrorlist' -) -> None: +): log(f'A new package mirror-list has been created: {destination}', level=logging.INFO) with open(destination, 'w') as mirrorlist: for region, mirrors in regions.items(): @@ -146,7 +157,7 @@ def re_rank_mirrors( def list_mirrors(sort_order :List[str] = ["https", "http"]) -> Dict[str, Any]: - regions = {} + regions: Dict[str, Dict[str, Any]] = {} if storage['arguments']['offline']: with pathlib.Path('/etc/pacman.d/mirrorlist').open('rb') as fh: @@ -170,18 +181,19 @@ def list_mirrors(sort_order :List[str] = ["https", "http"]) -> Dict[str, Any]: if len(line.strip()) == 0: continue - line = line.decode('UTF-8').strip('\n').strip('\r') - if line[:3] == '## ': - region = line[3:] - elif line[:10] == '#Server = ': + clean_line = line.decode('UTF-8').strip('\n').strip('\r') + + if clean_line[:3] == '## ': + region = clean_line[3:] + elif clean_line[:10] == '#Server = ': regions.setdefault(region, {}) - url = line.lstrip('#Server = ') + url = clean_line.lstrip('#Server = ') regions[region][url] = True - elif line.startswith('Server = '): + elif clean_line.startswith('Server = '): regions.setdefault(region, {}) - url = line.lstrip('Server = ') + url = clean_line.lstrip('Server = ') regions[region][url] = True return regions diff --git a/archinstall/lib/models/network_configuration.py b/archinstall/lib/models/network_configuration.py index b7ab690d..66230e24 100644 --- a/archinstall/lib/models/network_configuration.py +++ b/archinstall/lib/models/network_configuration.py @@ -1,8 +1,9 @@ from __future__ import annotations -from dataclasses import dataclass +import logging +from dataclasses import dataclass, field from enum import Enum -from typing import List, Optional, Dict, Union, Any, TYPE_CHECKING +from typing import List, Optional, Dict, Union, Any, TYPE_CHECKING, Tuple from ..output import log from ..storage import storage @@ -24,7 +25,7 @@ class NetworkConfiguration: ip: Optional[str] = None dhcp: bool = True gateway: Optional[str] = None - dns: Union[None, List[str]] = None + dns: List[str] = field(default_factory=list) def __str__(self): if self.is_iso(): @@ -53,6 +54,33 @@ class NetworkConfiguration: return data + def as_systemd_config(self) -> str: + match: List[Tuple[str, str]] = [] + network: List[Tuple[str, str]] = [] + + if self.iface: + match.append(('Name', self.iface)) + + if self.dhcp: + network.append(('DHCP', 'yes')) + else: + if self.ip: + network.append(('Address', self.ip)) + if self.gateway: + network.append(('Gateway', self.gateway)) + for dns in self.dns: + network.append(('DNS', dns)) + + config = {'Match': match, 'Network': network} + + config_str = '' + for top, entries in config.items(): + config_str += f'[{top}]\n' + config_str += '\n'.join([f'{k}={v}' for k, v in entries]) + config_str += '\n\n' + + return config_str + def json(self) -> Dict: # for json serialization when calling json.dumps(...) on this class return self.__dict__ @@ -90,41 +118,14 @@ class NetworkConfigurationHandler: # Perform a copy of the config if self._configuration.is_iso(): installation.copy_iso_network_config( - enable_services=True) # Sources the ISO network configuration to the install medium. + enable_services=True # Sources the ISO network configuration to the install medium. + ) elif self._configuration.is_network_manager(): installation.add_additional_packages(["networkmanager"]) if (profile := storage['arguments'].get('profile_config')) and profile.is_desktop_type_profile: installation.add_additional_packages(["network-manager-applet"]) installation.enable_service('NetworkManager.service') - def _backwards_compability_config(self, config: Union[str,Dict[str, str]]) -> Union[List[NetworkConfiguration], NetworkConfiguration, None]: - def get(config: Dict[str, str], key: str) -> List[str]: - if (value := config.get(key, None)) is not None: - return [value] - return [] - - if isinstance(config, str): # is a ISO network - return NetworkConfiguration(NicType.ISO) - elif config.get('NetworkManager'): # is a network manager configuration - return NetworkConfiguration(NicType.NM) - elif 'ip' in config: - return [NetworkConfiguration( - NicType.MANUAL, - iface=config.get('nic', ''), - ip=config.get('ip'), - gateway=config.get('gateway', ''), - dns=get(config, 'dns'), - dhcp=False - )] - elif 'nic' in config: - return [NetworkConfiguration( - NicType.MANUAL, - iface=config.get('nic', ''), - dhcp=True - )] - else: # not recognized - return None - def _parse_manual_config(self, configs: List[Dict[str, Any]]) -> Optional[List[NetworkConfiguration]]: configurations = [] @@ -145,13 +146,17 @@ class NetworkConfigurationHandler: log(_('Manual nic configuration with no auto DHCP requires an IP address'), fg='red') exit(1) + dns = manual_config.get('dns', []) + if not isinstance(dns, list): + dns = [dns] + configurations.append( NetworkConfiguration( NicType.MANUAL, iface=iface, ip=ip, gateway=manual_config.get('gateway', ''), - dns=manual_config.get('dns', []), + dns=dns, dhcp=False ) ) @@ -176,8 +181,5 @@ class NetworkConfigurationHandler: self._configuration = NetworkConfiguration(type_) else: # manual configuration settings self._configuration = self._parse_manual_config([config]) - else: # old style definitions - network_config = self._backwards_compability_config(config) - if network_config: - return network_config - return None + else: + log(f'Unable to parse network configuration: {config}', level=logging.DEBUG) diff --git a/archinstall/lib/plugins.py b/archinstall/lib/plugins.py index 0ff63610..b1ece04f 100644 --- a/archinstall/lib/plugins.py +++ b/archinstall/lib/plugins.py @@ -3,77 +3,86 @@ import importlib import logging import os import sys -import pathlib import urllib.parse import urllib.request from importlib import metadata +from pathlib import Path from typing import Optional, List -from types import ModuleType from .output import log from .storage import storage plugins = {} + # 1: List archinstall.plugin definitions # 2: Load the plugin entrypoint # 3: Initiate the plugin and store it as .name in plugins for plugin_definition in metadata.entry_points().select(group='archinstall.plugin'): plugin_entrypoint = plugin_definition.load() + try: plugins[plugin_definition.name] = plugin_entrypoint() except Exception as err: - log(err, level=logging.ERROR) + log(f'Error: {err}', level=logging.ERROR) log(f"The above error was detected when loading the plugin: {plugin_definition}", fg="red", level=logging.ERROR) -# The following functions and core are support structures for load_plugin() -def localize_path(profile_path :str) -> str: - if (url := urllib.parse.urlparse(profile_path)).scheme and url.scheme in ('https', 'http'): - converted_path = f"/tmp/{os.path.basename(profile_path).replace('.py', '')}_{hashlib.md5(os.urandom(12)).hexdigest()}.py" +def localize_path(path: Path) -> Path: + """ + Support structures for load_plugin() + """ + url = urllib.parse.urlparse(str(path)) + + if url.scheme and url.scheme in ('https', 'http'): + converted_path = Path(f'/tmp/{path.stem}_{hashlib.md5(os.urandom(12)).hexdigest()}.py') with open(converted_path, "w") as temp_file: temp_file.write(urllib.request.urlopen(url.geturl()).read().decode('utf-8')) return converted_path else: - return profile_path + return path -def import_via_path(path :str, namespace :Optional[str] = None) -> ModuleType: +def import_via_path(path: Path, namespace: Optional[str] = None) -> Optional[str]: if not namespace: namespace = os.path.basename(path) if namespace == '__init__.py': - path = pathlib.PurePath(path) namespace = path.parent.name try: spec = importlib.util.spec_from_file_location(namespace, path) - imported = importlib.util.module_from_spec(spec) - sys.modules[namespace] = imported - spec.loader.exec_module(sys.modules[namespace]) + if spec and spec.loader: + imported = importlib.util.module_from_spec(spec) + sys.modules[namespace] = imported + spec.loader.exec_module(sys.modules[namespace]) return namespace except Exception as err: - log(err, level=logging.ERROR) + log(f'Error: {err}', level=logging.ERROR) log(f"The above error was detected when loading the plugin: {path}", fg="red", level=logging.ERROR) try: - del(sys.modules[namespace]) # noqa: E275 - except: + del sys.modules[namespace] + except Exception: pass -def find_nth(haystack :List[str], needle :str, n :int) -> int: - start = haystack.find(needle) - while start >= 0 and n > 1: - start = haystack.find(needle, start + len(needle)) - n -= 1 - return start + return namespace + + +def find_nth(haystack: List[str], needle: str, n: int) -> Optional[int]: + indices = [idx for idx, elem in enumerate(haystack) if elem == needle] + if n <= len(indices): + return indices[n - 1] + return None + -def load_plugin(path :str) -> ModuleType: - parsed_url = urllib.parse.urlparse(path) - log(f"Loading plugin {parsed_url}.", fg="gray", level=logging.INFO) +def load_plugin(path: Path): + namespace: Optional[str] = None + parsed_url = urllib.parse.urlparse(str(path)) + log(f"Loading plugin from url {parsed_url}.", level=logging.INFO) # The Profile was not a direct match on a remote URL if not parsed_url.scheme: @@ -81,9 +90,10 @@ def load_plugin(path :str) -> ModuleType: if os.path.isfile(path): namespace = import_via_path(path) elif parsed_url.scheme in ('https', 'http'): - namespace = import_via_path(localize_path(path)) + localized = localize_path(path) + namespace = import_via_path(localized) - if namespace in sys.modules: + if namespace and namespace in sys.modules: # Version dependency via __archinstall__version__ variable (if present) in the plugin # Any errors in version inconsistency will be handled through normal error handling if not defined. if hasattr(sys.modules[namespace], '__archinstall__version__'): @@ -99,7 +109,7 @@ def load_plugin(path :str) -> ModuleType: plugins[namespace] = sys.modules[namespace].Plugin() log(f"Plugin {plugins[namespace]} has been loaded.", fg="gray", level=logging.INFO) except Exception as err: - log(err, level=logging.ERROR) + log(f'Error: {err}', level=logging.ERROR) log(f"The above error was detected when initiating the plugin: {path}", fg="red", level=logging.ERROR) else: log(f"Plugin '{path}' is missing a valid entry-point or is corrupt.", fg="yellow", level=logging.WARNING) diff --git a/archinstall/lib/profile/profiles_handler.py b/archinstall/lib/profile/profiles_handler.py index a8b5cc22..824849c3 100644 --- a/archinstall/lib/profile/profiles_handler.py +++ b/archinstall/lib/profile/profiles_handler.py @@ -194,23 +194,23 @@ class ProfileHandler: install_session.add_additional_packages(f"{kernel}-headers") # I've had kernel regen fail if it wasn't installed before nvidia-dkms - install_session.add_additional_packages("dkms xorg-server xorg-xinit nvidia-dkms") + install_session.add_additional_packages(['dkms', 'xorg-server', 'xorg-xinit', 'nvidia-dkms']) return elif 'amdgpu' in driver_pkgs: # The order of these two are important if amdgpu is installed #808 - if 'amdgpu' in install_session.MODULES: - install_session.MODULES.remove('amdgpu') - install_session.MODULES.append('amdgpu') + if 'amdgpu' in install_session.modules: + install_session.modules.remove('amdgpu') + install_session.modules.append('amdgpu') - if 'radeon' in install_session.MODULES: - install_session.MODULES.remove('radeon') - install_session.MODULES.append('radeon') + if 'radeon' in install_session.modules: + install_session.modules.remove('radeon') + install_session.modules.append('radeon') install_session.add_additional_packages(additional_pkg) except Exception as err: log(f"Could not handle nvidia and linuz-zen specific situations during xorg installation: {err}", level=logging.WARNING, fg="yellow") # Prep didn't run, so there's no driver to install - install_session.add_additional_packages("xorg-server xorg-xinit") + install_session.add_additional_packages(['xorg-server', 'xorg-xinit']) def install_profile_config(self, install_session: 'Installer', profile_config: ProfileConfiguration): profile = profile_config.profile diff --git a/archinstall/lib/systemd.py b/archinstall/lib/systemd.py index 64ffcae4..6ccbc5f6 100644 --- a/archinstall/lib/systemd.py +++ b/archinstall/lib/systemd.py @@ -1,6 +1,6 @@ import logging import time -from typing import Iterator +from typing import Iterator, Optional from .exceptions import SysCallError from .general import SysCommand, SysCommandWorker, locate_binary from .installer import Installer @@ -8,51 +8,11 @@ from .output import log from .storage import storage -class Ini: - def __init__(self, *args :str, **kwargs :str): - """ - Limited INI handler for now. - Supports multiple keywords through dictionary list items. - """ - self.kwargs = kwargs - - def __str__(self) -> str: - result = '' - first_row_done = False - for top_level in self.kwargs: - if first_row_done: - result += f"\n[{top_level}]\n" - else: - result += f"[{top_level}]\n" - first_row_done = True - - for key, val in self.kwargs[top_level].items(): - if type(val) == list: - for item in val: - result += f"{key}={item}\n" - else: - result += f"{key}={val}\n" - - return result - - -class Systemd(Ini): - """ - Placeholder class to do systemd specific setups. - """ - - -class Networkd(Systemd): - """ - Placeholder class to do systemd-network specific setups. - """ - - class Boot: def __init__(self, installation: Installer): self.instance = installation self.container_name = 'archinstall' - self.session = None + self.session: Optional[SysCommandWorker] = None self.ready = False def __enter__(self) -> 'Boot': @@ -63,17 +23,18 @@ class Boot: self.session = existing_session.session self.ready = existing_session.ready else: + # '-P' or --console=pipe could help us not having to do a bunch + # of os.write() calls, but instead use pipes (stdin, stdout and stderr) as usual. self.session = SysCommandWorker([ '/usr/bin/systemd-nspawn', - '-D', self.instance.target, + '-D', str(self.instance.target), '--timezone=off', '-b', '--no-pager', '--machine', self.container_name ]) - # '-P' or --console=pipe could help us not having to do a bunch of os.write() calls, but instead use pipes (stdin, stdout and stderr) as usual. - if not self.ready: + if not self.ready and self.session: while self.session.is_alive(): if b' login:' in self.session: self.ready = True @@ -91,25 +52,31 @@ class Boot: log(f"The error above occurred in a temporary boot-up of the installation {self.instance}", level=logging.ERROR, fg="red") shutdown = None - shutdown_exit_code = -1 + shutdown_exit_code: Optional[int] = -1 try: shutdown = SysCommand(f'systemd-run --machine={self.container_name} --pty shutdown now') except SysCallError as error: shutdown_exit_code = error.exit_code - while self.session.is_alive(): - time.sleep(0.25) + if self.session: + while self.session.is_alive(): + time.sleep(0.25) - if shutdown: + if shutdown and shutdown.exit_code: shutdown_exit_code = shutdown.exit_code - if self.session.exit_code == 0 or shutdown_exit_code == 0: + if self.session and (self.session.exit_code == 0 or shutdown_exit_code == 0): storage['active_boot'] = None else: - raise SysCallError(f"Could not shut down temporary boot of {self.instance}: {self.session.exit_code}/{shutdown_exit_code}", exit_code=next(filter(bool, [self.session.exit_code, shutdown_exit_code]))) + session_exit_code = self.session.exit_code if self.session else -1 + + raise SysCallError( + f"Could not shut down temporary boot of {self.instance}: {session_exit_code}/{shutdown_exit_code}", + exit_code=next(filter(bool, [session_exit_code, shutdown_exit_code])) + ) - def __iter__(self) -> Iterator[str]: + def __iter__(self) -> Iterator[bytes]: if self.session: for value in self.session: yield value diff --git a/archinstall/lib/user_interaction/general_conf.py b/archinstall/lib/user_interaction/general_conf.py index 7a6bb358..9722dc4d 100644 --- a/archinstall/lib/user_interaction/general_conf.py +++ b/archinstall/lib/user_interaction/general_conf.py @@ -3,7 +3,6 @@ from __future__ import annotations import logging import pathlib from typing import List, Any, Optional, Dict, TYPE_CHECKING -from typing import Union from ..locale_helpers import list_keyboard_languages, list_timezones from ..menu import MenuSelectionType, Menu, TextInput @@ -29,13 +28,18 @@ def ask_ntp(preset: bool = True) -> bool: return False if choice.value == Menu.no() else True -def ask_hostname(preset: str = None) -> str: +def ask_hostname(preset: str = '') -> str: while True: - hostname = TextInput(_('Desired hostname for the installation: '), preset).run().strip() + hostname = TextInput( + str(_('Desired hostname for the installation: ')), + preset + ).run().strip() + if hostname: return hostname -def ask_for_a_timezone(preset: str = None) -> str: + +def ask_for_a_timezone(preset: Optional[str] = None) -> Optional[str]: timezones = list_timezones() default = 'UTC' @@ -48,10 +52,12 @@ def ask_for_a_timezone(preset: str = None) -> str: match choice.type_: case MenuSelectionType.Skip: return preset - case MenuSelectionType.Selection: return choice.value + case MenuSelectionType.Selection: return choice.single_value + + return None -def ask_for_audio_selection(desktop: bool = True, preset: Union[str, None] = None) -> Union[str, None]: +def ask_for_audio_selection(desktop: bool = True, preset: Optional[str] = None) -> Optional[str]: no_audio = str(_('No audio server')) choices = ['pipewire', 'pulseaudio'] if desktop else ['pipewire', 'pulseaudio', no_audio] default = 'pipewire' if desktop else no_audio @@ -60,10 +66,12 @@ def ask_for_audio_selection(desktop: bool = True, preset: Union[str, None] = Non match choice.type_: case MenuSelectionType.Skip: return preset - case MenuSelectionType.Selection: return choice.value + case MenuSelectionType.Selection: return choice.single_value + return None -def select_language(preset_value: str = None) -> str: + +def select_language(preset: Optional[str] = None) -> Optional[str]: """ Asks the user to select a language Usually this is combined with :ref:`archinstall.list_keyboard_languages`. @@ -75,17 +83,18 @@ def select_language(preset_value: str = None) -> str: # sort alphabetically and then by length sorted_kb_lang = sorted(sorted(list(kb_lang)), key=len) - selected_lang = Menu( + choice = Menu( _('Select keyboard layout'), sorted_kb_lang, - preset_values=preset_value, + preset_values=preset, sort=False ).run() - if selected_lang.value is None: - return preset_value + match choice.type_: + case MenuSelectionType.Skip: return preset + case MenuSelectionType.Selection: return choice.single_value - return selected_lang.value + return None def select_mirror_regions(preset_values: Dict[str, Any] = {}) -> Dict[str, Any]: @@ -100,8 +109,10 @@ def select_mirror_regions(preset_values: Dict[str, Any] = {}) -> Dict[str, Any]: preselected = None else: preselected = list(preset_values.keys()) + mirrors = list_mirrors() - selected_mirror = Menu( + + choice = Menu( _('Select one of the regions to download packages from'), list(mirrors.keys()), preset_values=preselected, @@ -109,13 +120,18 @@ def select_mirror_regions(preset_values: Dict[str, Any] = {}) -> Dict[str, Any]: allow_reset=True ).run() - match selected_mirror.type_: - case MenuSelectionType.Reset: return {} - case MenuSelectionType.Skip: return preset_values - case _: return {selected: mirrors[selected] for selected in selected_mirror.value} + match choice.type_: + case MenuSelectionType.Reset: + return {} + case MenuSelectionType.Skip: + return preset_values + case MenuSelectionType.Selection: + return {selected: mirrors[selected] for selected in choice.multi_value} + + return {} -def select_archinstall_language(languages: List[Language], preset_value: Language) -> Language: +def select_archinstall_language(languages: List[Language], preset: Language) -> Language: # these are the displayed language names which can either be # the english name of a language or, if present, the # name of the language in its own language @@ -128,15 +144,15 @@ def select_archinstall_language(languages: List[Language], preset_value: Languag choice = Menu( title, list(options.keys()), - default_option=preset_value.display_name, + default_option=preset.display_name, preview_size=0.5 ).run() match choice.type_: - case MenuSelectionType.Skip: - return preset_value - case MenuSelectionType.Selection: - return options[choice.value] + case MenuSelectionType.Skip: return preset + case MenuSelectionType.Selection: return options[choice.single_value] + + raise ValueError('Language selection not handled') def ask_additional_packages_to_install(pre_set_packages: List[str] = []) -> List[str]: @@ -223,4 +239,6 @@ def select_additional_repositories(preset: List[str]) -> List[str]: match choice.type_: case MenuSelectionType.Skip: return preset case MenuSelectionType.Reset: return [] - case MenuSelectionType.Selection: return choice.value + case MenuSelectionType.Selection: return choice.single_value + + return [] diff --git a/archinstall/lib/user_interaction/locale_conf.py b/archinstall/lib/user_interaction/locale_conf.py index 88aec64e..cdc3423a 100644 --- a/archinstall/lib/user_interaction/locale_conf.py +++ b/archinstall/lib/user_interaction/locale_conf.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Any, TYPE_CHECKING +from typing import Any, TYPE_CHECKING, Optional from ..locale_helpers import list_locales from ..menu import Menu, MenuSelectionType @@ -9,33 +9,37 @@ if TYPE_CHECKING: _: Any -def select_locale_lang(preset: str = None) -> str: +def select_locale_lang(preset: Optional[str] = None) -> Optional[str]: locales = list_locales() locale_lang = set([locale.split()[0] for locale in locales]) - selected_locale = Menu( + choice = Menu( _('Choose which locale language to use'), list(locale_lang), sort=True, preset_values=preset ).run() - match selected_locale.type_: - case MenuSelectionType.Selection: return selected_locale.value + match choice.type_: + case MenuSelectionType.Selection: return choice.single_value case MenuSelectionType.Skip: return preset + return None -def select_locale_enc(preset: str = None) -> str: + +def select_locale_enc(preset: Optional[str] = None) -> Optional[str]: locales = list_locales() locale_enc = set([locale.split()[1] for locale in locales]) - selected_locale = Menu( + choice = Menu( _('Choose which locale encoding to use'), list(locale_enc), sort=True, preset_values=preset ).run() - match selected_locale.type_: - case MenuSelectionType.Selection: return selected_locale.value + match choice.type_: + case MenuSelectionType.Selection: return choice.single_value case MenuSelectionType.Skip: return preset + + return None diff --git a/archinstall/scripts/guided.py b/archinstall/scripts/guided.py index d9c5837c..48d141b7 100644 --- a/archinstall/scripts/guided.py +++ b/archinstall/scripts/guided.py @@ -223,7 +223,7 @@ def perform_installation(mountpoint: Path): # If the user provided a list of services to be enabled, pass the list to the enable_service function. # Note that while it's called enable_service, it can actually take a list of services and iterate it. if archinstall.arguments.get('services', None): - installation.enable_service(*archinstall.arguments['services']) + installation.enable_service(archinstall.arguments.get('services', [])) # If the user provided custom commands to be run post-installation, execute them now. if archinstall.arguments.get('custom-commands', None): diff --git a/archinstall/scripts/swiss.py b/archinstall/scripts/swiss.py index e2ee6fcb..34e4c022 100644 --- a/archinstall/scripts/swiss.py +++ b/archinstall/scripts/swiss.py @@ -239,7 +239,7 @@ def perform_installation(mountpoint: Path, exec_mode: ExecutionMode): handler.config_installer(installation) if archinstall.arguments.get('packages', None) and archinstall.arguments.get('packages', None)[0] != '': - installation.add_additional_packages(archinstall.arguments.get('packages', None)) + installation.add_additional_packages(archinstall.arguments.get('packages', [])) if users := archinstall.arguments.get('!users', None): installation.create_users(users) @@ -278,7 +278,7 @@ def perform_installation(mountpoint: Path, exec_mode: ExecutionMode): # If the user provided a list of services to be enabled, pass the list to the enable_service function. # Note that while it's called enable_service, it can actually take a list of services and iterate it. if archinstall.arguments.get('services', None): - installation.enable_service(*archinstall.arguments['services']) + installation.enable_service(archinstall.arguments.get('services', [])) # If the user provided custom commands to be run post-installation, execute them now. if archinstall.arguments.get('custom-commands', None): diff --git a/examples/interactive_installation.py b/examples/interactive_installation.py index a78b1712..f72f110b 100644 --- a/examples/interactive_installation.py +++ b/examples/interactive_installation.py @@ -147,7 +147,7 @@ def perform_installation(mountpoint: Path): handler.config_installer(installation) if archinstall.arguments.get('packages', None) and archinstall.arguments.get('packages', None)[0] != '': - installation.add_additional_packages(archinstall.arguments.get('packages', None)) + installation.add_additional_packages(archinstall.arguments.get('packages', [])) if users := archinstall.arguments.get('!users', None): installation.create_users(users) @@ -186,7 +186,7 @@ def perform_installation(mountpoint: Path): # If the user provided a list of services to be enabled, pass the list to the enable_service function. # Note that while it's called enable_service, it can actually take a list of services and iterate it. if archinstall.arguments.get('services', None): - installation.enable_service(*archinstall.arguments['services']) + installation.enable_service(archinstall.arguments.get('services', [])) # If the user provided custom commands to be run post-installation, execute them now. if archinstall.arguments.get('custom-commands', None): diff --git a/mypy.ini b/mypy.ini deleted file mode 100644 index a08b2d88..00000000 --- a/mypy.ini +++ /dev/null @@ -1,14 +0,0 @@ -[mypy] -python_version = 3.10 -follow_imports = silent -exclude = (?x)(^archinstall/lib/disk/btrfs/btrfssubvolumeinfo\.py$ - | ^archinstall/lib/general\.py$ - | ^archinstall/lib/hardware\.py$ - | ^archinstall/lib/menu/menu\.py$ - | ^archinstall/lib/mirrors\.py$ - | ^archinstall/lib/plugins\.py$ - | ^archinstall/lib/installer\.py$ - | ^archinstall/lib/systemd\.py$ - | ^archinstall/lib/user_interaction/general_conf\.py$ - | ^archinstall/lib/user_interaction/locale_conf\.py$) -files = archinstall/ diff --git a/pyproject.toml b/pyproject.toml index 557418cc..f837ebdf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,6 +60,7 @@ packages = ["archinstall"] [tool.mypy] python_version = "3.10" +files = "archinstall/" exclude = "tests" [tool.bandit] -- cgit v1.2.3-70-g09d2 From a968dccbf1b9c83c8abc40e709c5b1c3142a5f2c Mon Sep 17 00:00:00 2001 From: Daemon Coder <11915375+codefiles@users.noreply.github.com> Date: Sun, 7 May 2023 02:48:46 -0400 Subject: Fix default value idx in `_determine_cursor_pos()` (#1800) Co-authored-by: Daniel Girtler Co-authored-by: Daniel Girtler --- archinstall/lib/menu/menu.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) (limited to 'archinstall/lib/menu') diff --git a/archinstall/lib/menu/menu.py b/archinstall/lib/menu/menu.py index 12dbf1f5..f3fdb85f 100644 --- a/archinstall/lib/menu/menu.py +++ b/archinstall/lib/menu/menu.py @@ -337,7 +337,10 @@ class Menu(TerminalMenu): if '|' in p: p = p.replace('|', '\\|') - idx = self._menu_options.index(p) + if p in self._menu_options: + idx = self._menu_options.index(p) + else: + idx = self._menu_options.index(self._default_menu_value) indexes.append(idx) except (IndexError, ValueError): log(f'Error finding index of {p}: {self._menu_options}', level=logging.DEBUG) -- cgit v1.2.3-70-g09d2 From 89cefb9a1c7d4c4968e7d8645149078e601c9d1c Mon Sep 17 00:00:00 2001 From: Daniel Girtler Date: Fri, 12 May 2023 02:30:09 +1000 Subject: Cleanup imports and unused code (#1801) * Cleanup imports and unused code * Update build check * Keep deprecation exception * Simplify logging --------- Co-authored-by: Daniel Girtler --- .github/workflows/python-build.yml | 5 +- README.md | 2 +- archinstall/__init__.py | 85 +++-- archinstall/default_profiles/desktop.py | 4 +- archinstall/default_profiles/server.py | 9 +- archinstall/lib/boot.py | 111 ++++++ archinstall/lib/configuration.py | 114 +++++- archinstall/lib/disk/device_handler.py | 99 +++--- archinstall/lib/disk/device_model.py | 50 +-- archinstall/lib/disk/encryption_menu.py | 2 +- archinstall/lib/disk/fido.py | 11 +- archinstall/lib/disk/filesystem.py | 11 +- archinstall/lib/disk/partitioning_menu.py | 9 +- archinstall/lib/exceptions.py | 23 +- archinstall/lib/general.py | 88 ++--- archinstall/lib/global_menu.py | 36 +- archinstall/lib/hardware.py | 274 +++++++------- archinstall/lib/installer.py | 225 ++++++------ archinstall/lib/interactions/__init__.py | 20 ++ archinstall/lib/interactions/disk_conf.py | 393 +++++++++++++++++++++ archinstall/lib/interactions/general_conf.py | 243 +++++++++++++ archinstall/lib/interactions/locale_conf.py | 43 +++ archinstall/lib/interactions/manage_users_conf.py | 106 ++++++ archinstall/lib/interactions/network_conf.py | 172 +++++++++ archinstall/lib/interactions/system_conf.py | 117 ++++++ archinstall/lib/interactions/utils.py | 34 ++ archinstall/lib/locale.py | 68 ++++ archinstall/lib/locale_helpers.py | 176 --------- archinstall/lib/luks.py | 31 +- archinstall/lib/menu/abstract_menu.py | 9 +- archinstall/lib/menu/menu.py | 27 +- archinstall/lib/mirrors.py | 7 +- archinstall/lib/models/bootloader.py | 9 +- archinstall/lib/models/network_configuration.py | 14 +- archinstall/lib/networking.py | 53 +-- archinstall/lib/output.py | 154 +++++--- archinstall/lib/pacman.py | 7 +- archinstall/lib/plugins.py | 43 ++- archinstall/lib/profile/profile_menu.py | 2 +- archinstall/lib/profile/profiles_handler.py | 21 +- archinstall/lib/services.py | 11 - archinstall/lib/storage.py | 4 +- archinstall/lib/systemd.py | 110 ------ archinstall/lib/translationhandler.py | 14 +- archinstall/lib/user_interaction/__init__.py | 10 - archinstall/lib/user_interaction/disk_conf.py | 391 -------------------- archinstall/lib/user_interaction/general_conf.py | 244 ------------- archinstall/lib/user_interaction/locale_conf.py | 45 --- .../lib/user_interaction/manage_users_conf.py | 106 ------ archinstall/lib/user_interaction/network_conf.py | 173 --------- archinstall/lib/user_interaction/save_conf.py | 113 ------ archinstall/lib/user_interaction/system_conf.py | 117 ------ archinstall/lib/user_interaction/utils.py | 34 -- archinstall/lib/utils/util.py | 4 +- archinstall/scripts/guided.py | 35 +- archinstall/scripts/minimal.py | 30 +- archinstall/scripts/only_hd.py | 29 +- archinstall/scripts/swiss.py | 50 +-- archinstall/scripts/unattended.py | 9 +- examples/full_automated_installation.py | 11 +- examples/interactive_installation.py | 29 +- examples/mac_address_installation.py | 8 +- examples/minimal_installation.py | 21 +- examples/only_hd_installation.py | 6 +- pyproject.toml | 1 + 65 files changed, 2124 insertions(+), 2388 deletions(-) create mode 100644 archinstall/lib/boot.py create mode 100644 archinstall/lib/interactions/__init__.py create mode 100644 archinstall/lib/interactions/disk_conf.py create mode 100644 archinstall/lib/interactions/general_conf.py create mode 100644 archinstall/lib/interactions/locale_conf.py create mode 100644 archinstall/lib/interactions/manage_users_conf.py create mode 100644 archinstall/lib/interactions/network_conf.py create mode 100644 archinstall/lib/interactions/system_conf.py create mode 100644 archinstall/lib/interactions/utils.py create mode 100644 archinstall/lib/locale.py delete mode 100644 archinstall/lib/locale_helpers.py delete mode 100644 archinstall/lib/services.py delete mode 100644 archinstall/lib/systemd.py delete mode 100644 archinstall/lib/user_interaction/__init__.py delete mode 100644 archinstall/lib/user_interaction/disk_conf.py delete mode 100644 archinstall/lib/user_interaction/general_conf.py delete mode 100644 archinstall/lib/user_interaction/locale_conf.py delete mode 100644 archinstall/lib/user_interaction/manage_users_conf.py delete mode 100644 archinstall/lib/user_interaction/network_conf.py delete mode 100644 archinstall/lib/user_interaction/save_conf.py delete mode 100644 archinstall/lib/user_interaction/system_conf.py delete mode 100644 archinstall/lib/user_interaction/utils.py (limited to 'archinstall/lib/menu') diff --git a/.github/workflows/python-build.yml b/.github/workflows/python-build.yml index f98ce160..950ff8f4 100644 --- a/.github/workflows/python-build.yml +++ b/.github/workflows/python-build.yml @@ -36,7 +36,10 @@ jobs: - name: Run archinstall run: | python -V - archinstall -v + archinstall --script guided -v + archinstall --script swiss -v + archinstall --script only_hd -v + archinstall --script minimal -v - uses: actions/upload-artifact@v3 with: name: archinstall diff --git a/README.md b/README.md index 720bd487..15646170 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,7 @@ from archinstall.default_profiles.minimal import MinimalProfile from archinstall.lib.disk.device_model import FilesystemType from archinstall.lib.disk.encryption_menu import DiskEncryptionMenu from archinstall.lib.disk.filesystem import FilesystemHandler -from archinstall.lib.user_interaction.disk_conf import select_disk_config +from archinstall.lib.interactions.disk_conf import select_disk_config fs_type = FilesystemType('ext4') diff --git a/archinstall/__init__.py b/archinstall/__init__.py index 6f67d20f..992bd9fa 100644 --- a/archinstall/__init__.py +++ b/archinstall/__init__.py @@ -1,42 +1,75 @@ """Arch Linux installer - guided, templates etc.""" import importlib +import os from argparse import ArgumentParser, Namespace +from pathlib import Path +from typing import TYPE_CHECKING, Any, Dict, Union from .lib import disk from .lib import menu from .lib import models from .lib import packages - -from .lib.exceptions import * -from .lib.general import * -from .lib.hardware import * -from .lib.installer import __packages__, Installer, accessibility_tools_in_use -from .lib.locale_helpers import * -from .lib.luks import * -from .lib.mirrors import * -from .lib.networking import * -from .lib.output import * -from archinstall.lib.profile.profiles_handler import ProfileHandler, profile_handler -from .lib.profile.profile_menu import ProfileConfiguration -from .lib.services import * -from .lib.storage import * -from .lib.systemd import * -from .lib.user_interaction import * +from .lib import exceptions +from .lib import luks +from .lib import locale +from .lib import mirrors +from .lib import networking +from .lib import profile +from .lib import interactions +from . import default_profiles + +from .lib.hardware import SysInfo, AVAILABLE_GFX_DRIVERS +from .lib.installer import Installer, accessibility_tools_in_use +from .lib.output import ( + FormattedOutput, log, error, + check_log_permissions, debug, warn, info +) +from .lib.storage import storage from .lib.global_menu import GlobalMenu -from .lib.translationhandler import TranslationHandler, DeferredTranslation -from .lib.plugins import plugins, load_plugin # This initiates the plugin loading ceremony -from .lib.configuration import * +from .lib.boot import Boot +from .lib.translationhandler import TranslationHandler, Language, DeferredTranslation +from .lib.plugins import plugins, load_plugin +from .lib.configuration import ConfigurationOutput +from .lib.general import ( + generate_password, locate_binary, clear_vt100_escape_codes, + JsonEncoder, JSON, UNSAFE_JSON, SysCommandWorker, SysCommand, + run_custom_user_commands, json_stream_to_structure, secret +) + + +if TYPE_CHECKING: + _: Any -parser = ArgumentParser() __version__ = "2.5.6" storage['__version__'] = __version__ + # add the custome _ as a builtin, it can now be used anywhere in the # project to mark strings as translatable with _('translate me') DeferredTranslation.install() +check_log_permissions() + +# Log various information about hardware before starting the installation. This might assist in troubleshooting +debug(f"Hardware model detected: {SysInfo.sys_vendor()} {SysInfo.product_name()}; UEFI mode: {SysInfo.has_uefi()}") +debug(f"Processor model detected: {SysInfo.cpu_model()}") +debug(f"Memory statistics: {SysInfo.mem_available()} available out of {SysInfo.mem_total()} total installed") +debug(f"Virtualization detected: {SysInfo.virtualization()}; is VM: {SysInfo.is_vm()}") +debug(f"Graphics devices detected: {SysInfo._graphics_devices().keys()}") + +# For support reasons, we'll log the disk layout pre installation to match against post-installation layout +debug(f"Disk states before installing: {disk.disk_layouts()}") + + +if os.getuid() != 0: + print(_("Archinstall requires root privileges to run. See --help for more.")) + exit(1) + + +parser = ArgumentParser() + def define_arguments(): """ @@ -61,7 +94,7 @@ def define_arguments(): parser.add_argument("--plugin", nargs="?", type=str) -def parse_unspecified_argument_list(unknowns :list, multiple :bool = False, error :bool = False) -> dict: +def parse_unspecified_argument_list(unknowns :list, multiple :bool = False, err :bool = False) -> dict: """We accept arguments not defined to the parser. (arguments "ad hoc"). Internally argparse return to us a list of words so we have to parse its contents, manually. We accept following individual syntax for each argument @@ -105,14 +138,14 @@ def parse_unspecified_argument_list(unknowns :list, multiple :bool = False, erro config[last_key] = [config[last_key],element] else: config[last_key].append(element) - elif error: + elif err: raise ValueError(f"Entry {element} is not related to any argument") else: print(f" We ignore the entry {element} as it isn't related to any argument") return config -def cleanup_empty_args(args: Union[Namespace, dict]) -> dict: +def cleanup_empty_args(args: Union[Namespace, Dict]) -> Dict: """ Takes arguments (dictionary or argparse Namespace) and removes any None values. This ensures clean mergers during dict.update(args) @@ -190,14 +223,14 @@ def load_config(): arguments['disk_config'] = disk.DiskLayoutConfiguration.parse_arg(disk_config) if profile_config := arguments.get('profile_config', None): - arguments['profile_config'] = ProfileConfiguration.parse_arg(profile_config) + arguments['profile_config'] = profile.ProfileConfiguration.parse_arg(profile_config) if arguments.get('mirror-region', None) is not None: if type(arguments.get('mirror-region', None)) is dict: arguments['mirror-region'] = arguments.get('mirror-region', None) else: selected_region = arguments.get('mirror-region', None) - arguments['mirror-region'] = {selected_region: list_mirrors()[selected_region]} + arguments['mirror-region'] = {selected_region: mirrors.list_mirrors()[selected_region]} if arguments.get('servers', None) is not None: storage['_selected_servers'] = arguments.get('servers', None) @@ -230,7 +263,7 @@ def post_process_arguments(arguments): storage['MOUNT_POINT'] = Path(mountpoint) if arguments.get('debug', False): - log(f"Warning: --debug mode will write certain credentials to {storage['LOG_PATH']}/{storage['LOG_FILE']}!", fg="red", level=logging.WARNING) + warn(f"Warning: --debug mode will write certain credentials to {storage['LOG_PATH']}/{storage['LOG_FILE']}!") if arguments.get('plugin', None): path = arguments['plugin'] diff --git a/archinstall/default_profiles/desktop.py b/archinstall/default_profiles/desktop.py index 2351bd08..9d92f822 100644 --- a/archinstall/default_profiles/desktop.py +++ b/archinstall/default_profiles/desktop.py @@ -1,7 +1,7 @@ from typing import Any, TYPE_CHECKING, List, Optional, Dict from archinstall.lib import menu -from archinstall.lib.output import log +from archinstall.lib.output import info from archinstall.lib.profile.profiles_handler import profile_handler from archinstall.default_profiles.profile import Profile, ProfileType, SelectResult, GreeterType @@ -79,7 +79,7 @@ class DesktopProfile(Profile): install_session.add_additional_packages(self.packages) for profile in self._current_selection: - log(f'Installing profile {profile.name}...') + info(f'Installing profile {profile.name}...') install_session.add_additional_packages(profile.packages) install_session.enable_service(profile.services) diff --git a/archinstall/default_profiles/server.py b/archinstall/default_profiles/server.py index e240b3ef..ab758975 100644 --- a/archinstall/default_profiles/server.py +++ b/archinstall/default_profiles/server.py @@ -1,7 +1,6 @@ -import logging from typing import Any, TYPE_CHECKING, List -from archinstall.lib.output import log +from archinstall.lib.output import info from archinstall.lib.menu import MenuSelectionType from archinstall.lib.profile.profiles_handler import profile_handler from archinstall.default_profiles.profile import ProfileType, Profile, SelectResult, TProfile @@ -46,12 +45,12 @@ class ServerProfile(Profile): def install(self, install_session: 'Installer'): server_info = self.current_selection_names() details = ', '.join(server_info) - log(f'Now installing the selected servers: {details}', level=logging.INFO) + info(f'Now installing the selected servers: {details}') for server in self._current_selection: - log(f'Installing {server.name}...', level=logging.INFO) + info(f'Installing {server.name}...') install_session.add_additional_packages(server.packages) install_session.enable_service(server.services) server.install(install_session) - log('If your selections included multiple servers with the same port, you may have to reconfigure them.', fg="yellow", level=logging.INFO) + info('If your selections included multiple servers with the same port, you may have to reconfigure them.') diff --git a/archinstall/lib/boot.py b/archinstall/lib/boot.py new file mode 100644 index 00000000..62c50df3 --- /dev/null +++ b/archinstall/lib/boot.py @@ -0,0 +1,111 @@ +import time +from typing import Iterator, Optional +from .exceptions import SysCallError +from .general import SysCommand, SysCommandWorker, locate_binary +from .installer import Installer +from .output import error +from .storage import storage + + +class Boot: + def __init__(self, installation: Installer): + self.instance = installation + self.container_name = 'archinstall' + self.session: Optional[SysCommandWorker] = None + self.ready = False + + def __enter__(self) -> 'Boot': + if (existing_session := storage.get('active_boot', None)) and existing_session.instance != self.instance: + raise KeyError("Archinstall only supports booting up one instance, and a active session is already active and it is not this one.") + + if existing_session: + self.session = existing_session.session + self.ready = existing_session.ready + else: + # '-P' or --console=pipe could help us not having to do a bunch + # of os.write() calls, but instead use pipes (stdin, stdout and stderr) as usual. + self.session = SysCommandWorker([ + '/usr/bin/systemd-nspawn', + '-D', str(self.instance.target), + '--timezone=off', + '-b', + '--no-pager', + '--machine', self.container_name + ]) + + if not self.ready and self.session: + while self.session.is_alive(): + if b' login:' in self.session: + self.ready = True + break + + storage['active_boot'] = self + return self + + def __exit__(self, *args :str, **kwargs :str) -> None: + # b''.join(sys_command('sync')) # No need to, since the underlying fs() object will call sync. + # TODO: https://stackoverflow.com/questions/28157929/how-to-safely-handle-an-exception-inside-a-context-manager + + if len(args) >= 2 and args[1]: + error( + args[1], + f"The error above occurred in a temporary boot-up of the installation {self.instance}" + ) + + shutdown = None + shutdown_exit_code: Optional[int] = -1 + + try: + shutdown = SysCommand(f'systemd-run --machine={self.container_name} --pty shutdown now') + except SysCallError as err: + shutdown_exit_code = err.exit_code + + if self.session: + while self.session.is_alive(): + time.sleep(0.25) + + if shutdown and shutdown.exit_code: + shutdown_exit_code = shutdown.exit_code + + if self.session and (self.session.exit_code == 0 or shutdown_exit_code == 0): + storage['active_boot'] = None + else: + session_exit_code = self.session.exit_code if self.session else -1 + + raise SysCallError( + f"Could not shut down temporary boot of {self.instance}: {session_exit_code}/{shutdown_exit_code}", + exit_code=next(filter(bool, [session_exit_code, shutdown_exit_code])) + ) + + def __iter__(self) -> Iterator[bytes]: + if self.session: + for value in self.session: + yield value + + def __contains__(self, key: bytes) -> bool: + if self.session is None: + return False + + return key in self.session + + def is_alive(self) -> bool: + if self.session is None: + return False + + return self.session.is_alive() + + def SysCommand(self, cmd: list, *args, **kwargs) -> SysCommand: + if cmd[0][0] != '/' and cmd[0][:2] != './': + # This check is also done in SysCommand & SysCommandWorker. + # However, that check is done for `machinectl` and not for our chroot command. + # So this wrapper for SysCommand will do this additionally. + + cmd[0] = locate_binary(cmd[0]) + + return SysCommand(["systemd-run", f"--machine={self.container_name}", "--pty", *cmd], *args, **kwargs) + + def SysCommandWorker(self, cmd: list, *args, **kwargs) -> SysCommandWorker: + if cmd[0][0] != '/' and cmd[0][:2] != './': + cmd[0] = locate_binary(cmd[0]) + + return SysCommandWorker(["systemd-run", f"--machine={self.container_name}", "--pty", *cmd], *args, **kwargs) diff --git a/archinstall/lib/configuration.py b/archinstall/lib/configuration.py index 22c41c0d..c3af3a83 100644 --- a/archinstall/lib/configuration.py +++ b/archinstall/lib/configuration.py @@ -1,13 +1,13 @@ import os import json import stat -import logging from pathlib import Path from typing import Optional, Dict, Any, TYPE_CHECKING +from .menu import Menu, MenuSelectionType from .storage import storage -from .general import JSON, UNSAFE_JSON -from .output import log +from .general import JSON, UNSAFE_JSON, SysCommand +from .output import debug, info, warn if TYPE_CHECKING: _: Any @@ -69,18 +69,18 @@ class ConfigurationOutput: def show(self): print(_('\nThis is your chosen configuration:')) - log(" -- Chosen configuration --", level=logging.DEBUG) + debug(" -- Chosen configuration --") user_conig = self.user_config_to_json() - log(user_conig, level=logging.INFO) + info(user_conig) print() def _is_valid_path(self, dest_path: Path) -> bool: if (not dest_path.exists()) or not (dest_path.is_dir()): - log( - 'Destination directory {} does not exist or is not a directory,\n Configuration files can not be saved'.format(dest_path.resolve()), - fg="yellow" + warn( + f'Destination directory {dest_path.resolve()} does not exist or is not a directory\n.', + 'Configuration files can not be saved' ) return False return True @@ -111,3 +111,101 @@ class ConfigurationOutput: if self._is_valid_path(dest_path): self.save_user_config(dest_path) self.save_user_creds(dest_path) + + +def save_config(config: Dict): + def preview(selection: str): + if options['user_config'] == selection: + serialized = config_output.user_config_to_json() + return f'{config_output.user_configuration_file}\n{serialized}' + elif options['user_creds'] == selection: + if maybe_serial := config_output.user_credentials_to_json(): + return f'{config_output.user_credentials_file}\n{maybe_serial}' + else: + return str(_('No configuration')) + elif options['all'] == selection: + output = f'{config_output.user_configuration_file}\n' + if config_output.user_credentials_to_json(): + output += f'{config_output.user_credentials_file}\n' + return output[:-1] + return None + + config_output = ConfigurationOutput(config) + + options = { + 'user_config': str(_('Save user configuration')), + 'user_creds': str(_('Save user credentials')), + 'disk_layout': str(_('Save disk layout')), + 'all': str(_('Save all')) + } + + choice = Menu( + _('Choose which configuration to save'), + list(options.values()), + sort=False, + skip=True, + preview_size=0.75, + preview_command=preview + ).run() + + if choice.type_ == MenuSelectionType.Skip: + return + + save_config_value = choice.single_value + saving_key = [k for k, v in options.items() if v == save_config_value][0] + + dirs_to_exclude = [ + '/bin', + '/dev', + '/lib', + '/lib64', + '/lost+found', + '/opt', + '/proc', + '/run', + '/sbin', + '/srv', + '/sys', + '/usr', + '/var', + ] + + debug('Ignore configuration option folders: ' + ','.join(dirs_to_exclude)) + info(_('Finding possible directories to save configuration files ...')) + + find_exclude = '-path ' + ' -prune -o -path '.join(dirs_to_exclude) + ' -prune ' + file_picker_command = f'find / {find_exclude} -o -type d -print0' + + directories = SysCommand(file_picker_command).decode() + + if directories is None: + raise ValueError('Failed to retrieve possible configuration directories') + + possible_save_dirs = list(filter(None, directories.split('\x00'))) + + selection = Menu( + _('Select directory (or directories) for saving configuration files'), + possible_save_dirs, + multi=True, + skip=True, + allow_reset=False, + ).run() + + match selection.type_: + case MenuSelectionType.Skip: + return + + save_dirs = selection.multi_value + + debug(f'Saving {saving_key} configuration files to {save_dirs}') + + if save_dirs is not None: + for save_dir_str in save_dirs: + save_dir = Path(save_dir_str) + if options['user_config'] == save_config_value: + config_output.save_user_config(save_dir) + elif options['user_creds'] == save_config_value: + config_output.save_user_creds(save_dir) + elif options['all'] == save_config_value: + config_output.save_user_config(save_dir) + config_output.save_user_creds(save_dir) diff --git a/archinstall/lib/disk/device_handler.py b/archinstall/lib/disk/device_handler.py index 13bde77a..4341c53c 100644 --- a/archinstall/lib/disk/device_handler.py +++ b/archinstall/lib/disk/device_handler.py @@ -1,7 +1,6 @@ from __future__ import annotations import json -import logging import os import time from pathlib import Path @@ -24,7 +23,7 @@ from .device_model import ( from ..exceptions import DiskError, UnknownFilesystemFormat from ..general import SysCommand, SysCallError, JSON from ..luks import Luks2 -from ..output import log +from ..output import debug, error, info, warn from ..utils.util import is_subpath if TYPE_CHECKING: @@ -48,11 +47,11 @@ class DeviceHandler(object): for device in getAllDevices(): try: disk = Disk(device) - except DiskLabelException as error: - if 'unrecognised disk label' in getattr(error, 'message', str(error)): + except DiskLabelException as err: + if 'unrecognised disk label' in getattr(error, 'message', str(err)): disk = freshDisk(device, PartitionTable.GPT.value) else: - log(f'Unable to get disk from device: {device}', level=logging.DEBUG) + debug(f'Unable to get disk from device: {device}') continue device_info = _DeviceInfo.from_disk(disk) @@ -93,7 +92,7 @@ class DeviceHandler(object): return FilesystemType(lsblk_info.fstype) if lsblk_info.fstype else None return None except ValueError: - log(f'Could not determine the filesystem: {partition.fileSystem}', level=logging.DEBUG) + debug(f'Could not determine the filesystem: {partition.fileSystem}') return None @@ -137,7 +136,7 @@ class DeviceHandler(object): try: result = SysCommand(f'btrfs subvolume list {mountpoint}') except SysCallError as err: - log(f'Failed to read btrfs subvolume information: {err}', level=logging.DEBUG) + debug(f'Failed to read btrfs subvolume information: {err}') return subvol_infos try: @@ -150,7 +149,7 @@ class DeviceHandler(object): sub_vol_mountpoint = lsblk_info.btrfs_subvol_info.get(name, None) subvol_infos.append(_BtrfsSubvolumeInfo(name, sub_vol_mountpoint)) except json.decoder.JSONDecodeError as err: - log(f"Could not decode lsblk JSON: {result}", fg="red", level=logging.ERROR) + error(f"Could not decode lsblk JSON: {result}") raise err if not lsblk_info.mountpoint: @@ -203,14 +202,14 @@ class DeviceHandler(object): options += additional_parted_options options_str = ' '.join(options) - log(f'Formatting filesystem: /usr/bin/{command} {options_str} {path}') + info(f'Formatting filesystem: /usr/bin/{command} {options_str} {path}') try: SysCommand(f"/usr/bin/{command} {options_str} {path}") - except SysCallError as error: - msg = f'Could not format {path} with {fs_type.value}: {error.message}' - log(msg, fg='red') - raise DiskError(msg) from error + except SysCallError as err: + msg = f'Could not format {path} with {fs_type.value}: {err.message}' + error(msg) + raise DiskError(msg) from err def _perform_enc_formatting( self, @@ -227,16 +226,16 @@ class DeviceHandler(object): key_file = luks_handler.encrypt() - log(f'Unlocking luks2 device: {dev_path}', level=logging.DEBUG) + debug(f'Unlocking luks2 device: {dev_path}') luks_handler.unlock(key_file=key_file) if not luks_handler.mapper_dev: raise DiskError('Failed to unlock luks device') - log(f'luks2 formatting mapper dev: {luks_handler.mapper_dev}', level=logging.INFO) + info(f'luks2 formatting mapper dev: {luks_handler.mapper_dev}') self._perform_formatting(fs_type, luks_handler.mapper_dev) - log(f'luks2 locking device: {dev_path}', level=logging.INFO) + info(f'luks2 locking device: {dev_path}') luks_handler.lock() def format( @@ -285,7 +284,7 @@ class DeviceHandler(object): # when we require a delete and the partition to be (re)created # already exists then we have to delete it first if requires_delete and part_mod.status in [ModificationStatus.Modify, ModificationStatus.Delete]: - log(f'Delete existing partition: {part_mod.safe_dev_path}', level=logging.INFO) + info(f'Delete existing partition: {part_mod.safe_dev_path}') part_info = self.find_partition(part_mod.safe_dev_path) if not part_info: @@ -325,9 +324,9 @@ class DeviceHandler(object): for flag in part_mod.flags: partition.setFlag(flag.value) - log(f'\tType: {part_mod.type.value}', level=logging.DEBUG) - log(f'\tFilesystem: {part_mod.fs_type.value}', level=logging.DEBUG) - log(f'\tGeometry: {start_sector.value} start sector, {length_sector.value} length', level=logging.DEBUG) + debug(f'\tType: {part_mod.type.value}') + debug(f'\tFilesystem: {part_mod.fs_type.value}') + debug(f'\tGeometry: {start_sector.value} start sector, {length_sector.value} length') try: disk.addPartition(partition=partition, constraint=disk.device.optimalAlignedConstraint) @@ -339,41 +338,41 @@ class DeviceHandler(object): # the partition has a real path now as it was created part_mod.dev_path = Path(partition.path) - info = self._fetch_partuuid(part_mod.dev_path) + lsblk_info = self._fetch_partuuid(part_mod.dev_path) - part_mod.partuuid = info.partuuid - part_mod.uuid = info.uuid + part_mod.partuuid = lsblk_info.partuuid + part_mod.uuid = lsblk_info.uuid except PartitionException as ex: raise DiskError(f'Unable to add partition, most likely due to overlapping sectors: {ex}') from ex def _fetch_partuuid(self, path: Path) -> LsblkInfo: attempts = 3 - info: Optional[LsblkInfo] = None + lsblk_info: Optional[LsblkInfo] = None self.partprobe(path) for attempt_nr in range(attempts): time.sleep(attempt_nr + 1) - info = get_lsblk_info(path) + lsblk_info = get_lsblk_info(path) - if info.partuuid: + if lsblk_info.partuuid: break self.partprobe(path) - if not info or not info.partuuid: - log(f'Unable to determine new partition uuid: {path}\n{info}', level=logging.DEBUG) + if not lsblk_info or not lsblk_info.partuuid: + debug(f'Unable to determine new partition uuid: {path}\n{lsblk_info}') raise DiskError(f'Unable to determine new partition uuid: {path}') - log(f'partuuid found: {info.json()}', level=logging.DEBUG) + debug(f'partuuid found: {lsblk_info.json()}') - return info + return lsblk_info def create_btrfs_volumes( self, part_mod: PartitionModification, enc_conf: Optional['DiskEncryption'] = None ): - log(f'Creating subvolumes: {part_mod.safe_dev_path}', level=logging.INFO) + info(f'Creating subvolumes: {part_mod.safe_dev_path}') luks_handler = None @@ -396,7 +395,7 @@ class DeviceHandler(object): self.mount(part_mod.safe_dev_path, self._TMP_BTRFS_MOUNT, create_target_mountpoint=True) for sub_vol in part_mod.btrfs_subvols: - log(f'Creating subvolume: {sub_vol.name}', level=logging.DEBUG) + debug(f'Creating subvolume: {sub_vol.name}') if luks_handler is not None: subvol_path = self._TMP_BTRFS_MOUNT / sub_vol.name @@ -408,14 +407,14 @@ class DeviceHandler(object): if sub_vol.nodatacow: try: SysCommand(f'chattr +C {subvol_path}') - except SysCallError as error: - raise DiskError(f'Could not set nodatacow attribute at {subvol_path}: {error}') + except SysCallError as err: + raise DiskError(f'Could not set nodatacow attribute at {subvol_path}: {err}') if sub_vol.compress: try: SysCommand(f'chattr +c {subvol_path}') - except SysCallError as error: - raise DiskError(f'Could not set compress attribute at {subvol_path}: {error}') + except SysCallError as err: + raise DiskError(f'Could not set compress attribute at {subvol_path}: {err}') if luks_handler is not None and luks_handler.mapper_dev is not None: self.umount(luks_handler.mapper_dev) @@ -435,12 +434,12 @@ class DeviceHandler(object): return luks_handler def _umount_all_existing(self, modification: DeviceModification): - log(f'Unmounting all partitions: {modification.device_path}', level=logging.INFO) + info(f'Unmounting all partitions: {modification.device_path}') existing_partitions = self._devices[modification.device_path].partition_infos for partition in existing_partitions: - log(f'Unmounting: {partition.path}', level=logging.DEBUG) + debug(f'Unmounting: {partition.path}') # un-mount for existing encrypted partitions if partition.fs_type == FilesystemType.Crypto_luks: @@ -472,10 +471,10 @@ class DeviceHandler(object): part_table = partition_table.value if partition_table else None disk = freshDisk(modification.device.disk.device, part_table) else: - log(f'Use existing device: {modification.device_path}') + info(f'Use existing device: {modification.device_path}') disk = modification.device.disk - log(f'Creating partitions: {modification.device_path}') + info(f'Creating partitions: {modification.device_path}') # TODO sort by delete first @@ -507,7 +506,7 @@ class DeviceHandler(object): lsblk_info = get_lsblk_info(dev_path) if target_mountpoint in lsblk_info.mountpoints: - log(f'Device already mounted at {target_mountpoint}') + info(f'Device already mounted at {target_mountpoint}') return str_options = ','.join(options) @@ -517,7 +516,7 @@ class DeviceHandler(object): command = f'mount {mount_fs} {str_options} {dev_path} {target_mountpoint}' - log(f'Mounting {dev_path}: command', level=logging.DEBUG) + debug(f'Mounting {dev_path}: command') try: SysCommand(command) @@ -536,10 +535,10 @@ class DeviceHandler(object): raise ex if len(lsblk_info.mountpoints) > 0: - log(f'Partition {mountpoint} is currently mounted at: {[str(m) for m in lsblk_info.mountpoints]}', level=logging.DEBUG) + debug(f'Partition {mountpoint} is currently mounted at: {[str(m) for m in lsblk_info.mountpoints]}') for mountpoint in lsblk_info.mountpoints: - log(f'Unmounting mountpoint: {mountpoint}', level=logging.DEBUG) + debug(f'Unmounting mountpoint: {mountpoint}') command = 'umount' @@ -574,10 +573,10 @@ class DeviceHandler(object): command = 'partprobe' try: - log(f'Calling partprobe: {command}', level=logging.DEBUG) + debug(f'Calling partprobe: {command}') SysCommand(command) - except SysCallError as error: - log(f'"{command}" failed to run: {error}', level=logging.DEBUG) + except SysCallError as err: + error(f'"{command}" failed to run: {err}') def _wipe(self, dev_path: Path): """ @@ -594,7 +593,7 @@ class DeviceHandler(object): This is not intended to be secure, but rather to ensure that auto-discovery tools don't recognize anything here. """ - log(f'Wiping partitions and metadata: {block_device.device_info.path}') + info(f'Wiping partitions and metadata: {block_device.device_info.path}') for partition in block_device.partition_infos: self._wipe(partition.path) @@ -609,8 +608,8 @@ def disk_layouts() -> str: lsblk_info = get_all_lsblk_info() return json.dumps(lsblk_info, indent=4, sort_keys=True, cls=JSON) except SysCallError as err: - log(f"Could not return disk layouts: {err}", level=logging.WARNING, fg="yellow") + warn(f"Could not return disk layouts: {err}") return '' except json.decoder.JSONDecodeError as err: - log(f"Could not return disk layouts: {err}", level=logging.WARNING, fg="yellow") + warn(f"Could not return disk layouts: {err}") return '' diff --git a/archinstall/lib/disk/device_model.py b/archinstall/lib/disk/device_model.py index d57347b7..36dd0c4f 100644 --- a/archinstall/lib/disk/device_model.py +++ b/archinstall/lib/disk/device_model.py @@ -2,7 +2,6 @@ from __future__ import annotations import dataclasses import json -import logging import math import time import uuid @@ -18,7 +17,7 @@ from parted import Disk, Geometry, Partition from ..exceptions import DiskError, SysCallError from ..general import SysCommand -from ..output import log +from ..output import debug, error from ..storage import storage if TYPE_CHECKING: @@ -282,7 +281,7 @@ class _PartitionInfo: btrfs_subvol_infos: List[_BtrfsSubvolumeInfo] = field(default_factory=list) def as_json(self) -> Dict[str, Any]: - info = { + part_info = { 'Name': self.name, 'Type': self.type.value, 'Filesystem': self.fs_type.value if self.fs_type else str(_('Unknown')), @@ -293,9 +292,9 @@ class _PartitionInfo: } if self.btrfs_subvol_infos: - info['Btrfs vol.'] = f'{len(self.btrfs_subvol_infos)} subvolumes' + part_info['Btrfs vol.'] = f'{len(self.btrfs_subvol_infos)} subvolumes' - return info + return part_info @classmethod def from_partition( @@ -392,7 +391,7 @@ class SubvolumeModification: mods = [] for entry in subvol_args: if not entry.get('name', None) or not entry.get('mountpoint', None): - log(f'Subvolume arg is missing name: {entry}', level=logging.DEBUG) + debug(f'Subvolume arg is missing name: {entry}') continue mountpoint = Path(entry['mountpoint']) if entry['mountpoint'] else None @@ -705,7 +704,7 @@ class PartitionModification: """ Called for displaying data in table format """ - info = { + part_mod = { 'Status': self.status.value, 'Device': str(self.dev_path) if self.dev_path else '', 'Type': self.type.value, @@ -718,9 +717,9 @@ class PartitionModification: } if self.btrfs_subvols: - info['Btrfs vol.'] = f'{len(self.btrfs_subvols)} subvolumes' + part_mod['Btrfs vol.'] = f'{len(self.btrfs_subvols)} subvolumes' - return info + return part_mod @dataclass @@ -916,36 +915,36 @@ class LsblkInfo: @classmethod def from_json(cls, blockdevice: Dict[str, Any]) -> LsblkInfo: - info = cls() + lsblk_info = cls() for f in cls.fields(): lsblk_field = _clean_field(f, CleanType.Blockdevice) data_field = _clean_field(f, CleanType.Dataclass) val: Any = None - if isinstance(getattr(info, data_field), Path): + if isinstance(getattr(lsblk_info, data_field), Path): val = Path(blockdevice[lsblk_field]) - elif isinstance(getattr(info, data_field), Size): + elif isinstance(getattr(lsblk_info, data_field), Size): val = Size(blockdevice[lsblk_field], Unit.B) else: val = blockdevice[lsblk_field] - setattr(info, data_field, val) + setattr(lsblk_info, data_field, val) - info.children = [LsblkInfo.from_json(child) for child in blockdevice.get('children', [])] + lsblk_info.children = [LsblkInfo.from_json(child) for child in blockdevice.get('children', [])] # sometimes lsblk returns 'mountpoints': [null] - info.mountpoints = [Path(mnt) for mnt in info.mountpoints if mnt] + lsblk_info.mountpoints = [Path(mnt) for mnt in lsblk_info.mountpoints if mnt] fs_roots = [] - for r in info.fsroots: + for r in lsblk_info.fsroots: if r: path = Path(r) # store the fsroot entries without the leading / fs_roots.append(path.relative_to(path.anchor)) - info.fsroots = fs_roots + lsblk_info.fsroots = fs_roots - return info + return lsblk_info class CleanType(Enum): @@ -978,16 +977,16 @@ def _fetch_lsblk_info(dev_path: Optional[Union[Path, str]] = None, retry: int = try: result = SysCommand(f'lsblk --json -b -o+{lsblk_fields} {dev_path}') break - except SysCallError as error: + except SysCallError as err: # Get the output minus the message/info from lsblk if it returns a non-zero exit code. - if error.worker: - err = error.worker.decode('UTF-8') - log(f'Error calling lsblk: {err}', level=logging.DEBUG) + if err.worker: + err_str = err.worker.decode('UTF-8') + debug(f'Error calling lsblk: {err_str}') else: - raise error + raise err if retry_attempt == retry - 1: - raise error + raise err time.sleep(1) @@ -997,11 +996,12 @@ def _fetch_lsblk_info(dev_path: Optional[Union[Path, str]] = None, retry: int = blockdevices = block_devices['blockdevices'] return [LsblkInfo.from_json(device) for device in blockdevices] except json.decoder.JSONDecodeError as err: - log(f"Could not decode lsblk JSON: {result}", fg="red", level=logging.ERROR) + error(f"Could not decode lsblk JSON: {result}") raise err raise DiskError(f'Failed to read disk "{dev_path}" with lsblk') + def get_lsblk_info(dev_path: Union[Path, str]) -> LsblkInfo: if infos := _fetch_lsblk_info(dev_path): return infos[0] diff --git a/archinstall/lib/disk/encryption_menu.py b/archinstall/lib/disk/encryption_menu.py index 285270fb..8c64e65e 100644 --- a/archinstall/lib/disk/encryption_menu.py +++ b/archinstall/lib/disk/encryption_menu.py @@ -13,7 +13,7 @@ from ..menu import ( MenuSelectionType, TableMenu ) -from ..user_interaction.utils import get_password +from ..interactions.utils import get_password from ..menu import Menu from ..general import secret from .fido import Fido2Device, Fido2 diff --git a/archinstall/lib/disk/fido.py b/archinstall/lib/disk/fido.py index 2a53b551..97c38d84 100644 --- a/archinstall/lib/disk/fido.py +++ b/archinstall/lib/disk/fido.py @@ -1,13 +1,12 @@ from __future__ import annotations import getpass -import logging from pathlib import Path from typing import List, Optional from .device_model import PartitionModification, Fido2Device from ..general import SysCommand, SysCommandWorker, clear_vt100_escape_codes -from ..output import log +from ..output import error, info class Fido2: @@ -39,7 +38,7 @@ class Fido2: if not cls._loaded or reload: ret: Optional[str] = SysCommand(f"systemd-cryptenroll --fido2-device=list").decode('UTF-8') if not ret: - log('Unable to retrieve fido2 devices', level=logging.ERROR) + error('Unable to retrieve fido2 devices') return [] fido_devices: str = clear_vt100_escape_codes(ret) # type: ignore @@ -88,8 +87,4 @@ class Fido2: worker.write(bytes(getpass.getpass(" "), 'UTF-8')) pin_inputted = True - log( - f"You might need to touch the FIDO2 device to unlock it if no prompt comes up after 3 seconds.", - level=logging.INFO, - fg="yellow" - ) + info('You might need to touch the FIDO2 device to unlock it if no prompt comes up after 3 seconds') diff --git a/archinstall/lib/disk/filesystem.py b/archinstall/lib/disk/filesystem.py index 6ea99340..dc99afce 100644 --- a/archinstall/lib/disk/filesystem.py +++ b/archinstall/lib/disk/filesystem.py @@ -1,6 +1,5 @@ from __future__ import annotations -import logging import signal import sys import time @@ -8,8 +7,8 @@ from typing import Any, Optional, TYPE_CHECKING from .device_model import DiskLayoutConfiguration, DiskLayoutType, PartitionTable, FilesystemType, DiskEncryption from .device_handler import device_handler -from ..hardware import has_uefi -from ..output import log +from ..hardware import SysInfo +from ..output import debug from ..menu import Menu if TYPE_CHECKING: @@ -27,13 +26,13 @@ class FilesystemHandler: def perform_filesystem_operations(self, show_countdown: bool = True): if self._disk_config.config_type == DiskLayoutType.Pre_mount: - log('Disk layout configuration is set to pre-mount, not performing any operations', level=logging.DEBUG) + debug('Disk layout configuration is set to pre-mount, not performing any operations') return device_mods = list(filter(lambda x: len(x.partitions) > 0, self._disk_config.device_modifications)) if not device_mods: - log('No modifications required', level=logging.DEBUG) + debug('No modifications required') return device_paths = ', '.join([str(mod.device.device_info.path) for mod in device_mods]) @@ -48,7 +47,7 @@ class FilesystemHandler: # Setup the blockdevice, filesystem (and optionally encryption). # Once that's done, we'll hand over to perform_installation() partition_table = PartitionTable.GPT - if has_uefi() is False: + if SysInfo.has_uefi() is False: partition_table = PartitionTable.MBR for mod in device_mods: diff --git a/archinstall/lib/disk/partitioning_menu.py b/archinstall/lib/disk/partitioning_menu.py index 686e8c29..89cf6293 100644 --- a/archinstall/lib/disk/partitioning_menu.py +++ b/archinstall/lib/disk/partitioning_menu.py @@ -1,13 +1,12 @@ from __future__ import annotations -import logging from pathlib import Path from typing import Any, Dict, TYPE_CHECKING, List, Optional, Tuple from .device_model import PartitionModification, FilesystemType, BDevice, Size, Unit, PartitionType, PartitionFlag, \ ModificationStatus from ..menu import Menu, ListManager, MenuSelection, TextInput -from ..output import FormattedOutput, log +from ..output import FormattedOutput, warn from .subvolume_menu import SubvolumeMenu if TYPE_CHECKING: @@ -229,7 +228,7 @@ class PartitioningList(ListManager): if not start_sector or self._validate_sector(start_sector): break - log(f'Invalid start sector entered: {start_sector}', fg='red', level=logging.INFO) + warn(f'Invalid start sector entered: {start_sector}') if not start_sector: start_sector = str(largest_free_area.start) @@ -245,7 +244,7 @@ class PartitioningList(ListManager): if not end_value or self._validate_sector(start_sector, end_value): break - log(f'Invalid end sector entered: {start_sector}', fg='red', level=logging.INFO) + warn(f'Invalid end sector entered: {start_sector}') # override the default value with the user value if end_value: @@ -300,7 +299,7 @@ class PartitioningList(ListManager): if choice.value == Menu.no(): return [] - from ..user_interaction.disk_conf import suggest_single_disk_layout + from ..interactions.disk_conf import suggest_single_disk_layout device_modification = suggest_single_disk_layout(self._device) return device_modification.partitions diff --git a/archinstall/lib/exceptions.py b/archinstall/lib/exceptions.py index a66e4e04..53458d2c 100644 --- a/archinstall/lib/exceptions.py +++ b/archinstall/lib/exceptions.py @@ -3,6 +3,7 @@ from typing import Optional, TYPE_CHECKING if TYPE_CHECKING: from .general import SysCommandWorker + class RequirementError(BaseException): pass @@ -15,10 +16,6 @@ class UnknownFilesystemFormat(BaseException): pass -class ProfileError(BaseException): - pass - - class SysCallError(BaseException): def __init__(self, message :str, exit_code :Optional[int] = None, worker :Optional['SysCommandWorker'] = None) -> None: super(SysCallError, self).__init__(message) @@ -27,22 +24,10 @@ class SysCallError(BaseException): self.worker = worker -class PermissionError(BaseException): - pass - - -class ProfileNotFound(BaseException): - pass - - class HardwareIncompatibilityError(BaseException): pass -class UserError(BaseException): - pass - - class ServiceException(BaseException): pass @@ -51,9 +36,5 @@ class PackageError(BaseException): pass -class TranslationError(BaseException): - pass - - class Deprecated(BaseException): - pass \ No newline at end of file + pass diff --git a/archinstall/lib/general.py b/archinstall/lib/general.py index 997b7d67..777ee90e 100644 --- a/archinstall/lib/general.py +++ b/archinstall/lib/general.py @@ -1,8 +1,6 @@ from __future__ import annotations -import hashlib import json -import logging import os import secrets import shlex @@ -18,9 +16,10 @@ import urllib.error import pathlib from datetime import datetime, date from typing import Callable, Optional, Dict, Any, List, Union, Iterator, TYPE_CHECKING +from select import epoll, EPOLLIN, EPOLLHUP from .exceptions import RequirementError, SysCallError -from .output import log +from .output import debug, error, info from .storage import storage @@ -28,42 +27,6 @@ if TYPE_CHECKING: from .installer import Installer -if sys.platform == 'linux': - from select import epoll, EPOLLIN, EPOLLHUP -else: - import select - EPOLLIN = 0 - EPOLLHUP = 0 - - class epoll(): - """ #!if windows - Create a epoll() implementation that simulates the epoll() behavior. - This so that the rest of the code doesn't need to worry weither we're using select() or epoll(). - """ - def __init__(self) -> None: - self.sockets: Dict[str, Any] = {} - self.monitoring: Dict[int, Any] = {} - - def unregister(self, fileno :int, *args :List[Any], **kwargs :Dict[str, Any]) -> None: - try: - del(self.monitoring[fileno]) # noqa: E275 - except: - pass - - def register(self, fileno :int, *args :int, **kwargs :Dict[str, Any]) -> None: - self.monitoring[fileno] = True - - def poll(self, timeout: float = 0.05, *args :str, **kwargs :Dict[str, Any]) -> List[Any]: - try: - return [[fileno, 1] for fileno in select.select(list(self.monitoring.keys()), [], [], timeout)[0]] - except OSError: - return [] - - -def gen_uid(entropy_length :int = 256) -> str: - return hashlib.sha512(os.urandom(entropy_length)).hexdigest() - - def generate_password(length :int = 64) -> str: haystack = string.printable # digits, ascii_letters, punctiation (!"#$[] etc) and whitespace return ''.join(secrets.choice(haystack) for i in range(length)) @@ -156,6 +119,7 @@ class JsonEncoder: else: return JsonEncoder._encode(obj) + class JSON(json.JSONEncoder, json.JSONDecoder): """ A safe JSON encoder that will omit private information in dicts (starting with !) @@ -166,6 +130,7 @@ class JSON(json.JSONEncoder, json.JSONDecoder): def encode(self, obj :Any) -> Any: return super(JSON, self).encode(self._encode(obj)) + class UNSAFE_JSON(json.JSONEncoder, json.JSONDecoder): """ UNSAFE_JSON will call/encode and keep private information in dicts (starting with !) @@ -269,7 +234,7 @@ class SysCommandWorker: sys.stdout.flush() if len(args) >= 2 and args[1]: - log(args[1], level=logging.DEBUG, fg='red') + debug(args[1]) if self.exit_code != 0: raise SysCallError( @@ -350,7 +315,7 @@ class SysCommandWorker: self.ended = time.time() break - if self.ended or (got_output is False and pid_exists(self.pid) is False): + if self.ended or (got_output is False and _pid_exists(self.pid) is False): self.ended = time.time() try: wait_status = os.waitpid(self.pid, 0)[1] @@ -396,15 +361,15 @@ class SysCommandWorker: pass except Exception as e: exception_type = type(e).__name__ - log(f"Unexpected {exception_type} occurred in {self.cmd}: {e}", level=logging.ERROR) + error(f"Unexpected {exception_type} occurred in {self.cmd}: {e}") raise e os.execve(self.cmd[0], list(self.cmd), {**os.environ, **self.environment_vars}) if storage['arguments'].get('debug'): - log(f"Executing: {self.cmd}", level=logging.DEBUG) + debug(f"Executing: {self.cmd}") except FileNotFoundError: - log(f"{self.cmd[0]} does not exist.", level=logging.ERROR, fg="red") + error(f"{self.cmd[0]} does not exist.") self.exit_code = 1 return False else: @@ -455,7 +420,7 @@ class SysCommand: # TODO: https://stackoverflow.com/questions/28157929/how-to-safely-handle-an-exception-inside-a-context-manager if len(args) >= 2 and args[1]: - log(args[1], level=logging.ERROR, fg='red') + error(args[1]) def __iter__(self, *args :List[Any], **kwargs :Dict[str, Any]) -> Iterator[bytes]: if self.session: @@ -535,22 +500,7 @@ class SysCommand: return None -def prerequisite_check() -> bool: - """ - This function is used as a safety check before - continuing with an installation. - - Could be anything from checking that /boot is big enough - to check if nvidia hardware exists when nvidia driver was chosen. - """ - - return True - -def reboot(): - SysCommand("/usr/bin/reboot") - - -def pid_exists(pid: int) -> bool: +def _pid_exists(pid: int) -> bool: try: return any(subprocess.check_output(['/usr/bin/ps', '--no-headers', '-o', 'pid', '-p', str(pid)]).strip()) except subprocess.CalledProcessError: @@ -559,7 +509,7 @@ def pid_exists(pid: int) -> bool: def run_custom_user_commands(commands :List[str], installation :Installer) -> None: for index, command in enumerate(commands): - log(f'Executing custom command "{command}" ...', level=logging.INFO) + info(f'Executing custom command "{command}" ...') with open(f"{installation.target}/var/tmp/user-command.{index}.sh", "w") as temp_script: temp_script.write(command) @@ -568,6 +518,7 @@ def run_custom_user_commands(commands :List[str], installation :Installer) -> No os.unlink(f"{installation.target}/var/tmp/user-command.{index}.sh") + def json_stream_to_structure(configuration_identifier : str, stream :str, target :dict) -> bool : """ Function to load a stream (file (as name) or valid JSON string into an existing dictionary @@ -582,16 +533,16 @@ def json_stream_to_structure(configuration_identifier : str, stream :str, target try: with urllib.request.urlopen(urllib.request.Request(stream, headers={'User-Agent': 'ArchInstall'})) as response: target.update(json.loads(response.read())) - except urllib.error.HTTPError as error: - log(f"Could not load {configuration_identifier} via {parsed_url} due to: {error}", level=logging.ERROR, fg="red") + except urllib.error.HTTPError as err: + error(f"Could not load {configuration_identifier} via {parsed_url} due to: {err}") return False else: if pathlib.Path(stream).exists(): try: with pathlib.Path(stream).open() as fh: target.update(json.load(fh)) - except Exception as error: - log(f"{configuration_identifier} = {stream} does not contain a valid JSON format: {error}", level=logging.ERROR, fg="red") + except Exception as err: + error(f"{configuration_identifier} = {stream} does not contain a valid JSON format: {err}") return False else: # NOTE: This is a rudimentary check if what we're trying parse is a dict structure. @@ -600,14 +551,15 @@ def json_stream_to_structure(configuration_identifier : str, stream :str, target try: target.update(json.loads(stream)) except Exception as e: - log(f" {configuration_identifier} Contains an invalid JSON format : {e}",level=logging.ERROR, fg="red") + error(f"{configuration_identifier} Contains an invalid JSON format: {e}") return False else: - log(f" {configuration_identifier} is neither a file nor is a JSON string:",level=logging.ERROR, fg="red") + error(f"{configuration_identifier} is neither a file nor is a JSON string") return False return True + def secret(x :str): """ return * with len equal to to the input string """ return '*' * len(x) diff --git a/archinstall/lib/global_menu.py b/archinstall/lib/global_menu.py index a969d93f..13595132 100644 --- a/archinstall/lib/global_menu.py +++ b/archinstall/lib/global_menu.py @@ -11,24 +11,24 @@ from .models.users import User from .output import FormattedOutput from .profile.profile_menu import ProfileConfiguration from .storage import storage -from .user_interaction import add_number_of_parrallel_downloads -from .user_interaction import ask_additional_packages_to_install -from .user_interaction import ask_for_additional_users -from .user_interaction import ask_for_audio_selection -from .user_interaction import ask_for_bootloader -from .user_interaction import ask_for_swap -from .user_interaction import ask_hostname -from .user_interaction import ask_ntp -from .user_interaction import ask_to_configure_network -from .user_interaction import get_password, ask_for_a_timezone -from .user_interaction import select_additional_repositories -from .user_interaction import select_kernel -from .user_interaction import select_language -from .user_interaction import select_locale_enc -from .user_interaction import select_locale_lang -from .user_interaction import select_mirror_regions -from .user_interaction.disk_conf import select_disk_config -from .user_interaction.save_conf import save_config +from .configuration import save_config +from .interactions import add_number_of_parrallel_downloads +from .interactions import ask_additional_packages_to_install +from .interactions import ask_for_additional_users +from .interactions import ask_for_audio_selection +from .interactions import ask_for_bootloader +from .interactions import ask_for_swap +from .interactions import ask_hostname +from .interactions import ask_to_configure_network +from .interactions import get_password, ask_for_a_timezone +from .interactions import select_additional_repositories +from .interactions import select_kernel +from .interactions import select_language +from .interactions import select_locale_enc +from .interactions import select_locale_lang +from .interactions import select_mirror_regions +from .interactions import ask_ntp +from .interactions.disk_conf import select_disk_config if TYPE_CHECKING: _: Any diff --git a/archinstall/lib/hardware.py b/archinstall/lib/hardware.py index 3759725f..b95301f9 100644 --- a/archinstall/lib/hardware.py +++ b/archinstall/lib/hardware.py @@ -1,27 +1,12 @@ import os -import logging -from functools import partial +from functools import cached_property from pathlib import Path -from typing import Iterator, Optional, Dict +from typing import Optional, Dict from .general import SysCommand from .networking import list_interfaces, enrich_iface_types from .exceptions import SysCallError -from .output import log - -__packages__ = [ - "mesa", - "xf86-video-amdgpu", - "xf86-video-ati", - "xf86-video-nouveau", - "xf86-video-vmware", - "libva-mesa-driver", - "libva-intel-driver", - "intel-media-driver", - "vulkan-radeon", - "vulkan-intel", - "nvidia", -] +from .output import debug AVAILABLE_GFX_DRIVERS = { # Sub-dicts are layer-2 options to be selected @@ -62,136 +47,125 @@ AVAILABLE_GFX_DRIVERS = { } -def cpuinfo() -> Iterator[dict[str, str]]: - """ - Yields information about the CPUs of the system - """ - cpu_info_path = Path("/proc/cpuinfo") - cpu: Dict[str, str] = {} +class _SysInfo: + def __init__(self): + pass - with cpu_info_path.open() as file: - for line in file: - if not (line := line.strip()): - yield cpu - cpu = {} - continue - - key, value = line.split(":", maxsplit=1) - cpu[key.strip()] = value.strip() - - -def all_meminfo() -> Dict[str, int]: - """ - Returns a dict with memory info if called with no args - or the value of the given key of said dict. - """ - mem_info_path = Path("/proc/meminfo") - mem_info: Dict[str, int] = {} - - with mem_info_path.open() as file: - for line in file: - key, value = line.strip().split(':') - num = value.split()[0] - mem_info[key] = int(num) - - return mem_info - - -def meminfo_for_key(key: str) -> int: - info = all_meminfo() - return info[key] - - -def has_wifi() -> bool: - ifaces = list(list_interfaces().values()) - return 'WIRELESS' in enrich_iface_types(ifaces).values() - - -def has_cpu_vendor(vendor_id: str) -> bool: - return any(cpu.get("vendor_id") == vendor_id for cpu in cpuinfo()) - - -has_amd_cpu = partial(has_cpu_vendor, "AuthenticAMD") - - -has_intel_cpu = partial(has_cpu_vendor, "GenuineIntel") - - -def has_uefi() -> bool: - return os.path.isdir('/sys/firmware/efi') - - -def graphics_devices() -> dict: - cards = {} - for line in SysCommand("lspci"): - if b' VGA ' in line or b' 3D ' in line: - _, identifier = line.split(b': ', 1) - cards[identifier.strip().decode('UTF-8')] = line - return cards - - -def has_nvidia_graphics() -> bool: - return any('nvidia' in x.lower() for x in graphics_devices()) - - -def has_amd_graphics() -> bool: - return any('amd' in x.lower() for x in graphics_devices()) - - -def has_intel_graphics() -> bool: - return any('intel' in x.lower() for x in graphics_devices()) - - -def cpu_vendor() -> Optional[str]: - for cpu in cpuinfo(): - return cpu.get("vendor_id") - - return None - - -def cpu_model() -> Optional[str]: - for cpu in cpuinfo(): - return cpu.get("model name") - - return None - - -def sys_vendor() -> Optional[str]: - with open(f"/sys/devices/virtual/dmi/id/sys_vendor") as vendor: - return vendor.read().strip() - - -def product_name() -> Optional[str]: - with open(f"/sys/devices/virtual/dmi/id/product_name") as product: - return product.read().strip() - - -def mem_available() -> Optional[int]: - return meminfo_for_key('MemAvailable') - - -def mem_free() -> Optional[int]: - return meminfo_for_key('MemFree') - - -def mem_total() -> Optional[int]: - return meminfo_for_key('MemTotal') - - -def virtualization() -> Optional[str]: - try: - return str(SysCommand("systemd-detect-virt")).strip('\r\n') - except SysCallError as error: - log(f"Could not detect virtual system: {error}", level=logging.DEBUG) - - return None - - -def is_vm() -> bool: - try: - result = SysCommand("systemd-detect-virt") - return b"none" not in b"".join(result).lower() - except SysCallError as error: - log(f"System is not running in a VM: {error}", level=logging.DEBUG) - - return False + @cached_property + def cpu_info(self) -> Dict[str, str]: + """ + Returns system cpu information + """ + cpu_info_path = Path("/proc/cpuinfo") + cpu: Dict[str, str] = {} + + with cpu_info_path.open() as file: + for line in file: + if line := line.strip(): + key, value = line.split(":", maxsplit=1) + cpu[key.strip()] = value.strip() + + return cpu + + @cached_property + def mem_info(self) -> Dict[str, int]: + """ + Returns system memory information + """ + mem_info_path = Path("/proc/meminfo") + mem_info: Dict[str, int] = {} + + with mem_info_path.open() as file: + for line in file: + key, value = line.strip().split(':') + num = value.split()[0] + mem_info[key] = int(num) + + return mem_info + + def mem_info_by_key(self, key: str) -> int: + return self.mem_info[key] + + +_sys_info = _SysInfo() + + +class SysInfo: + @staticmethod + def has_wifi() -> bool: + ifaces = list(list_interfaces().values()) + return 'WIRELESS' in enrich_iface_types(ifaces).values() + + @staticmethod + def has_uefi() -> bool: + return os.path.isdir('/sys/firmware/efi') + + @staticmethod + def _graphics_devices() -> Dict[str, str]: + cards: Dict[str, str] = {} + for line in SysCommand("lspci"): + if b' VGA ' in line or b' 3D ' in line: + _, identifier = line.split(b': ', 1) + cards[identifier.strip().decode('UTF-8')] = str(line) + return cards + + @staticmethod + def has_nvidia_graphics() -> bool: + return any('nvidia' in x.lower() for x in SysInfo._graphics_devices()) + + @staticmethod + def has_amd_graphics() -> bool: + return any('amd' in x.lower() for x in SysInfo._graphics_devices()) + + @staticmethod + def has_intel_graphics() -> bool: + return any('intel' in x.lower() for x in SysInfo._graphics_devices()) + + @staticmethod + def cpu_vendor() -> Optional[str]: + return _sys_info.cpu_info.get('vendor_id', None) + + @staticmethod + def cpu_model() -> Optional[str]: + return _sys_info.cpu_info.get('model name', None) + + @staticmethod + def sys_vendor() -> str: + with open(f"/sys/devices/virtual/dmi/id/sys_vendor") as vendor: + return vendor.read().strip() + + @staticmethod + def product_name() -> str: + with open(f"/sys/devices/virtual/dmi/id/product_name") as product: + return product.read().strip() + + @staticmethod + def mem_available() -> int: + return _sys_info.mem_info_by_key('MemAvailable') + + @staticmethod + def mem_free() -> int: + return _sys_info.mem_info_by_key('MemFree') + + @staticmethod + def mem_total() -> int: + return _sys_info.mem_info_by_key('MemTotal') + + @staticmethod + def virtualization() -> Optional[str]: + try: + return str(SysCommand("systemd-detect-virt")).strip('\r\n') + except SysCallError as err: + debug(f"Could not detect virtual system: {err}") + + return None + + @staticmethod + def is_vm() -> bool: + try: + result = SysCommand("systemd-detect-virt") + return b"none" not in b"".join(result).lower() + except SysCallError as err: + debug(f"System is not running in a VM: {err}") + + return False diff --git a/archinstall/lib/installer.py b/archinstall/lib/installer.py index 726ff3d0..3c427ab2 100644 --- a/archinstall/lib/installer.py +++ b/archinstall/lib/installer.py @@ -1,5 +1,4 @@ import glob -import logging import os import re import shlex @@ -12,17 +11,16 @@ from typing import Any, List, Optional, TYPE_CHECKING, Union, Dict, Callable, It from . import disk from .exceptions import DiskError, ServiceException, RequirementError, HardwareIncompatibilityError, SysCallError from .general import SysCommand -from .hardware import has_uefi, is_vm, cpu_vendor -from .locale_helpers import verify_keyboard_layout, verify_x11_keyboard_layout +from .hardware import SysInfo +from .locale import verify_keyboard_layout, verify_x11_keyboard_layout from .luks import Luks2 from .mirrors import use_mirrors from .models.bootloader import Bootloader from .models.network_configuration import NetworkConfiguration from .models.users import User -from .output import log +from .output import log, error, info, warn, debug from .pacman import run_pacman from .plugins import plugins -from .services import service_state from .storage import storage if TYPE_CHECKING: @@ -41,28 +39,6 @@ def accessibility_tools_in_use() -> bool: class Installer: - """ - `Installer()` is the wrapper for most basic installation steps. - It also wraps :py:func:`~archinstall.Installer.pacstrap` among other things. - - :param partition: Requires a partition as the first argument, this is - so that the installer can mount to `mountpoint` and strap packages there. - :type partition: class:`archinstall.Partition` - - :param boot_partition: There's two reasons for needing a boot partition argument, - The first being so that `mkinitcpio` can place the `vmlinuz` kernel at the right place - during the `pacstrap` or `linux` and the base packages for a minimal installation. - The second being when :py:func:`~archinstall.Installer.add_bootloader` is called, - A `boot_partition` must be known to the installer before this is called. - :type boot_partition: class:`archinstall.Partition` - - :param profile: A profile to install, this is optional and can be called later manually. - This just simplifies the process by not having to call :py:func:`~archinstall.Installer.install_profile` later on. - :type profile: str, optional - - :param hostname: The given /etc/hostname for the machine. - :type hostname: str, optional - """ def __init__( self, target: Path, @@ -71,6 +47,10 @@ class Installer: base_packages: List[str] = [], kernels: Optional[List[str]] = None ): + """ + `Installer()` is the wrapper for most basic installation steps. + It also wraps :py:func:`~archinstall.Installer.pacstrap` among other things. + """ if not base_packages: base_packages = __packages__[:3] @@ -126,7 +106,7 @@ class Installer: def __exit__(self, exc_type, exc_val, exc_tb): if exc_type is not None: - log(exc_val, fg='red', level=logging.ERROR) + error(exc_val) self.sync_log_to_install_medium() @@ -137,48 +117,41 @@ class Installer: raise exc_val if not (missing_steps := self.post_install_check()): - self.log('Installation completed without any errors. You may now reboot.', fg='green', level=logging.INFO) + log('Installation completed without any errors. You may now reboot.', fg='green') self.sync_log_to_install_medium() return True else: - self.log('Some required steps were not successfully installed/configured before leaving the installer:', fg='red', level=logging.WARNING) + warn('Some required steps were not successfully installed/configured before leaving the installer:') for step in missing_steps: - self.log(f' - {step}', fg='red', level=logging.WARNING) + warn(f' - {step}') - self.log(f"Detailed error logs can be found at: {storage['LOG_PATH']}", level=logging.WARNING) - self.log("Submit this zip file as an issue to https://github.com/archlinux/archinstall/issues", level=logging.WARNING) + warn(f"Detailed error logs can be found at: {storage['LOG_PATH']}") + warn("Submit this zip file as an issue to https://github.com/archlinux/archinstall/issues") self.sync_log_to_install_medium() return False - def log(self, *args :str, level :int = logging.DEBUG, **kwargs :str): - """ - installer.log() wraps output.log() mainly to set a default log-level for this install session. - Any manual override can be done per log() call. - """ - log(*args, level=level, **kwargs) - def _verify_service_stop(self): """ Certain services might be running that affects the system during installation. One such service is "reflector.service" which updates /etc/pacman.d/mirrorlist We need to wait for it before we continue since we opted in to use a custom mirror/region. """ - log('Waiting for time sync (systemd-timesyncd.service) to complete.', level=logging.INFO) + info('Waiting for time sync (systemd-timesyncd.service) to complete.') while SysCommand('timedatectl show --property=NTPSynchronized --value').decode().rstrip() != 'yes': time.sleep(1) - log('Waiting for automatic mirror selection (reflector) to complete.', level=logging.INFO) - while service_state('reflector') not in ('dead', 'failed', 'exited'): + info('Waiting for automatic mirror selection (reflector) to complete.') + while self._service_state('reflector') not in ('dead', 'failed', 'exited'): time.sleep(1) - log('Waiting pacman-init.service to complete.', level=logging.INFO) - while service_state('pacman-init') not in ('dead', 'failed', 'exited'): + info('Waiting pacman-init.service to complete.') + while self._service_state('pacman-init') not in ('dead', 'failed', 'exited'): time.sleep(1) - log('Waiting Arch Linux keyring sync (archlinux-keyring-wkd-sync) to complete.', level=logging.INFO) - while service_state('archlinux-keyring-wkd-sync') not in ('dead', 'failed', 'exited'): + info('Waiting Arch Linux keyring sync (archlinux-keyring-wkd-sync) to complete.') + while self._service_state('archlinux-keyring-wkd-sync') not in ('dead', 'failed', 'exited'): time.sleep(1) def _verify_boot_part(self): @@ -204,7 +177,7 @@ class Installer: self._verify_service_stop() def mount_ordered_layout(self): - log('Mounting partitions in order', level=logging.INFO) + info('Mounting partitions in order') for mod in self._disk_config.device_modifications: # partitions have to mounted in the right order on btrfs the mountpoint will @@ -275,7 +248,7 @@ class Installer: ) if gen_enc_file and not part_mod.is_root(): - log(f'Creating key-file: {part_mod.dev_path}', level=logging.INFO) + info(f'Creating key-file: {part_mod.dev_path}') luks_handler.create_keyfile(self.target) if part_mod.is_root() and not gen_enc_file: @@ -384,25 +357,25 @@ class Installer: if (result := plugin.on_pacstrap(packages)): packages = result - self.log(f'Installing packages: {packages}', level=logging.INFO) + info(f'Installing packages: {packages}') # TODO: We technically only need to run the -Syy once. try: run_pacman('-Syy', default_cmd='/usr/bin/pacman') - except SysCallError as error: - self.log(f'Could not sync a new package database: {error}', level=logging.ERROR, fg="red") + except SysCallError as err: + error(f'Could not sync a new package database: {err}') if storage['arguments'].get('silent', False) is False: if input('Would you like to re-try this download? (Y/n): ').lower().strip() in ('', 'y'): return self._pacstrap(packages) - raise RequirementError(f'Could not sync mirrors: {error}') + raise RequirementError(f'Could not sync mirrors: {err}') try: SysCommand(f'/usr/bin/pacstrap -C /etc/pacman.conf -K {self.target} {" ".join(packages)} --noconfirm', peek_output=True) return True - except SysCallError as error: - self.log(f'Could not strap in packages: {error}', level=logging.ERROR, fg="red") + except SysCallError as err: + error(f'Could not strap in packages: {err}') if storage['arguments'].get('silent', False) is False: if input('Would you like to re-try this download? (Y/n): ').lower().strip() in ('', 'y'): @@ -420,12 +393,12 @@ class Installer: use_mirrors(mirrors, destination=destination) def genfstab(self, flags :str = '-pU'): - self.log(f"Updating {self.target}/etc/fstab", level=logging.INFO) + info(f"Updating {self.target}/etc/fstab") try: gen_fstab = SysCommand(f'/usr/bin/genfstab {flags} {self.target}').decode() - except SysCallError as error: - raise RequirementError(f'Could not generate fstab, strapping in packages most likely failed (disk out of space?)\n Error: {error}') + except SysCallError as err: + raise RequirementError(f'Could not generate fstab, strapping in packages most likely failed (disk out of space?)\n Error: {err}') if not gen_fstab: raise RequirementError(f'Genrating fstab returned empty value') @@ -530,24 +503,20 @@ class Installer: return True else: - self.log( - f"Time zone {zone} does not exist, continuing with system default.", - level=logging.WARNING, - fg='red' - ) + warn(f'Time zone {zone} does not exist, continuing with system default') return False def activate_time_syncronization(self) -> None: - self.log('Activating systemd-timesyncd for time synchronization using Arch Linux and ntp.org NTP servers.', level=logging.INFO) + info('Activating systemd-timesyncd for time synchronization using Arch Linux and ntp.org NTP servers') self.enable_service('systemd-timesyncd') def enable_espeakup(self) -> None: - self.log('Enabling espeakup.service for speech synthesis (accessibility).', level=logging.INFO) + info('Enabling espeakup.service for speech synthesis (accessibility)') self.enable_service('espeakup') def enable_periodic_trim(self) -> None: - self.log("Enabling periodic TRIM") + info("Enabling periodic TRIM") # fstrim is owned by util-linux, a dependency of both base and systemd. self.enable_service("fstrim.timer") @@ -556,12 +525,12 @@ class Installer: services = [services] for service in services: - self.log(f'Enabling service {service}', level=logging.INFO) + info(f'Enabling service {service}') try: self.arch_chroot(f'systemctl enable {service}') - except SysCallError as error: - raise ServiceException(f"Unable to start service {service}: {error}") + except SysCallError as err: + raise ServiceException(f"Unable to start service {service}: {err}") for plugin in plugins.values(): if hasattr(plugin, 'on_service'): @@ -713,11 +682,11 @@ class Installer: if 'encrypt' not in self._hooks: self._hooks.insert(self._hooks.index('filesystems'), 'encrypt') - if not has_uefi(): + if not SysInfo.has_uefi(): self.base_packages.append('grub') - if not is_vm(): - vendor = cpu_vendor() + if not SysInfo.is_vm(): + vendor = SysInfo.cpu_vendor() if vendor == "AuthenticAMD": self.base_packages.append("amd-ucode") if (ucode := Path(f"{self.target}/boot/amd-ucode.img")).exists(): @@ -727,21 +696,21 @@ class Installer: if (ucode := Path(f"{self.target}/boot/intel-ucode.img")).exists(): ucode.unlink() else: - self.log(f"Unknown CPU vendor '{vendor}' detected. Archinstall won't install any ucode.", level=logging.DEBUG) + debug(f"Unknown CPU vendor '{vendor}' detected. Archinstall won't install any ucode") # Determine whether to enable multilib/testing repositories before running pacstrap if testing flag is set. # This action takes place on the host system as pacstrap copies over package repository lists. if multilib: - self.log("The multilib flag is set. This system will be installed with the multilib repository enabled.") + info("The multilib flag is set. This system will be installed with the multilib repository enabled.") self.enable_multilib_repository() else: - self.log("The multilib flag is not set. This system will be installed without multilib repositories enabled.") + info("The multilib flag is not set. This system will be installed without multilib repositories enabled.") if testing: - self.log("The testing flag is set. This system will be installed with testing repositories enabled.") + info("The testing flag is set. This system will be installed with testing repositories enabled.") self.enable_testing_repositories(multilib) else: - self.log("The testing flag is not set. This system will be installed without testing repositories enabled.") + info("The testing flag is not set. This system will be installed without testing repositories enabled.") self._pacstrap(self.base_packages) self.helper_flags['base-strapped'] = True @@ -773,7 +742,7 @@ class Installer: # Run registered post-install hooks for function in self.post_base_install: - self.log(f"Running post-installation hook: {function}", level=logging.INFO) + info(f"Running post-installation hook: {function}") function(self) for plugin in plugins.values(): @@ -782,7 +751,7 @@ class Installer: def setup_swap(self, kind :str = 'zram'): if kind == 'zram': - self.log(f"Setting up swap on zram") + info(f"Setting up swap on zram") self._pacstrap('zram-generator') # We could use the default example below, but maybe not the best idea: https://github.com/archlinux/archinstall/pull/678#issuecomment-962124813 @@ -812,7 +781,7 @@ class Installer: def _add_systemd_bootloader(self, root_partition: disk.PartitionModification): self._pacstrap('efibootmgr') - if not has_uefi(): + if not SysInfo.has_uefi(): raise HardwareIncompatibilityError # TODO: Ideally we would want to check if another config @@ -862,16 +831,18 @@ class Installer: entry.write(f'# Created on: {self.init_time}\n') entry.write(f'title Arch Linux ({kernel}{variant})\n') entry.write(f"linux /vmlinuz-{kernel}\n") - if not is_vm(): - vendor = cpu_vendor() + if not SysInfo.is_vm(): + vendor = SysInfo.cpu_vendor() if vendor == "AuthenticAMD": entry.write("initrd /amd-ucode.img\n") elif vendor == "GenuineIntel": entry.write("initrd /intel-ucode.img\n") else: - self.log( - f"Unknown CPU vendor '{vendor}' detected. Archinstall won't add any ucode to systemd-boot config.", - level=logging.DEBUG) + debug( + f"Unknown CPU vendor '{vendor}' detected.", + "Archinstall won't add any ucode to systemd-boot config.", + ) + entry.write(f"initrd /initramfs-{kernel}{variant}.img\n") # blkid doesn't trigger on loopback devices really well, # so we'll use the old manual method until we get that sorted out. @@ -890,7 +861,7 @@ class Installer: if root_partition.fs_type.is_crypto(): # TODO: We need to detect if the encrypted device is a whole disk encryption, # or simply a partition encryption. Right now we assume it's a partition (and we always have) - log('Root partition is an encrypted device, identifying by PARTUUID: {root_partition.partuuid}', level=logging.DEBUG) + debug('Root partition is an encrypted device, identifying by PARTUUID: {root_partition.partuuid}') kernel_options = f"options" @@ -905,7 +876,7 @@ class Installer: entry.write(f'{kernel_options} root=/dev/mapper/luksdev {options_entry}') else: - log(f'Identifying root partition by PARTUUID: {root_partition.partuuid}', level=logging.DEBUG) + debug(f'Identifying root partition by PARTUUID: {root_partition.partuuid}') entry.write(f'options root=PARTUUID={root_partition.partuuid} {options_entry}') self.helper_flags['bootloader'] = 'systemd' @@ -920,7 +891,7 @@ class Installer: _file = "/etc/default/grub" if root_partition.fs_type.is_crypto(): - log(f"Using UUID {root_partition.uuid} as encrypted root identifier", level=logging.DEBUG) + debug(f"Using UUID {root_partition.uuid} as encrypted root identifier") cmd_line_linux = f"sed -i 's/GRUB_CMDLINE_LINUX=\"\"/GRUB_CMDLINE_LINUX=\"cryptdevice=UUID={root_partition.uuid}:cryptlvm rootfstype={root_partition.fs_type.value}\"/'" enable_cryptdisk = "sed -i 's/#GRUB_ENABLE_CRYPTODISK=y/GRUB_ENABLE_CRYPTODISK=y/'" @@ -931,9 +902,9 @@ class Installer: SysCommand(f"/usr/bin/arch-chroot {self.target} {cmd_line_linux} {_file}") - log(f"GRUB boot partition: {boot_partition.dev_path}", level=logging.INFO) + info(f"GRUB boot partition: {boot_partition.dev_path}") - if has_uefi(): + if SysInfo.has_uefi(): self._pacstrap('efibootmgr') # TODO: Do we need? Yes, but remove from minimal_installation() instead? try: @@ -941,8 +912,8 @@ class Installer: except SysCallError: try: SysCommand(f'/usr/bin/arch-chroot {self.target} grub-install --debug --target=x86_64-efi --efi-directory=/boot --bootloader-id=GRUB --removable', peek_output=True) - except SysCallError as error: - raise DiskError(f"Could not install GRUB to {self.target}/boot: {error}") + except SysCallError as err: + raise DiskError(f"Could not install GRUB to {self.target}/boot: {err}") else: device = disk.device_handler.get_device_by_partition_path(boot_partition.safe_dev_path) @@ -958,13 +929,13 @@ class Installer: f' --recheck {device.device_info.path}' SysCommand(cmd, peek_output=True) - except SysCallError as error: - raise DiskError(f"Failed to install GRUB boot on {boot_partition.dev_path}: {error}") + except SysCallError as err: + raise DiskError(f"Failed to install GRUB boot on {boot_partition.dev_path}: {err}") try: SysCommand(f'/usr/bin/arch-chroot {self.target} grub-mkconfig -o /boot/grub/grub.cfg') - except SysCallError as error: - raise DiskError(f"Could not configure GRUB: {error}") + except SysCallError as err: + raise DiskError(f"Could not configure GRUB: {err}") self.helper_flags['bootloader'] = "grub" @@ -975,7 +946,7 @@ class Installer: ): self._pacstrap('efibootmgr') - if not has_uefi(): + if not SysInfo.has_uefi(): raise HardwareIncompatibilityError # TODO: Ideally we would want to check if another config @@ -989,14 +960,14 @@ class Installer: kernel_parameters = [] - if not is_vm(): - vendor = cpu_vendor() + if not SysInfo.is_vm(): + vendor = SysInfo.cpu_vendor() if vendor == "AuthenticAMD": kernel_parameters.append("initrd=\\amd-ucode.img") elif vendor == "GenuineIntel": kernel_parameters.append("initrd=\\intel-ucode.img") else: - self.log(f"Unknown CPU vendor '{vendor}' detected. Archinstall won't add any ucode to firmware boot entry.", level=logging.DEBUG) + debug(f"Unknown CPU vendor '{vendor}' detected. Archinstall won't add any ucode to firmware boot entry.") kernel_parameters.append(f"initrd=\\initramfs-{kernel}.img") @@ -1006,10 +977,10 @@ class Installer: if root_partition.fs_type.is_crypto(): # TODO: We need to detect if the encrypted device is a whole disk encryption, # or simply a partition encryption. Right now we assume it's a partition (and we always have) - log(f'Identifying root partition by PARTUUID: {root_partition.partuuid}', level=logging.DEBUG) + debug(f'Identifying root partition by PARTUUID: {root_partition.partuuid}') kernel_parameters.append(f'cryptdevice=PARTUUID={root_partition.partuuid}:luksdev root=/dev/mapper/luksdev rw rootfstype={root_partition.fs_type.value} {" ".join(self._kernel_params)}') else: - log(f'Root partition is an encrypted device identifying by PARTUUID: {root_partition.partuuid}', level=logging.DEBUG) + debug(f'Root partition is an encrypted device identifying by PARTUUID: {root_partition.partuuid}') kernel_parameters.append(f'root=PARTUUID={root_partition.partuuid} rw rootfstype={root_partition.fs_type.value} {" ".join(self._kernel_params)}') device = disk.device_handler.get_device_by_partition_path(boot_partition.safe_dev_path) @@ -1060,7 +1031,7 @@ class Installer: if root_partition is None: raise ValueError(f'Could not detect root at mountpoint {self.target}') - self.log(f'Adding bootloader {bootloader.value} to {boot_partition.dev_path}', level=logging.INFO) + info(f'Adding bootloader {bootloader.value} to {boot_partition.dev_path}') match bootloader: case Bootloader.Systemd: @@ -1078,7 +1049,7 @@ class Installer: self.arch_chroot(f'systemctl enable --user {service}', run_as=user.username) def enable_sudo(self, entity: str, group :bool = False): - self.log(f'Enabling sudo permissions for {entity}.', level=logging.INFO) + info(f'Enabling sudo permissions for {entity}') sudoers_dir = f"{self.target}/etc/sudoers.d" @@ -1127,11 +1098,11 @@ class Installer: handled_by_plugin = result if not handled_by_plugin: - self.log(f'Creating user {user}', level=logging.INFO) + info(f'Creating user {user}') try: SysCommand(f'/usr/bin/arch-chroot {self.target} useradd -m -G wheel {user}') - except SysCallError as error: - raise SystemError(f"Could not create user inside installation: {error}") + except SysCallError as err: + raise SystemError(f"Could not create user inside installation: {err}") for plugin in plugins.values(): if hasattr(plugin, 'on_user_created'): @@ -1149,7 +1120,7 @@ class Installer: self.helper_flags['user'] = True def user_set_pw(self, user :str, password :str) -> bool: - self.log(f'Setting password for {user}', level=logging.INFO) + info(f'Setting password for {user}') if user == 'root': # This means the root account isn't locked/disabled with * in /etc/passwd @@ -1166,7 +1137,7 @@ class Installer: return False def user_set_shell(self, user :str, shell :str) -> bool: - self.log(f'Setting shell for {user} to {shell}', level=logging.INFO) + info(f'Setting shell for {user} to {shell}') try: SysCommand(f"/usr/bin/arch-chroot {self.target} sh -c \"chsh -s {shell} {user}\"") @@ -1183,49 +1154,59 @@ class Installer: return False def set_keyboard_language(self, language: str) -> bool: - log(f"Setting keyboard language to {language}", level=logging.INFO) + info(f"Setting keyboard language to {language}") + if len(language.strip()): if not verify_keyboard_layout(language): - self.log(f"Invalid keyboard language specified: {language}", fg="red", level=logging.ERROR) + error(f"Invalid keyboard language specified: {language}") return False # In accordance with https://github.com/archlinux/archinstall/issues/107#issuecomment-841701968 # Setting an empty keymap first, allows the subsequent call to set layout for both console and x11. - from .systemd import Boot + from .boot import Boot with Boot(self) as session: os.system('/usr/bin/systemd-run --machine=archinstall --pty localectl set-keymap ""') try: session.SysCommand(["localectl", "set-keymap", language]) - except SysCallError as error: - raise ServiceException(f"Unable to set locale '{language}' for console: {error}") + except SysCallError as err: + raise ServiceException(f"Unable to set locale '{language}' for console: {err}") - self.log(f"Keyboard language for this installation is now set to: {language}") + info(f"Keyboard language for this installation is now set to: {language}") else: - self.log('Keyboard language was not changed from default (no language specified).', fg="yellow", level=logging.INFO) + info('Keyboard language was not changed from default (no language specified)') return True def set_x11_keyboard_language(self, language: str) -> bool: - log(f"Setting x11 keyboard language to {language}", level=logging.INFO) """ A fallback function to set x11 layout specifically and separately from console layout. This isn't strictly necessary since .set_keyboard_language() does this as well. """ + info(f"Setting x11 keyboard language to {language}") + if len(language.strip()): if not verify_x11_keyboard_layout(language): - self.log(f"Invalid x11-keyboard language specified: {language}", fg="red", level=logging.ERROR) + error(f"Invalid x11-keyboard language specified: {language}") return False - from .systemd import Boot + from .boot import Boot with Boot(self) as session: session.SysCommand(["localectl", "set-x11-keymap", '""']) try: session.SysCommand(["localectl", "set-x11-keymap", language]) - except SysCallError as error: - raise ServiceException(f"Unable to set locale '{language}' for X11: {error}") + except SysCallError as err: + raise ServiceException(f"Unable to set locale '{language}' for X11: {err}") else: - self.log(f'X11-Keyboard language was not changed from default (no language specified).', fg="yellow", level=logging.INFO) + info(f'X11-Keyboard language was not changed from default (no language specified)') return True + + def _service_state(self, service_name: str) -> str: + if os.path.splitext(service_name)[1] != '.service': + service_name += '.service' # Just to be safe + + state = b''.join(SysCommand(f'systemctl show --no-pager -p SubState --value {service_name}', environment_vars={'SYSTEMD_COLORS': '0'})) + + return state.strip().decode('UTF-8') diff --git a/archinstall/lib/interactions/__init__.py b/archinstall/lib/interactions/__init__.py new file mode 100644 index 00000000..b5691a10 --- /dev/null +++ b/archinstall/lib/interactions/__init__.py @@ -0,0 +1,20 @@ +from .locale_conf import select_locale_lang, select_locale_enc +from .manage_users_conf import UserList, ask_for_additional_users +from .network_conf import ManualNetworkConfig, ask_to_configure_network +from .utils import get_password + +from .disk_conf import ( + select_devices, select_disk_config, get_default_partition_layout, + select_main_filesystem_format, suggest_single_disk_layout, + suggest_multi_disk_layout +) + +from .general_conf import ( + ask_ntp, ask_hostname, ask_for_a_timezone, ask_for_audio_selection, select_language, + select_mirror_regions, select_archinstall_language, ask_additional_packages_to_install, + add_number_of_parrallel_downloads, select_additional_repositories +) + +from .system_conf import ( + select_kernel, ask_for_bootloader, select_driver, ask_for_swap +) diff --git a/archinstall/lib/interactions/disk_conf.py b/archinstall/lib/interactions/disk_conf.py new file mode 100644 index 00000000..78e4cff4 --- /dev/null +++ b/archinstall/lib/interactions/disk_conf.py @@ -0,0 +1,393 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any, TYPE_CHECKING +from typing import Optional, List, Tuple + +from .. import disk +from ..hardware import SysInfo +from ..menu import Menu +from ..menu import TableMenu +from ..menu.menu import MenuSelectionType +from ..output import FormattedOutput, debug +from ..utils.util import prompt_dir + +if TYPE_CHECKING: + _: Any + + +def select_devices(preset: List[disk.BDevice] = []) -> List[disk.BDevice]: + """ + Asks the user to select one or multiple devices + + :return: List of selected devices + :rtype: list + """ + + def _preview_device_selection(selection: disk._DeviceInfo) -> Optional[str]: + dev = disk.device_handler.get_device(selection.path) + if dev and dev.partition_infos: + return FormattedOutput.as_table(dev.partition_infos) + return None + + if preset is None: + preset = [] + + title = str(_('Select one or more devices to use and configure')) + warning = str(_('If you reset the device selection this will also reset the current disk layout. Are you sure?')) + + devices = disk.device_handler.devices + options = [d.device_info for d in devices] + preset_value = [p.device_info for p in preset] + + choice = TableMenu( + title, + data=options, + multi=True, + preset=preset_value, + preview_command=_preview_device_selection, + preview_title=str(_('Existing Partitions')), + preview_size=0.2, + allow_reset=True, + allow_reset_warning_msg=warning + ).run() + + match choice.type_: + case MenuSelectionType.Reset: return [] + case MenuSelectionType.Skip: return preset + case MenuSelectionType.Selection: + selected_device_info: List[disk._DeviceInfo] = choice.value # type: ignore + selected_devices = [] + + for device in devices: + if device.device_info in selected_device_info: + selected_devices.append(device) + + return selected_devices + + +def get_default_partition_layout( + devices: List[disk.BDevice], + filesystem_type: Optional[disk.FilesystemType] = None, + advanced_option: bool = False +) -> List[disk.DeviceModification]: + + if len(devices) == 1: + device_modification = suggest_single_disk_layout( + devices[0], + filesystem_type=filesystem_type, + advanced_options=advanced_option + ) + return [device_modification] + else: + return suggest_multi_disk_layout( + devices, + filesystem_type=filesystem_type, + advanced_options=advanced_option + ) + + +def _manual_partitioning( + preset: List[disk.DeviceModification], + devices: List[disk.BDevice] +) -> List[disk.DeviceModification]: + modifications = [] + for device in devices: + mod = next(filter(lambda x: x.device == device, preset), None) + if not mod: + mod = disk.DeviceModification(device, wipe=False) + + if partitions := disk.manual_partitioning(device, preset=mod.partitions): + mod.partitions = partitions + modifications.append(mod) + + return modifications + + +def select_disk_config( + preset: Optional[disk.DiskLayoutConfiguration] = None, + advanced_option: bool = False +) -> Optional[disk.DiskLayoutConfiguration]: + default_layout = disk.DiskLayoutType.Default.display_msg() + manual_mode = disk.DiskLayoutType.Manual.display_msg() + pre_mount_mode = disk.DiskLayoutType.Pre_mount.display_msg() + + options = [default_layout, manual_mode, pre_mount_mode] + preset_value = preset.config_type.display_msg() if preset else None + warning = str(_('Are you sure you want to reset this setting?')) + + choice = Menu( + _('Select a partitioning option'), + options, + allow_reset=True, + allow_reset_warning_msg=warning, + sort=False, + preview_size=0.2, + preset_values=preset_value + ).run() + + match choice.type_: + case MenuSelectionType.Skip: return preset + case MenuSelectionType.Reset: return None + case MenuSelectionType.Selection: + if choice.single_value == pre_mount_mode: + output = "You will use whatever drive-setup is mounted at the specified directory\n" + output += "WARNING: Archinstall won't check the suitability of this setup\n" + + path = prompt_dir(str(_('Enter the root directory of the mounted devices: ')), output) + mods = disk.device_handler.detect_pre_mounted_mods(path) + + return disk.DiskLayoutConfiguration( + config_type=disk.DiskLayoutType.Pre_mount, + relative_mountpoint=path, + device_modifications=mods + ) + + preset_devices = [mod.device for mod in preset.device_modifications] if preset else [] + + devices = select_devices(preset_devices) + + if not devices: + return None + + if choice.value == default_layout: + modifications = get_default_partition_layout(devices, advanced_option=advanced_option) + if modifications: + return disk.DiskLayoutConfiguration( + config_type=disk.DiskLayoutType.Default, + device_modifications=modifications + ) + elif choice.value == manual_mode: + preset_mods = preset.device_modifications if preset else [] + modifications = _manual_partitioning(preset_mods, devices) + + if modifications: + return disk.DiskLayoutConfiguration( + config_type=disk.DiskLayoutType.Manual, + device_modifications=modifications + ) + + return None + + +def _boot_partition() -> disk.PartitionModification: + if SysInfo.has_uefi(): + start = disk.Size(1, disk.Unit.MiB) + size = disk.Size(512, disk.Unit.MiB) + else: + start = disk.Size(3, disk.Unit.MiB) + size = disk.Size(203, disk.Unit.MiB) + + # boot partition + return disk.PartitionModification( + status=disk.ModificationStatus.Create, + type=disk.PartitionType.Primary, + start=start, + length=size, + mountpoint=Path('/boot'), + fs_type=disk.FilesystemType.Fat32, + flags=[disk.PartitionFlag.Boot] + ) + + +def select_main_filesystem_format(advanced_options=False) -> disk.FilesystemType: + options = { + 'btrfs': disk.FilesystemType.Btrfs, + 'ext4': disk.FilesystemType.Ext4, + 'xfs': disk.FilesystemType.Xfs, + 'f2fs': disk.FilesystemType.F2fs + } + + if advanced_options: + options.update({'ntfs': disk.FilesystemType.Ntfs}) + + prompt = _('Select which filesystem your main partition should use') + choice = Menu(prompt, options, skip=False, sort=False).run() + return options[choice.single_value] + + +def suggest_single_disk_layout( + device: disk.BDevice, + filesystem_type: Optional[disk.FilesystemType] = None, + advanced_options: bool = False, + separate_home: Optional[bool] = None +) -> disk.DeviceModification: + if not filesystem_type: + filesystem_type = select_main_filesystem_format(advanced_options) + + min_size_to_allow_home_part = disk.Size(40, disk.Unit.GiB) + root_partition_size = disk.Size(20, disk.Unit.GiB) + using_subvolumes = False + using_home_partition = False + compression = False + device_size_gib = device.device_info.total_size + + if filesystem_type == disk.FilesystemType.Btrfs: + prompt = str(_('Would you like to use BTRFS subvolumes with a default structure?')) + choice = Menu(prompt, Menu.yes_no(), skip=False, default_option=Menu.yes()).run() + using_subvolumes = choice.value == Menu.yes() + + prompt = str(_('Would you like to use BTRFS compression?')) + choice = Menu(prompt, Menu.yes_no(), skip=False, default_option=Menu.yes()).run() + compression = choice.value == Menu.yes() + + device_modification = disk.DeviceModification(device, wipe=True) + + # Used for reference: https://wiki.archlinux.org/title/partitioning + # 2 MiB is unallocated for GRUB on BIOS. Potentially unneeded for other bootloaders? + + # TODO: On BIOS, /boot partition is only needed if the drive will + # be encrypted, otherwise it is not recommended. We should probably + # add a check for whether the drive will be encrypted or not. + + # Increase the UEFI partition if UEFI is detected. + # Also re-align the start to 1MiB since we don't need the first sectors + # like we do in MBR layouts where the boot loader is installed traditionally. + + boot_partition = _boot_partition() + device_modification.add_partition(boot_partition) + + if not using_subvolumes: + if device_size_gib >= min_size_to_allow_home_part: + if separate_home is None: + prompt = str(_('Would you like to create a separate partition for /home?')) + choice = Menu(prompt, Menu.yes_no(), skip=False, default_option=Menu.yes()).run() + using_home_partition = choice.value == Menu.yes() + elif separate_home is True: + using_home_partition = True + else: + using_home_partition = False + + # root partition + start = disk.Size(513, disk.Unit.MiB) if SysInfo.has_uefi() else disk.Size(206, disk.Unit.MiB) + + # Set a size for / (/root) + if using_subvolumes or device_size_gib < min_size_to_allow_home_part or not using_home_partition: + length = disk.Size(100, disk.Unit.Percent, total_size=device.device_info.total_size) + else: + length = min(device.device_info.total_size, root_partition_size) + + root_partition = disk.PartitionModification( + status=disk.ModificationStatus.Create, + type=disk.PartitionType.Primary, + start=start, + length=length, + mountpoint=Path('/') if not using_subvolumes else None, + fs_type=filesystem_type, + mount_options=['compress=zstd'] if compression else [], + ) + device_modification.add_partition(root_partition) + + if using_subvolumes: + # https://btrfs.wiki.kernel.org/index.php/FAQ + # https://unix.stackexchange.com/questions/246976/btrfs-subvolume-uuid-clash + # https://github.com/classy-giraffe/easy-arch/blob/main/easy-arch.sh + subvolumes = [ + disk.SubvolumeModification(Path('@'), Path('/')), + disk.SubvolumeModification(Path('@home'), Path('/home')), + disk.SubvolumeModification(Path('@log'), Path('/var/log')), + disk.SubvolumeModification(Path('@pkg'), Path('/var/cache/pacman/pkg')), + disk.SubvolumeModification(Path('@.snapshots'), Path('/.snapshots')) + ] + root_partition.btrfs_subvols = subvolumes + elif using_home_partition: + # If we don't want to use subvolumes, + # But we want to be able to re-use data between re-installs.. + # A second partition for /home would be nice if we have the space for it + home_partition = disk.PartitionModification( + status=disk.ModificationStatus.Create, + type=disk.PartitionType.Primary, + start=root_partition.length, + length=disk.Size(100, disk.Unit.Percent, total_size=device.device_info.total_size), + mountpoint=Path('/home'), + fs_type=filesystem_type, + mount_options=['compress=zstd'] if compression else [] + ) + device_modification.add_partition(home_partition) + + return device_modification + + +def suggest_multi_disk_layout( + devices: List[disk.BDevice], + filesystem_type: Optional[disk.FilesystemType] = None, + advanced_options: bool = False +) -> List[disk.DeviceModification]: + if not devices: + return [] + + # Not really a rock solid foundation of information to stand on, but it's a start: + # https://www.reddit.com/r/btrfs/comments/m287gp/partition_strategy_for_two_physical_disks/ + # https://www.reddit.com/r/btrfs/comments/9us4hr/what_is_your_btrfs_partitionsubvolumes_scheme/ + min_home_partition_size = disk.Size(40, disk.Unit.GiB) + # rough estimate taking in to account user desktops etc. TODO: Catch user packages to detect size? + desired_root_partition_size = disk.Size(20, disk.Unit.GiB) + compression = False + + if not filesystem_type: + filesystem_type = select_main_filesystem_format(advanced_options) + + # find proper disk for /home + possible_devices = list(filter(lambda x: x.device_info.total_size >= min_home_partition_size, devices)) + home_device = max(possible_devices, key=lambda d: d.device_info.total_size) if possible_devices else None + + # find proper device for /root + devices_delta = {} + for device in devices: + if device is not home_device: + delta = device.device_info.total_size - desired_root_partition_size + devices_delta[device] = delta + + sorted_delta: List[Tuple[disk.BDevice, Any]] = sorted(devices_delta.items(), key=lambda x: x[1]) + root_device: Optional[disk.BDevice] = sorted_delta[0][0] + + if home_device is None or root_device is None: + text = _('The selected drives do not have the minimum capacity required for an automatic suggestion\n') + text += _('Minimum capacity for /home partition: {}GiB\n').format(min_home_partition_size.format_size(disk.Unit.GiB)) + text += _('Minimum capacity for Arch Linux partition: {}GiB').format(desired_root_partition_size.format_size(disk.Unit.GiB)) + Menu(str(text), [str(_('Continue'))], skip=False).run() + return [] + + if filesystem_type == disk.FilesystemType.Btrfs: + prompt = str(_('Would you like to use BTRFS compression?')) + choice = Menu(prompt, Menu.yes_no(), skip=False, default_option=Menu.yes()).run() + compression = choice.value == Menu.yes() + + device_paths = ', '.join([str(d.device_info.path) for d in devices]) + + debug(f"Suggesting multi-disk-layout for devices: {device_paths}") + debug(f"/root: {root_device.device_info.path}") + debug(f"/home: {home_device.device_info.path}") + + root_device_modification = disk.DeviceModification(root_device, wipe=True) + home_device_modification = disk.DeviceModification(home_device, wipe=True) + + # add boot partition to the root device + boot_partition = _boot_partition() + root_device_modification.add_partition(boot_partition) + + # add root partition to the root device + root_partition = disk.PartitionModification( + status=disk.ModificationStatus.Create, + type=disk.PartitionType.Primary, + start=disk.Size(513, disk.Unit.MiB) if SysInfo.has_uefi() else disk.Size(206, disk.Unit.MiB), + length=disk.Size(100, disk.Unit.Percent, total_size=root_device.device_info.total_size), + mountpoint=Path('/'), + mount_options=['compress=zstd'] if compression else [], + fs_type=filesystem_type + ) + root_device_modification.add_partition(root_partition) + + # add home partition to home device + home_partition = disk.PartitionModification( + status=disk.ModificationStatus.Create, + type=disk.PartitionType.Primary, + start=disk.Size(1, disk.Unit.MiB), + length=disk.Size(100, disk.Unit.Percent, total_size=home_device.device_info.total_size), + mountpoint=Path('/home'), + mount_options=['compress=zstd'] if compression else [], + fs_type=filesystem_type, + ) + home_device_modification.add_partition(home_partition) + + return [root_device_modification, home_device_modification] diff --git a/archinstall/lib/interactions/general_conf.py b/archinstall/lib/interactions/general_conf.py new file mode 100644 index 00000000..5fcfa633 --- /dev/null +++ b/archinstall/lib/interactions/general_conf.py @@ -0,0 +1,243 @@ +from __future__ import annotations + +import pathlib +from typing import List, Any, Optional, Dict, TYPE_CHECKING + +from ..locale import list_keyboard_languages, list_timezones +from ..menu import MenuSelectionType, Menu, TextInput +from ..mirrors import list_mirrors +from ..output import warn +from ..packages.packages import validate_package_list +from ..storage import storage +from ..translationhandler import Language + +if TYPE_CHECKING: + _: Any + + +def ask_ntp(preset: bool = True) -> bool: + prompt = str(_('Would you like to use automatic time synchronization (NTP) with the default time servers?\n')) + prompt += str(_('Hardware time and other post-configuration steps might be required in order for NTP to work.\nFor more information, please check the Arch wiki')) + if preset: + preset_val = Menu.yes() + else: + preset_val = Menu.no() + choice = Menu(prompt, Menu.yes_no(), skip=False, preset_values=preset_val, default_option=Menu.yes()).run() + + return False if choice.value == Menu.no() else True + + +def ask_hostname(preset: str = '') -> str: + while True: + hostname = TextInput( + str(_('Desired hostname for the installation: ')), + preset + ).run().strip() + + if hostname: + return hostname + + +def ask_for_a_timezone(preset: Optional[str] = None) -> Optional[str]: + timezones = list_timezones() + default = 'UTC' + + choice = Menu( + _('Select a timezone'), + list(timezones), + preset_values=preset, + default_option=default + ).run() + + match choice.type_: + case MenuSelectionType.Skip: return preset + case MenuSelectionType.Selection: return choice.single_value + + return None + + +def ask_for_audio_selection(desktop: bool = True, preset: Optional[str] = None) -> Optional[str]: + no_audio = str(_('No audio server')) + choices = ['pipewire', 'pulseaudio'] if desktop else ['pipewire', 'pulseaudio', no_audio] + default = 'pipewire' if desktop else no_audio + + choice = Menu(_('Choose an audio server'), choices, preset_values=preset, default_option=default).run() + + match choice.type_: + case MenuSelectionType.Skip: return preset + case MenuSelectionType.Selection: return choice.single_value + + return None + + +def select_language(preset: Optional[str] = None) -> Optional[str]: + """ + Asks the user to select a language + Usually this is combined with :ref:`archinstall.list_keyboard_languages`. + + :return: The language/dictionary key of the selected language + :rtype: str + """ + kb_lang = list_keyboard_languages() + # sort alphabetically and then by length + sorted_kb_lang = sorted(sorted(list(kb_lang)), key=len) + + choice = Menu( + _('Select keyboard layout'), + sorted_kb_lang, + preset_values=preset, + sort=False + ).run() + + match choice.type_: + case MenuSelectionType.Skip: return preset + case MenuSelectionType.Selection: return choice.single_value + + return None + + +def select_mirror_regions(preset_values: Dict[str, Any] = {}) -> Dict[str, Any]: + """ + Asks the user to select a mirror or region + Usually this is combined with :ref:`archinstall.list_mirrors`. + + :return: The dictionary information about a mirror/region. + :rtype: dict + """ + if preset_values is None: + preselected = None + else: + preselected = list(preset_values.keys()) + + mirrors = list_mirrors() + + choice = Menu( + _('Select one of the regions to download packages from'), + list(mirrors.keys()), + preset_values=preselected, + multi=True, + allow_reset=True + ).run() + + match choice.type_: + case MenuSelectionType.Reset: + return {} + case MenuSelectionType.Skip: + return preset_values + case MenuSelectionType.Selection: + return {selected: mirrors[selected] for selected in choice.multi_value} + + return {} + + +def select_archinstall_language(languages: List[Language], preset: Language) -> Language: + # these are the displayed language names which can either be + # the english name of a language or, if present, the + # name of the language in its own language + options = {lang.display_name: lang for lang in languages} + + title = 'NOTE: If a language can not displayed properly, a proper font must be set manually in the console.\n' + title += 'All available fonts can be found in "/usr/share/kbd/consolefonts"\n' + title += 'e.g. setfont LatGrkCyr-8x16 (to display latin/greek/cyrillic characters)\n' + + choice = Menu( + title, + list(options.keys()), + default_option=preset.display_name, + preview_size=0.5 + ).run() + + match choice.type_: + case MenuSelectionType.Skip: return preset + case MenuSelectionType.Selection: return options[choice.single_value] + + raise ValueError('Language selection not handled') + + +def ask_additional_packages_to_install(pre_set_packages: List[str] = []) -> List[str]: + # Additional packages (with some light weight error handling for invalid package names) + print(_('Only packages such as base, base-devel, linux, linux-firmware, efibootmgr and optional profile packages are installed.')) + print(_('If you desire a web browser, such as firefox or chromium, you may specify it in the following prompt.')) + + def read_packages(already_defined: list = []) -> list: + display = ' '.join(already_defined) + input_packages = TextInput(_('Write additional packages to install (space separated, leave blank to skip): '), display).run().strip() + return input_packages.split() if input_packages else [] + + pre_set_packages = pre_set_packages if pre_set_packages else [] + packages = read_packages(pre_set_packages) + + if not storage['arguments']['offline'] and not storage['arguments']['no_pkg_lookups']: + while True: + if len(packages): + # Verify packages that were given + print(_("Verifying that additional packages exist (this might take a few seconds)")) + valid, invalid = validate_package_list(packages) + + if invalid: + warn(f"Some packages could not be found in the repository: {invalid}") + packages = read_packages(valid) + continue + break + + return packages + + +def add_number_of_parrallel_downloads(input_number :Optional[int] = None) -> Optional[int]: + max_downloads = 5 + print(_(f"This option enables the number of parallel downloads that can occur during installation")) + print(_(f"Enter the number of parallel downloads to be enabled.\n (Enter a value between 1 to {max_downloads})\nNote:")) + print(_(f" - Maximum value : {max_downloads} ( Allows {max_downloads} parallel downloads, allows {max_downloads+1} downloads at a time )")) + print(_(f" - Minimum value : 1 ( Allows 1 parallel download, allows 2 downloads at a time )")) + print(_(f" - Disable/Default : 0 ( Disables parallel downloading, allows only 1 download at a time )")) + + while True: + try: + input_number = int(TextInput(_("[Default value: 0] > ")).run().strip() or 0) + if input_number <= 0: + input_number = 0 + elif input_number > max_downloads: + input_number = max_downloads + break + except: + print(_(f"Invalid input! Try again with a valid input [1 to {max_downloads}, or 0 to disable]")) + + pacman_conf_path = pathlib.Path("/etc/pacman.conf") + with pacman_conf_path.open() as f: + pacman_conf = f.read().split("\n") + + with pacman_conf_path.open("w") as fwrite: + for line in pacman_conf: + if "ParallelDownloads" in line: + fwrite.write(f"ParallelDownloads = {input_number+1}\n") if not input_number == 0 else fwrite.write("#ParallelDownloads = 0\n") + else: + fwrite.write(f"{line}\n") + + return input_number + + +def select_additional_repositories(preset: List[str]) -> List[str]: + """ + Allows the user to select additional repositories (multilib, and testing) if desired. + + :return: The string as a selected repository + :rtype: string + """ + + repositories = ["multilib", "testing"] + + choice = Menu( + _('Choose which optional additional repositories to enable'), + repositories, + sort=False, + multi=True, + preset_values=preset, + allow_reset=True + ).run() + + match choice.type_: + case MenuSelectionType.Skip: return preset + case MenuSelectionType.Reset: return [] + case MenuSelectionType.Selection: return choice.single_value + + return [] diff --git a/archinstall/lib/interactions/locale_conf.py b/archinstall/lib/interactions/locale_conf.py new file mode 100644 index 00000000..de115202 --- /dev/null +++ b/archinstall/lib/interactions/locale_conf.py @@ -0,0 +1,43 @@ +from typing import Any, TYPE_CHECKING, Optional + +from ..locale import list_locales +from ..menu import Menu, MenuSelectionType + +if TYPE_CHECKING: + _: Any + + +def select_locale_lang(preset: Optional[str] = None) -> Optional[str]: + locales = list_locales() + locale_lang = set([locale.split()[0] for locale in locales]) + + choice = Menu( + _('Choose which locale language to use'), + list(locale_lang), + sort=True, + preset_values=preset + ).run() + + match choice.type_: + case MenuSelectionType.Selection: return choice.single_value + case MenuSelectionType.Skip: return preset + + return None + + +def select_locale_enc(preset: Optional[str] = None) -> Optional[str]: + locales = list_locales() + locale_enc = set([locale.split()[1] for locale in locales]) + + choice = Menu( + _('Choose which locale encoding to use'), + list(locale_enc), + sort=True, + preset_values=preset + ).run() + + match choice.type_: + case MenuSelectionType.Selection: return choice.single_value + case MenuSelectionType.Skip: return preset + + return None diff --git a/archinstall/lib/interactions/manage_users_conf.py b/archinstall/lib/interactions/manage_users_conf.py new file mode 100644 index 00000000..879578da --- /dev/null +++ b/archinstall/lib/interactions/manage_users_conf.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +import re +from typing import Any, Dict, TYPE_CHECKING, List, Optional + +from .utils import get_password +from ..menu import Menu, ListManager +from ..models.users import User +from ..output import FormattedOutput + +if TYPE_CHECKING: + _: Any + + +class UserList(ListManager): + """ + subclass of ListManager for the managing of user accounts + """ + + def __init__(self, prompt: str, lusers: List[User]): + self._actions = [ + str(_('Add a user')), + str(_('Change password')), + str(_('Promote/Demote user')), + str(_('Delete User')) + ] + super().__init__(prompt, lusers, [self._actions[0]], self._actions[1:]) + + def reformat(self, data: List[User]) -> Dict[str, Any]: + table = FormattedOutput.as_table(data) + rows = table.split('\n') + + # these are the header rows of the table and do not map to any User obviously + # we're adding 2 spaces as prefix because the menu selector '> ' will be put before + # the selectable rows so the header has to be aligned + display_data: Dict[str, Optional[User]] = {f' {rows[0]}': None, f' {rows[1]}': None} + + for row, user in zip(rows[2:], data): + row = row.replace('|', '\\|') + display_data[row] = user + + return display_data + + def selected_action_display(self, user: User) -> str: + return user.username + + def handle_action(self, action: str, entry: Optional[User], data: List[User]) -> List[User]: + if action == self._actions[0]: # add + new_user = self._add_user() + if new_user is not None: + # in case a user with the same username as an existing user + # was created we'll replace the existing one + data = [d for d in data if d.username != new_user.username] + data += [new_user] + elif action == self._actions[1] and entry: # change password + prompt = str(_('Password for user "{}": ').format(entry.username)) + new_password = get_password(prompt=prompt) + if new_password: + user = next(filter(lambda x: x == entry, data)) + user.password = new_password + elif action == self._actions[2] and entry: # promote/demote + user = next(filter(lambda x: x == entry, data)) + user.sudo = False if user.sudo else True + elif action == self._actions[3] and entry: # delete + data = [d for d in data if d != entry] + + return data + + def _check_for_correct_username(self, username: str) -> bool: + if re.match(r'^[a-z_][a-z0-9_-]*\$?$', username) and len(username) <= 32: + return True + return False + + def _add_user(self) -> Optional[User]: + prompt = '\n\n' + str(_('Enter username (leave blank to skip): ')) + + while True: + username = input(prompt).strip(' ') + if not username: + return None + if not self._check_for_correct_username(username): + error_prompt = str(_("The username you entered is invalid. Try again")) + print(error_prompt) + else: + break + + password = get_password(prompt=str(_('Password for user "{}": ').format(username))) + + if not password: + return None + + choice = Menu( + str(_('Should "{}" be a superuser (sudo)?')).format(username), Menu.yes_no(), + skip=False, + default_option=Menu.yes(), + clear_screen=False, + show_search_hint=False + ).run() + + sudo = True if choice.value == Menu.yes() else False + return User(username, password, sudo) + + +def ask_for_additional_users(prompt: str = '', defined_users: List[User] = []) -> List[User]: + users = UserList(prompt, defined_users).run() + return users diff --git a/archinstall/lib/interactions/network_conf.py b/archinstall/lib/interactions/network_conf.py new file mode 100644 index 00000000..18a834a1 --- /dev/null +++ b/archinstall/lib/interactions/network_conf.py @@ -0,0 +1,172 @@ +from __future__ import annotations + +import ipaddress +from typing import Any, Optional, TYPE_CHECKING, List, Union, Dict + +from ..menu import MenuSelectionType, TextInput +from ..models.network_configuration import NetworkConfiguration, NicType + +from ..networking import list_interfaces +from ..output import FormattedOutput, warn +from ..menu import ListManager, Menu + +if TYPE_CHECKING: + _: Any + + +class ManualNetworkConfig(ListManager): + """ + subclass of ListManager for the managing of network configurations + """ + + def __init__(self, prompt: str, ifaces: List[NetworkConfiguration]): + self._actions = [ + str(_('Add interface')), + str(_('Edit interface')), + str(_('Delete interface')) + ] + + super().__init__(prompt, ifaces, [self._actions[0]], self._actions[1:]) + + def reformat(self, data: List[NetworkConfiguration]) -> Dict[str, Optional[NetworkConfiguration]]: + table = FormattedOutput.as_table(data) + rows = table.split('\n') + + # these are the header rows of the table and do not map to any User obviously + # we're adding 2 spaces as prefix because the menu selector '> ' will be put before + # the selectable rows so the header has to be aligned + display_data: Dict[str, Optional[NetworkConfiguration]] = {f' {rows[0]}': None, f' {rows[1]}': None} + + for row, iface in zip(rows[2:], data): + row = row.replace('|', '\\|') + display_data[row] = iface + + return display_data + + def selected_action_display(self, iface: NetworkConfiguration) -> str: + return iface.iface if iface.iface else '' + + def handle_action(self, action: str, entry: Optional[NetworkConfiguration], data: List[NetworkConfiguration]): + if action == self._actions[0]: # add + iface_name = self._select_iface(data) + if iface_name: + iface = NetworkConfiguration(NicType.MANUAL, iface=iface_name) + iface = self._edit_iface(iface) + data += [iface] + elif entry: + if action == self._actions[1]: # edit interface + data = [d for d in data if d.iface != entry.iface] + data.append(self._edit_iface(entry)) + elif action == self._actions[2]: # delete + data = [d for d in data if d != entry] + + return data + + def _select_iface(self, data: List[NetworkConfiguration]) -> Optional[Any]: + all_ifaces = list_interfaces().values() + existing_ifaces = [d.iface for d in data] + available = set(all_ifaces) - set(existing_ifaces) + choice = Menu(str(_('Select interface to add')), list(available), skip=True).run() + + if choice.type_ == MenuSelectionType.Skip: + return None + + return choice.value + + def _edit_iface(self, edit_iface: NetworkConfiguration): + iface_name = edit_iface.iface + modes = ['DHCP (auto detect)', 'IP (static)'] + default_mode = 'DHCP (auto detect)' + + prompt = _('Select which mode to configure for "{}" or skip to use default mode "{}"').format(iface_name, default_mode) + mode = Menu(prompt, modes, default_option=default_mode, skip=False).run() + + if mode.value == 'IP (static)': + while 1: + prompt = _('Enter the IP and subnet for {} (example: 192.168.0.5/24): ').format(iface_name) + ip = TextInput(prompt, edit_iface.ip).run().strip() + # Implemented new check for correct IP/subnet input + try: + ipaddress.ip_interface(ip) + break + except ValueError: + warn("You need to enter a valid IP in IP-config mode") + + # Implemented new check for correct gateway IP address + gateway = None + + while 1: + gateway = TextInput( + _('Enter your gateway (router) IP address or leave blank for none: '), + edit_iface.gateway + ).run().strip() + try: + if len(gateway) > 0: + ipaddress.ip_address(gateway) + break + except ValueError: + warn("You need to enter a valid gateway (router) IP address") + + if edit_iface.dns: + display_dns = ' '.join(edit_iface.dns) + else: + display_dns = None + dns_input = TextInput(_('Enter your DNS servers (space separated, blank for none): '), display_dns).run().strip() + + dns = [] + if len(dns_input): + dns = dns_input.split(' ') + + return NetworkConfiguration(NicType.MANUAL, iface=iface_name, ip=ip, gateway=gateway, dns=dns, dhcp=False) + else: + # this will contain network iface names + return NetworkConfiguration(NicType.MANUAL, iface=iface_name) + + +def ask_to_configure_network( + preset: Union[NetworkConfiguration, List[NetworkConfiguration]] +) -> Optional[NetworkConfiguration | List[NetworkConfiguration]]: + """ + Configure the network on the newly installed system + """ + network_options = { + 'none': str(_('No network configuration')), + 'iso_config': str(_('Copy ISO network configuration to installation')), + 'network_manager': str(_('Use NetworkManager (necessary to configure internet graphically in GNOME and KDE)')), + 'manual': str(_('Manual configuration')) + } + # for this routine it's easier to set the cursor position rather than a preset value + cursor_idx = None + + if preset and not isinstance(preset, list): + if preset.type == 'iso_config': + cursor_idx = 0 + elif preset.type == 'network_manager': + cursor_idx = 1 + + warning = str(_('Are you sure you want to reset this setting?')) + + choice = Menu( + _('Select one network interface to configure'), + list(network_options.values()), + cursor_index=cursor_idx, + sort=False, + allow_reset=True, + allow_reset_warning_msg=warning + ).run() + + match choice.type_: + case MenuSelectionType.Skip: return preset + case MenuSelectionType.Reset: return None + + if choice.value == network_options['none']: + return None + elif choice.value == network_options['iso_config']: + return NetworkConfiguration(NicType.ISO) + elif choice.value == network_options['network_manager']: + return NetworkConfiguration(NicType.NM) + elif choice.value == network_options['manual']: + preset_ifaces = preset if isinstance(preset, list) else [] + return ManualNetworkConfig('Configure interfaces', preset_ifaces).run() + + return preset diff --git a/archinstall/lib/interactions/system_conf.py b/archinstall/lib/interactions/system_conf.py new file mode 100644 index 00000000..bbcb5b23 --- /dev/null +++ b/archinstall/lib/interactions/system_conf.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +from typing import List, Any, Dict, TYPE_CHECKING, Optional + +from ..hardware import AVAILABLE_GFX_DRIVERS, SysInfo +from ..menu import MenuSelectionType, Menu +from ..models.bootloader import Bootloader + +if TYPE_CHECKING: + _: Any + + +def select_kernel(preset: List[str] = []) -> List[str]: + """ + Asks the user to select a kernel for system. + + :return: The string as a selected kernel + :rtype: string + """ + + kernels = ["linux", "linux-lts", "linux-zen", "linux-hardened"] + default_kernel = "linux" + + warning = str(_('Are you sure you want to reset this setting?')) + + choice = Menu( + _('Choose which kernels to use or leave blank for default "{}"').format(default_kernel), + kernels, + sort=True, + multi=True, + preset_values=preset, + allow_reset=True, + allow_reset_warning_msg=warning + ).run() + + match choice.type_: + case MenuSelectionType.Skip: return preset + case MenuSelectionType.Reset: return [] + case MenuSelectionType.Selection: return choice.value # type: ignore + + +def ask_for_bootloader(preset: Bootloader) -> Bootloader: + # when the system only supports grub + if not SysInfo.has_uefi(): + options = [Bootloader.Grub.value] + default = Bootloader.Grub.value + else: + options = Bootloader.values() + default = Bootloader.Systemd.value + + preset_value = preset.value if preset else None + + choice = Menu( + _('Choose a bootloader'), + options, + preset_values=preset_value, + sort=False, + default_option=default + ).run() + + match choice.type_: + case MenuSelectionType.Skip: return preset + case MenuSelectionType.Selection: return Bootloader(choice.value) + + return preset + + +def select_driver(options: Dict[str, Any] = {}, current_value: Optional[str] = None) -> Optional[str]: + """ + Some what convoluted function, whose job is simple. + Select a graphics driver from a pre-defined set of popular options. + + (The template xorg is for beginner users, not advanced, and should + there for appeal to the general public first and edge cases later) + """ + + if not options: + options = AVAILABLE_GFX_DRIVERS + + drivers = sorted(list(options.keys())) + + if drivers: + title = '' + if SysInfo.has_amd_graphics(): + title += str(_('For the best compatibility with your AMD hardware, you may want to use either the all open-source or AMD / ATI options.')) + '\n' + if SysInfo.has_intel_graphics(): + title += str(_('For the best compatibility with your Intel hardware, you may want to use either the all open-source or Intel options.\n')) + if SysInfo.has_nvidia_graphics(): + title += str(_('For the best compatibility with your Nvidia hardware, you may want to use the Nvidia proprietary driver.\n')) + + title += str(_('\nSelect a graphics driver or leave blank to install all open-source drivers')) + + preset = current_value if current_value else None + choice = Menu(title, drivers, preset_values=preset).run() + + if choice.type_ != MenuSelectionType.Selection: + return None + + return choice.value # type: ignore + + return current_value + + +def ask_for_swap(preset: bool = True) -> bool: + if preset: + preset_val = Menu.yes() + else: + preset_val = Menu.no() + + prompt = _('Would you like to use swap on zram?') + choice = Menu(prompt, Menu.yes_no(), default_option=Menu.yes(), preset_values=preset_val).run() + + match choice.type_: + case MenuSelectionType.Skip: return preset + case MenuSelectionType.Selection: return False if choice.value == Menu.no() else True + + return preset diff --git a/archinstall/lib/interactions/utils.py b/archinstall/lib/interactions/utils.py new file mode 100644 index 00000000..f6b5b2d3 --- /dev/null +++ b/archinstall/lib/interactions/utils.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +import getpass +from typing import Any, Optional, TYPE_CHECKING + +from ..models import PasswordStrength +from ..output import log, error + +if TYPE_CHECKING: + _: Any + +# used for signal handler +SIG_TRIGGER = None + + +def get_password(prompt: str = '') -> Optional[str]: + if not prompt: + prompt = _("Enter a password: ") + + while password := getpass.getpass(prompt): + if len(password.strip()) <= 0: + break + + strength = PasswordStrength.strength(password) + log(f'Password strength: {strength.value}', fg=strength.color()) + + passwd_verification = getpass.getpass(prompt=_('And one more time for verification: ')) + if password != passwd_verification: + error(' * Passwords did not match * ') + continue + + return password + + return None diff --git a/archinstall/lib/locale.py b/archinstall/lib/locale.py new file mode 100644 index 00000000..0a36c072 --- /dev/null +++ b/archinstall/lib/locale.py @@ -0,0 +1,68 @@ +from typing import Iterator, List + +from .exceptions import ServiceException, SysCallError +from .general import SysCommand +from .output import error + + +def list_keyboard_languages() -> Iterator[str]: + for line in SysCommand("localectl --no-pager list-keymaps", environment_vars={'SYSTEMD_COLORS': '0'}): + yield line.decode('UTF-8').strip() + + +def list_locales() -> List[str]: + with open('/etc/locale.gen', 'r') as fp: + locales = [] + # before the list of locales begins there's an empty line with a '#' in front + # so we'll collect the localels from bottom up and halt when we're donw + entries = fp.readlines() + entries.reverse() + + for entry in entries: + text = entry.replace('#', '').strip() + if text == '': + break + locales.append(text) + + locales.reverse() + return locales + + +def list_x11_keyboard_languages() -> Iterator[str]: + for line in SysCommand("localectl --no-pager list-x11-keymap-layouts", environment_vars={'SYSTEMD_COLORS': '0'}): + yield line.decode('UTF-8').strip() + + +def verify_keyboard_layout(layout :str) -> bool: + for language in list_keyboard_languages(): + if layout.lower() == language.lower(): + return True + return False + + +def verify_x11_keyboard_layout(layout :str) -> bool: + for language in list_x11_keyboard_languages(): + if layout.lower() == language.lower(): + return True + return False + + +def set_keyboard_language(locale :str) -> bool: + if len(locale.strip()): + if not verify_keyboard_layout(locale): + error(f"Invalid keyboard locale specified: {locale}") + return False + + try: + SysCommand(f'localectl set-keymap {locale}') + except SysCallError as err: + raise ServiceException(f"Unable to set locale '{locale}' for console: {err}") + + return True + + return False + + +def list_timezones() -> Iterator[str]: + for line in SysCommand("timedatectl --no-pager list-timezones", environment_vars={'SYSTEMD_COLORS': '0'}): + yield line.decode('UTF-8').strip() diff --git a/archinstall/lib/locale_helpers.py b/archinstall/lib/locale_helpers.py deleted file mode 100644 index efb0365f..00000000 --- a/archinstall/lib/locale_helpers.py +++ /dev/null @@ -1,176 +0,0 @@ -import logging -from typing import Iterator, List, Callable, Optional - -from .exceptions import ServiceException, SysCallError -from .general import SysCommand -from .output import log -from .storage import storage - - -def list_keyboard_languages() -> Iterator[str]: - for line in SysCommand("localectl --no-pager list-keymaps", environment_vars={'SYSTEMD_COLORS': '0'}): - yield line.decode('UTF-8').strip() - - -def list_locales() -> List[str]: - with open('/etc/locale.gen', 'r') as fp: - locales = [] - # before the list of locales begins there's an empty line with a '#' in front - # so we'll collect the localels from bottom up and halt when we're donw - entries = fp.readlines() - entries.reverse() - - for entry in entries: - text = entry.replace('#', '').strip() - if text == '': - break - locales.append(text) - - locales.reverse() - return locales - -def get_locale_mode_text(mode): - if mode == 'LC_ALL': - mode_text = "general (LC_ALL)" - elif mode == "LC_CTYPE": - mode_text = "Character set" - elif mode == "LC_NUMERIC": - mode_text = "Numeric values" - elif mode == "LC_TIME": - mode_text = "Time Values" - elif mode == "LC_COLLATE": - mode_text = "sort order" - elif mode == "LC_MESSAGES": - mode_text = "text messages" - else: - mode_text = "Unassigned" - return mode_text - - -def reset_cmd_locale(): - """ sets the cmd_locale to its saved default """ - storage['CMD_LOCALE'] = storage.get('CMD_LOCALE_DEFAULT',{}) - - -def unset_cmd_locale(): - """ archinstall will use the execution environment default """ - storage['CMD_LOCALE'] = {} - - -def set_cmd_locale( - general: Optional[str] = None, - charset :str = 'C', - numbers :str = 'C', - time :str = 'C', - collate :str = 'C', - messages :str = 'C' -): - """ - Set the cmd locale. - If the parameter general is specified, it takes precedence over the rest (might as well not exist) - The rest define some specific settings above the installed default language. If anyone of this parameters is none means the installation default - """ - installed_locales = list_installed_locales() - result = {} - if general: - if general in installed_locales: - storage['CMD_LOCALE'] = {'LC_ALL':general} - else: - log(f"{get_locale_mode_text('LC_ALL')} {general} is not installed. Defaulting to C",fg="yellow",level=logging.WARNING) - return - - if numbers: - if numbers in installed_locales: - result["LC_NUMERIC"] = numbers - else: - log(f"{get_locale_mode_text('LC_NUMERIC')} {numbers} is not installed. Defaulting to installation language",fg="yellow",level=logging.WARNING) - if charset: - if charset in installed_locales: - result["LC_CTYPE"] = charset - else: - log(f"{get_locale_mode_text('LC_CTYPE')} {charset} is not installed. Defaulting to installation language",fg="yellow",level=logging.WARNING) - if time: - if time in installed_locales: - result["LC_TIME"] = time - else: - log(f"{get_locale_mode_text('LC_TIME')} {time} is not installed. Defaulting to installation language",fg="yellow",level=logging.WARNING) - if collate: - if collate in installed_locales: - result["LC_COLLATE"] = collate - else: - log(f"{get_locale_mode_text('LC_COLLATE')} {collate} is not installed. Defaulting to installation language",fg="yellow",level=logging.WARNING) - if messages: - if messages in installed_locales: - result["LC_MESSAGES"] = messages - else: - log(f"{get_locale_mode_text('LC_MESSAGES')} {messages} is not installed. Defaulting to installation language",fg="yellow",level=logging.WARNING) - storage['CMD_LOCALE'] = result - -def host_locale_environ(func :Callable): - """ decorator when we want a function executing in the host's locale environment """ - def wrapper(*args, **kwargs): - unset_cmd_locale() - result = func(*args,**kwargs) - reset_cmd_locale() - return result - return wrapper - -def c_locale_environ(func :Callable): - """ decorator when we want a function executing in the C locale environment """ - def wrapper(*args, **kwargs): - set_cmd_locale(general='C') - result = func(*args,**kwargs) - reset_cmd_locale() - return result - return wrapper - -def list_installed_locales() -> List[str]: - lista = [] - for line in SysCommand('locale -a'): - lista.append(line.decode('UTF-8').strip()) - return lista - -def list_x11_keyboard_languages() -> Iterator[str]: - for line in SysCommand("localectl --no-pager list-x11-keymap-layouts", environment_vars={'SYSTEMD_COLORS': '0'}): - yield line.decode('UTF-8').strip() - - -def verify_keyboard_layout(layout :str) -> bool: - for language in list_keyboard_languages(): - if layout.lower() == language.lower(): - return True - return False - - -def verify_x11_keyboard_layout(layout :str) -> bool: - for language in list_x11_keyboard_languages(): - if layout.lower() == language.lower(): - return True - return False - - -def search_keyboard_layout(layout :str) -> Iterator[str]: - for language in list_keyboard_languages(): - if layout.lower() in language.lower(): - yield language - - -def set_keyboard_language(locale :str) -> bool: - if len(locale.strip()): - if not verify_keyboard_layout(locale): - log(f"Invalid keyboard locale specified: {locale}", fg="red", level=logging.ERROR) - return False - - try: - SysCommand(f'localectl set-keymap {locale}') - except SysCallError as error: - raise ServiceException(f"Unable to set locale '{locale}' for console: {error}") - - return True - - return False - - -def list_timezones() -> Iterator[str]: - for line in SysCommand("timedatectl --no-pager list-timezones", environment_vars={'SYSTEMD_COLORS': '0'}): - yield line.decode('UTF-8').strip() diff --git a/archinstall/lib/luks.py b/archinstall/lib/luks.py index 53a5e8d2..f9b09b53 100644 --- a/archinstall/lib/luks.py +++ b/archinstall/lib/luks.py @@ -1,6 +1,5 @@ from __future__ import annotations -import logging import shlex import time from dataclasses import dataclass @@ -9,7 +8,7 @@ from typing import Optional, List from . import disk from .general import SysCommand, generate_password, SysCommandWorker -from .output import log +from .output import info, debug from .exceptions import SysCallError, DiskError from .storage import storage @@ -61,7 +60,7 @@ class Luks2: iter_time: int = 10000, key_file: Optional[Path] = None ) -> Path: - log(f'Luks2 encrypting: {self.luks_dev_path}', level=logging.INFO) + info(f'Luks2 encrypting: {self.luks_dev_path}') byte_password = self._password_bytes() @@ -95,21 +94,21 @@ class Luks2: try: SysCommand(cryptsetup_args) break - except SysCallError as error: + except SysCallError as err: time.sleep(storage['DISK_TIMEOUTS']) if retry_attempt != storage['DISK_RETRY_ATTEMPTS'] - 1: continue - if error.exit_code == 1: - log(f'luks2 partition currently in use: {self.luks_dev_path}') - log('Attempting to unmount, crypt-close and trying encryption again') + if err.exit_code == 1: + info(f'luks2 partition currently in use: {self.luks_dev_path}') + info('Attempting to unmount, crypt-close and trying encryption again') self.lock() # Then try again to set up the crypt-device SysCommand(cryptsetup_args) else: - raise DiskError(f'Could not encrypt volume "{self.luks_dev_path}": {error}') + raise DiskError(f'Could not encrypt volume "{self.luks_dev_path}": {err}') return key_file @@ -119,7 +118,7 @@ class Luks2: try: return SysCommand(command).decode().strip() # type: ignore except SysCallError as err: - log(f'Unable to get UUID for Luks device: {self.luks_dev_path}', level=logging.INFO) + info(f'Unable to get UUID for Luks device: {self.luks_dev_path}') raise err def is_unlocked(self) -> bool: @@ -133,7 +132,7 @@ class Luks2: :param key_file: An alternative key file :type key_file: Path """ - log(f'Unlocking luks2 device: {self.luks_dev_path}', level=logging.DEBUG) + debug(f'Unlocking luks2 device: {self.luks_dev_path}') if not self.mapper_name: raise ValueError('mapper name missing') @@ -170,11 +169,11 @@ class Luks2: for child in lsblk_info.children: # Unmount the child location for mountpoint in child.mountpoints: - log(f'Unmounting {mountpoint}', level=logging.DEBUG) + debug(f'Unmounting {mountpoint}') disk.device_handler.umount(mountpoint, recursive=True) # And close it if possible. - log(f"Closing crypt device {child.name}", level=logging.DEBUG) + debug(f"Closing crypt device {child.name}") SysCommand(f"cryptsetup close {child.name}") self._mapper_dev = None @@ -194,10 +193,10 @@ class Luks2: if key_file.exists(): if not override: - log(f'Key file {key_file} already exists, keeping existing') + info(f'Key file {key_file} already exists, keeping existing') return else: - log(f'Key file {key_file} already exists, overriding') + info(f'Key file {key_file} already exists, overriding') key_file_path.mkdir(parents=True, exist_ok=True) @@ -210,7 +209,7 @@ class Luks2: self._crypttab(crypttab_path, key_file, options=["luks", "key-slot=1"]) def _add_key(self, key_file: Path): - log(f'Adding additional key-file {key_file}', level=logging.INFO) + info(f'Adding additional key-file {key_file}') command = f'/usr/bin/cryptsetup -q -v luksAddKey {self.luks_dev_path} {key_file}' worker = SysCommandWorker(command, environment_vars={'LC_ALL': 'C'}) @@ -230,7 +229,7 @@ class Luks2: key_file: Path, options: List[str] ) -> None: - log(f'Adding crypttab entry for key {key_file}', level=logging.INFO) + info(f'Adding crypttab entry for key {key_file}') with open(crypttab_path, 'a') as crypttab: opt = ','.join(options) diff --git a/archinstall/lib/menu/abstract_menu.py b/archinstall/lib/menu/abstract_menu.py index e44d65a4..2bd56374 100644 --- a/archinstall/lib/menu/abstract_menu.py +++ b/archinstall/lib/menu/abstract_menu.py @@ -1,11 +1,10 @@ from __future__ import annotations -import logging from typing import Callable, Any, List, Iterator, Tuple, Optional, Dict, TYPE_CHECKING from .menu import Menu, MenuSelectionType -from ..locale_helpers import set_keyboard_language -from ..output import log +from ..locale import set_keyboard_language +from ..output import error from ..translationhandler import TranslationHandler, Language if TYPE_CHECKING: @@ -211,7 +210,7 @@ class AbstractMenu: # TODO: https://stackoverflow.com/questions/28157929/how-to-safely-handle-an-exception-inside-a-context-manager # TODO: skip processing when it comes from a planified exit if len(args) >= 2 and args[1]: - log(args[1], level=logging.ERROR, fg='red') + error(args[1]) print(" Please submit this issue (and file) to https://github.com/archlinux/archinstall/issues") raise args[1] @@ -483,7 +482,7 @@ class AbstractMenu: yield item def _select_archinstall_language(self, preset: Language) -> Language: - from ..user_interaction.general_conf import select_archinstall_language + from ..interactions.general_conf import select_archinstall_language language = select_archinstall_language(self.translation_handler.translated_languages, preset) self._translation_handler.activate(language) return language diff --git a/archinstall/lib/menu/menu.py b/archinstall/lib/menu/menu.py index f3fdb85f..768dfe55 100644 --- a/archinstall/lib/menu/menu.py +++ b/archinstall/lib/menu/menu.py @@ -6,11 +6,8 @@ from typing import Dict, List, Union, Any, TYPE_CHECKING, Optional, Callable from simple_term_menu import TerminalMenu # type: ignore from ..exceptions import RequirementError -from ..output import log +from ..output import debug -from collections.abc import Iterable -import sys -import logging if TYPE_CHECKING: _: Any @@ -127,33 +124,15 @@ class Menu(TerminalMenu): :param extra_bottom_space: Add an extra empty line at the end of the menu :type extra_bottom_space: bool """ - # we guarantee the inmutability of the options outside the class. - # an unknown number of iterables (.keys(),.values(),generator,...) can't be directly copied, in this case - # we recourse to make them lists before, but thru an exceptions - # this is the old code, which is not maintenable with more types - # options = copy(list(p_options) if isinstance(p_options,(type({}.keys()),type({}.values()))) else p_options) - # We check that the options are iterable. If not we abort. Else we copy them to lists - # it options is a dictionary we use the values as entries of the list - # if options is a string object, each character becomes an entry - # if options is a list, we implictily build a copy to maintain immutability - if not isinstance(p_options,Iterable): - log(f"Objects of type {type(p_options)} is not iterable, and are not supported at Menu",fg="red") - log(f"invalid parameter at Menu() call was at <{sys._getframe(1).f_code.co_name}>",level=logging.WARNING) - raise RequirementError("Menu() requires an iterable as option.") - - if isinstance(p_options,dict): + if isinstance(p_options, Dict): options = list(p_options.keys()) else: options = list(p_options) if not options: - log(" * Menu didn't find any options to choose from * ", fg='red') - log(f"invalid parameter at Menu() call was at <{sys._getframe(1).f_code.co_name}>",level=logging.WARNING) raise RequirementError('Menu.__init__() requires at least one option to proceed.') if any([o for o in options if not isinstance(o, str)]): - log(" * Menu options must be of type string * ", fg='red') - log(f"invalid parameter at Menu() call was at <{sys._getframe(1).f_code.co_name}>",level=logging.WARNING) raise RequirementError('Menu.__init__() requires the options to be of type string') if sort: @@ -343,7 +322,7 @@ class Menu(TerminalMenu): idx = self._menu_options.index(self._default_menu_value) indexes.append(idx) except (IndexError, ValueError): - log(f'Error finding index of {p}: {self._menu_options}', level=logging.DEBUG) + debug(f'Error finding index of {p}: {self._menu_options}') if len(indexes) == 0: indexes.append(0) diff --git a/archinstall/lib/mirrors.py b/archinstall/lib/mirrors.py index c6c5c8e4..62a0b081 100644 --- a/archinstall/lib/mirrors.py +++ b/archinstall/lib/mirrors.py @@ -1,4 +1,3 @@ -import logging import pathlib import urllib.error import urllib.request @@ -6,7 +5,7 @@ from typing import Union, Iterable, Dict, Any, List from dataclasses import dataclass from .general import SysCommand -from .output import log +from .output import info, warn from .exceptions import SysCallError from .storage import storage @@ -136,7 +135,7 @@ def use_mirrors( regions: Dict[str, Iterable[str]], destination: str = '/etc/pacman.d/mirrorlist' ): - log(f'A new package mirror-list has been created: {destination}', level=logging.INFO) + info(f'A new package mirror-list has been created: {destination}') with open(destination, 'w') as mirrorlist: for region, mirrors in regions.items(): for mirror in mirrors: @@ -170,7 +169,7 @@ def list_mirrors(sort_order :List[str] = ["https", "http"]) -> Dict[str, Any]: try: response = urllib.request.urlopen(url) except urllib.error.URLError as err: - log(f'Could not fetch an active mirror-list: {err}', level=logging.WARNING, fg="orange") + warn(f'Could not fetch an active mirror-list: {err}') return regions mirrorlist = response.read() diff --git a/archinstall/lib/models/bootloader.py b/archinstall/lib/models/bootloader.py index 38254c99..e21cda33 100644 --- a/archinstall/lib/models/bootloader.py +++ b/archinstall/lib/models/bootloader.py @@ -1,12 +1,11 @@ from __future__ import annotations -import logging import sys from enum import Enum from typing import List -from ..hardware import has_uefi -from ..output import log +from ..hardware import SysInfo +from ..output import warn class Bootloader(Enum): @@ -23,7 +22,7 @@ class Bootloader(Enum): @classmethod def get_default(cls) -> Bootloader: - if has_uefi(): + if SysInfo.has_uefi(): return Bootloader.Systemd else: return Bootloader.Grub @@ -35,6 +34,6 @@ class Bootloader(Enum): if bootloader not in cls.values(): values = ', '.join(cls.values()) - log(f'Invalid bootloader value "{bootloader}". Allowed values: {values}', level=logging.WARN) + warn(f'Invalid bootloader value "{bootloader}". Allowed values: {values}') sys.exit(1) return Bootloader(bootloader) diff --git a/archinstall/lib/models/network_configuration.py b/archinstall/lib/models/network_configuration.py index a8795fc1..93dd1c44 100644 --- a/archinstall/lib/models/network_configuration.py +++ b/archinstall/lib/models/network_configuration.py @@ -1,11 +1,10 @@ from __future__ import annotations -import logging from dataclasses import dataclass, field from enum import Enum from typing import List, Optional, Dict, Union, Any, TYPE_CHECKING, Tuple -from ..output import log +from ..output import debug from ..profile import ProfileConfiguration if TYPE_CHECKING: @@ -138,8 +137,7 @@ class NetworkConfigurationHandler: iface = manual_config.get('iface', None) if iface is None: - log(_('No iface specified for manual configuration')) - exit(1) + raise ValueError('No iface specified for manual configuration') if manual_config.get('dhcp', False) or not any([manual_config.get(v, '') for v in ['ip', 'gateway', 'dns']]): configurations.append( @@ -148,8 +146,7 @@ class NetworkConfigurationHandler: else: ip = manual_config.get('ip', '') if not ip: - log(_('Manual nic configuration with no auto DHCP requires an IP address'), fg='red') - exit(1) + raise ValueError('Manual nic configuration with no auto DHCP requires an IP address') dns = manual_config.get('dns', []) if not isinstance(dns, list): @@ -173,8 +170,7 @@ class NetworkConfigurationHandler: return NicType(nic_type) except ValueError: options = [e.value for e in NicType] - log(_('Unknown nic type: {}. Possible values are {}').format(nic_type, options), fg='red') - exit(1) + raise ValueError(f'Unknown nic type: {nic_type}. Possible values are {options}') def parse_arguments(self, config: Any): if isinstance(config, list): # new data format @@ -187,4 +183,4 @@ class NetworkConfigurationHandler: else: # manual configuration settings self._configuration = self._parse_manual_config([config]) else: - log(f'Unable to parse network configuration: {config}', level=logging.DEBUG) + debug(f'Unable to parse network configuration: {config}') diff --git a/archinstall/lib/networking.py b/archinstall/lib/networking.py index b858daaf..6906c320 100644 --- a/archinstall/lib/networking.py +++ b/archinstall/lib/networking.py @@ -1,4 +1,3 @@ -import logging import os import socket import ssl @@ -8,18 +7,16 @@ from urllib.error import URLError from urllib.parse import urlencode from urllib.request import urlopen -from .exceptions import HardwareIncompatibilityError, SysCallError -from .general import SysCommand -from .output import log +from .exceptions import SysCallError +from .output import error, info, debug from .pacman import run_pacman -from .storage import storage def get_hw_addr(ifname :str) -> str: import fcntl s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - info = fcntl.ioctl(s.fileno(), 0x8927, struct.pack('256s', bytes(ifname, 'utf-8')[:15])) - return ':'.join('%02x' % b for b in info[18:24]) + ret = fcntl.ioctl(s.fileno(), 0x8927, struct.pack('256s', bytes(ifname, 'utf-8')[:15])) + return ':'.join('%02x' % b for b in ret[18:24]) def list_interfaces(skip_loopback :bool = True) -> Dict[str, str]: @@ -36,26 +33,26 @@ def list_interfaces(skip_loopback :bool = True) -> Dict[str, str]: def check_mirror_reachable() -> bool: - log("Testing connectivity to the Arch Linux mirrors ...", level=logging.INFO) + info("Testing connectivity to the Arch Linux mirrors...") try: run_pacman("-Sy") return True except SysCallError as err: if os.geteuid() != 0: - log("check_mirror_reachable() uses 'pacman -Sy' which requires root.", level=logging.ERROR, fg="red") - log(f'exit_code: {err.exit_code}, Error: {err.message}', level=logging.DEBUG) + error("check_mirror_reachable() uses 'pacman -Sy' which requires root.") + debug(f'exit_code: {err.exit_code}, Error: {err.message}') return False def update_keyring() -> bool: - log("Updating archlinux-keyring ...", level=logging.INFO) + info("Updating archlinux-keyring ...") try: run_pacman("-Sy --noconfirm archlinux-keyring") return True except SysCallError: if os.geteuid() != 0: - log("update_keyring() uses 'pacman -Sy archlinux-keyring' which requires root.", level=logging.ERROR, fg="red") + error("update_keyring() uses 'pacman -Sy archlinux-keyring' which requires root.") return False @@ -80,38 +77,6 @@ def enrich_iface_types(interfaces: Union[Dict[str, Any], List[str]]) -> Dict[str return result -def wireless_scan(interface :str) -> None: - interfaces = enrich_iface_types(list(list_interfaces().values())) - if interfaces[interface] != 'WIRELESS': - raise HardwareIncompatibilityError(f"Interface {interface} is not a wireless interface: {interfaces}") - - try: - SysCommand(f"iwctl station {interface} scan") - except SysCallError as error: - raise SystemError(f"Could not scan for wireless networks: {error}") - - if '_WIFI' not in storage: - storage['_WIFI'] = {} - if interface not in storage['_WIFI']: - storage['_WIFI'][interface] = {} - - storage['_WIFI'][interface]['scanning'] = True - - -# TODO: Full WiFi experience might get evolved in the future, pausing for now 2021-01-25 -def get_wireless_networks(interface :str) -> None: - # TODO: Make this oneliner pritter to check if the interface is scanning or not. - # TODO: Rename this to list_wireless_networks() as it doesn't return anything - if '_WIFI' not in storage or interface not in storage['_WIFI'] or storage['_WIFI'][interface].get('scanning', False) is False: - import time - - wireless_scan(interface) - time.sleep(5) - - for line in SysCommand(f"iwctl station {interface} get-networks"): - print(line) - - def fetch_data_from_url(url: str, params: Optional[Dict] = None) -> str: ssl_context = ssl.create_default_context() ssl_context.check_hostname = False diff --git a/archinstall/lib/output.py b/archinstall/lib/output.py index d65f835f..bd31b5b3 100644 --- a/archinstall/lib/output.py +++ b/archinstall/lib/output.py @@ -1,15 +1,16 @@ import logging import os import sys +from enum import Enum + from pathlib import Path from typing import Dict, Union, List, Any, Callable, Optional +from dataclasses import asdict, is_dataclass from .storage import storage -from dataclasses import asdict, is_dataclass class FormattedOutput: - @classmethod def values( cls, @@ -118,7 +119,7 @@ class FormattedOutput: class Journald: @staticmethod - def log(message :str, level :int = logging.DEBUG) -> None: + def log(message: str, level: int = logging.DEBUG) -> None: try: import systemd.journal # type: ignore except ModuleNotFoundError: @@ -134,16 +135,37 @@ class Journald: log_adapter.log(level, message) -# TODO: Replace log() for session based logging. -class SessionLogging: - def __init__(self): - pass +def check_log_permissions(): + filename = storage.get('LOG_FILE', None) + + if not filename: + return + + log_dir = storage.get('LOG_PATH', Path('./')) + absolute_logfile = log_dir / filename + + try: + log_dir.mkdir(exist_ok=True, parents=True) + with absolute_logfile.open('a') as fp: + fp.write('') + except PermissionError: + # Fallback to creating the log file in the current folder + fallback_log_file = Path('./').absolute() / filename + absolute_logfile = fallback_log_file + absolute_logfile.mkdir(exist_ok=True, parents=True) + storage['LOG_PATH'] = Path('./').absolute() + err_string = f"Not enough permission to place log file at {absolute_logfile}, creating it in {fallback_log_file} instead." + warn(err_string) -# Found first reference here: https://stackoverflow.com/questions/7445658/how-to-detect-if-the-console-does-support-ansi-escape-codes-in-python -# And re-used this: https://github.com/django/django/blob/master/django/core/management/color.py#L12 -def supports_color() -> bool: + +def _supports_color() -> bool: """ + Found first reference here: + https://stackoverflow.com/questions/7445658/how-to-detect-if-the-console-does-support-ansi-escape-codes-in-python + And re-used this: + https://github.com/django/django/blob/master/django/core/management/color.py#L12 + Return True if the running system's terminal supports color, and False otherwise. """ @@ -154,13 +176,30 @@ def supports_color() -> bool: return supported_platform and is_a_tty -# Heavily influenced by: https://github.com/django/django/blob/ae8338daf34fd746771e0678081999b656177bae/django/utils/termcolors.py#L13 -# Color options here: https://askubuntu.com/questions/528928/how-to-do-underline-bold-italic-strikethrough-color-background-and-size-i -def stylize_output(text: str, *opts :str, **kwargs) -> str: +class Font(Enum): + bold = '1' + italic = '3' + underscore = '4' + blink = '5' + reverse = '7' + conceal = '8' + + +def _stylize_output( + text: str, + fg: str, + bg: Optional[str], + reset: bool, + font: List[Font] = [], +) -> str: """ + Heavily influenced by: + https://github.com/django/django/blob/ae8338daf34fd746771e0678081999b656177bae/django/utils/termcolors.py#L13 + Color options here: + https://askubuntu.com/questions/528928/how-to-do-underline-bold-italic-strikethrough-color-background-and-size-i + Adds styling to a text given a set of color arguments. """ - opt_dict = {'bold': '1', 'italic': '3', 'underscore': '4', 'blink': '5', 'reverse': '7', 'conceal': '8'} colors = { 'black' : '0', 'red' : '1', @@ -178,65 +217,72 @@ def stylize_output(text: str, *opts :str, **kwargs) -> str: 'darkgray' : '8;5;240', 'lightgray' : '8;5;256' } + foreground = {key: f'3{colors[key]}' for key in colors} background = {key: f'4{colors[key]}' for key in colors} - reset = '0' - code_list = [] - if text == '' and len(opts) == 1 and opts[0] == 'reset': - return '\x1b[%sm' % reset - for k, v in kwargs.items(): - if k == 'fg': - code_list.append(foreground[str(v)]) - elif k == 'bg': - code_list.append(background[str(v)]) + if text == '' and reset: + return '\x1b[%sm' % '0' + + code_list.append(foreground[str(fg)]) + + if bg: + code_list.append(background[str(bg)]) + + for o in font: + code_list.append(o.value) + + ansi = ';'.join(code_list) + + return f'\033[{ansi}m{text}\033[0m' + - for o in opts: - if o in opt_dict: - code_list.append(opt_dict[o]) +def info(*msgs: str): + log(*msgs, level=logging.INFO) - if 'noreset' not in opts: - text = '%s\x1b[%sm' % (text or '', reset) - return '%s%s' % (('\x1b[%sm' % ';'.join(code_list)), text or '') +def debug(*msgs: str): + log(*msgs, level=logging.DEBUG) -def log(*args :str, **kwargs :Union[str, int, Dict[str, Union[str, int]]]) -> None: - string = orig_string = ' '.join([str(x) for x in args]) +def error(*msgs: str): + log(*msgs, level=logging.ERROR, fg='red') + + +def warn(*msgs: str): + log(*msgs, level=logging.WARNING, fg='yellow') + + +def log( + *msgs: str, + level: int = logging.INFO, + fg: str = 'white', + bg: Optional[str] = None, + reset: bool = False, + font: List[Font] = [] +): + text = orig_string = ' '.join([str(x) for x in msgs]) # Attempt to colorize the output if supported # Insert default colors and override with **kwargs - if supports_color(): - kwargs = {'fg': 'white', **kwargs} - string = stylize_output(string, **kwargs) + if _supports_color(): + text = _stylize_output(text, fg, bg, reset, font) # If a logfile is defined in storage, # we use that one to output everything if filename := storage.get('LOG_FILE', None): - absolute_logfile = os.path.join(storage.get('LOG_PATH', './'), filename) + log_dir = storage.get('LOG_PATH', Path('./')) + absolute_logfile = log_dir / filename - try: - Path(absolute_logfile).parents[0].mkdir(exist_ok=True, parents=True) - with open(absolute_logfile, 'a') as log_file: - log_file.write("") - except PermissionError: - # Fallback to creating the log file in the current folder - err_string = f"Not enough permission to place log file at {absolute_logfile}, creating it in {Path('./').absolute() / filename} instead." - absolute_logfile = Path('./').absolute() / filename - absolute_logfile.parents[0].mkdir(exist_ok=True) - absolute_logfile = str(absolute_logfile) - storage['LOG_PATH'] = './' - log(err_string, fg="red") - - with open(absolute_logfile, 'a') as log_file: - log_file.write(f"{orig_string}\n") - - Journald.log(string, level=int(str(kwargs.get('level', logging.INFO)))) + with open(absolute_logfile, 'a') as fp: + fp.write(f"{orig_string}\n") + + Journald.log(text, level=level) # Finally, print the log unless we skipped it based on level. # We use sys.stdout.write()+flush() instead of print() to try and # fix issue #94 - if kwargs.get('level', logging.INFO) != logging.DEBUG or storage.get('arguments', {}).get('verbose', False): - sys.stdout.write(f"{string}\n") + if level != logging.DEBUG or storage.get('arguments', {}).get('verbose', False): + sys.stdout.write(f"{text}\n") sys.stdout.flush() diff --git a/archinstall/lib/pacman.py b/archinstall/lib/pacman.py index 0dfd5afa..f5514f05 100644 --- a/archinstall/lib/pacman.py +++ b/archinstall/lib/pacman.py @@ -1,10 +1,9 @@ -import logging import pathlib import time from typing import TYPE_CHECKING, Any from .general import SysCommand -from .output import log +from .output import warn, error if TYPE_CHECKING: _: Any @@ -19,14 +18,14 @@ def run_pacman(args :str, default_cmd :str = 'pacman') -> SysCommand: pacman_db_lock = pathlib.Path('/var/lib/pacman/db.lck') if pacman_db_lock.exists(): - log(_('Pacman is already running, waiting maximum 10 minutes for it to terminate.'), level=logging.WARNING, fg="red") + warn(_('Pacman is already running, waiting maximum 10 minutes for it to terminate.')) started = time.time() while pacman_db_lock.exists(): time.sleep(0.25) if time.time() - started > (60 * 10): - log(_('Pre-existing pacman lock never exited. Please clean up any existing pacman sessions before using archinstall.'), level=logging.WARNING, fg="red") + error(_('Pre-existing pacman lock never exited. Please clean up any existing pacman sessions before using archinstall.')) exit(1) return SysCommand(f'{default_cmd} {args}') diff --git a/archinstall/lib/plugins.py b/archinstall/lib/plugins.py index b1ece04f..4ccb0666 100644 --- a/archinstall/lib/plugins.py +++ b/archinstall/lib/plugins.py @@ -1,6 +1,5 @@ import hashlib import importlib -import logging import os import sys import urllib.parse @@ -9,7 +8,7 @@ from importlib import metadata from pathlib import Path from typing import Optional, List -from .output import log +from .output import error, info, warn from .storage import storage plugins = {} @@ -24,11 +23,13 @@ for plugin_definition in metadata.entry_points().select(group='archinstall.plugi try: plugins[plugin_definition.name] = plugin_entrypoint() except Exception as err: - log(f'Error: {err}', level=logging.ERROR) - log(f"The above error was detected when loading the plugin: {plugin_definition}", fg="red", level=logging.ERROR) + error( + f'Error: {err}', + f"The above error was detected when loading the plugin: {plugin_definition}" + ) -def localize_path(path: Path) -> Path: +def _localize_path(path: Path) -> Path: """ Support structures for load_plugin() """ @@ -45,7 +46,7 @@ def localize_path(path: Path) -> Path: return path -def import_via_path(path: Path, namespace: Optional[str] = None) -> Optional[str]: +def _import_via_path(path: Path, namespace: Optional[str] = None) -> Optional[str]: if not namespace: namespace = os.path.basename(path) @@ -61,8 +62,10 @@ def import_via_path(path: Path, namespace: Optional[str] = None) -> Optional[str return namespace except Exception as err: - log(f'Error: {err}', level=logging.ERROR) - log(f"The above error was detected when loading the plugin: {path}", fg="red", level=logging.ERROR) + error( + f'Error: {err}', + f"The above error was detected when loading the plugin: {path}" + ) try: del sys.modules[namespace] @@ -72,7 +75,7 @@ def import_via_path(path: Path, namespace: Optional[str] = None) -> Optional[str return namespace -def find_nth(haystack: List[str], needle: str, n: int) -> Optional[int]: +def _find_nth(haystack: List[str], needle: str, n: int) -> Optional[int]: indices = [idx for idx, elem in enumerate(haystack) if elem == needle] if n <= len(indices): return indices[n - 1] @@ -82,34 +85,36 @@ def find_nth(haystack: List[str], needle: str, n: int) -> Optional[int]: def load_plugin(path: Path): namespace: Optional[str] = None parsed_url = urllib.parse.urlparse(str(path)) - log(f"Loading plugin from url {parsed_url}.", level=logging.INFO) + info(f"Loading plugin from url {parsed_url}") # The Profile was not a direct match on a remote URL if not parsed_url.scheme: # Path was not found in any known examples, check if it's an absolute path if os.path.isfile(path): - namespace = import_via_path(path) + namespace = _import_via_path(path) elif parsed_url.scheme in ('https', 'http'): - localized = localize_path(path) - namespace = import_via_path(localized) + localized = _localize_path(path) + namespace = _import_via_path(localized) if namespace and namespace in sys.modules: # Version dependency via __archinstall__version__ variable (if present) in the plugin # Any errors in version inconsistency will be handled through normal error handling if not defined. if hasattr(sys.modules[namespace], '__archinstall__version__'): - archinstall_major_and_minor_version = float(storage['__version__'][:find_nth(storage['__version__'], '.', 2)]) + archinstall_major_and_minor_version = float(storage['__version__'][:_find_nth(storage['__version__'], '.', 2)]) if sys.modules[namespace].__archinstall__version__ < archinstall_major_and_minor_version: - log(f"Plugin {sys.modules[namespace]} does not support the current Archinstall version.", fg="red", level=logging.ERROR) + error(f"Plugin {sys.modules[namespace]} does not support the current Archinstall version.") # Locate the plugin entry-point called Plugin() # This in accordance with the entry_points() from setup.cfg above if hasattr(sys.modules[namespace], 'Plugin'): try: plugins[namespace] = sys.modules[namespace].Plugin() - log(f"Plugin {plugins[namespace]} has been loaded.", fg="gray", level=logging.INFO) + info(f"Plugin {plugins[namespace]} has been loaded.") except Exception as err: - log(f'Error: {err}', level=logging.ERROR) - log(f"The above error was detected when initiating the plugin: {path}", fg="red", level=logging.ERROR) + error( + f'Error: {err}', + f"The above error was detected when initiating the plugin: {path}" + ) else: - log(f"Plugin '{path}' is missing a valid entry-point or is corrupt.", fg="yellow", level=logging.WARNING) + warn(f"Plugin '{path}' is missing a valid entry-point or is corrupt.") diff --git a/archinstall/lib/profile/profile_menu.py b/archinstall/lib/profile/profile_menu.py index 6462685a..213466a6 100644 --- a/archinstall/lib/profile/profile_menu.py +++ b/archinstall/lib/profile/profile_menu.py @@ -6,7 +6,7 @@ from archinstall.default_profiles.profile import Profile, GreeterType from .profile_model import ProfileConfiguration from ..hardware import AVAILABLE_GFX_DRIVERS from ..menu import Menu, MenuSelectionType, AbstractSubMenu, Selector -from ..user_interaction.system_conf import select_driver +from ..interactions.system_conf import select_driver if TYPE_CHECKING: _: Any diff --git a/archinstall/lib/profile/profiles_handler.py b/archinstall/lib/profile/profiles_handler.py index 6ed95f8e..16fef251 100644 --- a/archinstall/lib/profile/profiles_handler.py +++ b/archinstall/lib/profile/profiles_handler.py @@ -1,7 +1,6 @@ from __future__ import annotations import importlib.util -import logging import sys from collections import Counter from functools import cached_property @@ -15,7 +14,7 @@ from .profile_model import ProfileConfiguration from ..hardware import AVAILABLE_GFX_DRIVERS from ..menu import MenuSelectionType, Menu, MenuSelection from ..networking import list_interfaces, fetch_data_from_url -from ..output import log +from ..output import error, debug, info, warn from ..storage import storage if TYPE_CHECKING: @@ -106,7 +105,7 @@ class ProfileHandler: invalid = ', '.join([k for k, v in resolved.items() if v is None]) if invalid: - log(f'No profile definition found: {invalid}') + info(f'No profile definition found: {invalid}') custom_settings = profile_config.get('custom_settings', {}) for profile in valid: @@ -216,7 +215,7 @@ class ProfileHandler: install_session.add_additional_packages(additional_pkg) except Exception as err: - log(f"Could not handle nvidia and linuz-zen specific situations during xorg installation: {err}", level=logging.WARNING, fg="yellow") + warn(f"Could not handle nvidia and linuz-zen specific situations during xorg installation: {err}") # Prep didn't run, so there's no driver to install install_session.add_additional_packages(['xorg-server', 'xorg-xinit']) @@ -250,7 +249,7 @@ class ProfileHandler: self.add_custom_profiles(profiles) except ValueError: err = str(_('Unable to fetch profile from specified url: {}')).format(url) - log(err, level=logging.ERROR, fg="red") + error(err) def _load_profile_class(self, module: ModuleType) -> List[Profile]: """ @@ -264,7 +263,7 @@ class ProfileHandler: if isinstance(cls_, Profile): profiles.append(cls_) except Exception: - log(f'Cannot import {module}, it does not appear to be a Profile class', level=logging.DEBUG) + debug(f'Cannot import {module}, it does not appear to be a Profile class') return profiles @@ -278,7 +277,7 @@ class ProfileHandler: if len(duplicates) > 0: err = str(_('Profiles must have unique name, but profile definitions with duplicate name found: {}')).format(duplicates[0][0]) - log(err, level=logging.ERROR, fg="red") + error(err) sys.exit(1) def _is_legacy(self, file: Path) -> bool: @@ -297,15 +296,15 @@ class ProfileHandler: Process a file for profile definitions """ if self._is_legacy(file): - log(f'Cannot import {file} because it is no longer supported, please use the new profile format') + info(f'Cannot import {file} because it is no longer supported, please use the new profile format') return [] if not file.is_file(): - log(f'Cannot find profile file {file}') + info(f'Cannot find profile file {file}') return [] name = file.name.removesuffix(file.suffix) - log(f'Importing profile: {file}', level=logging.DEBUG) + debug(f'Importing profile: {file}') try: spec = importlib.util.spec_from_file_location(name, file) @@ -315,7 +314,7 @@ class ProfileHandler: spec.loader.exec_module(imported) return self._load_profile_class(imported) except Exception as e: - log(f'Unable to parse file {file}: {e}', level=logging.ERROR) + error(f'Unable to parse file {file}: {e}') return [] diff --git a/archinstall/lib/services.py b/archinstall/lib/services.py deleted file mode 100644 index b177052b..00000000 --- a/archinstall/lib/services.py +++ /dev/null @@ -1,11 +0,0 @@ -import os -from .general import SysCommand - - -def service_state(service_name: str) -> str: - if os.path.splitext(service_name)[1] != '.service': - service_name += '.service' # Just to be safe - - state = b''.join(SysCommand(f'systemctl show --no-pager -p SubState --value {service_name}', environment_vars={'SYSTEMD_COLORS': '0'})) - - return state.strip().decode('UTF-8') diff --git a/archinstall/lib/storage.py b/archinstall/lib/storage.py index 5a54d816..2f256e5d 100644 --- a/archinstall/lib/storage.py +++ b/archinstall/lib/storage.py @@ -11,8 +11,8 @@ from pathlib import Path storage: Dict[str, Any] = { 'PROFILE': Path(__file__).parent.parent.joinpath('default_profiles'), - 'LOG_PATH': '/var/log/archinstall', - 'LOG_FILE': 'install.log', + 'LOG_PATH': Path('/var/log/archinstall'), + 'LOG_FILE': Path('install.log'), 'MOUNT_POINT': Path('/mnt/archinstall'), 'ENC_IDENTIFIER': 'ainst', 'DISK_TIMEOUTS' : 1, # seconds diff --git a/archinstall/lib/systemd.py b/archinstall/lib/systemd.py deleted file mode 100644 index 6ccbc5f6..00000000 --- a/archinstall/lib/systemd.py +++ /dev/null @@ -1,110 +0,0 @@ -import logging -import time -from typing import Iterator, Optional -from .exceptions import SysCallError -from .general import SysCommand, SysCommandWorker, locate_binary -from .installer import Installer -from .output import log -from .storage import storage - - -class Boot: - def __init__(self, installation: Installer): - self.instance = installation - self.container_name = 'archinstall' - self.session: Optional[SysCommandWorker] = None - self.ready = False - - def __enter__(self) -> 'Boot': - if (existing_session := storage.get('active_boot', None)) and existing_session.instance != self.instance: - raise KeyError("Archinstall only supports booting up one instance, and a active session is already active and it is not this one.") - - if existing_session: - self.session = existing_session.session - self.ready = existing_session.ready - else: - # '-P' or --console=pipe could help us not having to do a bunch - # of os.write() calls, but instead use pipes (stdin, stdout and stderr) as usual. - self.session = SysCommandWorker([ - '/usr/bin/systemd-nspawn', - '-D', str(self.instance.target), - '--timezone=off', - '-b', - '--no-pager', - '--machine', self.container_name - ]) - - if not self.ready and self.session: - while self.session.is_alive(): - if b' login:' in self.session: - self.ready = True - break - - storage['active_boot'] = self - return self - - def __exit__(self, *args :str, **kwargs :str) -> None: - # b''.join(sys_command('sync')) # No need to, since the underlying fs() object will call sync. - # TODO: https://stackoverflow.com/questions/28157929/how-to-safely-handle-an-exception-inside-a-context-manager - - if len(args) >= 2 and args[1]: - log(args[1], level=logging.ERROR, fg='red') - log(f"The error above occurred in a temporary boot-up of the installation {self.instance}", level=logging.ERROR, fg="red") - - shutdown = None - shutdown_exit_code: Optional[int] = -1 - - try: - shutdown = SysCommand(f'systemd-run --machine={self.container_name} --pty shutdown now') - except SysCallError as error: - shutdown_exit_code = error.exit_code - - if self.session: - while self.session.is_alive(): - time.sleep(0.25) - - if shutdown and shutdown.exit_code: - shutdown_exit_code = shutdown.exit_code - - if self.session and (self.session.exit_code == 0 or shutdown_exit_code == 0): - storage['active_boot'] = None - else: - session_exit_code = self.session.exit_code if self.session else -1 - - raise SysCallError( - f"Could not shut down temporary boot of {self.instance}: {session_exit_code}/{shutdown_exit_code}", - exit_code=next(filter(bool, [session_exit_code, shutdown_exit_code])) - ) - - def __iter__(self) -> Iterator[bytes]: - if self.session: - for value in self.session: - yield value - - def __contains__(self, key: bytes) -> bool: - if self.session is None: - return False - - return key in self.session - - def is_alive(self) -> bool: - if self.session is None: - return False - - return self.session.is_alive() - - def SysCommand(self, cmd: list, *args, **kwargs) -> SysCommand: - if cmd[0][0] != '/' and cmd[0][:2] != './': - # This check is also done in SysCommand & SysCommandWorker. - # However, that check is done for `machinectl` and not for our chroot command. - # So this wrapper for SysCommand will do this additionally. - - cmd[0] = locate_binary(cmd[0]) - - return SysCommand(["systemd-run", f"--machine={self.container_name}", "--pty", *cmd], *args, **kwargs) - - def SysCommandWorker(self, cmd: list, *args, **kwargs) -> SysCommandWorker: - if cmd[0][0] != '/' and cmd[0][:2] != './': - cmd[0] = locate_binary(cmd[0]) - - return SysCommandWorker(["systemd-run", f"--machine={self.container_name}", "--pty", *cmd], *args, **kwargs) diff --git a/archinstall/lib/translationhandler.py b/archinstall/lib/translationhandler.py index 0d74f974..5f0f0695 100644 --- a/archinstall/lib/translationhandler.py +++ b/archinstall/lib/translationhandler.py @@ -1,14 +1,14 @@ from __future__ import annotations import json -import logging import os import gettext from dataclasses import dataclass from pathlib import Path from typing import List, Dict, Any, TYPE_CHECKING, Optional -from .exceptions import TranslationError + +from .output import error, debug if TYPE_CHECKING: _: Any @@ -80,8 +80,8 @@ class TranslationHandler: language = Language(abbr, lang, translation, percent, translated_lang) languages.append(language) - except FileNotFoundError as error: - raise TranslationError(f"Could not locate language file for '{lang}': {error}") + except FileNotFoundError as err: + raise FileNotFoundError(f"Could not locate language file for '{lang}': {err}") return languages @@ -89,12 +89,12 @@ class TranslationHandler: """ Set the provided font as the new terminal font """ - from .general import SysCommand, log + from .general import SysCommand try: - log(f'Setting font: {font}', level=logging.DEBUG) + debug(f'Setting font: {font}') SysCommand(f'setfont {font}') except Exception: - log(f'Unable to set font {font}', level=logging.ERROR) + error(f'Unable to set font {font}') def _load_language_mappings(self) -> List[Dict[str, Any]]: """ diff --git a/archinstall/lib/user_interaction/__init__.py b/archinstall/lib/user_interaction/__init__.py deleted file mode 100644 index 5ee89de0..00000000 --- a/archinstall/lib/user_interaction/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -from .manage_users_conf import ask_for_additional_users -from .locale_conf import select_locale_lang, select_locale_enc -from .system_conf import select_kernel, select_driver, ask_for_bootloader, ask_for_swap -from .network_conf import ask_to_configure_network -from .general_conf import ( - ask_ntp, ask_for_a_timezone, ask_for_audio_selection, select_language, select_mirror_regions, - select_archinstall_language, ask_additional_packages_to_install, - select_additional_repositories, ask_hostname, add_number_of_parrallel_downloads -) -from .utils import get_password diff --git a/archinstall/lib/user_interaction/disk_conf.py b/archinstall/lib/user_interaction/disk_conf.py deleted file mode 100644 index a77e950a..00000000 --- a/archinstall/lib/user_interaction/disk_conf.py +++ /dev/null @@ -1,391 +0,0 @@ -from __future__ import annotations - -import logging -from pathlib import Path -from typing import Any, TYPE_CHECKING, Optional, List, Tuple - -from .. import disk -from ..hardware import has_uefi -from ..menu import Menu, MenuSelectionType, TableMenu -from ..output import FormattedOutput -from ..output import log -from ..utils.util import prompt_dir - -if TYPE_CHECKING: - _: Any - - -def select_devices(preset: List[disk.BDevice] = []) -> List[disk.BDevice]: - """ - Asks the user to select one or multiple devices - - :return: List of selected devices - :rtype: list - """ - - def _preview_device_selection(selection: disk._DeviceInfo) -> Optional[str]: - dev = disk.device_handler.get_device(selection.path) - if dev and dev.partition_infos: - return FormattedOutput.as_table(dev.partition_infos) - return None - - if preset is None: - preset = [] - - title = str(_('Select one or more devices to use and configure')) - warning = str(_('If you reset the device selection this will also reset the current disk layout. Are you sure?')) - - devices = disk.device_handler.devices - options = [d.device_info for d in devices] - preset_value = [p.device_info for p in preset] - - choice = TableMenu( - title, - data=options, - multi=True, - preset=preset_value, - preview_command=_preview_device_selection, - preview_title=str(_('Existing Partitions')), - preview_size=0.2, - allow_reset=True, - allow_reset_warning_msg=warning - ).run() - - match choice.type_: - case MenuSelectionType.Reset: return [] - case MenuSelectionType.Skip: return preset - case MenuSelectionType.Selection: - selected_device_info: List[disk._DeviceInfo] = choice.value # type: ignore - selected_devices = [] - - for device in devices: - if device.device_info in selected_device_info: - selected_devices.append(device) - - return selected_devices - - -def get_default_partition_layout( - devices: List[disk.BDevice], - filesystem_type: Optional[disk.FilesystemType] = None, - advanced_option: bool = False -) -> List[disk.DeviceModification]: - - if len(devices) == 1: - device_modification = suggest_single_disk_layout( - devices[0], - filesystem_type=filesystem_type, - advanced_options=advanced_option - ) - return [device_modification] - else: - return suggest_multi_disk_layout( - devices, - filesystem_type=filesystem_type, - advanced_options=advanced_option - ) - - -def _manual_partitioning( - preset: List[disk.DeviceModification], - devices: List[disk.BDevice] -) -> List[disk.DeviceModification]: - modifications = [] - for device in devices: - mod = next(filter(lambda x: x.device == device, preset), None) - if not mod: - mod = disk.DeviceModification(device, wipe=False) - - if partitions := disk.manual_partitioning(device, preset=mod.partitions): - mod.partitions = partitions - modifications.append(mod) - - return modifications - - -def select_disk_config( - preset: Optional[disk.DiskLayoutConfiguration] = None, - advanced_option: bool = False -) -> Optional[disk.DiskLayoutConfiguration]: - default_layout = disk.DiskLayoutType.Default.display_msg() - manual_mode = disk.DiskLayoutType.Manual.display_msg() - pre_mount_mode = disk.DiskLayoutType.Pre_mount.display_msg() - - options = [default_layout, manual_mode, pre_mount_mode] - preset_value = preset.config_type.display_msg() if preset else None - warning = str(_('Are you sure you want to reset this setting?')) - - choice = Menu( - _('Select a partitioning option'), - options, - allow_reset=True, - allow_reset_warning_msg=warning, - sort=False, - preview_size=0.2, - preset_values=preset_value - ).run() - - match choice.type_: - case MenuSelectionType.Skip: return preset - case MenuSelectionType.Reset: return None - case MenuSelectionType.Selection: - if choice.single_value == pre_mount_mode: - output = "You will use whatever drive-setup is mounted at the specified directory\n" - output += "WARNING: Archinstall won't check the suitability of this setup\n" - - path = prompt_dir(str(_('Enter the root directory of the mounted devices: ')), output) - mods = disk.device_handler.detect_pre_mounted_mods(path) - - return disk.DiskLayoutConfiguration( - config_type=disk.DiskLayoutType.Pre_mount, - relative_mountpoint=path, - device_modifications=mods - ) - - preset_devices = [mod.device for mod in preset.device_modifications] if preset else [] - - devices = select_devices(preset_devices) - - if not devices: - return None - - if choice.value == default_layout: - modifications = get_default_partition_layout(devices, advanced_option=advanced_option) - if modifications: - return disk.DiskLayoutConfiguration( - config_type=disk.DiskLayoutType.Default, - device_modifications=modifications - ) - elif choice.value == manual_mode: - preset_mods = preset.device_modifications if preset else [] - modifications = _manual_partitioning(preset_mods, devices) - - if modifications: - return disk.DiskLayoutConfiguration( - config_type=disk.DiskLayoutType.Manual, - device_modifications=modifications - ) - - return None - - -def _boot_partition() -> disk.PartitionModification: - if has_uefi(): - start = disk.Size(1, disk.Unit.MiB) - size = disk.Size(512, disk.Unit.MiB) - else: - start = disk.Size(3, disk.Unit.MiB) - size = disk.Size(203, disk.Unit.MiB) - - # boot partition - return disk.PartitionModification( - status=disk.ModificationStatus.Create, - type=disk.PartitionType.Primary, - start=start, - length=size, - mountpoint=Path('/boot'), - fs_type=disk.FilesystemType.Fat32, - flags=[disk.PartitionFlag.Boot] - ) - - -def ask_for_main_filesystem_format(advanced_options=False) -> disk.FilesystemType: - options = { - 'btrfs': disk.FilesystemType.Btrfs, - 'ext4': disk.FilesystemType.Ext4, - 'xfs': disk.FilesystemType.Xfs, - 'f2fs': disk.FilesystemType.F2fs - } - - if advanced_options: - options.update({'ntfs': disk.FilesystemType.Ntfs}) - - prompt = _('Select which filesystem your main partition should use') - choice = Menu(prompt, options, skip=False, sort=False).run() - return options[choice.single_value] - - -def suggest_single_disk_layout( - device: disk.BDevice, - filesystem_type: Optional[disk.FilesystemType] = None, - advanced_options: bool = False, - separate_home: Optional[bool] = None -) -> disk.DeviceModification: - if not filesystem_type: - filesystem_type = ask_for_main_filesystem_format(advanced_options) - - min_size_to_allow_home_part = disk.Size(40, disk.Unit.GiB) - root_partition_size = disk.Size(20, disk.Unit.GiB) - using_subvolumes = False - using_home_partition = False - compression = False - device_size_gib = device.device_info.total_size - - if filesystem_type == disk.FilesystemType.Btrfs: - prompt = str(_('Would you like to use BTRFS subvolumes with a default structure?')) - choice = Menu(prompt, Menu.yes_no(), skip=False, default_option=Menu.yes()).run() - using_subvolumes = choice.value == Menu.yes() - - prompt = str(_('Would you like to use BTRFS compression?')) - choice = Menu(prompt, Menu.yes_no(), skip=False, default_option=Menu.yes()).run() - compression = choice.value == Menu.yes() - - device_modification = disk.DeviceModification(device, wipe=True) - - # Used for reference: https://wiki.archlinux.org/title/partitioning - # 2 MiB is unallocated for GRUB on BIOS. Potentially unneeded for other bootloaders? - - # TODO: On BIOS, /boot partition is only needed if the drive will - # be encrypted, otherwise it is not recommended. We should probably - # add a check for whether the drive will be encrypted or not. - - # Increase the UEFI partition if UEFI is detected. - # Also re-align the start to 1MiB since we don't need the first sectors - # like we do in MBR layouts where the boot loader is installed traditionally. - - boot_partition = _boot_partition() - device_modification.add_partition(boot_partition) - - if not using_subvolumes: - if device_size_gib >= min_size_to_allow_home_part: - if separate_home is None: - prompt = str(_('Would you like to create a separate partition for /home?')) - choice = Menu(prompt, Menu.yes_no(), skip=False, default_option=Menu.yes()).run() - using_home_partition = choice.value == Menu.yes() - elif separate_home is True: - using_home_partition = True - else: - using_home_partition = False - - # root partition - start = disk.Size(513, disk.Unit.MiB) if has_uefi() else disk.Size(206, disk.Unit.MiB) - - # Set a size for / (/root) - if using_subvolumes or device_size_gib < min_size_to_allow_home_part or not using_home_partition: - length = disk.Size(100, disk.Unit.Percent, total_size=device.device_info.total_size) - else: - length = min(device.device_info.total_size, root_partition_size) - - root_partition = disk.PartitionModification( - status=disk.ModificationStatus.Create, - type=disk.PartitionType.Primary, - start=start, - length=length, - mountpoint=Path('/') if not using_subvolumes else None, - fs_type=filesystem_type, - mount_options=['compress=zstd'] if compression else [], - ) - device_modification.add_partition(root_partition) - - if using_subvolumes: - # https://btrfs.wiki.kernel.org/index.php/FAQ - # https://unix.stackexchange.com/questions/246976/btrfs-subvolume-uuid-clash - # https://github.com/classy-giraffe/easy-arch/blob/main/easy-arch.sh - subvolumes = [ - disk.SubvolumeModification(Path('@'), Path('/')), - disk.SubvolumeModification(Path('@home'), Path('/home')), - disk.SubvolumeModification(Path('@log'), Path('/var/log')), - disk.SubvolumeModification(Path('@pkg'), Path('/var/cache/pacman/pkg')), - disk.SubvolumeModification(Path('@.snapshots'), Path('/.snapshots')) - ] - root_partition.btrfs_subvols = subvolumes - elif using_home_partition: - # If we don't want to use subvolumes, - # But we want to be able to re-use data between re-installs.. - # A second partition for /home would be nice if we have the space for it - home_partition = disk.PartitionModification( - status=disk.ModificationStatus.Create, - type=disk.PartitionType.Primary, - start=root_partition.length, - length=disk.Size(100, disk.Unit.Percent, total_size=device.device_info.total_size), - mountpoint=Path('/home'), - fs_type=filesystem_type, - mount_options=['compress=zstd'] if compression else [] - ) - device_modification.add_partition(home_partition) - - return device_modification - - -def suggest_multi_disk_layout( - devices: List[disk.BDevice], - filesystem_type: Optional[disk.FilesystemType] = None, - advanced_options: bool = False -) -> List[disk.DeviceModification]: - if not devices: - return [] - - # Not really a rock solid foundation of information to stand on, but it's a start: - # https://www.reddit.com/r/btrfs/comments/m287gp/partition_strategy_for_two_physical_disks/ - # https://www.reddit.com/r/btrfs/comments/9us4hr/what_is_your_btrfs_partitionsubvolumes_scheme/ - min_home_partition_size = disk.Size(40, disk.Unit.GiB) - # rough estimate taking in to account user desktops etc. TODO: Catch user packages to detect size? - desired_root_partition_size = disk.Size(20, disk.Unit.GiB) - compression = False - - if not filesystem_type: - filesystem_type = ask_for_main_filesystem_format(advanced_options) - - # find proper disk for /home - possible_devices = list(filter(lambda x: x.device_info.total_size >= min_home_partition_size, devices)) - home_device = max(possible_devices, key=lambda d: d.device_info.total_size) if possible_devices else None - - # find proper device for /root - devices_delta = {} - for device in devices: - if device is not home_device: - delta = device.device_info.total_size - desired_root_partition_size - devices_delta[device] = delta - - sorted_delta: List[Tuple[disk.BDevice, Any]] = sorted(devices_delta.items(), key=lambda x: x[1]) - root_device: Optional[disk.BDevice] = sorted_delta[0][0] - - if home_device is None or root_device is None: - text = _('The selected drives do not have the minimum capacity required for an automatic suggestion\n') - text += _('Minimum capacity for /home partition: {}GiB\n').format(min_home_partition_size.format_size(disk.Unit.GiB)) - text += _('Minimum capacity for Arch Linux partition: {}GiB').format(desired_root_partition_size.format_size(disk.Unit.GiB)) - Menu(str(text), [str(_('Continue'))], skip=False).run() - return [] - - if filesystem_type == disk.FilesystemType.Btrfs: - prompt = str(_('Would you like to use BTRFS compression?')) - choice = Menu(prompt, Menu.yes_no(), skip=False, default_option=Menu.yes()).run() - compression = choice.value == Menu.yes() - - device_paths = ', '.join([str(d.device_info.path) for d in devices]) - log(f"Suggesting multi-disk-layout for devices: {device_paths}", level=logging.DEBUG) - log(f"/root: {root_device.device_info.path}", level=logging.DEBUG) - log(f"/home: {home_device.device_info.path}", level=logging.DEBUG) - - root_device_modification = disk.DeviceModification(root_device, wipe=True) - home_device_modification = disk.DeviceModification(home_device, wipe=True) - - # add boot partition to the root device - boot_partition = _boot_partition() - root_device_modification.add_partition(boot_partition) - - # add root partition to the root device - root_partition = disk.PartitionModification( - status=disk.ModificationStatus.Create, - type=disk.PartitionType.Primary, - start=disk.Size(513, disk.Unit.MiB) if has_uefi() else disk.Size(206, disk.Unit.MiB), - length=disk.Size(100, disk.Unit.Percent, total_size=root_device.device_info.total_size), - mountpoint=Path('/'), - mount_options=['compress=zstd'] if compression else [], - fs_type=filesystem_type - ) - root_device_modification.add_partition(root_partition) - - # add home partition to home device - home_partition = disk.PartitionModification( - status=disk.ModificationStatus.Create, - type=disk.PartitionType.Primary, - start=disk.Size(1, disk.Unit.MiB), - length=disk.Size(100, disk.Unit.Percent, total_size=home_device.device_info.total_size), - mountpoint=Path('/home'), - mount_options=['compress=zstd'] if compression else [], - fs_type=filesystem_type, - ) - home_device_modification.add_partition(home_partition) - - return [root_device_modification, home_device_modification] diff --git a/archinstall/lib/user_interaction/general_conf.py b/archinstall/lib/user_interaction/general_conf.py deleted file mode 100644 index 9722dc4d..00000000 --- a/archinstall/lib/user_interaction/general_conf.py +++ /dev/null @@ -1,244 +0,0 @@ -from __future__ import annotations - -import logging -import pathlib -from typing import List, Any, Optional, Dict, TYPE_CHECKING - -from ..locale_helpers import list_keyboard_languages, list_timezones -from ..menu import MenuSelectionType, Menu, TextInput -from ..mirrors import list_mirrors -from ..output import log -from ..packages.packages import validate_package_list -from ..storage import storage -from ..translationhandler import Language - -if TYPE_CHECKING: - _: Any - - -def ask_ntp(preset: bool = True) -> bool: - prompt = str(_('Would you like to use automatic time synchronization (NTP) with the default time servers?\n')) - prompt += str(_('Hardware time and other post-configuration steps might be required in order for NTP to work.\nFor more information, please check the Arch wiki')) - if preset: - preset_val = Menu.yes() - else: - preset_val = Menu.no() - choice = Menu(prompt, Menu.yes_no(), skip=False, preset_values=preset_val, default_option=Menu.yes()).run() - - return False if choice.value == Menu.no() else True - - -def ask_hostname(preset: str = '') -> str: - while True: - hostname = TextInput( - str(_('Desired hostname for the installation: ')), - preset - ).run().strip() - - if hostname: - return hostname - - -def ask_for_a_timezone(preset: Optional[str] = None) -> Optional[str]: - timezones = list_timezones() - default = 'UTC' - - choice = Menu( - _('Select a timezone'), - list(timezones), - preset_values=preset, - default_option=default - ).run() - - match choice.type_: - case MenuSelectionType.Skip: return preset - case MenuSelectionType.Selection: return choice.single_value - - return None - - -def ask_for_audio_selection(desktop: bool = True, preset: Optional[str] = None) -> Optional[str]: - no_audio = str(_('No audio server')) - choices = ['pipewire', 'pulseaudio'] if desktop else ['pipewire', 'pulseaudio', no_audio] - default = 'pipewire' if desktop else no_audio - - choice = Menu(_('Choose an audio server'), choices, preset_values=preset, default_option=default).run() - - match choice.type_: - case MenuSelectionType.Skip: return preset - case MenuSelectionType.Selection: return choice.single_value - - return None - - -def select_language(preset: Optional[str] = None) -> Optional[str]: - """ - Asks the user to select a language - Usually this is combined with :ref:`archinstall.list_keyboard_languages`. - - :return: The language/dictionary key of the selected language - :rtype: str - """ - kb_lang = list_keyboard_languages() - # sort alphabetically and then by length - sorted_kb_lang = sorted(sorted(list(kb_lang)), key=len) - - choice = Menu( - _('Select keyboard layout'), - sorted_kb_lang, - preset_values=preset, - sort=False - ).run() - - match choice.type_: - case MenuSelectionType.Skip: return preset - case MenuSelectionType.Selection: return choice.single_value - - return None - - -def select_mirror_regions(preset_values: Dict[str, Any] = {}) -> Dict[str, Any]: - """ - Asks the user to select a mirror or region - Usually this is combined with :ref:`archinstall.list_mirrors`. - - :return: The dictionary information about a mirror/region. - :rtype: dict - """ - if preset_values is None: - preselected = None - else: - preselected = list(preset_values.keys()) - - mirrors = list_mirrors() - - choice = Menu( - _('Select one of the regions to download packages from'), - list(mirrors.keys()), - preset_values=preselected, - multi=True, - allow_reset=True - ).run() - - match choice.type_: - case MenuSelectionType.Reset: - return {} - case MenuSelectionType.Skip: - return preset_values - case MenuSelectionType.Selection: - return {selected: mirrors[selected] for selected in choice.multi_value} - - return {} - - -def select_archinstall_language(languages: List[Language], preset: Language) -> Language: - # these are the displayed language names which can either be - # the english name of a language or, if present, the - # name of the language in its own language - options = {lang.display_name: lang for lang in languages} - - title = 'NOTE: If a language can not displayed properly, a proper font must be set manually in the console.\n' - title += 'All available fonts can be found in "/usr/share/kbd/consolefonts"\n' - title += 'e.g. setfont LatGrkCyr-8x16 (to display latin/greek/cyrillic characters)\n' - - choice = Menu( - title, - list(options.keys()), - default_option=preset.display_name, - preview_size=0.5 - ).run() - - match choice.type_: - case MenuSelectionType.Skip: return preset - case MenuSelectionType.Selection: return options[choice.single_value] - - raise ValueError('Language selection not handled') - - -def ask_additional_packages_to_install(pre_set_packages: List[str] = []) -> List[str]: - # Additional packages (with some light weight error handling for invalid package names) - print(_('Only packages such as base, base-devel, linux, linux-firmware, efibootmgr and optional profile packages are installed.')) - print(_('If you desire a web browser, such as firefox or chromium, you may specify it in the following prompt.')) - - def read_packages(already_defined: list = []) -> list: - display = ' '.join(already_defined) - input_packages = TextInput(_('Write additional packages to install (space separated, leave blank to skip): '), display).run().strip() - return input_packages.split() if input_packages else [] - - pre_set_packages = pre_set_packages if pre_set_packages else [] - packages = read_packages(pre_set_packages) - - if not storage['arguments']['offline'] and not storage['arguments']['no_pkg_lookups']: - while True: - if len(packages): - # Verify packages that were given - print(_("Verifying that additional packages exist (this might take a few seconds)")) - valid, invalid = validate_package_list(packages) - - if invalid: - log(f"Some packages could not be found in the repository: {invalid}", level=logging.WARNING, fg='red') - packages = read_packages(valid) - continue - break - - return packages - - -def add_number_of_parrallel_downloads(input_number :Optional[int] = None) -> Optional[int]: - max_downloads = 5 - print(_(f"This option enables the number of parallel downloads that can occur during installation")) - print(_(f"Enter the number of parallel downloads to be enabled.\n (Enter a value between 1 to {max_downloads})\nNote:")) - print(_(f" - Maximum value : {max_downloads} ( Allows {max_downloads} parallel downloads, allows {max_downloads+1} downloads at a time )")) - print(_(f" - Minimum value : 1 ( Allows 1 parallel download, allows 2 downloads at a time )")) - print(_(f" - Disable/Default : 0 ( Disables parallel downloading, allows only 1 download at a time )")) - - while True: - try: - input_number = int(TextInput(_("[Default value: 0] > ")).run().strip() or 0) - if input_number <= 0: - input_number = 0 - elif input_number > max_downloads: - input_number = max_downloads - break - except: - print(_(f"Invalid input! Try again with a valid input [1 to {max_downloads}, or 0 to disable]")) - - pacman_conf_path = pathlib.Path("/etc/pacman.conf") - with pacman_conf_path.open() as f: - pacman_conf = f.read().split("\n") - - with pacman_conf_path.open("w") as fwrite: - for line in pacman_conf: - if "ParallelDownloads" in line: - fwrite.write(f"ParallelDownloads = {input_number+1}\n") if not input_number == 0 else fwrite.write("#ParallelDownloads = 0\n") - else: - fwrite.write(f"{line}\n") - - return input_number - - -def select_additional_repositories(preset: List[str]) -> List[str]: - """ - Allows the user to select additional repositories (multilib, and testing) if desired. - - :return: The string as a selected repository - :rtype: string - """ - - repositories = ["multilib", "testing"] - - choice = Menu( - _('Choose which optional additional repositories to enable'), - repositories, - sort=False, - multi=True, - preset_values=preset, - allow_reset=True - ).run() - - match choice.type_: - case MenuSelectionType.Skip: return preset - case MenuSelectionType.Reset: return [] - case MenuSelectionType.Selection: return choice.single_value - - return [] diff --git a/archinstall/lib/user_interaction/locale_conf.py b/archinstall/lib/user_interaction/locale_conf.py deleted file mode 100644 index cdc3423a..00000000 --- a/archinstall/lib/user_interaction/locale_conf.py +++ /dev/null @@ -1,45 +0,0 @@ -from __future__ import annotations - -from typing import Any, TYPE_CHECKING, Optional - -from ..locale_helpers import list_locales -from ..menu import Menu, MenuSelectionType - -if TYPE_CHECKING: - _: Any - - -def select_locale_lang(preset: Optional[str] = None) -> Optional[str]: - locales = list_locales() - locale_lang = set([locale.split()[0] for locale in locales]) - - choice = Menu( - _('Choose which locale language to use'), - list(locale_lang), - sort=True, - preset_values=preset - ).run() - - match choice.type_: - case MenuSelectionType.Selection: return choice.single_value - case MenuSelectionType.Skip: return preset - - return None - - -def select_locale_enc(preset: Optional[str] = None) -> Optional[str]: - locales = list_locales() - locale_enc = set([locale.split()[1] for locale in locales]) - - choice = Menu( - _('Choose which locale encoding to use'), - list(locale_enc), - sort=True, - preset_values=preset - ).run() - - match choice.type_: - case MenuSelectionType.Selection: return choice.single_value - case MenuSelectionType.Skip: return preset - - return None diff --git a/archinstall/lib/user_interaction/manage_users_conf.py b/archinstall/lib/user_interaction/manage_users_conf.py deleted file mode 100644 index 879578da..00000000 --- a/archinstall/lib/user_interaction/manage_users_conf.py +++ /dev/null @@ -1,106 +0,0 @@ -from __future__ import annotations - -import re -from typing import Any, Dict, TYPE_CHECKING, List, Optional - -from .utils import get_password -from ..menu import Menu, ListManager -from ..models.users import User -from ..output import FormattedOutput - -if TYPE_CHECKING: - _: Any - - -class UserList(ListManager): - """ - subclass of ListManager for the managing of user accounts - """ - - def __init__(self, prompt: str, lusers: List[User]): - self._actions = [ - str(_('Add a user')), - str(_('Change password')), - str(_('Promote/Demote user')), - str(_('Delete User')) - ] - super().__init__(prompt, lusers, [self._actions[0]], self._actions[1:]) - - def reformat(self, data: List[User]) -> Dict[str, Any]: - table = FormattedOutput.as_table(data) - rows = table.split('\n') - - # these are the header rows of the table and do not map to any User obviously - # we're adding 2 spaces as prefix because the menu selector '> ' will be put before - # the selectable rows so the header has to be aligned - display_data: Dict[str, Optional[User]] = {f' {rows[0]}': None, f' {rows[1]}': None} - - for row, user in zip(rows[2:], data): - row = row.replace('|', '\\|') - display_data[row] = user - - return display_data - - def selected_action_display(self, user: User) -> str: - return user.username - - def handle_action(self, action: str, entry: Optional[User], data: List[User]) -> List[User]: - if action == self._actions[0]: # add - new_user = self._add_user() - if new_user is not None: - # in case a user with the same username as an existing user - # was created we'll replace the existing one - data = [d for d in data if d.username != new_user.username] - data += [new_user] - elif action == self._actions[1] and entry: # change password - prompt = str(_('Password for user "{}": ').format(entry.username)) - new_password = get_password(prompt=prompt) - if new_password: - user = next(filter(lambda x: x == entry, data)) - user.password = new_password - elif action == self._actions[2] and entry: # promote/demote - user = next(filter(lambda x: x == entry, data)) - user.sudo = False if user.sudo else True - elif action == self._actions[3] and entry: # delete - data = [d for d in data if d != entry] - - return data - - def _check_for_correct_username(self, username: str) -> bool: - if re.match(r'^[a-z_][a-z0-9_-]*\$?$', username) and len(username) <= 32: - return True - return False - - def _add_user(self) -> Optional[User]: - prompt = '\n\n' + str(_('Enter username (leave blank to skip): ')) - - while True: - username = input(prompt).strip(' ') - if not username: - return None - if not self._check_for_correct_username(username): - error_prompt = str(_("The username you entered is invalid. Try again")) - print(error_prompt) - else: - break - - password = get_password(prompt=str(_('Password for user "{}": ').format(username))) - - if not password: - return None - - choice = Menu( - str(_('Should "{}" be a superuser (sudo)?')).format(username), Menu.yes_no(), - skip=False, - default_option=Menu.yes(), - clear_screen=False, - show_search_hint=False - ).run() - - sudo = True if choice.value == Menu.yes() else False - return User(username, password, sudo) - - -def ask_for_additional_users(prompt: str = '', defined_users: List[User] = []) -> List[User]: - users = UserList(prompt, defined_users).run() - return users diff --git a/archinstall/lib/user_interaction/network_conf.py b/archinstall/lib/user_interaction/network_conf.py deleted file mode 100644 index b682c1d2..00000000 --- a/archinstall/lib/user_interaction/network_conf.py +++ /dev/null @@ -1,173 +0,0 @@ -from __future__ import annotations - -import ipaddress -import logging -from typing import Any, Optional, TYPE_CHECKING, List, Union, Dict - -from ..menu import MenuSelectionType, TextInput -from ..models.network_configuration import NetworkConfiguration, NicType - -from ..networking import list_interfaces -from ..output import log, FormattedOutput -from ..menu import ListManager, Menu - -if TYPE_CHECKING: - _: Any - - -class ManualNetworkConfig(ListManager): - """ - subclass of ListManager for the managing of network configurations - """ - - def __init__(self, prompt: str, ifaces: List[NetworkConfiguration]): - self._actions = [ - str(_('Add interface')), - str(_('Edit interface')), - str(_('Delete interface')) - ] - - super().__init__(prompt, ifaces, [self._actions[0]], self._actions[1:]) - - def reformat(self, data: List[NetworkConfiguration]) -> Dict[str, Optional[NetworkConfiguration]]: - table = FormattedOutput.as_table(data) - rows = table.split('\n') - - # these are the header rows of the table and do not map to any User obviously - # we're adding 2 spaces as prefix because the menu selector '> ' will be put before - # the selectable rows so the header has to be aligned - display_data: Dict[str, Optional[NetworkConfiguration]] = {f' {rows[0]}': None, f' {rows[1]}': None} - - for row, iface in zip(rows[2:], data): - row = row.replace('|', '\\|') - display_data[row] = iface - - return display_data - - def selected_action_display(self, iface: NetworkConfiguration) -> str: - return iface.iface if iface.iface else '' - - def handle_action(self, action: str, entry: Optional[NetworkConfiguration], data: List[NetworkConfiguration]): - if action == self._actions[0]: # add - iface_name = self._select_iface(data) - if iface_name: - iface = NetworkConfiguration(NicType.MANUAL, iface=iface_name) - iface = self._edit_iface(iface) - data += [iface] - elif entry: - if action == self._actions[1]: # edit interface - data = [d for d in data if d.iface != entry.iface] - data.append(self._edit_iface(entry)) - elif action == self._actions[2]: # delete - data = [d for d in data if d != entry] - - return data - - def _select_iface(self, data: List[NetworkConfiguration]) -> Optional[Any]: - all_ifaces = list_interfaces().values() - existing_ifaces = [d.iface for d in data] - available = set(all_ifaces) - set(existing_ifaces) - choice = Menu(str(_('Select interface to add')), list(available), skip=True).run() - - if choice.type_ == MenuSelectionType.Skip: - return None - - return choice.value - - def _edit_iface(self, edit_iface: NetworkConfiguration): - iface_name = edit_iface.iface - modes = ['DHCP (auto detect)', 'IP (static)'] - default_mode = 'DHCP (auto detect)' - - prompt = _('Select which mode to configure for "{}" or skip to use default mode "{}"').format(iface_name, default_mode) - mode = Menu(prompt, modes, default_option=default_mode, skip=False).run() - - if mode.value == 'IP (static)': - while 1: - prompt = _('Enter the IP and subnet for {} (example: 192.168.0.5/24): ').format(iface_name) - ip = TextInput(prompt, edit_iface.ip).run().strip() - # Implemented new check for correct IP/subnet input - try: - ipaddress.ip_interface(ip) - break - except ValueError: - log("You need to enter a valid IP in IP-config mode.", level=logging.WARNING, fg='red') - - # Implemented new check for correct gateway IP address - gateway = None - - while 1: - gateway = TextInput( - _('Enter your gateway (router) IP address or leave blank for none: '), - edit_iface.gateway - ).run().strip() - try: - if len(gateway) > 0: - ipaddress.ip_address(gateway) - break - except ValueError: - log("You need to enter a valid gateway (router) IP address.", level=logging.WARNING, fg='red') - - if edit_iface.dns: - display_dns = ' '.join(edit_iface.dns) - else: - display_dns = None - dns_input = TextInput(_('Enter your DNS servers (space separated, blank for none): '), display_dns).run().strip() - - dns = [] - if len(dns_input): - dns = dns_input.split(' ') - - return NetworkConfiguration(NicType.MANUAL, iface=iface_name, ip=ip, gateway=gateway, dns=dns, dhcp=False) - else: - # this will contain network iface names - return NetworkConfiguration(NicType.MANUAL, iface=iface_name) - - -def ask_to_configure_network( - preset: Union[NetworkConfiguration, List[NetworkConfiguration]] -) -> Optional[NetworkConfiguration | List[NetworkConfiguration]]: - """ - Configure the network on the newly installed system - """ - network_options = { - 'none': str(_('No network configuration')), - 'iso_config': str(_('Copy ISO network configuration to installation')), - 'network_manager': str(_('Use NetworkManager (necessary to configure internet graphically in GNOME and KDE)')), - 'manual': str(_('Manual configuration')) - } - # for this routine it's easier to set the cursor position rather than a preset value - cursor_idx = None - - if preset and not isinstance(preset, list): - if preset.type == 'iso_config': - cursor_idx = 0 - elif preset.type == 'network_manager': - cursor_idx = 1 - - warning = str(_('Are you sure you want to reset this setting?')) - - choice = Menu( - _('Select one network interface to configure'), - list(network_options.values()), - cursor_index=cursor_idx, - sort=False, - allow_reset=True, - allow_reset_warning_msg=warning - ).run() - - match choice.type_: - case MenuSelectionType.Skip: return preset - case MenuSelectionType.Reset: return None - - if choice.value == network_options['none']: - return None - elif choice.value == network_options['iso_config']: - return NetworkConfiguration(NicType.ISO) - elif choice.value == network_options['network_manager']: - return NetworkConfiguration(NicType.NM) - elif choice.value == network_options['manual']: - preset_ifaces = preset if isinstance(preset, list) else [] - return ManualNetworkConfig('Configure interfaces', preset_ifaces).run() - - return preset diff --git a/archinstall/lib/user_interaction/save_conf.py b/archinstall/lib/user_interaction/save_conf.py deleted file mode 100644 index e05b9afe..00000000 --- a/archinstall/lib/user_interaction/save_conf.py +++ /dev/null @@ -1,113 +0,0 @@ -from __future__ import annotations - -import logging - -from pathlib import Path -from typing import Any, Dict, TYPE_CHECKING - -from ..general import SysCommand -from ..menu import Menu -from ..menu.menu import MenuSelectionType -from ..output import log -from ..configuration import ConfigurationOutput - -if TYPE_CHECKING: - _: Any - - -def save_config(config: Dict): - def preview(selection: str): - if options['user_config'] == selection: - serialized = config_output.user_config_to_json() - return f'{config_output.user_configuration_file}\n{serialized}' - elif options['user_creds'] == selection: - if maybe_serial := config_output.user_credentials_to_json(): - return f'{config_output.user_credentials_file}\n{maybe_serial}' - else: - return str(_('No configuration')) - elif options['all'] == selection: - output = f'{config_output.user_configuration_file}\n' - if config_output.user_credentials_to_json(): - output += f'{config_output.user_credentials_file}\n' - return output[:-1] - return None - - config_output = ConfigurationOutput(config) - - options = { - 'user_config': str(_('Save user configuration')), - 'user_creds': str(_('Save user credentials')), - 'disk_layout': str(_('Save disk layout')), - 'all': str(_('Save all')) - } - - choice = Menu( - _('Choose which configuration to save'), - list(options.values()), - sort=False, - skip=True, - preview_size=0.75, - preview_command=preview - ).run() - - if choice.type_ == MenuSelectionType.Skip: - return - - save_config_value = choice.single_value - saving_key = [k for k, v in options.items() if v == save_config_value][0] - - dirs_to_exclude = [ - '/bin', - '/dev', - '/lib', - '/lib64', - '/lost+found', - '/opt', - '/proc', - '/run', - '/sbin', - '/srv', - '/sys', - '/usr', - '/var', - ] - - log('Ignore configuration option folders: ' + ','.join(dirs_to_exclude), level=logging.DEBUG) - log(_('Finding possible directories to save configuration files ...'), level=logging.INFO) - - find_exclude = '-path ' + ' -prune -o -path '.join(dirs_to_exclude) + ' -prune ' - file_picker_command = f'find / {find_exclude} -o -type d -print0' - - directories = SysCommand(file_picker_command).decode() - - if directories is None: - raise ValueError('Failed to retrieve possible configuration directories') - - possible_save_dirs = list(filter(None, directories.split('\x00'))) - - selection = Menu( - _('Select directory (or directories) for saving configuration files'), - possible_save_dirs, - multi=True, - skip=True, - allow_reset=False, - ).run() - - match selection.type_: - case MenuSelectionType.Skip: - return - - save_dirs = selection.multi_value - - log(f'Saving {saving_key} configuration files to {save_dirs}', level=logging.DEBUG) - - if save_dirs is not None: - for save_dir_str in save_dirs: - save_dir = Path(save_dir_str) - if options['user_config'] == save_config_value: - config_output.save_user_config(save_dir) - elif options['user_creds'] == save_config_value: - config_output.save_user_creds(save_dir) - elif options['all'] == save_config_value: - config_output.save_user_config(save_dir) - config_output.save_user_creds(save_dir) diff --git a/archinstall/lib/user_interaction/system_conf.py b/archinstall/lib/user_interaction/system_conf.py deleted file mode 100644 index 3f57d0e7..00000000 --- a/archinstall/lib/user_interaction/system_conf.py +++ /dev/null @@ -1,117 +0,0 @@ -from __future__ import annotations - -from typing import List, Any, Dict, TYPE_CHECKING, Optional - -from ..hardware import AVAILABLE_GFX_DRIVERS, has_uefi, has_amd_graphics, has_intel_graphics, has_nvidia_graphics -from ..menu import MenuSelectionType, Menu -from ..models.bootloader import Bootloader - -if TYPE_CHECKING: - _: Any - - -def select_kernel(preset: List[str] = []) -> List[str]: - """ - Asks the user to select a kernel for system. - - :return: The string as a selected kernel - :rtype: string - """ - - kernels = ["linux", "linux-lts", "linux-zen", "linux-hardened"] - default_kernel = "linux" - - warning = str(_('Are you sure you want to reset this setting?')) - - choice = Menu( - _('Choose which kernels to use or leave blank for default "{}"').format(default_kernel), - kernels, - sort=True, - multi=True, - preset_values=preset, - allow_reset=True, - allow_reset_warning_msg=warning - ).run() - - match choice.type_: - case MenuSelectionType.Skip: return preset - case MenuSelectionType.Reset: return [] - case MenuSelectionType.Selection: return choice.value # type: ignore - - -def ask_for_bootloader(preset: Bootloader) -> Bootloader: - # when the system only supports grub - if not has_uefi(): - options = [Bootloader.Grub.value] - default = Bootloader.Grub.value - else: - options = Bootloader.values() - default = Bootloader.Systemd.value - - preset_value = preset.value if preset else None - - choice = Menu( - _('Choose a bootloader'), - options, - preset_values=preset_value, - sort=False, - default_option=default - ).run() - - match choice.type_: - case MenuSelectionType.Skip: return preset - case MenuSelectionType.Selection: return Bootloader(choice.value) - - return preset - - -def select_driver(options: Dict[str, Any] = {}, current_value: Optional[str] = None) -> Optional[str]: - """ - Some what convoluted function, whose job is simple. - Select a graphics driver from a pre-defined set of popular options. - - (The template xorg is for beginner users, not advanced, and should - there for appeal to the general public first and edge cases later) - """ - - if not options: - options = AVAILABLE_GFX_DRIVERS - - drivers = sorted(list(options.keys())) - - if drivers: - title = '' - if has_amd_graphics(): - title += str(_('For the best compatibility with your AMD hardware, you may want to use either the all open-source or AMD / ATI options.')) + '\n' - if has_intel_graphics(): - title += str(_('For the best compatibility with your Intel hardware, you may want to use either the all open-source or Intel options.\n')) - if has_nvidia_graphics(): - title += str(_('For the best compatibility with your Nvidia hardware, you may want to use the Nvidia proprietary driver.\n')) - - title += str(_('\nSelect a graphics driver or leave blank to install all open-source drivers')) - - preset = current_value if current_value else None - choice = Menu(title, drivers, preset_values=preset).run() - - if choice.type_ != MenuSelectionType.Selection: - return None - - return choice.value # type: ignore - - return current_value - - -def ask_for_swap(preset: bool = True) -> bool: - if preset: - preset_val = Menu.yes() - else: - preset_val = Menu.no() - - prompt = _('Would you like to use swap on zram?') - choice = Menu(prompt, Menu.yes_no(), default_option=Menu.yes(), preset_values=preset_val).run() - - match choice.type_: - case MenuSelectionType.Skip: return preset - case MenuSelectionType.Selection: return False if choice.value == Menu.no() else True - - return preset diff --git a/archinstall/lib/user_interaction/utils.py b/archinstall/lib/user_interaction/utils.py deleted file mode 100644 index 918945c0..00000000 --- a/archinstall/lib/user_interaction/utils.py +++ /dev/null @@ -1,34 +0,0 @@ -from __future__ import annotations - -import getpass -from typing import Any, Optional, TYPE_CHECKING - -from ..models import PasswordStrength -from ..output import log - -if TYPE_CHECKING: - _: Any - -# used for signal handler -SIG_TRIGGER = None - - -def get_password(prompt: str = '') -> Optional[str]: - if not prompt: - prompt = _("Enter a password: ") - - while password := getpass.getpass(prompt): - if len(password.strip()) <= 0: - break - - strength = PasswordStrength.strength(password) - log(f'Password strength: {strength.value}', fg=strength.color()) - - passwd_verification = getpass.getpass(prompt=_('And one more time for verification: ')) - if password != passwd_verification: - log(' * Passwords did not match * ', fg='red') - continue - - return password - - return None diff --git a/archinstall/lib/utils/util.py b/archinstall/lib/utils/util.py index ded480ae..34716f4a 100644 --- a/archinstall/lib/utils/util.py +++ b/archinstall/lib/utils/util.py @@ -1,7 +1,7 @@ from pathlib import Path from typing import Any, TYPE_CHECKING, Optional -from ..output import log +from ..output import info if TYPE_CHECKING: _: Any @@ -16,7 +16,7 @@ def prompt_dir(text: str, header: Optional[str] = None) -> Path: dest_path = Path(path) if dest_path.exists() and dest_path.is_dir(): return dest_path - log(_('Not a valid directory: {}').format(dest_path), fg='red') + info(_('Not a valid directory: {}').format(dest_path)) def is_subpath(first: Path, second: Path): diff --git a/archinstall/scripts/guided.py b/archinstall/scripts/guided.py index 9906e0a9..37cc1cad 100644 --- a/archinstall/scripts/guided.py +++ b/archinstall/scripts/guided.py @@ -1,9 +1,10 @@ -import logging import os from pathlib import Path from typing import Any, TYPE_CHECKING import archinstall +from archinstall import info, debug +from archinstall import SysInfo from archinstall.lib import disk from archinstall.lib.global_menu import GlobalMenu from archinstall.default_profiles.applications.pipewire import PipewireProfile @@ -13,7 +14,7 @@ from archinstall.lib.menu import Menu from archinstall.lib.mirrors import use_mirrors from archinstall.lib.models.bootloader import Bootloader from archinstall.lib.models.network_configuration import NetworkConfigurationHandler -from archinstall.lib.output import log +from archinstall.lib.networking import check_mirror_reachable from archinstall.lib.profile.profiles_handler import profile_handler if TYPE_CHECKING: @@ -24,20 +25,6 @@ if archinstall.arguments.get('help'): print("See `man archinstall` for help.") exit(0) -if os.getuid() != 0: - print(_("Archinstall requires root privileges to run. See --help for more.")) - exit(1) - -# Log various information about hardware before starting the installation. This might assist in troubleshooting -archinstall.log(f"Hardware model detected: {archinstall.sys_vendor()} {archinstall.product_name()}; UEFI mode: {archinstall.has_uefi()}", level=logging.DEBUG) -archinstall.log(f"Processor model detected: {archinstall.cpu_model()}", level=logging.DEBUG) -archinstall.log(f"Memory statistics: {archinstall.mem_available()} available out of {archinstall.mem_total()} total installed", level=logging.DEBUG) -archinstall.log(f"Virtualization detected: {archinstall.virtualization()}; is VM: {archinstall.is_vm()}", level=logging.DEBUG) -archinstall.log(f"Graphics devices detected: {archinstall.graphics_devices().keys()}", level=logging.DEBUG) - -# For support reasons, we'll log the disk layout pre installation to match against post-installation layout -archinstall.log(f"Disk states before installing: {disk.disk_layouts()}", level=logging.DEBUG) - def ask_user_questions(): """ @@ -121,7 +108,7 @@ def perform_installation(mountpoint: Path): Only requirement is that the block devices are formatted and setup prior to entering this function. """ - log('Starting installation', level=logging.INFO) + info('Starting installation') disk_config: disk.DiskLayoutConfiguration = archinstall.arguments['disk_config'] # Retrieve list of additional repositories and set boolean values appropriately @@ -167,7 +154,7 @@ def perform_installation(mountpoint: Path): if archinstall.arguments.get('swap'): installation.setup_swap('zram') - if archinstall.arguments.get("bootloader") == Bootloader.Grub and archinstall.has_uefi(): + if archinstall.arguments.get("bootloader") == Bootloader.Grub and SysInfo.has_uefi(): installation.add_additional_packages("grub") installation.add_bootloader(archinstall.arguments["bootloader"]) @@ -190,13 +177,13 @@ def perform_installation(mountpoint: Path): installation.create_users(users) if audio := archinstall.arguments.get('audio', None): - log(f'Installing audio server: {audio}', level=logging.INFO) + info(f'Installing audio server: {audio}') if audio == 'pipewire': PipewireProfile().install(installation) elif audio == 'pulseaudio': installation.add_additional_packages("pulseaudio") else: - installation.log("No audio server will be installed.", level=logging.INFO) + info("No audio server will be installed") if profile_config := archinstall.arguments.get('profile_config', None): profile_handler.install_profile_config(installation, profile_config) @@ -231,7 +218,7 @@ def perform_installation(mountpoint: Path): installation.genfstab() - installation.log("For post-installation tips, see https://wiki.archlinux.org/index.php/Installation_guide#Post-installation", fg="yellow") + info("For post-installation tips, see https://wiki.archlinux.org/index.php/Installation_guide#Post-installation") if not archinstall.arguments.get('silent'): prompt = str(_('Would you like to chroot into the newly created installation and perform post-installation configuration?')) @@ -242,12 +229,12 @@ def perform_installation(mountpoint: Path): except: pass - archinstall.log(f"Disk states after installing: {disk.disk_layouts()}", level=logging.DEBUG) + debug(f"Disk states after installing: {disk.disk_layouts()}") -if archinstall.arguments.get('skip-mirror-check', False) is False and archinstall.check_mirror_reachable() is False: +if archinstall.arguments.get('skip-mirror-check', False) is False and check_mirror_reachable() is False: log_file = os.path.join(archinstall.storage.get('LOG_PATH', None), archinstall.storage.get('LOG_FILE', None)) - archinstall.log(f"Arch Linux mirrors are not reachable. Please check your internet connection and the log file '{log_file}'.", level=logging.INFO, fg="red") + info(f"Arch Linux mirrors are not reachable. Please check your internet connection and the log file '{log_file}'.") exit(1) if not archinstall.arguments.get('silent'): diff --git a/archinstall/scripts/minimal.py b/archinstall/scripts/minimal.py index 0cdbdcef..704759fc 100644 --- a/archinstall/scripts/minimal.py +++ b/archinstall/scripts/minimal.py @@ -2,23 +2,25 @@ from pathlib import Path from typing import TYPE_CHECKING, Any, List import archinstall -from archinstall import ConfigurationOutput, Installer, ProfileConfiguration, profile_handler +from archinstall import info +from archinstall import Installer, ConfigurationOutput from archinstall.default_profiles.minimal import MinimalProfile -from archinstall import disk -from archinstall import models -from archinstall.lib.user_interaction.disk_conf import select_devices, suggest_single_disk_layout +from archinstall.lib.interactions import suggest_single_disk_layout, select_devices +from archinstall.lib.models import Bootloader, User +from archinstall.lib.profile import ProfileConfiguration, profile_handler +from archinstall.lib import disk if TYPE_CHECKING: _: Any -archinstall.log("Minimal only supports:") -archinstall.log(" * Being installed to a single disk") +info("Minimal only supports:") +info(" * Being installed to a single disk") if archinstall.arguments.get('help', None): - archinstall.log(" - Optional disk encryption via --!encryption-password=") - archinstall.log(" - Optional filesystem type via --filesystem=") - archinstall.log(" - Optional systemd network via --network") + info(" - Optional disk encryption via --!encryption-password=") + info(" - Optional filesystem type via --filesystem=") + info(" - Optional systemd network via --network") def perform_installation(mountpoint: Path): @@ -35,7 +37,7 @@ def perform_installation(mountpoint: Path): # some other minor details as specified by this profile and user. if installation.minimal_installation(): installation.set_hostname('minimal-arch') - installation.add_bootloader(models.Bootloader.Systemd) + installation.add_bootloader(Bootloader.Systemd) # Optionally enable networking: if archinstall.arguments.get('network', None): @@ -46,14 +48,14 @@ def perform_installation(mountpoint: Path): profile_config = ProfileConfiguration(MinimalProfile()) profile_handler.install_profile_config(installation, profile_config) - user = models.User('devel', 'devel', False) + user = User('devel', 'devel', False) installation.create_users(user) # Once this is done, we output some useful information to the user # And the installation is complete. - archinstall.log("There are two new accounts in your installation after reboot:") - archinstall.log(" * root (password: airoot)") - archinstall.log(" * devel (password: devel)") + info("There are two new accounts in your installation after reboot:") + info(" * root (password: airoot)") + info(" * devel (password: devel)") def prompt_disk_layout(): diff --git a/archinstall/scripts/only_hd.py b/archinstall/scripts/only_hd.py index a903c5fe..d0ee1e39 100644 --- a/archinstall/scripts/only_hd.py +++ b/archinstall/scripts/only_hd.py @@ -1,22 +1,18 @@ -import logging import os from pathlib import Path import archinstall -from archinstall import Installer +from archinstall import info, debug +from archinstall.lib.installer import Installer from archinstall.lib.configuration import ConfigurationOutput -from archinstall import disk +from archinstall.lib import disk +from archinstall.lib.networking import check_mirror_reachable if archinstall.arguments.get('help'): print("See `man archinstall` for help.") exit(0) -if os.getuid() != 0: - print("Archinstall requires root privileges to run. See --help for more.") - exit(1) - - def ask_user_questions(): global_menu = archinstall.GlobalMenu(data_store=archinstall.arguments) @@ -59,23 +55,12 @@ def perform_installation(mountpoint: Path): target.parent.mkdir(parents=True) # For support reasons, we'll log the disk layout post installation (crash or no crash) - archinstall.log(f"Disk states after installing: {disk.disk_layouts()}", level=logging.DEBUG) - - -# Log various information about hardware before starting the installation. This might assist in troubleshooting -archinstall.log(f"Hardware model detected: {archinstall.sys_vendor()} {archinstall.product_name()}; UEFI mode: {archinstall.has_uefi()}", level=logging.DEBUG) -archinstall.log(f"Processor model detected: {archinstall.cpu_model()}", level=logging.DEBUG) -archinstall.log(f"Memory statistics: {archinstall.mem_available()} available out of {archinstall.mem_total()} total installed", level=logging.DEBUG) -archinstall.log(f"Virtualization detected: {archinstall.virtualization()}; is VM: {archinstall.is_vm()}", level=logging.DEBUG) -archinstall.log(f"Graphics devices detected: {archinstall.graphics_devices().keys()}", level=logging.DEBUG) - -# For support reasons, we'll log the disk layout pre installation to match against post-installation layout -archinstall.log(f"Disk states before installing: {disk.disk_layouts()}", level=logging.DEBUG) + debug(f"Disk states after installing: {disk.disk_layouts()}") -if archinstall.arguments.get('skip-mirror-check', False) is False and archinstall.check_mirror_reachable() is False: +if archinstall.arguments.get('skip-mirror-check', False) is False and check_mirror_reachable() is False: log_file = os.path.join(archinstall.storage.get('LOG_PATH', None), archinstall.storage.get('LOG_FILE', None)) - archinstall.log(f"Arch Linux mirrors are not reachable. Please check your internet connection and the log file '{log_file}'.", level=logging.INFO, fg="red") + info(f"Arch Linux mirrors are not reachable. Please check your internet connection and the log file '{log_file}'") exit(1) if not archinstall.arguments.get('silent'): diff --git a/archinstall/scripts/swiss.py b/archinstall/scripts/swiss.py index 3bf847b1..a49f568d 100644 --- a/archinstall/scripts/swiss.py +++ b/archinstall/scripts/swiss.py @@ -1,18 +1,18 @@ -import logging import os from enum import Enum from pathlib import Path from typing import TYPE_CHECKING, Any, Dict import archinstall +from archinstall import SysInfo, info, debug from archinstall.lib.mirrors import use_mirrors -from archinstall import models -from archinstall import disk +from archinstall.lib import models +from archinstall.lib import disk +from archinstall.lib.networking import check_mirror_reachable from archinstall.lib.profile.profiles_handler import profile_handler -from archinstall import menu +from archinstall.lib import menu from archinstall.lib.global_menu import GlobalMenu -from archinstall.lib.output import log -from archinstall import Installer +from archinstall.lib.installer import Installer from archinstall.lib.configuration import ConfigurationOutput from archinstall.default_profiles.applications.pipewire import PipewireProfile @@ -25,11 +25,6 @@ if archinstall.arguments.get('help'): exit(0) -if os.getuid() != 0: - print("Archinstall requires root privileges to run. See --help for more.") - exit(1) - - class ExecutionMode(Enum): Full = 'full' Lineal = 'lineal' @@ -76,7 +71,7 @@ class SetupMenu(GlobalMenu): def exit_callback(self): if self._data_store.get('mode', None): archinstall.arguments['mode'] = self._data_store['mode'] - log(f"Archinstall will execute under {archinstall.arguments['mode']} mode") + info(f"Archinstall will execute under {archinstall.arguments['mode']} mode") class SwissMainMenu(GlobalMenu): @@ -124,7 +119,7 @@ class SwissMainMenu(GlobalMenu): case ExecutionMode.Minimal: pass case _: - archinstall.log(f' Execution mode {self._execution_mode} not supported') + info(f' Execution mode {self._execution_mode} not supported') exit(1) if self._execution_mode != ExecutionMode.Lineal: @@ -219,7 +214,7 @@ def perform_installation(mountpoint: Path, exec_mode: ExecutionMode): if archinstall.arguments.get('swap'): installation.setup_swap('zram') - if archinstall.arguments.get("bootloader") == models.Bootloader.Grub and archinstall.has_uefi(): + if archinstall.arguments.get("bootloader") == models.Bootloader.Grub and SysInfo.has_uefi(): installation.add_additional_packages("grub") installation.add_bootloader(archinstall.arguments["bootloader"]) @@ -242,13 +237,13 @@ def perform_installation(mountpoint: Path, exec_mode: ExecutionMode): installation.create_users(users) if audio := archinstall.arguments.get('audio', None): - log(f'Installing audio server: {audio}', level=logging.INFO) + info(f'Installing audio server: {audio}') if audio == 'pipewire': PipewireProfile().install(installation) elif audio == 'pulseaudio': installation.add_additional_packages("pulseaudio") else: - installation.log("No audio server will be installed.", level=logging.INFO) + info("No audio server will be installed.") if profile_config := archinstall.arguments.get('profile_config', None): profile_handler.install_profile_config(installation, profile_config) @@ -283,9 +278,7 @@ def perform_installation(mountpoint: Path, exec_mode: ExecutionMode): installation.genfstab() - installation.log( - "For post-installation tips, see https://wiki.archlinux.org/index.php/Installation_guide#Post-installation", - fg="yellow") + info("For post-installation tips, see https://wiki.archlinux.org/index.php/Installation_guide#Post-installation") if not archinstall.arguments.get('silent'): prompt = str( @@ -297,23 +290,12 @@ def perform_installation(mountpoint: Path, exec_mode: ExecutionMode): except: pass - archinstall.log(f"Disk states after installing: {disk.disk_layouts()}", level=logging.DEBUG) - - -# Log various information about hardware before starting the installation. This might assist in troubleshooting -archinstall.log(f"Hardware model detected: {archinstall.sys_vendor()} {archinstall.product_name()}; UEFI mode: {archinstall.has_uefi()}", level=logging.DEBUG) -archinstall.log(f"Processor model detected: {archinstall.cpu_model()}", level=logging.DEBUG) -archinstall.log(f"Memory statistics: {archinstall.mem_available()} available out of {archinstall.mem_total()} total installed", level=logging.DEBUG) -archinstall.log(f"Virtualization detected: {archinstall.virtualization()}; is VM: {archinstall.is_vm()}", level=logging.DEBUG) -archinstall.log(f"Graphics devices detected: {archinstall.graphics_devices().keys()}", level=logging.DEBUG) - -# For support reasons, we'll log the disk layout pre installation to match against post-installation layout -archinstall.log(f"Disk states before installing: {disk.disk_layouts()}", level=logging.DEBUG) + debug(f"Disk states after installing: {disk.disk_layouts()}") -if not archinstall.check_mirror_reachable(): +if not check_mirror_reachable(): log_file = os.path.join(archinstall.storage.get('LOG_PATH', None), archinstall.storage.get('LOG_FILE', None)) - archinstall.log(f"Arch Linux mirrors are not reachable. Please check your internet connection and the log file '{log_file}'.", level=logging.INFO, fg="red") + info(f"Arch Linux mirrors are not reachable. Please check your internet connection and the log file '{log_file}'") exit(1) param_mode = archinstall.arguments.get('mode', ExecutionMode.Full.value).lower() @@ -321,7 +303,7 @@ param_mode = archinstall.arguments.get('mode', ExecutionMode.Full.value).lower() try: mode = ExecutionMode(param_mode) except KeyError: - log(f'Mode "{param_mode}" is not supported') + info(f'Mode "{param_mode}" is not supported') exit(1) if not archinstall.arguments.get('silent'): diff --git a/archinstall/scripts/unattended.py b/archinstall/scripts/unattended.py index 0a1c5160..5ae4ae3d 100644 --- a/archinstall/scripts/unattended.py +++ b/archinstall/scripts/unattended.py @@ -1,13 +1,14 @@ import time import archinstall -from archinstall.lib.profile.profiles_handler import profile_handler +from archinstall import info +from archinstall import profile -for profile in profile_handler.get_mac_addr_profiles(): +for p in profile.profile_handler.get_mac_addr_profiles(): # Tailored means it's a match for this machine # based on it's MAC address (or some other criteria # that fits the requirements for this machine specifically). - archinstall.log(f'Found a tailored profile for this machine called: "{profile.name}"') + info(f'Found a tailored profile for this machine called: "{p.name}"') print('Starting install in:') for i in range(10, 0, -1): @@ -15,4 +16,4 @@ for profile in profile_handler.get_mac_addr_profiles(): time.sleep(1) install_session = archinstall.storage['installation_session'] - profile.install(install_session) + p.install(install_session) diff --git a/examples/full_automated_installation.py b/examples/full_automated_installation.py index a169dd50..dcef731a 100644 --- a/examples/full_automated_installation.py +++ b/examples/full_automated_installation.py @@ -1,9 +1,10 @@ from pathlib import Path -from archinstall import Installer, ProfileConfiguration, profile_handler +from archinstall import Installer +from archinstall import profile from archinstall.default_profiles.minimal import MinimalProfile from archinstall import disk -from archinstall.lib.models import User +from archinstall import models # we're creating a new ext4 filesystem installation fs_type = disk.FilesystemType('ext4') @@ -88,8 +89,8 @@ with Installer( # Optionally, install a profile of choice. # In this case, we install a minimal profile that is empty -profile_config = ProfileConfiguration(MinimalProfile()) -profile_handler.install_profile_config(installation, profile_config) +profile_config = profile.ProfileConfiguration(MinimalProfile()) +profile.profile_handler.install_profile_config(installation, profile_config) -user = User('archinstall', 'password', True) +user = models.User('archinstall', 'password', True) installation.create_users(user) diff --git a/examples/interactive_installation.py b/examples/interactive_installation.py index a27ec0f9..72595048 100644 --- a/examples/interactive_installation.py +++ b/examples/interactive_installation.py @@ -1,13 +1,16 @@ -import logging from pathlib import Path from typing import TYPE_CHECKING, Any import archinstall -from archinstall import log, Installer, use_mirrors, profile_handler +from archinstall import Installer +from archinstall import profile +from archinstall import SysInfo +from archinstall import mirrors from archinstall.default_profiles.applications.pipewire import PipewireProfile from archinstall import disk from archinstall import menu -from archinstall.lib.models import Bootloader, NetworkConfigurationHandler +from archinstall import models +from archinstall import info, debug if TYPE_CHECKING: _: Any @@ -84,7 +87,7 @@ def perform_installation(mountpoint: Path): Only requirement is that the block devices are formatted and setup prior to entering this function. """ - log('Starting installation', level=logging.INFO) + info('Starting installation') disk_config: disk.DiskLayoutConfiguration = archinstall.arguments['disk_config'] # Retrieve list of additional repositories and set boolean values appropriately @@ -114,7 +117,7 @@ def perform_installation(mountpoint: Path): # Set mirrors used by pacstrap (outside of installation) if archinstall.arguments.get('mirror-region', None): - use_mirrors(archinstall.arguments['mirror-region']) # Set the mirrors for the live medium + mirrors.use_mirrors(archinstall.arguments['mirror-region']) # Set the mirrors for the live medium installation.minimal_installation( testing=enable_testing, @@ -130,7 +133,7 @@ def perform_installation(mountpoint: Path): if archinstall.arguments.get('swap'): installation.setup_swap('zram') - if archinstall.arguments.get("bootloader") == Bootloader.Grub and archinstall.has_uefi(): + if archinstall.arguments.get("bootloader") == models.Bootloader.Grub and SysInfo.has_uefi(): installation.add_additional_packages("grub") installation.add_bootloader(archinstall.arguments["bootloader"]) @@ -140,7 +143,7 @@ def perform_installation(mountpoint: Path): network_config = archinstall.arguments.get('nic', None) if network_config: - handler = NetworkConfigurationHandler(network_config) + handler = models.NetworkConfigurationHandler(network_config) handler.config_installer( installation, archinstall.arguments.get('profile_config', None) @@ -153,16 +156,16 @@ def perform_installation(mountpoint: Path): installation.create_users(users) if audio := archinstall.arguments.get('audio', None): - log(f'Installing audio server: {audio}', level=logging.INFO) + info(f'Installing audio server: {audio}') if audio == 'pipewire': PipewireProfile().install(installation) elif audio == 'pulseaudio': installation.add_additional_packages("pulseaudio") else: - installation.log("No audio server will be installed.", level=logging.INFO) + info("No audio server will be installed.") if profile_config := archinstall.arguments.get('profile_config', None): - profile_handler.install_profile_config(installation, profile_config) + profile.profile_handler.install_profile_config(installation, profile_config) if timezone := archinstall.arguments.get('timezone', None): installation.set_timezone(timezone) @@ -194,7 +197,7 @@ def perform_installation(mountpoint: Path): installation.genfstab() - installation.log("For post-installation tips, see https://wiki.archlinux.org/index.php/Installation_guide#Post-installation", fg="yellow") + info("For post-installation tips, see https://wiki.archlinux.org/index.php/Installation_guide#Post-installation") if not archinstall.arguments.get('silent'): prompt = str(_('Would you like to chroot into the newly created installation and perform post-installation configuration?')) @@ -202,10 +205,10 @@ def perform_installation(mountpoint: Path): if choice.value == menu.Menu.yes(): try: installation.drop_to_shell() - except: + except Exception: pass - archinstall.log(f"Disk states after installing: {disk.disk_layouts()}", level=logging.DEBUG) + debug(f"Disk states after installing: {disk.disk_layouts()}") ask_user_questions() diff --git a/examples/mac_address_installation.py b/examples/mac_address_installation.py index 0a1c5160..74a123c7 100644 --- a/examples/mac_address_installation.py +++ b/examples/mac_address_installation.py @@ -1,13 +1,13 @@ import time import archinstall -from archinstall.lib.profile.profiles_handler import profile_handler +from archinstall import profile, info -for profile in profile_handler.get_mac_addr_profiles(): +for _profile in profile.profile_handler.get_mac_addr_profiles(): # Tailored means it's a match for this machine # based on it's MAC address (or some other criteria # that fits the requirements for this machine specifically). - archinstall.log(f'Found a tailored profile for this machine called: "{profile.name}"') + info(f'Found a tailored profile for this machine called: "{_profile.name}"') print('Starting install in:') for i in range(10, 0, -1): @@ -15,4 +15,4 @@ for profile in profile_handler.get_mac_addr_profiles(): time.sleep(1) install_session = archinstall.storage['installation_session'] - profile.install(install_session) + _profile.install(install_session) diff --git a/examples/minimal_installation.py b/examples/minimal_installation.py index 8bd6fd55..e31adea4 100644 --- a/examples/minimal_installation.py +++ b/examples/minimal_installation.py @@ -2,11 +2,12 @@ from pathlib import Path from typing import TYPE_CHECKING, Any, List import archinstall -from archinstall.lib import disk -from archinstall import Installer, ProfileConfiguration, profile_handler +from archinstall import disk +from archinstall import Installer +from archinstall import profile +from archinstall import models +from archinstall import interactions from archinstall.default_profiles.minimal import MinimalProfile -from archinstall.lib.models import Bootloader, User -from archinstall.lib.user_interaction.disk_conf import select_devices, suggest_single_disk_layout if TYPE_CHECKING: _: Any @@ -26,7 +27,7 @@ def perform_installation(mountpoint: Path): # some other minor details as specified by this profile and user. if installation.minimal_installation(): installation.set_hostname('minimal-arch') - installation.add_bootloader(Bootloader.Systemd) + installation.add_bootloader(models.Bootloader.Systemd) # Optionally enable networking: if archinstall.arguments.get('network', None): @@ -34,10 +35,10 @@ def perform_installation(mountpoint: Path): installation.add_additional_packages(['nano', 'wget', 'git']) - profile_config = ProfileConfiguration(MinimalProfile()) - profile_handler.install_profile_config(installation, profile_config) + profile_config = profile.ProfileConfiguration(MinimalProfile()) + profile.profile_handler.install_profile_config(installation, profile_config) - user = User('devel', 'devel', False) + user = models.User('devel', 'devel', False) installation.create_users(user) @@ -46,8 +47,8 @@ def prompt_disk_layout(): if filesystem := archinstall.arguments.get('filesystem', None): fs_type = disk.FilesystemType(filesystem) - devices = select_devices() - modifications = suggest_single_disk_layout(devices[0], filesystem_type=fs_type) + devices = interactions.select_devices() + modifications = interactions.suggest_single_disk_layout(devices[0], filesystem_type=fs_type) archinstall.arguments['disk_config'] = disk.DiskLayoutConfiguration( config_type=disk.DiskLayoutType.Default, diff --git a/examples/only_hd_installation.py b/examples/only_hd_installation.py index 2fc74bf0..075bde20 100644 --- a/examples/only_hd_installation.py +++ b/examples/only_hd_installation.py @@ -1,9 +1,7 @@ -import logging from pathlib import Path import archinstall -from archinstall import Installer -from archinstall.lib import disk +from archinstall import Installer, disk, debug def ask_user_questions(): @@ -48,7 +46,7 @@ def perform_installation(mountpoint: Path): target.parent.mkdir(parents=True) # For support reasons, we'll log the disk layout post installation (crash or no crash) - archinstall.log(f"Disk states after installing: {disk.disk_layouts()}", level=logging.DEBUG) + debug(f"Disk states after installing: {disk.disk_layouts()}") ask_user_questions() diff --git a/pyproject.toml b/pyproject.toml index f837ebdf..8b6ae4c7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,7 @@ Source = "https://github.com/archlinux/archinstall" [project.optional-dependencies] dev = [ "mypy==1.1.1", + "pre-commit==3.3.1", ] doc = ["sphinx"] -- cgit v1.2.3-70-g09d2 From 06eadb31d4f0bca0c8cb95b6a9eb62ddd2d7cff2 Mon Sep 17 00:00:00 2001 From: Daniel Girtler Date: Mon, 5 Jun 2023 18:02:49 +1000 Subject: Move locales and cleanup menu (#1814) * Cleanup imports and unused code * Cleanup imports and unused code * Update build check * Keep deprecation exception * Simplify logging * Move locale into new sub-menu --------- Co-authored-by: Daniel Girtler --- archinstall/__init__.py | 3 +- archinstall/default_profiles/profile.py | 19 +--- archinstall/lib/global_menu.py | 95 +++++++++++----- archinstall/lib/hardware.py | 2 +- archinstall/lib/installer.py | 38 +++---- archinstall/lib/interactions/__init__.py | 3 +- archinstall/lib/interactions/general_conf.py | 12 +-- archinstall/lib/interactions/locale_conf.py | 43 -------- archinstall/lib/interactions/system_conf.py | 6 +- archinstall/lib/locale/__init__.py | 6 ++ archinstall/lib/locale/locale.py | 68 ++++++++++++ archinstall/lib/locale/locale_menu.py | 155 +++++++++++++++++++++++++++ archinstall/lib/menu/abstract_menu.py | 38 +------ archinstall/lib/utils/util.py | 23 +++- archinstall/scripts/guided.py | 17 ++- archinstall/scripts/swiss.py | 16 +-- examples/interactive_installation.py | 17 ++- 17 files changed, 374 insertions(+), 187 deletions(-) delete mode 100644 archinstall/lib/interactions/locale_conf.py create mode 100644 archinstall/lib/locale/__init__.py create mode 100644 archinstall/lib/locale/locale.py create mode 100644 archinstall/lib/locale/locale_menu.py (limited to 'archinstall/lib/menu') diff --git a/archinstall/__init__.py b/archinstall/__init__.py index e6fcb267..ce58e255 100644 --- a/archinstall/__init__.py +++ b/archinstall/__init__.py @@ -213,8 +213,7 @@ def load_config(): """ from .lib.models import NetworkConfiguration - arguments.setdefault('sys-language', 'en_US') - arguments.setdefault('sys-encoding', 'utf-8') + arguments['locale_config'] = locale.LocaleConfiguration.parse_arg(arguments) if (archinstall_lang := arguments.get('archinstall-language', None)) is not None: arguments['archinstall-language'] = TranslationHandler().get_language_by_name(archinstall_lang) diff --git a/archinstall/default_profiles/profile.py b/archinstall/default_profiles/profile.py index b1ad1f50..ce07c286 100644 --- a/archinstall/default_profiles/profile.py +++ b/archinstall/default_profiles/profile.py @@ -3,7 +3,7 @@ from __future__ import annotations from enum import Enum, auto from typing import List, Optional, Any, Dict, TYPE_CHECKING, TypeVar -from archinstall.lib.output import FormattedOutput +from archinstall.lib.utils.util import format_cols if TYPE_CHECKING: from archinstall.lib.installer import Installer @@ -185,17 +185,6 @@ class Profile: return None def packages_text(self) -> str: - text = str(_('Installed packages')) + ':\n' - - nr_packages = len(self.packages) - if nr_packages <= 5: - col = 1 - elif nr_packages <= 10: - col = 2 - elif nr_packages <= 15: - col = 3 - else: - col = 4 - - text += FormattedOutput.as_columns(self.packages, col) - return text + header = str(_('Installed packages')) + output = format_cols(self.packages, header) + return output diff --git a/archinstall/lib/global_menu.py b/archinstall/lib/global_menu.py index fc58a653..91ebc6a0 100644 --- a/archinstall/lib/global_menu.py +++ b/archinstall/lib/global_menu.py @@ -4,6 +4,7 @@ from typing import Any, List, Optional, Union, Dict, TYPE_CHECKING from . import disk from .general import secret +from .locale.locale_menu import LocaleConfiguration, LocaleMenu from .menu import Selector, AbstractMenu from .mirrors import MirrorConfiguration, MirrorMenu from .models import NetworkConfiguration @@ -24,9 +25,7 @@ from .interactions import ask_to_configure_network from .interactions import get_password, ask_for_a_timezone from .interactions import select_additional_repositories from .interactions import select_kernel -from .interactions import select_language -from .interactions import select_locale_enc -from .interactions import select_locale_lang +from .utils.util import format_cols from .interactions import ask_ntp from .interactions.disk_conf import select_disk_config @@ -36,6 +35,7 @@ if TYPE_CHECKING: class GlobalMenu(AbstractMenu): def __init__(self, data_store: Dict[str, Any]): + self._defined_text = str(_('Defined')) super().__init__(data_store=data_store, auto_cursor=True, preview_size=0.3) def setup_selection_menu_options(self): @@ -46,28 +46,19 @@ class GlobalMenu(AbstractMenu): lambda x: self._select_archinstall_language(x), display_func=lambda x: x.display_name, default=self.translation_handler.get_language_by_abbr('en')) - self._menu_options['keyboard-layout'] = \ + self._menu_options['locale_config'] = \ Selector( - _('Keyboard layout'), - lambda preset: select_language(preset), - default='us') + _('Locales'), + lambda preset: self._locale_selection(preset), + preview_func=self._prev_locale, + display_func=lambda x: self._defined_text if x else '') self._menu_options['mirror_config'] = \ Selector( _('Mirrors'), lambda preset: self._mirror_configuration(preset), - display_func=lambda x: str(_('Defined')) if x else '', + display_func=lambda x: self._defined_text if x else '', preview_func=self._prev_mirror_config ) - self._menu_options['sys-language'] = \ - Selector( - _('Locale language'), - lambda preset: select_locale_lang(preset), - default='en_US') - self._menu_options['sys-encoding'] = \ - Selector( - _('Locale encoding'), - lambda preset: select_locale_enc(preset), - default='UTF-8') self._menu_options['disk_config'] = \ Selector( _('Disk configuration'), @@ -103,32 +94,32 @@ class GlobalMenu(AbstractMenu): Selector( _('Root password'), lambda preset:self._set_root_password(), - display_func=lambda x: secret(x) if x else 'None') + display_func=lambda x: secret(x) if x else '') self._menu_options['!users'] = \ Selector( _('User account'), lambda x: self._create_user_account(x), default=[], - display_func=lambda x: f'{len(x)} {_("User(s)")}' if len(x) > 0 else None, + display_func=lambda x: f'{len(x)} {_("User(s)")}' if len(x) > 0 else '', preview_func=self._prev_users) self._menu_options['profile_config'] = \ Selector( _('Profile'), lambda preset: self._select_profile(preset), - display_func=lambda x: x.profile.name if x else 'None', + display_func=lambda x: x.profile.name if x else '', preview_func=self._prev_profile ) self._menu_options['audio'] = \ Selector( _('Audio'), lambda preset: self._select_audio(preset), - display_func=lambda x: x if x else 'None', + display_func=lambda x: x if x else '', default=None ) self._menu_options['parallel downloads'] = \ Selector( _('Parallel Downloads'), - add_number_of_parrallel_downloads, + lambda preset: add_number_of_parrallel_downloads(preset), display_func=lambda x: x if x else '0', default=0 ) @@ -141,19 +132,20 @@ class GlobalMenu(AbstractMenu): self._menu_options['packages'] = \ Selector( _('Additional packages'), - # lambda x: ask_additional_packages_to_install(storage['arguments'].get('packages', None)), - ask_additional_packages_to_install, + lambda preset: ask_additional_packages_to_install(preset), + display_func=lambda x: self._defined_text if x else '', + preview_func=self._prev_additional_pkgs, default=[]) self._menu_options['additional-repositories'] = \ Selector( _('Optional repositories'), - select_additional_repositories, + lambda preset: select_additional_repositories(preset), display_func=lambda x: ', '.join(x) if x else None, default=[]) self._menu_options['nic'] = \ Selector( _('Network configuration'), - ask_to_configure_network, + lambda preset: ask_to_configure_network(preset), display_func=lambda x: self._display_network_conf(x), preview_func=self._prev_network_config, default={}) @@ -177,12 +169,37 @@ class GlobalMenu(AbstractMenu): self._menu_options['install'] = \ Selector( self._install_text(), - exec_func=lambda n,v: True if len(self._missing_configs()) == 0 else False, + exec_func=lambda n, v: True if len(self._missing_configs()) == 0 else False, preview_func=self._prev_install_missing_config, no_store=True) self._menu_options['abort'] = Selector(_('Abort'), exec_func=lambda n,v:exit(1)) + def _missing_configs(self) -> List[str]: + def check(s): + return self._menu_options.get(s).has_selection() + + def has_superuser() -> bool: + sel = self._menu_options['!users'] + if sel.current_selection: + return any([u.sudo for u in sel.current_selection]) + return False + + mandatory_fields = dict(filter(lambda x: x[1].is_mandatory(), self._menu_options.items())) + missing = set() + + for key, selector in mandatory_fields.items(): + if key in ['!root-password', '!users']: + if not check('!root-password') and not has_superuser(): + missing.add( + str(_('Either root-password or at least 1 user with sudo privileges must be specified')) + ) + elif key == 'disk_config': + if not check('disk_config'): + missing.add(self._menu_options['disk_config'].description) + + return list(missing) + def _update_install_text(self, name: str, value: str): text = self._install_text() self._menu_options['install'].update_description(text) @@ -216,6 +233,21 @@ class GlobalMenu(AbstractMenu): disk_encryption = disk.DiskEncryptionMenu(mods, data_store, preset=preset).run() return disk_encryption + def _locale_selection(self, preset: LocaleConfiguration) -> LocaleConfiguration: + data_store: Dict[str, Any] = {} + locale_config = LocaleMenu(data_store, preset).run() + return locale_config + + def _prev_locale(self) -> Optional[str]: + selector = self._menu_options['locale_config'] + if selector.has_selection(): + config: LocaleConfiguration = selector.current_selection # type: ignore + output = '{}: {}\n'.format(str(_('Keyboard layout')), config.kb_layout) + output += '{}: {}\n'.format(str(_('Locale language')), config.sys_lang) + output += '{}: {}'.format(str(_('Locale encoding')), config.sys_enc) + return output + return None + def _prev_network_config(self) -> Optional[str]: selector = self._menu_options['nic'] if selector.has_selection(): @@ -224,6 +256,13 @@ class GlobalMenu(AbstractMenu): return FormattedOutput.as_table(ifaces) return None + def _prev_additional_pkgs(self): + selector = self._menu_options['packages'] + if selector.has_selection(): + packages: List[str] = selector.current_selection + return format_cols(packages, None) + return None + def _prev_disk_layouts(self) -> Optional[str]: selector = self._menu_options['disk_config'] disk_layout_conf: Optional[disk.DiskLayoutConfiguration] = selector.current_selection diff --git a/archinstall/lib/hardware.py b/archinstall/lib/hardware.py index 220d3d37..2b65e07c 100644 --- a/archinstall/lib/hardware.py +++ b/archinstall/lib/hardware.py @@ -3,9 +3,9 @@ from functools import cached_property from pathlib import Path from typing import Optional, Dict, List +from .exceptions import SysCallError from .general import SysCommand from .networking import list_interfaces, enrich_iface_types -from .exceptions import SysCallError from .output import debug AVAILABLE_GFX_DRIVERS = { diff --git a/archinstall/lib/installer.py b/archinstall/lib/installer.py index 30442774..6eac85fc 100644 --- a/archinstall/lib/installer.py +++ b/archinstall/lib/installer.py @@ -12,6 +12,7 @@ from . import disk from .exceptions import DiskError, ServiceException, RequirementError, HardwareIncompatibilityError, SysCallError from .general import SysCommand from .hardware import SysInfo +from .locale import LocaleConfiguration from .locale import verify_keyboard_layout, verify_x11_keyboard_layout from .luks import Luks2 from .mirrors import use_mirrors, MirrorConfiguration, add_custom_mirrors @@ -457,37 +458,36 @@ class Installer: with open(f'{self.target}/etc/hostname', 'w') as fh: fh.write(hostname + '\n') - def set_locale(self, locale :str, encoding :str = 'UTF-8', *args :str, **kwargs :str) -> bool: - if not len(locale): - return True - + def set_locale(self, locale_config: LocaleConfiguration): modifier = '' + lang = locale_config.sys_lang + encoding = locale_config.sys_enc # This is a temporary patch to fix #1200 - if '.' in locale: - locale, potential_encoding = locale.split('.', 1) + if '.' in locale_config.sys_lang: + lang, potential_encoding = locale_config.sys_lang.split('.', 1) # Override encoding if encoding is set to the default parameter # and the "found" encoding differs. - if encoding == 'UTF-8' and encoding != potential_encoding: + if locale_config.sys_enc == 'UTF-8' and locale_config.sys_enc != potential_encoding: encoding = potential_encoding # Make sure we extract the modifier, that way we can put it in if needed. - if '@' in locale: - locale, modifier = locale.split('@', 1) + if '@' in locale_config.sys_lang: + lang, modifier = locale_config.sys_lang.split('@', 1) modifier = f"@{modifier}" # - End patch with open(f'{self.target}/etc/locale.gen', 'a') as fh: - fh.write(f'{locale}.{encoding}{modifier} {encoding}\n') + fh.write(f'{lang}.{encoding}{modifier} {encoding}\n') + with open(f'{self.target}/etc/locale.conf', 'w') as fh: - fh.write(f'LANG={locale}.{encoding}{modifier}\n') + fh.write(f'LANG={lang}.{encoding}{modifier}\n') try: SysCommand(f'/usr/bin/arch-chroot {self.target} locale-gen') - return True - except SysCallError: - return False + except SysCallError as e: + error(f'Failed to run locale-gen on target: {e}') def set_timezone(self, zone :str, *args :str, **kwargs :str) -> bool: if not zone: @@ -620,7 +620,7 @@ class Installer: return True - def mkinitcpio(self, *flags :str) -> bool: + def mkinitcpio(self, flags: List[str], locale_config: LocaleConfiguration) -> bool: for plugin in plugins.values(): if hasattr(plugin, 'on_mkinitcpio'): # Allow plugins to override the usage of mkinitcpio altogether. @@ -630,7 +630,7 @@ class Installer: # mkinitcpio will error out if there's no vconsole. if (vconsole := Path(f"{self.target}/etc/vconsole.conf")).exists() is False: with vconsole.open('w') as fh: - fh.write(f"KEYMAP={storage['arguments']['keyboard-layout']}\n") + fh.write(f"KEYMAP={locale_config.kb_layout}\n") with open(f'{self.target}/etc/mkinitcpio.conf', 'w') as mkinit: mkinit.write(f"MODULES=({' '.join(self.modules)})\n") @@ -658,7 +658,7 @@ class Installer: testing: bool = False, multilib: bool = False, hostname: str = 'archinstall', - locales: List[str] = ['en_US.UTF-8 UTF-8'] + locale_config: LocaleConfiguration = LocaleConfiguration.default() ): for mod in self._disk_config.device_modifications: for part in mod.partitions: @@ -734,12 +734,12 @@ class Installer: # sys_command(f'/usr/bin/arch-chroot {self.target} ln -s /usr/share/zoneinfo/{localtime} /etc/localtime') # sys_command('/usr/bin/arch-chroot /mnt hwclock --hctosys --localtime') self.set_hostname(hostname) - self.set_locale(*locales[0].split()) + self.set_locale(locale_config) # TODO: Use python functions for this SysCommand(f'/usr/bin/arch-chroot {self.target} chmod 700 /root') - self.mkinitcpio('-P') + self.mkinitcpio(['-P'], locale_config) self.helper_flags['base'] = True diff --git a/archinstall/lib/interactions/__init__.py b/archinstall/lib/interactions/__init__.py index 158750cc..466cfa0b 100644 --- a/archinstall/lib/interactions/__init__.py +++ b/archinstall/lib/interactions/__init__.py @@ -1,4 +1,3 @@ -from .locale_conf import select_locale_lang, select_locale_enc from .manage_users_conf import UserList, ask_for_additional_users from .network_conf import ManualNetworkConfig, ask_to_configure_network from .utils import get_password @@ -10,7 +9,7 @@ from .disk_conf import ( ) from .general_conf import ( - ask_ntp, ask_hostname, ask_for_a_timezone, ask_for_audio_selection, select_language, + ask_ntp, ask_hostname, ask_for_a_timezone, ask_for_audio_selection, select_archinstall_language, ask_additional_packages_to_install, add_number_of_parrallel_downloads, select_additional_repositories ) diff --git a/archinstall/lib/interactions/general_conf.py b/archinstall/lib/interactions/general_conf.py index 0338c61e..3b78847b 100644 --- a/archinstall/lib/interactions/general_conf.py +++ b/archinstall/lib/interactions/general_conf.py @@ -3,7 +3,7 @@ from __future__ import annotations import pathlib from typing import List, Any, Optional, TYPE_CHECKING -from ..locale import list_keyboard_languages, list_timezones +from ..locale import list_timezones, list_keyboard_languages from ..menu import MenuSelectionType, Menu, TextInput from ..output import warn from ..packages.packages import validate_package_list @@ -119,18 +119,18 @@ def select_archinstall_language(languages: List[Language], preset: Language) -> raise ValueError('Language selection not handled') -def ask_additional_packages_to_install(pre_set_packages: List[str] = []) -> List[str]: +def ask_additional_packages_to_install(preset: List[str] = []) -> List[str]: # Additional packages (with some light weight error handling for invalid package names) print(_('Only packages such as base, base-devel, linux, linux-firmware, efibootmgr and optional profile packages are installed.')) print(_('If you desire a web browser, such as firefox or chromium, you may specify it in the following prompt.')) - def read_packages(already_defined: list = []) -> list: - display = ' '.join(already_defined) + def read_packages(p: List = []) -> list: + display = ' '.join(p) input_packages = TextInput(_('Write additional packages to install (space separated, leave blank to skip): '), display).run().strip() return input_packages.split() if input_packages else [] - pre_set_packages = pre_set_packages if pre_set_packages else [] - packages = read_packages(pre_set_packages) + preset = preset if preset else [] + packages = read_packages(preset) if not storage['arguments']['offline'] and not storage['arguments']['no_pkg_lookups']: while True: diff --git a/archinstall/lib/interactions/locale_conf.py b/archinstall/lib/interactions/locale_conf.py deleted file mode 100644 index de115202..00000000 --- a/archinstall/lib/interactions/locale_conf.py +++ /dev/null @@ -1,43 +0,0 @@ -from typing import Any, TYPE_CHECKING, Optional - -from ..locale import list_locales -from ..menu import Menu, MenuSelectionType - -if TYPE_CHECKING: - _: Any - - -def select_locale_lang(preset: Optional[str] = None) -> Optional[str]: - locales = list_locales() - locale_lang = set([locale.split()[0] for locale in locales]) - - choice = Menu( - _('Choose which locale language to use'), - list(locale_lang), - sort=True, - preset_values=preset - ).run() - - match choice.type_: - case MenuSelectionType.Selection: return choice.single_value - case MenuSelectionType.Skip: return preset - - return None - - -def select_locale_enc(preset: Optional[str] = None) -> Optional[str]: - locales = list_locales() - locale_enc = set([locale.split()[1] for locale in locales]) - - choice = Menu( - _('Choose which locale encoding to use'), - list(locale_enc), - sort=True, - preset_values=preset - ).run() - - match choice.type_: - case MenuSelectionType.Selection: return choice.single_value - case MenuSelectionType.Skip: return preset - - return None diff --git a/archinstall/lib/interactions/system_conf.py b/archinstall/lib/interactions/system_conf.py index bbcb5b23..ea7e5989 100644 --- a/archinstall/lib/interactions/system_conf.py +++ b/archinstall/lib/interactions/system_conf.py @@ -29,14 +29,14 @@ def select_kernel(preset: List[str] = []) -> List[str]: sort=True, multi=True, preset_values=preset, - allow_reset=True, allow_reset_warning_msg=warning ).run() match choice.type_: case MenuSelectionType.Skip: return preset - case MenuSelectionType.Reset: return [] - case MenuSelectionType.Selection: return choice.value # type: ignore + case MenuSelectionType.Selection: return choice.single_value + + return [] def ask_for_bootloader(preset: Bootloader) -> Bootloader: diff --git a/archinstall/lib/locale/__init__.py b/archinstall/lib/locale/__init__.py new file mode 100644 index 00000000..6c32d6f3 --- /dev/null +++ b/archinstall/lib/locale/__init__.py @@ -0,0 +1,6 @@ +from .locale_menu import LocaleConfiguration +from .locale import ( + list_keyboard_languages, list_locales, list_x11_keyboard_languages, + verify_keyboard_layout, verify_x11_keyboard_layout, set_kb_layout, + list_timezones +) diff --git a/archinstall/lib/locale/locale.py b/archinstall/lib/locale/locale.py new file mode 100644 index 00000000..c3294e83 --- /dev/null +++ b/archinstall/lib/locale/locale.py @@ -0,0 +1,68 @@ +from typing import Iterator, List + +from ..exceptions import ServiceException, SysCallError +from ..general import SysCommand +from ..output import error + + +def list_keyboard_languages() -> Iterator[str]: + for line in SysCommand("localectl --no-pager list-keymaps", environment_vars={'SYSTEMD_COLORS': '0'}): + yield line.decode('UTF-8').strip() + + +def list_locales() -> List[str]: + with open('/etc/locale.gen', 'r') as fp: + locales = [] + # before the list of locales begins there's an empty line with a '#' in front + # so we'll collect the localels from bottom up and halt when we're donw + entries = fp.readlines() + entries.reverse() + + for entry in entries: + text = entry.replace('#', '').strip() + if text == '': + break + locales.append(text) + + locales.reverse() + return locales + + +def list_x11_keyboard_languages() -> Iterator[str]: + for line in SysCommand("localectl --no-pager list-x11-keymap-layouts", environment_vars={'SYSTEMD_COLORS': '0'}): + yield line.decode('UTF-8').strip() + + +def verify_keyboard_layout(layout :str) -> bool: + for language in list_keyboard_languages(): + if layout.lower() == language.lower(): + return True + return False + + +def verify_x11_keyboard_layout(layout :str) -> bool: + for language in list_x11_keyboard_languages(): + if layout.lower() == language.lower(): + return True + return False + + +def set_kb_layout(locale :str) -> bool: + if len(locale.strip()): + if not verify_keyboard_layout(locale): + error(f"Invalid keyboard locale specified: {locale}") + return False + + try: + SysCommand(f'localectl set-keymap {locale}') + except SysCallError as err: + raise ServiceException(f"Unable to set locale '{locale}' for console: {err}") + + return True + + return False + + +def list_timezones() -> Iterator[str]: + for line in SysCommand("timedatectl --no-pager list-timezones", environment_vars={'SYSTEMD_COLORS': '0'}): + yield line.decode('UTF-8').strip() diff --git a/archinstall/lib/locale/locale_menu.py b/archinstall/lib/locale/locale_menu.py new file mode 100644 index 00000000..29dd775d --- /dev/null +++ b/archinstall/lib/locale/locale_menu.py @@ -0,0 +1,155 @@ +from dataclasses import dataclass +from typing import Dict, Any, TYPE_CHECKING, Optional + +from .locale import set_kb_layout, list_keyboard_languages, list_locales +from ..menu import Selector, AbstractSubMenu, MenuSelectionType, Menu + +if TYPE_CHECKING: + _: Any + + +@dataclass +class LocaleConfiguration: + kb_layout: str + sys_lang: str + sys_enc: str + + @staticmethod + def default() -> 'LocaleConfiguration': + return LocaleConfiguration('us', 'en_US', 'UTF-8') + + def json(self) -> Dict[str, str]: + return { + 'kb_layout': self.kb_layout, + 'sys_lang': self.sys_lang, + 'sys_enc': self.sys_enc + } + + @classmethod + def _load_config(cls, config: 'LocaleConfiguration', args: Dict[str, Any]) -> 'LocaleConfiguration': + if 'sys_lang' in args: + config.sys_lang = args['sys_lang'] + if 'sys_enc' in args: + config.sys_enc = args['sys_enc'] + if 'kb_layout' in args: + config.kb_layout = args['kb_layout'] + + return config + + @classmethod + def parse_arg(cls, args: Dict[str, Any]) -> 'LocaleConfiguration': + default = cls.default() + + if 'locale_config' in args: + default = cls._load_config(default, args['locale_config']) + else: + default = cls._load_config(default, args) + + return default + + +class LocaleMenu(AbstractSubMenu): + def __init__( + self, + data_store: Dict[str, Any], + locele_conf: LocaleConfiguration + ): + self._preset = locele_conf + super().__init__(data_store=data_store) + + def setup_selection_menu_options(self): + self._menu_options['keyboard-layout'] = \ + Selector( + _('Keyboard layout'), + lambda preset: self._select_kb_layout(preset), + default='us', + enabled=True) + self._menu_options['sys-language'] = \ + Selector( + _('Locale language'), + lambda preset: select_locale_lang(preset), + default='en_US', + enabled=True) + self._menu_options['sys-encoding'] = \ + Selector( + _('Locale encoding'), + lambda preset: select_locale_enc(preset), + default='UTF-8', + enabled=True) + + def run(self, allow_reset: bool = True) -> LocaleConfiguration: + super().run(allow_reset=allow_reset) + + return LocaleConfiguration( + self._data_store['keyboard-layout'], + self._data_store['sys-language'], + self._data_store['sys-encoding'] + ) + + def _select_kb_layout(self, preset: Optional[str]) -> Optional[str]: + kb_lang = select_kb_layout(preset) + if kb_lang: + set_kb_layout(kb_lang) + return kb_lang + + +def select_locale_lang(preset: Optional[str] = None) -> Optional[str]: + locales = list_locales() + locale_lang = set([locale.split()[0] for locale in locales]) + + choice = Menu( + _('Choose which locale language to use'), + list(locale_lang), + sort=True, + preset_values=preset + ).run() + + match choice.type_: + case MenuSelectionType.Selection: return choice.single_value + case MenuSelectionType.Skip: return preset + + return None + + +def select_locale_enc(preset: Optional[str] = None) -> Optional[str]: + locales = list_locales() + locale_enc = set([locale.split()[1] for locale in locales]) + + choice = Menu( + _('Choose which locale encoding to use'), + list(locale_enc), + sort=True, + preset_values=preset + ).run() + + match choice.type_: + case MenuSelectionType.Selection: return choice.single_value + case MenuSelectionType.Skip: return preset + + return None + + +def select_kb_layout(preset: Optional[str] = None) -> Optional[str]: + """ + Asks the user to select a language + Usually this is combined with :ref:`archinstall.list_keyboard_languages`. + + :return: The language/dictionary key of the selected language + :rtype: str + """ + kb_lang = list_keyboard_languages() + # sort alphabetically and then by length + sorted_kb_lang = sorted(sorted(list(kb_lang)), key=len) + + choice = Menu( + _('Select keyboard layout'), + sorted_kb_lang, + preset_values=preset, + sort=False + ).run() + + match choice.type_: + case MenuSelectionType.Skip: return preset + case MenuSelectionType.Selection: return choice.single_value + + return None diff --git a/archinstall/lib/menu/abstract_menu.py b/archinstall/lib/menu/abstract_menu.py index 2bd56374..eee99747 100644 --- a/archinstall/lib/menu/abstract_menu.py +++ b/archinstall/lib/menu/abstract_menu.py @@ -3,7 +3,6 @@ from __future__ import annotations from typing import Callable, Any, List, Iterator, Tuple, Optional, Dict, TYPE_CHECKING from .menu import Menu, MenuSelectionType -from ..locale import set_keyboard_language from ..output import error from ..translationhandler import TranslationHandler, Language @@ -130,7 +129,7 @@ class Selector: if current: padding += 5 description = str(self._description).ljust(padding, ' ') - current = str(_('set: {}').format(current)) + current = current else: description = self._description current = '' @@ -243,31 +242,6 @@ class AbstractMenu: elif selector is not None and selector.has_selection(): self._data_store[selector_name] = selector.current_selection - def _missing_configs(self) -> List[str]: - def check(s): - return self._menu_options.get(s).has_selection() - - def has_superuser() -> bool: - sel = self._menu_options['!users'] - if sel.current_selection: - return any([u.sudo for u in sel.current_selection]) - return False - - mandatory_fields = dict(filter(lambda x: x[1].is_mandatory(), self._menu_options.items())) - missing = set() - - for key, selector in mandatory_fields.items(): - if key in ['!root-password', '!users']: - if not check('!root-password') and not has_superuser(): - missing.add( - str(_('Either root-password or at least 1 user with sudo privileges must be specified')) - ) - elif key == 'disk_config': - if not check('disk_config'): - missing.add(self._menu_options['disk_config'].description) - - return list(missing) - def setup_selection_menu_options(self): """ Define the menu options. Menu options can be defined here in a subclass or done per program calling self.set_option() @@ -328,9 +302,6 @@ class AbstractMenu: cursor_pos = None while True: - # Before continuing, set the preferred keyboard layout/language in the current terminal. - # This will just help the user with the next following questions. - self._set_kb_language() enabled_menus = self._menus_to_enable() padding = self._get_menu_text_padding(list(enabled_menus.values())) @@ -425,13 +396,6 @@ class AbstractMenu: return True - def _set_kb_language(self): - """ general for ArchInstall""" - # Before continuing, set the preferred keyboard layout/language in the current terminal. - # This will just help the user with the next following questions. - if self._data_store.get('keyboard-layout', None) and len(self._data_store['keyboard-layout']): - set_keyboard_language(self._data_store['keyboard-layout']) - def _verify_selection_enabled(self, selection_name: str) -> bool: """ general """ if selection := self._menu_options.get(selection_name, None): diff --git a/archinstall/lib/utils/util.py b/archinstall/lib/utils/util.py index 34716f4a..8df75ab1 100644 --- a/archinstall/lib/utils/util.py +++ b/archinstall/lib/utils/util.py @@ -1,6 +1,7 @@ from pathlib import Path -from typing import Any, TYPE_CHECKING, Optional +from typing import Any, TYPE_CHECKING, Optional, List +from ..output import FormattedOutput from ..output import info if TYPE_CHECKING: @@ -28,3 +29,23 @@ def is_subpath(first: Path, second: Path): return True except ValueError: return False + + +def format_cols(items: List[str], header: Optional[str]) -> str: + if header: + text = f'{header}:\n' + else: + text = '' + + nr_items = len(items) + if nr_items <= 5: + col = 1 + elif nr_items <= 10: + col = 2 + elif nr_items <= 15: + col = 3 + else: + col = 4 + + text += FormattedOutput.as_columns(items, col) + return text diff --git a/archinstall/scripts/guided.py b/archinstall/scripts/guided.py index 1aecc1cd..7f9b9fd6 100644 --- a/archinstall/scripts/guided.py +++ b/archinstall/scripts/guided.py @@ -5,6 +5,7 @@ from typing import Any, TYPE_CHECKING import archinstall from archinstall import info, debug from archinstall import SysInfo +from archinstall.lib import locale from archinstall.lib import disk from archinstall.lib.global_menu import GlobalMenu from archinstall.default_profiles.applications.pipewire import PipewireProfile @@ -42,14 +43,10 @@ def ask_user_questions(): global_menu.enable('archinstall-language') - global_menu.enable('keyboard-layout') - # Set which region to download packages from during the installation global_menu.enable('mirror_config') - global_menu.enable('sys-language') - - global_menu.enable('sys-encoding') + global_menu.enable('locale_config') global_menu.enable('disk_config', mandatory=True) @@ -76,7 +73,7 @@ def ask_user_questions(): global_menu.enable('audio') # Ask for preferred kernel: - global_menu.enable('kernels') + global_menu.enable('kernels', mandatory=True) global_menu.enable('packages') @@ -114,9 +111,7 @@ def perform_installation(mountpoint: Path): # Retrieve list of additional repositories and set boolean values appropriately enable_testing = 'testing' in archinstall.arguments.get('additional-repositories', []) enable_multilib = 'multilib' in archinstall.arguments.get('additional-repositories', []) - - locale = f"{archinstall.arguments.get('sys-language', 'en_US')} {archinstall.arguments.get('sys-encoding', 'UTF-8').upper()}" - + locale_config: locale.LocaleConfiguration = archinstall.arguments['locale_config'] disk_encryption: disk.DiskEncryption = archinstall.arguments.get('disk_encryption', None) with Installer( @@ -147,7 +142,7 @@ def perform_installation(mountpoint: Path): testing=enable_testing, multilib=enable_multilib, hostname=archinstall.arguments.get('hostname', 'archlinux'), - locales=[locale] + locale_config=locale_config ) if mirror_config := archinstall.arguments.get('mirror_config', None): @@ -210,7 +205,7 @@ def perform_installation(mountpoint: Path): # This step must be after profile installs to allow profiles_bck to install language pre-requisits. # After which, this step will set the language both for console and x11 if x11 was installed for instance. - installation.set_keyboard_language(archinstall.arguments['keyboard-layout']) + installation.set_keyboard_language(locale_config.kb_layout) if profile_config := archinstall.arguments.get('profile_config', None): profile_config.profile.post_install(installation) diff --git a/archinstall/scripts/swiss.py b/archinstall/scripts/swiss.py index 1998f073..375458a1 100644 --- a/archinstall/scripts/swiss.py +++ b/archinstall/scripts/swiss.py @@ -8,6 +8,7 @@ from archinstall import SysInfo, info, debug from archinstall.lib import mirrors from archinstall.lib import models from archinstall.lib import disk +from archinstall.lib import locale from archinstall.lib.networking import check_mirror_reachable from archinstall.lib.profile.profiles_handler import profile_handler from archinstall.lib import menu @@ -92,14 +93,14 @@ class SwissMainMenu(GlobalMenu): match self._execution_mode: case ExecutionMode.Full | ExecutionMode.Lineal: options_list = [ - 'keyboard-layout', 'mirror_config', 'disk_config', + 'mirror_config', 'disk_config', 'disk_encryption', 'swap', 'bootloader', 'hostname', '!root-password', '!users', 'profile_config', 'audio', 'kernels', 'packages', 'additional-repositories', 'nic', 'timezone', 'ntp' ] if archinstall.arguments.get('advanced', False): - options_list.extend(['sys-language', 'sys-encoding']) + options_list.extend(['locale_config']) mandatory_list = ['disk_config', 'bootloader', 'hostname'] case ExecutionMode.Only_HD: @@ -107,7 +108,7 @@ class SwissMainMenu(GlobalMenu): mandatory_list = ['disk_config'] case ExecutionMode.Only_OS: options_list = [ - 'keyboard-layout', 'mirror_config','bootloader', 'hostname', + 'mirror_config','bootloader', 'hostname', '!root-password', '!users', 'profile_config', 'audio', 'kernels', 'packages', 'additional-repositories', 'nic', 'timezone', 'ntp' ] @@ -115,7 +116,7 @@ class SwissMainMenu(GlobalMenu): mandatory_list = ['hostname'] if archinstall.arguments.get('advanced', False): - options_list += ['sys-language','sys-encoding'] + options_list += ['locale_config'] case ExecutionMode.Minimal: pass case _: @@ -176,8 +177,7 @@ def perform_installation(mountpoint: Path, exec_mode: ExecutionMode): enable_testing = 'testing' in archinstall.arguments.get('additional-repositories', []) enable_multilib = 'multilib' in archinstall.arguments.get('additional-repositories', []) - - locale = f"{archinstall.arguments.get('sys-language', 'en_US')} {archinstall.arguments.get('sys-encoding', 'UTF-8').upper()}" + locale_config: locale.LocaleConfiguration = archinstall.arguments['locale_config'] with Installer( mountpoint, @@ -206,7 +206,7 @@ def perform_installation(mountpoint: Path, exec_mode: ExecutionMode): testing=enable_testing, multilib=enable_multilib, hostname=archinstall.arguments.get('hostname', 'archlinux'), - locales=[locale] + locale_config=locale_config ) if mirror_config := archinstall.arguments.get('mirror_config', None): @@ -263,7 +263,7 @@ def perform_installation(mountpoint: Path, exec_mode: ExecutionMode): # This step must be after profile installs to allow profiles_bck to install language pre-requisits. # After which, this step will set the language both for console and x11 if x11 was installed for instance. - installation.set_keyboard_language(archinstall.arguments['keyboard-layout']) + installation.set_keyboard_language(locale_config.kb_layout) if profile_config := archinstall.arguments.get('profile_config', None): profile_config.profile.post_install(installation) diff --git a/examples/interactive_installation.py b/examples/interactive_installation.py index 7c4ffed7..ce1a80ec 100644 --- a/examples/interactive_installation.py +++ b/examples/interactive_installation.py @@ -10,6 +10,7 @@ from archinstall.default_profiles.applications.pipewire import PipewireProfile from archinstall import disk from archinstall import menu from archinstall import models +from archinstall import locale from archinstall import info, debug if TYPE_CHECKING: @@ -21,14 +22,10 @@ def ask_user_questions(): global_menu.enable('archinstall-language') - global_menu.enable('keyboard-layout') - # Set which region to download packages from during the installation global_menu.enable('mirror_config') - global_menu.enable('sys-language') - - global_menu.enable('sys-encoding') + global_menu.enable('locale_config') global_menu.enable('disk_config', mandatory=True) @@ -55,7 +52,7 @@ def ask_user_questions(): global_menu.enable('audio') # Ask for preferred kernel: - global_menu.enable('kernels') + global_menu.enable('kernels', mandatory=True) global_menu.enable('packages') @@ -93,9 +90,7 @@ def perform_installation(mountpoint: Path): # Retrieve list of additional repositories and set boolean values appropriately enable_testing = 'testing' in archinstall.arguments.get('additional-repositories', []) enable_multilib = 'multilib' in archinstall.arguments.get('additional-repositories', []) - - locale = f"{archinstall.arguments.get('sys-language', 'en_US')} {archinstall.arguments.get('sys-encoding', 'UTF-8').upper()}" - + locale_config: locale.LocaleConfiguration = archinstall.arguments['locale_config'] disk_encryption: disk.DiskEncryption = archinstall.arguments.get('disk_encryption', None) with Installer( @@ -126,7 +121,7 @@ def perform_installation(mountpoint: Path): testing=enable_testing, multilib=enable_multilib, hostname=archinstall.arguments.get('hostname', 'archlinux'), - locales=[locale] + locale_config=locale_config ) if mirror_config := archinstall.arguments.get('mirror_config', None): @@ -189,7 +184,7 @@ def perform_installation(mountpoint: Path): # This step must be after profile installs to allow profiles_bck to install language pre-requisits. # After which, this step will set the language both for console and x11 if x11 was installed for instance. - installation.set_keyboard_language(archinstall.arguments['keyboard-layout']) + installation.set_keyboard_language(locale_config.kb_layout) if profile_config := archinstall.arguments.get('profile_config', None): profile_config.profile.post_install(installation) -- cgit v1.2.3-70-g09d2 From a548d7df70102252a557214edc74b77f4859f031 Mon Sep 17 00:00:00 2001 From: Daniel Girtler Date: Tue, 25 Jul 2023 19:19:14 +1000 Subject: Fix 1916 (#1920) * Do not stdout when menu is active * Handle missing libfido2 gracefully * Update --------- Co-authored-by: Daniel Girtler --- archinstall/lib/disk/encryption_menu.py | 25 ++++++++++++++++++++++--- archinstall/lib/disk/fido.py | 9 +++++---- archinstall/lib/menu/menu.py | 9 +++++++++ archinstall/lib/output.py | 14 ++++++++------ 4 files changed, 44 insertions(+), 13 deletions(-) (limited to 'archinstall/lib/menu') diff --git a/archinstall/lib/disk/encryption_menu.py b/archinstall/lib/disk/encryption_menu.py index 89eade2b..234e3b03 100644 --- a/archinstall/lib/disk/encryption_menu.py +++ b/archinstall/lib/disk/encryption_menu.py @@ -71,6 +71,7 @@ class DiskEncryptionMenu(AbstractSubMenu): description=_('Use HSM to unlock encrypted drive'), func=lambda preset: select_hsm(preset), display_func=lambda x: self._display_hsm(x), + preview_func=self._prev_hsm, dependencies=['encryption_password'], default=self._preset.hsm_device, enabled=True @@ -93,8 +94,6 @@ class DiskEncryptionMenu(AbstractSubMenu): if device: return device.manufacturer - if not Fido2.get_fido2_devices(): - return str(_('No HSM devices available')) return None def _prev_disk_layouts(self) -> Optional[str]: @@ -106,6 +105,22 @@ class DiskEncryptionMenu(AbstractSubMenu): return None + def _prev_hsm(self) -> Optional[str]: + try: + Fido2.get_fido2_devices() + except ValueError: + return str(_('Unable to determine fido2 devices. Is libfido2 installed?')) + + fido_device: Optional[Fido2Device] = self._menu_options['HSM'].current_selection + + if fido_device: + output = '{}: {}'.format(str(_('Path')), fido_device.path) + output += '{}: {}'.format(str(_('Manufacturer')), fido_device.manufacturer) + output += '{}: {}'.format(str(_('Product')), fido_device.product) + return output + + return None + def select_encryption_type(preset: EncryptionType) -> Optional[EncryptionType]: title = str(_('Select disk encryption option')) @@ -130,7 +145,11 @@ def select_encrypted_password() -> Optional[str]: def select_hsm(preset: Optional[Fido2Device] = None) -> Optional[Fido2Device]: title = _('Select a FIDO2 device to use for HSM') - fido_devices = Fido2.get_fido2_devices() + + try: + fido_devices = Fido2.get_fido2_devices() + except ValueError: + return None if fido_devices: choice = TableMenu(title, data=fido_devices).run() diff --git a/archinstall/lib/disk/fido.py b/archinstall/lib/disk/fido.py index d8c8fd3c..96a74991 100644 --- a/archinstall/lib/disk/fido.py +++ b/archinstall/lib/disk/fido.py @@ -7,6 +7,7 @@ from typing import List, Optional from .device_model import PartitionModification, Fido2Device from ..general import SysCommand, SysCommandWorker, clear_vt100_escape_codes from ..output import error, info +from ..exceptions import SysCallError class Fido2: @@ -36,13 +37,13 @@ class Fido2: # to prevent continous reloading which will slow # down moving the cursor in the menu if not cls._loaded or reload: - ret: Optional[str] = None try: - ret = SysCommand("systemd-cryptenroll --fido2-device=list").decode('UTF-8') - except: + ret: Optional[str] = SysCommand("systemd-cryptenroll --fido2-device=list").decode('UTF-8') + except SysCallError: error('fido2 support is most likely not installed') + raise ValueError('HSM devices can not be detected, is libfido2 installed?') + if not ret: - error('Unable to retrieve fido2 devices') return [] fido_devices: str = clear_vt100_escape_codes(ret) # type: ignore diff --git a/archinstall/lib/menu/menu.py b/archinstall/lib/menu/menu.py index 768dfe55..358ba5e4 100644 --- a/archinstall/lib/menu/menu.py +++ b/archinstall/lib/menu/menu.py @@ -34,6 +34,11 @@ class MenuSelection: class Menu(TerminalMenu): + _menu_is_active: bool = False + + @staticmethod + def is_menu_active() -> bool: + return Menu._menu_is_active @classmethod def back(cls) -> str: @@ -260,6 +265,8 @@ class Menu(TerminalMenu): return MenuSelection(type_=MenuSelectionType.Skip) def run(self) -> MenuSelection: + Menu._menu_is_active = True + selection = self._show() if selection.type_ == MenuSelectionType.Reset: @@ -277,6 +284,8 @@ class Menu(TerminalMenu): selection.type_ = MenuSelectionType.Skip selection.value = None + Menu._menu_is_active = False + return selection def set_cursor_pos(self,pos :int): diff --git a/archinstall/lib/output.py b/archinstall/lib/output.py index d1c95ec5..9f2a2ae3 100644 --- a/archinstall/lib/output.py +++ b/archinstall/lib/output.py @@ -318,9 +318,11 @@ def log( Journald.log(text, level=level) - # Finally, print the log unless we skipped it based on level. - # We use sys.stdout.write()+flush() instead of print() to try and - # fix issue #94 - if level != logging.DEBUG or storage.get('arguments', {}).get('verbose', False): - sys.stdout.write(f"{text}\n") - sys.stdout.flush() + from .menu import Menu + if not Menu.is_menu_active(): + # Finally, print the log unless we skipped it based on level. + # We use sys.stdout.write()+flush() instead of print() to try and + # fix issue #94 + if level != logging.DEBUG or storage.get('arguments', {}).get('verbose', False): + sys.stdout.write(f"{text}\n") + sys.stdout.flush() -- cgit v1.2.3-70-g09d2 From d878496a2fb9e3c7f48261f866cb285dfa987298 Mon Sep 17 00:00:00 2001 From: Wu Xiaotian Date: Mon, 31 Jul 2023 15:39:06 +0800 Subject: Improve the Chinese, Japanese and Korean text menu layout (#1945) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before this patch, menus in Korean language would not be aligned: ``` Archinstall 언어 Korean (71%) > Mirrors Locales Defined Disk configuration 부트로더 Systemd-boot 스왑 True ``` After apply this patch, menus in Korean language are aligned: ``` Archinstall 언어 Korean (71%) > Mirrors Locales Defined Disk configuration 부트로더 Systemd-boot 스왑 True ``` --- archinstall/lib/menu/abstract_menu.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) (limited to 'archinstall/lib/menu') diff --git a/archinstall/lib/menu/abstract_menu.py b/archinstall/lib/menu/abstract_menu.py index eee99747..f12d2b73 100644 --- a/archinstall/lib/menu/abstract_menu.py +++ b/archinstall/lib/menu/abstract_menu.py @@ -1,4 +1,5 @@ from __future__ import annotations +import unicodedata from typing import Callable, Any, List, Iterator, Tuple, Optional, Dict, TYPE_CHECKING @@ -9,6 +10,22 @@ from ..translationhandler import TranslationHandler, Language if TYPE_CHECKING: _: Any +def count_cjk_chars(string): + "Count the total number of CJK characters contained in a string" + return sum(unicodedata.east_asian_width(c) in 'FW' for c in string) + +def cjkljust(string, width, fillbyte=' '): + """Support left alignment of Chinese, Japanese, Korean text + >>> cjkljust('Hello', 15, '*') + 'Hello**********' + >>> cjkljust('你好', 15, '*') + '你好***********' + >>> cjkljust('안녕하세요', 15, '*') + '안녕하세요*****' + >>> cjkljust('こんにちは', 15, '*') + 'こんにちは*****' + """ + return string.ljust(width - count_cjk_chars(string), fillbyte) class Selector: def __init__( @@ -128,7 +145,7 @@ class Selector: if current: padding += 5 - description = str(self._description).ljust(padding, ' ') + description = cjkljust(str(self._description), padding, ' ') current = current else: description = self._description -- cgit v1.2.3-70-g09d2 From 47ed711743f66fd4cabeb270952405674ef985ba Mon Sep 17 00:00:00 2001 From: Wu Xiaotian Date: Sun, 17 Sep 2023 07:08:38 +0800 Subject: Support CJK text alignment (#2012) * Add functions for unicode string alignment * use unicode alignment function to show menu * Allow table content to support unicode text alignment --- archinstall/lib/menu/abstract_menu.py | 21 ++------------------ archinstall/lib/output.py | 37 ++++++++++++++++++++++++++++++++--- 2 files changed, 36 insertions(+), 22 deletions(-) (limited to 'archinstall/lib/menu') diff --git a/archinstall/lib/menu/abstract_menu.py b/archinstall/lib/menu/abstract_menu.py index f12d2b73..306c500a 100644 --- a/archinstall/lib/menu/abstract_menu.py +++ b/archinstall/lib/menu/abstract_menu.py @@ -1,32 +1,15 @@ from __future__ import annotations -import unicodedata from typing import Callable, Any, List, Iterator, Tuple, Optional, Dict, TYPE_CHECKING from .menu import Menu, MenuSelectionType from ..output import error +from ..output import unicode_ljust from ..translationhandler import TranslationHandler, Language if TYPE_CHECKING: _: Any -def count_cjk_chars(string): - "Count the total number of CJK characters contained in a string" - return sum(unicodedata.east_asian_width(c) in 'FW' for c in string) - -def cjkljust(string, width, fillbyte=' '): - """Support left alignment of Chinese, Japanese, Korean text - >>> cjkljust('Hello', 15, '*') - 'Hello**********' - >>> cjkljust('你好', 15, '*') - '你好***********' - >>> cjkljust('안녕하세요', 15, '*') - '안녕하세요*****' - >>> cjkljust('こんにちは', 15, '*') - 'こんにちは*****' - """ - return string.ljust(width - count_cjk_chars(string), fillbyte) - class Selector: def __init__( self, @@ -145,7 +128,7 @@ class Selector: if current: padding += 5 - description = cjkljust(str(self._description), padding, ' ') + description = unicode_ljust(str(self._description), padding, ' ') current = current else: description = self._description diff --git a/archinstall/lib/output.py b/archinstall/lib/output.py index 9f2a2ae3..63d9c1fb 100644 --- a/archinstall/lib/output.py +++ b/archinstall/lib/output.py @@ -1,6 +1,7 @@ import logging import os import sys +import unicodedata from enum import Enum from pathlib import Path @@ -83,7 +84,7 @@ class FormattedOutput: if capitalize: key = key.capitalize() - key_list.append(key.ljust(width)) + key_list.append(unicode_ljust(key, width)) output += ' | '.join(key_list) + '\n' output += '-' * len(output) + '\n' @@ -99,9 +100,9 @@ class FormattedOutput: value = '*' * width if isinstance(value, (int, float)) or (isinstance(value, str) and value.isnumeric()): - obj_data.append(str(value).rjust(width)) + obj_data.append(unicode_rjust(str(value), width)) else: - obj_data.append(str(value).ljust(width)) + obj_data.append(unicode_ljust(str(value), width)) output += ' | '.join(obj_data) + '\n' @@ -326,3 +327,33 @@ def log( if level != logging.DEBUG or storage.get('arguments', {}).get('verbose', False): sys.stdout.write(f"{text}\n") sys.stdout.flush() + +def _count_wchars(string: str) -> int: + "Count the total number of wide characters contained in a string" + return sum(unicodedata.east_asian_width(c) in 'FW' for c in string) + +def unicode_ljust(string: str, width: int, fillbyte: str = ' ') -> str: + """Return a left-justified unicode string of length width. + >>> unicode_ljust('Hello', 15, '*') + 'Hello**********' + >>> unicode_ljust('你好', 15, '*') + '你好***********' + >>> unicode_ljust('안녕하세요', 15, '*') + '안녕하세요*****' + >>> unicode_ljust('こんにちは', 15, '*') + 'こんにちは*****' + """ + return string.ljust(width - _count_wchars(string), fillbyte) + +def unicode_rjust(string: str, width: int, fillbyte: str = ' ') -> str: + """Return a right-justified unicode string of length width. + >>> unicode_rjust('Hello', 15, '*') + '**********Hello' + >>> unicode_rjust('你好', 15, '*') + '***********你好' + >>> unicode_rjust('안녕하세요', 15, '*') + '*****안녕하세요' + >>> unicode_rjust('こんにちは', 15, '*') + '*****こんにちは' + """ + return string.rjust(width - _count_wchars(string), fillbyte) -- cgit v1.2.3-70-g09d2 From 12b7017240a040fd4fbebf7c5794a1ca5560f0ea Mon Sep 17 00:00:00 2001 From: Alexander Seiler Date: Mon, 18 Sep 2023 14:04:36 +0200 Subject: Fix many typos (#1692) Signed-off-by: Alexander Seiler --- archinstall/__init__.py | 2 +- archinstall/default_profiles/profile.py | 6 +++--- archinstall/lib/disk/device_model.py | 2 +- archinstall/lib/disk/fido.py | 2 +- archinstall/lib/general.py | 2 +- archinstall/lib/global_menu.py | 4 ++-- archinstall/lib/installer.py | 10 +++++----- archinstall/lib/interactions/__init__.py | 2 +- archinstall/lib/interactions/general_conf.py | 2 +- archinstall/lib/menu/list_manager.py | 2 +- archinstall/lib/menu/menu.py | 2 +- archinstall/lib/output.py | 2 +- archinstall/lib/translationhandler.py | 2 +- archinstall/scripts/guided.py | 4 ++-- archinstall/scripts/swiss.py | 6 +++--- examples/interactive_installation.py | 4 ++-- 16 files changed, 27 insertions(+), 27 deletions(-) (limited to 'archinstall/lib/menu') diff --git a/archinstall/__init__.py b/archinstall/__init__.py index 39588904..7645ae39 100644 --- a/archinstall/__init__.py +++ b/archinstall/__init__.py @@ -43,7 +43,7 @@ __version__ = "2.6.0" storage['__version__'] = __version__ -# add the custome _ as a builtin, it can now be used anywhere in the +# add the custom _ as a builtin, it can now be used anywhere in the # project to mark strings as translatable with _('translate me') DeferredTranslation.install() diff --git a/archinstall/default_profiles/profile.py b/archinstall/default_profiles/profile.py index 982bd5a3..49a9c19d 100644 --- a/archinstall/default_profiles/profile.py +++ b/archinstall/default_profiles/profile.py @@ -81,7 +81,7 @@ class Profile: def packages(self) -> List[str]: """ Returns a list of packages that should be installed when - this profile is among the choosen ones + this profile is among the chosen ones """ return self._packages @@ -128,7 +128,7 @@ class Profile: """ Set the custom settings for the profile. This is also called when the settings are parsed from the config - and can be overriden to perform further actions based on the profile + and can be overridden to perform further actions based on the profile """ self.custom_settings = settings @@ -179,7 +179,7 @@ class Profile: def preview_text(self) -> Optional[str]: """ Used for preview text in profiles_bck. If a description is set for a - profile it will automatically display that one in the preivew. + profile it will automatically display that one in the preview. If no preview or a different text should be displayed just """ if self.description: diff --git a/archinstall/lib/disk/device_model.py b/archinstall/lib/disk/device_model.py index 6eeb0d91..69038b01 100644 --- a/archinstall/lib/disk/device_model.py +++ b/archinstall/lib/disk/device_model.py @@ -202,7 +202,7 @@ class Size: # not sure why we would ever wanna convert to percentages if target_unit == Unit.Percent and total_size is None: - raise ValueError('Missing paramter total size to be able to convert to percentage') + raise ValueError('Missing parameter total size to be able to convert to percentage') if self.unit == target_unit: return self diff --git a/archinstall/lib/disk/fido.py b/archinstall/lib/disk/fido.py index 96a74991..9eeba56a 100644 --- a/archinstall/lib/disk/fido.py +++ b/archinstall/lib/disk/fido.py @@ -34,7 +34,7 @@ class Fido2: /dev/hidraw1 Yubico YubiKey OTP+FIDO+CCID """ - # to prevent continous reloading which will slow + # to prevent continuous reloading which will slow # down moving the cursor in the menu if not cls._loaded or reload: try: diff --git a/archinstall/lib/general.py b/archinstall/lib/general.py index 611378ee..e22e7eed 100644 --- a/archinstall/lib/general.py +++ b/archinstall/lib/general.py @@ -30,7 +30,7 @@ if TYPE_CHECKING: def generate_password(length :int = 64) -> str: - haystack = string.printable # digits, ascii_letters, punctiation (!"#$[] etc) and whitespace + haystack = string.printable # digits, ascii_letters, punctuation (!"#$[] etc) and whitespace return ''.join(secrets.choice(haystack) for i in range(length)) diff --git a/archinstall/lib/global_menu.py b/archinstall/lib/global_menu.py index fb62b7b5..b38dac0b 100644 --- a/archinstall/lib/global_menu.py +++ b/archinstall/lib/global_menu.py @@ -15,7 +15,7 @@ from .output import FormattedOutput from .profile.profile_menu import ProfileConfiguration from .storage import storage from .configuration import save_config -from .interactions import add_number_of_parrallel_downloads +from .interactions import add_number_of_parallel_downloads from .interactions import ask_additional_packages_to_install from .interactions import ask_for_additional_users from .interactions import ask_for_audio_selection @@ -119,7 +119,7 @@ class GlobalMenu(AbstractMenu): self._menu_options['parallel downloads'] = \ Selector( _('Parallel Downloads'), - lambda preset: add_number_of_parrallel_downloads(preset), + lambda preset: add_number_of_parallel_downloads(preset), display_func=lambda x: x if x else '0', default=0 ) diff --git a/archinstall/lib/installer.py b/archinstall/lib/installer.py index 09e91ab8..34c9441f 100644 --- a/archinstall/lib/installer.py +++ b/archinstall/lib/installer.py @@ -194,7 +194,7 @@ class Installer: for part_mod in sorted_part_mods: if luks_handler := luks_handlers.get(part_mod): # mount encrypted partition - self._mount_luks_partiton(part_mod, luks_handler) + self._mount_luks_partition(part_mod, luks_handler) else: # partition is not encrypted self._mount_partition(part_mod) @@ -219,7 +219,7 @@ class Installer: if part_mod.fs_type == disk.FilesystemType.Btrfs and part_mod.dev_path: self._mount_btrfs_subvol(part_mod.dev_path, part_mod.btrfs_subvols) - def _mount_luks_partiton(self, part_mod: disk.PartitionModification, luks_handler: Luks2): + def _mount_luks_partition(self, part_mod: disk.PartitionModification, luks_handler: Luks2): # it would be none if it's btrfs as the subvolumes will have the mountpoints defined if part_mod.mountpoint and luks_handler.mapper_dev: target = self.target / part_mod.relative_mountpoint @@ -315,7 +315,7 @@ class Installer: raise RequirementError(f'Could not generate fstab, strapping in packages most likely failed (disk out of space?)\n Error: {err}') if not gen_fstab: - raise RequirementError(f'Genrating fstab returned empty value') + raise RequirementError(f'Generating fstab returned empty value') with open(fstab_path, 'a') as fp: fp.write(gen_fstab) @@ -434,7 +434,7 @@ class Installer: return False - def activate_time_syncronization(self) -> None: + def activate_time_synchronization(self) -> None: info('Activating systemd-timesyncd for time synchronization using Arch Linux and ntp.org NTP servers') self.enable_service('systemd-timesyncd') @@ -1008,7 +1008,7 @@ When = PostTransaction Exec = /bin/sh -c \\"/usr/bin/limine bios-install /dev/disk/by-uuid/{root_uuid} && /usr/bin/cp /usr/share/limine/limine-bios.sys /boot/\\" """) - # Limine does not ship with a default configuation file. We are going to + # Limine does not ship with a default configuration file. We are going to # create a basic one that is similar to the one GRUB generates. try: config = f""" diff --git a/archinstall/lib/interactions/__init__.py b/archinstall/lib/interactions/__init__.py index 53be8e7a..50c0012d 100644 --- a/archinstall/lib/interactions/__init__.py +++ b/archinstall/lib/interactions/__init__.py @@ -11,7 +11,7 @@ from .disk_conf import ( from .general_conf import ( ask_ntp, ask_hostname, ask_for_a_timezone, ask_for_audio_selection, select_archinstall_language, ask_additional_packages_to_install, - add_number_of_parrallel_downloads, select_additional_repositories + add_number_of_parallel_downloads, select_additional_repositories ) from .system_conf import ( diff --git a/archinstall/lib/interactions/general_conf.py b/archinstall/lib/interactions/general_conf.py index 14fcc3f8..8dd6e94f 100644 --- a/archinstall/lib/interactions/general_conf.py +++ b/archinstall/lib/interactions/general_conf.py @@ -164,7 +164,7 @@ def ask_additional_packages_to_install(preset: List[str] = []) -> List[str]: return packages -def add_number_of_parrallel_downloads(input_number :Optional[int] = None) -> Optional[int]: +def add_number_of_parallel_downloads(input_number :Optional[int] = None) -> Optional[int]: max_recommended = 5 print(_(f"This option enables the number of parallel downloads that can occur during package downloads")) print(_("Enter the number of parallel downloads to be enabled.\n\nNote:\n")) diff --git a/archinstall/lib/menu/list_manager.py b/archinstall/lib/menu/list_manager.py index be31fdf0..54fb6a1b 100644 --- a/archinstall/lib/menu/list_manager.py +++ b/archinstall/lib/menu/list_manager.py @@ -80,7 +80,7 @@ class ListManager: self._data = self.handle_action(choice.value, None, self._data) elif choice.value in self._terminate_actions: break - else: # an entry of the existing selection was choosen + else: # an entry of the existing selection was chosen selected_entry = data_formatted[choice.value] # type: ignore self._run_actions_on_entry(selected_entry) diff --git a/archinstall/lib/menu/menu.py b/archinstall/lib/menu/menu.py index 358ba5e4..3bd31b88 100644 --- a/archinstall/lib/menu/menu.py +++ b/archinstall/lib/menu/menu.py @@ -123,7 +123,7 @@ class Menu(TerminalMenu): :param allow_reset: This will explicitly handle a ctrl+c instead and return that specific state :type allow_reset: bool - param allow_reset_warning_msg: If raise_error_on_interrupt is True the warnign is set, a user confirmation is displayed + param allow_reset_warning_msg: If raise_error_on_interrupt is True the warning is set, a user confirmation is displayed type allow_reset_warning_msg: str :param extra_bottom_space: Add an extra empty line at the end of the menu diff --git a/archinstall/lib/output.py b/archinstall/lib/output.py index 63d9c1fb..62a1ba27 100644 --- a/archinstall/lib/output.py +++ b/archinstall/lib/output.py @@ -22,7 +22,7 @@ class FormattedOutput: ) -> Dict[str, Any]: """ the original values returned a dataclass as dict thru the call to some specific methods - this version allows thru the parameter class_formatter to call a dynamicly selected formatting method. + this version allows thru the parameter class_formatter to call a dynamically selected formatting method. Can transmit a filter list to the class_formatter, """ if class_formatter: diff --git a/archinstall/lib/translationhandler.py b/archinstall/lib/translationhandler.py index a2e44065..33230562 100644 --- a/archinstall/lib/translationhandler.py +++ b/archinstall/lib/translationhandler.py @@ -138,7 +138,7 @@ class TranslationHandler: def get_language_by_abbr(self, abbr: str) -> Language: """ - Get a language object by its abbrevation, e.g. en + Get a language object by its abbreviation, e.g. en """ try: return next(filter(lambda x: x.abbr == abbr, self._translated_languages)) diff --git a/archinstall/scripts/guided.py b/archinstall/scripts/guided.py index 51549fa8..d7cf16cd 100644 --- a/archinstall/scripts/guided.py +++ b/archinstall/scripts/guided.py @@ -185,7 +185,7 @@ def perform_installation(mountpoint: Path): installation.set_timezone(timezone) if archinstall.arguments.get('ntp', False): - installation.activate_time_syncronization() + installation.activate_time_synchronization() if archinstall.accessibility_tools_in_use(): installation.enable_espeakup() @@ -193,7 +193,7 @@ def perform_installation(mountpoint: Path): if (root_pw := archinstall.arguments.get('!root-password', None)) and len(root_pw): installation.user_set_pw('root', root_pw) - # This step must be after profile installs to allow profiles_bck to install language pre-requisits. + # This step must be after profile installs to allow profiles_bck to install language pre-requisites. # After which, this step will set the language both for console and x11 if x11 was installed for instance. installation.set_keyboard_language(locale_config.kb_layout) diff --git a/archinstall/scripts/swiss.py b/archinstall/scripts/swiss.py index 80fa0a48..c04ccca4 100644 --- a/archinstall/scripts/swiss.py +++ b/archinstall/scripts/swiss.py @@ -54,7 +54,7 @@ class SetupMenu(GlobalMenu): super().setup_selection_menu_options() self._menu_options['mode'] = menu.Selector( - 'Excution mode', + 'Execution mode', lambda x : select_mode(), display_func=lambda x: x.value if x else '', default=ExecutionMode.Full) @@ -249,7 +249,7 @@ def perform_installation(mountpoint: Path, exec_mode: ExecutionMode): installation.set_timezone(timezone) if archinstall.arguments.get('ntp', False): - installation.activate_time_syncronization() + installation.activate_time_synchronization() if archinstall.accessibility_tools_in_use(): installation.enable_espeakup() @@ -257,7 +257,7 @@ def perform_installation(mountpoint: Path, exec_mode: ExecutionMode): if (root_pw := archinstall.arguments.get('!root-password', None)) and len(root_pw): installation.user_set_pw('root', root_pw) - # This step must be after profile installs to allow profiles_bck to install language pre-requisits. + # This step must be after profile installs to allow profiles_bck to install language pre-requisites. # After which, this step will set the language both for console and x11 if x11 was installed for instance. installation.set_keyboard_language(locale_config.kb_layout) diff --git a/examples/interactive_installation.py b/examples/interactive_installation.py index 9eac029c..f8cc75fc 100644 --- a/examples/interactive_installation.py +++ b/examples/interactive_installation.py @@ -163,7 +163,7 @@ def perform_installation(mountpoint: Path): installation.set_timezone(timezone) if archinstall.arguments.get('ntp', False): - installation.activate_time_syncronization() + installation.activate_time_synchronization() if archinstall.accessibility_tools_in_use(): installation.enable_espeakup() @@ -171,7 +171,7 @@ def perform_installation(mountpoint: Path): if (root_pw := archinstall.arguments.get('!root-password', None)) and len(root_pw): installation.user_set_pw('root', root_pw) - # This step must be after profile installs to allow profiles_bck to install language pre-requisits. + # This step must be after profile installs to allow profiles_bck to install language pre-requisites. # After which, this step will set the language both for console and x11 if x11 was installed for instance. installation.set_keyboard_language(locale_config.kb_layout) -- cgit v1.2.3-70-g09d2 From edbc13590366e93bb8a85eacf104d5613bc5793a Mon Sep 17 00:00:00 2001 From: Daniel Girtler Date: Tue, 3 Oct 2023 18:31:17 +1100 Subject: Extend the mypy checks (#2120) * Extend the mypy checks * Update * Update * Update --------- Co-authored-by: Daniel Girtler --- archinstall/__init__.py | 4 ++-- archinstall/lib/disk/device_model.py | 12 ++++++------ archinstall/lib/disk/encryption_menu.py | 7 ++++--- archinstall/lib/global_menu.py | 11 +++++++---- archinstall/lib/installer.py | 14 ++++++++++---- archinstall/lib/menu/abstract_menu.py | 2 +- archinstall/lib/models/gen.py | 20 +++++++++++++++++--- archinstall/lib/packages/packages.py | 4 ++-- archinstall/lib/profile/profiles_handler.py | 6 +++--- archinstall/lib/translationhandler.py | 2 +- pyproject.toml | 2 +- 11 files changed, 54 insertions(+), 30 deletions(-) (limited to 'archinstall/lib/menu') diff --git a/archinstall/__init__.py b/archinstall/__init__.py index 07b85f96..11b47c48 100644 --- a/archinstall/__init__.py +++ b/archinstall/__init__.py @@ -225,8 +225,8 @@ def load_config(): if arguments.get('servers', None) is not None: storage['_selected_servers'] = arguments.get('servers', None) - if arguments.get('network_config', None) is not None: - config = NetworkConfiguration.parse_arg(arguments.get('network_config')) + if (net_config := arguments.get('network_config', None)) is not None: + config = NetworkConfiguration.parse_arg(net_config) arguments['network_config'] = config if arguments.get('!users', None) is not None or arguments.get('!superusers', None) is not None: diff --git a/archinstall/lib/disk/device_model.py b/archinstall/lib/disk/device_model.py index 4ac53b0c..cd955851 100644 --- a/archinstall/lib/disk/device_model.py +++ b/archinstall/lib/disk/device_model.py @@ -308,9 +308,9 @@ class _PartitionInfo: start: Size length: Size flags: List[PartitionFlag] - partn: int - partuuid: str - uuid: str + partn: Optional[int] + partuuid: Optional[str] + uuid: Optional[str] disk: Disk mountpoints: List[Path] btrfs_subvol_infos: List[_BtrfsSubvolumeInfo] = field(default_factory=list) @@ -344,9 +344,9 @@ class _PartitionInfo: cls, partition: Partition, fs_type: Optional[FilesystemType], - partn: int, - partuuid: str, - uuid: str, + partn: Optional[int], + partuuid: Optional[str], + uuid: Optional[str], mountpoints: List[Path], btrfs_subvol_infos: List[_BtrfsSubvolumeInfo] = [] ) -> _PartitionInfo: diff --git a/archinstall/lib/disk/encryption_menu.py b/archinstall/lib/disk/encryption_menu.py index 234e3b03..c3a1c32f 100644 --- a/archinstall/lib/disk/encryption_menu.py +++ b/archinstall/lib/disk/encryption_menu.py @@ -3,6 +3,7 @@ from typing import Dict, Optional, Any, TYPE_CHECKING, List from ..disk import ( DeviceModification, + DiskLayoutConfiguration, PartitionModification, DiskEncryption, EncryptionType @@ -26,7 +27,7 @@ if TYPE_CHECKING: class DiskEncryptionMenu(AbstractSubMenu): def __init__( self, - mods: List[DeviceModification], + disk_config: DiskLayoutConfiguration, data_store: Dict[str, Any], preset: Optional[DiskEncryption] = None ): @@ -35,7 +36,7 @@ class DiskEncryptionMenu(AbstractSubMenu): else: self._preset = DiskEncryption() - self._modifications = mods + self._disk_config = disk_config super().__init__(data_store=data_store) def setup_selection_menu_options(self): @@ -59,7 +60,7 @@ class DiskEncryptionMenu(AbstractSubMenu): self._menu_options['partitions'] = \ Selector( _('Partitions'), - func=lambda preset: select_partitions_to_encrypt(self._modifications.device_modifications, preset), + func=lambda preset: select_partitions_to_encrypt(self._disk_config.device_modifications, preset), display_func=lambda x: f'{len(x)} {_("Partitions")}' if x else None, dependencies=['encryption_password'], default=self._preset.partitions, diff --git a/archinstall/lib/global_menu.py b/archinstall/lib/global_menu.py index b38dac0b..deda2ef6 100644 --- a/archinstall/lib/global_menu.py +++ b/archinstall/lib/global_menu.py @@ -176,8 +176,11 @@ class GlobalMenu(AbstractMenu): self._menu_options['abort'] = Selector(_('Abort'), exec_func=lambda n,v:exit(1)) def _missing_configs(self) -> List[str]: - def check(s): - return self._menu_options.get(s).has_selection() + def check(s) -> bool: + obj = self._menu_options.get(s) + if obj and obj.has_selection(): + return True + return False def has_superuser() -> bool: sel = self._menu_options['!users'] @@ -228,7 +231,7 @@ class GlobalMenu(AbstractMenu): return config.type.display_msg() def _disk_encryption(self, preset: Optional[disk.DiskEncryption]) -> Optional[disk.DiskEncryption]: - mods: Optional[List[disk.DeviceModification]] = self._menu_options['disk_config'].current_selection + mods: Optional[disk.DiskLayoutConfiguration] = self._menu_options['disk_config'].current_selection if not mods: # this should not happen as the encryption menu has the disk_config as dependency @@ -263,7 +266,7 @@ class GlobalMenu(AbstractMenu): def _prev_additional_pkgs(self): selector = self._menu_options['packages'] - if selector.has_selection(): + if selector.current_selection: packages: List[str] = selector.current_selection return format_cols(packages, None) return None diff --git a/archinstall/lib/installer.py b/archinstall/lib/installer.py index ad98d9a8..8e716d3d 100644 --- a/archinstall/lib/installer.py +++ b/archinstall/lib/installer.py @@ -131,7 +131,11 @@ class Installer: We need to wait for it before we continue since we opted in to use a custom mirror/region. """ info('Waiting for time sync (systemd-timesyncd.service) to complete.') - while SysCommand('timedatectl show --property=NTPSynchronized --value').decode() != 'yes': + + while True: + time_val = SysCommand('timedatectl show --property=NTPSynchronized --value').decode() + if time_val and time_val.strip() == 'yes': + break time.sleep(1) info('Waiting for automatic mirror selection (reflector) to complete.') @@ -237,7 +241,7 @@ class Installer: gen_enc_file = self._disk_encryption.should_generate_encryption_file(part_mod) luks_handler = Luks2( - part_mod.dev_path, + part_mod.safe_dev_path, mapper_name=part_mod.mapper_name, password=self._disk_encryption.encryption_password ) @@ -281,8 +285,10 @@ class Installer: self._fstab_entries.append(f'{file} none swap defaults 0 0') if enable_resume: - resume_uuid = SysCommand(f'findmnt -no UUID -T {self.target}{file}').decode('UTF-8').strip() - resume_offset = SysCommand(f'/usr/bin/filefrag -v {self.target}{file}').decode().split('0:', 1)[1].split(":", 1)[1].split("..", 1)[0].strip() + resume_uuid = SysCommand(f'findmnt -no UUID -T {self.target}{file}').decode() + resume_offset = SysCommand( + f'/usr/bin/filefrag -v {self.target}{file}' + ).decode().split('0:', 1)[1].split(":", 1)[1].split("..", 1)[0].strip() self._hooks.append('resume') self._kernel_params.append(f'resume=UUID={resume_uuid}') diff --git a/archinstall/lib/menu/abstract_menu.py b/archinstall/lib/menu/abstract_menu.py index 306c500a..053f3c30 100644 --- a/archinstall/lib/menu/abstract_menu.py +++ b/archinstall/lib/menu/abstract_menu.py @@ -14,7 +14,7 @@ class Selector: def __init__( self, description: str, - func: Optional[Callable[[str], Any]] = None, + func: Optional[Callable[[Any], Any]] = None, display_func: Optional[Callable] = None, default: Optional[Any] = None, enabled: bool = False, diff --git a/archinstall/lib/models/gen.py b/archinstall/lib/models/gen.py index cc8d7605..fb7e5751 100644 --- a/archinstall/lib/models/gen.py +++ b/archinstall/lib/models/gen.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Optional, List +from typing import Optional, List, Dict, Any @dataclass @@ -87,6 +87,10 @@ class PackageSearchResult: makedepends: List[str] checkdepends: List[str] + @staticmethod + def from_json(data: Dict[str, Any]) -> 'PackageSearchResult': + return PackageSearchResult(**data) + @property def pkg_version(self) -> str: return self.pkgver @@ -107,8 +111,18 @@ class PackageSearch: page: int results: List[PackageSearchResult] - def __post_init__(self): - self.results = [PackageSearchResult(**x) for x in self.results] + @staticmethod + def from_json(data: Dict[str, Any]) -> 'PackageSearch': + results = [PackageSearchResult.from_json(r) for r in data['results']] + + return PackageSearch( + version=data['version'], + limit=data['limit'], + valid=data['valid'], + num_pages=data['num_pages'], + page=data['page'], + results=results + ) @dataclass diff --git a/archinstall/lib/packages/packages.py b/archinstall/lib/packages/packages.py index 7491df07..e495b03f 100644 --- a/archinstall/lib/packages/packages.py +++ b/archinstall/lib/packages/packages.py @@ -55,8 +55,8 @@ def package_search(package :str) -> PackageSearch: raise PackageError(f"Could not locate package: [{response.code}] {response}") data = response.read().decode('UTF-8') - - return PackageSearch(**json.loads(data)) + json_data = json.loads(data) + return PackageSearch.from_json(json_data) def find_package(package :str) -> List[PackageSearchResult]: diff --git a/archinstall/lib/profile/profiles_handler.py b/archinstall/lib/profile/profiles_handler.py index 03039321..515cdfe9 100644 --- a/archinstall/lib/profile/profiles_handler.py +++ b/archinstall/lib/profile/profiles_handler.py @@ -138,16 +138,16 @@ class ProfileHandler: profiles = [profiles] for profile in profiles: - self._profiles.append(profile) + self.profiles.append(profile) - self._verify_unique_profile_names(self._profiles) + self._verify_unique_profile_names(self.profiles) def remove_custom_profiles(self, profiles: Union[TProfile, List[TProfile]]): if not isinstance(profiles, list): profiles = [profiles] remove_names = [p.name for p in profiles] - self._profiles = [p for p in self._profiles if p.name not in remove_names] + self._profiles = [p for p in self.profiles if p.name not in remove_names] def get_profile_by_name(self, name: str) -> Optional[Profile]: return next(filter(lambda x: x.name == name, self.profiles), None) # type: ignore diff --git a/archinstall/lib/translationhandler.py b/archinstall/lib/translationhandler.py index 33230562..3ea4c70e 100644 --- a/archinstall/lib/translationhandler.py +++ b/archinstall/lib/translationhandler.py @@ -206,4 +206,4 @@ class DeferredTranslation: @classmethod def install(cls): import builtins - builtins._ = cls + builtins._ = cls # type: ignore diff --git a/pyproject.toml b/pyproject.toml index 36ee0492..445aa277 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,7 +61,7 @@ packages = ["archinstall"] python_version = "3.11" files = "archinstall/" exclude = "tests" -#check_untyped_defs=true +check_untyped_defs=true [tool.bandit] targets = ["archinstall"] -- cgit v1.2.3-70-g09d2 From 5e59acf937c3bb9cfe6a3b7a0a264b9df00239ee Mon Sep 17 00:00:00 2001 From: codefiles <11915375+codefiles@users.noreply.github.com> Date: Tue, 10 Oct 2023 04:00:22 -0400 Subject: Add handling of signal interrupt and EOF at input prompts (#2154) --- archinstall/lib/configuration.py | 2 +- archinstall/lib/interactions/disk_conf.py | 5 ++++- archinstall/lib/interactions/general_conf.py | 15 ++++++++------- archinstall/lib/interactions/manage_users_conf.py | 6 +++++- archinstall/lib/interactions/utils.py | 7 ++++++- archinstall/lib/menu/text_input.py | 11 ++++++++++- 6 files changed, 34 insertions(+), 12 deletions(-) (limited to 'archinstall/lib/menu') diff --git a/archinstall/lib/configuration.py b/archinstall/lib/configuration.py index aeeddbb8..95e237d7 100644 --- a/archinstall/lib/configuration.py +++ b/archinstall/lib/configuration.py @@ -177,5 +177,5 @@ def save_config(config: Dict): case "all": config_output.save(dest_path) - except KeyboardInterrupt: + except (KeyboardInterrupt, EOFError): return diff --git a/archinstall/lib/interactions/disk_conf.py b/archinstall/lib/interactions/disk_conf.py index 84a3196c..c18119ec 100644 --- a/archinstall/lib/interactions/disk_conf.py +++ b/archinstall/lib/interactions/disk_conf.py @@ -134,7 +134,10 @@ def select_disk_config( output = "You will use whatever drive-setup is mounted at the specified directory\n" output += "WARNING: Archinstall won't check the suitability of this setup\n" - path = prompt_dir(str(_('Enter the root directory of the mounted devices: ')), output) + try: + path = prompt_dir(str(_('Enter the root directory of the mounted devices: ')), output) + except (KeyboardInterrupt, EOFError): + return preset mods = disk.device_handler.detect_pre_mounted_mods(path) return disk.DiskLayoutConfiguration( diff --git a/archinstall/lib/interactions/general_conf.py b/archinstall/lib/interactions/general_conf.py index a23426d0..b12a6fb8 100644 --- a/archinstall/lib/interactions/general_conf.py +++ b/archinstall/lib/interactions/general_conf.py @@ -28,14 +28,15 @@ def ask_ntp(preset: bool = True) -> bool: def ask_hostname(preset: str = '') -> str: - while True: - hostname = TextInput( - str(_('Desired hostname for the installation: ')), - preset - ).run().strip() + hostname = TextInput( + str(_('Desired hostname for the installation: ')), + preset + ).run().strip() + + if not hostname: + return preset - if hostname: - return hostname + return hostname def ask_for_a_timezone(preset: Optional[str] = None) -> Optional[str]: diff --git a/archinstall/lib/interactions/manage_users_conf.py b/archinstall/lib/interactions/manage_users_conf.py index 879578da..ca912283 100644 --- a/archinstall/lib/interactions/manage_users_conf.py +++ b/archinstall/lib/interactions/manage_users_conf.py @@ -75,7 +75,11 @@ class UserList(ListManager): prompt = '\n\n' + str(_('Enter username (leave blank to skip): ')) while True: - username = input(prompt).strip(' ') + try: + username = input(prompt).strip(' ') + except (KeyboardInterrupt, EOFError): + return None + if not username: return None if not self._check_for_correct_username(username): diff --git a/archinstall/lib/interactions/utils.py b/archinstall/lib/interactions/utils.py index f6b5b2d3..fdbb4625 100644 --- a/archinstall/lib/interactions/utils.py +++ b/archinstall/lib/interactions/utils.py @@ -17,7 +17,12 @@ def get_password(prompt: str = '') -> Optional[str]: if not prompt: prompt = _("Enter a password: ") - while password := getpass.getpass(prompt): + while True: + try: + password = getpass.getpass(prompt) + except (KeyboardInterrupt, EOFError): + break + if len(password.strip()) <= 0: break diff --git a/archinstall/lib/menu/text_input.py b/archinstall/lib/menu/text_input.py index 05ca0f22..971df5fd 100644 --- a/archinstall/lib/menu/text_input.py +++ b/archinstall/lib/menu/text_input.py @@ -1,4 +1,5 @@ import readline +import sys class TextInput: @@ -12,6 +13,14 @@ class TextInput: def run(self) -> str: readline.set_pre_input_hook(self._hook) - result = input(self._prompt) + try: + result = input(self._prompt) + except (KeyboardInterrupt, EOFError): + # To make sure any output that may follow + # will be on the line after the prompt + sys.stdout.write('\n') + sys.stdout.flush() + + result = '' readline.set_pre_input_hook() return result -- cgit v1.2.3-70-g09d2 From 7930ea58fd3c55b38ff73949d74e9227ff351b60 Mon Sep 17 00:00:00 2001 From: codefiles <11915375+codefiles@users.noreply.github.com> Date: Fri, 13 Oct 2023 03:50:35 -0400 Subject: Fix install text initialization (#2162) --- archinstall/lib/global_menu.py | 4 ++-- archinstall/lib/menu/abstract_menu.py | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) (limited to 'archinstall/lib/menu') diff --git a/archinstall/lib/global_menu.py b/archinstall/lib/global_menu.py index deda2ef6..86c341a7 100644 --- a/archinstall/lib/global_menu.py +++ b/archinstall/lib/global_menu.py @@ -211,11 +211,11 @@ class GlobalMenu(AbstractMenu): return False return self._validate_bootloader() is None - def _update_install_text(self, name: str, value: str): + def _update_install_text(self, name: Optional[str] = None, value: Any = None): text = self._install_text() self._menu_options['install'].update_description(text) - def post_callback(self, name: str, value: str): + def post_callback(self, name: Optional[str] = None, value: Any = None): self._update_install_text(name, value) def _install_text(self): diff --git a/archinstall/lib/menu/abstract_menu.py b/archinstall/lib/menu/abstract_menu.py index 053f3c30..2ceb6ca7 100644 --- a/archinstall/lib/menu/abstract_menu.py +++ b/archinstall/lib/menu/abstract_menu.py @@ -252,7 +252,7 @@ class AbstractMenu: """ will be called before each action in the menu """ return - def post_callback(self, selection_name: str, value: Any): + def post_callback(self, selection_name: Optional[str] = None, value: Any = None): """ will be called after each action in the menu """ return True @@ -299,6 +299,7 @@ class AbstractMenu: def run(self, allow_reset: bool = False): self._sync_all() + self.post_callback() cursor_pos = None while True: -- cgit v1.2.3-70-g09d2 From 0d5e1cf752010e1c2d068077bbd55ae25d3d0bd7 Mon Sep 17 00:00:00 2001 From: Rafael Fontenelle Date: Thu, 7 Mar 2024 09:19:44 -0300 Subject: Fix misspellings (#2306) --- archinstall/__init__.py | 2 +- archinstall/lib/installer.py | 2 +- archinstall/lib/interactions/disk_conf.py | 2 +- archinstall/lib/menu/abstract_menu.py | 2 +- docs/cli_parameters/config/config_options.csv | 8 ++++---- docs/cli_parameters/config/disk_config.rst | 4 ++-- docs/examples/python.rst | 4 ++-- docs/help/known_issues.rst | 6 +++--- docs/installing/guided.rst | 4 ++-- 9 files changed, 17 insertions(+), 17 deletions(-) (limited to 'archinstall/lib/menu') diff --git a/archinstall/__init__.py b/archinstall/__init__.py index efb73710..78798441 100644 --- a/archinstall/__init__.py +++ b/archinstall/__init__.py @@ -84,7 +84,7 @@ def define_arguments(): parser.add_argument("--script", default="guided", nargs="?", help="Script to run for installation", type=str) parser.add_argument("--mount-point", "--mount_point", nargs="?", type=str, help="Define an alternate mount point for installation") - parser.add_argument("--skip-ntp", action="store_true", help="Disables NTP checks during instalation", default=False) + parser.add_argument("--skip-ntp", action="store_true", help="Disables NTP checks during installation", default=False) parser.add_argument("--debug", action="store_true", default=False, help="Adds debug info into the log") parser.add_argument("--offline", action="store_true", default=False, help="Disabled online upstream services such as package search and key-ring auto update.") diff --git a/archinstall/lib/installer.py b/archinstall/lib/installer.py index ccae8faa..2ea728bb 100644 --- a/archinstall/lib/installer.py +++ b/archinstall/lib/installer.py @@ -148,7 +148,7 @@ class Installer: if not _notified and time.time() - _started_wait > 5: _notified = True warn( - _("Time syncronization not completing, while you wait - check the docs for workarounds: https://archinstall.readthedocs.io/")) + _("Time synchronization not completing, while you wait - check the docs for workarounds: https://archinstall.readthedocs.io/")) time_val = SysCommand('timedatectl show --property=NTPSynchronized --value').decode() if time_val and time_val.strip() == 'yes': diff --git a/archinstall/lib/interactions/disk_conf.py b/archinstall/lib/interactions/disk_conf.py index 85b377b7..bbd8957d 100644 --- a/archinstall/lib/interactions/disk_conf.py +++ b/archinstall/lib/interactions/disk_conf.py @@ -308,7 +308,7 @@ def suggest_single_disk_layout( root_partition.btrfs_subvols = subvolumes elif using_home_partition: # If we don't want to use subvolumes, - # But we want to be able to re-use data between re-installs.. + # But we want to be able to reuse data between re-installs.. # A second partition for /home would be nice if we have the space for it home_start = root_partition.length home_length = device.device_info.total_size - root_partition.length diff --git a/archinstall/lib/menu/abstract_menu.py b/archinstall/lib/menu/abstract_menu.py index 2ceb6ca7..14db98ca 100644 --- a/archinstall/lib/menu/abstract_menu.py +++ b/archinstall/lib/menu/abstract_menu.py @@ -344,7 +344,7 @@ class AbstractMenu: value = value.strip() # if this calls returns false, we exit the menu - # we allow for an callback for special processing on realeasing control + # we allow for an callback for special processing on releasing control if not self._process_selection(value): break diff --git a/docs/cli_parameters/config/config_options.csv b/docs/cli_parameters/config/config_options.csv index 1861b1e1..7cf10982 100644 --- a/docs/cli_parameters/config/config_options.csv +++ b/docs/cli_parameters/config/config_options.csv @@ -5,8 +5,8 @@ audio_config,`pipewire `_!, `pulseaud bootloader,`Systemd-boot `_!, `grub `_,Bootloader to be installed *(grub being mandatory on BIOS machines)*,Yes debug,``true``!, ``false``,Enables debug output,No disk_config,*Read more under* :ref:`disk config`,Contains the desired disk setup to be used during installation,No -disk_encryption,*Read more about under* :ref:`disk encryption`,Parameters for disk encryption applied ontop of ``disk_config``,No -hostname,``str``,A string definining your machines hostname on the network *(defaults to ``archinstall``)*,No +disk_encryption,*Read more about under* :ref:`disk encryption`,Parameters for disk encryption applied on top of ``disk_config``,No +hostname,``str``,A string defining your machines hostname on the network *(defaults to ``archinstall``)*,No kernels,[ `linux `_!, `linux-hardened `_!, `linux-lts `_!, `linux-rt `_!, `linux-rt-lts `_!, `linux-zen `_ ],Defines which kernels should be installed and setup in the boot loader options,Yes custom-commands,*Read more under* :ref:`custom commands`,Custom commands that will be run post-install chrooted inside the installed system,No locale_config,{kb_layout: `lang `__!, sys_enc: `Character encoding `_!, sys_lang: `locale `_},Defines the keyboard key map!, system encoding and system locale,No @@ -16,9 +16,9 @@ no_pkg_lookups,``true``!, ``false``,Disabled package checking against https://ar ntp,``true``!, ``false``,enables or disables `NTP `_ during installation,No offline,``true``!, ``false``,enables or disables certain online checks such as mirror reachability etc,No packages,[ !, !, ... ],A list of packages to install during installation,No -parallel downloads,0-∞,sets a given number of paralell downloads to be used by `pacman `_,No +parallel downloads,0-∞,sets a given number of parallel downloads to be used by `pacman `_,No profile_config,*`read more under the profiles section`*,Installs a given profile if defined,No script,`guided `__! *(default)*!, `minimal `__!, `only_hdd `_!, `swiss `_!, `unattended `_,When used to autorun an installation!, this sets which script to autorun with,No silent,``true``!, ``false``,disables or enables user questions using the TUI,No swap,``true``!, ``false``,enables or disables swap,No -timezone,`timezone `_,sets a timezone for the installed system,No \ No newline at end of file +timezone,`timezone `_,sets a timezone for the installed system,No diff --git a/docs/cli_parameters/config/disk_config.rst b/docs/cli_parameters/config/disk_config.rst index ed5f42c1..3dc01fb2 100644 --- a/docs/cli_parameters/config/disk_config.rst +++ b/docs/cli_parameters/config/disk_config.rst @@ -26,7 +26,7 @@ Given the following disk example: ├── boot (/dev/sda1) └── home (/dev/sda3) -Runing ``archinstall --conf your.json --silent`` where the above JSON is configured. The disk will be left alone — and a working system will be installed to the above folders and mountpoints will be translated into the installed system. +Running ``archinstall --conf your.json --silent`` where the above JSON is configured. The disk will be left alone — and a working system will be installed to the above folders and mountpoints will be translated into the installed system. .. note:: @@ -242,4 +242,4 @@ This example contains both subvolumes and compression. }, "status": "create", "type": "primary" - } \ No newline at end of file + } diff --git a/docs/examples/python.rst b/docs/examples/python.rst index 7fb3f6c3..7226c825 100644 --- a/docs/examples/python.rst +++ b/docs/examples/python.rst @@ -38,7 +38,7 @@ To do this, we'll begin by importing :code:`archinstall` in our "`scripts`_:code print(archinstall.disk.device_handler.devices) Now, go ahead and reference the :ref:`installing.python.manual` installation method. -After runnig ``python -m archinstall test_installer`` it should print something that looks like: +After running ``python -m archinstall test_installer`` it should print something that looks like: .. code-block:: text @@ -93,4 +93,4 @@ That means your script is in the right place, and ``archinstall`` is working as Most calls, including the one above requires `root `_ privileges. -.. _scripts: https://github.com/archlinux/archinstall/tree/master/archinstall/scripts \ No newline at end of file +.. _scripts: https://github.com/archlinux/archinstall/tree/master/archinstall/scripts diff --git a/docs/help/known_issues.rst b/docs/help/known_issues.rst index 425829e5..622356c1 100644 --- a/docs/help/known_issues.rst +++ b/docs/help/known_issues.rst @@ -22,7 +22,7 @@ Waiting for time sync `#2144`_ Missing Nvidia Proprietary Driver `#2002`_ ------------------------------------------ -| In some instances, the nvidia driver might not have all the nessecary packages installed. +| In some instances, the nvidia driver might not have all the necessary packages installed. | This is due to the kernel selection and/or hardware setups requiring additional packages to work properly. A common workaround is to install the package `linux-headers`_ and `nvidia-dkms`_ @@ -49,7 +49,7 @@ Keyring is out of date `#2213`_ | Subsequently the ``archinstall`` run might operate on a old keyring despite there being an update service for this. | There is really no way to reliably over time work around this issue in ``archinstall``. -| Instead, efforts to the upstream service should be considered the way forward. And/or keys not expiring betwene a sane ammount of ISO's. +| Instead, efforts to the upstream service should be considered the way forward. And/or keys not expiring between a sane amount of ISO's. .. note:: @@ -99,4 +99,4 @@ AUR packages .. _archlinux-keyring-wkd-sync.service: https://gitlab.archlinux.org/archlinux/archlinux-keyring/-/blob/7e672dad10652a80d1cc575d75cdb46442cd7f96/wkd_sync/archlinux-keyring-wkd-sync.service.in .. _ZFS: https://aur.archlinux.org/packages/zfs-linux .. _archinstall: https://github.com/archlinux/archinstall/ -.. _timedatectl show: https://github.com/archlinux/archinstall/blob/e6344f93f7e476d05bbcd642f2ed91fdde545870/archinstall/lib/installer.py#L136 \ No newline at end of file +.. _timedatectl show: https://github.com/archlinux/archinstall/blob/e6344f93f7e476d05bbcd642f2ed91fdde545870/archinstall/lib/installer.py#L136 diff --git a/docs/installing/guided.rst b/docs/installing/guided.rst index dcedfc10..90abedb4 100644 --- a/docs/installing/guided.rst +++ b/docs/installing/guided.rst @@ -26,7 +26,7 @@ To start the installer, run the following in the latest Arch Linux ISO: archinstall -Since the `Guided Installer`_ is the default script, this is the equvilant of running :code:`archinstall guided` +Since the `Guided Installer`_ is the default script, this is the equivalent of running :code:`archinstall guided` The guided installation also supports installing with pre-configured answers to all the guided steps. This can be a quick and convenient way to re-run one or several installations. @@ -284,4 +284,4 @@ Options for ``--creds`` The key's start with ``!`` because internal log functions will mask any keys starting with explamation from logs and unrestricted configurations. .. _scripts: https://github.com/archlinux/archinstall/tree/master/archinstall/scripts -.. _Guided Installer: https://github.com/archlinux/archinstall/blob/master/archinstall/scripts/guided.py \ No newline at end of file +.. _Guided Installer: https://github.com/archlinux/archinstall/blob/master/archinstall/scripts/guided.py -- cgit v1.2.3-70-g09d2 From 08a6d402c4792cae95aec196460dc67aadd86f3c Mon Sep 17 00:00:00 2001 From: Daniel Girtler Date: Fri, 8 Mar 2024 00:43:51 +1100 Subject: Fix 2215 | Display installed packages for all profile submenus (#2355) * Display all packages to be installed * Display all packages to be installed --- archinstall/default_profiles/desktops/bspwm.py | 25 --------------------- archinstall/default_profiles/profile.py | 31 ++++++++++++++++++-------- archinstall/default_profiles/xorg.py | 5 ++++- archinstall/lib/hardware.py | 12 +++++++++- archinstall/lib/interactions/system_conf.py | 7 +++--- archinstall/lib/menu/menu.py | 4 +++- archinstall/lib/profile/profile_menu.py | 22 ++++++++++++++++-- archinstall/lib/utils/util.py | 8 +++---- 8 files changed, 68 insertions(+), 46 deletions(-) (limited to 'archinstall/lib/menu') diff --git a/archinstall/default_profiles/desktops/bspwm.py b/archinstall/default_profiles/desktops/bspwm.py index 2a29f41b..61eeba43 100644 --- a/archinstall/default_profiles/desktops/bspwm.py +++ b/archinstall/default_profiles/desktops/bspwm.py @@ -25,28 +25,3 @@ class BspwmProfile(XorgProfile): @property def default_greeter_type(self) -> Optional[GreeterType]: return GreeterType.Lightdm - - def preview_text(self) -> Optional[str]: - text = str(_('Environment type: {}')).format(self.profile_type.value) - return text + '\n' + self.packages_text() - - # The wiki specified xinit, but we already use greeter? - # https://wiki.archlinux.org/title/Bspwm#Starting - # - # # TODO: check if we selected a greeter, else run this: - # with open(f"{install_session.target}/etc/X11/xinit/xinitrc", 'r') as xinitrc: - # xinitrc_data = xinitrc.read() - - # for line in xinitrc_data.split('\n'): - # if "twm &" in line: - # xinitrc_data = xinitrc_data.replace(line, f"# {line}") - # if "xclock" in line: - # xinitrc_data = xinitrc_data.replace(line, f"# {line}") - # if "xterm" in line: - # xinitrc_data = xinitrc_data.replace(line, f"# {line}") - - # xinitrc_data += '\n' - # xinitrc_data += 'exec bspwn\n' - - # with open(f"{install_session.target}/etc/X11/xinit/xinitrc", 'w') as xinitrc: - # xinitrc.write(xinitrc_data) diff --git a/archinstall/default_profiles/profile.py b/archinstall/default_profiles/profile.py index 49a9c19d..4c85b0c7 100644 --- a/archinstall/default_profiles/profile.py +++ b/archinstall/default_profiles/profile.py @@ -178,15 +178,28 @@ class Profile: def preview_text(self) -> Optional[str]: """ - Used for preview text in profiles_bck. If a description is set for a - profile it will automatically display that one in the preview. - If no preview or a different text should be displayed just + Override this method to provide a preview text for the profile """ - if self.description: - return self.description - return None + return self.packages_text() - def packages_text(self) -> str: + def packages_text(self, include_sub_packages: bool = False) -> Optional[str]: header = str(_('Installed packages')) - output = format_cols(self.packages, header) - return output + + text = '' + packages = [] + + if self.packages: + packages = self.packages + + if include_sub_packages: + for p in self.current_selection: + if p.packages: + packages += p.packages + + text += format_cols(sorted(set(packages))) + + if text: + text = f'{header}: \n{text}' + return text + + return None diff --git a/archinstall/default_profiles/xorg.py b/archinstall/default_profiles/xorg.py index c9abf4da..88ba55a6 100644 --- a/archinstall/default_profiles/xorg.py +++ b/archinstall/default_profiles/xorg.py @@ -22,7 +22,10 @@ class XorgProfile(Profile): def preview_text(self) -> Optional[str]: text = str(_('Environment type: {}')).format(self.profile_type.value) - return text + '\n' + self.packages_text() + if packages := self.packages_text(): + text += f'\n{packages}' + + return text @property def packages(self) -> List[str]: diff --git a/archinstall/lib/hardware.py b/archinstall/lib/hardware.py index efdae430..c8001c19 100644 --- a/archinstall/lib/hardware.py +++ b/archinstall/lib/hardware.py @@ -2,12 +2,16 @@ import os from enum import Enum from functools import cached_property from pathlib import Path -from typing import Optional, Dict, List +from typing import Optional, Dict, List, TYPE_CHECKING, Any from .exceptions import SysCallError from .general import SysCommand from .networking import list_interfaces, enrich_iface_types from .output import debug +from .utils.util import format_cols + +if TYPE_CHECKING: + _: Any class CpuVendor(Enum): @@ -73,6 +77,12 @@ class GfxDriver(Enum): case _: return False + def packages_text(self) -> str: + text = str(_('Installed packages')) + ':\n' + pkg_names = [p.value for p in self.gfx_packages()] + text += format_cols(sorted(pkg_names)) + return text + def gfx_packages(self) -> List[GfxPackage]: packages = [GfxPackage.XorgServer, GfxPackage.XorgXinit] diff --git a/archinstall/lib/interactions/system_conf.py b/archinstall/lib/interactions/system_conf.py index aa72748e..35ba5a8b 100644 --- a/archinstall/lib/interactions/system_conf.py +++ b/archinstall/lib/interactions/system_conf.py @@ -103,14 +103,15 @@ def select_driver(options: List[GfxDriver] = [], current_value: Optional[GfxDriv if SysInfo.has_nvidia_graphics(): title += str(_('For the best compatibility with your Nvidia hardware, you may want to use the Nvidia proprietary driver.\n')) - title += str(_('\nSelect a graphics driver or leave blank to install all open-source drivers')) - preset = current_value.value if current_value else None + choice = Menu( title, drivers, preset_values=preset, - default_option=GfxDriver.AllOpenSource.value + default_option=GfxDriver.AllOpenSource.value, + preview_command=lambda x: GfxDriver(x).packages_text(), + preview_size=0.3 ).run() if choice.type_ != MenuSelectionType.Selection: diff --git a/archinstall/lib/menu/menu.py b/archinstall/lib/menu/menu.py index 3bd31b88..f14b855d 100644 --- a/archinstall/lib/menu/menu.py +++ b/archinstall/lib/menu/menu.py @@ -235,7 +235,9 @@ class Menu(TerminalMenu): if preview_command: if self._default_option is not None and self._default_menu_value == selection: selection = self._default_option - return preview_command(selection) + + if res := preview_command(selection): + return res.rstrip('\n') return None diff --git a/archinstall/lib/profile/profile_menu.py b/archinstall/lib/profile/profile_menu.py index d9e47190..aba75a88 100644 --- a/archinstall/lib/profile/profile_menu.py +++ b/archinstall/lib/profile/profile_menu.py @@ -40,6 +40,7 @@ class ProfileMenu(AbstractSubMenu): lambda preset: self._select_gfx_driver(preset), display_func=lambda x: x.value if x else None, dependencies=['profile'], + preview_func=self._preview_gfx, default=self._preset.gfx_driver if self._preset.profile and self._preset.profile.is_graphic_driver_supported() else None, enabled=self._preset.profile.is_graphic_driver_supported() if self._preset.profile else False ) @@ -67,6 +68,7 @@ class ProfileMenu(AbstractSubMenu): def _select_profile(self, preset: Optional[Profile]) -> Optional[Profile]: profile = select_profile(preset) + if profile is not None: if not profile.is_graphic_driver_supported(): self._menu_options['gfx_driver'].set_enabled(False) @@ -105,12 +107,28 @@ class ProfileMenu(AbstractSubMenu): return driver + def _preview_gfx(self) -> Optional[str]: + driver: Optional[GfxDriver] = self._menu_options['gfx_driver'].current_selection + + if driver: + return driver.packages_text() + + return None + def _preview_profile(self) -> Optional[str]: profile: Optional[Profile] = self._menu_options['profile'].current_selection + text = '' if profile: - names = profile.current_selection_names() - return '\n'.join(names) + if (sub_profiles := profile.current_selection) is not None: + text += str(_('Selected profiles: ')) + text += ', '.join([p.name for p in sub_profiles]) + '\n' + + if packages := profile.packages_text(include_sub_packages=True): + text += f'{packages}' + + if text: + return text return None diff --git a/archinstall/lib/utils/util.py b/archinstall/lib/utils/util.py index 8df75ab1..2e42b3cf 100644 --- a/archinstall/lib/utils/util.py +++ b/archinstall/lib/utils/util.py @@ -31,18 +31,18 @@ def is_subpath(first: Path, second: Path): return False -def format_cols(items: List[str], header: Optional[str]) -> str: +def format_cols(items: List[str], header: Optional[str] = None) -> str: if header: text = f'{header}:\n' else: text = '' nr_items = len(items) - if nr_items <= 5: + if nr_items <= 4: col = 1 - elif nr_items <= 10: + elif nr_items <= 8: col = 2 - elif nr_items <= 15: + elif nr_items <= 12: col = 3 else: col = 4 -- cgit v1.2.3-70-g09d2 From b470b16ec923260cfd9c5b9f2b88e0a39611b463 Mon Sep 17 00:00:00 2001 From: Daniel Girtler Date: Mon, 15 Apr 2024 18:49:00 +1000 Subject: LVM support (#2104) * Submenu for disk configuration * Update * Add LVM manual config * PV selection * LVM volume menu * Update * Fix mypy * Update * Update * Update * Update * Update * Update * Update * Update * Update LVM * Update * Update * Btrfs support * Refactor * LVM on Luks * Luks on LVM * Update * LVM on Luks * Update * Update * mypy * Update * Fix bug with LuksOnLvm and Btrfs * Update * Update * Info -> Debug output --- archinstall/lib/disk/__init__.py | 9 +- archinstall/lib/disk/device_handler.py | 264 ++++++++++--- archinstall/lib/disk/device_model.py | 408 +++++++++++++++++++-- archinstall/lib/disk/disk_menu.py | 140 +++++++ archinstall/lib/disk/encryption_menu.py | 131 +++++-- archinstall/lib/disk/fido.py | 8 +- archinstall/lib/disk/filesystem.py | 295 ++++++++++++++- archinstall/lib/disk/partitioning_menu.py | 18 +- archinstall/lib/disk/subvolume_menu.py | 18 +- archinstall/lib/global_menu.py | 70 ++-- archinstall/lib/installer.py | 427 +++++++++++++++++----- archinstall/lib/interactions/disk_conf.py | 134 ++++++- archinstall/lib/interactions/manage_users_conf.py | 18 +- archinstall/lib/luks.py | 30 +- archinstall/lib/menu/abstract_menu.py | 93 ++--- archinstall/lib/menu/list_manager.py | 28 +- archinstall/lib/menu/menu.py | 8 +- archinstall/lib/menu/table_selection_menu.py | 4 +- archinstall/lib/mirrors.py | 15 - archinstall/scripts/guided.py | 2 +- examples/interactive_installation.py | 2 +- 21 files changed, 1711 insertions(+), 411 deletions(-) create mode 100644 archinstall/lib/disk/disk_menu.py (limited to 'archinstall/lib/menu') diff --git a/archinstall/lib/disk/__init__.py b/archinstall/lib/disk/__init__.py index 24dafef5..7f881273 100644 --- a/archinstall/lib/disk/__init__.py +++ b/archinstall/lib/disk/__init__.py @@ -11,6 +11,11 @@ from .device_model import ( BDevice, DiskLayoutType, DiskLayoutConfiguration, + LvmLayoutType, + LvmConfiguration, + LvmVolumeGroup, + LvmVolume, + LvmVolumeStatus, PartitionTable, Unit, Size, @@ -30,7 +35,7 @@ from .device_model import ( CleanType, get_lsblk_info, get_all_lsblk_info, - get_lsblk_by_mountpoint + get_lsblk_by_mountpoint, ) from .encryption_menu import ( select_encryption_type, @@ -39,3 +44,5 @@ from .encryption_menu import ( select_partitions_to_encrypt, DiskEncryptionMenu, ) + +from .disk_menu import DiskLayoutConfigurationMenu diff --git a/archinstall/lib/disk/device_handler.py b/archinstall/lib/disk/device_handler.py index 6e91ac2e..7ba70382 100644 --- a/archinstall/lib/disk/device_handler.py +++ b/archinstall/lib/disk/device_handler.py @@ -3,8 +3,9 @@ from __future__ import annotations import json import os import logging +import time from pathlib import Path -from typing import List, Dict, Any, Optional, TYPE_CHECKING +from typing import List, Dict, Any, Optional, TYPE_CHECKING, Literal, Iterable from parted import ( # type: ignore Disk, Geometry, FileSystem, @@ -17,11 +18,12 @@ from .device_model import ( BDevice, _DeviceInfo, _PartitionInfo, FilesystemType, Unit, PartitionTable, ModificationStatus, get_lsblk_info, LsblkInfo, - _BtrfsSubvolumeInfo, get_all_lsblk_info, DiskEncryption + _BtrfsSubvolumeInfo, get_all_lsblk_info, DiskEncryption, LvmVolumeGroup, LvmVolume, Size, LvmGroupInfo, + SectorSize, LvmVolumeInfo, LvmPVInfo, SubvolumeModification, BtrfsMountOption ) from ..exceptions import DiskError, UnknownFilesystemFormat -from ..general import SysCommand, SysCallError, JSON +from ..general import SysCommand, SysCallError, JSON, SysCommandWorker from ..luks import Luks2 from ..output import debug, error, info, warn, log from ..utils.util import is_subpath @@ -189,7 +191,7 @@ class DeviceHandler(object): return subvol_infos - def _perform_formatting( + def format( self, fs_type: FilesystemType, path: Path, @@ -234,7 +236,7 @@ class DeviceHandler(object): options += additional_parted_options options_str = ' '.join(options) - info(f'Formatting filesystem: /usr/bin/{command} {options_str} {path}') + debug(f'Formatting filesystem: /usr/bin/{command} {options_str} {path}') try: SysCommand(f"/usr/bin/{command} {options_str} {path}") @@ -243,7 +245,33 @@ class DeviceHandler(object): error(msg) raise DiskError(msg) from err - def _perform_enc_formatting( + def encrypt( + self, + dev_path: Path, + mapper_name: Optional[str], + enc_password: str, + lock_after_create: bool = True + ) -> Luks2: + luks_handler = Luks2( + dev_path, + mapper_name=mapper_name, + password=enc_password + ) + + key_file = luks_handler.encrypt() + + luks_handler.unlock(key_file=key_file) + + if not luks_handler.mapper_dev: + raise DiskError('Failed to unlock luks device') + + if lock_after_create: + debug(f'luks2 locking device: {dev_path}') + luks_handler.lock() + + return luks_handler + + def format_encrypted( self, dev_path: Path, mapper_name: Optional[str], @@ -258,71 +286,160 @@ class DeviceHandler(object): key_file = luks_handler.encrypt() - debug(f'Unlocking luks2 device: {dev_path}') luks_handler.unlock(key_file=key_file) if not luks_handler.mapper_dev: raise DiskError('Failed to unlock luks device') info(f'luks2 formatting mapper dev: {luks_handler.mapper_dev}') - self._perform_formatting(fs_type, luks_handler.mapper_dev) + self.format(fs_type, luks_handler.mapper_dev) info(f'luks2 locking device: {dev_path}') luks_handler.lock() - def _validate_partitions(self, partitions: List[PartitionModification]): - checks = { - # verify that all partitions have a path set (which implies that they have been created) - lambda x: x.dev_path is None: ValueError('When formatting, all partitions must have a path set'), - # crypto luks is not a valid file system type - lambda x: x.fs_type is FilesystemType.Crypto_luks: ValueError('Crypto luks cannot be set as a filesystem type'), - # file system type must be set - lambda x: x.fs_type is None: ValueError('File system type must be set for modification') - } - - for check, exc in checks.items(): - found = next(filter(check, partitions), None) - if found is not None: - raise exc - - def format( + def _lvm_info( self, - device_mod: DeviceModification, - enc_conf: Optional['DiskEncryption'] = None - ): - """ - Format can be given an overriding path, for instance /dev/null to test - the formatting functionality and in essence the support for the given filesystem. - """ + cmd: str, + info_type: Literal['lv', 'vg', 'pvseg'] + ) -> Optional[Any]: + raw_info = SysCommand(cmd).decode().split('\n') - # only verify partitions that are being created or modified - create_or_modify_parts = [p for p in device_mod.partitions if p.is_create_or_modify()] + # for whatever reason the output sometimes contains + # "File descriptor X leaked leaked on vgs invocation + data = '\n'.join([raw for raw in raw_info if 'File descriptor' not in raw]) - self._validate_partitions(create_or_modify_parts) + debug(f'LVM info: {data}') - # make sure all devices are unmounted - self._umount_all_existing(device_mod.device_path) - - for part_mod in create_or_modify_parts: - # partition will be encrypted - if enc_conf is not None and part_mod in enc_conf.partitions: - self._perform_enc_formatting( - part_mod.safe_dev_path, - part_mod.mapper_name, - part_mod.safe_fs_type, - enc_conf - ) - else: - self._perform_formatting(part_mod.safe_fs_type, part_mod.safe_dev_path) + reports = json.loads(data) + + for report in reports['report']: + if len(report[info_type]) != 1: + raise ValueError(f'Report does not contain any entry') - # synchronize with udev before using lsblk - SysCommand('udevadm settle') + entry = report[info_type][0] - lsblk_info = self._fetch_part_info(part_mod.safe_dev_path) + match info_type: + case 'pvseg': + return LvmPVInfo( + pv_name=Path(entry['pv_name']), + lv_name=entry['lv_name'], + vg_name=entry['vg_name'], + ) + case 'lv': + return LvmVolumeInfo( + lv_name=entry['lv_name'], + vg_name=entry['vg_name'], + lv_size=Size(int(entry[f'lv_size'][:-1]), Unit.B, SectorSize.default()) + ) + case 'vg': + return LvmGroupInfo( + vg_uuid=entry['vg_uuid'], + vg_size=Size(int(entry[f'vg_size'][:-1]), Unit.B, SectorSize.default()) + ) + + return None - part_mod.partn = lsblk_info.partn - part_mod.partuuid = lsblk_info.partuuid - part_mod.uuid = lsblk_info.uuid + def _lvm_info_with_retry(self, cmd: str, info_type: Literal['lv', 'vg', 'pvseg']) -> Optional[Any]: + attempts = 3 + + for attempt_nr in range(attempts): + try: + return self._lvm_info(cmd, info_type) + except ValueError: + time.sleep(attempt_nr + 1) + + raise ValueError(f'Failed to fetch {info_type} information') + + def lvm_vol_info(self, lv_name: str) -> Optional[LvmVolumeInfo]: + cmd = ( + 'lvs --reportformat json ' + '--unit B ' + f'-S lv_name={lv_name}' + ) + + return self._lvm_info_with_retry(cmd, 'lv') + + def lvm_group_info(self, vg_name: str) -> Optional[LvmGroupInfo]: + cmd = ( + 'vgs --reportformat json ' + '--unit B ' + '-o vg_name,vg_uuid,vg_size ' + f'-S vg_name={vg_name}' + ) + + return self._lvm_info_with_retry(cmd, 'vg') + + def lvm_pvseg_info(self, vg_name: str, lv_name: str) -> Optional[LvmPVInfo]: + cmd = ( + 'pvs ' + '--segments -o+lv_name,vg_name ' + f'-S vg_name={vg_name},lv_name={lv_name} ' + '--reportformat json ' + ) + + return self._lvm_info_with_retry(cmd, 'pvseg') + + def lvm_vol_change(self, vol: LvmVolume, activate: bool): + active_flag = 'y' if activate else 'n' + cmd = f'lvchange -a {active_flag} {vol.safe_dev_path}' + + debug(f'lvchange volume: {cmd}') + SysCommand(cmd) + + def lvm_export_vg(self, vg: LvmVolumeGroup): + cmd = f'vgexport {vg.name}' + + debug(f'vgexport: {cmd}') + SysCommand(cmd) + + def lvm_import_vg(self, vg: LvmVolumeGroup): + cmd = f'vgimport {vg.name}' + + debug(f'vgimport: {cmd}') + SysCommand(cmd) + + def lvm_vol_reduce(self, vol_path: Path, amount: Size): + val = amount.format_size(Unit.B, include_unit=False) + cmd = f'lvreduce -L -{val}B {vol_path}' + + debug(f'Reducing LVM volume size: {cmd}') + SysCommand(cmd) + + def lvm_pv_create(self, pvs: Iterable[Path]): + cmd = 'pvcreate ' + ' '.join([str(pv) for pv in pvs]) + debug(f'Creating LVM PVS: {cmd}') + + worker = SysCommandWorker(cmd) + worker.poll() + worker.write(b'y\n', line_ending=False) + + def lvm_vg_create(self, pvs: Iterable[Path], vg_name: str): + pvs_str = ' '.join([str(pv) for pv in pvs]) + cmd = f'vgcreate --yes {vg_name} {pvs_str}' + + debug(f'Creating LVM group: {cmd}') + + worker = SysCommandWorker(cmd) + worker.poll() + worker.write(b'y\n', line_ending=False) + + def lvm_vol_create(self, vg_name: str, volume: LvmVolume, offset: Optional[Size] = None): + if offset is not None: + length = volume.length - offset + else: + length = volume.length + + length_str = length.format_size(Unit.B, include_unit=False) + cmd = f'lvcreate --yes -L {length_str}B {vg_name} -n {volume.name}' + + debug(f'Creating volume: {cmd}') + + worker = SysCommandWorker(cmd) + worker.poll() + worker.write(b'y\n', line_ending=False) + + volume.vg_name = vg_name + volume.dev_path = Path(f'/dev/{vg_name}/{volume.name}') def _setup_partition( self, @@ -385,7 +502,7 @@ class DeviceHandler(object): # the partition has a path now that it has been added part_mod.dev_path = Path(partition.path) - def _fetch_part_info(self, path: Path) -> LsblkInfo: + def fetch_part_info(self, path: Path) -> LsblkInfo: lsblk_info = get_lsblk_info(path) if not lsblk_info.partn: @@ -404,6 +521,37 @@ class DeviceHandler(object): return lsblk_info + def create_lvm_btrfs_subvolumes( + self, + path: Path, + btrfs_subvols: List[SubvolumeModification], + mount_options: List[str] + ): + info(f'Creating subvolumes: {path}') + + self.mount(path, self._TMP_BTRFS_MOUNT, create_target_mountpoint=True) + + for sub_vol in btrfs_subvols: + debug(f'Creating subvolume: {sub_vol.name}') + + subvol_path = self._TMP_BTRFS_MOUNT / sub_vol.name + + SysCommand(f"btrfs subvolume create {subvol_path}") + + if BtrfsMountOption.nodatacow.value in mount_options: + try: + SysCommand(f'chattr +C {subvol_path}') + except SysCallError as err: + raise DiskError(f'Could not set nodatacow attribute at {subvol_path}: {err}') + + if BtrfsMountOption.compress.value in mount_options: + try: + SysCommand(f'chattr +c {subvol_path}') + except SysCallError as err: + raise DiskError(f'Could not set compress attribute at {subvol_path}: {err}') + + self.umount(path) + def create_btrfs_volumes( self, part_mod: PartitionModification, @@ -468,8 +616,8 @@ class DeviceHandler(object): return luks_handler - def _umount_all_existing(self, device_path: Path): - info(f'Unmounting all existing partitions: {device_path}') + def umount_all_existing(self, device_path: Path): + debug(f'Unmounting all existing partitions: {device_path}') existing_partitions = self._devices[device_path].partition_infos @@ -498,7 +646,7 @@ class DeviceHandler(object): raise DiskError('Too many partitions on disk, MBR disks can only have 3 primary partitions') # make sure all devices are unmounted - self._umount_all_existing(modification.device_path) + self.umount_all_existing(modification.device_path) # WARNING: the entire device will be wiped and all data lost if modification.wipe: diff --git a/archinstall/lib/disk/device_model.py b/archinstall/lib/disk/device_model.py index fe96203c..1cd3d674 100644 --- a/archinstall/lib/disk/device_model.py +++ b/archinstall/lib/disk/device_model.py @@ -41,6 +41,8 @@ class DiskLayoutType(Enum): class DiskLayoutConfiguration: config_type: DiskLayoutType device_modifications: List[DeviceModification] = field(default_factory=list) + lvm_config: Optional[LvmConfiguration] = None + # used for pre-mounted config mountpoint: Optional[Path] = None @@ -51,13 +53,18 @@ class DiskLayoutConfiguration: 'mountpoint': str(self.mountpoint) } else: - return { + config: Dict[str, Any] = { 'config_type': self.config_type.value, - 'device_modifications': [mod.json() for mod in self.device_modifications] + 'device_modifications': [mod.json() for mod in self.device_modifications], } + if self.lvm_config: + config['lvm_config'] = self.lvm_config.json() + + return config + @classmethod - def parse_arg(cls, disk_config: Dict[str, List[Dict[str, Any]]]) -> Optional[DiskLayoutConfiguration]: + def parse_arg(cls, disk_config: Dict[str, Any]) -> Optional[DiskLayoutConfiguration]: from .device_handler import device_handler device_modifications: List[DeviceModification] = [] @@ -124,6 +131,10 @@ class DiskLayoutConfiguration: device_modification.partitions = device_partitions device_modifications.append(device_modification) + # Parse LVM configuration from settings + if (lvm_arg := disk_config.get('lvm_config', None)) is not None: + config.lvm_config = LvmConfiguration.parse_arg(lvm_arg, config) + return config @@ -133,24 +144,24 @@ class PartitionTable(Enum): class Unit(Enum): - B = 1 # byte - kB = 1000**1 # kilobyte - MB = 1000**2 # megabyte - GB = 1000**3 # gigabyte - TB = 1000**4 # terabyte - PB = 1000**5 # petabyte - EB = 1000**6 # exabyte - ZB = 1000**7 # zettabyte - YB = 1000**8 # yottabyte - - KiB = 1024**1 # kibibyte - MiB = 1024**2 # mebibyte - GiB = 1024**3 # gibibyte - TiB = 1024**4 # tebibyte - PiB = 1024**5 # pebibyte - EiB = 1024**6 # exbibyte - ZiB = 1024**7 # zebibyte - YiB = 1024**8 # yobibyte + B = 1 # byte + kB = 1000 ** 1 # kilobyte + MB = 1000 ** 2 # megabyte + GB = 1000 ** 3 # gigabyte + TB = 1000 ** 4 # terabyte + PB = 1000 ** 5 # petabyte + EB = 1000 ** 6 # exabyte + ZB = 1000 ** 7 # zettabyte + YB = 1000 ** 8 # yottabyte + + KiB = 1024 ** 1 # kibibyte + MiB = 1024 ** 2 # mebibyte + GiB = 1024 ** 3 # gibibyte + TiB = 1024 ** 4 # tebibyte + PiB = 1024 ** 5 # pebibyte + EiB = 1024 ** 6 # exbibyte + ZiB = 1024 ** 7 # zebibyte + YiB = 1024 ** 8 # yobibyte sectors = 'sectors' # size in sector @@ -575,7 +586,7 @@ class PartitionFlag(Enum): Which is the way libparted checks for its flags: https://git.savannah.gnu.org/gitweb/?p=parted.git;a=blob;f=libparted/labels/gpt.c;hb=4a0e468ed63fff85a1f9b923189f20945b32f4f1#l183 """ Boot = _ped.PARTITION_BOOT - XBOOTLDR = _ped.PARTITION_BLS_BOOT # Note: parted calls this bls_boot + XBOOTLDR = _ped.PARTITION_BLS_BOOT # Note: parted calls this bls_boot ESP = _ped.PARTITION_ESP @@ -658,6 +669,10 @@ class PartitionModification: flags: List[PartitionFlag] = field(default_factory=list) btrfs_subvols: List[SubvolumeModification] = field(default_factory=list) + # only set when modification was created from an existing + # partition info object to be able to reference it back + part_info: Optional[_PartitionInfo] = None + # only set if the device was created or exists dev_path: Optional[Path] = None partn: Optional[int] = None @@ -724,7 +739,8 @@ class PartitionModification: uuid=partition_info.uuid, flags=partition_info.flags, mountpoint=mountpoint, - btrfs_subvols=subvol_mods + btrfs_subvols=subvol_mods, + part_info=partition_info ) @property @@ -832,6 +848,270 @@ class PartitionModification: return part_mod +class LvmLayoutType(Enum): + Default = 'default' + + # Manual = 'manual_lvm' + + def display_msg(self) -> str: + match self: + case LvmLayoutType.Default: + return str(_('Default layout')) + # case LvmLayoutType.Manual: + # return str(_('Manual configuration')) + + raise ValueError(f'Unknown type: {self}') + + +@dataclass +class LvmVolumeGroup: + name: str + pvs: List[PartitionModification] + volumes: List[LvmVolume] = field(default_factory=list) + + def json(self) -> Dict[str, Any]: + return { + 'name': self.name, + 'lvm_pvs': [p.obj_id for p in self.pvs], + 'volumes': [vol.json() for vol in self.volumes] + } + + @staticmethod + def parse_arg(arg: Dict[str, Any], disk_config: DiskLayoutConfiguration) -> LvmVolumeGroup: + lvm_pvs = [] + for mod in disk_config.device_modifications: + for part in mod.partitions: + if part.obj_id in arg.get('lvm_pvs', []): + lvm_pvs.append(part) + + return LvmVolumeGroup( + arg['name'], + lvm_pvs, + [LvmVolume.parse_arg(vol) for vol in arg['volumes']] + ) + + def contains_lv(self, lv: LvmVolume) -> bool: + return lv in self.volumes + + +class LvmVolumeStatus(Enum): + Exist = 'existing' + Modify = 'modify' + Delete = 'delete' + Create = 'create' + + +@dataclass +class LvmVolume: + status: LvmVolumeStatus + name: str + fs_type: FilesystemType + length: Size + mountpoint: Optional[Path] + mount_options: List[str] = field(default_factory=list) + btrfs_subvols: List[SubvolumeModification] = field(default_factory=list) + + # volume group name + vg_name: Optional[str] = None + # mapper device path /dev// + dev_path: Optional[Path] = None + + def __post_init__(self): + # needed to use the object as a dictionary key due to hash func + if not hasattr(self, '_obj_id'): + self._obj_id = uuid.uuid4() + + def __hash__(self): + return hash(self._obj_id) + + @property + def obj_id(self) -> str: + if hasattr(self, '_obj_id'): + return str(self._obj_id) + return '' + + @property + def mapper_name(self) -> Optional[str]: + if self.dev_path: + return f'{storage.get("ENC_IDENTIFIER", "ai")}{self.safe_dev_path.name}' + return None + + @property + def mapper_path(self) -> Path: + if self.mapper_name: + return Path(f'/dev/mapper/{self.mapper_name}') + + raise ValueError('No mapper path set') + + @property + def safe_dev_path(self) -> Path: + if self.dev_path: + return self.dev_path + raise ValueError('No device path for volume defined') + + @property + def safe_fs_type(self) -> FilesystemType: + if self.fs_type is None: + raise ValueError('File system type is not set') + return self.fs_type + + @property + def relative_mountpoint(self) -> Path: + """ + Will return the relative path based on the anchor + e.g. Path('/mnt/test') -> Path('mnt/test') + """ + if self.mountpoint is not None: + return self.mountpoint.relative_to(self.mountpoint.anchor) + + raise ValueError('Mountpoint is not specified') + + @staticmethod + def parse_arg(arg: Dict[str, Any]) -> LvmVolume: + volume = LvmVolume( + status=LvmVolumeStatus(arg['status']), + name=arg['name'], + fs_type=FilesystemType(arg['fs_type']), + length=Size.parse_args(arg['length']), + mountpoint=Path(arg['mountpoint']) if arg['mountpoint'] else None, + mount_options=arg.get('mount_options', []), + btrfs_subvols=SubvolumeModification.parse_args(arg.get('btrfs', [])) + ) + + setattr(volume, '_obj_id', arg['obj_id']) + + return volume + + def json(self) -> Dict[str, Any]: + return { + 'obj_id': self.obj_id, + 'status': self.status.value, + 'name': self.name, + 'fs_type': self.fs_type.value, + 'length': self.length.json(), + 'mountpoint': str(self.mountpoint) if self.mountpoint else None, + 'mount_options': self.mount_options, + 'btrfs': [vol.json() for vol in self.btrfs_subvols] + } + + def table_data(self) -> Dict[str, Any]: + part_mod = { + 'Type': self.status.value, + 'Name': self.name, + 'Size': self.length.format_highest(), + 'FS type': self.fs_type.value, + 'Mountpoint': str(self.mountpoint) if self.mountpoint else '', + 'Mount options': ', '.join(self.mount_options), + 'Btrfs': '{} {}'.format(str(len(self.btrfs_subvols)), 'vol') + } + return part_mod + + def is_modify(self) -> bool: + return self.status == LvmVolumeStatus.Modify + + def exists(self) -> bool: + return self.status == LvmVolumeStatus.Exist + + def is_exists_or_modify(self) -> bool: + return self.status in [LvmVolumeStatus.Exist, LvmVolumeStatus.Modify] + + def is_root(self) -> bool: + if self.mountpoint is not None: + return Path('/') == self.mountpoint + else: + for subvol in self.btrfs_subvols: + if subvol.is_root(): + return True + + return False + + +@dataclass +class LvmGroupInfo: + vg_size: Size + vg_uuid: str + + +@dataclass +class LvmVolumeInfo: + lv_name: str + vg_name: str + lv_size: Size + + +@dataclass +class LvmPVInfo: + pv_name: Path + lv_name: str + vg_name: str + + +@dataclass +class LvmConfiguration: + config_type: LvmLayoutType + vol_groups: List[LvmVolumeGroup] + + def __post_init__(self): + # make sure all volume groups have unique PVs + pvs = [] + for group in self.vol_groups: + for pv in group.pvs: + if pv in pvs: + raise ValueError('A PV cannot be used in multiple volume groups') + pvs.append(pv) + + def json(self) -> Dict[str, Any]: + return { + 'config_type': self.config_type.value, + 'vol_groups': [vol_gr.json() for vol_gr in self.vol_groups] + } + + @staticmethod + def parse_arg(arg: Dict[str, Any], disk_config: DiskLayoutConfiguration) -> LvmConfiguration: + lvm_pvs = [] + for mod in disk_config.device_modifications: + for part in mod.partitions: + if part.obj_id in arg.get('lvm_pvs', []): + lvm_pvs.append(part) + + return LvmConfiguration( + config_type=LvmLayoutType(arg['config_type']), + vol_groups=[LvmVolumeGroup.parse_arg(vol_group, disk_config) for vol_group in arg['vol_groups']], + ) + + def get_all_pvs(self) -> List[PartitionModification]: + pvs = [] + for vg in self.vol_groups: + pvs += vg.pvs + + return pvs + + def get_all_volumes(self) -> List[LvmVolume]: + volumes = [] + + for vg in self.vol_groups: + volumes += vg.volumes + + return volumes + + def get_root_volume(self) -> Optional[LvmVolume]: + for vg in self.vol_groups: + filtered = next(filter(lambda x: x.is_root(), vg.volumes), None) + if filtered: + return filtered + + return None + + +# def get_lv_crypt_uuid(self, lv: LvmVolume, encryption: EncryptionType) -> str: +# """ +# Find the LUKS superblock UUID for the device that +# contains the given logical volume +# """ +# for vg in self.vol_groups: +# if vg.contains_lv(lv): + + @dataclass class DeviceModification: device: BDevice @@ -885,11 +1165,16 @@ class DeviceModification: class EncryptionType(Enum): NoEncryption = "no_encryption" Luks = "luks" + LvmOnLuks = 'lvm_on_luks' + LuksOnLvm = 'luks_on_lvm' @classmethod def _encryption_type_mapper(cls) -> Dict[str, 'EncryptionType']: return { - 'Luks': EncryptionType.Luks + str(_('No Encryption')): EncryptionType.NoEncryption, + str(_('LUKS')): EncryptionType.Luks, + str(_('LVM on LUKS')): EncryptionType.LvmOnLuks, + str(_('LUKS on LVM')): EncryptionType.LuksOnLvm } @classmethod @@ -906,18 +1191,31 @@ class EncryptionType(Enum): @dataclass class DiskEncryption: - encryption_type: EncryptionType = EncryptionType.Luks + encryption_type: EncryptionType = EncryptionType.NoEncryption encryption_password: str = '' partitions: List[PartitionModification] = field(default_factory=list) + lvm_volumes: List[LvmVolume] = field(default_factory=list) hsm_device: Optional[Fido2Device] = None - def should_generate_encryption_file(self, part_mod: PartitionModification) -> bool: - return part_mod in self.partitions and part_mod.mountpoint != Path('/') + def __post_init__(self): + if self.encryption_type in [EncryptionType.Luks, EncryptionType.LvmOnLuks] and not self.partitions: + raise ValueError('Luks or LvmOnLuks encryption require partitions to be defined') + + if self.encryption_type == EncryptionType.LuksOnLvm and not self.lvm_volumes: + raise ValueError('LuksOnLvm encryption require LMV volumes to be defined') + + def should_generate_encryption_file(self, dev: PartitionModification | LvmVolume) -> bool: + if isinstance(dev, PartitionModification): + return dev in self.partitions and dev.mountpoint != Path('/') + elif isinstance(dev, LvmVolume): + return dev in self.lvm_volumes and dev.mountpoint != Path('/') + return False def json(self) -> Dict[str, Any]: obj: Dict[str, Any] = { 'encryption_type': self.encryption_type.value, - 'partitions': [p.obj_id for p in self.partitions] + 'partitions': [p.obj_id for p in self.partitions], + 'lvm_volumes': [vol.obj_id for vol in self.lvm_volumes] } if self.hsm_device: @@ -925,23 +1223,47 @@ class DiskEncryption: return obj + @classmethod + def validate_enc(cls, disk_config: DiskLayoutConfiguration) -> bool: + partitions = [] + + for mod in disk_config.device_modifications: + for part in mod.partitions: + partitions.append(part) + + if len(partitions) > 2: # assume one boot and at least 2 additional + if disk_config.lvm_config: + return False + + return True + @classmethod def parse_arg( cls, disk_config: DiskLayoutConfiguration, arg: Dict[str, Any], password: str = '' - ) -> 'DiskEncryption': + ) -> Optional['DiskEncryption']: + if not cls.validate_enc(disk_config): + return None + enc_partitions = [] for mod in disk_config.device_modifications: for part in mod.partitions: if part.obj_id in arg.get('partitions', []): enc_partitions.append(part) + volumes = [] + if disk_config.lvm_config: + for vol in disk_config.lvm_config.get_all_volumes(): + if vol.obj_id in arg.get('lvm_volumes', []): + volumes.append(vol) + enc = DiskEncryption( EncryptionType(arg['encryption_type']), password, - enc_partitions + enc_partitions, + volumes ) if hsm := arg.get('hsm_device', None): @@ -992,7 +1314,7 @@ class LsblkInfo: tran: Optional[str] = None partn: Optional[int] = None partuuid: Optional[str] = None - parttype :Optional[str] = None + parttype: Optional[str] = None uuid: Optional[str] = None fstype: Optional[str] = None fsver: Optional[str] = None @@ -1017,7 +1339,7 @@ class LsblkInfo: 'tran': self.tran, 'partn': self.partn, 'partuuid': self.partuuid, - 'parttype' : self.parttype, + 'parttype': self.parttype, 'uuid': self.uuid, 'fstype': self.fstype, 'fsver': self.fsver, @@ -1102,13 +1424,24 @@ def _clean_field(name: str, clean_type: CleanType) -> str: return name.replace('_percentage', '%').replace('_', '-') -def _fetch_lsblk_info(dev_path: Optional[Union[Path, str]] = None) -> List[LsblkInfo]: +def _fetch_lsblk_info( + dev_path: Optional[Union[Path, str]] = None, + reverse: bool = False, + full_dev_path: bool = False, + retry: int = 3 +) -> List[LsblkInfo]: fields = [_clean_field(f, CleanType.Lsblk) for f in LsblkInfo.fields()] cmd = ['lsblk', '--json', '--bytes', '--output', '+' + ','.join(fields)] if dev_path: cmd.append(str(dev_path)) + if reverse: + cmd.append('--inverse') + + if full_dev_path: + cmd.append('--paths') + try: result = SysCommand(cmd).decode() except SysCallError as err: @@ -1132,8 +1465,12 @@ def _fetch_lsblk_info(dev_path: Optional[Union[Path, str]] = None) -> List[Lsblk return [LsblkInfo.from_json(device) for device in blockdevices] -def get_lsblk_info(dev_path: Union[Path, str]) -> LsblkInfo: - if infos := _fetch_lsblk_info(dev_path): +def get_lsblk_info( + dev_path: Union[Path, str], + reverse: bool = False, + full_dev_path: bool = False +) -> LsblkInfo: + if infos := _fetch_lsblk_info(dev_path, reverse=reverse, full_dev_path=full_dev_path): return infos[0] raise DiskError(f'lsblk failed to retrieve information for "{dev_path}"') @@ -1142,6 +1479,7 @@ def get_lsblk_info(dev_path: Union[Path, str]) -> LsblkInfo: def get_all_lsblk_info() -> List[LsblkInfo]: return _fetch_lsblk_info() + def get_lsblk_by_mountpoint(mountpoint: Path, as_prefix: bool = False) -> List[LsblkInfo]: def _check(infos: List[LsblkInfo]) -> List[LsblkInfo]: devices = [] diff --git a/archinstall/lib/disk/disk_menu.py b/archinstall/lib/disk/disk_menu.py new file mode 100644 index 00000000..a7d9ccc3 --- /dev/null +++ b/archinstall/lib/disk/disk_menu.py @@ -0,0 +1,140 @@ +from typing import Dict, Optional, Any, TYPE_CHECKING, List + +from . import DiskLayoutConfiguration, DiskLayoutType +from .device_model import LvmConfiguration +from ..disk import ( + DeviceModification +) +from ..interactions import select_disk_config +from ..interactions.disk_conf import select_lvm_config +from ..menu import ( + Selector, + AbstractSubMenu +) +from ..output import FormattedOutput + +if TYPE_CHECKING: + _: Any + + +class DiskLayoutConfigurationMenu(AbstractSubMenu): + def __init__( + self, + disk_layout_config: Optional[DiskLayoutConfiguration], + data_store: Dict[str, Any], + advanced: bool = False + ): + self._disk_layout_config = disk_layout_config + self._advanced = advanced + + super().__init__(data_store=data_store, preview_size=0.5) + + def setup_selection_menu_options(self): + self._menu_options['disk_config'] = \ + Selector( + _('Partitioning'), + lambda x: self._select_disk_layout_config(x), + display_func=lambda x: self._display_disk_layout(x), + preview_func=self._prev_disk_layouts, + default=self._disk_layout_config, + enabled=True + ) + self._menu_options['lvm_config'] = \ + Selector( + _('Logical Volume Management (LVM)'), + lambda x: self._select_lvm_config(x), + display_func=lambda x: self.defined_text if x else '', + preview_func=self._prev_lvm_config, + default=self._disk_layout_config.lvm_config if self._disk_layout_config else None, + dependencies=[self._check_dep_lvm], + enabled=True + ) + + def run(self, allow_reset: bool = True) -> Optional[DiskLayoutConfiguration]: + super().run(allow_reset=allow_reset) + + disk_layout_config: Optional[DiskLayoutConfiguration] = self._data_store.get('disk_config', None) + + if disk_layout_config: + disk_layout_config.lvm_config = self._data_store.get('lvm_config', None) + + return disk_layout_config + + def _check_dep_lvm(self) -> bool: + disk_layout_conf: Optional[DiskLayoutConfiguration] = self._menu_options['disk_config'].current_selection + + if disk_layout_conf and disk_layout_conf.config_type == DiskLayoutType.Default: + return True + + return False + + def _select_disk_layout_config( + self, + preset: Optional[DiskLayoutConfiguration] + ) -> Optional[DiskLayoutConfiguration]: + disk_config = select_disk_config(preset, advanced_option=self._advanced) + + if disk_config != preset: + self._menu_options['lvm_config'].set_current_selection(None) + + return disk_config + + def _select_lvm_config(self, preset: Optional[LvmConfiguration]) -> Optional[LvmConfiguration]: + disk_config: Optional[DiskLayoutConfiguration] = self._menu_options['disk_config'].current_selection + if disk_config: + return select_lvm_config(disk_config, preset=preset) + return preset + + def _display_disk_layout(self, current_value: Optional[DiskLayoutConfiguration] = None) -> str: + if current_value: + return current_value.config_type.display_msg() + return '' + + def _prev_disk_layouts(self) -> Optional[str]: + disk_layout_conf: Optional[DiskLayoutConfiguration] = self._menu_options['disk_config'].current_selection + + if disk_layout_conf: + device_mods: List[DeviceModification] = \ + list(filter(lambda x: len(x.partitions) > 0, disk_layout_conf.device_modifications)) + + if device_mods: + output_partition = '{}: {}\n'.format(str(_('Configuration')), disk_layout_conf.config_type.display_msg()) + output_btrfs = '' + + for mod in device_mods: + # create partition table + partition_table = FormattedOutput.as_table(mod.partitions) + + output_partition += f'{mod.device_path}: {mod.device.device_info.model}\n' + output_partition += partition_table + '\n' + + # create btrfs table + btrfs_partitions = list( + filter(lambda p: len(p.btrfs_subvols) > 0, mod.partitions) + ) + for partition in btrfs_partitions: + output_btrfs += FormattedOutput.as_table(partition.btrfs_subvols) + '\n' + + output = output_partition + output_btrfs + return output.rstrip() + + return None + + def _prev_lvm_config(self) -> Optional[str]: + lvm_config: Optional[LvmConfiguration] = self._menu_options['lvm_config'].current_selection + + if lvm_config: + output = '{}: {}\n'.format(str(_('Configuration')), lvm_config.config_type.display_msg()) + + for vol_gp in lvm_config.vol_groups: + pv_table = FormattedOutput.as_table(vol_gp.pvs) + output += '{}:\n{}'.format(str(_('Physical volumes')), pv_table) + + output += f'\nVolume Group: {vol_gp.name}' + + lvm_volumes = FormattedOutput.as_table(vol_gp.volumes) + output += '\n\n{}:\n{}'.format(str(_('Volumes')), lvm_volumes) + + return output + + return None diff --git a/archinstall/lib/disk/encryption_menu.py b/archinstall/lib/disk/encryption_menu.py index c3a1c32f..b0e292ce 100644 --- a/archinstall/lib/disk/encryption_menu.py +++ b/archinstall/lib/disk/encryption_menu.py @@ -1,6 +1,7 @@ from pathlib import Path from typing import Dict, Optional, Any, TYPE_CHECKING, List +from . import LvmConfiguration, LvmVolume from ..disk import ( DeviceModification, DiskLayoutConfiguration, @@ -40,31 +41,41 @@ class DiskEncryptionMenu(AbstractSubMenu): super().__init__(data_store=data_store) def setup_selection_menu_options(self): + self._menu_options['encryption_type'] = \ + Selector( + _('Encryption type'), + func=lambda preset: select_encryption_type(self._disk_config, preset), + display_func=lambda x: EncryptionType.type_to_text(x) if x else None, + default=self._preset.encryption_type, + enabled=True, + ) self._menu_options['encryption_password'] = \ Selector( _('Encryption password'), lambda x: select_encrypted_password(), + dependencies=[self._check_dep_enc_type], display_func=lambda x: secret(x) if x else '', default=self._preset.encryption_password, enabled=True ) - self._menu_options['encryption_type'] = \ - Selector( - _('Encryption type'), - func=lambda preset: select_encryption_type(preset), - display_func=lambda x: EncryptionType.type_to_text(x) if x else None, - dependencies=['encryption_password'], - default=self._preset.encryption_type, - enabled=True - ) self._menu_options['partitions'] = \ Selector( _('Partitions'), func=lambda preset: select_partitions_to_encrypt(self._disk_config.device_modifications, preset), display_func=lambda x: f'{len(x)} {_("Partitions")}' if x else None, - dependencies=['encryption_password'], + dependencies=[self._check_dep_partitions], default=self._preset.partitions, - preview_func=self._prev_disk_layouts, + preview_func=self._prev_partitions, + enabled=True + ) + self._menu_options['lvm_vols'] = \ + Selector( + _('LVM volumes'), + func=lambda preset: self._select_lvm_vols(preset), + display_func=lambda x: f'{len(x)} {_("LVM volumes")}' if x else None, + dependencies=[self._check_dep_lvm_vols], + default=self._preset.lvm_volumes, + preview_func=self._prev_lvm_vols, enabled=True ) self._menu_options['HSM'] = \ @@ -73,19 +84,54 @@ class DiskEncryptionMenu(AbstractSubMenu): func=lambda preset: select_hsm(preset), display_func=lambda x: self._display_hsm(x), preview_func=self._prev_hsm, - dependencies=['encryption_password'], + dependencies=[self._check_dep_enc_type], default=self._preset.hsm_device, enabled=True ) + def _select_lvm_vols(self, preset: List[LvmVolume]) -> List[LvmVolume]: + if self._disk_config.lvm_config: + return select_lvm_vols_to_encrypt(self._disk_config.lvm_config, preset=preset) + return [] + + def _check_dep_enc_type(self) -> bool: + enc_type: Optional[EncryptionType] = self._menu_options['encryption_type'].current_selection + if enc_type and enc_type != EncryptionType.NoEncryption: + return True + return False + + def _check_dep_partitions(self) -> bool: + enc_type: Optional[EncryptionType] = self._menu_options['encryption_type'].current_selection + if enc_type and enc_type in [EncryptionType.Luks, EncryptionType.LvmOnLuks]: + return True + return False + + def _check_dep_lvm_vols(self) -> bool: + enc_type: Optional[EncryptionType] = self._menu_options['encryption_type'].current_selection + if enc_type and enc_type == EncryptionType.LuksOnLvm: + return True + return False + def run(self, allow_reset: bool = True) -> Optional[DiskEncryption]: super().run(allow_reset=allow_reset) - if self._data_store.get('encryption_password', None): + enc_type = self._data_store.get('encryption_type', None) + enc_password = self._data_store.get('encryption_password', None) + enc_partitions = self._data_store.get('partitions', None) + enc_lvm_vols = self._data_store.get('lvm_vols', None) + + if enc_type in [EncryptionType.Luks, EncryptionType.LvmOnLuks] and enc_partitions: + enc_lvm_vols = [] + + if enc_type == EncryptionType.LuksOnLvm: + enc_partitions = [] + + if enc_type != EncryptionType.NoEncryption and enc_password and (enc_partitions or enc_lvm_vols): return DiskEncryption( - encryption_password=self._data_store.get('encryption_password', None), - encryption_type=self._data_store['encryption_type'], - partitions=self._data_store.get('partitions', None), + encryption_password=enc_password, + encryption_type=enc_type, + partitions=enc_partitions, + lvm_volumes=enc_lvm_vols, hsm_device=self._data_store.get('HSM', None) ) @@ -97,7 +143,7 @@ class DiskEncryptionMenu(AbstractSubMenu): return None - def _prev_disk_layouts(self) -> Optional[str]: + def _prev_partitions(self) -> Optional[str]: partitions: Optional[List[PartitionModification]] = self._menu_options['partitions'].current_selection if partitions: output = str(_('Partitions to be encrypted')) + '\n' @@ -106,6 +152,15 @@ class DiskEncryptionMenu(AbstractSubMenu): return None + def _prev_lvm_vols(self) -> Optional[str]: + volumes: Optional[List[PartitionModification]] = self._menu_options['lvm_vols'].current_selection + if volumes: + output = str(_('LVM volumes to be encrypted')) + '\n' + output += FormattedOutput.as_table(volumes) + return output.rstrip() + + return None + def _prev_hsm(self) -> Optional[str]: try: Fido2.get_fido2_devices() @@ -123,13 +178,19 @@ class DiskEncryptionMenu(AbstractSubMenu): return None -def select_encryption_type(preset: EncryptionType) -> Optional[EncryptionType]: +def select_encryption_type(disk_config: DiskLayoutConfiguration, preset: EncryptionType) -> Optional[EncryptionType]: title = str(_('Select disk encryption option')) - options = [ - EncryptionType.type_to_text(EncryptionType.Luks) - ] + + if disk_config.lvm_config: + options = [ + EncryptionType.type_to_text(EncryptionType.LvmOnLuks), + EncryptionType.type_to_text(EncryptionType.LuksOnLvm) + ] + else: + options = [EncryptionType.type_to_text(EncryptionType.Luks)] preset_value = EncryptionType.type_to_text(preset) + choice = Menu(title, options, preset_values=preset_value).run() match choice.type_: @@ -197,3 +258,31 @@ def select_partitions_to_encrypt( case MenuSelectionType.Selection: return choice.multi_value return [] + + +def select_lvm_vols_to_encrypt( + lvm_config: LvmConfiguration, + preset: List[LvmVolume] +) -> List[LvmVolume]: + volumes: List[LvmVolume] = lvm_config.get_all_volumes() + + if volumes: + title = str(_('Select which LVM volumes to encrypt')) + partition_table = FormattedOutput.as_table(volumes) + + choice = TableMenu( + title, + table_data=(volumes, partition_table), + preset=preset, + multi=True + ).run() + + match choice.type_: + case MenuSelectionType.Reset: + return [] + case MenuSelectionType.Skip: + return preset + case MenuSelectionType.Selection: + return choice.multi_value + + return [] diff --git a/archinstall/lib/disk/fido.py b/archinstall/lib/disk/fido.py index 49904c17..5a139534 100644 --- a/archinstall/lib/disk/fido.py +++ b/archinstall/lib/disk/fido.py @@ -4,7 +4,7 @@ import getpass from pathlib import Path from typing import List -from .device_model import PartitionModification, Fido2Device +from .device_model import Fido2Device from ..general import SysCommand, SysCommandWorker, clear_vt100_escape_codes from ..output import error, info from ..exceptions import SysCallError @@ -72,16 +72,16 @@ class Fido2: def fido2_enroll( cls, hsm_device: Fido2Device, - part_mod: PartitionModification, + dev_path: Path, password: str ): - worker = SysCommandWorker(f"systemd-cryptenroll --fido2-device={hsm_device.path} {part_mod.dev_path}", peek_output=True) + worker = SysCommandWorker(f"systemd-cryptenroll --fido2-device={hsm_device.path} {dev_path}", peek_output=True) pw_inputted = False pin_inputted = False while worker.is_alive(): if pw_inputted is False: - if bytes(f"please enter current passphrase for disk {part_mod.dev_path}", 'UTF-8') in worker._trace_log.lower(): + if bytes(f"please enter current passphrase for disk {dev_path}", 'UTF-8') in worker._trace_log.lower(): worker.write(bytes(password, 'UTF-8')) pw_inputted = True elif pin_inputted is False: diff --git a/archinstall/lib/disk/filesystem.py b/archinstall/lib/disk/filesystem.py index 9c6e6d35..5c11896e 100644 --- a/archinstall/lib/disk/filesystem.py +++ b/archinstall/lib/disk/filesystem.py @@ -3,13 +3,21 @@ from __future__ import annotations import signal import sys import time -from typing import Any, Optional, TYPE_CHECKING +from pathlib import Path +from typing import Any, Optional, TYPE_CHECKING, List, Dict, Set -from .device_model import DiskLayoutConfiguration, DiskLayoutType, PartitionTable, FilesystemType, DiskEncryption from .device_handler import device_handler +from .device_model import ( + DiskLayoutConfiguration, DiskLayoutType, PartitionTable, + FilesystemType, DiskEncryption, LvmVolumeGroup, + Size, Unit, SectorSize, PartitionModification, EncryptionType, + LvmVolume, LvmConfiguration +) from ..hardware import SysInfo -from ..output import debug +from ..luks import Luks2 from ..menu import Menu +from ..output import debug, info +from ..general import SysCommand if TYPE_CHECKING: _: Any @@ -52,13 +60,288 @@ class FilesystemHandler: for mod in device_mods: device_handler.partition(mod, partition_table=partition_table) - device_handler.format(mod, enc_conf=self._enc_config) - for part_mod in mod.partitions: - if part_mod.is_create_or_modify(): + if self._disk_config.lvm_config: + for mod in device_mods: + if boot_part := mod.get_boot_partition(): + debug(f'Formatting boot partition: {boot_part.dev_path}') + self._format_partitions( + [boot_part], + mod.device_path + ) + + self.perform_lvm_operations() + else: + for mod in device_mods: + self._format_partitions( + mod.partitions, + mod.device_path + ) + + for part_mod in mod.partitions: if part_mod.fs_type == FilesystemType.Btrfs: device_handler.create_btrfs_volumes(part_mod, enc_conf=self._enc_config) + def _format_partitions( + self, + partitions: List[PartitionModification], + device_path: Path + ): + """ + Format can be given an overriding path, for instance /dev/null to test + the formatting functionality and in essence the support for the given filesystem. + """ + + # don't touch existing partitions + create_or_modify_parts = [p for p in partitions if p.is_create_or_modify()] + + self._validate_partitions(create_or_modify_parts) + + # make sure all devices are unmounted + device_handler.umount_all_existing(device_path) + + for part_mod in create_or_modify_parts: + # partition will be encrypted + if self._enc_config is not None and part_mod in self._enc_config.partitions: + device_handler.format_encrypted( + part_mod.safe_dev_path, + part_mod.mapper_name, + part_mod.safe_fs_type, + self._enc_config + ) + else: + device_handler.format(part_mod.safe_fs_type, part_mod.safe_dev_path) + + # synchronize with udev before using lsblk + SysCommand('udevadm settle') + + lsblk_info = device_handler.fetch_part_info(part_mod.safe_dev_path) + + part_mod.partn = lsblk_info.partn + part_mod.partuuid = lsblk_info.partuuid + part_mod.uuid = lsblk_info.uuid + + def _validate_partitions(self, partitions: List[PartitionModification]): + checks = { + # verify that all partitions have a path set (which implies that they have been created) + lambda x: x.dev_path is None: ValueError('When formatting, all partitions must have a path set'), + # crypto luks is not a valid file system type + lambda x: x.fs_type is FilesystemType.Crypto_luks: ValueError( + 'Crypto luks cannot be set as a filesystem type'), + # file system type must be set + lambda x: x.fs_type is None: ValueError('File system type must be set for modification') + } + + for check, exc in checks.items(): + found = next(filter(check, partitions), None) + if found is not None: + raise exc + + def perform_lvm_operations(self): + info('Setting up LVM config...') + + if not self._disk_config.lvm_config: + return + + if self._enc_config: + self._setup_lvm_encrypted( + self._disk_config.lvm_config, + self._enc_config + ) + else: + self._setup_lvm(self._disk_config.lvm_config) + self._format_lvm_vols(self._disk_config.lvm_config) + + def _setup_lvm_encrypted(self, lvm_config: LvmConfiguration, enc_config: DiskEncryption): + if enc_config.encryption_type == EncryptionType.LvmOnLuks: + enc_mods = self._encrypt_partitions(enc_config, lock_after_create=False) + + self._setup_lvm(lvm_config, enc_mods) + self._format_lvm_vols(lvm_config) + + # export the lvm group safely otherwise the Luks cannot be closed + self._safely_close_lvm(lvm_config) + + for luks in enc_mods.values(): + luks.lock() + elif enc_config.encryption_type == EncryptionType.LuksOnLvm: + self._setup_lvm(lvm_config) + enc_vols = self._encrypt_lvm_vols(lvm_config, enc_config, False) + self._format_lvm_vols(lvm_config, enc_vols) + + for luks in enc_vols.values(): + luks.lock() + + self._safely_close_lvm(lvm_config) + + def _safely_close_lvm(self, lvm_config: LvmConfiguration): + for vg in lvm_config.vol_groups: + for vol in vg.volumes: + device_handler.lvm_vol_change(vol, False) + + device_handler.lvm_export_vg(vg) + + def _setup_lvm( + self, + lvm_config: LvmConfiguration, + enc_mods: Dict[PartitionModification, Luks2] = {} + ): + self._lvm_create_pvs(lvm_config, enc_mods) + + for vg in lvm_config.vol_groups: + pv_dev_paths = self._get_all_pv_dev_paths(vg.pvs, enc_mods) + + device_handler.lvm_vg_create(pv_dev_paths, vg.name) + + # figure out what the actual available size in the group is + vg_info = device_handler.lvm_group_info(vg.name) + + if not vg_info: + raise ValueError('Unable to fetch VG info') + + # the actual available LVM Group size will be smaller than the + # total PVs size due to reserved metadata storage etc. + # so we'll have a look at the total avail. size, check the delta + # to the desired sizes and subtract some equally from the actually + # created volume + avail_size = vg_info.vg_size + desired_size = sum([vol.length for vol in vg.volumes], Size(0, Unit.B, SectorSize.default())) + + delta = desired_size - avail_size + max_vol_offset = delta.convert(Unit.B) + + max_vol = max(vg.volumes, key=lambda x: x.length) + + for lv in vg.volumes: + offset = max_vol_offset if lv == max_vol else None + + debug(f'vg: {vg.name}, vol: {lv.name}, offset: {offset}') + device_handler.lvm_vol_create(vg.name, lv, offset) + + while True: + debug('Fetching LVM volume info') + lv_info = device_handler.lvm_vol_info(lv.name) + if lv_info is not None: + break + + time.sleep(1) + + self._lvm_vol_handle_e2scrub(vg) + + def _format_lvm_vols( + self, + lvm_config: LvmConfiguration, + enc_vols: Dict[LvmVolume, Luks2] = {} + ): + for vol in lvm_config.get_all_volumes(): + if enc_vol := enc_vols.get(vol, None): + if not enc_vol.mapper_dev: + raise ValueError('No mapper device defined') + path = enc_vol.mapper_dev + else: + path = vol.safe_dev_path + + # wait a bit otherwise the mkfs will fail as it can't + # find the mapper device yet + device_handler.format(vol.fs_type, path) + + if vol.fs_type == FilesystemType.Btrfs: + device_handler.create_lvm_btrfs_subvolumes(path, vol.btrfs_subvols, vol.mount_options) + + def _lvm_create_pvs( + self, + lvm_config: LvmConfiguration, + enc_mods: Dict[PartitionModification, Luks2] = {} + ): + pv_paths: Set[Path] = set() + + for vg in lvm_config.vol_groups: + pv_paths |= self._get_all_pv_dev_paths(vg.pvs, enc_mods) + + device_handler.lvm_pv_create(pv_paths) + + def _get_all_pv_dev_paths( + self, + pvs: List[PartitionModification], + enc_mods: Dict[PartitionModification, Luks2] = {} + ) -> Set[Path]: + pv_paths: Set[Path] = set() + + for pv in pvs: + if enc_pv := enc_mods.get(pv, None): + if mapper := enc_pv.mapper_dev: + pv_paths.add(mapper) + else: + pv_paths.add(pv.safe_dev_path) + + return pv_paths + + def _encrypt_lvm_vols( + self, + lvm_config: LvmConfiguration, + enc_config: DiskEncryption, + lock_after_create: bool = True + ) -> Dict[LvmVolume, Luks2]: + enc_vols: Dict[LvmVolume, Luks2] = {} + + for vol in lvm_config.get_all_volumes(): + if vol in enc_config.lvm_volumes: + luks_handler = device_handler.encrypt( + vol.safe_dev_path, + vol.mapper_name, + enc_config.encryption_password, + lock_after_create + ) + + enc_vols[vol] = luks_handler + + return enc_vols + + def _encrypt_partitions( + self, + enc_config: DiskEncryption, + lock_after_create: bool = True + ) -> Dict[PartitionModification, Luks2]: + enc_mods: Dict[PartitionModification, Luks2] = {} + + for mod in self._disk_config.device_modifications: + partitions = mod.partitions + + # don't touch existing partitions + filtered_part = [p for p in partitions if not p.exists()] + + self._validate_partitions(filtered_part) + + # make sure all devices are unmounted + device_handler.umount_all_existing(mod.device_path) + + enc_mods = {} + + for part_mod in filtered_part: + if part_mod in enc_config.partitions: + luks_handler = device_handler.encrypt( + part_mod.safe_dev_path, + part_mod.mapper_name, + enc_config.encryption_password, + lock_after_create=lock_after_create + ) + + enc_mods[part_mod] = luks_handler + + return enc_mods + + def _lvm_vol_handle_e2scrub(self, vol_gp: LvmVolumeGroup): + # from arch wiki: + # If a logical volume will be formatted with ext4, leave at least 256 MiB + # free space in the volume group to allow using e2scrub + if any([vol.fs_type == FilesystemType.Ext4 for vol in vol_gp.volumes]): + largest_vol = max(vol_gp.volumes, key=lambda x: x.length) + + device_handler.lvm_vol_reduce( + largest_vol.safe_dev_path, + Size(256, Unit.MiB, SectorSize.default()) + ) + def _do_countdown(self) -> bool: SIG_TRIGGER = False diff --git a/archinstall/lib/disk/partitioning_menu.py b/archinstall/lib/disk/partitioning_menu.py index 823605e3..330f61a3 100644 --- a/archinstall/lib/disk/partitioning_menu.py +++ b/archinstall/lib/disk/partitioning_menu.py @@ -2,7 +2,7 @@ from __future__ import annotations import re from pathlib import Path -from typing import Any, Dict, TYPE_CHECKING, List, Optional, Tuple +from typing import Any, TYPE_CHECKING, List, Optional, Tuple from .device_model import PartitionModification, FilesystemType, BDevice, Size, Unit, PartitionType, PartitionFlag, \ ModificationStatus, DeviceGeometry, SectorSize, BtrfsMountOption @@ -38,21 +38,6 @@ class PartitioningList(ListManager): display_actions = list(self._actions.values()) super().__init__(prompt, device_partitions, display_actions[:2], display_actions[3:]) - def reformat(self, data: List[PartitionModification]) -> Dict[str, Optional[PartitionModification]]: - table = FormattedOutput.as_table(data) - rows = table.split('\n') - - # these are the header rows of the table and do not map to any User obviously - # we're adding 2 spaces as prefix because the menu selector '> ' will be put before - # the selectable rows so the header has to be aligned - display_data: Dict[str, Optional[PartitionModification]] = {f' {rows[0]}': None, f' {rows[1]}': None} - - for row, user in zip(rows[2:], data): - row = row.replace('|', '\\|') - display_data[row] = user - - return display_data - def selected_action_display(self, partition: PartitionModification) -> str: return str(_('Partition')) @@ -258,7 +243,6 @@ class PartitioningList(ListManager): while True: value = TextInput(prompt).run().strip() size: Optional[Size] = None - if not value: size = default else: diff --git a/archinstall/lib/disk/subvolume_menu.py b/archinstall/lib/disk/subvolume_menu.py index 48afa829..ea77149d 100644 --- a/archinstall/lib/disk/subvolume_menu.py +++ b/archinstall/lib/disk/subvolume_menu.py @@ -1,9 +1,8 @@ from pathlib import Path -from typing import Dict, List, Optional, Any, TYPE_CHECKING +from typing import List, Optional, Any, TYPE_CHECKING from .device_model import SubvolumeModification from ..menu import TextInput, ListManager -from ..output import FormattedOutput if TYPE_CHECKING: _: Any @@ -18,21 +17,6 @@ class SubvolumeMenu(ListManager): ] super().__init__(prompt, btrfs_subvols, [self._actions[0]], self._actions[1:]) - def reformat(self, data: List[SubvolumeModification]) -> Dict[str, Optional[SubvolumeModification]]: - table = FormattedOutput.as_table(data) - rows = table.split('\n') - - # these are the header rows of the table and do not map to any User obviously - # we're adding 2 spaces as prefix because the menu selector '> ' will be put before - # the selectable rows so the header has to be aligned - display_data: Dict[str, Optional[SubvolumeModification]] = {f' {rows[0]}': None, f' {rows[1]}': None} - - for row, subvol in zip(rows[2:], data): - row = row.replace('|', '\\|') - display_data[row] = subvol - - return display_data - def selected_action_display(self, subvolume: SubvolumeModification) -> str: return str(subvolume.name) diff --git a/archinstall/lib/global_menu.py b/archinstall/lib/global_menu.py index e65915db..1b5e779b 100644 --- a/archinstall/lib/global_menu.py +++ b/archinstall/lib/global_menu.py @@ -14,7 +14,6 @@ from .models.audio_configuration import Audio, AudioConfiguration from .models.users import User from .output import FormattedOutput from .profile.profile_menu import ProfileConfiguration -from .storage import storage from .configuration import save_config from .interactions import add_number_of_parallel_downloads from .interactions import ask_additional_packages_to_install @@ -30,7 +29,6 @@ from .interactions import select_additional_repositories from .interactions import select_kernel from .utils.util import format_cols from .interactions import ask_ntp -from .interactions.disk_conf import select_disk_config if TYPE_CHECKING: _: Any @@ -38,7 +36,6 @@ if TYPE_CHECKING: class GlobalMenu(AbstractMenu): def __init__(self, data_store: Dict[str, Any]): - self._defined_text = str(_('Defined')) super().__init__(data_store=data_store, auto_cursor=True, preview_size=0.3) def setup_selection_menu_options(self): @@ -54,20 +51,20 @@ class GlobalMenu(AbstractMenu): _('Locales'), lambda preset: self._locale_selection(preset), preview_func=self._prev_locale, - display_func=lambda x: self._defined_text if x else '') + display_func=lambda x: self.defined_text if x else '') self._menu_options['mirror_config'] = \ Selector( _('Mirrors'), lambda preset: self._mirror_configuration(preset), - display_func=lambda x: self._defined_text if x else '', + display_func=lambda x: self.defined_text if x else '', preview_func=self._prev_mirror_config ) self._menu_options['disk_config'] = \ Selector( _('Disk configuration'), lambda preset: self._select_disk_config(preset), - preview_func=self._prev_disk_layouts, - display_func=lambda x: self._display_disk_layout(x), + preview_func=self._prev_disk_config, + display_func=lambda x: self.defined_text if x else '', ) self._menu_options['disk_encryption'] = \ Selector( @@ -75,7 +72,8 @@ class GlobalMenu(AbstractMenu): lambda preset: self._disk_encryption(preset), preview_func=self._prev_disk_encryption, display_func=lambda x: self._display_disk_encryption(x), - dependencies=['disk_config']) + dependencies=['disk_config'] + ) self._menu_options['swap'] = \ Selector( _('Swap'), @@ -140,7 +138,7 @@ class GlobalMenu(AbstractMenu): Selector( _('Additional packages'), lambda preset: ask_additional_packages_to_install(preset), - display_func=lambda x: self._defined_text if x else '', + display_func=lambda x: self.defined_text if x else '', preview_func=self._prev_additional_pkgs, default=[]) self._menu_options['additional-repositories'] = \ @@ -247,14 +245,17 @@ class GlobalMenu(AbstractMenu): return config.type.display_msg() def _disk_encryption(self, preset: Optional[disk.DiskEncryption]) -> Optional[disk.DiskEncryption]: - mods: Optional[disk.DiskLayoutConfiguration] = self._menu_options['disk_config'].current_selection + disk_config: Optional[disk.DiskLayoutConfiguration] = self._menu_options['disk_config'].current_selection - if not mods: + if not disk_config: # this should not happen as the encryption menu has the disk_config as dependency raise ValueError('No disk layout specified') + if not disk.DiskEncryption.validate_enc(disk_config): + return None + data_store: Dict[str, Any] = {} - disk_encryption = disk.DiskEncryptionMenu(mods, data_store, preset=preset).run() + disk_encryption = disk.DiskEncryptionMenu(disk_config, data_store, preset=preset).run() return disk_encryption def _locale_selection(self, preset: LocaleConfiguration) -> LocaleConfiguration: @@ -287,44 +288,35 @@ class GlobalMenu(AbstractMenu): return format_cols(packages, None) return None - def _prev_disk_layouts(self) -> Optional[str]: + def _prev_disk_config(self) -> Optional[str]: selector = self._menu_options['disk_config'] disk_layout_conf: Optional[disk.DiskLayoutConfiguration] = selector.current_selection + output = '' if disk_layout_conf: - device_mods: List[disk.DeviceModification] = \ - list(filter(lambda x: len(x.partitions) > 0, disk_layout_conf.device_modifications)) - - if device_mods: - output_partition = '{}: {}\n'.format(str(_('Configuration')), disk_layout_conf.config_type.display_msg()) - output_btrfs = '' + output += str(_('Configuration type: {}')).format(disk_layout_conf.config_type.display_msg()) - for mod in device_mods: - # create partition table - partition_table = FormattedOutput.as_table(mod.partitions) - - output_partition += f'{mod.device_path}: {mod.device.device_info.model}\n' - output_partition += partition_table + '\n' - - # create btrfs table - btrfs_partitions = list( - filter(lambda p: len(p.btrfs_subvols) > 0, mod.partitions) - ) - for partition in btrfs_partitions: - output_btrfs += FormattedOutput.as_table(partition.btrfs_subvols) + '\n' + if disk_layout_conf.lvm_config: + output += '\n{}: {}'.format(str(_('LVM configuration type')), disk_layout_conf.lvm_config.config_type.display_msg()) - output = output_partition + output_btrfs - return output.rstrip() + if output: + return output return None - def _display_disk_layout(self, current_value: Optional[disk.DiskLayoutConfiguration] = None) -> str: + def _display_disk_config(self, current_value: Optional[disk.DiskLayoutConfiguration] = None) -> str: if current_value: return current_value.config_type.display_msg() return '' def _prev_disk_encryption(self) -> Optional[str]: + disk_config: Optional[disk.DiskLayoutConfiguration] = self._menu_options['disk_config'].current_selection + + if disk_config and not disk.DiskEncryption.validate_enc(disk_config): + return str(_('LVM disk encryption with more than 2 partitions is currently not supported')) + encryption: Optional[disk.DiskEncryption] = self._menu_options['disk_encryption'].current_selection + if encryption: enc_type = disk.EncryptionType.type_to_text(encryption.encryption_type) output = str(_('Encryption type')) + f': {enc_type}\n' @@ -332,6 +324,8 @@ class GlobalMenu(AbstractMenu): if encryption.partitions: output += 'Partitions: {} selected'.format(len(encryption.partitions)) + '\n' + elif encryption.lvm_volumes: + output += 'LVM volumes: {} selected'.format(len(encryption.lvm_volumes)) + '\n' if encryption.hsm_device: output += f'HSM: {encryption.hsm_device.manufacturer}' @@ -425,10 +419,8 @@ class GlobalMenu(AbstractMenu): self, preset: Optional[disk.DiskLayoutConfiguration] = None ) -> Optional[disk.DiskLayoutConfiguration]: - disk_config = select_disk_config( - preset, - storage['arguments'].get('advanced', False) - ) + data_store: Dict[str, Any] = {} + disk_config = disk.DiskLayoutConfigurationMenu(preset, data_store).run() if disk_config != preset: self._menu_options['disk_encryption'].set_current_selection(None) diff --git a/archinstall/lib/installer.py b/archinstall/lib/installer.py index 37121118..8292a3be 100644 --- a/archinstall/lib/installer.py +++ b/archinstall/lib/installer.py @@ -52,7 +52,7 @@ class Installer: `Installer()` is the wrapper for most basic installation steps. It also wraps :py:func:`~archinstall.Installer.pacstrap` among other things. """ - self.base_packages = base_packages or __packages__[:3] + self._base_packages = base_packages or __packages__[:3] self.kernels = kernels or ['linux'] self._disk_config = disk_config @@ -64,11 +64,11 @@ class Installer: self.helper_flags: Dict[str, Any] = {'base': False, 'bootloader': None} for kernel in self.kernels: - self.base_packages.append(kernel) + self._base_packages.append(kernel) # If using accessibility tools in the live environment, append those to the packages list if accessibility_tools_in_use(): - self.base_packages.extend(__accessibility_packages__) + self._base_packages.extend(__accessibility_packages__) self.post_base_install: List[Callable] = [] @@ -90,6 +90,8 @@ class Installer: self._fstab_entries: List[str] = [] self._zram_enabled = False + self._disable_fstrim = False + self.pacman = Pacman(self.target, storage['arguments'].get('silent', False)) def __enter__(self) -> 'Installer': @@ -198,31 +200,71 @@ class Installer: self._verify_service_stop() def mount_ordered_layout(self): - info('Mounting partitions in order') + debug('Mounting ordered layout') + + luks_handlers: Dict[Any, Luks2] = {} + + match self._disk_encryption.encryption_type: + case disk.EncryptionType.NoEncryption: + self._mount_lvm_layout() + case disk.EncryptionType.Luks: + luks_handlers = self._prepare_luks_partitions(self._disk_encryption.partitions) + case disk.EncryptionType.LvmOnLuks: + luks_handlers = self._prepare_luks_partitions(self._disk_encryption.partitions) + self._import_lvm() + self._mount_lvm_layout(luks_handlers) + case disk.EncryptionType.LuksOnLvm: + self._import_lvm() + luks_handlers = self._prepare_luks_lvm(self._disk_encryption.lvm_volumes) + self._mount_lvm_layout(luks_handlers) + + # mount all regular partitions + self._mount_partition_layout(luks_handlers) + + def _mount_partition_layout(self, luks_handlers: Dict[Any, Luks2]): + debug('Mounting partition layout') + + # do not mount any PVs part of the LVM configuration + pvs = [] + if self._disk_config.lvm_config: + pvs = self._disk_config.lvm_config.get_all_pvs() for mod in self._disk_config.device_modifications: + not_pv_part_mods = list(filter(lambda x: x not in pvs, mod.partitions)) + # partitions have to mounted in the right order on btrfs the mountpoint will # be empty as the actual subvolumes are getting mounted instead so we'll use # '/' just for sorting - sorted_part_mods = sorted(mod.partitions, key=lambda x: x.mountpoint or Path('/')) - - enc_partitions = [] - if self._disk_encryption.encryption_type is not disk.EncryptionType.NoEncryption: - enc_partitions = list(set(sorted_part_mods) & set(self._disk_encryption.partitions)) - - # attempt to decrypt all luks partitions - luks_handlers = self._prepare_luks_partitions(enc_partitions) + sorted_part_mods = sorted(not_pv_part_mods, key=lambda x: x.mountpoint or Path('/')) for part_mod in sorted_part_mods: if luks_handler := luks_handlers.get(part_mod): - # mount encrypted partition self._mount_luks_partition(part_mod, luks_handler) else: - # partition is not encrypted self._mount_partition(part_mod) - def _prepare_luks_partitions(self, partitions: List[disk.PartitionModification]) -> Dict[ - disk.PartitionModification, Luks2]: + def _mount_lvm_layout(self, luks_handlers: Dict[Any, Luks2] = {}): + lvm_config = self._disk_config.lvm_config + + if not lvm_config: + debug('No lvm config defined to be mounted') + return + + debug('Mounting LVM layout') + + for vg in lvm_config.vol_groups: + sorted_vol = sorted(vg.volumes, key=lambda x: x.mountpoint or Path('/')) + + for vol in sorted_vol: + if luks_handler := luks_handlers.get(vol): + self._mount_luks_volume(vol, luks_handler) + else: + self._mount_lvm_vol(vol) + + def _prepare_luks_partitions( + self, + partitions: List[disk.PartitionModification] + ) -> Dict[disk.PartitionModification, Luks2]: return { part_mod: disk.device_handler.unlock_luks2_dev( part_mod.dev_path, @@ -233,6 +275,33 @@ class Installer: if part_mod.mapper_name and part_mod.dev_path } + def _import_lvm(self): + lvm_config = self._disk_config.lvm_config + + if not lvm_config: + debug('No lvm config defined to be imported') + return + + for vg in lvm_config.vol_groups: + disk.device_handler.lvm_import_vg(vg) + + for vol in vg.volumes: + disk.device_handler.lvm_vol_change(vol, True) + + def _prepare_luks_lvm( + self, + lvm_volumes: List[disk.LvmVolume] + ) -> Dict[disk.LvmVolume, Luks2]: + return { + vol: disk.device_handler.unlock_luks2_dev( + vol.dev_path, + vol.mapper_name, + self._disk_encryption.encryption_password + ) + for vol in lvm_volumes + if vol.mapper_name and vol.dev_path + } + def _mount_partition(self, part_mod: disk.PartitionModification): # it would be none if it's btrfs as the subvolumes will have the mountpoints defined if part_mod.mountpoint and part_mod.dev_path: @@ -246,14 +315,32 @@ class Installer: part_mod.mount_options ) + def _mount_lvm_vol(self, volume: disk.LvmVolume): + if volume.fs_type != disk.FilesystemType.Btrfs: + if volume.mountpoint and volume.dev_path: + target = self.target / volume.relative_mountpoint + disk.device_handler.mount(volume.dev_path, target, options=volume.mount_options) + + if volume.fs_type == disk.FilesystemType.Btrfs and volume.dev_path: + self._mount_btrfs_subvol(volume.dev_path, volume.btrfs_subvols, volume.mount_options) + def _mount_luks_partition(self, part_mod: disk.PartitionModification, luks_handler: Luks2): - # it would be none if it's btrfs as the subvolumes will have the mountpoints defined - if part_mod.mountpoint and luks_handler.mapper_dev: - target = self.target / part_mod.relative_mountpoint - disk.device_handler.mount(luks_handler.mapper_dev, target, options=part_mod.mount_options) + if part_mod.fs_type != disk.FilesystemType.Btrfs: + if part_mod.mountpoint and luks_handler.mapper_dev: + target = self.target / part_mod.relative_mountpoint + disk.device_handler.mount(luks_handler.mapper_dev, target, options=part_mod.mount_options) if part_mod.fs_type == disk.FilesystemType.Btrfs and luks_handler.mapper_dev: - self._mount_btrfs_subvol(luks_handler.mapper_dev, part_mod.btrfs_subvols) + self._mount_btrfs_subvol(luks_handler.mapper_dev, part_mod.btrfs_subvols, part_mod.mount_options) + + def _mount_luks_volume(self, volume: disk.LvmVolume, luks_handler: Luks2): + if volume.fs_type != disk.FilesystemType.Btrfs: + if volume.mountpoint and luks_handler.mapper_dev: + target = self.target / volume.relative_mountpoint + disk.device_handler.mount(luks_handler.mapper_dev, target, options=volume.mount_options) + + if volume.fs_type == disk.FilesystemType.Btrfs and luks_handler.mapper_dev: + self._mount_btrfs_subvol(luks_handler.mapper_dev, volume.btrfs_subvols, volume.mount_options) def _mount_btrfs_subvol( self, @@ -262,13 +349,23 @@ class Installer: mount_options: List[str] = [] ): for subvol in subvolumes: - disk.device_handler.mount( - dev_path, - self.target / subvol.relative_mountpoint, - options=mount_options + [f'subvol={subvol.name}'] - ) + mountpoint = self.target / subvol.relative_mountpoint + mount_options = mount_options + [f'subvol={subvol.name}'] + disk.device_handler.mount(dev_path, mountpoint, options=mount_options) def generate_key_files(self): + match self._disk_encryption.encryption_type: + case disk.EncryptionType.Luks: + self._generate_key_files_partitions() + case disk.EncryptionType.LuksOnLvm: + self._generate_key_file_lvm_volumes() + case disk.EncryptionType.LvmOnLuks: + # currently LvmOnLuks only supports a single + # partitioning layout (boot + partition) + # so we won't need any keyfile generation atm + pass + + def _generate_key_files_partitions(self): for part_mod in self._disk_encryption.partitions: gen_enc_file = self._disk_encryption.should_generate_encryption_file(part_mod) @@ -279,14 +376,36 @@ class Installer: ) if gen_enc_file and not part_mod.is_root(): - info(f'Creating key-file: {part_mod.dev_path}') + debug(f'Creating key-file: {part_mod.dev_path}') luks_handler.create_keyfile(self.target) if part_mod.is_root() and not gen_enc_file: if self._disk_encryption.hsm_device: disk.Fido2.fido2_enroll( self._disk_encryption.hsm_device, - part_mod, + part_mod.safe_dev_path, + self._disk_encryption.encryption_password + ) + + def _generate_key_file_lvm_volumes(self): + for vol in self._disk_encryption.lvm_volumes: + gen_enc_file = self._disk_encryption.should_generate_encryption_file(vol) + + luks_handler = Luks2( + vol.safe_dev_path, + mapper_name=vol.mapper_name, + password=self._disk_encryption.encryption_password + ) + + if gen_enc_file and not vol.is_root(): + info(f'Creating key-file: {vol.dev_path}') + luks_handler.create_keyfile(self.target) + + if vol.is_root() and not gen_enc_file: + if self._disk_encryption.hsm_device: + disk.Fido2.fido2_enroll( + self._disk_encryption.hsm_device, + vol.safe_dev_path, self._disk_encryption.encryption_password ) @@ -393,7 +512,7 @@ class Installer: for entry in self._fstab_entries: fp.write(f'{entry}\n') - def set_hostname(self, hostname: str, *args: str, **kwargs: str) -> None: + def set_hostname(self, hostname: str): with open(f'{self.target}/etc/hostname', 'w') as fh: fh.write(hostname + '\n') @@ -444,7 +563,7 @@ class Installer: (self.target / 'etc/locale.conf').write_text(f'LANG={lang_value}\n') return True - def set_timezone(self, zone: str, *args: str, **kwargs: str) -> bool: + def set_timezone(self, zone: str) -> bool: if not zone: return True if not len(zone): @@ -532,7 +651,7 @@ class Installer: if enable_services: # If we haven't installed the base yet (function called pre-maturely) if self.helper_flags.get('base', False) is False: - self.base_packages.append('iwd') + self._base_packages.append('iwd') # This function will be called after minimal_installation() # as a hook for post-installs. This hook is only needed if @@ -608,51 +727,98 @@ class Installer: return vendor.get_ucode() return None - def minimal_installation( - self, - testing: bool = False, - multilib: bool = False, - mkinitcpio: bool = True, - hostname: str = 'archinstall', - locale_config: LocaleConfiguration = LocaleConfiguration.default() - ): - _disable_fstrim = False + def _handle_partition_installation(self): + pvs = [] + if self._disk_config.lvm_config: + pvs = self._disk_config.lvm_config.get_all_pvs() + for mod in self._disk_config.device_modifications: for part in mod.partitions: - if part.fs_type is not None: - if (pkg := part.fs_type.installation_pkg) is not None: - self.base_packages.append(pkg) - if (module := part.fs_type.installation_module) is not None: + if part in pvs or part.fs_type is None: + continue + + if (pkg := part.fs_type.installation_pkg) is not None: + self._base_packages.append(pkg) + if (module := part.fs_type.installation_module) is not None: + self._modules.append(module) + if (binary := part.fs_type.installation_binary) is not None: + self._binaries.append(binary) + + # https://github.com/archlinux/archinstall/issues/1837 + if part.fs_type.fs_type_mount == 'btrfs': + self._disable_fstrim = True + + # There is not yet an fsck tool for NTFS. If it's being used for the root filesystem, the hook should be removed. + if part.fs_type.fs_type_mount == 'ntfs3' and part.mountpoint == self.target: + if 'fsck' in self._hooks: + self._hooks.remove('fsck') + + if part in self._disk_encryption.partitions: + if self._disk_encryption.hsm_device: + # Required by mkinitcpio to add support for fido2-device options + self.pacman.strap('libfido2') + + if 'sd-encrypt' not in self._hooks: + self._hooks.insert(self._hooks.index('filesystems'), 'sd-encrypt') + else: + if 'encrypt' not in self._hooks: + self._hooks.insert(self._hooks.index('filesystems'), 'encrypt') + + def _handle_lvm_installation(self): + if not self._disk_config.lvm_config: + return + + self.add_additional_packages('lvm2') + self._hooks.insert(self._hooks.index('filesystems') - 1, 'lvm2') + + for vg in self._disk_config.lvm_config.vol_groups: + for vol in vg.volumes: + if vol.fs_type is not None: + if (pkg := vol.fs_type.installation_pkg) is not None: + self._base_packages.append(pkg) + if (module := vol.fs_type.installation_module) is not None: self._modules.append(module) - if (binary := part.fs_type.installation_binary) is not None: + if (binary := vol.fs_type.installation_binary) is not None: self._binaries.append(binary) - # https://github.com/archlinux/archinstall/issues/1837 - if part.fs_type.fs_type_mount == 'btrfs': - _disable_fstrim = True + if vol.fs_type.fs_type_mount == 'btrfs': + self._disable_fstrim = True # There is not yet an fsck tool for NTFS. If it's being used for the root filesystem, the hook should be removed. - if part.fs_type.fs_type_mount == 'ntfs3' and part.mountpoint == self.target: + if vol.fs_type.fs_type_mount == 'ntfs3' and vol.mountpoint == self.target: if 'fsck' in self._hooks: self._hooks.remove('fsck') - if part in self._disk_encryption.partitions: - if self._disk_encryption.hsm_device: - # Required by mkinitcpio to add support for fido2-device options - self.pacman.strap('libfido2') + if self._disk_encryption.encryption_type in [disk.EncryptionType.LvmOnLuks, disk.EncryptionType.LuksOnLvm]: + if self._disk_encryption.hsm_device: + # Required by mkinitcpio to add support for fido2-device options + self.pacman.strap('libfido2') + + if 'sd-encrypt' not in self._hooks: + self._hooks.insert(self._hooks.index('lvm2') - 1, 'sd-encrypt') + else: + if 'encrypt' not in self._hooks: + self._hooks.insert(self._hooks.index('lvm2') - 1, 'encrypt') - if 'sd-encrypt' not in self._hooks: - self._hooks.insert(self._hooks.index('filesystems'), 'sd-encrypt') - else: - if 'encrypt' not in self._hooks: - self._hooks.insert(self._hooks.index('filesystems'), 'encrypt') + def minimal_installation( + self, + testing: bool = False, + multilib: bool = False, + mkinitcpio: bool = True, + hostname: str = 'archinstall', + locale_config: LocaleConfiguration = LocaleConfiguration.default() + ): + if self._disk_config.lvm_config: + self._handle_lvm_installation() + else: + self._handle_partition_installation() if not SysInfo.has_uefi(): - self.base_packages.append('grub') + self._base_packages.append('grub') if ucode := self._get_microcode(): (self.target / 'boot' / ucode).unlink(missing_ok=True) - self.base_packages.append(ucode.stem) + self._base_packages.append(ucode.stem) else: debug('Archinstall will not install any ucode.') @@ -673,7 +839,7 @@ class Installer: pacman_conf.apply() - self.pacman.strap(self.base_packages) + self.pacman.strap(self._base_packages) self.helper_flags['base-strapped'] = True pacman_conf.persist() @@ -685,7 +851,7 @@ class Installer: # https://github.com/archlinux/archinstall/issues/880 # https://github.com/archlinux/archinstall/issues/1837 # https://github.com/archlinux/archinstall/issues/1841 - if not _disable_fstrim: + if not self._disable_fstrim: self.enable_periodic_trim() # TODO: Support locale and timezone @@ -742,13 +908,24 @@ class Installer: return boot return None - def _get_root_partition(self) -> Optional[disk.PartitionModification]: - for mod in self._disk_config.device_modifications: - if root := mod.get_root_partition(): - return root + def _get_root(self) -> Optional[disk.PartitionModification | disk.LvmVolume]: + if self._disk_config.lvm_config: + return self._disk_config.lvm_config.get_root_volume() + else: + for mod in self._disk_config.device_modifications: + if root := mod.get_root_partition(): + return root return None - def _get_kernel_params( + def _get_luks_uuid_from_mapper_dev(self, mapper_dev_path: Path) -> str: + lsblk_info = disk.get_lsblk_info(mapper_dev_path, reverse=True, full_dev_path=True) + + if not lsblk_info.children or not lsblk_info.children[0].uuid: + raise ValueError('Unable to determine UUID of luks superblock') + + return lsblk_info.children[0].uuid + + def _get_kernel_params_partition( self, root_partition: disk.PartitionModification, id_root: bool = True, @@ -784,20 +961,74 @@ class Installer: debug(f'Identifying root partition by UUID: {root_partition.uuid}') kernel_parameters.append(f'root=UUID={root_partition.uuid}') + return kernel_parameters + + def _get_kernel_params_lvm( + self, + lvm: disk.LvmVolume + ) -> List[str]: + kernel_parameters = [] + + match self._disk_encryption.encryption_type: + case disk.EncryptionType.LvmOnLuks: + if not lvm.vg_name: + raise ValueError(f'Unable to determine VG name for {lvm.name}') + + pv_seg_info = disk.device_handler.lvm_pvseg_info(lvm.vg_name, lvm.name) + + if not pv_seg_info: + raise ValueError(f'Unable to determine PV segment info for {lvm.vg_name}/{lvm.name}') + + uuid = self._get_luks_uuid_from_mapper_dev(pv_seg_info.pv_name) + + if self._disk_encryption.hsm_device: + debug(f'LvmOnLuks, encrypted root partition, HSM, identifying by UUID: {uuid}') + kernel_parameters.append(f'rd.luks.name={uuid}=cryptlvm root={lvm.safe_dev_path}') + else: + debug(f'LvmOnLuks, encrypted root partition, identifying by UUID: {uuid}') + kernel_parameters.append(f'cryptdevice=UUID={uuid}:cryptlvm root={lvm.safe_dev_path}') + case disk.EncryptionType.LuksOnLvm: + uuid = self._get_luks_uuid_from_mapper_dev(lvm.mapper_path) + + if self._disk_encryption.hsm_device: + debug(f'LuksOnLvm, encrypted root partition, HSM, identifying by UUID: {uuid}') + kernel_parameters.append(f'rd.luks.name={uuid}=root root=/dev/mapper/root') + else: + debug(f'LuksOnLvm, encrypted root partition, identifying by UUID: {uuid}') + kernel_parameters.append(f'cryptdevice=UUID={uuid}:root root=/dev/mapper/root') + case disk.EncryptionType.NoEncryption: + debug(f'Identifying root lvm by mapper device: {lvm.dev_path}') + kernel_parameters.append(f'root={lvm.safe_dev_path}') + + return kernel_parameters + + def _get_kernel_params( + self, + root: disk.PartitionModification | disk.LvmVolume, + id_root: bool = True, + partuuid: bool = True + ) -> List[str]: + kernel_parameters = [] + + if isinstance(root, disk.LvmVolume): + kernel_parameters = self._get_kernel_params_lvm(root) + else: + kernel_parameters = self._get_kernel_params_partition(root, id_root, partuuid) + # Zswap should be disabled when using zram. # https://github.com/archlinux/archinstall/issues/881 if self._zram_enabled: kernel_parameters.append('zswap.enabled=0') if id_root: - for sub_vol in root_partition.btrfs_subvols: + for sub_vol in root.btrfs_subvols: if sub_vol.is_root(): kernel_parameters.append(f'rootflags=subvol={sub_vol.name}') break kernel_parameters.append('rw') - kernel_parameters.append(f'rootfstype={root_partition.safe_fs_type.fs_type_mount}') + kernel_parameters.append(f'rootfstype={root.safe_fs_type.fs_type_mount}') kernel_parameters.extend(self._kernel_params) debug(f'kernel parameters: {" ".join(kernel_parameters)}') @@ -807,10 +1038,12 @@ class Installer: def _add_systemd_bootloader( self, boot_partition: disk.PartitionModification, - root_partition: disk.PartitionModification, + root: disk.PartitionModification | disk.LvmVolume, efi_partition: Optional[disk.PartitionModification], uki_enabled: bool = False ): + debug('Installing systemd bootloader') + self.pacman.strap('efibootmgr') if not SysInfo.has_uefi(): @@ -882,7 +1115,7 @@ class Installer: f'# Created on: {self.init_time}' ) - options = 'options ' + ' '.join(self._get_kernel_params(root_partition)) + options = 'options ' + ' '.join(self._get_kernel_params(root)) for kernel in self.kernels: for variant in ("", "-fallback"): @@ -904,15 +1137,17 @@ class Installer: def _add_grub_bootloader( self, boot_partition: disk.PartitionModification, - root_partition: disk.PartitionModification, + root: disk.PartitionModification | disk.LvmVolume, efi_partition: Optional[disk.PartitionModification] ): + debug('Installing grub bootloader') + self.pacman.strap('grub') # no need? grub_default = self.target / 'etc/default/grub' config = grub_default.read_text() - kernel_parameters = ' '.join(self._get_kernel_params(root_partition, False, False)) + kernel_parameters = ' '.join(self._get_kernel_params(root, False, False)) config = re.sub(r'(GRUB_CMDLINE_LINUX=")("\n)', rf'\1{kernel_parameters}\2', config, 1) grub_default.write_text(config) @@ -934,7 +1169,7 @@ class Installer: info(f"GRUB EFI partition: {efi_partition.dev_path}") - self.pacman.strap('efibootmgr') # TODO: Do we need? Yes, but remove from minimal_installation() instead? + self.pacman.strap('efibootmgr') # TODO: Do we need? Yes, but remove from minimal_installation() instead? boot_dir_arg = [] if boot_partition.mountpoint and boot_partition.mountpoint != boot_dir: @@ -988,8 +1223,10 @@ class Installer: self, boot_partition: disk.PartitionModification, efi_partition: Optional[disk.PartitionModification], - root_partition: disk.PartitionModification + root: disk.PartitionModification | disk.LvmVolume ): + debug('Installing limine bootloader') + self.pacman.strap('limine') info(f"Limine boot partition: {boot_partition.dev_path}") @@ -1052,7 +1289,7 @@ Exec = /bin/sh -c "{hook_command}" hook_path = hooks_dir / '99-limine.hook' hook_path.write_text(hook_contents) - kernel_params = ' '.join(self._get_kernel_params(root_partition)) + kernel_params = ' '.join(self._get_kernel_params(root)) config_contents = 'TIMEOUT=5\n' for kernel in self.kernels: @@ -1075,9 +1312,11 @@ Exec = /bin/sh -c "{hook_command}" def _add_efistub_bootloader( self, boot_partition: disk.PartitionModification, - root_partition: disk.PartitionModification, + root: disk.PartitionModification | disk.LvmVolume, uki_enabled: bool = False ): + debug('Installing efistub bootloader') + self.pacman.strap('efibootmgr') if not SysInfo.has_uefi(): @@ -1092,7 +1331,7 @@ Exec = /bin/sh -c "{hook_command}" entries = ( 'initrd=/initramfs-{kernel}.img', - *self._get_kernel_params(root_partition) + *self._get_kernel_params(root) ) cmdline = [' '.join(entries)] @@ -1122,7 +1361,7 @@ Exec = /bin/sh -c "{hook_command}" def _config_uki( self, - root_partition: disk.PartitionModification, + root: disk.PartitionModification | disk.LvmVolume, efi_partition: Optional[disk.PartitionModification] ): if not efi_partition or not efi_partition.mountpoint: @@ -1130,7 +1369,7 @@ Exec = /bin/sh -c "{hook_command}" # Set up kernel command line with open(self.target / 'etc/kernel/cmdline', 'w') as cmdline: - kernel_parameters = self._get_kernel_params(root_partition) + kernel_parameters = self._get_kernel_params(root) cmdline.write(' '.join(kernel_parameters) + '\n') diff_mountpoint = None @@ -1191,37 +1430,33 @@ Exec = /bin/sh -c "{hook_command}" efi_partition = self._get_efi_partition() boot_partition = self._get_boot_partition() - root_partition = self._get_root_partition() + root = self._get_root() if boot_partition is None: raise ValueError(f'Could not detect boot at mountpoint {self.target}') - if root_partition is None: + if root is None: raise ValueError(f'Could not detect root at mountpoint {self.target}') info(f'Adding bootloader {bootloader.value} to {boot_partition.dev_path}') if uki_enabled: - self._config_uki(root_partition, efi_partition) + self._config_uki(root, efi_partition) match bootloader: case Bootloader.Systemd: - self._add_systemd_bootloader(boot_partition, root_partition, efi_partition, uki_enabled) + self._add_systemd_bootloader(boot_partition, root, efi_partition, uki_enabled) case Bootloader.Grub: - self._add_grub_bootloader(boot_partition, root_partition, efi_partition) + self._add_grub_bootloader(boot_partition, root, efi_partition) case Bootloader.Efistub: - self._add_efistub_bootloader(boot_partition, root_partition, uki_enabled) + self._add_efistub_bootloader(boot_partition, root, uki_enabled) case Bootloader.Limine: - self._add_limine_bootloader(boot_partition, efi_partition, root_partition) + self._add_limine_bootloader(boot_partition, efi_partition, root) def add_additional_packages(self, packages: Union[str, List[str]]) -> bool: return self.pacman.strap(packages) - def _enable_users(self, service: str, users: List[User]): - for user in users: - self.arch_chroot(f'systemctl enable --user {service}', run_as=user.username) - - def enable_sudo(self, entity: str, group :bool = False): + def enable_sudo(self, entity: str, group: bool = False): info(f'Enabling sudo permissions for {entity}') sudoers_dir = f"{self.target}/etc/sudoers.d" @@ -1237,7 +1472,7 @@ Exec = /bin/sh -c "{hook_command}" # We count how many files are there already so we know which number to prefix the file with num_of_rules_already = len(os.listdir(sudoers_dir)) - file_num_str = "{:02d}".format(num_of_rules_already) # We want 00_user1, 01_user2, etc + file_num_str = "{:02d}".format(num_of_rules_already) # We want 00_user1, 01_user2, etc # Guarantees that entity str does not contain invalid characters for a linux file name: # \ / : * ? " < > | @@ -1293,7 +1528,7 @@ Exec = /bin/sh -c "{hook_command}" if sudo and self.enable_sudo(user): self.helper_flags['user'] = True - def user_set_pw(self, user :str, password :str) -> bool: + def user_set_pw(self, user: str, password: str) -> bool: info(f'Setting password for {user}') if user == 'root': @@ -1310,7 +1545,7 @@ Exec = /bin/sh -c "{hook_command}" except SysCallError: return False - def user_set_shell(self, user :str, shell :str) -> bool: + def user_set_shell(self, user: str, shell: str) -> bool: info(f'Setting shell for {user} to {shell}') try: @@ -1319,7 +1554,7 @@ Exec = /bin/sh -c "{hook_command}" except SysCallError: return False - def chown(self, owner :str, path :str, options :List[str] = []) -> bool: + def chown(self, owner: str, path: str, options: List[str] = []) -> bool: cleaned_path = path.replace('\'', '\\\'') try: SysCommand(f"/usr/bin/arch-chroot {self.target} sh -c 'chown {' '.join(options)} {owner} {cleaned_path}'") diff --git a/archinstall/lib/interactions/disk_conf.py b/archinstall/lib/interactions/disk_conf.py index 9d0042d6..f80af9ca 100644 --- a/archinstall/lib/interactions/disk_conf.py +++ b/archinstall/lib/interactions/disk_conf.py @@ -58,7 +58,7 @@ def select_devices(preset: List[disk.BDevice] = []) -> List[disk.BDevice]: case MenuSelectionType.Reset: return [] case MenuSelectionType.Skip: return preset case MenuSelectionType.Selection: - selected_device_info: List[disk._DeviceInfo] = choice.value # type: ignore + selected_device_info: List[disk._DeviceInfo] = choice.single_value selected_devices = [] for device in devices: @@ -73,7 +73,6 @@ def get_default_partition_layout( filesystem_type: Optional[disk.FilesystemType] = None, advanced_option: bool = False ) -> List[disk.DeviceModification]: - if len(devices) == 1: device_modification = suggest_single_disk_layout( devices[0], @@ -133,7 +132,7 @@ def select_disk_config( case MenuSelectionType.Reset: return None case MenuSelectionType.Selection: if choice.single_value == pre_mount_mode: - output = "You will use whatever drive-setup is mounted at the specified directory\n" + output = 'You will use whatever drive-setup is mounted at the specified directory\n' output += "WARNING: Archinstall won't check the suitability of this setup\n" try: @@ -151,7 +150,6 @@ def select_disk_config( ) preset_devices = [mod.device for mod in preset.device_modifications] if preset else [] - devices = select_devices(preset_devices) if not devices: @@ -177,6 +175,36 @@ def select_disk_config( return None +def select_lvm_config( + disk_config: disk.DiskLayoutConfiguration, + preset: Optional[disk.LvmConfiguration] = None, +) -> Optional[disk.LvmConfiguration]: + default_mode = disk.LvmLayoutType.Default.display_msg() + + options = [default_mode] + + preset_value = preset.config_type.display_msg() if preset else None + warning = str(_('Are you sure you want to reset this setting?')) + + choice = Menu( + _('Select a LVM option'), + options, + allow_reset=True, + allow_reset_warning_msg=warning, + sort=False, + preview_size=0.2, + preset_values=preset_value + ).run() + + match choice.type_: + case MenuSelectionType.Skip: return preset + case MenuSelectionType.Reset: return None + case MenuSelectionType.Selection: + if choice.single_value == default_mode: + return suggest_lvm_layout(disk_config) + return preset + + def _boot_partition(sector_size: disk.SectorSize, using_gpt: bool) -> disk.PartitionModification: flags = [disk.PartitionFlag.Boot] if using_gpt: @@ -199,7 +227,7 @@ def _boot_partition(sector_size: disk.SectorSize, using_gpt: bool) -> disk.Parti ) -def select_main_filesystem_format(advanced_options=False) -> disk.FilesystemType: +def select_main_filesystem_format(advanced_options: bool = False) -> disk.FilesystemType: options = { 'btrfs': disk.FilesystemType.Btrfs, 'ext4': disk.FilesystemType.Ext4, @@ -250,7 +278,6 @@ def suggest_single_disk_layout( prompt = str(_('Would you like to use BTRFS subvolumes with a default structure?')) choice = Menu(prompt, Menu.yes_no(), skip=False, default_option=Menu.yes()).run() using_subvolumes = choice.value == Menu.yes() - mount_options = select_mount_options() device_modification = disk.DeviceModification(device, wipe=True) @@ -288,7 +315,11 @@ def suggest_single_disk_layout( root_start = boot_partition.start + boot_partition.length # Set a size for / (/root) - if using_subvolumes or device_size_gib < min_size_to_allow_home_part or not using_home_partition: + if ( + using_subvolumes + or device_size_gib < min_size_to_allow_home_part + or not using_home_partition + ): root_length = device.device_info.total_size - root_start else: root_length = min(device.device_info.total_size, root_partition_size) @@ -305,6 +336,7 @@ def suggest_single_disk_layout( fs_type=filesystem_type, mount_options=mount_options ) + device_modification.add_partition(root_partition) if using_subvolumes: @@ -388,9 +420,9 @@ def suggest_multi_disk_layout( device_paths = ', '.join([str(d.device_info.path) for d in devices]) - debug(f"Suggesting multi-disk-layout for devices: {device_paths}") - debug(f"/root: {root_device.device_info.path}") - debug(f"/home: {home_device.device_info.path}") + debug(f'Suggesting multi-disk-layout for devices: {device_paths}') + debug(f'/root: {root_device.device_info.path}') + debug(f'/home: {home_device.device_info.path}') root_device_modification = disk.DeviceModification(root_device, wipe=True) home_device_modification = disk.DeviceModification(home_device, wipe=True) @@ -444,3 +476,85 @@ def suggest_multi_disk_layout( home_device_modification.add_partition(home_partition) return [root_device_modification, home_device_modification] + + +def suggest_lvm_layout( + disk_config: disk.DiskLayoutConfiguration, + filesystem_type: Optional[disk.FilesystemType] = None, + vg_grp_name: str = 'ArchinstallVg', +) -> disk.LvmConfiguration: + if disk_config.config_type != disk.DiskLayoutType.Default: + raise ValueError('LVM suggested volumes are only available for default partitioning') + + using_subvolumes = False + btrfs_subvols = [] + home_volume = True + mount_options = [] + + if not filesystem_type: + filesystem_type = select_main_filesystem_format() + + if filesystem_type == disk.FilesystemType.Btrfs: + prompt = str(_('Would you like to use BTRFS subvolumes with a default structure?')) + choice = Menu(prompt, Menu.yes_no(), skip=False, default_option=Menu.yes()).run() + using_subvolumes = choice.value == Menu.yes() + + mount_options = select_mount_options() + + if using_subvolumes: + btrfs_subvols = [ + disk.SubvolumeModification(Path('@'), Path('/')), + disk.SubvolumeModification(Path('@home'), Path('/home')), + disk.SubvolumeModification(Path('@log'), Path('/var/log')), + disk.SubvolumeModification(Path('@pkg'), Path('/var/cache/pacman/pkg')), + disk.SubvolumeModification(Path('@.snapshots'), Path('/.snapshots')), + ] + + home_volume = False + + boot_part: Optional[disk.PartitionModification] = None + other_part: List[disk.PartitionModification] = [] + + for mod in disk_config.device_modifications: + for part in mod.partitions: + if part.is_boot(): + boot_part = part + else: + other_part.append(part) + + if not boot_part: + raise ValueError('Unable to find boot partition in partition modifications') + + total_vol_available = sum( + [p.length for p in other_part], + disk.Size(0, disk.Unit.B, disk.SectorSize.default()), + ) + root_vol_size = disk.Size(20, disk.Unit.GiB, disk.SectorSize.default()) + home_vol_size = total_vol_available - root_vol_size + + lvm_vol_group = disk.LvmVolumeGroup(vg_grp_name, pvs=other_part, ) + + root_vol = disk.LvmVolume( + status=disk.LvmVolumeStatus.Create, + name='root', + fs_type=filesystem_type, + length=root_vol_size, + mountpoint=Path('/'), + btrfs_subvols=btrfs_subvols, + mount_options=mount_options + ) + + lvm_vol_group.volumes.append(root_vol) + + if home_volume: + home_vol = disk.LvmVolume( + status=disk.LvmVolumeStatus.Create, + name='home', + fs_type=filesystem_type, + length=home_vol_size, + mountpoint=Path('/home'), + ) + + lvm_vol_group.volumes.append(home_vol) + + return disk.LvmConfiguration(disk.LvmLayoutType.Default, [lvm_vol_group]) diff --git a/archinstall/lib/interactions/manage_users_conf.py b/archinstall/lib/interactions/manage_users_conf.py index ca912283..886f85b6 100644 --- a/archinstall/lib/interactions/manage_users_conf.py +++ b/archinstall/lib/interactions/manage_users_conf.py @@ -1,12 +1,11 @@ from __future__ import annotations import re -from typing import Any, Dict, TYPE_CHECKING, List, Optional +from typing import Any, TYPE_CHECKING, List, Optional from .utils import get_password from ..menu import Menu, ListManager from ..models.users import User -from ..output import FormattedOutput if TYPE_CHECKING: _: Any @@ -26,21 +25,6 @@ class UserList(ListManager): ] super().__init__(prompt, lusers, [self._actions[0]], self._actions[1:]) - def reformat(self, data: List[User]) -> Dict[str, Any]: - table = FormattedOutput.as_table(data) - rows = table.split('\n') - - # these are the header rows of the table and do not map to any User obviously - # we're adding 2 spaces as prefix because the menu selector '> ' will be put before - # the selectable rows so the header has to be aligned - display_data: Dict[str, Optional[User]] = {f' {rows[0]}': None, f' {rows[1]}': None} - - for row, user in zip(rows[2:], data): - row = row.replace('|', '\\|') - display_data[row] = user - - return display_data - def selected_action_display(self, user: User) -> str: return user.username diff --git a/archinstall/lib/luks.py b/archinstall/lib/luks.py index c917420e..50e15cee 100644 --- a/archinstall/lib/luks.py +++ b/archinstall/lib/luks.py @@ -60,7 +60,7 @@ class Luks2: iter_time: int = 10000, key_file: Optional[Path] = None ) -> Path: - info(f'Luks2 encrypting: {self.luks_dev_path}') + debug(f'Luks2 encrypting: {self.luks_dev_path}') byte_password = self._password_bytes() @@ -87,12 +87,15 @@ class Luks2: 'luksFormat', str(self.luks_dev_path), ]) + debug(f'cryptsetup format: {cryptsetup_args}') + # Retry formatting the volume because archinstall can some times be too quick # which generates a "Device /dev/sdX does not exist or access denied." between # setting up partitions and us trying to encrypt it. for retry_attempt in range(storage['DISK_RETRY_ATTEMPTS'] + 1): try: - SysCommand(cryptsetup_args) + result = SysCommand(cryptsetup_args).decode() + debug(f'cryptsetup luksFormat output: {result}') break except SysCallError as err: time.sleep(storage['DISK_TIMEOUTS']) @@ -106,10 +109,13 @@ class Luks2: self.lock() # Then try again to set up the crypt-device - SysCommand(cryptsetup_args) + result = SysCommand(cryptsetup_args).decode() + debug(f'cryptsetup luksFormat output: {result}') else: raise DiskError(f'Could not encrypt volume "{self.luks_dev_path}": {err}') + self.key_file = key_file + return key_file def _get_luks_uuid(self) -> str: @@ -152,7 +158,15 @@ class Luks2: while Path(self.luks_dev_path).exists() is False and time.time() - wait_timer < 10: time.sleep(0.025) - SysCommand(f'/usr/bin/cryptsetup open {self.luks_dev_path} {self.mapper_name} --key-file {key_file} --type luks2') + result = SysCommand( + '/usr/bin/cryptsetup open ' + f'{self.luks_dev_path} ' + f'{self.mapper_name} ' + f'--key-file {key_file} ' + f'--type luks2' + ).decode() + + debug(f'cryptsetup open output: {result}') if not self.mapper_dev or not self.mapper_dev.is_symlink(): raise DiskError(f'Failed to open luks2 device: {self.luks_dev_path}') @@ -199,8 +213,8 @@ class Luks2: key_file.parent.mkdir(parents=True, exist_ok=True) - with open(key_file, "w") as keyfile: - keyfile.write(generate_password(length=512)) + pwd = generate_password(length=512) + key_file.write_text(pwd) key_file.chmod(0o400) @@ -208,7 +222,7 @@ class Luks2: self._crypttab(crypttab_path, kf_path, options=["luks", "key-slot=1"]) def _add_key(self, key_file: Path): - info(f'Adding additional key-file {key_file}') + debug(f'Adding additional key-file {key_file}') command = f'/usr/bin/cryptsetup -q -v luksAddKey {self.luks_dev_path} {key_file}' worker = SysCommandWorker(command, environment_vars={'LC_ALL': 'C'}) @@ -228,7 +242,7 @@ class Luks2: key_file: Path, options: List[str] ) -> None: - info(f'Adding crypttab entry for key {key_file}') + debug(f'Adding crypttab entry for key {key_file}') with open(crypttab_path, 'a') as crypttab: opt = ','.join(options) diff --git a/archinstall/lib/menu/abstract_menu.py b/archinstall/lib/menu/abstract_menu.py index 14db98ca..ee55f5c9 100644 --- a/archinstall/lib/menu/abstract_menu.py +++ b/archinstall/lib/menu/abstract_menu.py @@ -10,6 +10,7 @@ from ..translationhandler import TranslationHandler, Language if TYPE_CHECKING: _: Any + class Selector: def __init__( self, @@ -68,42 +69,19 @@ class Selector: :param no_store: A boolean which determines that the field should or shouldn't be stored in the data storage :type no_store: bool """ - self._description = description - self.func = func self._display_func = display_func - self._current_selection = default + self._no_store = no_store + + self.description = description + self.func = func + self.current_selection = default self.enabled = enabled - self._dependencies = dependencies - self._dependencies_not = dependencies_not + self.dependencies = dependencies + self.dependencies_not = dependencies_not self.exec_func = exec_func - self._preview_func = preview_func + self.preview_func = preview_func self.mandatory = mandatory - self._no_store = no_store - self._default = default - - @property - def default(self) -> Any: - return self._default - - @property - def description(self) -> str: - return self._description - - @property - def dependencies(self) -> List: - return self._dependencies - - @property - def dependencies_not(self) -> List: - return self._dependencies_not - - @property - def current_selection(self) -> Optional[Any]: - return self._current_selection - - @property - def preview_func(self): - return self._preview_func + self.default = default def do_store(self) -> bool: return self._no_store is False @@ -112,45 +90,45 @@ class Selector: self.enabled = status def update_description(self, description: str): - self._description = description + self.description = description def menu_text(self, padding: int = 0) -> str: - if self._description == '': # special menu option for __separator__ + if self.description == '': # special menu option for __separator__ return '' current = '' if self._display_func: - current = self._display_func(self._current_selection) + current = self._display_func(self.current_selection) else: - if self._current_selection is not None: - current = str(self._current_selection) + if self.current_selection is not None: + current = str(self.current_selection) if current: padding += 5 - description = unicode_ljust(str(self._description), padding, ' ') + description = unicode_ljust(str(self.description), padding, ' ') current = current else: - description = self._description + description = self.description current = '' return f'{description} {current}' def set_current_selection(self, current: Optional[Any]): - self._current_selection = current + self.current_selection = current def has_selection(self) -> bool: - if not self._current_selection: + if not self.current_selection: return False return True def get_selection(self) -> Any: - return self._current_selection + return self.current_selection def is_empty(self) -> bool: - if self._current_selection is None: + if self.current_selection is None: return True - elif isinstance(self._current_selection, (str, list, dict)) and len(self._current_selection) == 0: + elif isinstance(self.current_selection, (str, list, dict)) and len(self.current_selection) == 0: return True return False @@ -197,6 +175,8 @@ class AbstractMenu: self._sync_all() self._populate_default_values() + self.defined_text = str(_('Defined')) + @property def last_choice(self): return self._last_choice @@ -382,9 +362,10 @@ class AbstractMenu: result = None if selector.func is not None: - presel_val = self.option(config_name).get_selection() - result = selector.func(presel_val) + cur_value = self.option(config_name).get_selection() + result = selector.func(cur_value) self._menu_options[config_name].set_current_selection(result) + if selector.do_store(): self._data_store[config_name] = result @@ -398,19 +379,23 @@ class AbstractMenu: return True def _verify_selection_enabled(self, selection_name: str) -> bool: - """ general """ if selection := self._menu_options.get(selection_name, None): if not selection.enabled: return False if len(selection.dependencies) > 0: - for d in selection.dependencies: - if not self._verify_selection_enabled(d) or self._menu_options[d].is_empty(): - return False + for dep in selection.dependencies: + if isinstance(dep, str): + if not self._verify_selection_enabled(dep) or self._menu_options[dep].is_empty(): + return False + elif callable(dep): # callable dependency eval + return dep() + else: + raise ValueError(f'Unsupported dependency: {selection_name}') if len(selection.dependencies_not) > 0: - for d in selection.dependencies_not: - if not self._menu_options[d].is_empty(): + for dep in selection.dependencies_not: + if not self._menu_options[dep].is_empty(): return False return True @@ -454,8 +439,8 @@ class AbstractMenu: class AbstractSubMenu(AbstractMenu): - def __init__(self, data_store: Dict[str, Any] = {}): - super().__init__(data_store=data_store) + def __init__(self, data_store: Dict[str, Any] = {}, preview_size: float = 0.2): + super().__init__(data_store=data_store, preview_size=preview_size) self._menu_options['__separator__'] = Selector('') self._menu_options['back'] = \ diff --git a/archinstall/lib/menu/list_manager.py b/archinstall/lib/menu/list_manager.py index 54fb6a1b..de18791c 100644 --- a/archinstall/lib/menu/list_manager.py +++ b/archinstall/lib/menu/list_manager.py @@ -3,6 +3,7 @@ from os import system from typing import Any, TYPE_CHECKING, Dict, Optional, Tuple, List from .menu import Menu +from ..output import FormattedOutput if TYPE_CHECKING: _: Any @@ -127,18 +128,29 @@ class ListManager: if choice.value and choice.value != self._cancel_action: self._data = self.handle_action(choice.value, entry, self._data) - def selected_action_display(self, selection: Any) -> str: + def reformat(self, data: List[Any]) -> Dict[str, Optional[Any]]: """ - this will return the value to be displayed in the - "Select an action for '{}'" string + Default implementation of the table to be displayed. + Override if any custom formatting is needed """ - raise NotImplementedError('Please implement me in the child class') + table = FormattedOutput.as_table(data) + rows = table.split('\n') - def reformat(self, data: List[Any]) -> Dict[str, Optional[Any]]: + # these are the header rows of the table and do not map to any User obviously + # we're adding 2 spaces as prefix because the menu selector '> ' will be put before + # the selectable rows so the header has to be aligned + display_data: Dict[str, Optional[Any]] = {f' {rows[0]}': None, f' {rows[1]}': None} + + for row, entry in zip(rows[2:], data): + row = row.replace('|', '\\|') + display_data[row] = entry + + return display_data + + def selected_action_display(self, selection: Any) -> str: """ - this should return a dictionary of display string to actual data entry - mapping; if the value for a given display string is None it will be used - in the header value (useful when displaying tables) + this will return the value to be displayed in the + "Select an action for '{}'" string """ raise NotImplementedError('Please implement me in the child class') diff --git a/archinstall/lib/menu/menu.py b/archinstall/lib/menu/menu.py index f14b855d..38301d3a 100644 --- a/archinstall/lib/menu/menu.py +++ b/archinstall/lib/menu/menu.py @@ -66,7 +66,7 @@ class Menu(TerminalMenu): sort: bool = True, preset_values: Optional[Union[str, List[str]]] = None, cursor_index: Optional[int] = None, - preview_command: Optional[Callable] = None, + preview_command: Optional[Callable[[Any], str | None]] = None, preview_size: float = 0.0, preview_title: str = 'Info', header: Union[List[str], str] = [], @@ -228,7 +228,11 @@ class Menu(TerminalMenu): default_str = str(_('(default)')) return f'{self._default_option} {default_str}' - def _show_preview(self, preview_command: Optional[Callable], selection: str) -> Optional[str]: + def _show_preview( + self, + preview_command: Optional[Callable[[Any], str | None]], + selection: str + ) -> Optional[str]: if selection == self.back(): return None diff --git a/archinstall/lib/menu/table_selection_menu.py b/archinstall/lib/menu/table_selection_menu.py index 4cff7216..fec6ae59 100644 --- a/archinstall/lib/menu/table_selection_menu.py +++ b/archinstall/lib/menu/table_selection_menu.py @@ -19,6 +19,7 @@ class TableMenu(Menu): preview_size: float = 0.0, allow_reset: bool = True, allow_reset_warning_msg: Optional[str] = None, + skip: bool = True ): """ param title: Text that will be displayed above the menu @@ -81,7 +82,8 @@ class TableMenu(Menu): preview_title=preview_title, extra_bottom_space=extra_bottom_space, allow_reset=allow_reset, - allow_reset_warning_msg=allow_reset_warning_msg + allow_reset_warning_msg=allow_reset_warning_msg, + skip=skip ) def _preset_values(self, preset: List[Any]) -> List[str]: diff --git a/archinstall/lib/mirrors.py b/archinstall/lib/mirrors.py index 61f3c568..c9094669 100644 --- a/archinstall/lib/mirrors.py +++ b/archinstall/lib/mirrors.py @@ -121,21 +121,6 @@ class CustomMirrorList(ListManager): ] super().__init__(prompt, custom_mirrors, [self._actions[0]], self._actions[1:]) - def reformat(self, data: List[CustomMirror]) -> Dict[str, Any]: - table = FormattedOutput.as_table(data) - rows = table.split('\n') - - # these are the header rows of the table and do not map to any User obviously - # we're adding 2 spaces as prefix because the menu selector '> ' will be put before - # the selectable rows so the header has to be aligned - display_data: Dict[str, Optional[CustomMirror]] = {f' {rows[0]}': None, f' {rows[1]}': None} - - for row, user in zip(rows[2:], data): - row = row.replace('|', '\\|') - display_data[row] = user - - return display_data - def selected_action_display(self, mirror: CustomMirror) -> str: return mirror.name diff --git a/archinstall/scripts/guided.py b/archinstall/scripts/guided.py index b1fc8fd9..385ff500 100644 --- a/archinstall/scripts/guided.py +++ b/archinstall/scripts/guided.py @@ -104,7 +104,7 @@ def perform_installation(mountpoint: Path): Only requirement is that the block devices are formatted and setup prior to entering this function. """ - info('Starting installation') + info('Starting installation...') disk_config: disk.DiskLayoutConfiguration = archinstall.arguments['disk_config'] # Retrieve list of additional repositories and set boolean values appropriately diff --git a/examples/interactive_installation.py b/examples/interactive_installation.py index 3c9a5876..4513b6f2 100644 --- a/examples/interactive_installation.py +++ b/examples/interactive_installation.py @@ -82,7 +82,7 @@ def perform_installation(mountpoint: Path): Only requirement is that the block devices are formatted and setup prior to entering this function. """ - info('Starting installation') + info('Starting installation...') disk_config: disk.DiskLayoutConfiguration = archinstall.arguments['disk_config'] # Retrieve list of additional repositories and set boolean values appropriately -- cgit v1.2.3-70-g09d2