diff --git a/archinstall/__init__.py b/archinstall/__init__.py index 8afa2939..e76ca4a1 100644 --- a/archinstall/__init__.py +++ b/archinstall/__init__.py @@ -32,7 +32,7 @@ from .lib.output import FormattedOutput, debug, error, info, log, warn from .lib.pacman import Pacman from .lib.plugins import load_plugin, plugins from .lib.storage import storage -from .lib.translationhandler import DeferredTranslation, Language, TranslationHandler +from .lib.translationhandler import DeferredTranslation, Language, translation_handler from .tui import Tui if TYPE_CHECKING: @@ -224,7 +224,7 @@ def load_config() -> None: arguments['locale_config'] = locale.LocaleConfiguration.parse_arg(arguments) if (archinstall_lang := arguments.get('archinstall-language', None)) is not None: - arguments['archinstall-language'] = TranslationHandler().get_language_by_name(archinstall_lang) + arguments['archinstall-language'] = translation_handler.get_language_by_name(archinstall_lang) if disk_config := arguments.get('disk_config', {}): arguments['disk_config'] = disk.DiskLayoutConfiguration.parse_arg(disk_config) diff --git a/archinstall/lib/args.py b/archinstall/lib/args.py new file mode 100644 index 00000000..77555bdb --- /dev/null +++ b/archinstall/lib/args.py @@ -0,0 +1,341 @@ +import argparse +import json +import urllib.error +import urllib.parse +from argparse import ArgumentParser, Namespace +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any +from urllib.request import Request, urlopen + +from pydantic.dataclasses import dataclass as p_dataclass + +from .disk import DiskEncryption, DiskLayoutConfiguration +from .locale import LocaleConfiguration +from .mirrors import MirrorConfiguration +from .models import AudioConfiguration, Bootloader, NetworkConfiguration, User +from .output import error, warn +from .plugins import load_plugin +from .profile import ProfileConfiguration +from .storage import storage +from .translationhandler import Language, translation_handler + + +@p_dataclass +class Arguments: + config: Path | None = None + config_url: str | None = None + creds: Path | None = None + creds_url: str | None = None + silent: bool = False + dry_run: bool = False + script: str = 'guided' + mount_point: Path | None = Path('/mnt') + skip_ntp: bool = False + debug: bool = False + offline: bool = False + no_pkg_lookups: bool = False + plugin: str | None = None + skip_version_check: bool = False + advanced: bool = False + + +@dataclass +class ArchConfig: + version: str = field(default_factory=lambda: storage['__version__']) + locale_config: LocaleConfiguration | None = None + archinstall_language: Language = field(default_factory=lambda: translation_handler.get_language_by_abbr('en')) + disk_config: DiskLayoutConfiguration | None = None + profile_config: ProfileConfiguration | None = None + mirror_config: MirrorConfiguration | None = None + network_config: NetworkConfiguration | None = None + bootloader: Bootloader = field(default=Bootloader.get_default()) + uki: bool = False + audio_config: AudioConfiguration | None = None + hostname: str = 'archlinux' + kernels: list[str] = field(default_factory=lambda: ['linux']) + ntp: bool = False + packages: list[str] = field(default_factory=list) + parallel_downloads: int = 0 + swap: bool = True + timezone: str = 'UTC' + additional_repositories: list[str] = field(default_factory=list) + + # Special fields that should be handle with care due to security implications + _users: list[User] = field(default_factory=list) + _disk_encryption: DiskEncryption | None = None + + @classmethod + def from_config(cls, args_config: dict[str, Any]) -> 'ArchConfig': + arch_config = ArchConfig() + + arch_config.locale_config = LocaleConfiguration.parse_arg(args_config) + + if archinstall_lang := args_config.get('archinstall-language', None): + arch_config.archinstall_language = translation_handler.get_language_by_name(archinstall_lang) + + if disk_config := args_config.get('disk_config', {}): + arch_config.disk_config = DiskLayoutConfiguration.parse_arg(disk_config) + + if profile_config := args_config.get('profile_config', None): + arch_config.profile_config = ProfileConfiguration.parse_arg(profile_config) + + if mirror_config := args_config.get('mirror_config', None): + arch_config.mirror_config = MirrorConfiguration.parse_args(mirror_config) + + if net_config := args_config.get('network_config', None): + arch_config.network_config = NetworkConfiguration.parse_arg(net_config) + + users = args_config.get('!users', None) + superusers = args_config.get('!superusers', None) + if users is not None or superusers is not None: + arch_config._users = User.parse_arguments(users, superusers) + + if bootloader_config := args_config.get('bootloader', None): + arch_config.bootloader = Bootloader.from_arg(bootloader_config) + + if args_config.get('uki', False) and not arch_config.bootloader.has_uki_support(): + arch_config.uki = False + + if audio_config := args_config.get('audio_config', None): + arch_config.audio_config = AudioConfiguration.parse_arg(audio_config) + + if args_config.get('disk_encryption', None) is not None and arch_config.disk_config is not None: + arch_config._disk_encryption = DiskEncryption.parse_arg( + arch_config.disk_config, + args_config['disk_encryption'], + args_config.get('encryption_password', '') + ) + + if hostname := args_config.get('hostname', ''): + arch_config.hostname = hostname + + if kernels := args_config.get('kernels', []): + arch_config.kernels = kernels + + if ntp := args_config.get('ntp', False): + arch_config.ntp = ntp + + if packages := args_config.get('packages', []): + arch_config.packages = packages + + if parallel_downloads := args_config.get('parallel_downloads', 0): + arch_config.parallel_downloads = parallel_downloads + + arch_config.swap = args_config.get('swap', True) + + if timezone := args_config.get('timezone', 'UTC'): + arch_config.timezone = timezone + + if additional_repositories := args_config.get('additional-repositories', []): + arch_config.additional_repositories = additional_repositories + + return arch_config + + +class ArchConfigHandler: + def __init__(self) -> None: + self._parser: ArgumentParser = self._define_arguments() + self._args: Arguments = self._parse_args() + + config = self._parse_config() + self._arch_config = ArchConfig.from_config(config) + + @property + def arch_config(self) -> ArchConfig: + return self._arch_config + + @property + def args(self) -> Arguments: + return self._args + + def print_help(self) -> None: + self._parser.print_help() + + def _define_arguments(self) -> ArgumentParser: + parser = ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter) + parser.add_argument( + "-v", + "--version", + action="version", + default=False, + version="%(prog)s " + storage['__version__'] + ) + parser.add_argument( + "--config", + type=Path, + nargs="?", + default=None, + help="JSON configuration file" + ) + parser.add_argument( + "--config-url", + type=str, + nargs="?", + default=None, + help="Url to a JSON configuration file" + ) + parser.add_argument( + "--creds", + type=Path, + nargs="?", + default=None, + help="JSON credentials configuration file" + ) + parser.add_argument( + "--creds-url", + type=str, + nargs="?", + default=None, + help="Url to a JSON credentials configuration file" + ) + parser.add_argument( + "--silent", + action="store_true", + default=False, + help="WARNING: Disables all prompts for input and confirmation. If no configuration is provided, this is ignored" + ) + parser.add_argument( + "--dry-run", + "--dry_run", + action="store_true", + default=False, + help="Generates a configuration file and then exits instead of performing an installation" + ) + parser.add_argument( + "--script", + default="guided", + nargs="?", + help="Script to run for installation", + type=str + ) + parser.add_argument( + "--mount-point", + "--mount_point", + type=Path, + nargs="?", + default=Path('/mnt'), + help="Define an alternate mount point for installation" + ) + parser.add_argument( + "--skip-ntp", + action="store_true", + help="Disables NTP checks during installation", + default=False + ) + parser.add_argument( + "--debug", + action="store_true", + default=False, + help="Adds debug info into the log" + ) + parser.add_argument( + "--offline", + action="store_true", + default=False, + help="Disabled online upstream services such as package search and key-ring auto update." + ) + parser.add_argument( + "--no-pkg-lookups", + action="store_true", + default=False, + help="Disabled package validation specifically prior to starting installation." + ) + parser.add_argument( + "--plugin", + nargs="?", + type=str, + default=None, + help='File path to a plugin to load' + ) + parser.add_argument( + "--skip-version-check", + action="store_true", + default=False, + help="Skip the version check when running archinstall" + ) + parser.add_argument( + "--advanced", + action="store_true", + default=False, + help="Enabled advanced options" + ) + + return parser + + def _parse_args(self) -> Arguments: + argparse_args = vars(self._parser.parse_args()) + args: Arguments = Arguments(**argparse_args) + + # amend the parameters (check internal consistency) + # Installation can't be silent if config is not passed + if args.config is None: + args.silent = False + + if args.mount_point is not None: + storage['MOUNT_POINT'] = Path(args.mount_point) + + if args.debug: + warn(f"Warning: --debug mode will write certain credentials to {storage['LOG_PATH']}/{storage['LOG_FILE']}!") + + if args.plugin: + plugin_path = Path(args.plugin) + load_plugin(plugin_path) + + return args + + def _parse_config(self) -> dict[str, Any]: + config: dict[str, Any] = {} + config_data: str | None = None + creds_data: str | None = None + + if self._args.config is not None: + config_data = self._read_file(self._args.config) + elif self._args.config_url is not None: + config_data = self._fetch_from_url(self._args.config_url) + + if config_data is not None: + config.update(json.loads(config_data)) + + if self._args.creds is not None: + creds_data = self._read_file(self._args.creds) + elif self._args.creds_url is not None: + creds_data = self._fetch_from_url(self._args.creds_url) + + if creds_data is not None: + config.update(json.loads(creds_data)) + + config = self._cleanup_config(config) + + return config + + def _fetch_from_url(self, url: str) -> str: + if urllib.parse.urlparse(url).scheme: + try: + req = Request(url, headers={'User-Agent': 'ArchInstall'}) + with urlopen(req) as resp: + return resp.read() + except urllib.error.HTTPError as err: + error(f"Could not fetch JSON from {url}: {err}") + else: + error('Not a valid url') + + exit(1) + + def _read_file(self, path: Path) -> str: + if not path.exists(): + error(f"Could not find file {path}") + exit(1) + + return path.read_text() + + def _cleanup_config(self, config: Namespace | dict) -> dict[str, Any]: + clean_args = {} + for key, val in config.items(): + if isinstance(val, dict): + val = self._cleanup_config(val) + + if val is not None: + clean_args[key] = val + + return clean_args diff --git a/archinstall/lib/global_menu.py b/archinstall/lib/global_menu.py index 36e8e40b..54530f00 100644 --- a/archinstall/lib/global_menu.py +++ b/archinstall/lib/global_menu.py @@ -32,7 +32,7 @@ from .models.bootloader import Bootloader from .models.users import User from .output import FormattedOutput from .profile.profile_menu import ProfileConfiguration -from .translationhandler import Language, TranslationHandler +from .translationhandler import Language, translation_handler from .utils.util import format_cols, get_password if TYPE_CHECKING: @@ -46,10 +46,9 @@ if TYPE_CHECKING: class GlobalMenu(AbstractMenu): def __init__(self, data_store: dict[str, Any]): self._data_store = data_store - self._translation_handler = TranslationHandler() if 'archinstall-language' not in data_store: - data_store['archinstall-language'] = self._translation_handler.get_language_by_abbr('en') + data_store['archinstall-language'] = translation_handler.get_language_by_abbr('en') menu_optioons = self._get_menu_options(data_store) self._item_group = MenuItemGroup( @@ -263,8 +262,8 @@ class GlobalMenu(AbstractMenu): def _select_archinstall_language(self, preset: Language) -> Language: from .interactions.general_conf import select_archinstall_language - language = select_archinstall_language(self._translation_handler.translated_languages, preset) - self._translation_handler.activate(language) + language = select_archinstall_language(translation_handler.translated_languages, preset) + translation_handler.activate(language) self._upate_lang_text() diff --git a/archinstall/lib/translationhandler.py b/archinstall/lib/translationhandler.py index d6cb0853..08e6581c 100644 --- a/archinstall/lib/translationhandler.py +++ b/archinstall/lib/translationhandler.py @@ -206,3 +206,6 @@ class DeferredTranslation: def install(cls) -> None: import builtins builtins._ = cls # type: ignore + + +translation_handler = TranslationHandler() diff --git a/pyproject.toml b/pyproject.toml index f8f0fa6a..66240e3a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,7 @@ dev = [ "ruff==0.8.0", "pylint==3.3.1", "pylint-pydantic==0.3.2", + "pytest==6.2.5", ] doc = ["sphinx"] @@ -114,6 +115,10 @@ warn_unreachable = false module = "archinstall.tui.*" warn_unreachable = false +[[tool.mypy.overrides]] +module = "tests.*" +ignore_errors = true + [[tool.mypy.overrides]] module = [ "parted", @@ -124,6 +129,11 @@ ignore_missing_imports = true targets = ["archinstall"] exclude = ["/tests"] +[tool.pytest.ini_options] +pythonpath = ["."] +addopts = "-s" +testpaths = ["tests"] + [tool.pylint.main] ignore-paths = [ "^build/", diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..6c63cb12 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,13 @@ +from pathlib import Path + +import pytest + + +@pytest.fixture(scope='session') +def config_fixture() -> Path: + return Path(__file__).parent / 'data' / 'test_config.json' + + +@pytest.fixture(scope='session') +def creds_fixture() -> Path: + return Path(__file__).parent / 'data' / 'test_creds.json' diff --git a/tests/data/__init__.py b/tests/data/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/data/test_config.json b/tests/data/test_config.json new file mode 100644 index 00000000..cc215569 --- /dev/null +++ b/tests/data/test_config.json @@ -0,0 +1,135 @@ +{ + "additional-repositories": ["testing"], + "archinstall-language": "English", + "audio_config": {"audio": "pipewire"}, + "bootloader": "Systemd-boot", + "disk_config": { + "config_type": "default_layout", + "device_modifications": [ + { + "device": "/dev/sda", + "partitions": [ + { + "btrfs": [], + "flags": [ + "boot" + ], + "fs_type": "fat32", + "size": { + "sector_size": null, + "unit": "MiB", + "value": 512 + }, + "mount_options": [], + "mountpoint": "/boot", + "obj_id": "2c3fa2d5-2c79-4fab-86ec-22d0ea1543c0", + "start": { + "sector_size": null, + "unit": "MiB", + "value": 1 + }, + "status": "create", + "type": "primary" + }, + { + "btrfs": [], + "flags": [], + "fs_type": "ext4", + "size": { + "sector_size": null, + "unit": "GiB", + "value": 20 + }, + "mount_options": [], + "mountpoint": "/", + "obj_id": "3e7018a0-363b-4d05-ab83-8e82d13db208", + "start": { + "sector_size": null, + "unit": "MiB", + "value": 513 + }, + "status": "create", + "type": "primary" + }, + { + "btrfs": [], + "flags": [], + "fs_type": "ext4", + "size": { + "sector_size": null, + "unit": "Percent", + "value": 100 + }, + "mount_options": [], + "mountpoint": "/home", + "obj_id": "ce58b139-f041-4a06-94da-1f8bad775d3f", + "start": { + "sector_size": null, + "unit": "GiB", + "value": 20 + }, + "status": "create", + "type": "primary" + } + ], + "wipe": true + } + ] + }, + "hostname": "archy", + "kernels": [ + "linux-zen" + ], + "locale_config": { + "kb_layout": "us", + "sys_enc": "UTF-8", + "sys_lang": "en_US" + }, + "mirror_config": { + "mirror-regions": { + "Australia": [ + "http://archlinux.mirror.digitalpacific.com.au/$repo/os/$arch" + ] + } + }, + "network_config": { + "type": "manual", + "nics": [ + { + "iface": "eno1", + "ip": "192.168.1.15/24", + "dhcp": true, + "gateway": "192.168.1.1", + "dns": [ + "192.168.1.1", + "9.9.9.9" + ] + } + ] + }, + "ntp": true, + "packages": ["firefox"], + "parallel_downloads": 66, + "profile_config": { + "gfx_driver": "All open-source", + "greeter": "lightdm-gtk-greeter", + "profile": { + "custom_settings": { + "Hyprland": { + "seat_access": "polkit" + }, + "Sway": { + "seat_access": "seatd" + } + }, + "details": [ + "Sway", + "Hyprland" + ], + "main": "Desktop" + } + }, + "swap": false, + "timezone": "UTC", + "version": "2.8.6" +} diff --git a/tests/data/test_creds.json b/tests/data/test_creds.json new file mode 100644 index 00000000..2f52f67c --- /dev/null +++ b/tests/data/test_creds.json @@ -0,0 +1,10 @@ +{ + "!root-password": "super_pwd", + "!users": [ + { + "!password": "user_pwd", + "sudo": true, + "username": "user_name" + } + ] +} diff --git a/tests/test_args.py b/tests/test_args.py new file mode 100644 index 00000000..c2ba9aaa --- /dev/null +++ b/tests/test_args.py @@ -0,0 +1,171 @@ +from pathlib import Path + +from pytest import MonkeyPatch + +from archinstall.default_profiles.profile import GreeterType +from archinstall.lib.args import ArchConfig, ArchConfigHandler, Arguments +from archinstall.lib.disk import DiskLayoutConfiguration, DiskLayoutType +from archinstall.lib.hardware import GfxDriver +from archinstall.lib.locale import LocaleConfiguration +from archinstall.lib.mirrors import MirrorConfiguration +from archinstall.lib.models import Audio, AudioConfiguration, Bootloader, NetworkConfiguration, User +from archinstall.lib.models.network_configuration import Nic, NicType +from archinstall.lib.profile.profile_model import ProfileConfiguration +from archinstall.lib.profile.profiles_handler import profile_handler +from archinstall.lib.translationhandler import translation_handler + + +def test_default_args(monkeypatch): + monkeypatch.setattr('sys.argv', ['archinstall']) + handler = ArchConfigHandler() + args = handler.args + assert args == Arguments( + config=None, + config_url=None, + creds=None, + silent=False, + dry_run=False, + script='guided', + mount_point=Path('/mnt'), + skip_ntp=False, + debug=False, + offline=False, + no_pkg_lookups=False, + plugin=None, + skip_version_check=False, + advanced=False + ) + + +def test_correct_parsing_args( + monkeypatch: MonkeyPatch, + config_fixture: Path, + creds_fixture: Path +) -> None: + monkeypatch.setattr('sys.argv', [ + 'archinstall', + '--config', + str(config_fixture), + '--config-url', + 'https://example.com', + '--creds', + str(creds_fixture), + '--script', + 'execution_script', + '--mount-point', + '/tmp', + '--skip-ntp', + '--debug', + '--offline', + '--no-pkg-lookups', + '--plugin', + 'pytest_plugin.py', + '--skip-version-check', + '--advanced', + '--dry-run', + '--silent' + ]) + + handler = ArchConfigHandler() + args = handler.args + + assert args == Arguments( + config=config_fixture, + config_url='https://example.com', + creds=creds_fixture, + silent=True, + dry_run=True, + script='execution_script', + mount_point=Path('/tmp'), + skip_ntp=True, + debug=True, + offline=True, + no_pkg_lookups=True, + plugin='pytest_plugin.py', + skip_version_check=True, + advanced=True + ) + + +def test_config_file_parsing( + monkeypatch: MonkeyPatch, + config_fixture: Path, + creds_fixture: Path +) -> None: + monkeypatch.setattr('sys.argv', [ + 'archinstall', + '--config', + str(config_fixture), + '--creds', + str(creds_fixture), + ]) + + handler = ArchConfigHandler() + arch_config = handler.arch_config + + assert arch_config == ArchConfig( + version='3.0.0', + locale_config=LocaleConfiguration( + kb_layout='us', + sys_lang='en_US', + sys_enc='UTF-8' + ), + archinstall_language=translation_handler.get_language_by_abbr('en'), + disk_config=DiskLayoutConfiguration( + config_type=DiskLayoutType.Default, + device_modifications=[], + lvm_config=None, + mountpoint=None + ), + profile_config=ProfileConfiguration( + profile=profile_handler.parse_profile_config({ + "custom_settings": { + "Hyprland": { + "seat_access": "polkit" + }, + "Sway": { + "seat_access": "seatd" + } + }, + "details": [ + "Sway", + "Hyprland" + ], + "main": "Desktop" + }), + gfx_driver=GfxDriver.AllOpenSource, + greeter=GreeterType.Lightdm + ), + mirror_config=MirrorConfiguration( + mirror_regions={}, + custom_mirrors=[] + ), + network_config=NetworkConfiguration( + type=NicType.MANUAL, + nics=[ + Nic( + iface='eno1', + ip='192.168.1.15/24', + dhcp=True, + gateway='192.168.1.1', + dns=[ + '192.168.1.1', + '9.9.9.9' + ] + ) + ] + ), + bootloader=Bootloader.Systemd, + uki=False, + audio_config=AudioConfiguration(Audio.Pipewire), + hostname='archy', + kernels=['linux-zen'], + ntp=True, + packages=["firefox"], + parallel_downloads=66, + swap=False, + timezone='UTC', + additional_repositories=["testing"], + _users=[User(username='user_name', password='user_pwd', sudo=True)], + _disk_encryption=None + )