From c3649e2f88773000ccacc60205ef5ece609943b8 Mon Sep 17 00:00:00 2001 From: CooperWang0912 Date: Thu, 23 Jul 2026 11:44:18 +0800 Subject: [PATCH 1/8] Added Status Indicators in Menu --- archinstall/lib/global_menu.py | 64 ++++++++++++++++++++++++++++------ 1 file changed, 53 insertions(+), 11 deletions(-) diff --git a/archinstall/lib/global_menu.py b/archinstall/lib/global_menu.py index 72c31033af..663a32c846 100644 --- a/archinstall/lib/global_menu.py +++ b/archinstall/lib/global_menu.py @@ -59,6 +59,46 @@ def __init__( ) super().__init__(self._item_group, config=arch_config, title=title) + self._update_item_labels() + + def _get_status_prefix(self, item: MenuItem) -> str: + """ + Returns a rich-formatted status prefix icon depending on item state: + - [✓] (Green) for configured items + - [!] (Yellow) for missing mandatory or required items + - [•] (Dim) for unconfigured optional items + """ + if item.read_only or item.key in (SpecialMenuKey.SAVE.value, SpecialMenuKey.INSTALL.value, SpecialMenuKey.ABORT.value): + return "" + + if item.key == 'auth_config': + auth_config: AuthenticationConfiguration | None = item.value + is_auth_valid = ( + auth_config is not None + and (auth_config.root_enc_password is not None or auth_config.has_superuser()) + ) + if is_auth_valid: + return "[bold green][✓][/bold green] " + return "[bold yellow][!][/bold yellow] " + + if item.has_value(): + return "[bold green][✓][/bold green] " + elif item.mandatory: + return "[bold yellow][!][/bold yellow] " + else: + return "[dim white][•][/dim white] " + + def _update_item_labels(self) -> None: + """ + Re-applies translated titles and status prefixes to all active menu items. + """ + new_options = {o.key: o.text for o in self._get_menu_options() if o.key is not None} + + for item in self._item_group.items: + if item.key in new_options: + base_title = new_options[item.key] + prefix = self._get_status_prefix(item) + item.text = f"{prefix}{base_title}" def _get_menu_options(self) -> list[MenuItem]: menu_options = [ @@ -265,27 +305,24 @@ def _prev_archinstall_language(self, item: MenuItem) -> str | None: async def _select_applications(self, preset: ApplicationConfiguration | None) -> ApplicationConfiguration | None: app_config = await ApplicationMenu(preset).show() + self._update_item_labels() return app_config async def _select_authentication(self, preset: AuthenticationConfiguration | None) -> AuthenticationConfiguration | None: auth_config = await AuthenticationMenu(preset).show() + self._update_item_labels() return auth_config def _update_lang_text(self) -> None: """ - The options for the global menu are generated with a static text; - each entry of the menu needs to be updated with the new translation + Updates option titles and status prefixes when language changes or settings are modified. """ - new_options = self._get_menu_options() - - for o in new_options: - if o.key is not None: - self._item_group.find_by_key(o.key).text = o.text - + self._update_item_labels() tui.translate_bindings() async def _locale_selection(self, preset: LocaleConfiguration) -> LocaleConfiguration | None: locale_config = await LocaleMenu(preset).show() + self._update_item_labels() return locale_config def _prev_locale(self, item: MenuItem) -> str | None: @@ -420,7 +457,9 @@ def _prev_hostname(self, item: MenuItem) -> str | None: return None async def _pacman_configuration(self, preset: PacmanConfiguration) -> PacmanConfiguration | None: - return await PacmanMenu(preset, advanced=self._advanced).show() + res = await PacmanMenu(preset, advanced=self._advanced).show() + self._update_item_labels() + return res def _prev_pacman_config(self, item: MenuItem) -> str | None: if not item.value: @@ -561,6 +600,7 @@ async def _select_disk_config( preset: DiskLayoutConfiguration | None = None, ) -> DiskLayoutConfiguration | None: disk_config = await DiskLayoutConfigurationMenu(preset).show() + self._update_item_labels() return disk_config async def _select_bootloader_config( @@ -571,13 +611,14 @@ async def _select_bootloader_config( preset = BootloaderConfiguration.get_default(self._uefi, self._skip_boot) bootloader_config = await BootloaderMenu(preset, self._uefi, self._skip_boot).show() - + self._update_item_labels() return bootloader_config async def _select_profile(self, current_profile: ProfileConfiguration | None) -> ProfileConfiguration | None: from archinstall.lib.profile.profile_menu import ProfileMenu profile_config = await ProfileMenu(preset=current_profile).show() + self._update_item_labels() return profile_config async def _select_additional_packages(self, preset: list[str]) -> list[str]: @@ -591,7 +632,7 @@ async def _select_additional_packages(self, preset: list[str]) -> list[str]: preset, repositories=repositories, ) - + self._update_item_labels() return packages async def _mirror_configuration(self, preset: MirrorConfiguration | None = None) -> MirrorConfiguration | None: @@ -609,6 +650,7 @@ async def _mirror_configuration(self, preset: MirrorConfiguration | None = None) pacman_config.enable(mirror_configuration.optional_repositories) pacman_config.apply() + self._update_item_labels() return mirror_configuration def _prev_mirror_config(self, item: MenuItem) -> str | None: From 4d839631a4ea649542fd9a150ceff8106d47e050 Mon Sep 17 00:00:00 2001 From: CooperWang0912 Date: Thu, 23 Jul 2026 13:05:16 +0800 Subject: [PATCH 2/8] Wrapper for Instant Update and Color Change --- archinstall/lib/global_menu.py | 58 ++++++++++++++++++++++------------ 1 file changed, 38 insertions(+), 20 deletions(-) diff --git a/archinstall/lib/global_menu.py b/archinstall/lib/global_menu.py index 663a32c846..5ac0fe78d4 100644 --- a/archinstall/lib/global_menu.py +++ b/archinstall/lib/global_menu.py @@ -1,4 +1,5 @@ -from typing import override +import inspect +from typing import Any, Callable, override from archinstall.default_profiles.profile import GreeterType from archinstall.lib.applications.application_menu import ApplicationMenu @@ -50,7 +51,7 @@ def __init__( self._skip_boot = skip_boot self._advanced = advanced self._uefi = SysInfo.has_uefi() - menu_options = self._get_menu_options() + menu_options = self._get_menu_options(wrap_actions=True) self._item_group = MenuItemGroup( menu_options, @@ -65,8 +66,8 @@ def _get_status_prefix(self, item: MenuItem) -> str: """ Returns a rich-formatted status prefix icon depending on item state: - [✓] (Green) for configured items - - [!] (Yellow) for missing mandatory or required items - - [•] (Dim) for unconfigured optional items + - [!] (Red) for missing mandatory or required items + - [•] (Yellow) for unconfigured optional items """ if item.read_only or item.key in (SpecialMenuKey.SAVE.value, SpecialMenuKey.INSTALL.value, SpecialMenuKey.ABORT.value): return "" @@ -79,20 +80,22 @@ def _get_status_prefix(self, item: MenuItem) -> str: ) if is_auth_valid: return "[bold green][✓][/bold green] " - return "[bold yellow][!][/bold yellow] " + return "[bold red][!][/bold red] " + # Standard mandatory or configured item check if item.has_value(): return "[bold green][✓][/bold green] " elif item.mandatory: - return "[bold yellow][!][/bold yellow] " + return "[bold red][!][/bold red] " else: - return "[dim white][•][/dim white] " + return "[bold yellow][•][/bold yellow] " def _update_item_labels(self) -> None: """ Re-applies translated titles and status prefixes to all active menu items. """ - new_options = {o.key: o.text for o in self._get_menu_options() if o.key is not None} + raw_options = self._get_menu_options(wrap_actions=False) + new_options = {o.key: o.text for o in raw_options if o.key is not None} for item in self._item_group.items: if item.key in new_options: @@ -100,7 +103,26 @@ def _update_item_labels(self) -> None: prefix = self._get_status_prefix(item) item.text = f"{prefix}{base_title}" - def _get_menu_options(self) -> list[MenuItem]: + def _wrap_action(self, key: str, action: Callable) -> Callable: + async def wrapper(*args, **kwargs) -> Any: + if inspect.iscoroutinefunction(action): + result = await action(*args, **kwargs) + else: + result = action(*args, **kwargs) + if inspect.isawaitable(result): + result = await result + + item = self._item_group.find_by_key(key) + if item: + item.value = result + + self._update_item_labels() + + return result + + return wrapper + + def _get_menu_options(self, wrap_actions: bool = True) -> list[MenuItem]: menu_options = [ MenuItem( text=tr('Archinstall language'), @@ -231,6 +253,11 @@ def _get_menu_options(self) -> list[MenuItem]: ), ] + if wrap_actions: + for item in menu_options: + if item.key and item.action: + item.action = self._wrap_action(item.key, item.action) + return menu_options async def _safe_config(self) -> None: @@ -305,12 +332,10 @@ def _prev_archinstall_language(self, item: MenuItem) -> str | None: async def _select_applications(self, preset: ApplicationConfiguration | None) -> ApplicationConfiguration | None: app_config = await ApplicationMenu(preset).show() - self._update_item_labels() return app_config async def _select_authentication(self, preset: AuthenticationConfiguration | None) -> AuthenticationConfiguration | None: auth_config = await AuthenticationMenu(preset).show() - self._update_item_labels() return auth_config def _update_lang_text(self) -> None: @@ -322,7 +347,6 @@ def _update_lang_text(self) -> None: async def _locale_selection(self, preset: LocaleConfiguration) -> LocaleConfiguration | None: locale_config = await LocaleMenu(preset).show() - self._update_item_labels() return locale_config def _prev_locale(self, item: MenuItem) -> str | None: @@ -457,9 +481,7 @@ def _prev_hostname(self, item: MenuItem) -> str | None: return None async def _pacman_configuration(self, preset: PacmanConfiguration) -> PacmanConfiguration | None: - res = await PacmanMenu(preset, advanced=self._advanced).show() - self._update_item_labels() - return res + return await PacmanMenu(preset, advanced=self._advanced).show() def _prev_pacman_config(self, item: MenuItem) -> str | None: if not item.value: @@ -600,7 +622,6 @@ async def _select_disk_config( preset: DiskLayoutConfiguration | None = None, ) -> DiskLayoutConfiguration | None: disk_config = await DiskLayoutConfigurationMenu(preset).show() - self._update_item_labels() return disk_config async def _select_bootloader_config( @@ -611,14 +632,12 @@ async def _select_bootloader_config( preset = BootloaderConfiguration.get_default(self._uefi, self._skip_boot) bootloader_config = await BootloaderMenu(preset, self._uefi, self._skip_boot).show() - self._update_item_labels() return bootloader_config async def _select_profile(self, current_profile: ProfileConfiguration | None) -> ProfileConfiguration | None: from archinstall.lib.profile.profile_menu import ProfileMenu profile_config = await ProfileMenu(preset=current_profile).show() - self._update_item_labels() return profile_config async def _select_additional_packages(self, preset: list[str]) -> list[str]: @@ -632,7 +651,7 @@ async def _select_additional_packages(self, preset: list[str]) -> list[str]: preset, repositories=repositories, ) - self._update_item_labels() + return packages async def _mirror_configuration(self, preset: MirrorConfiguration | None = None) -> MirrorConfiguration | None: @@ -650,7 +669,6 @@ async def _mirror_configuration(self, preset: MirrorConfiguration | None = None) pacman_config.enable(mirror_configuration.optional_repositories) pacman_config.apply() - self._update_item_labels() return mirror_configuration def _prev_mirror_config(self, item: MenuItem) -> str | None: From 10858aaec88959616e599f9905de80381be93d7b Mon Sep 17 00:00:00 2001 From: CooperWang0912 Date: Thu, 23 Jul 2026 13:55:48 +0800 Subject: [PATCH 3/8] Formatting and Testing --- archinstall/lib/global_menu.py | 32 ++++--- tests/test_global_menu_ui.py | 154 +++++++++++++++++++++++++++++++++ 2 files changed, 169 insertions(+), 17 deletions(-) create mode 100644 tests/test_global_menu_ui.py diff --git a/archinstall/lib/global_menu.py b/archinstall/lib/global_menu.py index 5ac0fe78d4..b21e4c14e7 100644 --- a/archinstall/lib/global_menu.py +++ b/archinstall/lib/global_menu.py @@ -1,5 +1,6 @@ import inspect -from typing import Any, Callable, override +from collections.abc import Callable +from typing import Any, override from archinstall.default_profiles.profile import GreeterType from archinstall.lib.applications.application_menu import ApplicationMenu @@ -70,25 +71,22 @@ def _get_status_prefix(self, item: MenuItem) -> str: - [•] (Yellow) for unconfigured optional items """ if item.read_only or item.key in (SpecialMenuKey.SAVE.value, SpecialMenuKey.INSTALL.value, SpecialMenuKey.ABORT.value): - return "" + return '' if item.key == 'auth_config': auth_config: AuthenticationConfiguration | None = item.value - is_auth_valid = ( - auth_config is not None - and (auth_config.root_enc_password is not None or auth_config.has_superuser()) - ) + is_auth_valid = auth_config is not None and (auth_config.root_enc_password is not None or auth_config.has_superuser()) if is_auth_valid: - return "[bold green][✓][/bold green] " - return "[bold red][!][/bold red] " + return '[bold green][✓][/bold green] ' + return '[bold red][!][/bold red] ' # Standard mandatory or configured item check if item.has_value(): - return "[bold green][✓][/bold green] " + return '[bold green][✓][/bold green] ' elif item.mandatory: - return "[bold red][!][/bold red] " + return '[bold red][!][/bold red] ' else: - return "[bold yellow][•][/bold yellow] " + return '[bold yellow][•][/bold yellow] ' def _update_item_labels(self) -> None: """ @@ -101,9 +99,9 @@ def _update_item_labels(self) -> None: if item.key in new_options: base_title = new_options[item.key] prefix = self._get_status_prefix(item) - item.text = f"{prefix}{base_title}" + item.text = f'{prefix}{base_title}' - def _wrap_action(self, key: str, action: Callable) -> Callable: + def _wrap_action(self, key: str, action: Callable[..., Any]) -> Callable[..., Any]: async def wrapper(*args, **kwargs) -> Any: if inspect.iscoroutinefunction(action): result = await action(*args, **kwargs) @@ -111,15 +109,15 @@ async def wrapper(*args, **kwargs) -> Any: result = action(*args, **kwargs) if inspect.isawaitable(result): result = await result - + item = self._item_group.find_by_key(key) if item: item.value = result - + self._update_item_labels() - + return result - + return wrapper def _get_menu_options(self, wrap_actions: bool = True) -> list[MenuItem]: diff --git a/tests/test_global_menu_ui.py b/tests/test_global_menu_ui.py new file mode 100644 index 0000000000..cb1435d3ae --- /dev/null +++ b/tests/test_global_menu_ui.py @@ -0,0 +1,154 @@ +import asyncio +import inspect +from unittest.mock import MagicMock + + +class MockSpecialMenuKey: + SAVE = 'save' + INSTALL = 'install' + ABORT = 'abort' + + +class MockMenuItem: + def __init__(self, key=None, read_only=False, mandatory=False, value=None): + self.key = key + self.read_only = read_only + self.mandatory = mandatory + self.value = value + + def has_value(self): + return self.value is not None + + +def isolated_get_status_prefix(item: MockMenuItem) -> str: + special_keys = (MockSpecialMenuKey.SAVE, MockSpecialMenuKey.INSTALL, MockSpecialMenuKey.ABORT) + + if item.read_only or item.key in special_keys: + return '' + + if item.key == 'auth_config': + auth_config = item.value + has_root = getattr(auth_config, 'root_enc_password', None) is not None + has_super = getattr(auth_config, 'has_superuser', lambda: False)() if auth_config else False + + if auth_config is not None and (has_root or has_super): + return '[bold green][✓][/bold green] ' + return '[bold red][!][/bold red] ' + + if item.has_value(): + return '[bold green][✓][/bold green] ' + elif item.mandatory: + return '[bold red][!][/bold red] ' + else: + return '[bold yellow][•][/bold yellow] ' + + +def isolated_wrap_action(item_dictionary, update_callback, key, action): + + async def wrapper(*args, **kwargs): + if inspect.iscoroutinefunction(action): + result = await action(*args, **kwargs) + else: + result = action(*args, **kwargs) + if inspect.isawaitable(result): + result = await result + + if key in item_dictionary: + item_dictionary[key].value = result + + update_callback() + return result + + return wrapper + + +class TestPrefixLogic: + def test_special_keys_have_no_prefix(self): + item = MockMenuItem(key=MockSpecialMenuKey.SAVE) + assert isolated_get_status_prefix(item) == '' + + def test_read_only_has_no_prefix(self): + item = MockMenuItem(read_only=True) + assert isolated_get_status_prefix(item) == '' + + def test_configured_item_has_checkmark(self): + item = MockMenuItem(key='hostname', value='archlinux') + prefix = isolated_get_status_prefix(item) + assert '[✓]' in prefix + assert 'green' in prefix + + def test_missing_mandatory_item_has_warning(self): + item = MockMenuItem(key='disk_config', mandatory=True, value=None) + prefix = isolated_get_status_prefix(item) + assert '[!]' in prefix + assert 'red' in prefix + + def test_missing_optional_item_has_dot(self): + item = MockMenuItem(key='network_config', mandatory=False, value=None) + prefix = isolated_get_status_prefix(item) + assert '[•]' in prefix + assert 'yellow' in prefix + + +class TestAuthConfigLogic: + def test_auth_missing_entirely(self): + item = MockMenuItem(key='auth_config', value=None) + assert '[!]' in isolated_get_status_prefix(item) + + def test_auth_invalid_state(self): + mock_auth = MagicMock() + mock_auth.root_enc_password = None + mock_auth.has_superuser.return_value = False + + item = MockMenuItem(key='auth_config', value=mock_auth) + assert '[!]' in isolated_get_status_prefix(item) + + def test_auth_valid_root(self): + mock_auth = MagicMock() + mock_auth.root_enc_password = 'hashed' + mock_auth.has_superuser.return_value = False + + item = MockMenuItem(key='auth_config', value=mock_auth) + assert '[✓]' in isolated_get_status_prefix(item) + + def test_auth_valid_superuser(self): + mock_auth = MagicMock() + mock_auth.root_enc_password = None + mock_auth.has_superuser.return_value = True + + item = MockMenuItem(key='auth_config', value=mock_auth) + assert '[✓]' in isolated_get_status_prefix(item) + + +class TestActionWrapperLogic: + def test_async_action_updates_value(self): + mock_item = MockMenuItem(key='disk') + item_dict = {'disk': mock_item} + update_spy = MagicMock() + + async def dummy_async_action(): + return 'new_disk_layout' + + wrapped = isolated_wrap_action(item_dict, update_spy, 'disk', dummy_async_action) + + result = asyncio.run(wrapped()) + + assert result == 'new_disk_layout' + assert mock_item.value == 'new_disk_layout' + update_spy.assert_called_once() + + def test_sync_action_updates_value(self): + mock_item = MockMenuItem(key='hostname') + item_dict = {'hostname': mock_item} + update_spy = MagicMock() + + def dummy_sync_action(): + return 'my-pc' + + wrapped = isolated_wrap_action(item_dict, update_spy, 'hostname', dummy_sync_action) + + result = asyncio.run(wrapped()) + + assert result == 'my-pc' + assert mock_item.value == 'my-pc' + update_spy.assert_called_once() From 151916a7f7d4cfce77204c8fb6fd619cfb107c22 Mon Sep 17 00:00:00 2001 From: CooperWang0912 Date: Thu, 23 Jul 2026 17:23:28 +0800 Subject: [PATCH 4/8] Improved Wrapper Logic --- archinstall/lib/global_menu.py | 33 ++++++++-------- tests/test_configuration_output.py | 63 ------------------------------ 2 files changed, 17 insertions(+), 79 deletions(-) delete mode 100644 tests/test_configuration_output.py diff --git a/archinstall/lib/global_menu.py b/archinstall/lib/global_menu.py index b21e4c14e7..410fdf9f12 100644 --- a/archinstall/lib/global_menu.py +++ b/archinstall/lib/global_menu.py @@ -1,6 +1,6 @@ import inspect -from collections.abc import Callable -from typing import Any, override +from functools import wraps +from typing import override from archinstall.default_profiles.profile import GreeterType from archinstall.lib.applications.application_menu import ApplicationMenu @@ -80,7 +80,6 @@ def _get_status_prefix(self, item: MenuItem) -> str: return '[bold green][✓][/bold green] ' return '[bold red][!][/bold red] ' - # Standard mandatory or configured item check if item.has_value(): return '[bold green][✓][/bold green] ' elif item.mandatory: @@ -101,22 +100,24 @@ def _update_item_labels(self) -> None: prefix = self._get_status_prefix(item) item.text = f'{prefix}{base_title}' - def _wrap_action(self, key: str, action: Callable[..., Any]) -> Callable[..., Any]: - async def wrapper(*args, **kwargs) -> Any: - if inspect.iscoroutinefunction(action): - result = await action(*args, **kwargs) - else: - result = action(*args, **kwargs) - if inspect.isawaitable(result): - result = await result + def _wrap_action(item_dictionary, update_callback, key, action): + @wraps(action) + async def wrapper(*args, **kwargs): + try: + if inspect.iscoroutinefunction(action): + result = await action(*args, **kwargs) + else: + result = action(*args, **kwargs) + if inspect.isawaitable(result): + result = await result - item = self._item_group.find_by_key(key) - if item: - item.value = result + if key in item_dictionary: + item_dictionary[key].value = result - self._update_item_labels() + return result - return result + finally: + update_callback() return wrapper diff --git a/tests/test_configuration_output.py b/tests/test_configuration_output.py deleted file mode 100644 index cbedc0260c..0000000000 --- a/tests/test_configuration_output.py +++ /dev/null @@ -1,63 +0,0 @@ -import json -from pathlib import Path - -from pytest import MonkeyPatch - -from archinstall.lib.args import USER_CONFIG_FILE, USER_CREDS_FILE, ArchConfigHandler - - -def test_user_config_roundtrip( - monkeypatch: MonkeyPatch, - config_fixture: Path, -) -> None: - monkeypatch.setattr('sys.argv', ['archinstall', '--config', str(config_fixture)]) - - handler = ArchConfigHandler() - arch_config = handler.config - - # the version is retrieved dynamically from an installed archinstall package - # as there is no version present in the test environment we'll set it manually - arch_config.version = '3.0.2' - - test_out_dir = Path('/tmp/') - test_out_file = test_out_dir / USER_CONFIG_FILE - - arch_config.save(test_out_dir) - - result = json.loads(test_out_file.read_text()) - expected = json.loads(config_fixture.read_text()) - - # the parsed config will check if the given device exists otherwise - # it will ignore the modification; as this test will run on various local systems - # and the CI pipeline there's no good way specify a real device so we'll simply - # copy the expected result to the actual result - result['disk_config']['config_type'] = expected['disk_config']['config_type'] - result['disk_config']['device_modifications'] = expected['disk_config']['device_modifications'] - - assert json.dumps( - result['mirror_config'], - sort_keys=True, - ) == json.dumps( - expected['mirror_config'], - sort_keys=True, - ) - - -def test_creds_roundtrip( - monkeypatch: MonkeyPatch, - creds_fixture: Path, -) -> None: - monkeypatch.setattr('sys.argv', ['archinstall', '--creds', str(creds_fixture)]) - - handler = ArchConfigHandler() - arch_config = handler.config - - test_out_dir = Path('/tmp/') - test_out_file = test_out_dir / USER_CREDS_FILE - - arch_config.save(test_out_dir, creds=True) - - result = json.loads(test_out_file.read_text()) - expected = json.loads(creds_fixture.read_text()) - - assert sorted(result.items()) == sorted(expected.items()) From 0d2e2820931227ee2bac13c2550de65b61baae54 Mon Sep 17 00:00:00 2001 From: CooperWang0912 Date: Thu, 23 Jul 2026 17:24:52 +0800 Subject: [PATCH 5/8] Revert "Improved Wrapper Logic" This reverts commit 151916a7f7d4cfce77204c8fb6fd619cfb107c22. --- archinstall/lib/global_menu.py | 33 ++++++++-------- tests/test_configuration_output.py | 63 ++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 17 deletions(-) create mode 100644 tests/test_configuration_output.py diff --git a/archinstall/lib/global_menu.py b/archinstall/lib/global_menu.py index 410fdf9f12..b21e4c14e7 100644 --- a/archinstall/lib/global_menu.py +++ b/archinstall/lib/global_menu.py @@ -1,6 +1,6 @@ import inspect -from functools import wraps -from typing import override +from collections.abc import Callable +from typing import Any, override from archinstall.default_profiles.profile import GreeterType from archinstall.lib.applications.application_menu import ApplicationMenu @@ -80,6 +80,7 @@ def _get_status_prefix(self, item: MenuItem) -> str: return '[bold green][✓][/bold green] ' return '[bold red][!][/bold red] ' + # Standard mandatory or configured item check if item.has_value(): return '[bold green][✓][/bold green] ' elif item.mandatory: @@ -100,24 +101,22 @@ def _update_item_labels(self) -> None: prefix = self._get_status_prefix(item) item.text = f'{prefix}{base_title}' - def _wrap_action(item_dictionary, update_callback, key, action): - @wraps(action) - async def wrapper(*args, **kwargs): - try: - if inspect.iscoroutinefunction(action): - result = await action(*args, **kwargs) - else: - result = action(*args, **kwargs) - if inspect.isawaitable(result): - result = await result + def _wrap_action(self, key: str, action: Callable[..., Any]) -> Callable[..., Any]: + async def wrapper(*args, **kwargs) -> Any: + if inspect.iscoroutinefunction(action): + result = await action(*args, **kwargs) + else: + result = action(*args, **kwargs) + if inspect.isawaitable(result): + result = await result - if key in item_dictionary: - item_dictionary[key].value = result + item = self._item_group.find_by_key(key) + if item: + item.value = result - return result + self._update_item_labels() - finally: - update_callback() + return result return wrapper diff --git a/tests/test_configuration_output.py b/tests/test_configuration_output.py new file mode 100644 index 0000000000..cbedc0260c --- /dev/null +++ b/tests/test_configuration_output.py @@ -0,0 +1,63 @@ +import json +from pathlib import Path + +from pytest import MonkeyPatch + +from archinstall.lib.args import USER_CONFIG_FILE, USER_CREDS_FILE, ArchConfigHandler + + +def test_user_config_roundtrip( + monkeypatch: MonkeyPatch, + config_fixture: Path, +) -> None: + monkeypatch.setattr('sys.argv', ['archinstall', '--config', str(config_fixture)]) + + handler = ArchConfigHandler() + arch_config = handler.config + + # the version is retrieved dynamically from an installed archinstall package + # as there is no version present in the test environment we'll set it manually + arch_config.version = '3.0.2' + + test_out_dir = Path('/tmp/') + test_out_file = test_out_dir / USER_CONFIG_FILE + + arch_config.save(test_out_dir) + + result = json.loads(test_out_file.read_text()) + expected = json.loads(config_fixture.read_text()) + + # the parsed config will check if the given device exists otherwise + # it will ignore the modification; as this test will run on various local systems + # and the CI pipeline there's no good way specify a real device so we'll simply + # copy the expected result to the actual result + result['disk_config']['config_type'] = expected['disk_config']['config_type'] + result['disk_config']['device_modifications'] = expected['disk_config']['device_modifications'] + + assert json.dumps( + result['mirror_config'], + sort_keys=True, + ) == json.dumps( + expected['mirror_config'], + sort_keys=True, + ) + + +def test_creds_roundtrip( + monkeypatch: MonkeyPatch, + creds_fixture: Path, +) -> None: + monkeypatch.setattr('sys.argv', ['archinstall', '--creds', str(creds_fixture)]) + + handler = ArchConfigHandler() + arch_config = handler.config + + test_out_dir = Path('/tmp/') + test_out_file = test_out_dir / USER_CREDS_FILE + + arch_config.save(test_out_dir, creds=True) + + result = json.loads(test_out_file.read_text()) + expected = json.loads(creds_fixture.read_text()) + + assert sorted(result.items()) == sorted(expected.items()) From ceb615f68bd59d1203b9d8da0671b6ad9507c0c5 Mon Sep 17 00:00:00 2001 From: CooperWang0912 Date: Thu, 23 Jul 2026 18:04:07 +0800 Subject: [PATCH 6/8] Improved Wrapper Logic --- archinstall/lib/global_menu.py | 31 ++++--- tests/test_global_menu_ui.py | 154 --------------------------------- 2 files changed, 17 insertions(+), 168 deletions(-) delete mode 100644 tests/test_global_menu_ui.py diff --git a/archinstall/lib/global_menu.py b/archinstall/lib/global_menu.py index b21e4c14e7..35a5eb40b4 100644 --- a/archinstall/lib/global_menu.py +++ b/archinstall/lib/global_menu.py @@ -1,5 +1,6 @@ import inspect from collections.abc import Callable +from functools import wraps from typing import Any, override from archinstall.default_profiles.profile import GreeterType @@ -101,22 +102,24 @@ def _update_item_labels(self) -> None: prefix = self._get_status_prefix(item) item.text = f'{prefix}{base_title}' - def _wrap_action(self, key: str, action: Callable[..., Any]) -> Callable[..., Any]: - async def wrapper(*args, **kwargs) -> Any: - if inspect.iscoroutinefunction(action): - result = await action(*args, **kwargs) - else: - result = action(*args, **kwargs) - if inspect.isawaitable(result): - result = await result - - item = self._item_group.find_by_key(key) - if item: + def _wrap_action(self, item: MenuItem, action: Callable[..., Any]) -> Callable[..., Any]: + @wraps(action) + async def wrapper(*args: Any, **kwargs: Any) -> Any: + try: + if inspect.iscoroutinefunction(action): + result = await action(*args, **kwargs) + else: + result = action(*args, **kwargs) + if inspect.isawaitable(result): + result = await result + + # Directly update the item's value item.value = result - self._update_item_labels() + return result - return result + finally: + self._update_item_labels() return wrapper @@ -254,7 +257,7 @@ def _get_menu_options(self, wrap_actions: bool = True) -> list[MenuItem]: if wrap_actions: for item in menu_options: if item.key and item.action: - item.action = self._wrap_action(item.key, item.action) + item.action = self._wrap_action(item, item.action) return menu_options diff --git a/tests/test_global_menu_ui.py b/tests/test_global_menu_ui.py deleted file mode 100644 index cb1435d3ae..0000000000 --- a/tests/test_global_menu_ui.py +++ /dev/null @@ -1,154 +0,0 @@ -import asyncio -import inspect -from unittest.mock import MagicMock - - -class MockSpecialMenuKey: - SAVE = 'save' - INSTALL = 'install' - ABORT = 'abort' - - -class MockMenuItem: - def __init__(self, key=None, read_only=False, mandatory=False, value=None): - self.key = key - self.read_only = read_only - self.mandatory = mandatory - self.value = value - - def has_value(self): - return self.value is not None - - -def isolated_get_status_prefix(item: MockMenuItem) -> str: - special_keys = (MockSpecialMenuKey.SAVE, MockSpecialMenuKey.INSTALL, MockSpecialMenuKey.ABORT) - - if item.read_only or item.key in special_keys: - return '' - - if item.key == 'auth_config': - auth_config = item.value - has_root = getattr(auth_config, 'root_enc_password', None) is not None - has_super = getattr(auth_config, 'has_superuser', lambda: False)() if auth_config else False - - if auth_config is not None and (has_root or has_super): - return '[bold green][✓][/bold green] ' - return '[bold red][!][/bold red] ' - - if item.has_value(): - return '[bold green][✓][/bold green] ' - elif item.mandatory: - return '[bold red][!][/bold red] ' - else: - return '[bold yellow][•][/bold yellow] ' - - -def isolated_wrap_action(item_dictionary, update_callback, key, action): - - async def wrapper(*args, **kwargs): - if inspect.iscoroutinefunction(action): - result = await action(*args, **kwargs) - else: - result = action(*args, **kwargs) - if inspect.isawaitable(result): - result = await result - - if key in item_dictionary: - item_dictionary[key].value = result - - update_callback() - return result - - return wrapper - - -class TestPrefixLogic: - def test_special_keys_have_no_prefix(self): - item = MockMenuItem(key=MockSpecialMenuKey.SAVE) - assert isolated_get_status_prefix(item) == '' - - def test_read_only_has_no_prefix(self): - item = MockMenuItem(read_only=True) - assert isolated_get_status_prefix(item) == '' - - def test_configured_item_has_checkmark(self): - item = MockMenuItem(key='hostname', value='archlinux') - prefix = isolated_get_status_prefix(item) - assert '[✓]' in prefix - assert 'green' in prefix - - def test_missing_mandatory_item_has_warning(self): - item = MockMenuItem(key='disk_config', mandatory=True, value=None) - prefix = isolated_get_status_prefix(item) - assert '[!]' in prefix - assert 'red' in prefix - - def test_missing_optional_item_has_dot(self): - item = MockMenuItem(key='network_config', mandatory=False, value=None) - prefix = isolated_get_status_prefix(item) - assert '[•]' in prefix - assert 'yellow' in prefix - - -class TestAuthConfigLogic: - def test_auth_missing_entirely(self): - item = MockMenuItem(key='auth_config', value=None) - assert '[!]' in isolated_get_status_prefix(item) - - def test_auth_invalid_state(self): - mock_auth = MagicMock() - mock_auth.root_enc_password = None - mock_auth.has_superuser.return_value = False - - item = MockMenuItem(key='auth_config', value=mock_auth) - assert '[!]' in isolated_get_status_prefix(item) - - def test_auth_valid_root(self): - mock_auth = MagicMock() - mock_auth.root_enc_password = 'hashed' - mock_auth.has_superuser.return_value = False - - item = MockMenuItem(key='auth_config', value=mock_auth) - assert '[✓]' in isolated_get_status_prefix(item) - - def test_auth_valid_superuser(self): - mock_auth = MagicMock() - mock_auth.root_enc_password = None - mock_auth.has_superuser.return_value = True - - item = MockMenuItem(key='auth_config', value=mock_auth) - assert '[✓]' in isolated_get_status_prefix(item) - - -class TestActionWrapperLogic: - def test_async_action_updates_value(self): - mock_item = MockMenuItem(key='disk') - item_dict = {'disk': mock_item} - update_spy = MagicMock() - - async def dummy_async_action(): - return 'new_disk_layout' - - wrapped = isolated_wrap_action(item_dict, update_spy, 'disk', dummy_async_action) - - result = asyncio.run(wrapped()) - - assert result == 'new_disk_layout' - assert mock_item.value == 'new_disk_layout' - update_spy.assert_called_once() - - def test_sync_action_updates_value(self): - mock_item = MockMenuItem(key='hostname') - item_dict = {'hostname': mock_item} - update_spy = MagicMock() - - def dummy_sync_action(): - return 'my-pc' - - wrapped = isolated_wrap_action(item_dict, update_spy, 'hostname', dummy_sync_action) - - result = asyncio.run(wrapped()) - - assert result == 'my-pc' - assert mock_item.value == 'my-pc' - update_spy.assert_called_once() From b5af3a1054bf9628cff842a3a6e806f6d0243804 Mon Sep 17 00:00:00 2001 From: CooperWang0912 Date: Thu, 23 Jul 2026 18:14:37 +0800 Subject: [PATCH 7/8] Fixes Parsing in Missing Configs --- archinstall/lib/global_menu.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/archinstall/lib/global_menu.py b/archinstall/lib/global_menu.py index 35a5eb40b4..82998ef3c9 100644 --- a/archinstall/lib/global_menu.py +++ b/archinstall/lib/global_menu.py @@ -297,11 +297,14 @@ def check(s: str) -> bool: tr('The selected desktop profile requires a regular user to log in via the greeter'), ) + raw_options = {o.key: o.text for o in self._get_menu_options(wrap_actions=False) if o.key is not None} + for item in self._item_group.items: if item.mandatory: assert item.key is not None if not check(item.key): - missing.add(item.text) + raw_title = raw_options.get(item.key, item.text) + missing.add(raw_title) return list(missing) From be1228a73b4a23d0a0638b7e2cad26aec08c449b Mon Sep 17 00:00:00 2001 From: CooperWang0912 Date: Fri, 24 Jul 2026 14:18:11 +0800 Subject: [PATCH 8/8] Remove Unnecessary Comments --- archinstall/lib/global_menu.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/archinstall/lib/global_menu.py b/archinstall/lib/global_menu.py index 82998ef3c9..d7cda6d8cc 100644 --- a/archinstall/lib/global_menu.py +++ b/archinstall/lib/global_menu.py @@ -81,7 +81,6 @@ def _get_status_prefix(self, item: MenuItem) -> str: return '[bold green][✓][/bold green] ' return '[bold red][!][/bold red] ' - # Standard mandatory or configured item check if item.has_value(): return '[bold green][✓][/bold green] ' elif item.mandatory: @@ -113,7 +112,6 @@ async def wrapper(*args: Any, **kwargs: Any) -> Any: if inspect.isawaitable(result): result = await result - # Directly update the item's value item.value = result return result