Refactor configuration file (#4583)

This commit is contained in:
Daniel Girtler 2026-06-29 05:04:20 +10:00 committed by GitHub
parent c777ee03f6
commit c6a5a130a8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 150 additions and 175 deletions

View File

@ -1,6 +1,7 @@
import argparse
import json
import os
import stat
import sys
import urllib.error
import urllib.parse
@ -11,9 +12,10 @@ from pathlib import Path
from typing import Any, Self
from urllib.request import Request, urlopen
from pydantic import TypeAdapter
from pydantic.dataclasses import dataclass as p_dataclass
from archinstall.lib.crypt import decrypt
from archinstall.lib.crypt import decrypt, encrypt
from archinstall.lib.log import debug, error, logger, warn
from archinstall.lib.menu.util import get_password
from archinstall.lib.models.application import ApplicationConfiguration, ZramConfiguration
@ -31,6 +33,8 @@ from archinstall.lib.models.profile import ProfileConfiguration
from archinstall.lib.models.users import Password, User, UserSerialization
from archinstall.lib.plugins import load_plugin
from archinstall.lib.translationhandler import Language, tr, translation_handler
from archinstall.lib.utils.format import as_key_value_pair
from archinstall.lib.utils.util import is_valid_path
from archinstall.lib.version import get_version
from archinstall.tui.components import tui
@ -140,6 +144,11 @@ class ArchConfigType(StrEnum):
return tr('Disk encryption password')
USER_CONFIG_FILE: Path = Path('user_configuration.json')
USER_CREDS_FILE: Path = Path('user_credentials.json')
DEFAULT_SAVE_PATH = logger.directory
@dataclass
class ArchConfig:
version: str | None = None
@ -367,6 +376,94 @@ class ArchConfig:
return arch_config
def user_config_to_json(self) -> str:
config = self.safe_config()
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:
cfg = self.unsafe_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:
debug(' -- Chosen configuration --')
debug(self.user_config_to_json())
def save(
self,
dest_path: Path | None = None,
creds: bool = False,
password: str | None = None,
) -> None:
save_path = dest_path or DEFAULT_SAVE_PATH
if not is_valid_path(save_path):
warn(
f'Destination directory {save_path} does not exist or is not a directory\n.',
'Configuration files can not be saved',
)
return
self.save_user_config(save_path)
if creds:
self.save_user_creds(save_path, password=password)
def save_user_config(self, dest_path: Path) -> None:
if not is_valid_path(dest_path):
error(f'Invalid path {dest_path}. User configuration could not be saved.')
return
target = dest_path / USER_CONFIG_FILE
data = self.user_config_to_json()
target.write_text(data)
target.chmod(stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP)
def save_user_creds(
self,
dest_path: Path,
password: str | None = None,
) -> None:
if not is_valid_path(dest_path):
error(f'Invalid path {dest_path}. User credentials could not be saved.')
return
data = self.user_credentials_to_json()
if password:
data = encrypt(password, data)
target = dest_path / USER_CREDS_FILE
target.write_text(data)
target.chmod(stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP)
def as_summary(self) -> str:
"""
Render a concise two-column summary of the current configuration.
Returns an empty string if nothing meaningful to show.
"""
cfg: dict[str, str | list[str] | bool] = {}
for key, value in self.plain_cfg().items():
cfg[key.text()] = value
for config_type, obj in self.sub_cfg().items():
if not hasattr(obj, 'summary'):
continue
summary = obj.summary()
if summary:
cfg[config_type.text()] = summary
simple_summary = as_key_value_pair(cfg, ignore_empty=True)
return simple_summary
class ArchConfigHandler:
def __init__(self) -> None:

View File

@ -1,171 +1,53 @@
import json
import readline
import stat
from pathlib import Path
from typing import Any
from pydantic import TypeAdapter
from archinstall.lib.args import ArchConfig, ArchConfigType
from archinstall.lib.crypt import encrypt
from archinstall.lib.log import debug, logger, warn
from archinstall.lib.args import USER_CONFIG_FILE, USER_CREDS_FILE, ArchConfig
from archinstall.lib.log import debug
from archinstall.lib.menu.helpers import Confirmation, Selection
from archinstall.lib.menu.util import get_password, prompt_dir
from archinstall.lib.translationhandler import tr
from archinstall.lib.utils.format import as_key_value_pair
from archinstall.tui.menu_item import MenuItem, MenuItemGroup
from archinstall.tui.result import ResultType
class ConfigurationOutput:
def __init__(self, config: ArchConfig):
"""
Configuration output handler to parse the existing
configuration data structure and prepare for output on the
console and for saving it to configuration files
async def confirm_config(config: ArchConfig) -> bool:
header = f'{tr("The specified configuration will be applied")}. '
header += tr('Would you like to continue?') + '\n'
:param config: Archinstall configuration object
:type config: ArchConfig
"""
group = MenuItemGroup.yes_no()
group.set_preview_for_all(lambda x: config.user_config_to_json())
self._config = config
self._default_save_path = logger.directory
self._user_config_file = Path('user_configuration.json')
self._user_creds_file = Path('user_credentials.json')
result = await Confirmation(
group=group,
header=header,
allow_skip=False,
preset=True,
preview_location='bottom',
preview_header=tr('Configuration preview'),
).show()
@property
def user_configuration_file(self) -> Path:
return self._user_config_file
if not result.get_value():
return False
@property
def user_credentials_file(self) -> Path:
return self._user_creds_file
def user_config_to_json(self) -> str:
config = self._config.safe_config()
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:
cfg = self._config.unsafe_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:
debug(' -- Chosen configuration --')
debug(self.user_config_to_json())
def as_summary(self) -> str:
"""
Render a concise two-column summary of the current configuration.
Returns an empty string if nothing meaningful to show.
"""
cfg: dict[str, str | list[str] | bool] = {}
for key, value in self._config.plain_cfg().items():
cfg[key.text()] = value
for config_type, obj in self._config.sub_cfg().items():
if not hasattr(obj, 'summary'):
continue
summary = obj.summary()
if summary:
cfg[config_type.text()] = summary
simple_summary = as_key_value_pair(cfg, ignore_empty=True)
return simple_summary
async def confirm_config(self) -> bool:
header = f'{tr("The specified configuration will be applied")}. '
header += tr('Would you like to continue?') + '\n'
group = MenuItemGroup.yes_no()
group.set_preview_for_all(lambda x: self.user_config_to_json())
result = await Confirmation(
group=group,
header=header,
allow_skip=False,
preset=True,
preview_location='bottom',
preview_header=tr('Configuration preview'),
).show()
if not result.get_value():
return False
return True
def _is_valid_path(self, dest_path: Path) -> bool:
dest_path_ok = dest_path.exists() and dest_path.is_dir()
if not dest_path_ok:
warn(
f'Destination directory {dest_path.resolve()} does not exist or is not a directory\n.',
'Configuration files can not be saved',
)
return dest_path_ok
def save_user_config(self, dest_path: Path) -> None:
if self._is_valid_path(dest_path):
target = dest_path / self._user_config_file
target.write_text(self.user_config_to_json())
target.chmod(stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP)
def save_user_creds(
self,
dest_path: Path,
password: str | None = None,
) -> None:
data = self.user_credentials_to_json()
if password:
data = encrypt(password, data)
if self._is_valid_path(dest_path):
target = dest_path / self._user_creds_file
target.write_text(data)
target.chmod(stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP)
def save(
self,
dest_path: Path | None = None,
creds: bool = False,
password: str | None = None,
) -> None:
save_path = dest_path or self._default_save_path
if self._is_valid_path(save_path):
self.save_user_config(save_path)
if creds:
self.save_user_creds(save_path, password=password)
return True
async def save_config(config: ArchConfig) -> None:
def preview(item: MenuItem) -> str | None:
match item.value:
case 'user_config':
serialized = config_output.user_config_to_json()
return f'{config_output.user_configuration_file}\n{serialized}'
serialized = config.user_config_to_json()
return f'{USER_CONFIG_FILE}\n{serialized}'
case 'user_creds':
if maybe_serial := config_output.user_credentials_to_json():
return f'{config_output.user_credentials_file}\n{maybe_serial}'
if maybe_serial := config.user_credentials_to_json():
return f'{USER_CREDS_FILE}\n{maybe_serial}'
return tr('No configuration')
case 'all':
output = [str(config_output.user_configuration_file)]
config_output.user_credentials_to_json()
output.append(str(config_output.user_credentials_file))
output = [str(USER_CONFIG_FILE)]
config.user_credentials_to_json()
output.append(str(USER_CREDS_FILE))
return '\n'.join(output)
return None
config_output = ConfigurationOutput(config)
items = [
MenuItem(
tr('Save user configuration (including disk layout)'),
@ -248,8 +130,8 @@ async def save_config(config: ArchConfig) -> None:
match save_option:
case 'user_config':
config_output.save_user_config(dest_path)
config.save_user_config(dest_path)
case 'user_creds':
config_output.save_user_creds(dest_path, password=enc_password)
config.save_user_creds(dest_path, password=enc_password)
case 'all':
config_output.save(dest_path, creds=True, password=enc_password)
config.save(dest_path, creds=True, password=enc_password)

View File

@ -6,7 +6,7 @@ from archinstall.lib.args import ArchConfig
from archinstall.lib.authentication.authentication_menu import AuthenticationMenu
from archinstall.lib.bootloader.bootloader_menu import BootloaderMenu
from archinstall.lib.bootloader.utils import validate_bootloader_layout
from archinstall.lib.configuration import ConfigurationOutput, save_config
from archinstall.lib.configuration import save_config
from archinstall.lib.disk.disk_menu import DiskLayoutConfigurationMenu
from archinstall.lib.general.general_menu import select_hostname, select_ntp, select_timezone
from archinstall.lib.general.system_menu import select_kernel, select_swap
@ -504,7 +504,6 @@ class GlobalMenu(AbstractMenu[None]):
def _prev_install_invalid_config(self, item: MenuItem) -> PreviewResult | None:
self.sync_all_to_config()
config_output = ConfigurationOutput(self._arch_config)
warnings = self._get_install_warnings()
messages: list[tuple[str, MsgLevelType]] = []
@ -531,7 +530,7 @@ class GlobalMenu(AbstractMenu[None]):
messages.append((text, MsgLevelType.MsgWarning))
if not errors:
summary = config_output.as_summary()
summary = self._arch_config.as_summary()
if summary:
messages.append((summary, MsgLevelType.MsgNone))

View File

@ -1,6 +1,7 @@
import secrets
import string
from datetime import UTC, datetime
from pathlib import Path
from archinstall.lib.pathnames import ARCHISO_MOUNTPOINT
from archinstall.lib.utils.format import as_columns
@ -46,3 +47,7 @@ def format_cols(items: list[str], header: str | None = None) -> str:
# remove whitespaces on each row
text = '\n'.join(t.strip() for t in text.split('\n'))
return text
def is_valid_path(path: Path) -> bool:
return path.exists() and path.is_dir()

View File

@ -6,7 +6,7 @@ from archinstall.lib.applications.application_handler import ApplicationHandler
from archinstall.lib.args import ArchConfig, ArchConfigHandler
from archinstall.lib.authentication.authentication_handler import AuthenticationHandler
from archinstall.lib.bootloader.utils import validate_bootloader_layout
from archinstall.lib.configuration import ConfigurationOutput
from archinstall.lib.configuration import confirm_config
from archinstall.lib.disk.filesystem import FilesystemHandler
from archinstall.lib.disk.utils import disk_layouts
from archinstall.lib.general.general_menu import PostInstallationAction, select_post_installation
@ -213,9 +213,8 @@ def main(arch_config_handler: ArchConfigHandler | None = None) -> None:
if not arch_config_handler.args.silent:
show_menu(arch_config_handler, mirror_list_handler)
config = ConfigurationOutput(arch_config_handler.config)
config.write_debug()
config.save()
arch_config_handler.config.write_debug()
arch_config_handler.config.save()
# Safety net for silent/config-file flow. The TUI menu blocks Install via
# GlobalMenu._validate_bootloader() before reaching this point.
@ -231,7 +230,7 @@ def main(arch_config_handler: ArchConfigHandler | None = None) -> None:
if not arch_config_handler.args.silent:
aborted = False
res: bool = tui.run(config.confirm_config)
res: bool = tui.run(lambda: confirm_config(arch_config_handler.config))
if not res:
debug('Installation aborted')

View File

@ -1,6 +1,6 @@
from archinstall.default_profiles.minimal import MinimalProfile
from archinstall.lib.args import ArchConfigHandler
from archinstall.lib.configuration import ConfigurationOutput
from archinstall.lib.configuration import confirm_config
from archinstall.lib.disk.disk_menu import DiskLayoutConfigurationMenu
from archinstall.lib.disk.filesystem import FilesystemHandler
from archinstall.lib.installer import Installer
@ -68,16 +68,15 @@ async def main(arch_config_handler: ArchConfigHandler | None = None) -> None:
disk_config = await DiskLayoutConfigurationMenu(disk_layout_config=None).show()
arch_config_handler.config.disk_config = disk_config
config = ConfigurationOutput(arch_config_handler.config)
config.write_debug()
config.save()
arch_config_handler.config.write_debug()
arch_config_handler.config.save()
if arch_config_handler.args.dry_run:
return
if not arch_config_handler.args.silent:
aborted = False
res: bool = tui.run(config.confirm_config)
res: bool = tui.run(lambda: confirm_config(arch_config_handler.config))
if not res:
debug('Installation aborted')

View File

@ -2,7 +2,7 @@ import sys
from pathlib import Path
from archinstall.lib.args import ArchConfig, ArchConfigHandler
from archinstall.lib.configuration import ConfigurationOutput
from archinstall.lib.configuration import confirm_config
from archinstall.lib.disk.filesystem import FilesystemHandler
from archinstall.lib.disk.utils import disk_layouts
from archinstall.lib.global_menu import GlobalMenu
@ -69,16 +69,15 @@ def main(arch_config_handler: ArchConfigHandler | None = None) -> None:
if not arch_config_handler.args.silent:
show_menu(arch_config_handler)
config = ConfigurationOutput(arch_config_handler.config)
config.write_debug()
config.save()
arch_config_handler.config.write_debug()
arch_config_handler.config.save()
if arch_config_handler.args.dry_run:
return
if not arch_config_handler.args.silent:
aborted = False
res: bool = tui.run(config.confirm_config)
res: bool = tui.run(lambda: confirm_config(arch_config_handler.config))
if not res:
debug('Installation aborted')

View File

@ -3,8 +3,7 @@ from pathlib import Path
from pytest import MonkeyPatch
from archinstall.lib.args import ArchConfigHandler
from archinstall.lib.configuration import ConfigurationOutput
from archinstall.lib.args import USER_CONFIG_FILE, USER_CREDS_FILE, ArchConfigHandler
def test_user_config_roundtrip(
@ -20,12 +19,10 @@ def test_user_config_roundtrip(
# as there is no version present in the test environment we'll set it manually
arch_config.version = '3.0.2'
config_output = ConfigurationOutput(arch_config)
test_out_dir = Path('/tmp/')
test_out_file = test_out_dir / config_output.user_configuration_file
test_out_file = test_out_dir / USER_CONFIG_FILE
config_output.save(test_out_dir)
arch_config.save(test_out_dir)
result = json.loads(test_out_file.read_text())
expected = json.loads(config_fixture.read_text())
@ -55,12 +52,10 @@ def test_creds_roundtrip(
handler = ArchConfigHandler()
arch_config = handler.config
config_output = ConfigurationOutput(arch_config)
test_out_dir = Path('/tmp/')
test_out_file = test_out_dir / config_output.user_credentials_file
test_out_file = test_out_dir / USER_CREDS_FILE
config_output.save(test_out_dir, creds=True)
arch_config.save(test_out_dir, creds=True)
result = json.loads(test_out_file.read_text())
expected = json.loads(creds_fixture.read_text())