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/profile/__init__.py | 0 archinstall/lib/profile/profile_menu.py | 203 +++++++++++++++ archinstall/lib/profile/profile_model.py | 35 +++ archinstall/lib/profile/profiles_handler.py | 391 ++++++++++++++++++++++++++++ 4 files changed, 629 insertions(+) create mode 100644 archinstall/lib/profile/__init__.py create mode 100644 archinstall/lib/profile/profile_menu.py create mode 100644 archinstall/lib/profile/profile_model.py create mode 100644 archinstall/lib/profile/profiles_handler.py (limited to 'archinstall/lib/profile') diff --git a/archinstall/lib/profile/__init__.py b/archinstall/lib/profile/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/archinstall/lib/profile/profile_menu.py b/archinstall/lib/profile/profile_menu.py new file mode 100644 index 00000000..6462685a --- /dev/null +++ b/archinstall/lib/profile/profile_menu.py @@ -0,0 +1,203 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Optional, Dict + +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 + +if TYPE_CHECKING: + _: Any + + +class ProfileMenu(AbstractSubMenu): + def __init__( + self, + data_store: Dict[str, Any], + preset: Optional[ProfileConfiguration] = None + ): + if preset: + self._preset = preset + else: + self._preset = ProfileConfiguration() + + super().__init__(data_store=data_store) + + def setup_selection_menu_options(self): + self._menu_options['profile'] = Selector( + _('Profile'), + lambda x: self._select_profile(x), + display_func=lambda x: x.name if x else None, + preview_func=self._preview_profile, + default=self._preset.profile, + enabled=True + ) + + self._menu_options['gfx_driver'] = Selector( + _('Graphics driver'), + lambda preset: self._select_gfx_driver(preset), + display_func=lambda x: x if x else None, + dependencies=['profile'], + 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 + ) + + self._menu_options['greeter'] = Selector( + _('Greeter'), + lambda preset: select_greeter(self._menu_options['profile'].current_selection, preset), + display_func=lambda x: x.value if x else None, + dependencies=['profile'], + default=self._preset.greeter if self._preset.profile and self._preset.profile.is_greeter_supported() else None, + enabled=self._preset.profile.is_greeter_supported() if self._preset.profile else False + ) + + def run(self, allow_reset: bool = True) -> Optional[ProfileConfiguration]: + super().run(allow_reset=allow_reset) + + if self._data_store.get('profile', None): + return ProfileConfiguration( + self._menu_options['profile'].current_selection, + self._menu_options['gfx_driver'].current_selection, + self._menu_options['greeter'].current_selection + ) + + return None + + 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) + self._menu_options['gfx_driver'].set_current_selection(None) + else: + self._menu_options['gfx_driver'].set_enabled(True) + self._menu_options['gfx_driver'].set_current_selection('All open-source (default)') + + if not profile.is_greeter_supported(): + self._menu_options['greeter'].set_enabled(False) + self._menu_options['greeter'].set_current_selection(None) + else: + self._menu_options['greeter'].set_enabled(True) + self._menu_options['greeter'].set_current_selection(profile.default_greeter_type) + else: + self._menu_options['gfx_driver'].set_current_selection(None) + self._menu_options['greeter'].set_current_selection(None) + + return profile + + def _select_gfx_driver(self, preset: Optional[str] = None) -> Optional[str]: + driver = preset + profile: Optional[Profile] = self._menu_options['profile'].current_selection + + if profile: + if profile.is_graphic_driver_supported(): + driver = select_driver(current_value=preset) + + if driver and 'Sway' in profile.current_selection_names(): + packages = AVAILABLE_GFX_DRIVERS[driver] + + if packages and "nvidia" in packages: + prompt = str( + _('The proprietary Nvidia driver is not supported by Sway. It is likely that you will run into issues, are you okay with that?')) + choice = Menu(prompt, Menu.yes_no(), default_option=Menu.no(), skip=False).run() + + if choice.value == Menu.no(): + return None + + return driver + + def _preview_profile(self) -> Optional[str]: + profile: Optional[Profile] = self._menu_options['profile'].current_selection + + if profile: + names = profile.current_selection_names() + return '\n'.join(names) + + return None + + +def select_greeter( + profile: Optional[Profile] = None, + preset: Optional[GreeterType] = None +) -> Optional[GreeterType]: + if not profile or profile.is_greeter_supported(): + title = str(_('Please chose which greeter to install')) + greeter_options = [greeter.value for greeter in GreeterType] + + default: Optional[GreeterType] = None + + if preset is not None: + default = preset + elif profile is not None: + default_greeter = profile.default_greeter_type + default = default_greeter if default_greeter else None + + choice = Menu( + title, + greeter_options, + skip=True, + default_option=default.value if default else None + ).run() + + match choice.type_: + case MenuSelectionType.Skip: + return default + + return GreeterType(choice.single_value) + + return None + + +def select_profile( + current_profile: Optional[Profile] = None, + title: Optional[str] = None, + allow_reset: bool = True, + multi: bool = False +) -> Optional[Profile]: + from archinstall.lib.profile.profiles_handler import profile_handler + top_level_profiles = profile_handler.get_top_level_profiles() + + display_title = title + if not display_title: + display_title = str(_('This is a list of pre-programmed default_profiles')) + + choice = profile_handler.select_profile( + top_level_profiles, + current_profile=current_profile, + title=display_title, + allow_reset=allow_reset, + multi=multi + ) + + match choice.type_: + case MenuSelectionType.Selection: + profile_selection: Profile = choice.single_value + select_result = profile_selection.do_on_select() + + if not select_result: + return select_profile( + current_profile=current_profile, + title=title, + allow_reset=allow_reset, + multi=multi + ) + + # we're going to reset the currently selected profile(s) to avoid + # any stale data laying around + match select_result: + case select_result.NewSelection: + profile_handler.reset_top_level_profiles(exclude=[profile_selection]) + current_profile = profile_selection + case select_result.ResetCurrent: + profile_handler.reset_top_level_profiles() + current_profile = None + case select_result.SameSelection: + pass + + return current_profile + case MenuSelectionType.Reset: + return None + case MenuSelectionType.Skip: + return current_profile diff --git a/archinstall/lib/profile/profile_model.py b/archinstall/lib/profile/profile_model.py new file mode 100644 index 00000000..ad3015ae --- /dev/null +++ b/archinstall/lib/profile/profile_model.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Optional, Dict + +from archinstall.default_profiles.profile import Profile, GreeterType + +if TYPE_CHECKING: + _: Any + + +@dataclass +class ProfileConfiguration: + profile: Optional[Profile] = None + gfx_driver: Optional[str] = None + greeter: Optional[GreeterType] = None + + def json(self) -> Dict[str, Any]: + from .profiles_handler import profile_handler + return { + 'profile': profile_handler.to_json(self.profile), + 'gfx_driver': self.gfx_driver, + 'greeter': self.greeter.value if self.greeter else None + } + + @classmethod + def parse_arg(cls, arg: Dict[str, Any]) -> 'ProfileConfiguration': + from .profiles_handler import profile_handler + greeter = arg.get('greeter', None) + + return ProfileConfiguration( + profile_handler.parse_profile_config(arg['profile']), + arg.get('gfx_driver', None), + GreeterType(greeter) if greeter else None + ) diff --git a/archinstall/lib/profile/profiles_handler.py b/archinstall/lib/profile/profiles_handler.py new file mode 100644 index 00000000..063b12ea --- /dev/null +++ b/archinstall/lib/profile/profiles_handler.py @@ -0,0 +1,391 @@ +from __future__ import annotations + +import importlib.util +import logging +import sys +from collections import Counter +from functools import cached_property +from pathlib import Path +from tempfile import NamedTemporaryFile +from types import ModuleType +from typing import List, TYPE_CHECKING, Any, Optional, Dict, Union + +from archinstall.default_profiles.profile import Profile, TProfile, GreeterType +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 ..storage import storage + +if TYPE_CHECKING: + from ..installer import Installer + _: Any + + +class ProfileHandler: + def __init__(self): + self._profiles_path: Path = storage['PROFILE'] + self._profiles = None + + # special variable to keep track of a profile url configuration + # it is merely used to be able to export the path again when a user + # wants to save the configuration + self._url_path = None + + def to_json(self, profile: Optional[Profile]) -> Dict[str, Any]: + """ + Serialize the selected profile setting to JSON + """ + data: Dict[str, Any] = {} + + if profile is not None: + data = { + 'main': profile.name, + 'details': [profile.name for profile in profile.current_selection], + } + + if self._url_path is not None: + data['path'] = self._url_path + + return data + + def parse_profile_config(self, profile_config: Dict[str, Any]) -> Optional[Profile]: + """ + Deserialize JSON configuration + """ + profile = None + + # the order of these is important, we want to + # load all the default_profiles from url and custom + # so that we can then apply whatever was specified + # in the main/detail sections + if url_path := profile_config.get('path', None): + self._url_path = url_path + local_path = Path(url_path) + + if local_path.is_file(): + profiles = self._process_profile_file(local_path) + self.remove_custom_profiles(profiles) + self.add_custom_profiles(profiles) + else: + self._import_profile_from_url(url_path) + + if custom := profile_config.get('custom', None): + from archinstall.default_profiles.custom import CustomTypeProfile + custom_types = [] + + for entry in custom: + custom_types.append( + CustomTypeProfile( + entry['name'], + entry['enabled'], + entry.get('packages', []), + entry.get('services', []) + ) + ) + + self.remove_custom_profiles(custom_types) + self.add_custom_profiles(custom_types) + + # this doesn't mean it's actual going to be set as a selection + # but we are simply populating the custom profile with all + # possible custom definitions + if custom_profile := self.get_profile_by_name('Custom'): + custom_profile.set_current_selection(custom_types) + + if main := profile_config.get('main', None): + profile = self.get_profile_by_name(main) if main else None + + valid: List[Profile] = [] + if details := profile_config.get('details', []): + resolved = {detail: self.get_profile_by_name(detail) for detail in details if detail} + valid = [p for p in resolved.values() if p is not None] + invalid = ', '.join([k for k, v in resolved.items() if v is None]) + + if invalid: + log(f'No profile definition found: {invalid}') + + if profile is not None: + profile.set_current_selection(valid) + + return profile + + @property + def profiles(self) -> List[Profile]: + """ + List of all available default_profiles + """ + if self._profiles is None: + self._profiles = self._find_available_profiles() + return self._profiles + + @cached_property + def _local_mac_addresses(self) -> List[str]: + ifaces = list_interfaces() + return list(ifaces.keys()) + + def add_custom_profiles(self, profiles: Union[TProfile, List[TProfile]]): + if not isinstance(profiles, list): + profiles = [profiles] + + for profile in profiles: + self._profiles.append(profile) + + 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] + + def get_profile_by_name(self, name: str) -> Optional[Profile]: + return next(filter(lambda x: x.name == name, self.profiles), None) # type: ignore + + def get_top_level_profiles(self) -> List[Profile]: + return list(filter(lambda x: x.is_top_level_profile(), self.profiles)) + + def get_server_profiles(self) -> List[Profile]: + return list(filter(lambda x: x.is_server_type_profile(), self.profiles)) + + def get_desktop_profiles(self) -> List[Profile]: + return list(filter(lambda x: x.is_desktop_type_profile(), self.profiles)) + + def get_custom_profiles(self) -> List[Profile]: + return list(filter(lambda x: x.is_custom_type_profile(), self.profiles)) + + def get_mac_addr_profiles(self) -> List[Profile]: + tailored = list(filter(lambda x: x.is_tailored(), self.profiles)) + match_mac_addr_profiles = list(filter(lambda x: x.name in self._local_mac_addresses, tailored)) + return match_mac_addr_profiles + + def install_greeter(self, install_session: 'Installer', greeter: GreeterType): + packages = [] + service = None + + match greeter: + case GreeterType.Lightdm: + packages = ['lightdm', 'lightdm-gtk-greeter'] + service = ['lightdm'] + case GreeterType.Sddm: + packages = ['sddm'] + service = ['sddm'] + case GreeterType.Gdm: + packages = ['gdm'] + service = ['gdm'] + + if packages: + install_session.add_additional_packages(packages) + if service: + install_session.enable_service(service) + + def install_gfx_driver(self, install_session: 'Installer', driver: str): + try: + driver_pkgs = AVAILABLE_GFX_DRIVERS[driver] if driver else [] + additional_pkg = ' '.join(['xorg-server', 'xorg-xinit'] + driver_pkgs) + + if driver is not None: + if 'nvidia' in driver: + if "linux-zen" in install_session.base_packages or "linux-lts" in install_session.base_packages: + for kernel in install_session.kernels: + # Fixes https://github.com/archlinux/archinstall/issues/585 + 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") + 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 '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") + + def install_profile_config(self, install_session: 'Installer', profile_config: ProfileConfiguration): + profile = profile_config.profile + + if profile: + profile.install(install_session) + + if profile and profile_config.gfx_driver: + if profile.is_xorg_type_profile() or profile.is_desktop_type_profile(): + self.install_gfx_driver(install_session, profile_config.gfx_driver) + + if profile and profile_config.greeter: + self.install_greeter(install_session, profile_config.greeter) + + def _import_profile_from_url(self, url: str): + """ + Import default_profiles from a url path + """ + try: + data = fetch_data_from_url(url) + b_data = bytes(data, 'utf-8') + + with NamedTemporaryFile(delete=False, suffix='.py') as fp: + fp.write(b_data) + filepath = Path(fp.name) + + profiles = self._process_profile_file(filepath) + self.remove_custom_profiles(profiles) + 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") + + def _load_profile_class(self, module: ModuleType) -> List[Profile]: + """ + Load all default_profiles defined in a module + """ + profiles = [] + for k, v in module.__dict__.items(): + if isinstance(v, type) and v.__module__ == module.__name__: + try: + cls_ = v() + 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) + + return profiles + + def _verify_unique_profile_names(self, profiles: List[Profile]): + """ + All profile names have to be unique, this function will verify + that the provided list contains only default_profiles with unique names + """ + counter = Counter([p.name for p in profiles]) + duplicates = list(filter(lambda x: x[1] != 1, counter.items())) + + 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") + sys.exit(1) + + def _is_legacy(self, file: Path) -> bool: + """ + Check if the provided profile file contains a + legacy profile definition + """ + with open(file, 'r') as fp: + for line in fp.readlines(): + if '__packages__' in line: + return True + return False + + def _process_profile_file(self, file: Path) -> List[Profile]: + """ + 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') + return [] + + if not file.is_file(): + log(f'Cannot find profile file {file}') + return [] + + name = file.name.removesuffix(file.suffix) + log(f'Importing profile: {file}', level=logging.DEBUG) + + try: + spec = importlib.util.spec_from_file_location(name, file) + if spec is not None: + imported = importlib.util.module_from_spec(spec) + if spec.loader is not None: + 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) + + return [] + + def _find_available_profiles(self) -> List[Profile]: + """ + Search the profile path for profile definitions + """ + profiles = [] + for file in self._profiles_path.glob('**/*.py'): + # ignore the abstract default_profiles class + if 'profile.py' in file.name: + continue + profiles += self._process_profile_file(file) + + self._verify_unique_profile_names(profiles) + return profiles + + def reset_top_level_profiles(self, exclude: List[Profile] = []): + """ + Reset all top level profile configurations, this is usually necessary + when a new top level profile is selected + """ + excluded_profiles = [p.name for p in exclude] + for profile in self.get_top_level_profiles(): + if profile.name not in excluded_profiles: + profile.reset() + + def select_profile( + self, + selectable_profiles: List[Profile], + current_profile: Optional[Union[TProfile, List[TProfile]]] = None, + title: str = '', + allow_reset: bool = True, + multi: bool = False, + ) -> MenuSelection: + """ + Helper function to perform a profile selection + """ + options = {p.name: p for p in selectable_profiles} + + warning = str(_('Are you sure you want to reset this setting?')) + + preset_value: Optional[Union[str, List[str]]] = None + if current_profile is not None: + if isinstance(current_profile, list): + preset_value = [p.name for p in current_profile] + else: + preset_value = current_profile.name + + choice = Menu( + title=title, + preset_values=preset_value, + p_options=options, + allow_reset=allow_reset, + allow_reset_warning_msg=warning, + multi=multi, + sort=True, + preview_command=self.preview_text, + preview_size=0.5 + ).run() + + if choice.type_ == MenuSelectionType.Selection: + value = choice.value + if multi: + # this is quite dirty and should eb switched to a + # dedicated return type instead + choice.value = [options[val] for val in value] # type: ignore + else: + choice.value = options[value] # type: ignore + + return choice + + def preview_text(self, selection: str) -> Optional[str]: + """ + Callback for preview display on profile selection + """ + profile = self.get_profile_by_name(selection) + return profile.preview_text() if profile is not None else None + + +profile_handler = ProfileHandler() -- cgit v1.2.3-54-g00ecf