Merge branch 'master' into fix-3021-plugin

This commit is contained in:
Hariom Phulre 2026-05-13 20:59:03 +05:30 committed by GitHub
commit ef7d71bb7d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 1202 additions and 464 deletions

View File

@ -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

View File

@ -31,7 +31,7 @@ repos:
args: [--config=.flake8]
fail_fast: true
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.20.2
rev: v2.1.0
hooks:
- id: mypy
args: [

View File

@ -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<br>
* To upload the log from the ISO image and get a shareable URL, run<br>
```shell
curl -F'file=@/var/log/archinstall/install.log' https://0x0.st
archinstall share-log
```

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -6,6 +6,7 @@ import urllib.error
import urllib.parse
from argparse import ArgumentParser, Namespace
from dataclasses import dataclass, field
from enum import StrEnum
from pathlib import Path
from typing import Any, Self
from urllib.request import Request, urlopen
@ -17,6 +18,7 @@ from archinstall.lib.menu.util import get_password
from archinstall.lib.models.application import ApplicationConfiguration, ZramConfiguration
from archinstall.lib.models.authentication import AuthenticationConfiguration
from archinstall.lib.models.bootloader import Bootloader, BootloaderConfiguration
from archinstall.lib.models.config import SubConfig
from archinstall.lib.models.device import DiskEncryption, DiskLayoutConfiguration
from archinstall.lib.models.locale import LocaleConfiguration
from archinstall.lib.models.mirrors import MirrorConfiguration
@ -57,121 +59,222 @@ class Arguments:
verbose: bool = False
class ArchConfigType(StrEnum):
VERSION = 'version'
SCRIPT = 'script'
LOCALE_CONFIG = 'locale_config'
ARCHINSTALL_LANGUAGE = 'archinstall_language'
DISK_CONFIG = 'disk_config'
PROFILE_CONFIG = 'profile_config'
MIRROR_CONFIG = 'mirror_config'
NETWORK_CONFIG = 'network_config'
BOOTLOADER_CONFIG = 'bootloader_config'
APP_CONFIG = 'app_config'
AUTH_CONFIG = 'auth_config'
SWAP = 'swap'
USERS = 'users'
ROOT_ENC_PASSWORD = 'root_enc_password'
ENCRYPTION_PASSWORD = 'encryption_password'
HOSTNAME = 'hostname'
KERNELS = 'kernels'
NTP = 'ntp'
TIMEZONE = 'timezone'
SERVICES = 'services'
PACKAGES = 'packages'
PACMAN_CONFIG = 'pacman_config'
CUSTOM_COMMANDS = 'custom_commands'
def text(self) -> str:
match self:
case ArchConfigType.ARCHINSTALL_LANGUAGE:
return tr('ArchInstall Language')
case ArchConfigType.VERSION:
return tr('Version')
case ArchConfigType.SCRIPT:
return tr('Installation Script')
case ArchConfigType.LOCALE_CONFIG:
return tr('Locales')
case ArchConfigType.DISK_CONFIG:
return tr('Disk configuration')
case ArchConfigType.PROFILE_CONFIG:
return tr('Profile')
case ArchConfigType.MIRROR_CONFIG:
return tr('Mirrors and repositories')
case ArchConfigType.NETWORK_CONFIG:
return tr('Network')
case ArchConfigType.BOOTLOADER_CONFIG:
return tr('Bootloader')
case ArchConfigType.APP_CONFIG:
return tr('Application')
case ArchConfigType.AUTH_CONFIG:
return tr('Authentication')
case ArchConfigType.SWAP:
return tr('Swap')
case ArchConfigType.HOSTNAME:
return tr('Hostname')
case ArchConfigType.KERNELS:
return tr('Kernels')
case ArchConfigType.NTP:
return tr('Automatic time sync (NTP)')
case ArchConfigType.TIMEZONE:
return tr('Timezone')
case ArchConfigType.SERVICES:
return tr('Services')
case ArchConfigType.PACKAGES:
return tr('Additional packages')
case ArchConfigType.PACMAN_CONFIG:
return tr('Pacman')
case ArchConfigType.CUSTOM_COMMANDS:
return tr('Custom commands')
case ArchConfigType.USERS:
return tr('Users')
case ArchConfigType.ROOT_ENC_PASSWORD:
return tr('Root encrypted password')
case ArchConfigType.ENCRYPTION_PASSWORD:
return tr('Disk encryption password')
@dataclass
class ArchConfig:
version: str | None = None
script: str | None = None
locale_config: LocaleConfiguration | None = None
archinstall_language: Language = field(default_factory=lambda: translation_handler.get_language_by_abbr('en'))
disk_config: DiskLayoutConfiguration | None = None
profile_config: ProfileConfiguration | None = None
mirror_config: MirrorConfiguration | None = None
network_config: NetworkConfiguration | None = None
bootloader_config: BootloaderConfiguration | None = None
app_config: ApplicationConfiguration | None = None
auth_config: AuthenticationConfiguration | None = None
swap: ZramConfiguration | None = None
hostname: str = 'archlinux'
kernels: list[str] = field(default_factory=lambda: [DEFAULT_KERNEL.value])
ntp: bool = True
packages: list[str] = field(default_factory=list)
pacman_config: PacmanConfiguration = field(default_factory=PacmanConfiguration.default)
timezone: str = 'UTC'
services: list[str] = field(default_factory=list)
custom_commands: list[str] = field(default_factory=list)
version: str | None = None
script: str | None = None
locale_config: LocaleConfiguration | None = None
archinstall_language: Language = field(default_factory=lambda: translation_handler.get_language_by_abbr('en'))
disk_config: DiskLayoutConfiguration | None = None
profile_config: ProfileConfiguration | None = None
mirror_config: MirrorConfiguration | None = None
network_config: NetworkConfiguration | None = None
bootloader_config: BootloaderConfiguration | None = None
app_config: ApplicationConfiguration | None = None
auth_config: AuthenticationConfiguration | None = None
swap: ZramConfiguration | None = None
hostname: str = 'archlinux'
kernels: list[str] = field(default_factory=lambda: [DEFAULT_KERNEL.value])
ntp: bool = True
packages: list[str] = field(default_factory=list)
pacman_config: PacmanConfiguration = field(default_factory=PacmanConfiguration.default)
timezone: str = 'UTC'
services: list[str] = field(default_factory=list)
custom_commands: list[str] = field(default_factory=list)
def unsafe_config(self) -> dict[str, Any]:
config: dict[str, list[UserSerialization] | str | None] = {}
def unsafe_config(self) -> dict[ArchConfigType, Any]:
config: dict[ArchConfigType, list[UserSerialization] | str | None] = {}
if self.auth_config:
if self.auth_config.users:
config['users'] = [user.json() for user in self.auth_config.users]
if self.auth_config:
if self.auth_config.users:
config[ArchConfigType.USERS] = [user.json() for user in self.auth_config.users]
if self.auth_config.root_enc_password:
config['root_enc_password'] = self.auth_config.root_enc_password.enc_password
if self.auth_config.root_enc_password:
config[ArchConfigType.ROOT_ENC_PASSWORD] = self.auth_config.root_enc_password.enc_password
if self.disk_config:
disk_encryption = self.disk_config.disk_encryption
if disk_encryption and disk_encryption.encryption_password:
config['encryption_password'] = disk_encryption.encryption_password.plaintext
if self.disk_config:
disk_encryption = self.disk_config.disk_encryption
if disk_encryption and disk_encryption.encryption_password:
config[ArchConfigType.ENCRYPTION_PASSWORD] = disk_encryption.encryption_password.plaintext
return config
return config
def safe_config(self) -> dict[str, Any]:
config: Any = {
'version': self.version,
'script': self.script,
'archinstall-language': self.archinstall_language.json(),
'hostname': self.hostname,
'kernels': self.kernels,
'ntp': self.ntp,
'packages': self.packages,
'pacman_config': self.pacman_config.json(),
'swap': self.swap,
'timezone': self.timezone,
'services': self.services,
'custom_commands': self.custom_commands,
'bootloader_config': self.bootloader_config.json() if self.bootloader_config else None,
'app_config': self.app_config.json() if self.app_config else None,
'auth_config': self.auth_config.json() if self.auth_config else None,
}
def safe_config(self) -> dict[ArchConfigType, Any]:
base_config: dict[ArchConfigType, Any] = {
ArchConfigType.VERSION: self.version,
ArchConfigType.SCRIPT: self.script,
ArchConfigType.ARCHINSTALL_LANGUAGE: self.archinstall_language.json(),
}
if self.locale_config:
config['locale_config'] = self.locale_config.json()
base_config.update(self.plain_cfg())
sub_config = self.sub_cfg()
if self.disk_config:
config['disk_config'] = self.disk_config.json()
for config_type, value in sub_config.items():
if not hasattr(value, 'json'):
raise ValueError(f'Config value for {config_type} must implement json() method')
base_config[config_type] = value.json()
if self.profile_config:
config['profile_config'] = self.profile_config.json()
return base_config
if self.mirror_config:
config['mirror_config'] = self.mirror_config.json()
def plain_cfg(self) -> dict[ArchConfigType, str | list[str] | bool]:
return {
ArchConfigType.HOSTNAME: self.hostname,
ArchConfigType.KERNELS: self.kernels,
ArchConfigType.NTP: self.ntp,
ArchConfigType.TIMEZONE: self.timezone,
ArchConfigType.SERVICES: self.services,
ArchConfigType.PACKAGES: self.packages,
ArchConfigType.CUSTOM_COMMANDS: self.custom_commands,
}
if self.network_config:
config['network_config'] = self.network_config.json()
def sub_cfg(self) -> dict[ArchConfigType, SubConfig]:
cfg: dict[ArchConfigType, SubConfig] = {
ArchConfigType.PACMAN_CONFIG: self.pacman_config,
}
return config
if self.mirror_config:
cfg[ArchConfigType.MIRROR_CONFIG] = self.mirror_config
@classmethod
def from_config(cls, args_config: dict[str, Any], args: Arguments) -> Self:
arch_config = cls()
if self.bootloader_config:
cfg[ArchConfigType.BOOTLOADER_CONFIG] = self.bootloader_config
arch_config.locale_config = LocaleConfiguration.parse_arg(args_config)
if self.disk_config:
cfg[ArchConfigType.DISK_CONFIG] = self.disk_config
if script := args_config.get('script', None):
arch_config.script = script
if self.swap:
cfg[ArchConfigType.SWAP] = self.swap
if archinstall_lang := args_config.get('archinstall-language', None):
arch_config.archinstall_language = translation_handler.get_language_by_name(archinstall_lang)
translation_handler.activate(arch_config.archinstall_language, set_font=False)
if self.auth_config:
cfg[ArchConfigType.AUTH_CONFIG] = self.auth_config
if disk_config := args_config.get('disk_config', {}):
enc_password = args_config.get('encryption_password', '')
password = Password(plaintext=enc_password) if enc_password else None
arch_config.disk_config = DiskLayoutConfiguration.parse_arg(disk_config, password)
if self.locale_config:
cfg[ArchConfigType.LOCALE_CONFIG] = self.locale_config
# DEPRECATED
# backwards compatibility for main level disk_encryption entry
disk_encryption: DiskEncryption | None = None
if self.profile_config:
cfg[ArchConfigType.PROFILE_CONFIG] = self.profile_config
if args_config.get('disk_encryption', None) is not None and arch_config.disk_config is not None:
disk_encryption = DiskEncryption.parse_arg(
arch_config.disk_config,
args_config['disk_encryption'],
Password(plaintext=args_config.get('encryption_password', '')),
)
if self.network_config:
cfg[ArchConfigType.NETWORK_CONFIG] = self.network_config
if disk_encryption:
arch_config.disk_config.disk_encryption = disk_encryption
if self.app_config:
cfg[ArchConfigType.APP_CONFIG] = self.app_config
if profile_config := args_config.get('profile_config', None):
arch_config.profile_config = ProfileConfiguration.parse_arg(profile_config)
return cfg
if mirror_config := args_config.get('mirror_config', None):
backwards_compatible_repo = []
if additional_repositories := args_config.get('additional-repositories', []):
backwards_compatible_repo = [Repository(r) for r in additional_repositories]
@classmethod
def from_config(cls, args_config: dict[str, Any], args: Arguments) -> Self:
arch_config = cls()
arch_config.locale_config = LocaleConfiguration.parse_arg(args_config)
if script := args_config.get('script', None):
arch_config.script = script
if archinstall_lang := args_config.get('archinstall-language', None):
arch_config.archinstall_language = translation_handler.get_language_by_name(archinstall_lang)
translation_handler.activate(arch_config.archinstall_language, set_font=False)
if disk_config := args_config.get('disk_config', {}):
enc_password = args_config.get('encryption_password', '')
password = Password(plaintext=enc_password) if enc_password else None
arch_config.disk_config = DiskLayoutConfiguration.parse_arg(disk_config, password)
# DEPRECATED
# backwards compatibility for main level disk_encryption entry
disk_encryption: DiskEncryption | None = None
if args_config.get('disk_encryption', None) is not None and arch_config.disk_config is not None:
disk_encryption = DiskEncryption.parse_arg(
arch_config.disk_config,
args_config['disk_encryption'],
Password(plaintext=args_config.get('encryption_password', '')),
)
if disk_encryption:
arch_config.disk_config.disk_encryption = disk_encryption
if profile_config := args_config.get('profile_config', None):
arch_config.profile_config = ProfileConfiguration.parse_arg(profile_config)
if mirror_config := args_config.get('mirror_config', None):
backwards_compatible_repo = []
if additional_repositories := args_config.get('additional-repositories', []):
backwards_compatible_repo = [Repository(r) for r in additional_repositories]
arch_config.mirror_config = MirrorConfiguration.parse_args(
mirror_config,
@ -260,305 +363,298 @@ class ArchConfig:
class ArchConfigHandler:
def __init__(self) -> None:
self._parser: ArgumentParser = self._define_arguments()
args: Arguments = self._parse_args()
self._args = args
def __init__(self) -> None:
self._parser: ArgumentParser = self._define_arguments()
args: Arguments = self._parse_args()
self._args = args
config = self._parse_config()
config = self._parse_config()
try:
self._config = ArchConfig.from_config(config, args)
self._config.version = get_version()
except ValueError as err:
warn(str(err))
sys.exit(1)
try:
self._config = ArchConfig.from_config(config, args)
self._config.version = get_version()
except ValueError as err:
warn(str(err))
sys.exit(1)
@property
def config(self) -> ArchConfig:
return self._config
@property
def config(self) -> ArchConfig:
return self._config
@property
def args(self) -> Arguments:
return self._args
@property
def args(self) -> Arguments:
return self._args
def get_script(self) -> str:
if script := self.args.script:
return script
def get_script(self) -> str:
if script := self.args.script:
return script
if script := self.config.script:
return script
if script := self.config.script:
return script
return 'guided'
return 'guided'
def print_help(self) -> None:
self._parser.print_help()
def print_help(self) -> None:
self._parser.print_help()
def _define_arguments(self) -> ArgumentParser:
parser = ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument(
'-v',
'--version',
action='version',
default=False,
version='%(prog)s ' + get_version(),
)
parser.add_argument(
'--config',
type=Path,
nargs='?',
default=None,
help='JSON configuration file',
)
parser.add_argument(
'--config-url',
type=str,
nargs='?',
default=None,
help='Url to a JSON configuration file',
)
parser.add_argument(
'--creds',
type=Path,
nargs='?',
default=None,
help='JSON credentials configuration file',
)
parser.add_argument(
'--creds-url',
type=str,
nargs='?',
default=None,
help='Url to a JSON credentials configuration file',
)
parser.add_argument(
'--creds-decryption-key',
type=str,
nargs='?',
default=None,
help='Decryption key for credentials file',
)
parser.add_argument(
'--silent',
action='store_true',
default=False,
help='WARNING: Disables all prompts for input and confirmation. If no configuration is provided, this is ignored',
)
parser.add_argument(
'--dry-run',
'--dry_run',
action='store_true',
default=False,
help='Generates a configuration file and then exits instead of performing an installation',
)
parser.add_argument(
'--script',
nargs='?',
help='Script to run for installation',
type=str,
)
parser.add_argument(
'--mountpoint',
type=Path,
nargs='?',
default=Path('/mnt'),
help='Define an alternate mount point for installation',
)
parser.add_argument(
'--skip-ntp',
action='store_true',
help='Disables NTP checks during installation',
default=False,
)
parser.add_argument(
'--skip-wkd',
action='store_true',
help='Disables checking if archlinux keyring wkd sync is complete.',
default=False,
)
parser.add_argument(
'--skip-boot',
action='store_true',
help='Disables installation of a boot loader (note: only use this when problems arise with the boot loader step).',
default=False,
)
parser.add_argument(
'--debug',
action='store_true',
default=False,
help='Adds debug info into the log',
)
parser.add_argument(
'--offline',
action='store_true',
default=False,
help='Disabled online upstream services such as package search and key-ring auto update.',
)
parser.add_argument(
'--no-pkg-lookups',
action='store_true',
default=False,
help='Disabled package validation specifically prior to starting installation.',
)
parser.add_argument(
'--plugin',
nargs='?',
type=str,
default=None,
help='File path to a plugin to load',
)
parser.add_argument(
'--skip-version-check',
action='store_true',
default=False,
help='Skip the version check when running archinstall',
)
parser.add_argument(
'--skip-wifi-check',
action='store_true',
default=False,
help='Skip wifi check when running archinstall',
)
parser.add_argument(
'--advanced',
action='store_true',
default=False,
help='Enabled advanced options',
)
parser.add_argument(
'--verbose',
action='store_true',
default=False,
help='Enabled verbose options',
)
def _define_arguments(self) -> ArgumentParser:
parser = ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument(
'-v',
'--version',
action='version',
default=False,
version='%(prog)s ' + get_version(),
)
parser.add_argument(
'--config',
type=Path,
nargs='?',
default=None,
help='JSON configuration file',
)
parser.add_argument(
'--config-url',
type=str,
nargs='?',
default=None,
help='Url to a JSON configuration file',
)
parser.add_argument(
'--creds',
type=Path,
nargs='?',
default=None,
help='JSON credentials configuration file',
)
parser.add_argument(
'--creds-url',
type=str,
nargs='?',
default=None,
help='Url to a JSON credentials configuration file',
)
parser.add_argument(
'--creds-decryption-key',
type=str,
nargs='?',
default=None,
help='Decryption key for credentials file',
)
parser.add_argument(
'--silent',
action='store_true',
default=False,
help='WARNING: Disables all prompts for input and confirmation. If no configuration is provided, this is ignored',
)
parser.add_argument(
'--dry-run',
'--dry_run',
action='store_true',
default=False,
help='Generates a configuration file and then exits instead of performing an installation',
)
parser.add_argument(
'--script',
nargs='?',
help='Script to run for installation',
type=str,
)
parser.add_argument(
'--mountpoint',
type=Path,
nargs='?',
default=Path('/mnt'),
help='Define an alternate mount point for installation',
)
parser.add_argument(
'--skip-ntp',
action='store_true',
help='Disables NTP checks during installation',
default=False,
)
parser.add_argument(
'--skip-wkd',
action='store_true',
help='Disables checking if archlinux keyring wkd sync is complete.',
default=False,
)
parser.add_argument(
'--skip-boot',
action='store_true',
help='Disables installation of a boot loader (note: only use this when problems arise with the boot loader step).',
default=False,
)
parser.add_argument(
'--debug',
action='store_true',
default=False,
help='Adds debug info into the log',
)
parser.add_argument(
'--offline',
action='store_true',
default=False,
help='Disabled online upstream services such as package search and key-ring auto update.',
)
parser.add_argument(
'--no-pkg-lookups',
action='store_true',
default=False,
help='Disabled package validation specifically prior to starting installation.',
)
parser.add_argument(
'--plugin',
nargs='?',
type=str,
default=None,
help='File path to a plugin to load',
)
parser.add_argument(
'--skip-version-check',
action='store_true',
default=False,
help='Skip the version check when running archinstall',
)
parser.add_argument(
'--skip-wifi-check',
action='store_true',
default=False,
help='Skip wifi check when running archinstall',
)
parser.add_argument(
'--advanced',
action='store_true',
default=False,
help='Enabled advanced options',
)
parser.add_argument(
'--verbose',
action='store_true',
default=False,
help='Enabled verbose options',
)
return parser
return parser
def _parse_args(self) -> Arguments:
argparse_args = vars(self._parser.parse_args())
args: Arguments = Arguments(**argparse_args)
def _parse_args(self) -> Arguments:
argparse_args = vars(self._parser.parse_args())
args: Arguments = Arguments(**argparse_args)
# amend the parameters (check internal consistency)
# Installation can't be silent if config is not passed
if args.config is None and args.config_url is None:
args.silent = False
# amend the parameters (check internal consistency)
# Installation can't be silent if config is not passed
if args.config is None and args.config_url is None:
args.silent = False
if args.debug:
warn(f'Warning: --debug mode will write certain credentials to {logger.path}!')
if args.debug:
warn(f'Warning: --debug mode will write certain credentials to {logger.path}!')
if args.plugin:
plugin_path = Path(args.plugin)
load_plugin(plugin_path)
if args.plugin:
# Pass plugin as string to preserve URL format (avoid Path normalization)
load_plugin(args.plugin)
if args.creds_decryption_key is None:
if os.environ.get('ARCHINSTALL_CREDS_DECRYPTION_KEY'):
args.creds_decryption_key = os.environ.get('ARCHINSTALL_CREDS_DECRYPTION_KEY')
if args.creds_decryption_key is None:
if os.environ.get('ARCHINSTALL_CREDS_DECRYPTION_KEY'):
args.creds_decryption_key = os.environ.get('ARCHINSTALL_CREDS_DECRYPTION_KEY')
return args
return args
def _parse_config(self) -> dict[str, Any]:
config: dict[str, Any] = {}
config_data: str | None = None
creds_data: str | None = None
def _parse_config(self) -> dict[str, Any]:
config: dict[str, Any] = {}
config_data: str | None = None
creds_data: str | None = None
if self._args.config is not None:
config_data = self._read_file(self._args.config)
elif self._args.config_url is not None:
config_data = self._fetch_from_url(self._args.config_url)
if self._args.config is not None:
config_data = self._read_file(self._args.config)
elif self._args.config_url is not None:
config_data = self._fetch_from_url(self._args.config_url)
if config_data is not None:
config.update(json.loads(config_data))
if config_data is not None:
config.update(json.loads(config_data))
if self._args.creds is not None:
creds_data = self._read_file(self._args.creds)
elif self._args.creds_url is not None:
creds_data = self._fetch_from_url(self._args.creds_url)
if self._args.creds is not None:
creds_data = self._read_file(self._args.creds)
elif self._args.creds_url is not None:
creds_data = self._fetch_from_url(self._args.creds_url)
if creds_data is not None:
json_data = self._process_creds_data(creds_data)
if json_data is not None:
config.update(json_data)
if creds_data is not None:
json_data = self._process_creds_data(creds_data)
if json_data is not None:
config.update(json_data)
config = self._cleanup_config(config)
config = self._cleanup_config(config)
return config
return config
def _process_creds_data(self, creds_data: str) -> dict[str, Any] | None:
if creds_data.startswith('$'): # encrypted data
if self._args.creds_decryption_key is not None:
try:
creds_data = decrypt(creds_data, self._args.creds_decryption_key)
return json.loads(creds_data)
except ValueError as err:
if 'Invalid password' in str(err):
error(tr('Incorrect credentials file decryption password'))
sys.exit(1)
else:
debug(f'Error decrypting credentials file: {err}')
raise err from err
else:
header = tr('Enter credentials file decryption password')
wrong_pwd_text = tr('Incorrect password')
prompt = header
def _process_creds_data(self, creds_data: str) -> dict[str, Any] | None:
if creds_data.startswith('$'): # encrypted data
if self._args.creds_decryption_key is not None:
try:
creds_data = decrypt(creds_data, self._args.creds_decryption_key)
return json.loads(creds_data)
except ValueError as err:
if 'Invalid password' in str(err):
error(tr('Incorrect credentials file decryption password'))
sys.exit(1)
else:
debug(f'Error decrypting credentials file: {err}')
raise err from err
else:
header = tr('Enter credentials file decryption password')
wrong_pwd_text = tr('Incorrect password')
prompt = header
while True:
decryption_pwd: Password | None = tui.run(
lambda p=prompt: get_password( # type: ignore[misc]
header=p,
allow_skip=False,
no_confirmation=True,
)
)
while True:
decryption_pwd: Password | None = tui.run(
lambda p=prompt: get_password( # type: ignore[misc]
header=p,
allow_skip=False,
no_confirmation=True,
)
)
if not decryption_pwd:
return None
if not decryption_pwd:
return None
try:
creds_data = decrypt(creds_data, decryption_pwd.plaintext)
break
except ValueError as err:
if 'Invalid password' in str(err):
debug('Incorrect credentials file decryption password')
prompt = f'{header}' + f'\n\n{wrong_pwd_text}'
else:
debug(f'Error decrypting credentials file: {err}')
raise err from err
try:
creds_data = decrypt(creds_data, decryption_pwd.plaintext)
break
except ValueError as err:
if 'Invalid password' in str(err):
debug('Incorrect credentials file decryption password')
prompt = f'{header}' + f'\n\n{wrong_pwd_text}'
else:
debug(f'Error decrypting credentials file: {err}')
raise err from err
return json.loads(creds_data)
return json.loads(creds_data)
def _fetch_from_url(self, url: str) -> str:
if urllib.parse.urlparse(url).scheme:
try:
req = Request(url, headers={'User-Agent': 'ArchInstall'})
with urlopen(req) as resp:
return resp.read().decode('utf-8')
except urllib.error.HTTPError as err:
error(f'Could not fetch JSON from {url}: {err}')
else:
error('Not a valid url')
def _fetch_from_url(self, url: str) -> str:
if urllib.parse.urlparse(url).scheme:
try:
req = Request(url, headers={'User-Agent': 'ArchInstall'})
# FIXED: Added a 15-second timeout to prevent infinite hanging
with urlopen(req, timeout=15) as resp:
return resp.read().decode('utf-8')
except urllib.error.HTTPError as err:
error(f'Could not fetch JSON from {url}: {err}')
else:
error('Not a valid url')
sys.exit(1)
sys.exit(1)
def _read_file(self, path: Path) -> str:
if not path.exists():
error(f'Could not find file {path}')
sys.exit(1)
def _read_file(self, path: Path) -> str:
if not path.exists():
error(f'Could not find file {path}')
sys.exit(1)
return path.read_text()
return path.read_text()
def _cleanup_config(self, config: Namespace | dict[str, Any]) -> dict[str, Any]:
clean_args = {}
for key, val in config.items():
if isinstance(val, dict):
val = self._cleanup_config(val)
# FIXED: Added safe handling to support both ArgumentParser Namespace objects AND dictionaries,
# as calling .items() on a Namespace object without using `vars()` throws an AttributeError.
def _cleanup_config(self, config: Namespace | dict[str, Any]) -> dict[str, Any]:
clean_args = {}
# Convert Namespace to dict if necessary before iterating
config_dict = vars(config) if isinstance(config, Namespace) else config
for key, val in config_dict.items():
if isinstance(val, dict):
val = self._cleanup_config(val)
if val is not None:
clean_args[key] = val
if val is not None:
clean_args[key] = val
return clean_args
return clean_args

View File

@ -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")}. '

View File

@ -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:

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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'

View File

@ -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)

View File

@ -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 = ''

View File

@ -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)

