From 7b5dddf34bfcf3c4101e89cd18d53750e4aa2a38 Mon Sep 17 00:00:00 2001 From: Softer Date: Thu, 7 May 2026 03:17:13 +0300 Subject: [PATCH 01/11] Fix truncated package metadata in additional packages preview (#3580) (#4510) * Fix truncated package metadata in additional packages preview (#3580) * Simplify package info fix: use rstrip and CSS wrap instead of env var plumbing * Apply preview text wrap conditionally via wrap_preview parameter * Add missing wrap-preview CSS to OptionListScreen and pass parameter through SelectMenu --- archinstall/lib/menu/helpers.py | 4 ++++ archinstall/lib/models/packages.py | 1 - archinstall/lib/packages/packages.py | 7 ++++--- archinstall/tui/components.py | 24 ++++++++++++++++++++++-- 4 files changed, 30 insertions(+), 6 deletions(-) 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/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/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/tui/components.py b/archinstall/tui/components.py index f92364bb..f732c21f 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') @@ -433,6 +443,11 @@ class SelectListScreen(BaseScreen[ValueT]): color: white; text-style: bold; } + + .wrap-preview { + width: 100%; + height: auto; + } """ def __init__( @@ -443,6 +458,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 +466,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 +527,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') From ef1cfe7b56a962ccccb4e14646f0767f490a7c2b Mon Sep 17 00:00:00 2001 From: Softer Date: Thu, 7 May 2026 03:18:56 +0300 Subject: [PATCH 02/11] Add share-log subcommand to upload install.log to paste.rs (#4511) * Add --share-log flag to upload install.log to paste.rs * Apply ruff-format to share_log.py * Rework share-log per review: subcommand, TUI confirmation, truncate large logs * Replace curl/SysCommand with urllib.request, remove defensive try/except Per review: use stdlib urllib instead of shelling out to curl, drop unnecessary try/except around TUI confirmation, remove tempfile (content is passed directly as bytes). * Fix TUI imports after ui module was pulled one level up (#4515) * Decouple share_install_log from TUI module * Add unit tests for share_install_log function * Update docs to use share-log subcommand syntax --- .github/ISSUE_TEMPLATE/01_bug.yml | 4 +- README.md | 4 +- archinstall/lib/args.py | 1 - archinstall/lib/output.py | 50 ++++++++++++++++ archinstall/main.py | 24 +++++++- docs/help/report_bug.rst | 2 +- tests/test_share_log.py | 94 +++++++++++++++++++++++++++++++ 7 files changed, 170 insertions(+), 9 deletions(-) create mode 100644 tests/test_share_log.py 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/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/lib/args.py b/archinstall/lib/args.py index 8d78f7e6..d8aaaa83 100644 --- a/archinstall/lib/args.py +++ b/archinstall/lib/args.py @@ -431,7 +431,6 @@ class ArchConfigHandler: default=False, help='Enabled verbose options', ) - return parser def _parse_args(self) -> Arguments: diff --git a/archinstall/lib/output.py b/archinstall/lib/output.py index bc4bb113..3a972dfb 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 @@ -333,3 +335,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/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/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/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 From b0b7983af2b0cabce282e576ed3a20010e20fb2c Mon Sep 17 00:00:00 2001 From: Softer Date: Thu, 7 May 2026 03:20:09 +0300 Subject: [PATCH 03/11] Fix bspwm black screen: add provision() delegation and default configs (#4518) * Fix bspwm black screen: add provision() delegation and default configs * Move TYPE_CHECKING imports to top level in bspwm profile --- archinstall/default_profiles/desktop.py | 6 ++++++ archinstall/default_profiles/desktops/bspwm.py | 10 ++++++++++ 2 files changed, 16 insertions(+) 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) From 4265be6e4386a2d8b00436aacbef1e37d2ebe2d9 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 7 May 2026 21:56:30 +1000 Subject: [PATCH 04/11] Update pre-commit hook pre-commit/mirrors-mypy to v2 (#4525) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7ee79056..6cf16130 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.0.0 hooks: - id: mypy args: [ From af106eb23838fdee05e1025ff259533f7a965be5 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 7 May 2026 21:57:10 +1000 Subject: [PATCH 05/11] Update dependency mypy to v2 (#4523) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 28e91c2a..9fee23fa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,7 @@ Source = "https://github.com/archlinux/archinstall" [project.optional-dependencies] log = ["systemd_python==235"] dev = [ - "mypy==1.20.2", + "mypy==2.0.0", "flake8==7.3.0", "pre-commit==4.6.0", "ruff==0.15.12", From 2de7254b21ddd4d554b9933c8131133658fed9cf Mon Sep 17 00:00:00 2001 From: Lena Pastwa Date: Fri, 8 May 2026 03:28:36 +0200 Subject: [PATCH 06/11] Update Polish translation (#4529) --- archinstall/locales/base.pot | 11 + archinstall/locales/pl/LC_MESSAGES/base.mo | Bin 62517 -> 67521 bytes archinstall/locales/pl/LC_MESSAGES/base.po | 237 ++++++++++++++++++++- 3 files changed, 247 insertions(+), 1 deletion(-) 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 4a57d37caea35cb2f39e0904bfcdd33b192b6be0..0ab34f9eca17057f5f6a378cba00b090d27ae98a 100644 GIT binary patch delta 18125 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 Date: Mon, 11 May 2026 06:35:53 +1000 Subject: [PATCH 07/11] Enhance config types and summary (#4532) * Enhance config types and summary * Update --- archinstall/lib/args.py | 165 ++++++++++++++++++----- archinstall/lib/configuration.py | 76 +++-------- archinstall/lib/models/application.py | 52 ++++++- archinstall/lib/models/authentication.py | 21 ++- archinstall/lib/models/bootloader.py | 17 ++- archinstall/lib/models/config.py | 12 ++ archinstall/lib/models/device.py | 37 ++++- archinstall/lib/models/locale.py | 15 ++- archinstall/lib/models/mirrors.py | 23 +++- archinstall/lib/models/network.py | 8 +- archinstall/lib/models/pacman.py | 12 +- archinstall/lib/models/profile.py | 24 +++- archinstall/lib/output.py | 32 ++++- archinstall/tui/components.py | 7 - archinstall/tui/rich.py | 25 ++++ 15 files changed, 411 insertions(+), 115 deletions(-) create mode 100644 archinstall/lib/models/config.py create mode 100644 archinstall/tui/rich.py diff --git a/archinstall/lib/args.py b/archinstall/lib/args.py index d8aaaa83..efe9baa2 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,6 +59,81 @@ 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 @@ -80,58 +157,84 @@ class ArchConfig: 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] + 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 + 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 + config[ArchConfigType.ENCRYPTION_PASSWORD] = disk_encryption.encryption_password.plaintext 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 + + 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, + } + + def sub_cfg(self) -> dict[ArchConfigType, SubConfig]: + cfg: dict[ArchConfigType, SubConfig] = { + ArchConfigType.PACMAN_CONFIG: self.pacman_config, + } if self.mirror_config: - config['mirror_config'] = self.mirror_config.json() + cfg[ArchConfigType.MIRROR_CONFIG] = self.mirror_config + + if self.bootloader_config: + cfg[ArchConfigType.BOOTLOADER_CONFIG] = self.bootloader_config + + if self.disk_config: + cfg[ArchConfigType.DISK_CONFIG] = self.disk_config + + if self.swap: + cfg[ArchConfigType.SWAP] = self.swap + + if self.auth_config: + cfg[ArchConfigType.AUTH_CONFIG] = self.auth_config + + if self.locale_config: + cfg[ArchConfigType.LOCALE_CONFIG] = self.locale_config + + if self.profile_config: + cfg[ArchConfigType.PROFILE_CONFIG] = self.profile_config if self.network_config: - config['network_config'] = self.network_config.json() + cfg[ArchConfigType.NETWORK_CONFIG] = self.network_config - return config + if self.app_config: + cfg[ArchConfigType.APP_CONFIG] = self.app_config + + return cfg @classmethod def from_config(cls, args_config: dict[str, Any], args: Arguments) -> Self: 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/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/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 3a972dfb..86a47ed1 100644 --- a/archinstall/lib/output.py +++ b/archinstall/lib/output.py @@ -11,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 @@ -20,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]: """ @@ -129,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: diff --git a/archinstall/tui/components.py b/archinstall/tui/components.py index f732c21f..9fecf3c6 100644 --- a/archinstall/tui/components.py +++ b/archinstall/tui/components.py @@ -350,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() @@ -1163,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 From f7a6f70fc858280888fce47c01839e0a407b2c20 Mon Sep 17 00:00:00 2001 From: stinga11 Date: Tue, 12 May 2026 18:21:15 -0400 Subject: [PATCH 08/11] Replace terminal and file manager in Budgie profile (#4533) I think it was a mistake to have made the previous changes to KDE Apps. In retrospect, it seemed like a good idea since the Budgie developer had done it that way in Fedora, which is the distro the developer uses as a reference for Budgie. When you start using it, you realize there's no bridge between the desktop and the KDE Apps, and things like Dolphin recognizes icons and themes, requiring some rather annoying manual configurations. Then, if you want to change them again, you have to change those configurations again. Files don't open by default with the apps either; you have to configure them for that to work. At first, I thought the Budgie packager for Arch had forgotten some stray dependency, but with some free time, I tested it with Fedora 44, and to my surprise, it has exactly the same problems, which is completely unacceptable for a final stable release. I suppose he'll make the necessary changes in the near future, but right now, it's a disaster. --- archinstall/default_profiles/desktops/budgie.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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 From 3dec5025c303d73c2258872bbba3fd4cbfb5b2d6 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 13 May 2026 08:21:44 +1000 Subject: [PATCH 09/11] Update dependency arch/python-pydantic to v2.13.4 (#4534) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 9fee23fa..3bbc7acb 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", From b95321d38ca7db8f3e0432279bfbd76ba143bb89 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 13 May 2026 08:33:42 +1000 Subject: [PATCH 10/11] Update dependency mypy to v2.1.0 (#4535) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 3bbc7acb..13e0933f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,7 @@ Source = "https://github.com/archlinux/archinstall" [project.optional-dependencies] log = ["systemd_python==235"] dev = [ - "mypy==2.0.0", + "mypy==2.1.0", "flake8==7.3.0", "pre-commit==4.6.0", "ruff==0.15.12", From 13944f3ccad8ad6ec7de26791a65eab9aed872d1 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 13 May 2026 08:54:05 +1000 Subject: [PATCH 11/11] Update pre-commit hook pre-commit/mirrors-mypy to v2.1.0 (#4538) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6cf16130..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: v2.0.0 + rev: v2.1.0 hooks: - id: mypy args: [