diff --git a/.github/ISSUE_TEMPLATE/01_bug.yml b/.github/ISSUE_TEMPLATE/01_bug.yml index ab40754f..92061f64 100644 --- a/.github/ISSUE_TEMPLATE/01_bug.yml +++ b/.github/ISSUE_TEMPLATE/01_bug.yml @@ -41,8 +41,8 @@ body: attributes: value: > **Note**: Assuming you have network connectivity, - you can easily post the installation log using the following command: - `curl -F'file=@/var/log/archinstall/install.log' https://0x0.st` + you can easily upload the installation log and get a shareable URL by running: + `archinstall share-log` - type: textarea id: freeform diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7ee79056..c573ae04 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -31,7 +31,7 @@ repos: args: [--config=.flake8] fail_fast: true - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.20.2 + rev: v2.1.0 hooks: - id: mypy args: [ diff --git a/README.md b/README.md index 95d15702..4e6847f4 100644 --- a/README.md +++ b/README.md @@ -101,9 +101,9 @@ If you come across any issues, kindly submit your issue here on GitHub or post y When submitting an issue, please: * Provide the stacktrace of the output if applicable * Attach the `/var/log/archinstall/install.log` to the issue ticket. This helps us help you! - * To extract the log from the ISO image, one way is to use
+ * To upload the log from the ISO image and get a shareable URL, run
```shell - curl -F'file=@/var/log/archinstall/install.log' https://0x0.st + archinstall share-log ``` diff --git a/archinstall/default_profiles/desktop.py b/archinstall/default_profiles/desktop.py index 19681bc7..daf70831 100644 --- a/archinstall/default_profiles/desktop.py +++ b/archinstall/default_profiles/desktop.py @@ -9,6 +9,7 @@ from archinstall.tui.result import ResultType if TYPE_CHECKING: from archinstall.lib.installer import Installer + from archinstall.lib.models.users import User class DesktopProfile(Profile): @@ -88,6 +89,11 @@ class DesktopProfile(Profile): for profile in self.current_selection: profile.post_install(install_session) + @override + def provision(self, install_session: Installer, users: list[User]) -> None: + for profile in self.current_selection: + profile.provision(install_session, users) + @override def install(self, install_session: Installer) -> None: # Install common packages for all desktop environments diff --git a/archinstall/default_profiles/desktops/bspwm.py b/archinstall/default_profiles/desktops/bspwm.py index f8c2a7b2..8886298a 100644 --- a/archinstall/default_profiles/desktops/bspwm.py +++ b/archinstall/default_profiles/desktops/bspwm.py @@ -1,6 +1,8 @@ from typing import override from archinstall.default_profiles.profile import DisplayServerType, GreeterType, Profile, ProfileType +from archinstall.lib.installer import Installer +from archinstall.lib.models.users import User class BspwmProfile(Profile): @@ -27,3 +29,11 @@ class BspwmProfile(Profile): @override def default_greeter_type(self) -> GreeterType: return GreeterType.Lightdm + + @override + def provision(self, install_session: Installer, users: list[User]) -> None: + for user in users: + install_session.arch_chroot('mkdir -p ~/.config/bspwm ~/.config/sxhkd', run_as=user.username) + install_session.arch_chroot('cp /usr/share/doc/bspwm/examples/bspwmrc ~/.config/bspwm/', run_as=user.username) + install_session.arch_chroot('cp /usr/share/doc/bspwm/examples/sxhkdrc ~/.config/sxhkd/', run_as=user.username) + install_session.arch_chroot('chmod +x ~/.config/bspwm/bspwmrc', run_as=user.username) diff --git a/archinstall/default_profiles/desktops/budgie.py b/archinstall/default_profiles/desktops/budgie.py index f156a53b..da147851 100644 --- a/archinstall/default_profiles/desktops/budgie.py +++ b/archinstall/default_profiles/desktops/budgie.py @@ -18,12 +18,13 @@ class BudgieProfile(Profile): return [ 'materia-gtk-theme', 'budgie', - 'konsole', - 'dolphin', + 'mate-terminal', + 'nemo', + 'nemo-fileroller', 'papirus-icon-theme', ] @property @override def default_greeter_type(self) -> GreeterType: - return GreeterType.Sddm + return GreeterType.LightdmSlick diff --git a/archinstall/lib/args.py b/archinstall/lib/args.py index 2d9b4489..b65024d3 100644 --- a/archinstall/lib/args.py +++ b/archinstall/lib/args.py @@ -6,6 +6,7 @@ import urllib.error import urllib.parse from argparse import ArgumentParser, Namespace from dataclasses import dataclass, field +from enum import StrEnum from pathlib import Path from typing import Any, Self from urllib.request import Request, urlopen @@ -17,6 +18,7 @@ from archinstall.lib.menu.util import get_password from archinstall.lib.models.application import ApplicationConfiguration, ZramConfiguration from archinstall.lib.models.authentication import AuthenticationConfiguration from archinstall.lib.models.bootloader import Bootloader, BootloaderConfiguration +from archinstall.lib.models.config import SubConfig from archinstall.lib.models.device import DiskEncryption, DiskLayoutConfiguration from archinstall.lib.models.locale import LocaleConfiguration from archinstall.lib.models.mirrors import MirrorConfiguration @@ -57,121 +59,222 @@ class Arguments: verbose: bool = False +class ArchConfigType(StrEnum): + VERSION = 'version' + SCRIPT = 'script' + LOCALE_CONFIG = 'locale_config' + ARCHINSTALL_LANGUAGE = 'archinstall_language' + DISK_CONFIG = 'disk_config' + PROFILE_CONFIG = 'profile_config' + MIRROR_CONFIG = 'mirror_config' + NETWORK_CONFIG = 'network_config' + BOOTLOADER_CONFIG = 'bootloader_config' + APP_CONFIG = 'app_config' + AUTH_CONFIG = 'auth_config' + SWAP = 'swap' + USERS = 'users' + ROOT_ENC_PASSWORD = 'root_enc_password' + ENCRYPTION_PASSWORD = 'encryption_password' + HOSTNAME = 'hostname' + KERNELS = 'kernels' + NTP = 'ntp' + TIMEZONE = 'timezone' + SERVICES = 'services' + PACKAGES = 'packages' + PACMAN_CONFIG = 'pacman_config' + CUSTOM_COMMANDS = 'custom_commands' + + def text(self) -> str: + match self: + case ArchConfigType.ARCHINSTALL_LANGUAGE: + return tr('ArchInstall Language') + case ArchConfigType.VERSION: + return tr('Version') + case ArchConfigType.SCRIPT: + return tr('Installation Script') + case ArchConfigType.LOCALE_CONFIG: + return tr('Locales') + case ArchConfigType.DISK_CONFIG: + return tr('Disk configuration') + case ArchConfigType.PROFILE_CONFIG: + return tr('Profile') + case ArchConfigType.MIRROR_CONFIG: + return tr('Mirrors and repositories') + case ArchConfigType.NETWORK_CONFIG: + return tr('Network') + case ArchConfigType.BOOTLOADER_CONFIG: + return tr('Bootloader') + case ArchConfigType.APP_CONFIG: + return tr('Application') + case ArchConfigType.AUTH_CONFIG: + return tr('Authentication') + case ArchConfigType.SWAP: + return tr('Swap') + case ArchConfigType.HOSTNAME: + return tr('Hostname') + case ArchConfigType.KERNELS: + return tr('Kernels') + case ArchConfigType.NTP: + return tr('Automatic time sync (NTP)') + case ArchConfigType.TIMEZONE: + return tr('Timezone') + case ArchConfigType.SERVICES: + return tr('Services') + case ArchConfigType.PACKAGES: + return tr('Additional packages') + case ArchConfigType.PACMAN_CONFIG: + return tr('Pacman') + case ArchConfigType.CUSTOM_COMMANDS: + return tr('Custom commands') + case ArchConfigType.USERS: + return tr('Users') + case ArchConfigType.ROOT_ENC_PASSWORD: + return tr('Root encrypted password') + case ArchConfigType.ENCRYPTION_PASSWORD: + return tr('Disk encryption password') + + @dataclass class ArchConfig: - version: str | None = None - script: str | None = None - locale_config: LocaleConfiguration | None = None - archinstall_language: Language = field(default_factory=lambda: translation_handler.get_language_by_abbr('en')) - disk_config: DiskLayoutConfiguration | None = None - profile_config: ProfileConfiguration | None = None - mirror_config: MirrorConfiguration | None = None - network_config: NetworkConfiguration | None = None - bootloader_config: BootloaderConfiguration | None = None - app_config: ApplicationConfiguration | None = None - auth_config: AuthenticationConfiguration | None = None - swap: ZramConfiguration | None = None - hostname: str = 'archlinux' - kernels: list[str] = field(default_factory=lambda: [DEFAULT_KERNEL.value]) - ntp: bool = True - packages: list[str] = field(default_factory=list) - pacman_config: PacmanConfiguration = field(default_factory=PacmanConfiguration.default) - timezone: str = 'UTC' - services: list[str] = field(default_factory=list) - custom_commands: list[str] = field(default_factory=list) + version: str | None = None + script: str | None = None + locale_config: LocaleConfiguration | None = None + archinstall_language: Language = field(default_factory=lambda: translation_handler.get_language_by_abbr('en')) + disk_config: DiskLayoutConfiguration | None = None + profile_config: ProfileConfiguration | None = None + mirror_config: MirrorConfiguration | None = None + network_config: NetworkConfiguration | None = None + bootloader_config: BootloaderConfiguration | None = None + app_config: ApplicationConfiguration | None = None + auth_config: AuthenticationConfiguration | None = None + swap: ZramConfiguration | None = None + hostname: str = 'archlinux' + kernels: list[str] = field(default_factory=lambda: [DEFAULT_KERNEL.value]) + ntp: bool = True + packages: list[str] = field(default_factory=list) + pacman_config: PacmanConfiguration = field(default_factory=PacmanConfiguration.default) + timezone: str = 'UTC' + services: list[str] = field(default_factory=list) + custom_commands: list[str] = field(default_factory=list) - def unsafe_config(self) -> dict[str, Any]: - config: dict[str, list[UserSerialization] | str | None] = {} + def unsafe_config(self) -> dict[ArchConfigType, Any]: + config: dict[ArchConfigType, list[UserSerialization] | str | None] = {} - if self.auth_config: - if self.auth_config.users: - config['users'] = [user.json() for user in self.auth_config.users] + if self.auth_config: + if self.auth_config.users: + config[ArchConfigType.USERS] = [user.json() for user in self.auth_config.users] - if self.auth_config.root_enc_password: - config['root_enc_password'] = self.auth_config.root_enc_password.enc_password + if self.auth_config.root_enc_password: + config[ArchConfigType.ROOT_ENC_PASSWORD] = self.auth_config.root_enc_password.enc_password - if self.disk_config: - disk_encryption = self.disk_config.disk_encryption - if disk_encryption and disk_encryption.encryption_password: - config['encryption_password'] = disk_encryption.encryption_password.plaintext + if self.disk_config: + disk_encryption = self.disk_config.disk_encryption + if disk_encryption and disk_encryption.encryption_password: + config[ArchConfigType.ENCRYPTION_PASSWORD] = disk_encryption.encryption_password.plaintext - return config + return config - def safe_config(self) -> dict[str, Any]: - config: Any = { - 'version': self.version, - 'script': self.script, - 'archinstall-language': self.archinstall_language.json(), - 'hostname': self.hostname, - 'kernels': self.kernels, - 'ntp': self.ntp, - 'packages': self.packages, - 'pacman_config': self.pacman_config.json(), - 'swap': self.swap, - 'timezone': self.timezone, - 'services': self.services, - 'custom_commands': self.custom_commands, - 'bootloader_config': self.bootloader_config.json() if self.bootloader_config else None, - 'app_config': self.app_config.json() if self.app_config else None, - 'auth_config': self.auth_config.json() if self.auth_config else None, - } + def safe_config(self) -> dict[ArchConfigType, Any]: + base_config: dict[ArchConfigType, Any] = { + ArchConfigType.VERSION: self.version, + ArchConfigType.SCRIPT: self.script, + ArchConfigType.ARCHINSTALL_LANGUAGE: self.archinstall_language.json(), + } - if self.locale_config: - config['locale_config'] = self.locale_config.json() + base_config.update(self.plain_cfg()) + sub_config = self.sub_cfg() - if self.disk_config: - config['disk_config'] = self.disk_config.json() + for config_type, value in sub_config.items(): + if not hasattr(value, 'json'): + raise ValueError(f'Config value for {config_type} must implement json() method') + base_config[config_type] = value.json() - if self.profile_config: - config['profile_config'] = self.profile_config.json() + return base_config - if self.mirror_config: - config['mirror_config'] = self.mirror_config.json() + def plain_cfg(self) -> dict[ArchConfigType, str | list[str] | bool]: + return { + ArchConfigType.HOSTNAME: self.hostname, + ArchConfigType.KERNELS: self.kernels, + ArchConfigType.NTP: self.ntp, + ArchConfigType.TIMEZONE: self.timezone, + ArchConfigType.SERVICES: self.services, + ArchConfigType.PACKAGES: self.packages, + ArchConfigType.CUSTOM_COMMANDS: self.custom_commands, + } - if self.network_config: - config['network_config'] = self.network_config.json() + def sub_cfg(self) -> dict[ArchConfigType, SubConfig]: + cfg: dict[ArchConfigType, SubConfig] = { + ArchConfigType.PACMAN_CONFIG: self.pacman_config, + } - return config + if self.mirror_config: + cfg[ArchConfigType.MIRROR_CONFIG] = self.mirror_config - @classmethod - def from_config(cls, args_config: dict[str, Any], args: Arguments) -> Self: - arch_config = cls() + if self.bootloader_config: + cfg[ArchConfigType.BOOTLOADER_CONFIG] = self.bootloader_config - arch_config.locale_config = LocaleConfiguration.parse_arg(args_config) + if self.disk_config: + cfg[ArchConfigType.DISK_CONFIG] = self.disk_config - if script := args_config.get('script', None): - arch_config.script = script + if self.swap: + cfg[ArchConfigType.SWAP] = self.swap - if archinstall_lang := args_config.get('archinstall-language', None): - arch_config.archinstall_language = translation_handler.get_language_by_name(archinstall_lang) - translation_handler.activate(arch_config.archinstall_language, set_font=False) + if self.auth_config: + cfg[ArchConfigType.AUTH_CONFIG] = self.auth_config - if disk_config := args_config.get('disk_config', {}): - enc_password = args_config.get('encryption_password', '') - password = Password(plaintext=enc_password) if enc_password else None - arch_config.disk_config = DiskLayoutConfiguration.parse_arg(disk_config, password) + if self.locale_config: + cfg[ArchConfigType.LOCALE_CONFIG] = self.locale_config - # DEPRECATED - # backwards compatibility for main level disk_encryption entry - disk_encryption: DiskEncryption | None = None + if self.profile_config: + cfg[ArchConfigType.PROFILE_CONFIG] = self.profile_config - if args_config.get('disk_encryption', None) is not None and arch_config.disk_config is not None: - disk_encryption = DiskEncryption.parse_arg( - arch_config.disk_config, - args_config['disk_encryption'], - Password(plaintext=args_config.get('encryption_password', '')), - ) + if self.network_config: + cfg[ArchConfigType.NETWORK_CONFIG] = self.network_config - if disk_encryption: - arch_config.disk_config.disk_encryption = disk_encryption + if self.app_config: + cfg[ArchConfigType.APP_CONFIG] = self.app_config - if profile_config := args_config.get('profile_config', None): - arch_config.profile_config = ProfileConfiguration.parse_arg(profile_config) + return cfg - if mirror_config := args_config.get('mirror_config', None): - backwards_compatible_repo = [] - if additional_repositories := args_config.get('additional-repositories', []): - backwards_compatible_repo = [Repository(r) for r in additional_repositories] + @classmethod + def from_config(cls, args_config: dict[str, Any], args: Arguments) -> Self: + arch_config = cls() + + arch_config.locale_config = LocaleConfiguration.parse_arg(args_config) + + if script := args_config.get('script', None): + arch_config.script = script + + if archinstall_lang := args_config.get('archinstall-language', None): + arch_config.archinstall_language = translation_handler.get_language_by_name(archinstall_lang) + translation_handler.activate(arch_config.archinstall_language, set_font=False) + + if disk_config := args_config.get('disk_config', {}): + enc_password = args_config.get('encryption_password', '') + password = Password(plaintext=enc_password) if enc_password else None + arch_config.disk_config = DiskLayoutConfiguration.parse_arg(disk_config, password) + + # DEPRECATED + # backwards compatibility for main level disk_encryption entry + disk_encryption: DiskEncryption | None = None + + if args_config.get('disk_encryption', None) is not None and arch_config.disk_config is not None: + disk_encryption = DiskEncryption.parse_arg( + arch_config.disk_config, + args_config['disk_encryption'], + Password(plaintext=args_config.get('encryption_password', '')), + ) + + if disk_encryption: + arch_config.disk_config.disk_encryption = disk_encryption + + if profile_config := args_config.get('profile_config', None): + arch_config.profile_config = ProfileConfiguration.parse_arg(profile_config) + + if mirror_config := args_config.get('mirror_config', None): + backwards_compatible_repo = [] + if additional_repositories := args_config.get('additional-repositories', []): + backwards_compatible_repo = [Repository(r) for r in additional_repositories] arch_config.mirror_config = MirrorConfiguration.parse_args( mirror_config, @@ -260,305 +363,298 @@ class ArchConfig: class ArchConfigHandler: - def __init__(self) -> None: - self._parser: ArgumentParser = self._define_arguments() - args: Arguments = self._parse_args() - self._args = args + def __init__(self) -> None: + self._parser: ArgumentParser = self._define_arguments() + args: Arguments = self._parse_args() + self._args = args - config = self._parse_config() + config = self._parse_config() - try: - self._config = ArchConfig.from_config(config, args) - self._config.version = get_version() - except ValueError as err: - warn(str(err)) - sys.exit(1) + try: + self._config = ArchConfig.from_config(config, args) + self._config.version = get_version() + except ValueError as err: + warn(str(err)) + sys.exit(1) - @property - def config(self) -> ArchConfig: - return self._config + @property + def config(self) -> ArchConfig: + return self._config - @property - def args(self) -> Arguments: - return self._args + @property + def args(self) -> Arguments: + return self._args - def get_script(self) -> str: - if script := self.args.script: - return script + def get_script(self) -> str: + if script := self.args.script: + return script - if script := self.config.script: - return script + if script := self.config.script: + return script - return 'guided' + return 'guided' - def print_help(self) -> None: - self._parser.print_help() + def print_help(self) -> None: + self._parser.print_help() - def _define_arguments(self) -> ArgumentParser: - parser = ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter) - parser.add_argument( - '-v', - '--version', - action='version', - default=False, - version='%(prog)s ' + get_version(), - ) - parser.add_argument( - '--config', - type=Path, - nargs='?', - default=None, - help='JSON configuration file', - ) - parser.add_argument( - '--config-url', - type=str, - nargs='?', - default=None, - help='Url to a JSON configuration file', - ) - parser.add_argument( - '--creds', - type=Path, - nargs='?', - default=None, - help='JSON credentials configuration file', - ) - parser.add_argument( - '--creds-url', - type=str, - nargs='?', - default=None, - help='Url to a JSON credentials configuration file', - ) - parser.add_argument( - '--creds-decryption-key', - type=str, - nargs='?', - default=None, - help='Decryption key for credentials file', - ) - parser.add_argument( - '--silent', - action='store_true', - default=False, - help='WARNING: Disables all prompts for input and confirmation. If no configuration is provided, this is ignored', - ) - parser.add_argument( - '--dry-run', - '--dry_run', - action='store_true', - default=False, - help='Generates a configuration file and then exits instead of performing an installation', - ) - parser.add_argument( - '--script', - nargs='?', - help='Script to run for installation', - type=str, - ) - parser.add_argument( - '--mountpoint', - type=Path, - nargs='?', - default=Path('/mnt'), - help='Define an alternate mount point for installation', - ) - parser.add_argument( - '--skip-ntp', - action='store_true', - help='Disables NTP checks during installation', - default=False, - ) - parser.add_argument( - '--skip-wkd', - action='store_true', - help='Disables checking if archlinux keyring wkd sync is complete.', - default=False, - ) - parser.add_argument( - '--skip-boot', - action='store_true', - help='Disables installation of a boot loader (note: only use this when problems arise with the boot loader step).', - 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.', - ) - parser.add_argument( - '--no-pkg-lookups', - action='store_true', - default=False, - help='Disabled package validation specifically prior to starting installation.', - ) - parser.add_argument( - '--plugin', - nargs='?', - type=str, - default=None, - help='File path to a plugin to load', - ) - parser.add_argument( - '--skip-version-check', - action='store_true', - default=False, - help='Skip the version check when running archinstall', - ) - parser.add_argument( - '--skip-wifi-check', - action='store_true', - default=False, - help='Skip wifi check when running archinstall', - ) - parser.add_argument( - '--advanced', - action='store_true', - default=False, - help='Enabled advanced options', - ) - parser.add_argument( - '--verbose', - action='store_true', - default=False, - help='Enabled verbose options', - ) + def _define_arguments(self) -> ArgumentParser: + parser = ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter) + parser.add_argument( + '-v', + '--version', + action='version', + default=False, + version='%(prog)s ' + get_version(), + ) + parser.add_argument( + '--config', + type=Path, + nargs='?', + default=None, + help='JSON configuration file', + ) + parser.add_argument( + '--config-url', + type=str, + nargs='?', + default=None, + help='Url to a JSON configuration file', + ) + parser.add_argument( + '--creds', + type=Path, + nargs='?', + default=None, + help='JSON credentials configuration file', + ) + parser.add_argument( + '--creds-url', + type=str, + nargs='?', + default=None, + help='Url to a JSON credentials configuration file', + ) + parser.add_argument( + '--creds-decryption-key', + type=str, + nargs='?', + default=None, + help='Decryption key for credentials file', + ) + parser.add_argument( + '--silent', + action='store_true', + default=False, + help='WARNING: Disables all prompts for input and confirmation. If no configuration is provided, this is ignored', + ) + parser.add_argument( + '--dry-run', + '--dry_run', + action='store_true', + default=False, + help='Generates a configuration file and then exits instead of performing an installation', + ) + parser.add_argument( + '--script', + nargs='?', + help='Script to run for installation', + type=str, + ) + parser.add_argument( + '--mountpoint', + type=Path, + nargs='?', + default=Path('/mnt'), + help='Define an alternate mount point for installation', + ) + parser.add_argument( + '--skip-ntp', + action='store_true', + help='Disables NTP checks during installation', + default=False, + ) + parser.add_argument( + '--skip-wkd', + action='store_true', + help='Disables checking if archlinux keyring wkd sync is complete.', + default=False, + ) + parser.add_argument( + '--skip-boot', + action='store_true', + help='Disables installation of a boot loader (note: only use this when problems arise with the boot loader step).', + 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.', + ) + parser.add_argument( + '--no-pkg-lookups', + action='store_true', + default=False, + help='Disabled package validation specifically prior to starting installation.', + ) + parser.add_argument( + '--plugin', + nargs='?', + type=str, + default=None, + help='File path to a plugin to load', + ) + parser.add_argument( + '--skip-version-check', + action='store_true', + default=False, + help='Skip the version check when running archinstall', + ) + parser.add_argument( + '--skip-wifi-check', + action='store_true', + default=False, + help='Skip wifi check when running archinstall', + ) + parser.add_argument( + '--advanced', + action='store_true', + default=False, + help='Enabled advanced options', + ) + parser.add_argument( + '--verbose', + action='store_true', + default=False, + help='Enabled verbose options', + ) + return parser - return parser + def _parse_args(self) -> Arguments: + argparse_args = vars(self._parser.parse_args()) + args: Arguments = Arguments(**argparse_args) - def _parse_args(self) -> Arguments: - argparse_args = vars(self._parser.parse_args()) - args: Arguments = Arguments(**argparse_args) + # amend the parameters (check internal consistency) + # Installation can't be silent if config is not passed + if args.config is None and args.config_url is None: + args.silent = False - # amend the parameters (check internal consistency) - # Installation can't be silent if config is not passed - if args.config is None and args.config_url is None: - args.silent = False + if args.debug: + warn(f'Warning: --debug mode will write certain credentials to {logger.path}!') - if args.debug: - warn(f'Warning: --debug mode will write certain credentials to {logger.path}!') + if args.plugin: + plugin_path = Path(args.plugin) + load_plugin(plugin_path) - if args.plugin: - # Pass plugin as string to preserve URL format (avoid Path normalization) - load_plugin(args.plugin) + if args.creds_decryption_key is None: + if os.environ.get('ARCHINSTALL_CREDS_DECRYPTION_KEY'): + args.creds_decryption_key = os.environ.get('ARCHINSTALL_CREDS_DECRYPTION_KEY') - if args.creds_decryption_key is None: - if os.environ.get('ARCHINSTALL_CREDS_DECRYPTION_KEY'): - args.creds_decryption_key = os.environ.get('ARCHINSTALL_CREDS_DECRYPTION_KEY') + return args - return args + def _parse_config(self) -> dict[str, Any]: + config: dict[str, Any] = {} + config_data: str | None = None + creds_data: str | None = None - def _parse_config(self) -> dict[str, Any]: - config: dict[str, Any] = {} - config_data: str | None = None - creds_data: str | None = None + if self._args.config is not None: + config_data = self._read_file(self._args.config) + elif self._args.config_url is not None: + config_data = self._fetch_from_url(self._args.config_url) - if self._args.config is not None: - config_data = self._read_file(self._args.config) - elif self._args.config_url is not None: - config_data = self._fetch_from_url(self._args.config_url) + if config_data is not None: + config.update(json.loads(config_data)) - if config_data is not None: - config.update(json.loads(config_data)) + if self._args.creds is not None: + creds_data = self._read_file(self._args.creds) + elif self._args.creds_url is not None: + creds_data = self._fetch_from_url(self._args.creds_url) - if self._args.creds is not None: - creds_data = self._read_file(self._args.creds) - elif self._args.creds_url is not None: - creds_data = self._fetch_from_url(self._args.creds_url) + if creds_data is not None: + json_data = self._process_creds_data(creds_data) + if json_data is not None: + config.update(json_data) - if creds_data is not None: - json_data = self._process_creds_data(creds_data) - if json_data is not None: - config.update(json_data) + config = self._cleanup_config(config) - config = self._cleanup_config(config) + return config - return config + def _process_creds_data(self, creds_data: str) -> dict[str, Any] | None: + if creds_data.startswith('$'): # encrypted data + if self._args.creds_decryption_key is not None: + try: + creds_data = decrypt(creds_data, self._args.creds_decryption_key) + return json.loads(creds_data) + except ValueError as err: + if 'Invalid password' in str(err): + error(tr('Incorrect credentials file decryption password')) + sys.exit(1) + else: + debug(f'Error decrypting credentials file: {err}') + raise err from err + else: + header = tr('Enter credentials file decryption password') + wrong_pwd_text = tr('Incorrect password') + prompt = header - def _process_creds_data(self, creds_data: str) -> dict[str, Any] | None: - if creds_data.startswith('$'): # encrypted data - if self._args.creds_decryption_key is not None: - try: - creds_data = decrypt(creds_data, self._args.creds_decryption_key) - return json.loads(creds_data) - except ValueError as err: - if 'Invalid password' in str(err): - error(tr('Incorrect credentials file decryption password')) - sys.exit(1) - else: - debug(f'Error decrypting credentials file: {err}') - raise err from err - else: - header = tr('Enter credentials file decryption password') - wrong_pwd_text = tr('Incorrect password') - prompt = header + while True: + decryption_pwd: Password | None = tui.run( + lambda p=prompt: get_password( # type: ignore[misc] + header=p, + allow_skip=False, + no_confirmation=True, + ) + ) - while True: - decryption_pwd: Password | None = tui.run( - lambda p=prompt: get_password( # type: ignore[misc] - header=p, - allow_skip=False, - no_confirmation=True, - ) - ) + if not decryption_pwd: + return None - if not decryption_pwd: - return None + try: + creds_data = decrypt(creds_data, decryption_pwd.plaintext) + break + except ValueError as err: + if 'Invalid password' in str(err): + debug('Incorrect credentials file decryption password') + prompt = f'{header}' + f'\n\n{wrong_pwd_text}' + else: + debug(f'Error decrypting credentials file: {err}') + raise err from err - try: - creds_data = decrypt(creds_data, decryption_pwd.plaintext) - break - except ValueError as err: - if 'Invalid password' in str(err): - debug('Incorrect credentials file decryption password') - prompt = f'{header}' + f'\n\n{wrong_pwd_text}' - else: - debug(f'Error decrypting credentials file: {err}') - raise err from err + return json.loads(creds_data) - return json.loads(creds_data) + def _fetch_from_url(self, url: str) -> str: + if urllib.parse.urlparse(url).scheme: + try: + req = Request(url, headers={'User-Agent': 'ArchInstall'}) + with urlopen(req) as resp: + return resp.read().decode('utf-8') + except urllib.error.HTTPError as err: + error(f'Could not fetch JSON from {url}: {err}') + else: + error('Not a valid url') - def _fetch_from_url(self, url: str) -> str: - if urllib.parse.urlparse(url).scheme: - try: - req = Request(url, headers={'User-Agent': 'ArchInstall'}) - # FIXED: Added a 15-second timeout to prevent infinite hanging - with urlopen(req, timeout=15) as resp: - return resp.read().decode('utf-8') - except urllib.error.HTTPError as err: - error(f'Could not fetch JSON from {url}: {err}') - else: - error('Not a valid url') + sys.exit(1) - sys.exit(1) + def _read_file(self, path: Path) -> str: + if not path.exists(): + error(f'Could not find file {path}') + sys.exit(1) - def _read_file(self, path: Path) -> str: - if not path.exists(): - error(f'Could not find file {path}') - sys.exit(1) + return path.read_text() - return path.read_text() + def _cleanup_config(self, config: Namespace | dict[str, Any]) -> dict[str, Any]: + clean_args = {} + for key, val in config.items(): + if isinstance(val, dict): + val = self._cleanup_config(val) - # FIXED: Added safe handling to support both ArgumentParser Namespace objects AND dictionaries, - # as calling .items() on a Namespace object without using `vars()` throws an AttributeError. - def _cleanup_config(self, config: Namespace | dict[str, Any]) -> dict[str, Any]: - clean_args = {} - # Convert Namespace to dict if necessary before iterating - config_dict = vars(config) if isinstance(config, Namespace) else config - - for key, val in config_dict.items(): - if isinstance(val, dict): - val = self._cleanup_config(val) + if val is not None: + clean_args[key] = val - if val is not None: - clean_args[key] = val - - return clean_args \ No newline at end of file + return clean_args diff --git a/archinstall/lib/configuration.py b/archinstall/lib/configuration.py index aba835d9..c40ef161 100644 --- a/archinstall/lib/configuration.py +++ b/archinstall/lib/configuration.py @@ -6,13 +6,12 @@ from typing import Any from pydantic import TypeAdapter -from archinstall.lib.args import ArchConfig +from archinstall.lib.args import ArchConfig, ArchConfigType from archinstall.lib.crypt import encrypt from archinstall.lib.menu.helpers import Confirmation, Selection from archinstall.lib.menu.util import get_password, prompt_dir -from archinstall.lib.models.bootloader import Bootloader from archinstall.lib.models.network import NetworkConfiguration -from archinstall.lib.output import debug, logger, warn +from archinstall.lib.output import as_key_value_pair, debug, logger, warn from archinstall.lib.translationhandler import tr from archinstall.tui.menu_item import MenuItem, MenuItemGroup from archinstall.tui.result import ResultType @@ -45,15 +44,15 @@ class ConfigurationOutput: def user_config_to_json(self) -> str: config = self._config.safe_config() - adapter = TypeAdapter(dict[str, Any]) + adapter = TypeAdapter(dict[ArchConfigType, Any]) python_dict = adapter.dump_python(config) return json.dumps(python_dict, indent=4, sort_keys=True) def user_credentials_to_json(self) -> str: - config = self._config.unsafe_config() + cfg = self._config.unsafe_config() - adapter = TypeAdapter(dict[str, Any]) - python_dict = adapter.dump_python(config) + adapter = TypeAdapter(dict[ArchConfigType, Any]) + python_dict = adapter.dump_python(cfg) return json.dumps(python_dict, indent=4, sort_keys=True) def write_debug(self) -> None: @@ -64,65 +63,24 @@ class ConfigurationOutput: """ Render a concise two-column summary of the current configuration. - The left column holds section labels, the right column holds values. - Column width adapts to the longest translated label so translations - do not break the alignment. Rows whose underlying config is not set - are skipped. - Returns an empty string if nothing meaningful to show. """ - rows: list[tuple[str, str]] = [] + cfg: dict[str, str | list[str] | bool] = {} - disk_config = self._config.disk_config - if disk_config and disk_config.device_modifications: - disk_parts: list[str] = [] - for mod in disk_config.device_modifications: - path = str(mod.device_path) - root_part = mod.get_root_partition() - flags: list[str] = [] - if root_part and root_part.fs_type: - flags.append(root_part.fs_type.value) - if disk_config.disk_encryption: - flags.append(tr('LUKS')) - disk_parts.append(f'{path} ({" + ".join(flags)})' if flags else path) - rows.append((tr('Disks'), ', '.join(disk_parts))) + for key, value in self._config.plain_cfg().items(): + cfg[key.text()] = value - bl_config = self._config.bootloader_config - if bl_config and bl_config.bootloader != Bootloader.NO_BOOTLOADER: - rows.append((tr('Bootloader'), bl_config.bootloader.value)) + for config_type, obj in self._config.sub_cfg().items(): + if not hasattr(obj, 'summary'): + continue - kernels = self._config.kernels - if kernels: - rows.append((tr('Kernel'), ', '.join(kernels))) + summary = obj.summary() + if summary: + cfg[config_type.text()] = summary - profile_config = self._config.profile_config - if profile_config and profile_config.profile: - names = profile_config.profile.current_selection_names() - rows.append((tr('Profile'), ', '.join(names) if names else profile_config.profile.name)) - if profile_config.greeter: - rows.append((tr('Greeter'), profile_config.greeter.value)) + simple_summary = as_key_value_pair(cfg, ignore_empty=True) - packages = self._config.packages - if packages: - rows.append((tr('Packages'), str(len(packages)))) - - net_config = self._config.network_config - if isinstance(net_config, NetworkConfiguration): - rows.append((tr('Network'), net_config.type.display_msg())) - - locale_config = self._config.locale_config - if locale_config: - rows.append((tr('Locale'), locale_config.sys_lang)) - - tz = self._config.timezone - if tz: - rows.append((tr('Timezone'), tz)) - - if not rows: - return '' - - label_width = max(len(label) for label, _ in rows) + 2 - return '\n'.join(f'{label:<{label_width}}{value}' for label, value in rows) + return simple_summary async def confirm_config(self, show_install_warnings: bool = False) -> bool: header = f'{tr("The specified configuration will be applied")}. ' diff --git a/archinstall/lib/menu/helpers.py b/archinstall/lib/menu/helpers.py index b512c956..947f3c55 100644 --- a/archinstall/lib/menu/helpers.py +++ b/archinstall/lib/menu/helpers.py @@ -20,6 +20,7 @@ class Selection[ValueT]: preview_location: Literal['right', 'bottom'] | None = None, multi: bool = False, enable_filter: bool = False, + wrap_preview: bool = False, ): self._header = header self._title = title @@ -29,6 +30,7 @@ class Selection[ValueT]: self._preview_location = preview_location self._multi = multi self._enable_filter = enable_filter + self._wrap_preview = wrap_preview async def show(self) -> Result[ValueT]: if self._multi: @@ -39,6 +41,7 @@ class Selection[ValueT]: allow_reset=self._allow_reset, preview_location=self._preview_location, enable_filter=self._enable_filter, + wrap_preview=self._wrap_preview, ).run() else: result = await OptionListScreen[ValueT]( @@ -49,6 +52,7 @@ class Selection[ValueT]: allow_reset=self._allow_reset, preview_location=self._preview_location, enable_filter=self._enable_filter, + wrap_preview=self._wrap_preview, ).run() if result.type_ == ResultType.Reset: diff --git a/archinstall/lib/models/application.py b/archinstall/lib/models/application.py index 04d1a342..1e97a784 100644 --- a/archinstall/lib/models/application.py +++ b/archinstall/lib/models/application.py @@ -1,7 +1,8 @@ from dataclasses import dataclass from enum import StrEnum, auto -from typing import Any, NotRequired, Self, TypedDict +from typing import Any, NotRequired, Self, TypedDict, override +from archinstall.lib.models.config import SubConfig from archinstall.lib.translationhandler import tr @@ -168,7 +169,7 @@ class FontsConfiguration: @dataclass(frozen=True) -class ZramConfiguration: +class ZramConfiguration(SubConfig): enabled: bool algorithm: ZramAlgorithm = ZramAlgorithm.ZSTD @@ -181,9 +182,28 @@ class ZramConfiguration: algo = arg.get('algorithm', arg.get('algo', ZramAlgorithm.ZSTD.value)) return cls(enabled=enabled, algorithm=ZramAlgorithm(algo)) + @override + def json(self) -> dict[str, bool | str]: + return { + 'enabled': self.enabled, + 'algorithm': self.algorithm.value, + } + + @override + def summary(self) -> list[str] | None: + out: list[str] = [] + + if self.enabled: + out.append(tr('Zram enabled')) + + out.append(tr('Zram algorithm {}').format(self.algorithm)) + return out + + return None + @dataclass -class ApplicationConfiguration: +class ApplicationConfiguration(SubConfig): bluetooth_config: BluetoothConfiguration | None = None audio_config: AudioConfiguration | None = None power_management_config: PowerManagementConfiguration | None = None @@ -223,6 +243,7 @@ class ApplicationConfiguration: return app_config + @override def json(self) -> ApplicationSerialization: config: ApplicationSerialization = {} @@ -245,3 +266,28 @@ class ApplicationConfiguration: config['fonts_config'] = self.fonts_config.json() return config + + @override + def summary(self) -> list[str]: + out: list[str] = [] + + if self.bluetooth_config and self.bluetooth_config.enabled: + out.append(tr('Bluetooth enabled')) + + if self.audio_config: + out.append(tr('Audio server "{}"').format(self.audio_config.audio)) + + if self.power_management_config: + out.append(tr('Power management "{}"').format(self.power_management_config.power_management)) + + if self.print_service_config and self.print_service_config.enabled: + out.append(tr('Print service enabled')) + + if self.firewall_config: + out.append(tr('Firewall "{}"').format(self.firewall_config.firewall)) + + if self.fonts_config and self.fonts_config.fonts: + fonts = ', '.join(f.value for f in self.fonts_config.fonts) + out.append(tr('Extra fonts "{}"').format(fonts)) + + return out diff --git a/archinstall/lib/models/authentication.py b/archinstall/lib/models/authentication.py index 51120479..37261e7c 100644 --- a/archinstall/lib/models/authentication.py +++ b/archinstall/lib/models/authentication.py @@ -1,7 +1,8 @@ from dataclasses import dataclass, field from enum import Enum -from typing import Any, NotRequired, Self, TypedDict +from typing import Any, NotRequired, Self, TypedDict, override +from archinstall.lib.models.config import SubConfig from archinstall.lib.models.users import Password, User from archinstall.lib.translationhandler import tr @@ -58,7 +59,7 @@ class U2FLoginConfiguration: @dataclass -class AuthenticationConfiguration: +class AuthenticationConfiguration(SubConfig): root_enc_password: Password | None = None users: list[User] = field(default_factory=list) u2f_config: U2FLoginConfiguration | None = None @@ -75,6 +76,7 @@ class AuthenticationConfiguration: return auth_config + @override def json(self) -> AuthenticationSerialization: config: AuthenticationSerialization = {} @@ -83,6 +85,21 @@ class AuthenticationConfiguration: return config + @override + def summary(self) -> list[str]: + out: list[str] = [] + + if self.root_enc_password: + out.append(tr('Root password set')) + + if self.users: + out.append(tr('Configured {} user(s)').format(len(self.users))) + + if self.u2f_config: + out.append(tr('U2F set up')) + + return out + def has_superuser(self) -> bool: return any(u.sudo for u in self.users) diff --git a/archinstall/lib/models/bootloader.py b/archinstall/lib/models/bootloader.py index 5d8d0d80..0dbd4268 100644 --- a/archinstall/lib/models/bootloader.py +++ b/archinstall/lib/models/bootloader.py @@ -1,8 +1,9 @@ import sys from dataclasses import dataclass from enum import Enum -from typing import Any, Self +from typing import Any, Self, override +from archinstall.lib.models.config import SubConfig from archinstall.lib.output import warn from archinstall.lib.translationhandler import tr @@ -60,14 +61,26 @@ class Bootloader(Enum): @dataclass -class BootloaderConfiguration: +class BootloaderConfiguration(SubConfig): bootloader: Bootloader uki: bool = False removable: bool = True + @override def json(self) -> dict[str, Any]: return {'bootloader': self.bootloader.json(), 'uki': self.uki, 'removable': self.removable} + @override + def summary(self) -> list[str]: + out = [tr('Bootloader "{}"').format(self.bootloader.value)] + + if self.uki: + out.append(tr('UKI enabled')) + if self.removable: + out.append(tr('Removable')) + + return out + @classmethod def parse_arg(cls, config: dict[str, Any], skip_boot: bool) -> Self: bootloader = Bootloader.from_arg(config.get('bootloader', ''), skip_boot) diff --git a/archinstall/lib/models/config.py b/archinstall/lib/models/config.py new file mode 100644 index 00000000..45835bc0 --- /dev/null +++ b/archinstall/lib/models/config.py @@ -0,0 +1,12 @@ +from abc import ABC, abstractmethod +from typing import Any + + +class SubConfig(ABC): + @abstractmethod + def json(self) -> Any: + pass + + @abstractmethod + def summary(self) -> str | list[str] | None: + pass diff --git a/archinstall/lib/models/device.py b/archinstall/lib/models/device.py index 7fa05407..a21f3c6f 100644 --- a/archinstall/lib/models/device.py +++ b/archinstall/lib/models/device.py @@ -12,6 +12,7 @@ from parted import Disk, Geometry, Partition from pydantic import BaseModel, Field, ValidationInfo, field_serializer, field_validator from archinstall.lib.hardware import SysInfo +from archinstall.lib.models.config import SubConfig from archinstall.lib.models.users import Password from archinstall.lib.output import debug from archinstall.lib.translationhandler import tr @@ -34,6 +35,15 @@ class DiskLayoutType(Enum): case DiskLayoutType.Pre_mount: return tr('Pre-mounted configuration') + def short_msg(self) -> str: + match self: + case DiskLayoutType.Default: + return tr('Default') + case DiskLayoutType.Manual: + return tr('Manual') + case DiskLayoutType.Pre_mount: + return tr('Pre-mount') + class _DiskLayoutConfigurationSerialization(TypedDict): config_type: str @@ -45,7 +55,7 @@ class _DiskLayoutConfigurationSerialization(TypedDict): @dataclass -class DiskLayoutConfiguration: +class DiskLayoutConfiguration(SubConfig): config_type: DiskLayoutType device_modifications: list[DeviceModification] = field(default_factory=list) lvm_config: LvmConfiguration | None = None @@ -55,6 +65,7 @@ class DiskLayoutConfiguration: # used for pre-mounted config mountpoint: Path | None = None + @override def json(self) -> _DiskLayoutConfigurationSerialization: if self.config_type == DiskLayoutType.Pre_mount: return { @@ -78,6 +89,28 @@ class DiskLayoutConfiguration: return config + @override + def summary(self) -> list[str]: + out = [tr('{} layout').format(self.config_type.short_msg())] + + devices = set(mod.device_path for mod in self.device_modifications) + + if devices: + dev_str = ', '.join(str(d) for d in devices) + out.append(tr('Devices {}').format(dev_str)) + + if self.lvm_config is not None: + out.append(tr('LVM set up')) + + if self.disk_encryption is not None: + out.append(tr('{} encryption').format(self.disk_encryption.encryption_type.type_to_text())) + + if self.btrfs_options is not None: + if self.btrfs_options.snapshot_config: + out.append(tr('Btrfs snapshot "{}"').format(self.btrfs_options.snapshot_config.snapshot_type)) + + return out + @classmethod def parse_arg( cls, @@ -1321,7 +1354,7 @@ class _SnapshotConfigSerialization(TypedDict): type: str -class SnapshotType(Enum): +class SnapshotType(StrEnum): Snapper = 'Snapper' Timeshift = 'Timeshift' diff --git a/archinstall/lib/models/locale.py b/archinstall/lib/models/locale.py index 8f580989..26ab3cc0 100644 --- a/archinstall/lib/models/locale.py +++ b/archinstall/lib/models/locale.py @@ -1,12 +1,13 @@ from dataclasses import dataclass -from typing import Any, Self +from typing import Any, Self, override from archinstall.lib.locale.utils import get_kb_layout +from archinstall.lib.models.config import SubConfig from archinstall.lib.translationhandler import tr @dataclass -class LocaleConfiguration: +class LocaleConfiguration(SubConfig): kb_layout: str sys_lang: str sys_enc: str @@ -23,6 +24,7 @@ class LocaleConfiguration: layout = 'us' return cls(layout, 'en_US.UTF-8', 'UTF-8') + @override def json(self) -> dict[str, str]: return { 'kb_layout': self.kb_layout, @@ -31,6 +33,15 @@ class LocaleConfiguration: 'console_font': self.console_font, } + @override + def summary(self) -> list[str]: + return [ + tr('Keyboard layout "{}"').format(self.kb_layout), + tr('Locale language "{}"').format(self.sys_lang), + tr('Locale encoding "{}"').format(self.sys_enc), + tr('Console font "{}"').format(self.console_font), + ] + def preview(self) -> str: output = '{}: {}\n'.format(tr('Keyboard layout'), self.kb_layout) output += '{}: {}\n'.format(tr('Locale language'), self.sys_lang) diff --git a/archinstall/lib/models/mirrors.py b/archinstall/lib/models/mirrors.py index 0a596c13..3ded2a9a 100644 --- a/archinstall/lib/models/mirrors.py +++ b/archinstall/lib/models/mirrors.py @@ -9,9 +9,11 @@ from typing import TYPE_CHECKING, Any, Self, TypedDict, override from pydantic import BaseModel, ValidationInfo, field_validator, model_validator +from archinstall.lib.models.config import SubConfig from archinstall.lib.models.packages import Repository from archinstall.lib.networking import DownloadTimer, ping from archinstall.lib.output import debug +from archinstall.lib.translationhandler import tr if TYPE_CHECKING: from archinstall.lib.mirror.mirror_handler import MirrorListHandler @@ -236,7 +238,7 @@ class _MirrorConfigurationSerialization(TypedDict): @dataclass -class MirrorConfiguration: +class MirrorConfiguration(SubConfig): mirror_regions: list[MirrorRegion] = field(default_factory=list) custom_servers: list[CustomServer] = field(default_factory=list) optional_repositories: list[Repository] = field(default_factory=list) @@ -250,6 +252,7 @@ class MirrorConfiguration: def custom_server_urls(self) -> str: return '\n'.join(s.url for s in self.custom_servers) + @override def json(self) -> _MirrorConfigurationSerialization: regions = {} for m in self.mirror_regions: @@ -262,6 +265,24 @@ class MirrorConfiguration: 'custom_repositories': [c.json() for c in self.custom_repositories], } + @override + def summary(self) -> list[str]: + out: list[str] = [] + + if self.mirror_regions: + out.append(tr('Mirror regions "{}"').format(', '.join(m.name for m in self.mirror_regions))) + + if self.optional_repositories: + out.append(tr('Optional repositories "{}"').format(', '.join(r.value for r in self.optional_repositories))) + + if self.custom_servers: + out.append(tr('Custom servers set up')) + + if self.custom_repositories: + out.append(tr('Custom repositories set up')) + + return out + def custom_servers_config(self) -> str: config = '' diff --git a/archinstall/lib/models/network.py b/archinstall/lib/models/network.py index 71f3dca2..9e61cb56 100644 --- a/archinstall/lib/models/network.py +++ b/archinstall/lib/models/network.py @@ -3,6 +3,7 @@ from dataclasses import dataclass, field from enum import Enum from typing import NotRequired, Self, TypedDict, override +from archinstall.lib.models.config import SubConfig from archinstall.lib.output import debug from archinstall.lib.translationhandler import tr @@ -103,10 +104,11 @@ class _NetworkConfigurationSerialization(TypedDict): @dataclass -class NetworkConfiguration: +class NetworkConfiguration(SubConfig): type: NicType nics: list[Nic] = field(default_factory=list) + @override def json(self) -> _NetworkConfigurationSerialization: config: _NetworkConfigurationSerialization = {'type': self.type.value} if self.nics: @@ -114,6 +116,10 @@ class NetworkConfiguration: return config + @override + def summary(self) -> str: + return self.type.display_msg() + @classmethod def parse_arg(cls, config: _NetworkConfigurationSerialization) -> Self | None: nic_type = config.get('type', None) diff --git a/archinstall/lib/models/packages.py b/archinstall/lib/models/packages.py index 6ba6e134..205d653e 100644 --- a/archinstall/lib/models/packages.py +++ b/archinstall/lib/models/packages.py @@ -132,7 +132,6 @@ class AvailablePackage(BaseModel): def longest_key(self) -> int: return max(len(key) for key in self.model_dump().keys()) - # return all package info line by line def info(self) -> str: output = '' for key, value in self.model_dump().items(): diff --git a/archinstall/lib/models/pacman.py b/archinstall/lib/models/pacman.py index 5d271ea9..004dfbc3 100644 --- a/archinstall/lib/models/pacman.py +++ b/archinstall/lib/models/pacman.py @@ -1,6 +1,7 @@ from dataclasses import dataclass -from typing import Self, TypedDict +from typing import Self, TypedDict, override +from archinstall.lib.models.config import SubConfig from archinstall.lib.translationhandler import tr @@ -10,7 +11,7 @@ class PacmanConfigSerialization(TypedDict): @dataclass -class PacmanConfiguration: +class PacmanConfiguration(SubConfig): parallel_downloads: int = 5 color: bool = True @@ -18,12 +19,19 @@ class PacmanConfiguration: def default(cls) -> Self: return cls() + @override def json(self) -> PacmanConfigSerialization: return { 'parallel_downloads': self.parallel_downloads, 'color': self.color, } + @override + def summary(self) -> str | None: + if self.color: + return tr('Color enabled') + return None + def preview(self) -> str: color_str = str(self.color) output = '{}: {}\n'.format(tr('Parallel Downloads'), self.parallel_downloads) diff --git a/archinstall/lib/models/profile.py b/archinstall/lib/models/profile.py index e7ae8c8c..fa890a1e 100644 --- a/archinstall/lib/models/profile.py +++ b/archinstall/lib/models/profile.py @@ -1,8 +1,10 @@ from dataclasses import dataclass -from typing import TYPE_CHECKING, Self, TypedDict +from typing import TYPE_CHECKING, Self, TypedDict, override from archinstall.default_profiles.profile import GreeterType, Profile from archinstall.lib.hardware import GfxDriver +from archinstall.lib.models.config import SubConfig +from archinstall.lib.translationhandler import tr if TYPE_CHECKING: from archinstall.lib.profile.profiles_handler import ProfileSerialization @@ -15,11 +17,12 @@ class _ProfileConfigurationSerialization(TypedDict): @dataclass -class ProfileConfiguration: +class ProfileConfiguration(SubConfig): profile: Profile | None = None gfx_driver: GfxDriver | None = None greeter: GreeterType | None = None + @override def json(self) -> _ProfileConfigurationSerialization: from archinstall.lib.profile.profiles_handler import profile_handler @@ -29,6 +32,23 @@ class ProfileConfiguration: 'greeter': self.greeter.value if self.greeter else None, } + @override + def summary(self) -> list[str] | None: + out: list[str] = [] + + if self.profile: + out.append(self.profile.name) + + if self.gfx_driver: + out.append(tr('{} grphics driver').format(self.gfx_driver.value)) + + if self.greeter: + out.append(tr('{} greeter').format(self.greeter.value)) + + return out + + return None + @classmethod def parse_arg(cls, arg: _ProfileConfigurationSerialization) -> Self: from archinstall.lib.profile.profiles_handler import profile_handler diff --git a/archinstall/lib/output.py b/archinstall/lib/output.py index bc4bb113..86a47ed1 100644 --- a/archinstall/lib/output.py +++ b/archinstall/lib/output.py @@ -1,6 +1,8 @@ import logging import os import sys +import urllib.error +import urllib.request from collections.abc import Callable from dataclasses import asdict, is_dataclass from datetime import UTC, datetime @@ -9,6 +11,7 @@ from pathlib import Path from typing import TYPE_CHECKING, Any from archinstall.lib.utils.encoding import unicode_ljust, unicode_rjust +from archinstall.tui.rich import BaseRichTable if TYPE_CHECKING: from _typeshed import DataclassInstance @@ -18,7 +21,7 @@ class FormattedOutput: @staticmethod def _get_values( o: DataclassInstance, - class_formatter: str | Callable | None = None, # type: ignore[type-arg] + class_formatter: str | Callable | None = None, # type: ignore[type-arg] # pyright: ignore[reportMissingTypeArgument] filter_list: list[str] = [], ) -> dict[str, Any]: """ @@ -127,6 +130,35 @@ class FormattedOutput: return output +def as_key_value_pair( + entries: dict[str, str | list[str] | bool], + ignore_empty: bool = True, +) -> str: + """ + Formats key-values as a Rich Table: + key1 : value1 + key2 : value2 + ... + """ + table = BaseRichTable() + table.add_column('key', style='bold', no_wrap=True) + table.add_column('value', style='white', max_width=70) + + for label, value in entries.items(): + if ignore_empty and not value: + continue + + if isinstance(value, bool): + value = 'Yes' if value else 'No' + + if isinstance(value, list): + value = '\n '.join(str(val) for val in value) + + table.add_row(label.title(), f': {value}') + + return table.stringify() + + class Journald: @staticmethod def log(message: str, level: int = logging.DEBUG) -> None: @@ -333,3 +365,51 @@ def log( if level != logging.DEBUG: print(text) + + +def share_install_log( + paste_url: str = 'https://paste.rs', + max_size: int = 10 * 1024 * 1024, + confirm: Callable[[str], bool] = lambda _: True, +) -> int: + log_path = logger.path + + if not log_path.exists(): + info(f'Log file not found: {log_path}') + return 1 + + size = log_path.stat().st_size + if size == 0: + info(f'Log file is empty: {log_path}') + return 1 + + if size > max_size: + info(f'Log file exceeds {max_size} bytes, uploading last {max_size} bytes') + content = log_path.read_bytes()[-max_size:] + else: + content = log_path.read_bytes() + + header = f'About to upload {log_path} ({len(content)} bytes) to {paste_url}\n\n' + header += 'The log may contain hostname, mirror URLs, package list and partition layout.\n' + header += 'The uploaded paste is public.\n\n' + header += 'Continue?' + + if not confirm(header): + info('Cancelled.') + return 1 + + try: + req = urllib.request.Request(paste_url, data=content) + with urllib.request.urlopen(req) as response: + url = response.read().decode().strip() + except urllib.error.URLError as e: + info(f'Upload failed: {e}') + return 1 + + if not url.startswith('http'): + info(f'Unexpected response from {paste_url}: {url[:200]!r}') + return 1 + + # raw print so the URL is pipe-friendly (no ANSI colors, no log prefix) + print(url) + return 0 diff --git a/archinstall/lib/packages/packages.py b/archinstall/lib/packages/packages.py index dcc4da9e..a7422986 100644 --- a/archinstall/lib/packages/packages.py +++ b/archinstall/lib/packages/packages.py @@ -14,7 +14,7 @@ def installed_package(package: str) -> LocalPackage | None: try: package_info = [] for line in Pacman.run(f'-Q --info {package}'): - package_info.append(line.decode().strip()) + package_info.append(line.decode().rstrip()) return _parse_package_output(package_info, LocalPackage) except SysCallError: @@ -53,7 +53,7 @@ def available_package(package: str) -> AvailablePackage | None: try: package_info: list[str] = [] for line in Pacman.run(f'-S --info {package}'): - package_info.append(line.decode().strip()) + package_info.append(line.decode().rstrip()) return _parse_package_output(package_info, AvailablePackage) except SysCallError: @@ -79,7 +79,7 @@ def list_available_packages( debug(f'Failed to sync Arch Linux package database: {e}') for line in Pacman.run('-S --info'): - dec_line = line.decode().strip() + dec_line = line.decode().rstrip() current_package.append(dec_line) if dec_line.startswith('Validated'): @@ -187,6 +187,7 @@ async def select_additional_packages( multi=True, preview_location='right', enable_filter=True, + wrap_preview=True, ).show() match pck_result.type_: diff --git a/archinstall/locales/base.pot b/archinstall/locales/base.pot index 31f5e196..0b08df29 100644 --- a/archinstall/locales/base.pot +++ b/archinstall/locales/base.pot @@ -2293,3 +2293,14 @@ msgstr "" #, python-brace-format msgid "Default: {}ms, Recommended range: 1000-60000" msgstr "" + +#, python-brace-format +msgid "{} needs access to your seat" +msgstr "" + +msgid "collection of hardware devices i.e. keyboard, mouse" +msgstr "" + +#, python-brace-format +msgid "Choose an option how to give {} access to your hardware" +msgstr "" diff --git a/archinstall/locales/pl/LC_MESSAGES/base.mo b/archinstall/locales/pl/LC_MESSAGES/base.mo index 4a57d37c..0ab34f9e 100644 Binary files a/archinstall/locales/pl/LC_MESSAGES/base.mo and b/archinstall/locales/pl/LC_MESSAGES/base.mo differ diff --git a/archinstall/locales/pl/LC_MESSAGES/base.po b/archinstall/locales/pl/LC_MESSAGES/base.po index ab73ad85..d01f1012 100644 --- a/archinstall/locales/pl/LC_MESSAGES/base.po +++ b/archinstall/locales/pl/LC_MESSAGES/base.po @@ -256,6 +256,9 @@ msgstr "Język" msgid "Locale encoding" msgstr "Kodowanie" +msgid "Console font" +msgstr "Czcionka konsoli" + msgid "Drive(s)" msgstr "Dysk(i)" @@ -820,6 +823,19 @@ msgstr "Nieprawidłowa wartość! Spróbuj jeszcze raz z prawidłową wartości msgid "Parallel Downloads" msgstr "Pobieranie kilku plików jednocześnie" +msgid "Pacman" +msgstr "Pacman" + +msgid "Color" +msgstr "Kolor" + +#, python-brace-format +msgid "Enter the number of parallel downloads (1-{})" +msgstr "Wprowadź maksymalną liczbę plików pobieranych jednocześnie (1-{})" + +msgid "Enable colored output for pacman" +msgstr "Włącz kolory w pacmanie" + msgid "ESC to skip" msgstr "Naciśnij ESC, aby pominąć" @@ -1203,6 +1219,21 @@ msgstr "Produkt" msgid "Invalid configuration: {error}" msgstr "Niepoprawna konfiguracja: {error}" +msgid "Ready to install" +msgstr "Gotowe do zainstalowania" + +msgid "Disks" +msgstr "Dyski" + +msgid "Packages" +msgstr "Pakiety" + +msgid "Network" +msgstr "Sieć" + +msgid "Locale" +msgstr "Ustawienia regionalne (locale)" + msgid "Type" msgstr "Typ" @@ -1660,6 +1691,123 @@ msgstr "Wejdź w tryb wyszukiwania" msgid "Exit search mode" msgstr "Wyjdź z trybu wyszukiwania" +msgid "General" +msgstr "Ogólne" + +msgid "Navigation" +msgstr "Nawigacja" + +msgid "Selection" +msgstr "Wybór" + +msgid "Search" +msgstr "Szukaj" + +msgid "Down" +msgstr "W dół" + +msgid "Up" +msgstr "W górę" + +msgid "Confirm" +msgstr "Potwierdź" + +msgid "Focus right" +msgstr "W prawo" + +msgid "Focus left" +msgstr "W lewo" + +msgid "Toggle" +msgstr "Zaznacz/Odznacz" + +msgid "Show/Hide help" +msgstr "Pokaż/Ukryj pomoc" + +msgid "Quit" +msgstr "Wyjdź" + +msgid "First" +msgstr "Pierwszy" + +msgid "Last" +msgstr "Ostatni" + +msgid "Select" +msgstr "Wybierz" + +msgid "Page Up" +msgstr "Strona w górę" + +msgid "Page Down" +msgstr "Strona w dół" + +msgid "Page up" +msgstr "Strona w górę" + +msgid "Page down" +msgstr "Strona w dół" + +msgid "Page Left" +msgstr "Strona w lewo" + +msgid "Page Right" +msgstr "Strona w prawo" + +msgid "Cursor up" +msgstr "Kursor w górę" + +msgid "Cursor down" +msgstr "Kursor w dół" + +msgid "Cursor right" +msgstr "Kursor w prawo" + +msgid "Cursor left" +msgstr "Kursor w lewo" + +msgid "Top" +msgstr "Góra" + +msgid "Bottom" +msgstr "Dół" + +msgid "Home" +msgstr "Początek" + +msgid "End" +msgstr "Koniec" + +msgid "Toggle option" +msgstr "Zaznacz/Odznacz opcję" + +msgid "Scroll Up" +msgstr "Przewiń w górę" + +msgid "Scroll Down" +msgstr "Przewiń w dół" + +msgid "Scroll Left" +msgstr "Przewiń w lewo" + +msgid "Scroll Right" +msgstr "Przewiń w prawo" + +msgid "Scroll Home" +msgstr "Przewiń do początku" + +msgid "Scroll End" +msgstr "Przewiń do końca" + +msgid "Focus Next" +msgstr "Następny" + +msgid "Focus Previous" +msgstr "Poprzedni" + +msgid "Copy selected text" +msgstr "Skopiuj zaznaczony tekst" + msgid "labwc needs access to your seat (collection of hardware devices i.e. keyboard, mouse, etc)" msgstr "labwc potrzebuje dostępu do twojego seat'a (sprzętu, np. klawiatury, myszki)" @@ -1878,6 +2026,21 @@ msgstr "Użyj NetworkManager'a (backend iwd)" msgid "Firewall" msgstr "Zapora sieciowa" +msgid "Additional fonts" +msgstr "Dodatkowe czcionki" + +msgid "Select font packages to install" +msgstr "Wybierz pakiety czcionek do zainstalowania" + +msgid "Unicode font coverage for most languages" +msgstr "Wsparcie Unicode dla większości języków" + +msgid "color emoji for browsers and apps" +msgstr "kolorowe emoji dla przeglądarek i aplikacji" + +msgid "Chinese, Japanese, Korean characters" +msgstr "Chińskie, Japońskie, Koreańskie znaki" + msgid "Select audio configuration" msgstr "Wybierz konfigurację audio" @@ -1965,7 +2128,13 @@ msgid "Select an interface" msgstr "Wybierz interfejs" msgid "Choose network configuration" -msgstr "Wybierz konfigurację sieciową" +msgstr "Wybierz konfigurację sieci" + +msgid "Recommended: Network Manager for desktop, Manual for server" +msgstr "Rekomendowane: Network Manager dla desktopów, ręcznie dla serwerów" + +msgid "Warning: no network configuration selected. Network will need to be set up manually on the installed system." +msgstr "Uwaga: nie wybrano konfiguracji sieci. Sieć musi zostać skonfigurowana ręcznie w zainstalowanym systemie." msgid "No packages found" msgstr "Nie znaleziono pakietów" @@ -2004,6 +2173,72 @@ msgstr "Wybrany profil pulpitu wymaga logowania normalnego użytkownika przez gr msgid "Environment type: {} {}" msgstr "Typ środowiska: {} {}" +msgid "Input cannot be empty" +msgstr "Podana wartość nie może być pusta" + +msgid "Recommended" +msgstr "Rekomendowane" + +msgid "Package" +msgstr "Pakiet" + +msgid "Curated selection of KDE Plasma packages" +msgstr "Wyselekcjonowany zestaw pakietów KDE Plasma" + +msgid "Dependencies" +msgstr "Zależności" + +msgid "Package group" +msgstr "Grupa pakietów" + +msgid "Extensive KDE Plasma installation" +msgstr "Obszerna instalacja KDE Plasma" + +msgid "Packages in group" +msgstr "Pakiety w grupie" + +msgid "Minimal KDE Plasma installation" +msgstr "Minimalistyczna instalacja KDE Plasma" + +msgid "Description" +msgstr "Opis" + +msgid "Select a flavor of KDE Plasma to install" +msgstr "Wybierz rodzaj KDE Plasma do zainstalowania" + +msgid "Arial/Times/Courier replacement, Cyrillic support for Steam/games" +msgstr "Zamiennik Arial/Times/Courier, obsługa Cyrylicy dla Steam/gier" + +msgid "wide Unicode coverage, good fallback font" +msgstr "szerokie wsparcie Unicode, dobra czczionka zastępcza" + +#, python-brace-format +msgid "Setting up U2F login: {u2f_config.u2f_login_method.value}" +msgstr "Ustawianie logowania U2F {u2f_config.u2f_login_method.value}" + +#, python-brace-format +msgid "Default: {DEFAULT_ITER_TIME}ms, Recommended range: 1000-60000" +msgstr "Domyślnie: {DEFAULT_ITER_TIME}ms, Rekomendowany przedział: 1000-60000" + +#, python-brace-format +msgid "Setting up U2F login: {}" +msgstr "Konfigurowanie logowania U2F: {}" + +#, python-brace-format +msgid "Default: {}ms, Recommended range: 1000-60000" +msgstr "Domyślnie: {}ms, Rekomendowany przedział: 1000-60000" + +#, python-brace-format +msgid "{} needs access to your seat" +msgstr "{} potrzebuje dostępu do twojego seat'a" + +msgid "collection of hardware devices i.e. keyboard, mouse" +msgstr "zbiór urządzeń, t.j. klawiatura, myszka" + +#, python-brace-format +msgid "Choose an option how to give {} access to your hardware" +msgstr "Wybierz opcję, aby nadać {} dostęp do twojego sprzętu" + #~ msgid "When picking a directory to save configuration files to, by default we will ignore the following folders: " #~ msgstr "Podczas wybierania katalogu do zapisywania plików konfiguracyjnych, domyślnie ignorowane są następujące foldery: " diff --git a/archinstall/main.py b/archinstall/main.py index 505652a4..9b91681b 100644 --- a/archinstall/main.py +++ b/archinstall/main.py @@ -11,14 +11,16 @@ from pathlib import Path from archinstall.lib.args import ArchConfigHandler from archinstall.lib.disk.utils import disk_layouts from archinstall.lib.hardware import SysInfo +from archinstall.lib.menu.helpers import Confirmation from archinstall.lib.network.wifi_handler import WifiHandler from archinstall.lib.networking import ping -from archinstall.lib.output import debug, error, info, warn +from archinstall.lib.output import debug, error, info, share_install_log, warn from archinstall.lib.packages.util import check_version_upgrade from archinstall.lib.pacman.pacman import Pacman from archinstall.lib.translationhandler import tr, translation_handler from archinstall.lib.utils.util import running_from_iso from archinstall.tui.components import tui +from archinstall.tui.menu_item import MenuItemGroup def _log_sys_info() -> None: @@ -73,12 +75,28 @@ def _list_scripts() -> str: return '\n'.join(lines) +def _tui_confirm(header: str) -> bool: + async def _ask() -> bool: + result = await Confirmation( + group=MenuItemGroup.yes_no(), + header=header, + allow_skip=False, + preset=False, + ).show() + return result.get_value() + + return tui.run(_ask) + + def run() -> int: """ This can either be run as the compiled and installed application: python setup.py install OR straight as a module: python -m archinstall In any case we will be attempting to load the provided script to be run from the scripts/ folder """ + if 'share-log' in sys.argv: + return share_install_log(confirm=_tui_confirm) + arch_config_handler = ArchConfigHandler() if '--help' in sys.argv or '-h' in sys.argv: @@ -141,8 +159,8 @@ def _error_message(exc: Exception) -> None: Archinstall experienced the above error. If you think this is a bug, please report it to https://github.com/archlinux/archinstall and include the log file "/var/log/archinstall/install.log". - Hint: To extract the log from a live ISO - curl -F 'file=@/var/log/archinstall/install.log' https://0x0.st + Hint: To upload the log and get a shareable URL, run + archinstall share-log """ ) warn(text) diff --git a/archinstall/tui/components.py b/archinstall/tui/components.py index f92364bb..9fecf3c6 100644 --- a/archinstall/tui/components.py +++ b/archinstall/tui/components.py @@ -200,6 +200,11 @@ class OptionListScreen(BaseScreen[ValueT]): color: white; text-style: bold; } + + .wrap-preview { + width: 100%; + height: auto; + } """ def __init__( @@ -211,6 +216,7 @@ class OptionListScreen(BaseScreen[ValueT]): allow_reset: bool = False, preview_location: Literal['right', 'bottom'] | None = None, enable_filter: bool = False, + wrap_preview: bool = False, ): super().__init__(allow_skip, allow_reset) self._group = group @@ -218,6 +224,7 @@ class OptionListScreen(BaseScreen[ValueT]): self._title = title self._preview_location = preview_location self._filter = enable_filter + self._wrap_preview = wrap_preview self._show_frame = False self._options = self._get_options() @@ -280,7 +287,10 @@ class OptionListScreen(BaseScreen[ValueT]): with Container(): yield option_list yield Rule(orientation=rule_orientation) - yield ScrollableContainer(Label('', id='preview_content', markup=False)) + preview_label = Label('', id='preview_content', markup=False) + if self._wrap_preview: + preview_label.add_class('wrap-preview') + yield ScrollableContainer(preview_label) if self._filter: yield Input(placeholder='/filter', id='filter-input') @@ -340,11 +350,6 @@ class OptionListScreen(BaseScreen[ValueT]): ] ) - # debug(f'Index: {index}') - # debug(f'Region: {option_list.region}') - # debug(f'Scroll offset: {option_list.scroll_offset}') - # debug(f'Target_Y: {target_y}') - self.app.cursor_position = Offset(option_list.region.x, target_y) self.app.refresh() @@ -433,6 +438,11 @@ class SelectListScreen(BaseScreen[ValueT]): color: white; text-style: bold; } + + .wrap-preview { + width: 100%; + height: auto; + } """ def __init__( @@ -443,6 +453,7 @@ class SelectListScreen(BaseScreen[ValueT]): allow_reset: bool = False, preview_location: Literal['right', 'bottom'] | None = None, enable_filter: bool = False, + wrap_preview: bool = False, ): super().__init__(allow_skip, allow_reset) self._group = group @@ -450,6 +461,7 @@ class SelectListScreen(BaseScreen[ValueT]): self._preview_location = preview_location self._show_frame = False self._filter = enable_filter + self._wrap_preview = wrap_preview self._selected_items: list[MenuItem] = self._group.selected_items self._options: list[Selection[MenuItem]] = self._get_selections() @@ -510,7 +522,10 @@ class SelectListScreen(BaseScreen[ValueT]): with Container(): yield selection_list yield Rule(orientation=rule_orientation) - yield ScrollableContainer(Label('', id='preview_content', markup=False)) + preview_label = Label('', id='preview_content', markup=False) + if self._wrap_preview: + preview_label.add_class('wrap-preview') + yield ScrollableContainer(preview_label) if self._filter: yield Input(placeholder='/filter', id='filter-input') @@ -1143,8 +1158,6 @@ class TableSelectionScreen(BaseScreen[ValueT]): ] ) - debug(f'Setting cursor to target_y: {target_y}') - self.app.cursor_position = Offset(data_table.region.x, target_y) self.app.refresh() diff --git a/archinstall/tui/rich.py b/archinstall/tui/rich.py new file mode 100644 index 00000000..055ec22c --- /dev/null +++ b/archinstall/tui/rich.py @@ -0,0 +1,25 @@ +from io import StringIO + +from rich import box +from rich.console import Console +from rich.table import Table as RichTable + + +class BaseRichTable(RichTable): + def __init__(self) -> None: + super().__init__( + box=box.SIMPLE, + show_header=False, + pad_edge=False, + show_edge=False, + ) + + def stringify(self) -> str: + string_io = StringIO() + buf = Console(file=string_io, highlight=False) + buf.print(self) + + _ = string_io.seek(0) + output = string_io.read() + + return output diff --git a/docs/help/report_bug.rst b/docs/help/report_bug.rst index bacaeb4c..b11027ab 100644 --- a/docs/help/report_bug.rst +++ b/docs/help/report_bug.rst @@ -15,7 +15,7 @@ When submitting a help ticket, please include the :code:`/var/log/archinstall/in It can be found both on the live ISO but also in the installed filesystem if the base packages were strapped in. .. tip:: - | An easy way to submit logs is ``curl -F 'file=@/var/log/archinstall/install.log' https://0x0.st``. + | An easy way to submit logs is ``archinstall share-log``, which uploads ``install.log`` to paste.rs and prints a shareable URL. | Use caution when submitting other log files, but ``archinstall`` pledges to keep ``install.log`` safe for posting publicly! There are additional log files under ``/var/log/archinstall/`` that can be useful: diff --git a/pyproject.toml b/pyproject.toml index 28e91c2a..13e0933f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,7 @@ classifiers = [ ] dependencies = [ "pyparted==3.13.0", - "pydantic==2.12.5", + "pydantic==2.13.4", "cryptography==48.0.0", "textual==8.2.5", "markdown-it-py==4.0.0", @@ -34,7 +34,7 @@ Source = "https://github.com/archlinux/archinstall" [project.optional-dependencies] log = ["systemd_python==235"] dev = [ - "mypy==1.20.2", + "mypy==2.1.0", "flake8==7.3.0", "pre-commit==4.6.0", "ruff==0.15.12", diff --git a/tests/test_share_log.py b/tests/test_share_log.py new file mode 100644 index 00000000..c1c3ca84 --- /dev/null +++ b/tests/test_share_log.py @@ -0,0 +1,94 @@ +# pylint: disable=redefined-outer-name +import urllib.error +from io import BytesIO +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from archinstall.lib.output import share_install_log + + +@pytest.fixture() +def log_file(tmp_path: Path) -> Path: + log_dir = tmp_path / 'archinstall' + log_dir.mkdir() + return log_dir / 'install.log' + + +def _fake_logger(log_file: Path) -> MagicMock: + mock = MagicMock() + mock.path = log_file + return mock + + +def test_file_not_found(tmp_path: Path) -> None: + missing = tmp_path / 'no-such' / 'install.log' + with patch('archinstall.lib.output.logger', _fake_logger(missing)): + assert share_install_log() == 1 + + +def test_empty_file(log_file: Path) -> None: + log_file.write_bytes(b'') + with patch('archinstall.lib.output.logger', _fake_logger(log_file)): + assert share_install_log() == 1 + + +def test_user_cancels(log_file: Path) -> None: + log_file.write_text('some log content') + with patch('archinstall.lib.output.logger', _fake_logger(log_file)): + assert share_install_log(confirm=lambda _: False) == 1 + + +def test_successful_upload(log_file: Path) -> None: + log_file.write_text('some log content') + fake_response = BytesIO(b'https://paste.rs/abc.def') + + with ( + patch('archinstall.lib.output.logger', _fake_logger(log_file)), + patch('urllib.request.urlopen', return_value=fake_response) as mock_open, + ): + result = share_install_log() + + assert result == 0 + req = mock_open.call_args[0][0] + assert req.data == b'some log content' + + +def test_truncation(log_file: Path) -> None: + max_size = 100 + content = b'A' * 50 + b'B' * 80 + log_file.write_bytes(content) + fake_response = BytesIO(b'https://paste.rs/abc.def') + + with ( + patch('archinstall.lib.output.logger', _fake_logger(log_file)), + patch('urllib.request.urlopen', return_value=fake_response) as mock_open, + ): + result = share_install_log(max_size=max_size) + + assert result == 0 + req = mock_open.call_args[0][0] + assert len(req.data) == max_size + assert req.data == content[-max_size:] + + +def test_network_error(log_file: Path) -> None: + log_file.write_text('some log content') + + with ( + patch('archinstall.lib.output.logger', _fake_logger(log_file)), + patch('urllib.request.urlopen', side_effect=urllib.error.URLError('no network')), + ): + assert share_install_log() == 1 + + +def test_unexpected_response(log_file: Path) -> None: + log_file.write_text('some log content') + fake_response = BytesIO(b'ERROR: something went wrong') + + with ( + patch('archinstall.lib.output.logger', _fake_logger(log_file)), + patch('urllib.request.urlopen', return_value=fake_response), + ): + assert share_install_log() == 1