View File

@ -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():

View File

@ -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)

View File

@ -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

View File

@ -1,6 +1,8 @@
import logging
import os
import sys
import urllib.error
import urllib.request
from collections.abc import Callable
from dataclasses import asdict, is_dataclass
from datetime import UTC, datetime
@ -9,6 +11,7 @@ from pathlib import Path
from typing import TYPE_CHECKING, Any
from archinstall.lib.utils.encoding import unicode_ljust, unicode_rjust
from archinstall.tui.rich import BaseRichTable
if TYPE_CHECKING:
from _typeshed import DataclassInstance
@ -18,7 +21,7 @@ class FormattedOutput:
@staticmethod
def _get_values(
o: DataclassInstance,
class_formatter: str | Callable | None = None, # type: ignore[type-arg]
class_formatter: str | Callable | None = None, # type: ignore[type-arg] # pyright: ignore[reportMissingTypeArgument]
filter_list: list[str] = [],
) -> dict[str, Any]:
"""
@ -127,6 +130,35 @@ class FormattedOutput:
return output
def as_key_value_pair(
entries: dict[str, str | list[str] | bool],
ignore_empty: bool = True,
) -> str:
"""
Formats key-values as a Rich Table:
key1 : value1
key2 : value2
...
"""
table = BaseRichTable()
table.add_column('key', style='bold', no_wrap=True)
table.add_column('value', style='white', max_width=70)
for label, value in entries.items():
if ignore_empty and not value:
continue
if isinstance(value, bool):
value = 'Yes' if value else 'No'
if isinstance(value, list):
value = '\n '.join(str(val) for val in value)
table.add_row(label.title(), f': {value}')
return table.stringify()
class Journald:
@staticmethod
def log(message: str, level: int = logging.DEBUG) -> None:
@ -333,3 +365,51 @@ def log(
if level != logging.DEBUG:
print(text)
def share_install_log(
paste_url: str = 'https://paste.rs',
max_size: int = 10 * 1024 * 1024,
confirm: Callable[[str], bool] = lambda _: True,
) -> int:
log_path = logger.path
if not log_path.exists():
info(f'Log file not found: {log_path}')
return 1
size = log_path.stat().st_size
if size == 0:
info(f'Log file is empty: {log_path}')
return 1
if size > max_size:
info(f'Log file exceeds {max_size} bytes, uploading last {max_size} bytes')
content = log_path.read_bytes()[-max_size:]
else:
content = log_path.read_bytes()
header = f'About to upload {log_path} ({len(content)} bytes) to {paste_url}\n\n'
header += 'The log may contain hostname, mirror URLs, package list and partition layout.\n'
header += 'The uploaded paste is public.\n\n'
header += 'Continue?'
if not confirm(header):
info('Cancelled.')
return 1
try:
req = urllib.request.Request(paste_url, data=content)
with urllib.request.urlopen(req) as response:
url = response.read().decode().strip()
except urllib.error.URLError as e:
info(f'Upload failed: {e}')
return 1
if not url.startswith('http'):
info(f'Unexpected response from {paste_url}: {url[:200]!r}')
return 1
# raw print so the URL is pipe-friendly (no ANSI colors, no log prefix)
print(url)
return 0

View File

@ -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_:

View File

@ -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 ""

View File

@ -256,6 +256,9 @@ msgstr "Język"
msgid "Locale encoding"
msgstr "Kodowanie"
msgid "Console font"
msgstr "Czcionka konsoli"
msgid "Drive(s)"
msgstr "Dysk(i)"
@ -820,6 +823,19 @@ msgstr "Nieprawidłowa wartość! Spróbuj jeszcze raz z prawidłową wartości
msgid "Parallel Downloads"
msgstr "Pobieranie kilku plików jednocześnie"
msgid "Pacman"
msgstr "Pacman"
msgid "Color"
msgstr "Kolor"
#, python-brace-format
msgid "Enter the number of parallel downloads (1-{})"
msgstr "Wprowadź maksymalną liczbę plików pobieranych jednocześnie (1-{})"
msgid "Enable colored output for pacman"
msgstr "Włącz kolory w pacmanie"
msgid "ESC to skip"
msgstr "Naciśnij ESC, aby pominąć"
@ -1203,6 +1219,21 @@ msgstr "Produkt"
msgid "Invalid configuration: {error}"
msgstr "Niepoprawna konfiguracja: {error}"
msgid "Ready to install"
msgstr "Gotowe do zainstalowania"
msgid "Disks"
msgstr "Dyski"
msgid "Packages"
msgstr "Pakiety"
msgid "Network"
msgstr "Sieć"
msgid "Locale"
msgstr "Ustawienia regionalne (locale)"
msgid "Type"
msgstr "Typ"
@ -1660,6 +1691,123 @@ msgstr "Wejdź w tryb wyszukiwania"
msgid "Exit search mode"
msgstr "Wyjdź z trybu wyszukiwania"
msgid "General"
msgstr "Ogólne"
msgid "Navigation"
msgstr "Nawigacja"
msgid "Selection"
msgstr "Wybór"
msgid "Search"
msgstr "Szukaj"
msgid "Down"
msgstr "W dół"
msgid "Up"
msgstr "W górę"
msgid "Confirm"
msgstr "Potwierdź"
msgid "Focus right"
msgstr "W prawo"
msgid "Focus left"
msgstr "W lewo"
msgid "Toggle"
msgstr "Zaznacz/Odznacz"
msgid "Show/Hide help"
msgstr "Pokaż/Ukryj pomoc"
msgid "Quit"
msgstr "Wyjdź"
msgid "First"
msgstr "Pierwszy"
msgid "Last"
msgstr "Ostatni"
msgid "Select"
msgstr "Wybierz"
msgid "Page Up"
msgstr "Strona w górę"
msgid "Page Down"
msgstr "Strona w dół"
msgid "Page up"
msgstr "Strona w górę"
msgid "Page down"
msgstr "Strona w dół"
msgid "Page Left"
msgstr "Strona w lewo"
msgid "Page Right"
msgstr "Strona w prawo"
msgid "Cursor up"
msgstr "Kursor w górę"
msgid "Cursor down"
msgstr "Kursor w dół"
msgid "Cursor right"
msgstr "Kursor w prawo"
msgid "Cursor left"
msgstr "Kursor w lewo"
msgid "Top"
msgstr "Góra"
msgid "Bottom"
msgstr "Dół"
msgid "Home"
msgstr "Początek"
msgid "End"
msgstr "Koniec"
msgid "Toggle option"
msgstr "Zaznacz/Odznacz opcję"
msgid "Scroll Up"
msgstr "Przewiń w górę"
msgid "Scroll Down"
msgstr "Przewiń w dół"
msgid "Scroll Left"
msgstr "Przewiń w lewo"
msgid "Scroll Right"
msgstr "Przewiń w prawo"
msgid "Scroll Home"
msgstr "Przewiń do początku"
msgid "Scroll End"
msgstr "Przewiń do końca"
msgid "Focus Next"
msgstr "Następny"
msgid "Focus Previous"
msgstr "Poprzedni"
msgid "Copy selected text"
msgstr "Skopiuj zaznaczony tekst"
msgid "labwc needs access to your seat (collection of hardware devices i.e. keyboard, mouse, etc)"
msgstr "labwc potrzebuje dostępu do twojego seat'a (sprzętu, np. klawiatury, myszki)"
@ -1878,6 +2026,21 @@ msgstr "Użyj NetworkManager'a (backend iwd)"
msgid "Firewall"
msgstr "Zapora sieciowa"
msgid "Additional fonts"
msgstr "Dodatkowe czcionki"
msgid "Select font packages to install"
msgstr "Wybierz pakiety czcionek do zainstalowania"
msgid "Unicode font coverage for most languages"
msgstr "Wsparcie Unicode dla większości języków"
msgid "color emoji for browsers and apps"
msgstr "kolorowe emoji dla przeglądarek i aplikacji"
msgid "Chinese, Japanese, Korean characters"
msgstr "Chińskie, Japońskie, Koreańskie znaki"
msgid "Select audio configuration"
msgstr "Wybierz konfigurację audio"
@ -1965,7 +2128,13 @@ msgid "Select an interface"
msgstr "Wybierz interfejs"
msgid "Choose network configuration"
msgstr "Wybierz konfigurację sieciową"
msgstr "Wybierz konfigurację sieci"
msgid "Recommended: Network Manager for desktop, Manual for server"
msgstr "Rekomendowane: Network Manager dla desktopów, ręcznie dla serwerów"
msgid "Warning: no network configuration selected. Network will need to be set up manually on the installed system."
msgstr "Uwaga: nie wybrano konfiguracji sieci. Sieć musi zostać skonfigurowana ręcznie w zainstalowanym systemie."
msgid "No packages found"
msgstr "Nie znaleziono pakietów"
@ -2004,6 +2173,72 @@ msgstr "Wybrany profil pulpitu wymaga logowania normalnego użytkownika przez gr
msgid "Environment type: {} {}"
msgstr "Typ środowiska: {} {}"
msgid "Input cannot be empty"
msgstr "Podana wartość nie może być pusta"
msgid "Recommended"
msgstr "Rekomendowane"
msgid "Package"
msgstr "Pakiet"
msgid "Curated selection of KDE Plasma packages"
msgstr "Wyselekcjonowany zestaw pakietów KDE Plasma"
msgid "Dependencies"
msgstr "Zależności"
msgid "Package group"
msgstr "Grupa pakietów"
msgid "Extensive KDE Plasma installation"
msgstr "Obszerna instalacja KDE Plasma"
msgid "Packages in group"
msgstr "Pakiety w grupie"
msgid "Minimal KDE Plasma installation"
msgstr "Minimalistyczna instalacja KDE Plasma"
msgid "Description"
msgstr "Opis"
msgid "Select a flavor of KDE Plasma to install"
msgstr "Wybierz rodzaj KDE Plasma do zainstalowania"
msgid "Arial/Times/Courier replacement, Cyrillic support for Steam/games"
msgstr "Zamiennik Arial/Times/Courier, obsługa Cyrylicy dla Steam/gier"
msgid "wide Unicode coverage, good fallback font"
msgstr "szerokie wsparcie Unicode, dobra czczionka zastępcza"
#, python-brace-format
msgid "Setting up U2F login: {u2f_config.u2f_login_method.value}"
msgstr "Ustawianie logowania U2F {u2f_config.u2f_login_method.value}"
#, python-brace-format
msgid "Default: {DEFAULT_ITER_TIME}ms, Recommended range: 1000-60000"
msgstr "Domyślnie: {DEFAULT_ITER_TIME}ms, Rekomendowany przedział: 1000-60000"
#, python-brace-format
msgid "Setting up U2F login: {}"
msgstr "Konfigurowanie logowania U2F: {}"
#, python-brace-format
msgid "Default: {}ms, Recommended range: 1000-60000"
msgstr "Domyślnie: {}ms, Rekomendowany przedział: 1000-60000"
#, python-brace-format
msgid "{} needs access to your seat"
msgstr "{} potrzebuje dostępu do twojego seat'a"
msgid "collection of hardware devices i.e. keyboard, mouse"
msgstr "zbiór urządzeń, t.j. klawiatura, myszka"
#, python-brace-format
msgid "Choose an option how to give {} access to your hardware"
msgstr "Wybierz opcję, aby nadać {} dostęp do twojego sprzętu"
#~ msgid "When picking a directory to save configuration files to, by default we will ignore the following folders: "
#~ msgstr "Podczas wybierania katalogu do zapisywania plików konfiguracyjnych, domyślnie ignorowane są następujące foldery: "

View File

@ -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)

View File

@ -200,6 +200,11 @@ class OptionListScreen(BaseScreen[ValueT]):
color: white;
text-style: bold;
}
.wrap-preview {
width: 100%;
height: auto;
}
"""
def __init__(
@ -211,6 +216,7 @@ class OptionListScreen(BaseScreen[ValueT]):
allow_reset: bool = False,
preview_location: Literal['right', 'bottom'] | None = None,
enable_filter: bool = False,
wrap_preview: bool = False,
):
super().__init__(allow_skip, allow_reset)
self._group = group
@ -218,6 +224,7 @@ class OptionListScreen(BaseScreen[ValueT]):
self._title = title
self._preview_location = preview_location
self._filter = enable_filter
self._wrap_preview = wrap_preview
self._show_frame = False
self._options = self._get_options()
@ -280,7 +287,10 @@ class OptionListScreen(BaseScreen[ValueT]):
with Container():
yield option_list
yield Rule(orientation=rule_orientation)
yield ScrollableContainer(Label('', id='preview_content', markup=False))
preview_label = Label('', id='preview_content', markup=False)
if self._wrap_preview:
preview_label.add_class('wrap-preview')
yield ScrollableContainer(preview_label)
if self._filter:
yield Input(placeholder='/filter', id='filter-input')
@ -340,11 +350,6 @@ class OptionListScreen(BaseScreen[ValueT]):
]
)
# debug(f'Index: {index}')
# debug(f'Region: {option_list.region}')
# debug(f'Scroll offset: {option_list.scroll_offset}')
# debug(f'Target_Y: {target_y}')
self.app.cursor_position = Offset(option_list.region.x, target_y)
self.app.refresh()
@ -433,6 +438,11 @@ class SelectListScreen(BaseScreen[ValueT]):
color: white;
text-style: bold;
}
.wrap-preview {
width: 100%;
height: auto;
}
"""
def __init__(
@ -443,6 +453,7 @@ class SelectListScreen(BaseScreen[ValueT]):
allow_reset: bool = False,
preview_location: Literal['right', 'bottom'] | None = None,
enable_filter: bool = False,
wrap_preview: bool = False,
):
super().__init__(allow_skip, allow_reset)
self._group = group
@ -450,6 +461,7 @@ class SelectListScreen(BaseScreen[ValueT]):
self._preview_location = preview_location
self._show_frame = False
self._filter = enable_filter
self._wrap_preview = wrap_preview
self._selected_items: list[MenuItem] = self._group.selected_items
self._options: list[Selection[MenuItem]] = self._get_selections()
@ -510,7 +522,10 @@ class SelectListScreen(BaseScreen[ValueT]):
with Container():
yield selection_list
yield Rule(orientation=rule_orientation)
yield ScrollableContainer(Label('', id='preview_content', markup=False))
preview_label = Label('', id='preview_content', markup=False)
if self._wrap_preview:
preview_label.add_class('wrap-preview')
yield ScrollableContainer(preview_label)
if self._filter:
yield Input(placeholder='/filter', id='filter-input')
@ -1143,8 +1158,6 @@ class TableSelectionScreen(BaseScreen[ValueT]):
]
)
debug(f'Setting cursor to target_y: {target_y}')
self.app.cursor_position = Offset(data_table.region.x, target_y)
self.app.refresh()

25
archinstall/tui/rich.py Normal file
View File

@ -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

View File

@ -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:

View File

@ -19,7 +19,7 @@ classifiers = [
]
dependencies = [
"pyparted==3.13.0",
"pydantic==2.12.5",
"pydantic==2.13.4",
"cryptography==48.0.0",
"textual==8.2.5",
"markdown-it-py==4.0.0",
@ -34,7 +34,7 @@ Source = "https://github.com/archlinux/archinstall"
[project.optional-dependencies]
log = ["systemd_python==235"]
dev = [
"mypy==1.20.2",
"mypy==2.1.0",
"flake8==7.3.0",
"pre-commit==4.6.0",
"ruff==0.15.12",

94
tests/test_share_log.py Normal file
View File

@ -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