Send patches - preferably formatted by git format-patch - to patches at archlinux32 dot org.
summaryrefslogtreecommitdiff
path: root/archinstall/lib
diff options
context:
space:
mode:
authorAndreas Baumann <mail@andreasbaumann.cc>2024-05-10 15:56:28 +0200
committerAndreas Baumann <mail@andreasbaumann.cc>2024-05-10 15:56:28 +0200
commit683da22298abbd90f51d4dd38a7ec4b0dfb04555 (patch)
treeec2ac04967f9277df038edc362201937b331abe5 /archinstall/lib
parentaf7ab9833c9f9944874f0162ae0975175ddc628d (diff)
parent3381cd55673e5105697d354cf4a1be9a7bcef062 (diff)
merged with upstreamHEADmaster
Diffstat (limited to 'archinstall/lib')
-rw-r--r--archinstall/lib/boot.py (renamed from archinstall/lib/systemd.py)84
-rw-r--r--archinstall/lib/configuration.py191
-rw-r--r--archinstall/lib/disk/__init__.py55
-rw-r--r--archinstall/lib/disk/blockdevice.py301
-rw-r--r--archinstall/lib/disk/btrfs/__init__.py56
-rw-r--r--archinstall/lib/disk/btrfs/btrfs_helpers.py136
-rw-r--r--archinstall/lib/disk/btrfs/btrfspartition.py109
-rw-r--r--archinstall/lib/disk/btrfs/btrfssubvolumeinfo.py192
-rw-r--r--archinstall/lib/disk/device_handler.py809
-rw-r--r--archinstall/lib/disk/device_model.py1499
-rw-r--r--archinstall/lib/disk/disk_menu.py140
-rw-r--r--archinstall/lib/disk/diskinfo.py40
-rw-r--r--archinstall/lib/disk/dmcryptdev.py48
-rw-r--r--archinstall/lib/disk/encryption.py174
-rw-r--r--archinstall/lib/disk/encryption_menu.py288
-rw-r--r--archinstall/lib/disk/fido.py (renamed from archinstall/lib/hsm/fido.py)75
-rw-r--r--archinstall/lib/disk/filesystem.py622
-rw-r--r--archinstall/lib/disk/helpers.py556
-rw-r--r--archinstall/lib/disk/mapperdev.py92
-rw-r--r--archinstall/lib/disk/partition.py661
-rw-r--r--archinstall/lib/disk/partitioning_menu.py429
-rw-r--r--archinstall/lib/disk/subvolume_menu.py61
-rw-r--r--archinstall/lib/disk/user_guides.py240
-rw-r--r--archinstall/lib/disk/validators.py48
-rw-r--r--archinstall/lib/exceptions.py35
-rw-r--r--archinstall/lib/general.py426
-rw-r--r--archinstall/lib/global_menu.py472
-rw-r--r--archinstall/lib/hardware.py496
-rw-r--r--archinstall/lib/hsm/__init__.py1
-rw-r--r--archinstall/lib/installer.py1881
-rw-r--r--archinstall/lib/interactions/__init__.py19
-rw-r--r--archinstall/lib/interactions/disk_conf.py572
-rw-r--r--archinstall/lib/interactions/general_conf.py209
-rw-r--r--archinstall/lib/interactions/manage_users_conf.py (renamed from archinstall/lib/user_interaction/manage_users_conf.py)41
-rw-r--r--archinstall/lib/interactions/network_menu.py (renamed from archinstall/lib/user_interaction/network_conf.py)110
-rw-r--r--archinstall/lib/interactions/system_conf.py138
-rw-r--r--archinstall/lib/interactions/utils.py39
-rw-r--r--archinstall/lib/locale/__init__.py10
-rw-r--r--archinstall/lib/locale/locale_menu.py158
-rw-r--r--archinstall/lib/locale/utils.py67
-rw-r--r--archinstall/lib/locale_helpers.py168
-rw-r--r--archinstall/lib/luks.py361
-rw-r--r--archinstall/lib/menu/__init__.py11
-rw-r--r--archinstall/lib/menu/abstract_menu.py275
-rw-r--r--archinstall/lib/menu/global_menu.py429
-rw-r--r--archinstall/lib/menu/list_manager.py64
-rw-r--r--archinstall/lib/menu/menu.py253
-rw-r--r--archinstall/lib/menu/simple_menu.py2002
-rw-r--r--archinstall/lib/menu/table_selection_menu.py70
-rw-r--r--archinstall/lib/menu/text_input.py11
-rw-r--r--archinstall/lib/mirrors.py449
-rw-r--r--archinstall/lib/models/__init__.py10
-rw-r--r--archinstall/lib/models/audio_configuration.py54
-rw-r--r--archinstall/lib/models/bootloader.py47
-rw-r--r--archinstall/lib/models/disk_encryption.py90
-rw-r--r--archinstall/lib/models/gen.py (renamed from archinstall/lib/models/dataclasses.py)68
-rw-r--r--archinstall/lib/models/network_configuration.py261
-rw-r--r--archinstall/lib/models/password_strength.py85
-rw-r--r--archinstall/lib/models/pydantic.py134
-rw-r--r--archinstall/lib/models/subvolume.py68
-rw-r--r--archinstall/lib/models/users.py94
-rw-r--r--archinstall/lib/networking.py88
-rw-r--r--archinstall/lib/output.py306
-rw-r--r--archinstall/lib/packages/__init__.py4
-rw-r--r--archinstall/lib/packages/packages.py14
-rw-r--r--archinstall/lib/pacman.py28
-rw-r--r--archinstall/lib/pacman/__init__.py88
-rw-r--r--archinstall/lib/pacman/config.py44
-rw-r--r--archinstall/lib/pacman/repo.py5
-rw-r--r--archinstall/lib/plugins.py91
-rw-r--r--archinstall/lib/profile/__init__.py3
-rw-r--r--archinstall/lib/profile/profile_menu.py218
-rw-r--r--archinstall/lib/profile/profile_model.py39
-rw-r--r--archinstall/lib/profile/profiles_handler.py413
-rw-r--r--archinstall/lib/profiles.py340
-rw-r--r--archinstall/lib/services.py11
-rw-r--r--archinstall/lib/storage.py23
-rw-r--r--archinstall/lib/translationhandler.py20
-rw-r--r--archinstall/lib/udev/__init__.py1
-rw-r--r--archinstall/lib/udev/udevadm.py17
-rw-r--r--archinstall/lib/user_interaction/__init__.py12
-rw-r--r--archinstall/lib/user_interaction/backwards_compatible_conf.py95
-rw-r--r--archinstall/lib/user_interaction/disk_conf.py86
-rw-r--r--archinstall/lib/user_interaction/general_conf.py271
-rw-r--r--archinstall/lib/user_interaction/locale_conf.py42
-rw-r--r--archinstall/lib/user_interaction/partitioning_conf.py362
-rw-r--r--archinstall/lib/user_interaction/save_conf.py135
-rw-r--r--archinstall/lib/user_interaction/subvolume_config.py98
-rw-r--r--archinstall/lib/user_interaction/system_conf.py168
-rw-r--r--archinstall/lib/user_interaction/utils.py79
-rw-r--r--archinstall/lib/utils/__init__.py0
-rw-r--r--archinstall/lib/utils/singleton.py15
-rw-r--r--archinstall/lib/utils/util.py51
93 files changed, 9597 insertions, 10154 deletions
diff --git a/archinstall/lib/systemd.py b/archinstall/lib/boot.py
index 64ffcae4..62c50df3 100644
--- a/archinstall/lib/systemd.py
+++ b/archinstall/lib/boot.py
@@ -1,58 +1,17 @@
-import logging
import time
-from typing import Iterator
+from typing import Iterator, Optional
from .exceptions import SysCallError
from .general import SysCommand, SysCommandWorker, locate_binary
from .installer import Installer
-from .output import log
+from .output import error
from .storage import storage
-class Ini:
- def __init__(self, *args :str, **kwargs :str):
- """
- Limited INI handler for now.
- Supports multiple keywords through dictionary list items.
- """
- self.kwargs = kwargs
-
- def __str__(self) -> str:
- result = ''
- first_row_done = False
- for top_level in self.kwargs:
- if first_row_done:
- result += f"\n[{top_level}]\n"
- else:
- result += f"[{top_level}]\n"
- first_row_done = True
-
- for key, val in self.kwargs[top_level].items():
- if type(val) == list:
- for item in val:
- result += f"{key}={item}\n"
- else:
- result += f"{key}={val}\n"
-
- return result
-
-
-class Systemd(Ini):
- """
- Placeholder class to do systemd specific setups.
- """
-
-
-class Networkd(Systemd):
- """
- Placeholder class to do systemd-network specific setups.
- """
-
-
class Boot:
def __init__(self, installation: Installer):
self.instance = installation
self.container_name = 'archinstall'
- self.session = None
+ self.session: Optional[SysCommandWorker] = None
self.ready = False
def __enter__(self) -> 'Boot':
@@ -63,17 +22,18 @@ class Boot:
self.session = existing_session.session
self.ready = existing_session.ready
else:
+ # '-P' or --console=pipe could help us not having to do a bunch
+ # of os.write() calls, but instead use pipes (stdin, stdout and stderr) as usual.
self.session = SysCommandWorker([
'/usr/bin/systemd-nspawn',
- '-D', self.instance.target,
+ '-D', str(self.instance.target),
'--timezone=off',
'-b',
'--no-pager',
'--machine', self.container_name
])
- # '-P' or --console=pipe could help us not having to do a bunch of os.write() calls, but instead use pipes (stdin, stdout and stderr) as usual.
- if not self.ready:
+ if not self.ready and self.session:
while self.session.is_alive():
if b' login:' in self.session:
self.ready = True
@@ -87,29 +47,37 @@ class Boot:
# TODO: https://stackoverflow.com/questions/28157929/how-to-safely-handle-an-exception-inside-a-context-manager
if len(args) >= 2 and args[1]:
- log(args[1], level=logging.ERROR, fg='red')
- log(f"The error above occurred in a temporary boot-up of the installation {self.instance}", level=logging.ERROR, fg="red")
+ error(
+ args[1],
+ f"The error above occurred in a temporary boot-up of the installation {self.instance}"
+ )
shutdown = None
- shutdown_exit_code = -1
+ shutdown_exit_code: Optional[int] = -1
try:
shutdown = SysCommand(f'systemd-run --machine={self.container_name} --pty shutdown now')
- except SysCallError as error:
- shutdown_exit_code = error.exit_code
+ except SysCallError as err:
+ shutdown_exit_code = err.exit_code
- while self.session.is_alive():
- time.sleep(0.25)
+ if self.session:
+ while self.session.is_alive():
+ time.sleep(0.25)
- if shutdown:
+ if shutdown and shutdown.exit_code:
shutdown_exit_code = shutdown.exit_code
- if self.session.exit_code == 0 or shutdown_exit_code == 0:
+ if self.session and (self.session.exit_code == 0 or shutdown_exit_code == 0):
storage['active_boot'] = None
else:
- raise SysCallError(f"Could not shut down temporary boot of {self.instance}: {self.session.exit_code}/{shutdown_exit_code}", exit_code=next(filter(bool, [self.session.exit_code, shutdown_exit_code])))
+ session_exit_code = self.session.exit_code if self.session else -1
+
+ raise SysCallError(
+ f"Could not shut down temporary boot of {self.instance}: {session_exit_code}/{shutdown_exit_code}",
+ exit_code=next(filter(bool, [session_exit_code, shutdown_exit_code]))
+ )
- def __iter__(self) -> Iterator[str]:
+ def __iter__(self) -> Iterator[bytes]:
if self.session:
for value in self.session:
yield value
diff --git a/archinstall/lib/configuration.py b/archinstall/lib/configuration.py
index c036783f..95e237d7 100644
--- a/archinstall/lib/configuration.py
+++ b/archinstall/lib/configuration.py
@@ -1,28 +1,17 @@
import os
import json
import stat
-import logging
-import pathlib
-from typing import Optional, Dict
+import readline
+from pathlib import Path
+from typing import Optional, Dict, Any, TYPE_CHECKING
-from .hsm.fido import Fido2
-from .models.disk_encryption import DiskEncryption
+from .menu import Menu, MenuSelectionType
from .storage import storage
from .general import JSON, UNSAFE_JSON
-from .output import log
-from .exceptions import RequirementError
-
-
-def configuration_sanity_check():
- disk_encryption: DiskEncryption = storage['arguments'].get('disk_encryption')
- if disk_encryption is not None and disk_encryption.hsm_device:
- if not Fido2.get_fido2_devices():
- raise RequirementError(
- f"In order to use HSM to pair with the disk encryption,"
- + f" one needs to be accessible through /dev/hidraw* and support"
- + f" the FIDO2 protocol. You can check this by running"
- + f" 'systemd-cryptenroll --fido2-device=list'."
- )
+from .output import debug, info, warn
+
+if TYPE_CHECKING:
+ _: Any
class ConfigurationOutput:
@@ -35,15 +24,13 @@ class ConfigurationOutput:
:type config: Dict
"""
self._config = config
- self._user_credentials = {}
- self._disk_layout = None
- self._user_config = {}
- self._default_save_path = pathlib.Path(storage.get('LOG_PATH', '.'))
+ self._user_credentials: Dict[str, Any] = {}
+ self._user_config: Dict[str, Any] = {}
+ self._default_save_path = storage.get('LOG_PATH', Path('.'))
self._user_config_file = 'user_configuration.json'
self._user_creds_file = "user_credentials.json"
- self._disk_layout_file = "user_disk_layout.json"
- self._sensitive = ['!users']
+ self._sensitive = ['!users', '!root-password']
self._ignore = ['abort', 'install', 'config', 'creds', 'dry_run']
self._process_config()
@@ -56,23 +43,18 @@ class ConfigurationOutput:
def user_configuration_file(self):
return self._user_config_file
- @property
- def disk_layout_file(self):
- return self._disk_layout_file
-
def _process_config(self):
- for key in self._config:
+ for key, value in self._config.items():
if key in self._sensitive:
- self._user_credentials[key] = self._config[key]
- elif key == 'disk_layouts':
- self._disk_layout = self._config[key]
+ self._user_credentials[key] = value
elif key in self._ignore:
pass
else:
- self._user_config[key] = self._config[key]
+ self._user_config[key] = value
- if key == 'disk_encryption' and self._config[key]: # special handling for encryption password
- self._user_credentials['encryption_password'] = self._config[key].encryption_password
+ # special handling for encryption password
+ if key == 'disk_encryption' and value:
+ self._user_credentials['encryption_password'] = value.encryption_password
def user_config_to_json(self) -> str:
return json.dumps({
@@ -81,11 +63,6 @@ class ConfigurationOutput:
'version': storage['__version__']
}, indent=4, sort_keys=True, cls=JSON)
- def disk_layout_to_json(self) -> Optional[str]:
- if self._disk_layout:
- return json.dumps(self._disk_layout, indent=4, sort_keys=True, cls=JSON)
- return None
-
def user_credentials_to_json(self) -> Optional[str]:
if self._user_credentials:
return json.dumps(self._user_credentials, indent=4, sort_keys=True, cls=UNSAFE_JSON)
@@ -93,60 +70,112 @@ class ConfigurationOutput:
def show(self):
print(_('\nThis is your chosen configuration:'))
- log(" -- Chosen configuration --", level=logging.DEBUG)
-
- user_conig = self.user_config_to_json()
- disk_layout = self.disk_layout_to_json()
- log(user_conig, level=logging.INFO)
-
- if disk_layout:
- log(disk_layout, level=logging.INFO)
+ debug(" -- Chosen configuration --")
+ info(self.user_config_to_json())
print()
- def _is_valid_path(self, dest_path :pathlib.Path) -> bool:
- if (not dest_path.exists()) or not (dest_path.is_dir()):
- log(
- 'Destination directory {} does not exist or is not a directory,\n Configuration files can not be saved'.format(dest_path.resolve()),
- fg="yellow"
+ 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 False
- return True
+ return dest_path_ok
- def save_user_config(self, dest_path :pathlib.Path = None):
+ def save_user_config(self, dest_path: Path):
if self._is_valid_path(dest_path):
target = dest_path / self._user_config_file
+ target.write_text(self.user_config_to_json())
+ os.chmod(target, stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP)
- with open(target, 'w') as config_file:
- config_file.write(self.user_config_to_json())
-
- os.chmod(str(dest_path / self._user_config_file), stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP)
-
- def save_user_creds(self, dest_path :pathlib.Path = None):
+ def save_user_creds(self, dest_path: Path):
if self._is_valid_path(dest_path):
if user_creds := self.user_credentials_to_json():
target = dest_path / self._user_creds_file
+ target.write_text(user_creds)
+ os.chmod(target, stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP)
- with open(target, 'w') as config_file:
- config_file.write(user_creds)
-
- os.chmod(str(target), stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP)
+ def save(self, dest_path: Optional[Path] = None):
+ dest_path = dest_path or self._default_save_path
- def save_disk_layout(self, dest_path :pathlib.Path = None):
if self._is_valid_path(dest_path):
- if disk_layout := self.disk_layout_to_json():
- target = dest_path / self._disk_layout_file
-
- with target.open('w') as config_file:
- config_file.write(disk_layout)
+ self.save_user_config(dest_path)
+ self.save_user_creds(dest_path)
- os.chmod(str(target), stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP)
- def save(self, dest_path :pathlib.Path = None):
- if not dest_path:
- dest_path = self._default_save_path
+def save_config(config: Dict):
+ def preview(selection: str):
+ match options[selection]:
+ case "user_config":
+ serialized = config_output.user_config_to_json()
+ return f"{config_output.user_configuration_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}"
+ return str(_("No configuration"))
+ case "all":
+ output = [config_output.user_configuration_file]
+ if config_output.user_credentials_to_json():
+ output.append(config_output.user_credentials_file)
+ return '\n'.join(output)
+ return None
- if self._is_valid_path(dest_path):
- self.save_user_config(dest_path)
- self.save_user_creds(dest_path)
- self.save_disk_layout(dest_path)
+ try:
+ config_output = ConfigurationOutput(config)
+
+ options = {
+ str(_("Save user configuration (including disk layout)")): "user_config",
+ str(_("Save user credentials")): "user_creds",
+ str(_("Save all")): "all",
+ }
+
+ save_choice = Menu(
+ _("Choose which configuration to save"),
+ list(options),
+ sort=False,
+ skip=True,
+ preview_size=0.75,
+ preview_command=preview,
+ ).run()
+
+ if save_choice.type_ == MenuSelectionType.Skip:
+ return
+
+ readline.set_completer_delims("\t\n=")
+ readline.parse_and_bind("tab: complete")
+ while True:
+ path = input(
+ _(
+ "Enter a directory for the configuration(s) to be saved (tab completion enabled)\nSave directory: "
+ )
+ ).strip(" ")
+ dest_path = Path(path)
+ if dest_path.exists() and dest_path.is_dir():
+ break
+ info(_("Not a valid directory: {}").format(dest_path), fg="red")
+
+ if not path:
+ return
+
+ prompt = _(
+ "Do you want to save {} configuration file(s) in the following location?\n\n{}"
+ ).format(options[str(save_choice.value)], dest_path.absolute())
+
+ save_confirmation = Menu(prompt, Menu.yes_no(), default_option=Menu.yes()).run()
+ if save_confirmation == Menu.no():
+ return
+
+ debug("Saving {} configuration files to {}".format(options[str(save_choice.value)], dest_path.absolute()))
+
+ match options[str(save_choice.value)]:
+ case "user_config":
+ config_output.save_user_config(dest_path)
+ case "user_creds":
+ config_output.save_user_creds(dest_path)
+ case "all":
+ config_output.save(dest_path)
+
+ except (KeyboardInterrupt, EOFError):
+ return
diff --git a/archinstall/lib/disk/__init__.py b/archinstall/lib/disk/__init__.py
index 352d04b9..7f881273 100644
--- a/archinstall/lib/disk/__init__.py
+++ b/archinstall/lib/disk/__init__.py
@@ -1,7 +1,48 @@
-from .btrfs import *
-from .helpers import *
-from .blockdevice import BlockDevice
-from .filesystem import Filesystem, MBR, GPT
-from .partition import *
-from .user_guides import *
-from .validators import * \ No newline at end of file
+from .device_handler import device_handler, disk_layouts
+from .fido import Fido2
+from .filesystem import FilesystemHandler
+from .subvolume_menu import SubvolumeMenu
+from .partitioning_menu import (
+ manual_partitioning,
+ PartitioningList
+)
+from .device_model import (
+ _DeviceInfo,
+ BDevice,
+ DiskLayoutType,
+ DiskLayoutConfiguration,
+ LvmLayoutType,
+ LvmConfiguration,
+ LvmVolumeGroup,
+ LvmVolume,
+ LvmVolumeStatus,
+ PartitionTable,
+ Unit,
+ Size,
+ SectorSize,
+ SubvolumeModification,
+ DeviceGeometry,
+ PartitionType,
+ PartitionFlag,
+ FilesystemType,
+ ModificationStatus,
+ PartitionModification,
+ DeviceModification,
+ EncryptionType,
+ DiskEncryption,
+ Fido2Device,
+ LsblkInfo,
+ CleanType,
+ get_lsblk_info,
+ get_all_lsblk_info,
+ get_lsblk_by_mountpoint,
+)
+from .encryption_menu import (
+ select_encryption_type,
+ select_encrypted_password,
+ select_hsm,
+ select_partitions_to_encrypt,
+ DiskEncryptionMenu,
+)
+
+from .disk_menu import DiskLayoutConfigurationMenu
diff --git a/archinstall/lib/disk/blockdevice.py b/archinstall/lib/disk/blockdevice.py
deleted file mode 100644
index 178b786a..00000000
--- a/archinstall/lib/disk/blockdevice.py
+++ /dev/null
@@ -1,301 +0,0 @@
-from __future__ import annotations
-import json
-import logging
-import time
-
-from collections import OrderedDict
-from dataclasses import dataclass
-from typing import Optional, Dict, Any, Iterator, List, TYPE_CHECKING
-
-from ..exceptions import DiskError, SysCallError
-from ..output import log
-from ..general import SysCommand
-from ..storage import storage
-
-
-if TYPE_CHECKING:
- from .partition import Partition
- _: Any
-
-
-@dataclass
-class BlockSizeInfo:
- start: str
- end: str
- size: str
-
-
-@dataclass
-class BlockInfo:
- pttype: str
- ptuuid: str
- size: int
- tran: Optional[str]
- rota: bool
- free_space: Optional[List[BlockSizeInfo]]
-
-
-class BlockDevice:
- def __init__(self, path :str, info :Optional[Dict[str, Any]] = None):
- if not info:
- from .helpers import all_blockdevices
- # If we don't give any information, we need to auto-fill it.
- # Otherwise any subsequent usage will break.
- self.info = all_blockdevices(partitions=False)[path].info
- else:
- self.info = info
-
- self._path = path
- self.keep_partitions = True
- self._block_info = self._fetch_information()
- self._partitions: Dict[str, 'Partition'] = {}
-
- self._load_partitions()
-
- # TODO: Currently disk encryption is a BIT misleading.
- # It's actually partition-encryption, but for future-proofing this
- # I'm placing the encryption password on a BlockDevice level.
-
- def __repr__(self, *args :str, **kwargs :str) -> str:
- return self._str_repr
-
- @property
- def path(self) -> str:
- return self._path
-
- @property
- def _str_repr(self) -> str:
- return f"BlockDevice({self._device_or_backfile}, size={self.size}GB, free_space={self._safe_free_space()}, bus_type={self.bus_type})"
-
- def as_json(self) -> Dict[str, Any]:
- return {
- str(_('Device')): self._device_or_backfile,
- str(_('Size')): f'{self.size}GB',
- str(_('Free space')): f'{self._safe_free_space()}',
- str(_('Bus-type')): f'{self.bus_type}'
- }
-
- def __iter__(self) -> Iterator['Partition']:
- for partition in self.partitions:
- yield self.partitions[partition]
-
- def __getitem__(self, key :str, *args :str, **kwargs :str) -> Any:
- if hasattr(self, key):
- return getattr(self, key)
-
- if self.info and key in self.info:
- return self.info[key]
-
- raise KeyError(f'{self.info} does not contain information: "{key}"')
-
- def __lt__(self, left_comparitor :'BlockDevice') -> bool:
- return self._path < left_comparitor.path
-
- def json(self) -> str:
- """
- json() has precedence over __dump__, so this is a way
- to give less/partial information for user readability.
- """
- return self._path
-
- def __dump__(self) -> Dict[str, Dict[str, Any]]:
- return {
- self._path: {
- 'partuuid': self.uuid,
- 'wipe': self.info.get('wipe', None),
- 'partitions': [part.__dump__() for part in self.partitions.values()]
- }
- }
-
- def _call_lsblk(self, path: str) -> Dict[str, Any]:
- output = SysCommand(f'lsblk --json -b -o+SIZE,PTTYPE,ROTA,TRAN,PTUUID {self._path}').decode('UTF-8')
- if output:
- lsblk_info = json.loads(output)
- return lsblk_info
-
- raise DiskError(f'Failed to read disk "{self.path}" with lsblk')
-
- def _load_partitions(self):
- from .partition import Partition
-
- self._partitions.clear()
-
- lsblk_info = self._call_lsblk(self._path)
- device = lsblk_info['blockdevices'][0]
- self._partitions.clear()
-
- if children := device.get('children', None):
- root = f'/dev/{device["name"]}'
- for child in children:
- part_id = child['name'].removeprefix(device['name'])
- self._partitions[part_id] = Partition(root + part_id, block_device=self, part_id=part_id)
-
- def _get_free_space(self) -> Optional[List[BlockSizeInfo]]:
- # NOTE: parted -s will default to `cancel` on prompt, skipping any partition
- # that is "outside" the disk. in /dev/sr0 this is usually the case with Archiso,
- # so the free will ignore the ESP partition and just give the "free" space.
- # Doesn't harm us, but worth noting in case something weird happens.
- try:
- output = SysCommand(f"parted -s --machine {self._path} print free").decode('utf-8')
- if output:
- free_lines = [line for line in output.split('\n') if 'free' in line]
- sizes = []
- for free_space in free_lines:
- _, start, end, size, *_ = free_space.strip('\r\n;').split(':')
- sizes.append(BlockSizeInfo(start, end, size))
-
- return sizes
- except SysCallError as error:
- log(f"Could not get free space on {self._path}: {error}", level=logging.DEBUG)
-
- return None
-
- def _fetch_information(self) -> BlockInfo:
- lsblk_info = self._call_lsblk(self._path)
- device = lsblk_info['blockdevices'][0]
- free_space = self._get_free_space()
-
- return BlockInfo(
- pttype=device['pttype'],
- ptuuid=device['ptuuid'],
- size=device['size'],
- tran=device['tran'],
- rota=device['rota'],
- free_space=free_space
- )
-
- @property
- def _device_or_backfile(self) -> Optional[str]:
- """
- Returns the actual device-endpoint of the BlockDevice.
- If it's a loop-back-device it returns the back-file,
- For other types it return self.device
- """
- if self.info.get('type') == 'loop':
- return self.info['back-file']
- else:
- return self.device
-
- @property
- def mountpoint(self) -> None:
- """
- A dummy function to enable transparent comparisons of mountpoints.
- As blockdevices can't be mounted directly, this will always be None
- """
- return None
-
- @property
- def device(self) -> Optional[str]:
- """
- Returns the device file of the BlockDevice.
- If it's a loop-back-device it returns the /dev/X device,
- If it's a ATA-drive it returns the /dev/X device
- And if it's a crypto-device it returns the parent device
- """
- if "DEVTYPE" not in self.info:
- raise DiskError(f'Could not locate backplane info for "{self._path}"')
-
- if self.info['DEVTYPE'] in ['disk','loop']:
- return self._path
- elif self.info['DEVTYPE'][:4] == 'raid':
- # This should catch /dev/md## raid devices
- return self._path
- elif self.info['DEVTYPE'] == 'crypt':
- if 'pkname' not in self.info:
- raise DiskError(f'A crypt device ({self._path}) without a parent kernel device name.')
- return f"/dev/{self.info['pkname']}"
- else:
- log(f"Unknown blockdevice type for {self._path}: {self.info['DEVTYPE']}", level=logging.DEBUG)
-
- return None
-
- @property
- def partition_type(self) -> str:
- return self._block_info.pttype
-
- @property
- def uuid(self) -> str:
- return self._block_info.ptuuid
-
- @property
- def size(self) -> float:
- from .helpers import convert_size_to_gb
- return convert_size_to_gb(self._block_info.size)
-
- @property
- def bus_type(self) -> Optional[str]:
- return self._block_info.tran
-
- @property
- def spinning(self) -> bool:
- return self._block_info.rota
-
- @property
- def partitions(self) -> Dict[str, 'Partition']:
- return OrderedDict(sorted(self._partitions.items()))
-
- @property
- def partition(self) -> List['Partition']:
- return list(self.partitions.values())
-
- @property
- def first_free_sector(self) -> str:
- if block_size := self._largest_free_space():
- return block_size.start
- else:
- return '512MB'
-
- @property
- def first_end_sector(self) -> str:
- if block_size := self._largest_free_space():
- return block_size.end
- else:
- return f"{self.size}GB"
-
- def _safe_free_space(self) -> str:
- if self._block_info.free_space:
- sizes = [free_space.size for free_space in self._block_info.free_space]
- return '+'.join(sizes)
- return '?'
-
- def _largest_free_space(self) -> Optional[BlockSizeInfo]:
- if self._block_info.free_space:
- sorted_sizes = sorted(self._block_info.free_space, key=lambda x: x.size, reverse=True)
- return sorted_sizes[0]
- return None
-
- def _partprobe(self) -> bool:
- return SysCommand(['partprobe', self._path]).exit_code == 0
-
- def flush_cache(self) -> None:
- self._load_partitions()
-
- def get_partition(self, uuid :Optional[str] = None, partuuid :Optional[str] = None) -> Partition:
- if not uuid and not partuuid:
- raise ValueError(f"BlockDevice.get_partition() requires either a UUID or a PARTUUID for lookups.")
-
- log(f"Retrieving partition PARTUUID={partuuid} or UUID={uuid}", level=logging.DEBUG, fg="gray")
-
- for count in range(storage.get('DISK_RETRY_ATTEMPTS', 5)):
- for partition_index, partition in self.partitions.items():
- try:
- if uuid and partition.uuid and partition.uuid.lower() == uuid.lower():
- log(f"Matched UUID={uuid} against {partition.uuid}", level=logging.DEBUG, fg="gray")
- return partition
- elif partuuid and partition.part_uuid and partition.part_uuid.lower() == partuuid.lower():
- log(f"Matched PARTUUID={partuuid} against {partition.part_uuid}", level=logging.DEBUG, fg="gray")
- return partition
- except DiskError as error:
- # Most likely a blockdevice that doesn't support or use UUID's
- # (like Microsoft recovery partition)
- log(f"Could not get UUID/PARTUUID of {partition}: {error}", level=logging.DEBUG, fg="gray")
- pass
-
- log(f"uuid {uuid} or {partuuid} not found. Waiting {storage.get('DISK_TIMEOUTS', 1) * count}s for next attempt",level=logging.DEBUG)
- self.flush_cache()
- time.sleep(storage.get('DISK_TIMEOUTS', 1) * count)
-
- log(f"Could not find {uuid}/{partuuid} in disk after 5 retries", level=logging.INFO)
- log(f"Cache: {self._partitions}")
- log(f"Partitions: {self.partitions.items()}")
- raise DiskError(f"Partition {uuid}/{partuuid} was never found on {self} despite several attempts.")
diff --git a/archinstall/lib/disk/btrfs/__init__.py b/archinstall/lib/disk/btrfs/__init__.py
deleted file mode 100644
index a26e0160..00000000
--- a/archinstall/lib/disk/btrfs/__init__.py
+++ /dev/null
@@ -1,56 +0,0 @@
-from __future__ import annotations
-import pathlib
-import glob
-import logging
-from typing import Union, Dict, TYPE_CHECKING
-
-# https://stackoverflow.com/a/39757388/929999
-if TYPE_CHECKING:
- from ...installer import Installer
-
-from .btrfs_helpers import (
- subvolume_info_from_path as subvolume_info_from_path,
- find_parent_subvolume as find_parent_subvolume,
- setup_subvolumes as setup_subvolumes,
- mount_subvolume as mount_subvolume
-)
-from .btrfssubvolumeinfo import BtrfsSubvolumeInfo as BtrfsSubvolume
-from .btrfspartition import BTRFSPartition as BTRFSPartition
-
-from ...exceptions import DiskError, Deprecated
-from ...general import SysCommand
-from ...output import log
-
-
-def create_subvolume(installation: Installer, subvolume_location :Union[pathlib.Path, str]) -> bool:
- """
- This function uses btrfs to create a subvolume.
-
- @installation: archinstall.Installer instance
- @subvolume_location: a localized string or path inside the installation / or /boot for instance without specifying /mnt/boot
- """
-
- installation_mountpoint = installation.target
- if type(installation_mountpoint) == str:
- installation_mountpoint = pathlib.Path(installation_mountpoint)
- # Set up the required physical structure
- if type(subvolume_location) == str:
- subvolume_location = pathlib.Path(subvolume_location)
-
- target = installation_mountpoint / subvolume_location.relative_to(subvolume_location.anchor)
-
- # Difference from mount_subvolume:
- # We only check if the parent exists, since we'll run in to "target path already exists" otherwise
- if not target.parent.exists():
- target.parent.mkdir(parents=True)
-
- if glob.glob(str(target / '*')):
- raise DiskError(f"Cannot create subvolume at {target} because it contains data (non-empty folder target)")
-
- # Remove the target if it exists
- if target.exists():
- target.rmdir()
-
- log(f"Creating a subvolume on {target}", level=logging.INFO)
- if (cmd := SysCommand(f"btrfs subvolume create {target}")).exit_code != 0:
- raise DiskError(f"Could not create a subvolume at {target}: {cmd}")
diff --git a/archinstall/lib/disk/btrfs/btrfs_helpers.py b/archinstall/lib/disk/btrfs/btrfs_helpers.py
deleted file mode 100644
index f6d2734a..00000000
--- a/archinstall/lib/disk/btrfs/btrfs_helpers.py
+++ /dev/null
@@ -1,136 +0,0 @@
-import logging
-import re
-from pathlib import Path
-from typing import Optional, Dict, Any, TYPE_CHECKING
-
-from ...models.subvolume import Subvolume
-from ...exceptions import SysCallError, DiskError
-from ...general import SysCommand
-from ...output import log
-from ...plugins import plugins
-from ..helpers import get_mount_info
-from .btrfssubvolumeinfo import BtrfsSubvolumeInfo
-
-if TYPE_CHECKING:
- from .btrfspartition import BTRFSPartition
- from ...installer import Installer
-
-
-class fstab_btrfs_compression_plugin():
- def __init__(self, partition_dict):
- self.partition_dict = partition_dict
-
- def on_genfstab(self, installation):
- with open(f"{installation.target}/etc/fstab", 'r') as fh:
- fstab = fh.read()
-
- # Replace the {installation}/etc/fstab with entries
- # using the compress=zstd where the mountpoint has compression set.
- with open(f"{installation.target}/etc/fstab", 'w') as fh:
- for line in fstab.split('\n'):
- # So first we grab the mount options by using subvol=.*? as a locator.
- # And we also grab the mountpoint for the entry, for instance /var/log
- if (subvoldef := re.findall(',.*?subvol=.*?[\t ]', line)) and (mountpoint := re.findall('[\t ]/.*?[\t ]', line)):
- for subvolume in self.partition_dict.get('btrfs', {}).get('subvolumes', []):
- # We then locate the correct subvolume and check if it's compressed
- if subvolume.compress and subvolume.mountpoint == mountpoint[0].strip():
- # We then sneak in the compress=zstd option if it doesn't already exist:
- # We skip entries where compression is already defined
- if ',compress=zstd,' not in line:
- line = line.replace(subvoldef[0], f",compress=zstd{subvoldef[0]}")
- break
-
- fh.write(f"{line}\n")
-
- return True
-
-
-def mount_subvolume(installation: 'Installer', device: 'BTRFSPartition', subvolume: Subvolume):
- # we normalize the subvolume name (getting rid of slash at the start if exists.
- # In our implementation has no semantic load.
- # Every subvolume is created from the top of the hierarchy- and simplifies its further use
- name = subvolume.name.lstrip('/')
- mountpoint = Path(subvolume.mountpoint)
- installation_target = Path(installation.target)
-
- mountpoint = installation_target / mountpoint.relative_to(mountpoint.anchor)
- mountpoint.mkdir(parents=True, exist_ok=True)
- mount_options = subvolume.options + [f'subvol={name}']
-
- log(f"Mounting subvolume {name} on {device} to {mountpoint}", level=logging.INFO, fg="gray")
- SysCommand(f"mount {device.path} {mountpoint} -o {','.join(mount_options)}")
-
-
-def setup_subvolumes(installation: 'Installer', partition_dict: Dict[str, Any]):
- log(f"Setting up subvolumes: {partition_dict['btrfs']['subvolumes']}", level=logging.INFO, fg="gray")
-
- for subvolume in partition_dict['btrfs']['subvolumes']:
- # we normalize the subvolume name (getting rid of slash at the start if exists. In our implementation has no semantic load.
- # Every subvolume is created from the top of the hierarchy- and simplifies its further use
- name = subvolume.name.lstrip('/')
-
- # We create the subvolume using the BTRFSPartition instance.
- # That way we ensure not only easy access, but also accurate mount locations etc.
- partition_dict['device_instance'].create_subvolume(name, installation=installation)
-
- # Make the nodatacow processing now
- # It will be the main cause of creation of subvolumes which are not to be mounted
- # it is not an options which can be established by subvolume (but for whole file systems), and can be
- # set up via a simple attribute change in a directory (if empty). And here the directories are brand new
- if subvolume.nodatacow:
- if (cmd := SysCommand(f"chattr +C {installation.target}/{name}")).exit_code != 0:
- raise DiskError(f"Could not set nodatacow attribute at {installation.target}/{name}: {cmd}")
-
- # Make the compress processing now
- # it is not an options which can be established by subvolume (but for whole file systems), and can be
- # set up via a simple attribute change in a directory (if empty). And here the directories are brand new
- # in this way only zstd compression is activaded
- # TODO WARNING it is not clear if it should be a standard feature, so it might need to be deactivated
-
- if subvolume.compress:
- if not any(['compress' in filesystem_option for filesystem_option in partition_dict.get('filesystem', {}).get('mount_options', [])]):
- if (cmd := SysCommand(f"chattr +c {installation.target}/{name}")).exit_code != 0:
- raise DiskError(f"Could not set compress attribute at {installation.target}/{name}: {cmd}")
-
- if 'fstab_btrfs_compression_plugin' not in plugins:
- plugins['fstab_btrfs_compression_plugin'] = fstab_btrfs_compression_plugin(partition_dict)
-
-
-def subvolume_info_from_path(path: Path) -> Optional[BtrfsSubvolumeInfo]:
- try:
- subvolume_name = ''
- result = {}
- for index, line in enumerate(SysCommand(f"btrfs subvolume show {path}")):
- if index == 0:
- subvolume_name = line.strip().decode('UTF-8')
- continue
-
- if b':' in line:
- key, value = line.strip().decode('UTF-8').split(':', 1)
-
- # A bit of a hack, until I figure out how @dataclass
- # allows for hooking in a pre-processor to do this we have to do it here:
- result[key.lower().replace(' ', '_').replace('(s)', 's')] = value.strip()
-
- return BtrfsSubvolumeInfo(**{'full_path' : path, 'name' : subvolume_name, **result}) # type: ignore
- except SysCallError as error:
- log(f"Could not retrieve subvolume information from {path}: {error}", level=logging.WARNING, fg="orange")
-
- return None
-
-
-def find_parent_subvolume(path: Path, filters=[]) -> Optional[BtrfsSubvolumeInfo]:
- # A root path cannot have a parent
- if str(path) == '/':
- return None
-
- if found_mount := get_mount_info(str(path.parent), traverse=True, ignore=filters):
- if not (subvolume := subvolume_info_from_path(found_mount['target'])):
- if found_mount['target'] == '/':
- return None
-
- return find_parent_subvolume(path.parent, filters=[*filters, found_mount['target']])
-
- return subvolume
-
- return None
diff --git a/archinstall/lib/disk/btrfs/btrfspartition.py b/archinstall/lib/disk/btrfs/btrfspartition.py
deleted file mode 100644
index d04c9b98..00000000
--- a/archinstall/lib/disk/btrfs/btrfspartition.py
+++ /dev/null
@@ -1,109 +0,0 @@
-import glob
-import pathlib
-import logging
-from typing import Optional, TYPE_CHECKING
-
-from ...exceptions import DiskError
-from ...storage import storage
-from ...output import log
-from ...general import SysCommand
-from ..partition import Partition
-from ..helpers import findmnt
-from .btrfs_helpers import (
- subvolume_info_from_path
-)
-
-if TYPE_CHECKING:
- from ...installer import Installer
- from .btrfssubvolumeinfo import BtrfsSubvolumeInfo
-
-
-class BTRFSPartition(Partition):
- def __init__(self, *args, **kwargs):
- Partition.__init__(self, *args, **kwargs)
-
- @property
- def subvolumes(self):
- for filesystem in findmnt(pathlib.Path(self.path), recurse=True).get('filesystems', []):
- if '[' in filesystem.get('source', ''):
- yield subvolume_info_from_path(filesystem['target'])
-
- def iterate_children(struct):
- for c in struct.get('children', []):
- if '[' in child.get('source', ''):
- yield subvolume_info_from_path(c['target'])
-
- for sub_child in iterate_children(c):
- yield sub_child
-
- for child in iterate_children(filesystem):
- yield child
-
- def create_subvolume(self, subvolume :pathlib.Path, installation :Optional['Installer'] = None) -> 'BtrfsSubvolumeInfo':
- """
- Subvolumes have to be created within a mountpoint.
- This means we need to get the current installation target.
- After we get it, we need to verify it is a btrfs subvolume filesystem.
- Finally, the destination must be empty.
- """
-
- # Allow users to override the installation session
- if not installation:
- installation = storage.get('installation_session')
-
- # Determain if the path given, is an absolute path or a relative path.
- # We do this by checking if the path contains a known mountpoint.
- if str(subvolume)[0] == '/':
- if filesystems := findmnt(subvolume, traverse=True).get('filesystems'):
- if (target := filesystems[0].get('target')) and target != '/' and str(subvolume).startswith(target):
- # Path starts with a known mountpoint which isn't /
- # Which means it's an absolute path to a mounted location.
- pass
- else:
- # Since it's not an absolute position with a known start.
- # We omit the anchor ('/' basically) and make sure it's appendable
- # to the installation.target later
- subvolume = subvolume.relative_to(subvolume.anchor)
- # else: We don't need to do anything about relative paths, they should be appendable to installation.target as-is.
-
- # If the subvolume is not absolute, then we do two checks:
- # 1. Check if the partition itself is mounted somewhere, and use that as a root
- # 2. Use an active Installer().target as the root, assuming it's filesystem is btrfs
- # If both above fail, we need to warn the user that such setup is not supported.
- if str(subvolume)[0] != '/':
- if self.mountpoint is None and installation is None:
- raise DiskError("When creating a subvolume on BTRFSPartition()'s, you need to either initiate a archinstall.Installer() or give absolute paths when creating the subvoulme.")
- elif self.mountpoint:
- subvolume = self.mountpoint / subvolume
- elif installation:
- ongoing_installation_destination = installation.target
- if type(ongoing_installation_destination) == str:
- ongoing_installation_destination = pathlib.Path(ongoing_installation_destination)
-
- subvolume = ongoing_installation_destination / subvolume
-
- subvolume.parent.mkdir(parents=True, exist_ok=True)
-
- # <!--
- # We perform one more check from the given absolute position.
- # And we traverse backwards in order to locate any if possible subvolumes above
- # our new btrfs subvolume. This is because it needs to be mounted under it to properly
- # function.
- # if btrfs_parent := find_parent_subvolume(subvolume):
- # print('Found parent:', btrfs_parent)
- # -->
-
- log(f'Attempting to create subvolume at {subvolume}', level=logging.DEBUG, fg="grey")
-
- if glob.glob(str(subvolume / '*')):
- raise DiskError(f"Cannot create subvolume at {subvolume} because it contains data (non-empty folder target is not supported by BTRFS)")
- # Ideally we would like to check if the destination is already a subvolume.
- # But then we would need the mount-point at this stage as well.
- # So we'll comment out this check:
- # elif subvolinfo := subvolume_info_from_path(subvolume):
- # raise DiskError(f"Destination {subvolume} is already a subvolume: {subvolinfo}")
-
- # And deal with it here:
- SysCommand(f"btrfs subvolume create {subvolume}")
-
- return subvolume_info_from_path(subvolume)
diff --git a/archinstall/lib/disk/btrfs/btrfssubvolumeinfo.py b/archinstall/lib/disk/btrfs/btrfssubvolumeinfo.py
deleted file mode 100644
index 5f5bdea6..00000000
--- a/archinstall/lib/disk/btrfs/btrfssubvolumeinfo.py
+++ /dev/null
@@ -1,192 +0,0 @@
-import pathlib
-import datetime
-import logging
-import string
-import random
-import shutil
-from dataclasses import dataclass
-from typing import Optional, List# , TYPE_CHECKING
-from functools import cached_property
-
-# if TYPE_CHECKING:
-# from ..blockdevice import BlockDevice
-
-from ...exceptions import DiskError
-from ...general import SysCommand
-from ...output import log
-from ...storage import storage
-
-
-@dataclass
-class BtrfsSubvolumeInfo:
- full_path :pathlib.Path
- name :str
- uuid :str
- parent_uuid :str
- creation_time :datetime.datetime
- subvolume_id :int
- generation :int
- gen_at_creation :int
- parent_id :int
- top_level_id :int
- send_transid :int
- send_time :datetime.datetime
- receive_transid :int
- received_uuid :Optional[str] = None
- flags :Optional[str] = None
- receive_time :Optional[datetime.datetime] = None
- snapshots :Optional[List] = None
-
- def __post_init__(self):
- self.full_path = pathlib.Path(self.full_path)
-
- # Convert "-" entries to `None`
- if self.parent_uuid == "-":
- self.parent_uuid = None
- if self.received_uuid == "-":
- self.received_uuid = None
- if self.flags == "-":
- self.flags = None
- if self.receive_time == "-":
- self.receive_time = None
- if self.snapshots == "":
- self.snapshots = []
-
- # Convert timestamps into datetime workable objects (and preserve timezone by using ISO formats)
- self.creation_time = datetime.datetime.fromisoformat(self.convert_to_ISO_format(self.creation_time))
- self.send_time = datetime.datetime.fromisoformat(self.convert_to_ISO_format(self.send_time))
- if self.receive_time:
- self.receive_time = datetime.datetime.fromisoformat(self.convert_to_ISO_format(self.receive_time))
-
- @property
- def parent_subvolume(self):
- from .btrfs_helpers import find_parent_subvolume
-
- return find_parent_subvolume(self.full_path)
-
- @property
- def root(self) -> bool:
- from .btrfs_helpers import subvolume_info_from_path
-
- # TODO: Make this function traverse storage['MOUNT_POINT'] and find the first
- # occurrence of a mountpoint that is a btrfs volume instead of lazy assume / is a subvolume.
- # It would also be nice if it could use findmnt(self.full_path) and traverse backwards
- # finding the last occurrence of a subvolume which 'self' belongs to.
- if volume := subvolume_info_from_path(storage['MOUNT_POINT']):
- return self.full_path == volume.full_path
-
- return False
-
- @cached_property
- def partition(self):
- from ..helpers import findmnt, get_parent_of_partition, all_blockdevices
- from ..partition import Partition
- from ..blockdevice import BlockDevice
- from ..mapperdev import MapperDev
- from .btrfspartition import BTRFSPartition
- from .btrfs_helpers import subvolume_info_from_path
-
- try:
- # If the subvolume is mounted, it's pretty trivial to lookup the partition (parent) device.
- if filesystem := findmnt(self.full_path).get('filesystems', []):
- if source := filesystem[0].get('source', None):
- # Strip away subvolume definitions from findmnt
- if '[' in source:
- source = source[:source.find('[')]
-
- if filesystem[0].get('fstype', '') == 'btrfs':
- return BTRFSPartition(source, BlockDevice(get_parent_of_partition(pathlib.Path(source))))
- elif filesystem[0].get('source', '').startswith('/dev/mapper'):
- return MapperDev(source)
- else:
- return Partition(source, BlockDevice(get_parent_of_partition(pathlib.Path(source))))
- except DiskError:
- # Subvolume has never been mounted, we have no reliable way of finding where it is.
- # But we have the UUID of the partition, and can begin looking for it by mounting
- # all blockdevices that we can reliably support.. This is taxing tho and won't cover all devices.
-
- log(f"Looking up {self}, this might take time.", fg="orange", level=logging.WARNING)
- for blockdevice, instance in all_blockdevices(mappers=True, partitions=True, error=True).items():
- if type(instance) in (Partition, MapperDev):
- we_mounted_it = False
- detection_mountpoint = instance.mountpoint
- if not detection_mountpoint:
- if type(instance) == Partition and instance.encrypted:
- # TODO: Perhaps support unlocking encrypted volumes?
- # This will cause a lot of potential user interactions tho.
- log(f"Ignoring {blockdevice} because it's encrypted.", fg="gray", level=logging.DEBUG)
- continue
-
- detection_mountpoint = pathlib.Path(f"/tmp/{''.join([random.choice(string.ascii_letters) for x in range(20)])}")
- detection_mountpoint.mkdir(parents=True, exist_ok=True)
-
- instance.mount(str(detection_mountpoint))
- we_mounted_it = True
-
- if (filesystem := findmnt(detection_mountpoint)) and (filesystem := filesystem.get('filesystems', [])):
- if subvolume := subvolume_info_from_path(filesystem[0]['target']):
- if subvolume.uuid == self.uuid:
- # The top level subvolume matched of ourselves,
- # which means the instance we're iterating has the subvol we're looking for.
- log(f"Found the subvolume on device {instance}", level=logging.DEBUG, fg="gray")
- return instance
-
- def iterate_children(struct):
- for child in struct.get('children', []):
- if '[' in child.get('source', ''):
- yield subvolume_info_from_path(child['target'])
-
- for sub_child in iterate_children(child):
- yield sub_child
-
- for child in iterate_children(filesystem[0]):
- if child.uuid == self.uuid:
- # We found a child within the instance that has the subvol we're looking for.
- log(f"Found the subvolume on device {instance}", level=logging.DEBUG, fg="gray")
- return instance
-
- if we_mounted_it:
- instance.unmount()
- shutil.rmtree(detection_mountpoint)
-
- @cached_property
- def mount_options(self) -> Optional[List[str]]:
- from ..helpers import findmnt
-
- if filesystem := findmnt(self.full_path).get('filesystems', []):
- return filesystem[0].get('options').split(',')
-
- def convert_to_ISO_format(self, time_string):
- time_string_almost_done = time_string.replace(' ', 'T', 1).replace(' ', '')
- iso_string = f"{time_string_almost_done[:-2]}:{time_string_almost_done[-2:]}"
- return iso_string
-
- def mount(self, mountpoint :pathlib.Path, options=None, include_previously_known_options=True):
- from ..helpers import findmnt
-
- try:
- if mnt_info := findmnt(pathlib.Path(mountpoint), traverse=False):
- log(f"Unmounting {mountpoint} as it was already mounted using {mnt_info}")
- SysCommand(f"umount {mountpoint}")
- except DiskError:
- # No previously mounted device at the mountpoint
- pass
-
- if not options:
- options = []
-
- try:
- if include_previously_known_options and (cached_options := self.mount_options):
- options += cached_options
- except DiskError:
- pass
-
- if not any('subvol=' in x for x in options):
- options += f'subvol={self.name}'
-
- SysCommand(f"mount {self.partition.path} {mountpoint} -o {','.join(options)}")
- log(f"{self} has successfully been mounted to {mountpoint}", level=logging.INFO, fg="gray")
-
- def unmount(self, recurse :bool = True):
- SysCommand(f"umount {'-R' if recurse else ''} {self.full_path}")
- log(f"Successfully unmounted {self}", level=logging.INFO, fg="gray")
diff --git a/archinstall/lib/disk/device_handler.py b/archinstall/lib/disk/device_handler.py
new file mode 100644
index 00000000..7ba70382
--- /dev/null
+++ b/archinstall/lib/disk/device_handler.py
@@ -0,0 +1,809 @@
+from __future__ import annotations
+
+import json
+import os
+import logging
+import time
+from pathlib import Path
+from typing import List, Dict, Any, Optional, TYPE_CHECKING, Literal, Iterable
+
+from parted import ( # type: ignore
+ Disk, Geometry, FileSystem,
+ PartitionException, DiskLabelException,
+ getDevice, getAllDevices, freshDisk, Partition, Device
+)
+
+from .device_model import (
+ DeviceModification, PartitionModification,
+ BDevice, _DeviceInfo, _PartitionInfo,
+ FilesystemType, Unit, PartitionTable,
+ ModificationStatus, get_lsblk_info, LsblkInfo,
+ _BtrfsSubvolumeInfo, get_all_lsblk_info, DiskEncryption, LvmVolumeGroup, LvmVolume, Size, LvmGroupInfo,
+ SectorSize, LvmVolumeInfo, LvmPVInfo, SubvolumeModification, BtrfsMountOption
+)
+
+from ..exceptions import DiskError, UnknownFilesystemFormat
+from ..general import SysCommand, SysCallError, JSON, SysCommandWorker
+from ..luks import Luks2
+from ..output import debug, error, info, warn, log
+from ..utils.util import is_subpath
+
+if TYPE_CHECKING:
+ _: Any
+
+
+class DeviceHandler(object):
+ _TMP_BTRFS_MOUNT = Path('/mnt/arch_btrfs')
+
+ def __init__(self):
+ self._devices: Dict[Path, BDevice] = {}
+ self.load_devices()
+
+ @property
+ def devices(self) -> List[BDevice]:
+ return list(self._devices.values())
+
+ def load_devices(self):
+ block_devices = {}
+
+ devices = getAllDevices()
+
+ try:
+ loop_devices = SysCommand(['losetup', '-a'])
+ for ld_info in str(loop_devices).splitlines():
+ loop_device = getDevice(ld_info.split(':', maxsplit=1)[0])
+ devices.append(loop_device)
+ except Exception as err:
+ debug(f'Failed to get loop devices: {err}')
+
+ for device in devices:
+ if get_lsblk_info(device.path).type == 'rom':
+ continue
+
+ try:
+ disk = Disk(device)
+ except DiskLabelException as err:
+ if 'unrecognised disk label' in getattr(error, 'message', str(err)):
+ disk = freshDisk(device, PartitionTable.GPT.value)
+ else:
+ debug(f'Unable to get disk from device: {device}')
+ continue
+
+ device_info = _DeviceInfo.from_disk(disk)
+ partition_infos = []
+
+ for partition in disk.partitions:
+ lsblk_info = get_lsblk_info(partition.path)
+ fs_type = self._determine_fs_type(partition, lsblk_info)
+ subvol_infos = []
+
+ if fs_type == FilesystemType.Btrfs:
+ subvol_infos = self.get_btrfs_info(partition.path)
+
+ partition_infos.append(
+ _PartitionInfo.from_partition(
+ partition,
+ fs_type,
+ lsblk_info.partn,
+ lsblk_info.partuuid,
+ lsblk_info.uuid,
+ lsblk_info.mountpoints,
+ subvol_infos
+ )
+ )
+
+ block_device = BDevice(disk, device_info, partition_infos)
+ block_devices[block_device.device_info.path] = block_device
+
+ self._devices = block_devices
+
+ def _determine_fs_type(
+ self,
+ partition: Partition,
+ lsblk_info: Optional[LsblkInfo] = None
+ ) -> Optional[FilesystemType]:
+ try:
+ if partition.fileSystem:
+ return FilesystemType(partition.fileSystem.type)
+ elif lsblk_info is not None:
+ return FilesystemType(lsblk_info.fstype) if lsblk_info.fstype else None
+ return None
+ except ValueError:
+ debug(f'Could not determine the filesystem: {partition.fileSystem}')
+
+ return None
+
+ def get_device(self, path: Path) -> Optional[BDevice]:
+ return self._devices.get(path, None)
+
+ def get_device_by_partition_path(self, partition_path: Path) -> Optional[BDevice]:
+ partition = self.find_partition(partition_path)
+ if partition:
+ device: Device = partition.disk.device
+ return self.get_device(Path(device.path))
+ return None
+
+ def find_partition(self, path: Path) -> Optional[_PartitionInfo]:
+ for device in self._devices.values():
+ part = next(filter(lambda x: str(x.path) == str(path), device.partition_infos), None)
+ if part is not None:
+ return part
+ return None
+
+ def get_parent_device_path(self, dev_path: Path) -> Path:
+ lsblk = get_lsblk_info(dev_path)
+ return Path(f'/dev/{lsblk.pkname}')
+
+ def get_unique_path_for_device(self, dev_path: Path) -> Optional[Path]:
+ paths = Path('/dev/disk/by-id').glob('*')
+ linked_targets = {p.resolve(): p for p in paths}
+ linked_wwn_targets = {p: linked_targets[p] for p in linked_targets
+ if p.name.startswith('wwn-') or p.name.startswith('nvme-eui.')}
+
+ if dev_path in linked_wwn_targets:
+ return linked_wwn_targets[dev_path]
+
+ if dev_path in linked_targets:
+ return linked_targets[dev_path]
+
+ return None
+
+ def get_uuid_for_path(self, path: Path) -> Optional[str]:
+ partition = self.find_partition(path)
+ return partition.partuuid if partition else None
+
+ def get_btrfs_info(self, dev_path: Path) -> List[_BtrfsSubvolumeInfo]:
+ lsblk_info = get_lsblk_info(dev_path)
+ subvol_infos: List[_BtrfsSubvolumeInfo] = []
+
+ if not lsblk_info.mountpoint:
+ self.mount(dev_path, self._TMP_BTRFS_MOUNT, create_target_mountpoint=True)
+ mountpoint = self._TMP_BTRFS_MOUNT
+ else:
+ # when multiple subvolumes are mounted then the lsblk output may look like
+ # "mountpoint": "/mnt/archinstall/.snapshots"
+ # "mountpoints": ["/mnt/archinstall/.snapshots", "/mnt/archinstall/home", ..]
+ # so we'll determine the minimum common path and assume that's the root
+ path_strings = [str(m) for m in lsblk_info.mountpoints]
+ common_prefix = os.path.commonprefix(path_strings)
+ mountpoint = Path(common_prefix)
+
+ try:
+ result = SysCommand(f'btrfs subvolume list {mountpoint}').decode()
+ except SysCallError as err:
+ debug(f'Failed to read btrfs subvolume information: {err}')
+ return subvol_infos
+
+ try:
+ # ID 256 gen 16 top level 5 path @
+ for line in result.splitlines():
+ # expected output format:
+ # ID 257 gen 8 top level 5 path @home
+ name = Path(line.split(' ')[-1])
+ sub_vol_mountpoint = lsblk_info.btrfs_subvol_info.get(name, None)
+ subvol_infos.append(_BtrfsSubvolumeInfo(name, sub_vol_mountpoint))
+ except json.decoder.JSONDecodeError as err:
+ error(f"Could not decode lsblk JSON: {result}")
+ raise err
+
+ if not lsblk_info.mountpoint:
+ self.umount(dev_path)
+
+ return subvol_infos
+
+ def format(
+ self,
+ fs_type: FilesystemType,
+ path: Path,
+ additional_parted_options: List[str] = []
+ ):
+ options = []
+ command = ''
+
+ match fs_type:
+ case FilesystemType.Btrfs:
+ options += ['-f']
+ command += 'mkfs.btrfs'
+ case FilesystemType.Fat16:
+ options += ['-F16']
+ command += 'mkfs.fat'
+ case FilesystemType.Fat32:
+ options += ['-F32']
+ command += 'mkfs.fat'
+ case FilesystemType.Ext2:
+ options += ['-F']
+ command += 'mkfs.ext2'
+ case FilesystemType.Ext3:
+ options += ['-F']
+ command += 'mkfs.ext3'
+ case FilesystemType.Ext4:
+ options += ['-F']
+ command += 'mkfs.ext4'
+ case FilesystemType.Xfs:
+ options += ['-f']
+ command += 'mkfs.xfs'
+ case FilesystemType.F2fs:
+ options += ['-f']
+ command += 'mkfs.f2fs'
+ case FilesystemType.Ntfs:
+ options += ['-f', '-Q']
+ command += 'mkfs.ntfs'
+ case FilesystemType.Reiserfs:
+ command += 'mkfs.reiserfs'
+ case _:
+ raise UnknownFilesystemFormat(f'Filetype "{fs_type.value}" is not supported')
+
+ options += additional_parted_options
+ options_str = ' '.join(options)
+
+ debug(f'Formatting filesystem: /usr/bin/{command} {options_str} {path}')
+
+ try:
+ SysCommand(f"/usr/bin/{command} {options_str} {path}")
+ except SysCallError as err:
+ msg = f'Could not format {path} with {fs_type.value}: {err.message}'
+ error(msg)
+ raise DiskError(msg) from err
+
+ def encrypt(
+ self,
+ dev_path: Path,
+ mapper_name: Optional[str],
+ enc_password: str,
+ lock_after_create: bool = True
+ ) -> Luks2:
+ luks_handler = Luks2(
+ dev_path,
+ mapper_name=mapper_name,
+ password=enc_password
+ )
+
+ key_file = luks_handler.encrypt()
+
+ luks_handler.unlock(key_file=key_file)
+
+ if not luks_handler.mapper_dev:
+ raise DiskError('Failed to unlock luks device')
+
+ if lock_after_create:
+ debug(f'luks2 locking device: {dev_path}')
+ luks_handler.lock()
+
+ return luks_handler
+
+ def format_encrypted(
+ self,
+ dev_path: Path,
+ mapper_name: Optional[str],
+ fs_type: FilesystemType,
+ enc_conf: DiskEncryption
+ ):
+ luks_handler = Luks2(
+ dev_path,
+ mapper_name=mapper_name,
+ password=enc_conf.encryption_password
+ )
+
+ key_file = luks_handler.encrypt()
+
+ luks_handler.unlock(key_file=key_file)
+
+ if not luks_handler.mapper_dev:
+ raise DiskError('Failed to unlock luks device')
+
+ info(f'luks2 formatting mapper dev: {luks_handler.mapper_dev}')
+ self.format(fs_type, luks_handler.mapper_dev)
+
+ info(f'luks2 locking device: {dev_path}')
+ luks_handler.lock()
+
+ def _lvm_info(
+ self,
+ cmd: str,
+ info_type: Literal['lv', 'vg', 'pvseg']
+ ) -> Optional[Any]:
+ raw_info = SysCommand(cmd).decode().split('\n')
+
+ # for whatever reason the output sometimes contains
+ # "File descriptor X leaked leaked on vgs invocation
+ data = '\n'.join([raw for raw in raw_info if 'File descriptor' not in raw])
+
+ debug(f'LVM info: {data}')
+
+ reports = json.loads(data)
+
+ for report in reports['report']:
+ if len(report[info_type]) != 1:
+ raise ValueError(f'Report does not contain any entry')
+
+ entry = report[info_type][0]
+
+ match info_type:
+ case 'pvseg':
+ return LvmPVInfo(
+ pv_name=Path(entry['pv_name']),
+ lv_name=entry['lv_name'],
+ vg_name=entry['vg_name'],
+ )
+ case 'lv':
+ return LvmVolumeInfo(
+ lv_name=entry['lv_name'],
+ vg_name=entry['vg_name'],
+ lv_size=Size(int(entry[f'lv_size'][:-1]), Unit.B, SectorSize.default())
+ )
+ case 'vg':
+ return LvmGroupInfo(
+ vg_uuid=entry['vg_uuid'],
+ vg_size=Size(int(entry[f'vg_size'][:-1]), Unit.B, SectorSize.default())
+ )
+
+ return None
+
+ def _lvm_info_with_retry(self, cmd: str, info_type: Literal['lv', 'vg', 'pvseg']) -> Optional[Any]:
+ attempts = 3
+
+ for attempt_nr in range(attempts):
+ try:
+ return self._lvm_info(cmd, info_type)
+ except ValueError:
+ time.sleep(attempt_nr + 1)
+
+ raise ValueError(f'Failed to fetch {info_type} information')
+
+ def lvm_vol_info(self, lv_name: str) -> Optional[LvmVolumeInfo]:
+ cmd = (
+ 'lvs --reportformat json '
+ '--unit B '
+ f'-S lv_name={lv_name}'
+ )
+
+ return self._lvm_info_with_retry(cmd, 'lv')
+
+ def lvm_group_info(self, vg_name: str) -> Optional[LvmGroupInfo]:
+ cmd = (
+ 'vgs --reportformat json '
+ '--unit B '
+ '-o vg_name,vg_uuid,vg_size '
+ f'-S vg_name={vg_name}'
+ )
+
+ return self._lvm_info_with_retry(cmd, 'vg')
+
+ def lvm_pvseg_info(self, vg_name: str, lv_name: str) -> Optional[LvmPVInfo]:
+ cmd = (
+ 'pvs '
+ '--segments -o+lv_name,vg_name '
+ f'-S vg_name={vg_name},lv_name={lv_name} '
+ '--reportformat json '
+ )
+
+ return self._lvm_info_with_retry(cmd, 'pvseg')
+
+ def lvm_vol_change(self, vol: LvmVolume, activate: bool):
+ active_flag = 'y' if activate else 'n'
+ cmd = f'lvchange -a {active_flag} {vol.safe_dev_path}'
+
+ debug(f'lvchange volume: {cmd}')
+ SysCommand(cmd)
+
+ def lvm_export_vg(self, vg: LvmVolumeGroup):
+ cmd = f'vgexport {vg.name}'
+
+ debug(f'vgexport: {cmd}')
+ SysCommand(cmd)
+
+ def lvm_import_vg(self, vg: LvmVolumeGroup):
+ cmd = f'vgimport {vg.name}'
+
+ debug(f'vgimport: {cmd}')
+ SysCommand(cmd)
+
+ def lvm_vol_reduce(self, vol_path: Path, amount: Size):
+ val = amount.format_size(Unit.B, include_unit=False)
+ cmd = f'lvreduce -L -{val}B {vol_path}'
+
+ debug(f'Reducing LVM volume size: {cmd}')
+ SysCommand(cmd)
+
+ def lvm_pv_create(self, pvs: Iterable[Path]):
+ cmd = 'pvcreate ' + ' '.join([str(pv) for pv in pvs])
+ debug(f'Creating LVM PVS: {cmd}')
+
+ worker = SysCommandWorker(cmd)
+ worker.poll()
+ worker.write(b'y\n', line_ending=False)
+
+ def lvm_vg_create(self, pvs: Iterable[Path], vg_name: str):
+ pvs_str = ' '.join([str(pv) for pv in pvs])
+ cmd = f'vgcreate --yes {vg_name} {pvs_str}'
+
+ debug(f'Creating LVM group: {cmd}')
+
+ worker = SysCommandWorker(cmd)
+ worker.poll()
+ worker.write(b'y\n', line_ending=False)
+
+ def lvm_vol_create(self, vg_name: str, volume: LvmVolume, offset: Optional[Size] = None):
+ if offset is not None:
+ length = volume.length - offset
+ else:
+ length = volume.length
+
+ length_str = length.format_size(Unit.B, include_unit=False)
+ cmd = f'lvcreate --yes -L {length_str}B {vg_name} -n {volume.name}'
+
+ debug(f'Creating volume: {cmd}')
+
+ worker = SysCommandWorker(cmd)
+ worker.poll()
+ worker.write(b'y\n', line_ending=False)
+
+ volume.vg_name = vg_name
+ volume.dev_path = Path(f'/dev/{vg_name}/{volume.name}')
+
+ def _setup_partition(
+ self,
+ part_mod: PartitionModification,
+ block_device: BDevice,
+ disk: Disk,
+ requires_delete: bool
+ ):
+ # when we require a delete and the partition to be (re)created
+ # already exists then we have to delete it first
+ if requires_delete and part_mod.status in [ModificationStatus.Modify, ModificationStatus.Delete]:
+ info(f'Delete existing partition: {part_mod.safe_dev_path}')
+ part_info = self.find_partition(part_mod.safe_dev_path)
+
+ if not part_info:
+ raise DiskError(f'No partition for dev path found: {part_mod.safe_dev_path}')
+
+ disk.deletePartition(part_info.partition)
+
+ if part_mod.status == ModificationStatus.Delete:
+ return
+
+ start_sector = part_mod.start.convert(
+ Unit.sectors,
+ block_device.device_info.sector_size
+ )
+
+ length_sector = part_mod.length.convert(
+ Unit.sectors,
+ block_device.device_info.sector_size
+ )
+
+ geometry = Geometry(
+ device=block_device.disk.device,
+ start=start_sector.value,
+ length=length_sector.value
+ )
+
+ filesystem = FileSystem(type=part_mod.safe_fs_type.value, geometry=geometry)
+
+ partition = Partition(
+ disk=disk,
+ type=part_mod.type.get_partition_code(),
+ fs=filesystem,
+ geometry=geometry
+ )
+
+ for flag in part_mod.flags:
+ partition.setFlag(flag.value)
+
+ debug(f'\tType: {part_mod.type.value}')
+ debug(f'\tFilesystem: {part_mod.safe_fs_type.value}')
+ debug(f'\tGeometry: {start_sector.value} start sector, {length_sector.value} length')
+
+ try:
+ disk.addPartition(partition=partition, constraint=disk.device.optimalAlignedConstraint)
+ except PartitionException as ex:
+ raise DiskError(f'Unable to add partition, most likely due to overlapping sectors: {ex}') from ex
+
+ # the partition has a path now that it has been added
+ part_mod.dev_path = Path(partition.path)
+
+ def fetch_part_info(self, path: Path) -> LsblkInfo:
+ lsblk_info = get_lsblk_info(path)
+
+ if not lsblk_info.partn:
+ debug(f'Unable to determine new partition number: {path}\n{lsblk_info}')
+ raise DiskError(f'Unable to determine new partition number: {path}')
+
+ if not lsblk_info.partuuid:
+ debug(f'Unable to determine new partition uuid: {path}\n{lsblk_info}')
+ raise DiskError(f'Unable to determine new partition uuid: {path}')
+
+ if not lsblk_info.uuid:
+ debug(f'Unable to determine new uuid: {path}\n{lsblk_info}')
+ raise DiskError(f'Unable to determine new uuid: {path}')
+
+ debug(f'partition information found: {lsblk_info.json()}')
+
+ return lsblk_info
+
+ def create_lvm_btrfs_subvolumes(
+ self,
+ path: Path,
+ btrfs_subvols: List[SubvolumeModification],
+ mount_options: List[str]
+ ):
+ info(f'Creating subvolumes: {path}')
+
+ self.mount(path, self._TMP_BTRFS_MOUNT, create_target_mountpoint=True)
+
+ for sub_vol in btrfs_subvols:
+ debug(f'Creating subvolume: {sub_vol.name}')
+
+ subvol_path = self._TMP_BTRFS_MOUNT / sub_vol.name
+
+ SysCommand(f"btrfs subvolume create {subvol_path}")
+
+ if BtrfsMountOption.nodatacow.value in mount_options:
+ try:
+ SysCommand(f'chattr +C {subvol_path}')
+ except SysCallError as err:
+ raise DiskError(f'Could not set nodatacow attribute at {subvol_path}: {err}')
+
+ if BtrfsMountOption.compress.value in mount_options:
+ try:
+ SysCommand(f'chattr +c {subvol_path}')
+ except SysCallError as err:
+ raise DiskError(f'Could not set compress attribute at {subvol_path}: {err}')
+
+ self.umount(path)
+
+ def create_btrfs_volumes(
+ self,
+ part_mod: PartitionModification,
+ enc_conf: Optional['DiskEncryption'] = None
+ ):
+ info(f'Creating subvolumes: {part_mod.safe_dev_path}')
+
+ luks_handler = None
+
+ # unlock the partition first if it's encrypted
+ if enc_conf is not None and part_mod in enc_conf.partitions:
+ if not part_mod.mapper_name:
+ raise ValueError('No device path specified for modification')
+
+ luks_handler = self.unlock_luks2_dev(
+ part_mod.safe_dev_path,
+ part_mod.mapper_name,
+ enc_conf.encryption_password
+ )
+
+ if not luks_handler.mapper_dev:
+ raise DiskError('Failed to unlock luks device')
+
+ self.mount(
+ luks_handler.mapper_dev,
+ self._TMP_BTRFS_MOUNT,
+ create_target_mountpoint=True,
+ options=part_mod.mount_options
+ )
+ else:
+ self.mount(
+ part_mod.safe_dev_path,
+ self._TMP_BTRFS_MOUNT,
+ create_target_mountpoint=True,
+ options=part_mod.mount_options
+ )
+
+ for sub_vol in part_mod.btrfs_subvols:
+ debug(f'Creating subvolume: {sub_vol.name}')
+
+ if luks_handler is not None:
+ subvol_path = self._TMP_BTRFS_MOUNT / sub_vol.name
+ else:
+ subvol_path = self._TMP_BTRFS_MOUNT / sub_vol.name
+
+ SysCommand(f"btrfs subvolume create {subvol_path}")
+
+ if luks_handler is not None and luks_handler.mapper_dev is not None:
+ self.umount(luks_handler.mapper_dev)
+ luks_handler.lock()
+ else:
+ self.umount(part_mod.safe_dev_path)
+
+ def unlock_luks2_dev(self, dev_path: Path, mapper_name: str, enc_password: str) -> Luks2:
+ luks_handler = Luks2(dev_path, mapper_name=mapper_name, password=enc_password)
+
+ if not luks_handler.is_unlocked():
+ luks_handler.unlock()
+
+ if not luks_handler.is_unlocked():
+ raise DiskError(f'Failed to unlock luks2 device: {dev_path}')
+
+ return luks_handler
+
+ def umount_all_existing(self, device_path: Path):
+ debug(f'Unmounting all existing partitions: {device_path}')
+
+ existing_partitions = self._devices[device_path].partition_infos
+
+ for partition in existing_partitions:
+ debug(f'Unmounting: {partition.path}')
+
+ # un-mount for existing encrypted partitions
+ if partition.fs_type == FilesystemType.Crypto_luks:
+ Luks2(partition.path).lock()
+ else:
+ self.umount(partition.path, recursive=True)
+
+ def partition(
+ self,
+ modification: DeviceModification,
+ partition_table: Optional[PartitionTable] = None
+ ):
+ """
+ Create a partition table on the block device and create all partitions.
+ """
+ if modification.wipe:
+ if partition_table is None:
+ raise ValueError('Modification is marked as wipe but no partitioning table was provided')
+
+ if partition_table.MBR and len(modification.partitions) > 3:
+ raise DiskError('Too many partitions on disk, MBR disks can only have 3 primary partitions')
+
+ # make sure all devices are unmounted
+ self.umount_all_existing(modification.device_path)
+
+ # WARNING: the entire device will be wiped and all data lost
+ if modification.wipe:
+ self.wipe_dev(modification.device)
+ part_table = partition_table.value if partition_table else None
+ disk = freshDisk(modification.device.disk.device, part_table)
+ else:
+ info(f'Use existing device: {modification.device_path}')
+ disk = modification.device.disk
+
+ info(f'Creating partitions: {modification.device_path}')
+
+ # don't touch existing partitions
+ filtered_part = [p for p in modification.partitions if not p.exists()]
+
+ for part_mod in filtered_part:
+ # if the entire disk got nuked then we don't have to delete
+ # any existing partitions anymore because they're all gone already
+ requires_delete = modification.wipe is False
+ self._setup_partition(part_mod, modification.device, disk, requires_delete=requires_delete)
+
+ disk.commit()
+
+ def mount(
+ self,
+ dev_path: Path,
+ target_mountpoint: Path,
+ mount_fs: Optional[str] = None,
+ create_target_mountpoint: bool = True,
+ options: List[str] = []
+ ):
+ if create_target_mountpoint and not target_mountpoint.exists():
+ target_mountpoint.mkdir(parents=True, exist_ok=True)
+
+ if not target_mountpoint.exists():
+ raise ValueError('Target mountpoint does not exist')
+
+ lsblk_info = get_lsblk_info(dev_path)
+ if target_mountpoint in lsblk_info.mountpoints:
+ info(f'Device already mounted at {target_mountpoint}')
+ return
+
+ cmd = ['mount']
+
+ if len(options):
+ cmd.extend(('-o', ','.join(options)))
+ if mount_fs:
+ cmd.extend(('-t', mount_fs))
+
+ cmd.extend((str(dev_path), str(target_mountpoint)))
+
+ command = ' '.join(cmd)
+
+ debug(f'Mounting {dev_path}: {command}')
+
+ try:
+ SysCommand(command)
+ except SysCallError as err:
+ raise DiskError(f'Could not mount {dev_path}: {command}\n{err.message}')
+
+ def umount(self, mountpoint: Path, recursive: bool = False):
+ try:
+ lsblk_info = get_lsblk_info(mountpoint)
+ except SysCallError as ex:
+ # this could happen if before partitioning the device contained 3 partitions
+ # and after partitioning only 2 partitions were created, then the modifications object
+ # will have a reference to /dev/sX3 which is being tried to umount here now
+ if 'not a block device' in ex.message:
+ return
+ raise ex
+
+ if len(lsblk_info.mountpoints) > 0:
+ debug(f'Partition {mountpoint} is currently mounted at: {[str(m) for m in lsblk_info.mountpoints]}')
+
+ for mountpoint in lsblk_info.mountpoints:
+ debug(f'Unmounting mountpoint: {mountpoint}')
+
+ command = 'umount'
+
+ if recursive:
+ command += ' -R'
+
+ SysCommand(f'{command} {mountpoint}')
+
+ def detect_pre_mounted_mods(self, base_mountpoint: Path) -> List[DeviceModification]:
+ part_mods: Dict[Path, List[PartitionModification]] = {}
+
+ for device in self.devices:
+ for part_info in device.partition_infos:
+ for mountpoint in part_info.mountpoints:
+ if is_subpath(mountpoint, base_mountpoint):
+ path = Path(part_info.disk.device.path)
+ part_mods.setdefault(path, [])
+ part_mod = PartitionModification.from_existing_partition(part_info)
+ if part_mod.mountpoint:
+ part_mod.mountpoint = mountpoint.root / mountpoint.relative_to(base_mountpoint)
+ else:
+ for subvol in part_mod.btrfs_subvols:
+ if sm := subvol.mountpoint:
+ subvol.mountpoint = sm.root / sm.relative_to(base_mountpoint)
+ part_mods[path].append(part_mod)
+ break
+
+ device_mods: List[DeviceModification] = []
+ for device_path, mods in part_mods.items():
+ device_mod = DeviceModification(self._devices[device_path], False, mods)
+ device_mods.append(device_mod)
+
+ return device_mods
+
+ def partprobe(self, path: Optional[Path] = None):
+ if path is not None:
+ command = f'partprobe {path}'
+ else:
+ command = 'partprobe'
+
+ try:
+ debug(f'Calling partprobe: {command}')
+ SysCommand(command)
+ except SysCallError as err:
+ if 'have been written, but we have been unable to inform the kernel of the change' in str(err):
+ log(f"Partprobe was not able to inform the kernel of the new disk state (ignoring error): {err}", fg="gray", level=logging.INFO)
+ else:
+ error(f'"{command}" failed to run (continuing anyway): {err}')
+
+ def _wipe(self, dev_path: Path):
+ """
+ Wipe a device (partition or otherwise) of meta-data, be it file system, LVM, etc.
+ @param dev_path: Device path of the partition to be wiped.
+ @type dev_path: str
+ """
+ with open(dev_path, 'wb') as p:
+ p.write(bytearray(1024))
+
+ def wipe_dev(self, block_device: BDevice):
+ """
+ Wipe the block device of meta-data, be it file system, LVM, etc.
+ This is not intended to be secure, but rather to ensure that
+ auto-discovery tools don't recognize anything here.
+ """
+ info(f'Wiping partitions and metadata: {block_device.device_info.path}')
+ for partition in block_device.partition_infos:
+ self._wipe(partition.path)
+
+ self._wipe(block_device.device_info.path)
+
+
+device_handler = DeviceHandler()
+
+
+def disk_layouts() -> str:
+ try:
+ lsblk_info = get_all_lsblk_info()
+ return json.dumps(lsblk_info, indent=4, sort_keys=True, cls=JSON)
+ except SysCallError as err:
+ warn(f"Could not return disk layouts: {err}")
+ return ''
+ except json.decoder.JSONDecodeError as err:
+ warn(f"Could not return disk layouts: {err}")
+ return ''
diff --git a/archinstall/lib/disk/device_model.py b/archinstall/lib/disk/device_model.py
new file mode 100644
index 00000000..f98d05fb
--- /dev/null
+++ b/archinstall/lib/disk/device_model.py
@@ -0,0 +1,1499 @@
+from __future__ import annotations
+
+import dataclasses
+import json
+import math
+import uuid
+from dataclasses import dataclass, field
+from enum import Enum
+from enum import auto
+from pathlib import Path
+from typing import Optional, List, Dict, TYPE_CHECKING, Any
+from typing import Union
+
+import parted # type: ignore
+import _ped # type: ignore
+from parted import Disk, Geometry, Partition
+
+from ..exceptions import DiskError, SysCallError
+from ..general import SysCommand
+from ..output import debug, error
+from ..storage import storage
+
+if TYPE_CHECKING:
+ _: Any
+
+
+class DiskLayoutType(Enum):
+ Default = 'default_layout'
+ Manual = 'manual_partitioning'
+ Pre_mount = 'pre_mounted_config'
+
+ def display_msg(self) -> str:
+ match self:
+ case DiskLayoutType.Default: return str(_('Use a best-effort default partition layout'))
+ case DiskLayoutType.Manual: return str(_('Manual Partitioning'))
+ case DiskLayoutType.Pre_mount: return str(_('Pre-mounted configuration'))
+
+
+@dataclass
+class DiskLayoutConfiguration:
+ config_type: DiskLayoutType
+ device_modifications: List[DeviceModification] = field(default_factory=list)
+ lvm_config: Optional[LvmConfiguration] = None
+
+ # used for pre-mounted config
+ mountpoint: Optional[Path] = None
+
+ def json(self) -> Dict[str, Any]:
+ if self.config_type == DiskLayoutType.Pre_mount:
+ return {
+ 'config_type': self.config_type.value,
+ 'mountpoint': str(self.mountpoint)
+ }
+ else:
+ config: Dict[str, Any] = {
+ 'config_type': self.config_type.value,
+ 'device_modifications': [mod.json() for mod in self.device_modifications],
+ }
+
+ if self.lvm_config:
+ config['lvm_config'] = self.lvm_config.json()
+
+ return config
+
+ @classmethod
+ def parse_arg(cls, disk_config: Dict[str, Any]) -> Optional[DiskLayoutConfiguration]:
+ from .device_handler import device_handler
+
+ device_modifications: List[DeviceModification] = []
+ config_type = disk_config.get('config_type', None)
+
+ if not config_type:
+ raise ValueError('Missing disk layout configuration: config_type')
+
+ config = DiskLayoutConfiguration(
+ config_type=DiskLayoutType(config_type),
+ device_modifications=device_modifications
+ )
+
+ if config_type == DiskLayoutType.Pre_mount.value:
+ if not (mountpoint := disk_config.get('mountpoint')):
+ raise ValueError('Must set a mountpoint when layout type is pre-mount')
+
+ path = Path(str(mountpoint))
+
+ mods = device_handler.detect_pre_mounted_mods(path)
+ device_modifications.extend(mods)
+
+ storage['MOUNT_POINT'] = path
+
+ config.mountpoint = path
+
+ return config
+
+ for entry in disk_config.get('device_modifications', []):
+ device_path = Path(entry.get('device', None)) if entry.get('device', None) else None
+
+ if not device_path:
+ continue
+
+ device = device_handler.get_device(device_path)
+
+ if not device:
+ continue
+
+ device_modification = DeviceModification(
+ wipe=entry.get('wipe', False),
+ device=device
+ )
+
+ device_partitions: List[PartitionModification] = []
+
+ for partition in entry.get('partitions', []):
+ device_partition = PartitionModification(
+ status=ModificationStatus(partition['status']),
+ fs_type=FilesystemType(partition['fs_type']) if partition.get('fs_type') else None,
+ start=Size.parse_args(partition['start']),
+ length=Size.parse_args(partition['size']),
+ mount_options=partition['mount_options'],
+ mountpoint=Path(partition['mountpoint']) if partition['mountpoint'] else None,
+ dev_path=Path(partition['dev_path']) if partition['dev_path'] else None,
+ type=PartitionType(partition['type']),
+ flags=[PartitionFlag[f] for f in partition.get('flags', [])],
+ btrfs_subvols=SubvolumeModification.parse_args(partition.get('btrfs', [])),
+ )
+ # special 'invisible attr to internally identify the part mod
+ setattr(device_partition, '_obj_id', partition['obj_id'])
+ device_partitions.append(device_partition)
+
+ device_modification.partitions = device_partitions
+ device_modifications.append(device_modification)
+
+ # Parse LVM configuration from settings
+ if (lvm_arg := disk_config.get('lvm_config', None)) is not None:
+ config.lvm_config = LvmConfiguration.parse_arg(lvm_arg, config)
+
+ return config
+
+
+class PartitionTable(Enum):
+ GPT = 'gpt'
+ MBR = 'msdos'
+
+
+class Unit(Enum):
+ B = 1 # byte
+ kB = 1000 ** 1 # kilobyte
+ MB = 1000 ** 2 # megabyte
+ GB = 1000 ** 3 # gigabyte
+ TB = 1000 ** 4 # terabyte
+ PB = 1000 ** 5 # petabyte
+ EB = 1000 ** 6 # exabyte
+ ZB = 1000 ** 7 # zettabyte
+ YB = 1000 ** 8 # yottabyte
+
+ KiB = 1024 ** 1 # kibibyte
+ MiB = 1024 ** 2 # mebibyte
+ GiB = 1024 ** 3 # gibibyte
+ TiB = 1024 ** 4 # tebibyte
+ PiB = 1024 ** 5 # pebibyte
+ EiB = 1024 ** 6 # exbibyte
+ ZiB = 1024 ** 7 # zebibyte
+ YiB = 1024 ** 8 # yobibyte
+
+ sectors = 'sectors' # size in sector
+
+ @staticmethod
+ def get_all_units() -> List[str]:
+ return [u.name for u in Unit]
+
+ @staticmethod
+ def get_si_units() -> List[Unit]:
+ return [u for u in Unit if 'i' not in u.name and u.name != 'sectors']
+
+
+@dataclass
+class SectorSize:
+ value: int
+ unit: Unit
+
+ def __post_init__(self):
+ match self.unit:
+ case Unit.sectors:
+ raise ValueError('Unit type sector not allowed for SectorSize')
+
+ @staticmethod
+ def default() -> SectorSize:
+ return SectorSize(512, Unit.B)
+
+ def json(self) -> Dict[str, Any]:
+ return {
+ 'value': self.value,
+ 'unit': self.unit.name,
+ }
+
+ @classmethod
+ def parse_args(cls, arg: Dict[str, Any]) -> SectorSize:
+ return SectorSize(
+ arg['value'],
+ Unit[arg['unit']]
+ )
+
+ def normalize(self) -> int:
+ """
+ will normalize the value of the unit to Byte
+ """
+ return int(self.value * self.unit.value) # type: ignore
+
+
+@dataclass
+class Size:
+ value: int
+ unit: Unit
+ sector_size: SectorSize
+
+ def __post_init__(self):
+ if not isinstance(self.sector_size, SectorSize):
+ raise ValueError('sector size must be of type SectorSize')
+
+ def json(self) -> Dict[str, Any]:
+ return {
+ 'value': self.value,
+ 'unit': self.unit.name,
+ 'sector_size': self.sector_size.json() if self.sector_size else None
+ }
+
+ @classmethod
+ def parse_args(cls, size_arg: Dict[str, Any]) -> Size:
+ sector_size = size_arg['sector_size']
+
+ return Size(
+ size_arg['value'],
+ Unit[size_arg['unit']],
+ SectorSize.parse_args(sector_size),
+ )
+
+ def convert(
+ self,
+ target_unit: Unit,
+ sector_size: Optional[SectorSize] = None
+ ) -> Size:
+ if target_unit == Unit.sectors and sector_size is None:
+ raise ValueError('If target has unit sector, a sector size must be provided')
+
+ if self.unit == target_unit:
+ return self
+ elif self.unit == Unit.sectors:
+ norm = self._normalize()
+ return Size(norm, Unit.B, self.sector_size).convert(target_unit, sector_size)
+ else:
+ if target_unit == Unit.sectors and sector_size is not None:
+ norm = self._normalize()
+ sectors = math.ceil(norm / sector_size.value)
+ return Size(sectors, Unit.sectors, sector_size)
+ else:
+ value = int(self._normalize() / target_unit.value) # type: ignore
+ return Size(value, target_unit, self.sector_size)
+
+ def as_text(self) -> str:
+ return self.format_size(
+ self.unit,
+ self.sector_size
+ )
+
+ def format_size(
+ self,
+ target_unit: Unit,
+ sector_size: Optional[SectorSize] = None,
+ include_unit: bool = True
+ ) -> str:
+ target_size = self.convert(target_unit, sector_size)
+
+ if include_unit:
+ return f'{target_size.value} {target_unit.name}'
+ return f'{target_size.value}'
+
+ def format_highest(self, include_unit: bool = True) -> str:
+ si_units = Unit.get_si_units()
+ all_si_values = [self.convert(si) for si in si_units]
+ filtered = filter(lambda x: x.value >= 1, all_si_values)
+
+ # we have to get the max by the unit value as we're interested
+ # in getting the value in the highest possible unit without floats
+ si_value = max(filtered, key=lambda x: x.unit.value)
+
+ if include_unit:
+ return f'{si_value.value} {si_value.unit.name}'
+ return f'{si_value.value}'
+
+ def _normalize(self) -> int:
+ """
+ will normalize the value of the unit to Byte
+ """
+ if self.unit == Unit.sectors and self.sector_size is not None:
+ return self.value * self.sector_size.normalize()
+ return int(self.value * self.unit.value) # type: ignore
+
+ def __sub__(self, other: Size) -> Size:
+ src_norm = self._normalize()
+ dest_norm = other._normalize()
+ return Size(abs(src_norm - dest_norm), Unit.B, self.sector_size)
+
+ def __add__(self, other: Size) -> Size:
+ src_norm = self._normalize()
+ dest_norm = other._normalize()
+ return Size(abs(src_norm + dest_norm), Unit.B, self.sector_size)
+
+ def __lt__(self, other):
+ return self._normalize() < other._normalize()
+
+ def __le__(self, other):
+ return self._normalize() <= other._normalize()
+
+ def __eq__(self, other):
+ return self._normalize() == other._normalize()
+
+ def __ne__(self, other):
+ return self._normalize() != other._normalize()
+
+ def __gt__(self, other):
+ return self._normalize() > other._normalize()
+
+ def __ge__(self, other):
+ return self._normalize() >= other._normalize()
+
+
+class BtrfsMountOption(Enum):
+ compress = 'compress=zstd'
+ nodatacow = 'nodatacow'
+
+
+@dataclass
+class _BtrfsSubvolumeInfo:
+ name: Path
+ mountpoint: Optional[Path]
+
+
+@dataclass
+class _PartitionInfo:
+ partition: Partition
+ name: str
+ type: PartitionType
+ fs_type: Optional[FilesystemType]
+ path: Path
+ start: Size
+ length: Size
+ flags: List[PartitionFlag]
+ partn: Optional[int]
+ partuuid: Optional[str]
+ uuid: Optional[str]
+ disk: Disk
+ mountpoints: List[Path]
+ btrfs_subvol_infos: List[_BtrfsSubvolumeInfo] = field(default_factory=list)
+
+ @property
+ def sector_size(self) -> SectorSize:
+ sector_size = self.partition.geometry.device.sectorSize
+ return SectorSize(sector_size, Unit.B)
+
+ def table_data(self) -> Dict[str, Any]:
+ end = self.start + self.length
+
+ part_info = {
+ 'Name': self.name,
+ 'Type': self.type.value,
+ 'Filesystem': self.fs_type.value if self.fs_type else str(_('Unknown')),
+ 'Path': str(self.path),
+ 'Start': self.start.format_size(Unit.sectors, self.sector_size, include_unit=False),
+ 'End': end.format_size(Unit.sectors, self.sector_size, include_unit=False),
+ 'Size': self.length.format_highest(),
+ 'Flags': ', '.join([f.name for f in self.flags])
+ }
+
+ if self.btrfs_subvol_infos:
+ part_info['Btrfs vol.'] = f'{len(self.btrfs_subvol_infos)} subvolumes'
+
+ return part_info
+
+ @classmethod
+ def from_partition(
+ cls,
+ partition: Partition,
+ fs_type: Optional[FilesystemType],
+ partn: Optional[int],
+ partuuid: Optional[str],
+ uuid: Optional[str],
+ mountpoints: List[Path],
+ btrfs_subvol_infos: List[_BtrfsSubvolumeInfo] = []
+ ) -> _PartitionInfo:
+ partition_type = PartitionType.get_type_from_code(partition.type)
+ flags = [f for f in PartitionFlag if partition.getFlag(f.value)]
+
+ start = Size(
+ partition.geometry.start,
+ Unit.sectors,
+ SectorSize(partition.disk.device.sectorSize, Unit.B)
+ )
+
+ length = Size(
+ int(partition.getLength(unit='B')),
+ Unit.B,
+ SectorSize(partition.disk.device.sectorSize, Unit.B)
+ )
+
+ return _PartitionInfo(
+ partition=partition,
+ name=partition.get_name(),
+ type=partition_type,
+ fs_type=fs_type,
+ path=partition.path,
+ start=start,
+ length=length,
+ flags=flags,
+ partn=partn,
+ partuuid=partuuid,
+ uuid=uuid,
+ disk=partition.disk,
+ mountpoints=mountpoints,
+ btrfs_subvol_infos=btrfs_subvol_infos
+ )
+
+
+@dataclass
+class _DeviceInfo:
+ model: str
+ path: Path
+ type: str
+ total_size: Size
+ free_space_regions: List[DeviceGeometry]
+ sector_size: SectorSize
+ read_only: bool
+ dirty: bool
+
+ def table_data(self) -> Dict[str, Any]:
+ total_free_space = sum([region.get_length(unit=Unit.MiB) for region in self.free_space_regions])
+ return {
+ 'Model': self.model,
+ 'Path': str(self.path),
+ 'Type': self.type,
+ 'Size': self.total_size.format_highest(),
+ 'Free space': int(total_free_space),
+ 'Sector size': self.sector_size.value,
+ 'Read only': self.read_only
+ }
+
+ @classmethod
+ def from_disk(cls, disk: Disk) -> _DeviceInfo:
+ device = disk.device
+ if device.type == 18:
+ device_type = 'loop'
+ elif device.type in parted.devices:
+ device_type = parted.devices[device.type]
+ else:
+ debug(f'Device code unknown: {device.type}')
+ device_type = parted.devices[parted.DEVICE_UNKNOWN]
+
+ sector_size = SectorSize(device.sectorSize, Unit.B)
+ free_space = [DeviceGeometry(g, sector_size) for g in disk.getFreeSpaceRegions()]
+
+ sector_size = SectorSize(device.sectorSize, Unit.B)
+
+ return _DeviceInfo(
+ model=device.model.strip(),
+ path=Path(device.path),
+ type=device_type,
+ sector_size=sector_size,
+ total_size=Size(int(device.getLength(unit='B')), Unit.B, sector_size),
+ free_space_regions=free_space,
+ read_only=device.readOnly,
+ dirty=device.dirty
+ )
+
+
+@dataclass
+class SubvolumeModification:
+ name: Path
+ mountpoint: Optional[Path] = None
+
+ @classmethod
+ def from_existing_subvol_info(cls, info: _BtrfsSubvolumeInfo) -> SubvolumeModification:
+ return SubvolumeModification(info.name, mountpoint=info.mountpoint)
+
+ @classmethod
+ def parse_args(cls, subvol_args: List[Dict[str, Any]]) -> List[SubvolumeModification]:
+ mods = []
+ for entry in subvol_args:
+ if not entry.get('name', None) or not entry.get('mountpoint', None):
+ debug(f'Subvolume arg is missing name: {entry}')
+ continue
+
+ mountpoint = Path(entry['mountpoint']) if entry['mountpoint'] else None
+
+ mods.append(SubvolumeModification(entry['name'], mountpoint))
+
+ return mods
+
+ @property
+ def relative_mountpoint(self) -> Path:
+ """
+ Will return the relative path based on the anchor
+ e.g. Path('/mnt/test') -> Path('mnt/test')
+ """
+ if self.mountpoint is not None:
+ return self.mountpoint.relative_to(self.mountpoint.anchor)
+
+ raise ValueError('Mountpoint is not specified')
+
+ def is_root(self) -> bool:
+ if self.mountpoint:
+ return self.mountpoint == Path('/')
+ return False
+
+ def json(self) -> Dict[str, Any]:
+ return {'name': str(self.name), 'mountpoint': str(self.mountpoint)}
+
+ def table_data(self) -> Dict[str, Any]:
+ return self.json()
+
+
+class DeviceGeometry:
+ def __init__(self, geometry: Geometry, sector_size: SectorSize):
+ self._geometry = geometry
+ self._sector_size = sector_size
+
+ @property
+ def start(self) -> int:
+ return self._geometry.start
+
+ @property
+ def end(self) -> int:
+ return self._geometry.end
+
+ def get_length(self, unit: Unit = Unit.sectors) -> int:
+ return self._geometry.getLength(unit.name)
+
+ def table_data(self) -> Dict[str, Any]:
+ start = Size(self._geometry.start, Unit.sectors, self._sector_size)
+ end = Size(self._geometry.end, Unit.sectors, self._sector_size)
+ length = Size(self._geometry.getLength(), Unit.sectors, self._sector_size)
+
+ start_str = f'{self._geometry.start} / {start.format_size(Unit.B, include_unit=False)}'
+ end_str = f'{self._geometry.end} / {end.format_size(Unit.B, include_unit=False)}'
+ length_str = f'{self._geometry.getLength()} / {length.format_size(Unit.B, include_unit=False)}'
+
+ return {
+ 'Sector size': self._sector_size.value,
+ 'Start (sector/B)': start_str,
+ 'End (sector/B)': end_str,
+ 'Size (sectors/B)': length_str
+ }
+
+
+@dataclass
+class BDevice:
+ disk: Disk
+ device_info: _DeviceInfo
+ partition_infos: List[_PartitionInfo]
+
+ def __hash__(self):
+ return hash(self.disk.device.path)
+
+
+class PartitionType(Enum):
+ Boot = 'boot'
+ Primary = 'primary'
+ _Unknown = 'unknown'
+
+ @classmethod
+ def get_type_from_code(cls, code: int) -> PartitionType:
+ if code == parted.PARTITION_NORMAL:
+ return PartitionType.Primary
+ else:
+ debug(f'Partition code not supported: {code}')
+ return PartitionType._Unknown
+
+ def get_partition_code(self) -> Optional[int]:
+ if self == PartitionType.Primary:
+ return parted.PARTITION_NORMAL
+ elif self == PartitionType.Boot:
+ return parted.PARTITION_BOOT
+ return None
+
+
+class PartitionFlag(Enum):
+ """
+ Flags are taken from _ped because pyparted uses this to look
+ up their flag definitions: https://github.com/dcantrell/pyparted/blob/c4e0186dad45c8efbe67c52b02c8c4319df8aa9b/src/parted/__init__.py#L200-L202
+ Which is the way libparted checks for its flags: https://git.savannah.gnu.org/gitweb/?p=parted.git;a=blob;f=libparted/labels/gpt.c;hb=4a0e468ed63fff85a1f9b923189f20945b32f4f1#l183
+ """
+ Boot = _ped.PARTITION_BOOT
+ XBOOTLDR = _ped.PARTITION_BLS_BOOT # Note: parted calls this bls_boot
+ ESP = _ped.PARTITION_ESP
+
+
+# class PartitionGUIDs(Enum):
+# """
+# A list of Partition type GUIDs (lsblk -o+PARTTYPE) can be found here: https://en.wikipedia.org/wiki/GUID_Partition_Table#Partition_type_GUIDs
+# """
+# XBOOTLDR = 'bc13c2ff-59e6-4262-a352-b275fd6f7172'
+
+
+class FilesystemType(Enum):
+ Btrfs = 'btrfs'
+ Ext2 = 'ext2'
+ Ext3 = 'ext3'
+ Ext4 = 'ext4'
+ F2fs = 'f2fs'
+ Fat16 = 'fat16'
+ Fat32 = 'fat32'
+ Ntfs = 'ntfs'
+ Reiserfs = 'reiserfs'
+ Xfs = 'xfs'
+
+ # this is not a FS known to parted, so be careful
+ # with the usage from this enum
+ Crypto_luks = 'crypto_LUKS'
+
+ def is_crypto(self) -> bool:
+ return self == FilesystemType.Crypto_luks
+
+ @property
+ def fs_type_mount(self) -> str:
+ match self:
+ case FilesystemType.Ntfs: return 'ntfs3'
+ case FilesystemType.Fat32: return 'vfat'
+ case _: return self.value # type: ignore
+
+ @property
+ def installation_pkg(self) -> Optional[str]:
+ match self:
+ case FilesystemType.Btrfs: return 'btrfs-progs'
+ case FilesystemType.Xfs: return 'xfsprogs'
+ case FilesystemType.F2fs: return 'f2fs-tools'
+ case _: return None
+
+ @property
+ def installation_module(self) -> Optional[str]:
+ match self:
+ case FilesystemType.Btrfs: return 'btrfs'
+ case _: return None
+
+ @property
+ def installation_binary(self) -> Optional[str]:
+ match self:
+ case FilesystemType.Btrfs: return '/usr/bin/btrfs'
+ case _: return None
+
+ @property
+ def installation_hooks(self) -> Optional[str]:
+ match self:
+ case FilesystemType.Btrfs: return 'btrfs'
+ case _: return None
+
+
+class ModificationStatus(Enum):
+ Exist = 'existing'
+ Modify = 'modify'
+ Delete = 'delete'
+ Create = 'create'
+
+
+@dataclass
+class PartitionModification:
+ status: ModificationStatus
+ type: PartitionType
+ start: Size
+ length: Size
+ fs_type: Optional[FilesystemType] = None
+ mountpoint: Optional[Path] = None
+ mount_options: List[str] = field(default_factory=list)
+ flags: List[PartitionFlag] = field(default_factory=list)
+ btrfs_subvols: List[SubvolumeModification] = field(default_factory=list)
+
+ # only set if the device was created or exists
+ dev_path: Optional[Path] = None
+ partn: Optional[int] = None
+ partuuid: Optional[str] = None
+ uuid: Optional[str] = None
+
+ _efi_indicator_flags = (PartitionFlag.Boot, PartitionFlag.ESP)
+ _boot_indicator_flags = (PartitionFlag.Boot, PartitionFlag.XBOOTLDR)
+
+ def __post_init__(self):
+ # needed to use the object as a dictionary key due to hash func
+ if not hasattr(self, '_obj_id'):
+ self._obj_id = uuid.uuid4()
+
+ if self.is_exists_or_modify() and not self.dev_path:
+ raise ValueError('If partition marked as existing a path must be set')
+
+ if self.fs_type is None and self.status == ModificationStatus.Modify:
+ raise ValueError('FS type must not be empty on modifications with status type modify')
+
+ def __hash__(self):
+ return hash(self._obj_id)
+
+ @property
+ def end(self) -> Size:
+ return self.start + self.length
+
+ @property
+ def obj_id(self) -> str:
+ if hasattr(self, '_obj_id'):
+ return str(self._obj_id)
+ return ''
+
+ @property
+ def safe_dev_path(self) -> Path:
+ if self.dev_path is None:
+ raise ValueError('Device path was not set')
+ return self.dev_path
+
+ @property
+ def safe_fs_type(self) -> FilesystemType:
+ if self.fs_type is None:
+ raise ValueError('File system type is not set')
+ return self.fs_type
+
+ @classmethod
+ def from_existing_partition(cls, partition_info: _PartitionInfo) -> PartitionModification:
+ if partition_info.btrfs_subvol_infos:
+ mountpoint = None
+ subvol_mods = []
+ for i in partition_info.btrfs_subvol_infos:
+ subvol_mods.append(
+ SubvolumeModification.from_existing_subvol_info(i)
+ )
+ else:
+ mountpoint = partition_info.mountpoints[0] if partition_info.mountpoints else None
+ subvol_mods = []
+
+ return PartitionModification(
+ status=ModificationStatus.Exist,
+ type=partition_info.type,
+ start=partition_info.start,
+ length=partition_info.length,
+ fs_type=partition_info.fs_type,
+ dev_path=partition_info.path,
+ partn=partition_info.partn,
+ partuuid=partition_info.partuuid,
+ uuid=partition_info.uuid,
+ flags=partition_info.flags,
+ mountpoint=mountpoint,
+ btrfs_subvols=subvol_mods
+ )
+
+ @property
+ def relative_mountpoint(self) -> Path:
+ """
+ Will return the relative path based on the anchor
+ e.g. Path('/mnt/test') -> Path('mnt/test')
+ """
+ if self.mountpoint:
+ return self.mountpoint.relative_to(self.mountpoint.anchor)
+
+ raise ValueError('Mountpoint is not specified')
+
+ def is_efi(self) -> bool:
+ return (
+ any(set(self.flags) & set(self._efi_indicator_flags))
+ and self.fs_type == FilesystemType.Fat32
+ and PartitionFlag.XBOOTLDR not in self.flags
+ )
+
+ def is_boot(self) -> bool:
+ """
+ Returns True if any of the boot indicator flags are found in self.flags
+ """
+ return any(set(self.flags) & set(self._boot_indicator_flags))
+
+ def is_root(self) -> bool:
+ if self.mountpoint is not None:
+ return Path('/') == self.mountpoint
+ else:
+ for subvol in self.btrfs_subvols:
+ if subvol.is_root():
+ return True
+
+ return False
+
+ def is_modify(self) -> bool:
+ return self.status == ModificationStatus.Modify
+
+ def exists(self) -> bool:
+ return self.status == ModificationStatus.Exist
+
+ def is_exists_or_modify(self) -> bool:
+ return self.status in [ModificationStatus.Exist, ModificationStatus.Modify]
+
+ def is_create_or_modify(self) -> bool:
+ return self.status in [ModificationStatus.Create, ModificationStatus.Modify]
+
+ @property
+ def mapper_name(self) -> Optional[str]:
+ if self.dev_path:
+ return f'{storage.get("ENC_IDENTIFIER", "ai")}{self.dev_path.name}'
+ return None
+
+ def set_flag(self, flag: PartitionFlag):
+ if flag not in self.flags:
+ self.flags.append(flag)
+
+ def invert_flag(self, flag: PartitionFlag):
+ if flag in self.flags:
+ self.flags = [f for f in self.flags if f != flag]
+ else:
+ self.set_flag(flag)
+
+ def json(self) -> Dict[str, Any]:
+ """
+ Called for configuration settings
+ """
+ return {
+ 'obj_id': self.obj_id,
+ 'status': self.status.value,
+ 'type': self.type.value,
+ 'start': self.start.json(),
+ 'size': self.length.json(),
+ 'fs_type': self.fs_type.value if self.fs_type else None,
+ 'mountpoint': str(self.mountpoint) if self.mountpoint else None,
+ 'mount_options': self.mount_options,
+ 'flags': [f.name for f in self.flags],
+ 'dev_path': str(self.dev_path) if self.dev_path else None,
+ 'btrfs': [vol.json() for vol in self.btrfs_subvols]
+ }
+
+ def table_data(self) -> Dict[str, Any]:
+ """
+ Called for displaying data in table format
+ """
+ part_mod = {
+ 'Status': self.status.value,
+ 'Device': str(self.dev_path) if self.dev_path else '',
+ 'Type': self.type.value,
+ 'Start': self.start.format_size(Unit.sectors, self.start.sector_size, include_unit=False),
+ 'End': self.end.format_size(Unit.sectors, self.start.sector_size, include_unit=False),
+ 'Size': self.length.format_highest(),
+ 'FS type': self.fs_type.value if self.fs_type else 'Unknown',
+ 'Mountpoint': self.mountpoint if self.mountpoint else '',
+ 'Mount options': ', '.join(self.mount_options),
+ 'Flags': ', '.join([f.name for f in self.flags]),
+ }
+
+ if self.btrfs_subvols:
+ part_mod['Btrfs vol.'] = f'{len(self.btrfs_subvols)} subvolumes'
+
+ return part_mod
+
+
+class LvmLayoutType(Enum):
+ Default = 'default'
+
+ # Manual = 'manual_lvm'
+
+ def display_msg(self) -> str:
+ match self:
+ case LvmLayoutType.Default:
+ return str(_('Default layout'))
+ # case LvmLayoutType.Manual:
+ # return str(_('Manual configuration'))
+
+ raise ValueError(f'Unknown type: {self}')
+
+
+@dataclass
+class LvmVolumeGroup:
+ name: str
+ pvs: List[PartitionModification]
+ volumes: List[LvmVolume] = field(default_factory=list)
+
+ def json(self) -> Dict[str, Any]:
+ return {
+ 'name': self.name,
+ 'lvm_pvs': [p.obj_id for p in self.pvs],
+ 'volumes': [vol.json() for vol in self.volumes]
+ }
+
+ @staticmethod
+ def parse_arg(arg: Dict[str, Any], disk_config: DiskLayoutConfiguration) -> LvmVolumeGroup:
+ lvm_pvs = []
+ for mod in disk_config.device_modifications:
+ for part in mod.partitions:
+ if part.obj_id in arg.get('lvm_pvs', []):
+ lvm_pvs.append(part)
+
+ return LvmVolumeGroup(
+ arg['name'],
+ lvm_pvs,
+ [LvmVolume.parse_arg(vol) for vol in arg['volumes']]
+ )
+
+ def contains_lv(self, lv: LvmVolume) -> bool:
+ return lv in self.volumes
+
+
+class LvmVolumeStatus(Enum):
+ Exist = 'existing'
+ Modify = 'modify'
+ Delete = 'delete'
+ Create = 'create'
+
+
+@dataclass
+class LvmVolume:
+ status: LvmVolumeStatus
+ name: str
+ fs_type: FilesystemType
+ length: Size
+ mountpoint: Optional[Path]
+ mount_options: List[str] = field(default_factory=list)
+ btrfs_subvols: List[SubvolumeModification] = field(default_factory=list)
+
+ # volume group name
+ vg_name: Optional[str] = None
+ # mapper device path /dev/<vg>/<vol>
+ dev_path: Optional[Path] = None
+
+ def __post_init__(self):
+ # needed to use the object as a dictionary key due to hash func
+ if not hasattr(self, '_obj_id'):
+ self._obj_id = uuid.uuid4()
+
+ def __hash__(self):
+ return hash(self._obj_id)
+
+ @property
+ def obj_id(self) -> str:
+ if hasattr(self, '_obj_id'):
+ return str(self._obj_id)
+ return ''
+
+ @property
+ def mapper_name(self) -> Optional[str]:
+ if self.dev_path:
+ return f'{storage.get("ENC_IDENTIFIER", "ai")}{self.safe_dev_path.name}'
+ return None
+
+ @property
+ def mapper_path(self) -> Path:
+ if self.mapper_name:
+ return Path(f'/dev/mapper/{self.mapper_name}')
+
+ raise ValueError('No mapper path set')
+
+ @property
+ def safe_dev_path(self) -> Path:
+ if self.dev_path:
+ return self.dev_path
+ raise ValueError('No device path for volume defined')
+
+ @property
+ def safe_fs_type(self) -> FilesystemType:
+ if self.fs_type is None:
+ raise ValueError('File system type is not set')
+ return self.fs_type
+
+ @property
+ def relative_mountpoint(self) -> Path:
+ """
+ Will return the relative path based on the anchor
+ e.g. Path('/mnt/test') -> Path('mnt/test')
+ """
+ if self.mountpoint is not None:
+ return self.mountpoint.relative_to(self.mountpoint.anchor)
+
+ raise ValueError('Mountpoint is not specified')
+
+ @staticmethod
+ def parse_arg(arg: Dict[str, Any]) -> LvmVolume:
+ volume = LvmVolume(
+ status=LvmVolumeStatus(arg['status']),
+ name=arg['name'],
+ fs_type=FilesystemType(arg['fs_type']),
+ length=Size.parse_args(arg['length']),
+ mountpoint=Path(arg['mountpoint']) if arg['mountpoint'] else None,
+ mount_options=arg.get('mount_options', []),
+ btrfs_subvols=SubvolumeModification.parse_args(arg.get('btrfs', []))
+ )
+
+ setattr(volume, '_obj_id', arg['obj_id'])
+
+ return volume
+
+ def json(self) -> Dict[str, Any]:
+ return {
+ 'obj_id': self.obj_id,
+ 'status': self.status.value,
+ 'name': self.name,
+ 'fs_type': self.fs_type.value,
+ 'length': self.length.json(),
+ 'mountpoint': str(self.mountpoint) if self.mountpoint else None,
+ 'mount_options': self.mount_options,
+ 'btrfs': [vol.json() for vol in self.btrfs_subvols]
+ }
+
+ def table_data(self) -> Dict[str, Any]:
+ part_mod = {
+ 'Type': self.status.value,
+ 'Name': self.name,
+ 'Size': self.length.format_highest(),
+ 'FS type': self.fs_type.value,
+ 'Mountpoint': str(self.mountpoint) if self.mountpoint else '',
+ 'Mount options': ', '.join(self.mount_options),
+ 'Btrfs': '{} {}'.format(str(len(self.btrfs_subvols)), 'vol')
+ }
+ return part_mod
+
+ def is_modify(self) -> bool:
+ return self.status == LvmVolumeStatus.Modify
+
+ def exists(self) -> bool:
+ return self.status == LvmVolumeStatus.Exist
+
+ def is_exists_or_modify(self) -> bool:
+ return self.status in [LvmVolumeStatus.Exist, LvmVolumeStatus.Modify]
+
+ def is_root(self) -> bool:
+ if self.mountpoint is not None:
+ return Path('/') == self.mountpoint
+ else:
+ for subvol in self.btrfs_subvols:
+ if subvol.is_root():
+ return True
+
+ return False
+
+
+@dataclass
+class LvmGroupInfo:
+ vg_size: Size
+ vg_uuid: str
+
+
+@dataclass
+class LvmVolumeInfo:
+ lv_name: str
+ vg_name: str
+ lv_size: Size
+
+
+@dataclass
+class LvmPVInfo:
+ pv_name: Path
+ lv_name: str
+ vg_name: str
+
+
+@dataclass
+class LvmConfiguration:
+ config_type: LvmLayoutType
+ vol_groups: List[LvmVolumeGroup]
+
+ def __post_init__(self):
+ # make sure all volume groups have unique PVs
+ pvs = []
+ for group in self.vol_groups:
+ for pv in group.pvs:
+ if pv in pvs:
+ raise ValueError('A PV cannot be used in multiple volume groups')
+ pvs.append(pv)
+
+ def json(self) -> Dict[str, Any]:
+ return {
+ 'config_type': self.config_type.value,
+ 'vol_groups': [vol_gr.json() for vol_gr in self.vol_groups]
+ }
+
+ @staticmethod
+ def parse_arg(arg: Dict[str, Any], disk_config: DiskLayoutConfiguration) -> LvmConfiguration:
+ lvm_pvs = []
+ for mod in disk_config.device_modifications:
+ for part in mod.partitions:
+ if part.obj_id in arg.get('lvm_pvs', []):
+ lvm_pvs.append(part)
+
+ return LvmConfiguration(
+ config_type=LvmLayoutType(arg['config_type']),
+ vol_groups=[LvmVolumeGroup.parse_arg(vol_group, disk_config) for vol_group in arg['vol_groups']],
+ )
+
+ def get_all_pvs(self) -> List[PartitionModification]:
+ pvs = []
+ for vg in self.vol_groups:
+ pvs += vg.pvs
+
+ return pvs
+
+ def get_all_volumes(self) -> List[LvmVolume]:
+ volumes = []
+
+ for vg in self.vol_groups:
+ volumes += vg.volumes
+
+ return volumes
+
+ def get_root_volume(self) -> Optional[LvmVolume]:
+ for vg in self.vol_groups:
+ filtered = next(filter(lambda x: x.is_root(), vg.volumes), None)
+ if filtered:
+ return filtered
+
+ return None
+
+
+# def get_lv_crypt_uuid(self, lv: LvmVolume, encryption: EncryptionType) -> str:
+# """
+# Find the LUKS superblock UUID for the device that
+# contains the given logical volume
+# """
+# for vg in self.vol_groups:
+# if vg.contains_lv(lv):
+
+
+@dataclass
+class DeviceModification:
+ device: BDevice
+ wipe: bool
+ partitions: List[PartitionModification] = field(default_factory=list)
+
+ @property
+ def device_path(self) -> Path:
+ return self.device.device_info.path
+
+ def add_partition(self, partition: PartitionModification):
+ self.partitions.append(partition)
+
+ def get_efi_partition(self) -> Optional[PartitionModification]:
+ """
+ Similar to get_boot_partition() but excludes XBOOTLDR partitions from it's candidates.
+ """
+ filtered = filter(lambda x: x.is_efi() and x.mountpoint, self.partitions)
+ return next(filtered, None)
+
+ def get_boot_partition(self) -> Optional[PartitionModification]:
+ """
+ Returns the first partition marked as XBOOTLDR (PARTTYPE id of bc13c2ff-...) or Boot and has a mountpoint.
+ Only returns XBOOTLDR if separate EFI is detected using self.get_efi_partition()
+ Will return None if no suitable partition is found.
+ """
+ if efi_partition := self.get_efi_partition():
+ filtered = filter(lambda x: x.is_boot() and x != efi_partition and x.mountpoint, self.partitions)
+ if boot_partition := next(filtered, None):
+ return boot_partition
+ return efi_partition
+ else:
+ filtered = filter(lambda x: x.is_boot() and x.mountpoint, self.partitions)
+ return next(filtered, None)
+
+ def get_root_partition(self) -> Optional[PartitionModification]:
+ filtered = filter(lambda x: x.is_root(), self.partitions)
+ return next(filtered, None)
+
+ def json(self) -> Dict[str, Any]:
+ """
+ Called when generating configuration files
+ """
+ return {
+ 'device': str(self.device.device_info.path),
+ 'wipe': self.wipe,
+ 'partitions': [p.json() for p in self.partitions]
+ }
+
+
+class EncryptionType(Enum):
+ NoEncryption = "no_encryption"
+ Luks = "luks"
+ LvmOnLuks = 'lvm_on_luks'
+ LuksOnLvm = 'luks_on_lvm'
+
+ @classmethod
+ def _encryption_type_mapper(cls) -> Dict[str, 'EncryptionType']:
+ return {
+ str(_('No Encryption')): EncryptionType.NoEncryption,
+ str(_('LUKS')): EncryptionType.Luks,
+ str(_('LVM on LUKS')): EncryptionType.LvmOnLuks,
+ str(_('LUKS on LVM')): EncryptionType.LuksOnLvm
+ }
+
+ @classmethod
+ def text_to_type(cls, text: str) -> 'EncryptionType':
+ mapping = cls._encryption_type_mapper()
+ return mapping[text]
+
+ @classmethod
+ def type_to_text(cls, type_: 'EncryptionType') -> str:
+ mapping = cls._encryption_type_mapper()
+ type_to_text = {type_: text for text, type_ in mapping.items()}
+ return type_to_text[type_]
+
+
+@dataclass
+class DiskEncryption:
+ encryption_type: EncryptionType = EncryptionType.NoEncryption
+ encryption_password: str = ''
+ partitions: List[PartitionModification] = field(default_factory=list)
+ lvm_volumes: List[LvmVolume] = field(default_factory=list)
+ hsm_device: Optional[Fido2Device] = None
+
+ def __post_init__(self):
+ if self.encryption_type in [EncryptionType.Luks, EncryptionType.LvmOnLuks] and not self.partitions:
+ raise ValueError('Luks or LvmOnLuks encryption require partitions to be defined')
+
+ if self.encryption_type == EncryptionType.LuksOnLvm and not self.lvm_volumes:
+ raise ValueError('LuksOnLvm encryption require LMV volumes to be defined')
+
+ def should_generate_encryption_file(self, dev: PartitionModification | LvmVolume) -> bool:
+ if isinstance(dev, PartitionModification):
+ return dev in self.partitions and dev.mountpoint != Path('/')
+ elif isinstance(dev, LvmVolume):
+ return dev in self.lvm_volumes and dev.mountpoint != Path('/')
+ return False
+
+ def json(self) -> Dict[str, Any]:
+ obj: Dict[str, Any] = {
+ 'encryption_type': self.encryption_type.value,
+ 'partitions': [p.obj_id for p in self.partitions],
+ 'lvm_volumes': [vol.obj_id for vol in self.lvm_volumes]
+ }
+
+ if self.hsm_device:
+ obj['hsm_device'] = self.hsm_device.json()
+
+ return obj
+
+ @classmethod
+ def validate_enc(cls, disk_config: DiskLayoutConfiguration) -> bool:
+ partitions = []
+
+ for mod in disk_config.device_modifications:
+ for part in mod.partitions:
+ partitions.append(part)
+
+ if len(partitions) > 2: # assume one boot and at least 2 additional
+ if disk_config.lvm_config:
+ return False
+
+ return True
+
+ @classmethod
+ def parse_arg(
+ cls,
+ disk_config: DiskLayoutConfiguration,
+ arg: Dict[str, Any],
+ password: str = ''
+ ) -> Optional['DiskEncryption']:
+ if not cls.validate_enc(disk_config):
+ return None
+
+ enc_partitions = []
+ for mod in disk_config.device_modifications:
+ for part in mod.partitions:
+ if part.obj_id in arg.get('partitions', []):
+ enc_partitions.append(part)
+
+ volumes = []
+ if disk_config.lvm_config:
+ for vol in disk_config.lvm_config.get_all_volumes():
+ if vol.obj_id in arg.get('lvm_volumes', []):
+ volumes.append(vol)
+
+ enc = DiskEncryption(
+ EncryptionType(arg['encryption_type']),
+ password,
+ enc_partitions,
+ volumes
+ )
+
+ if hsm := arg.get('hsm_device', None):
+ enc.hsm_device = Fido2Device.parse_arg(hsm)
+
+ return enc
+
+
+@dataclass
+class Fido2Device:
+ path: Path
+ manufacturer: str
+ product: str
+
+ def json(self) -> Dict[str, str]:
+ return {
+ 'path': str(self.path),
+ 'manufacturer': self.manufacturer,
+ 'product': self.product
+ }
+
+ def table_data(self) -> Dict[str, str]:
+ return {
+ 'Path': str(self.path),
+ 'Manufacturer': self.manufacturer,
+ 'Product': self.product
+ }
+
+ @classmethod
+ def parse_arg(cls, arg: Dict[str, str]) -> 'Fido2Device':
+ return Fido2Device(
+ Path(arg['path']),
+ arg['manufacturer'],
+ arg['product']
+ )
+
+
+@dataclass
+class LsblkInfo:
+ name: str = ''
+ path: Path = Path()
+ pkname: str = ''
+ size: Size = field(default_factory=lambda: Size(0, Unit.B, SectorSize.default()))
+ log_sec: int = 0
+ pttype: str = ''
+ ptuuid: str = ''
+ rota: bool = False
+ tran: Optional[str] = None
+ partn: Optional[int] = None
+ partuuid: Optional[str] = None
+ parttype: Optional[str] = None
+ uuid: Optional[str] = None
+ fstype: Optional[str] = None
+ fsver: Optional[str] = None
+ fsavail: Optional[str] = None
+ fsuse_percentage: Optional[str] = None
+ type: Optional[str] = None
+ mountpoint: Optional[Path] = None
+ mountpoints: List[Path] = field(default_factory=list)
+ fsroots: List[Path] = field(default_factory=list)
+ children: List[LsblkInfo] = field(default_factory=list)
+
+ def json(self) -> Dict[str, Any]:
+ return {
+ 'name': self.name,
+ 'path': str(self.path),
+ 'pkname': self.pkname,
+ 'size': self.size.format_size(Unit.MiB),
+ 'log_sec': self.log_sec,
+ 'pttype': self.pttype,
+ 'ptuuid': self.ptuuid,
+ 'rota': self.rota,
+ 'tran': self.tran,
+ 'partn': self.partn,
+ 'partuuid': self.partuuid,
+ 'parttype': self.parttype,
+ 'uuid': self.uuid,
+ 'fstype': self.fstype,
+ 'fsver': self.fsver,
+ 'fsavail': self.fsavail,
+ 'fsuse_percentage': self.fsuse_percentage,
+ 'type': self.type,
+ 'mountpoint': self.mountpoint,
+ 'mountpoints': [str(m) for m in self.mountpoints],
+ 'fsroots': [str(r) for r in self.fsroots],
+ 'children': [c.json() for c in self.children]
+ }
+
+ @property
+ def btrfs_subvol_info(self) -> Dict[Path, Path]:
+ """
+ It is assumed that lsblk will contain the fields as
+
+ "mountpoints": ["/mnt/archinstall/log", "/mnt/archinstall/home", "/mnt/archinstall", ...]
+ "fsroots": ["/@log", "/@home", "/@"...]
+
+ we'll thereby map the fsroot, which are the mounted filesystem roots
+ to the corresponding mountpoints
+ """
+ return dict(zip(self.fsroots, self.mountpoints))
+
+ @classmethod
+ def exclude(cls) -> List[str]:
+ return ['children']
+
+ @classmethod
+ def fields(cls) -> List[str]:
+ return [f.name for f in dataclasses.fields(LsblkInfo) if f.name not in cls.exclude()]
+
+ @classmethod
+ def from_json(cls, blockdevice: Dict[str, Any]) -> LsblkInfo:
+ lsblk_info = cls()
+
+ for f in cls.fields():
+ lsblk_field = _clean_field(f, CleanType.Blockdevice)
+ data_field = _clean_field(f, CleanType.Dataclass)
+
+ val: Any = None
+ if isinstance(getattr(lsblk_info, data_field), Path):
+ val = Path(blockdevice[lsblk_field])
+ elif isinstance(getattr(lsblk_info, data_field), Size):
+ sector_size = SectorSize(blockdevice['log-sec'], Unit.B)
+ val = Size(blockdevice[lsblk_field], Unit.B, sector_size)
+ else:
+ val = blockdevice[lsblk_field]
+
+ setattr(lsblk_info, data_field, val)
+
+ lsblk_info.children = [LsblkInfo.from_json(child) for child in blockdevice.get('children', [])]
+
+ # sometimes lsblk returns 'mountpoints': [null]
+ lsblk_info.mountpoints = [Path(mnt) for mnt in lsblk_info.mountpoints if mnt]
+
+ fs_roots = []
+ for r in lsblk_info.fsroots:
+ if r:
+ path = Path(r)
+ # store the fsroot entries without the leading /
+ fs_roots.append(path.relative_to(path.anchor))
+ lsblk_info.fsroots = fs_roots
+
+ return lsblk_info
+
+
+class CleanType(Enum):
+ Blockdevice = auto()
+ Dataclass = auto()
+ Lsblk = auto()
+
+
+def _clean_field(name: str, clean_type: CleanType) -> str:
+ match clean_type:
+ case CleanType.Blockdevice:
+ return name.replace('_percentage', '%').replace('_', '-')
+ case CleanType.Dataclass:
+ return name.lower().replace('-', '_').replace('%', '_percentage')
+ case CleanType.Lsblk:
+ return name.replace('_percentage', '%').replace('_', '-')
+
+
+def _fetch_lsblk_info(
+ dev_path: Optional[Union[Path, str]] = None,
+ reverse: bool = False,
+ full_dev_path: bool = False
+) -> List[LsblkInfo]:
+ fields = [_clean_field(f, CleanType.Lsblk) for f in LsblkInfo.fields()]
+ cmd = ['lsblk', '--json', '--bytes', '--output', '+' + ','.join(fields)]
+
+ if dev_path:
+ cmd.append(str(dev_path))
+
+ if reverse:
+ cmd.append('--inverse')
+
+ if full_dev_path:
+ cmd.append('--paths')
+
+ try:
+ result = SysCommand(cmd).decode()
+ except SysCallError as err:
+ # Get the output minus the message/info from lsblk if it returns a non-zero exit code.
+ if err.worker:
+ err_str = err.worker.decode()
+ debug(f'Error calling lsblk: {err_str}')
+
+ if dev_path:
+ raise DiskError(f'Failed to read disk "{dev_path}" with lsblk')
+
+ raise err
+
+ try:
+ block_devices = json.loads(result)
+ except json.decoder.JSONDecodeError as err:
+ error(f"Could not decode lsblk JSON: {result}")
+ raise err
+
+ blockdevices = block_devices['blockdevices']
+ return [LsblkInfo.from_json(device) for device in blockdevices]
+
+
+def get_lsblk_info(
+ dev_path: Union[Path, str],
+ reverse: bool = False,
+ full_dev_path: bool = False
+) -> LsblkInfo:
+ if infos := _fetch_lsblk_info(dev_path, reverse=reverse, full_dev_path=full_dev_path):
+ return infos[0]
+
+ raise DiskError(f'lsblk failed to retrieve information for "{dev_path}"')
+
+
+def get_all_lsblk_info() -> List[LsblkInfo]:
+ return _fetch_lsblk_info()
+
+
+def get_lsblk_by_mountpoint(mountpoint: Path, as_prefix: bool = False) -> List[LsblkInfo]:
+ def _check(infos: List[LsblkInfo]) -> List[LsblkInfo]:
+ devices = []
+ for entry in infos:
+ if as_prefix:
+ matches = [m for m in entry.mountpoints if str(m).startswith(str(mountpoint))]
+ if matches:
+ devices += [entry]
+ elif mountpoint in entry.mountpoints:
+ devices += [entry]
+
+ if len(entry.children) > 0:
+ if len(match := _check(entry.children)) > 0:
+ devices += match
+
+ return devices
+
+ all_info = get_all_lsblk_info()
+ return _check(all_info)
diff --git a/archinstall/lib/disk/disk_menu.py b/archinstall/lib/disk/disk_menu.py
new file mode 100644
index 00000000..a7d9ccc3
--- /dev/null
+++ b/archinstall/lib/disk/disk_menu.py
@@ -0,0 +1,140 @@
+from typing import Dict, Optional, Any, TYPE_CHECKING, List
+
+from . import DiskLayoutConfiguration, DiskLayoutType
+from .device_model import LvmConfiguration
+from ..disk import (
+ DeviceModification
+)
+from ..interactions import select_disk_config
+from ..interactions.disk_conf import select_lvm_config
+from ..menu import (
+ Selector,
+ AbstractSubMenu
+)
+from ..output import FormattedOutput
+
+if TYPE_CHECKING:
+ _: Any
+
+
+class DiskLayoutConfigurationMenu(AbstractSubMenu):
+ def __init__(
+ self,
+ disk_layout_config: Optional[DiskLayoutConfiguration],
+ data_store: Dict[str, Any],
+ advanced: bool = False
+ ):
+ self._disk_layout_config = disk_layout_config
+ self._advanced = advanced
+
+ super().__init__(data_store=data_store, preview_size=0.5)
+
+ def setup_selection_menu_options(self):
+ self._menu_options['disk_config'] = \
+ Selector(
+ _('Partitioning'),
+ lambda x: self._select_disk_layout_config(x),
+ display_func=lambda x: self._display_disk_layout(x),
+ preview_func=self._prev_disk_layouts,
+ default=self._disk_layout_config,
+ enabled=True
+ )
+ self._menu_options['lvm_config'] = \
+ Selector(
+ _('Logical Volume Management (LVM)'),
+ lambda x: self._select_lvm_config(x),
+ display_func=lambda x: self.defined_text if x else '',
+ preview_func=self._prev_lvm_config,
+ default=self._disk_layout_config.lvm_config if self._disk_layout_config else None,
+ dependencies=[self._check_dep_lvm],
+ enabled=True
+ )
+
+ def run(self, allow_reset: bool = True) -> Optional[DiskLayoutConfiguration]:
+ super().run(allow_reset=allow_reset)
+
+ disk_layout_config: Optional[DiskLayoutConfiguration] = self._data_store.get('disk_config', None)
+
+ if disk_layout_config:
+ disk_layout_config.lvm_config = self._data_store.get('lvm_config', None)
+
+ return disk_layout_config
+
+ def _check_dep_lvm(self) -> bool:
+ disk_layout_conf: Optional[DiskLayoutConfiguration] = self._menu_options['disk_config'].current_selection
+
+ if disk_layout_conf and disk_layout_conf.config_type == DiskLayoutType.Default:
+ return True
+
+ return False
+
+ def _select_disk_layout_config(
+ self,
+ preset: Optional[DiskLayoutConfiguration]
+ ) -> Optional[DiskLayoutConfiguration]:
+ disk_config = select_disk_config(preset, advanced_option=self._advanced)
+
+ if disk_config != preset:
+ self._menu_options['lvm_config'].set_current_selection(None)
+
+ return disk_config
+
+ def _select_lvm_config(self, preset: Optional[LvmConfiguration]) -> Optional[LvmConfiguration]:
+ disk_config: Optional[DiskLayoutConfiguration] = self._menu_options['disk_config'].current_selection
+ if disk_config:
+ return select_lvm_config(disk_config, preset=preset)
+ return preset
+
+ def _display_disk_layout(self, current_value: Optional[DiskLayoutConfiguration] = None) -> str:
+ if current_value:
+ return current_value.config_type.display_msg()
+ return ''
+
+ def _prev_disk_layouts(self) -> Optional[str]:
+ disk_layout_conf: Optional[DiskLayoutConfiguration] = self._menu_options['disk_config'].current_selection
+
+ if disk_layout_conf:
+ device_mods: List[DeviceModification] = \
+ list(filter(lambda x: len(x.partitions) > 0, disk_layout_conf.device_modifications))
+
+ if device_mods:
+ output_partition = '{}: {}\n'.format(str(_('Configuration')), disk_layout_conf.config_type.display_msg())
+ output_btrfs = ''
+
+ for mod in device_mods:
+ # create partition table
+ partition_table = FormattedOutput.as_table(mod.partitions)
+
+ output_partition += f'{mod.device_path}: {mod.device.device_info.model}\n'
+ output_partition += partition_table + '\n'
+
+ # create btrfs table
+ btrfs_partitions = list(
+ filter(lambda p: len(p.btrfs_subvols) > 0, mod.partitions)
+ )
+ for partition in btrfs_partitions:
+ output_btrfs += FormattedOutput.as_table(partition.btrfs_subvols) + '\n'
+
+ output = output_partition + output_btrfs
+ return output.rstrip()
+
+ return None
+
+ def _prev_lvm_config(self) -> Optional[str]:
+ lvm_config: Optional[LvmConfiguration] = self._menu_options['lvm_config'].current_selection
+
+ if lvm_config:
+ output = '{}: {}\n'.format(str(_('Configuration')), lvm_config.config_type.display_msg())
+
+ for vol_gp in lvm_config.vol_groups:
+ pv_table = FormattedOutput.as_table(vol_gp.pvs)
+ output += '{}:\n{}'.format(str(_('Physical volumes')), pv_table)
+
+ output += f'\nVolume Group: {vol_gp.name}'
+
+ lvm_volumes = FormattedOutput.as_table(vol_gp.volumes)
+ output += '\n\n{}:\n{}'.format(str(_('Volumes')), lvm_volumes)
+
+ return output
+
+ return None
diff --git a/archinstall/lib/disk/diskinfo.py b/archinstall/lib/disk/diskinfo.py
deleted file mode 100644
index b56ba282..00000000
--- a/archinstall/lib/disk/diskinfo.py
+++ /dev/null
@@ -1,40 +0,0 @@
-import dataclasses
-import json
-from dataclasses import dataclass, field
-from typing import Optional, List
-
-from ..general import SysCommand
-from ..exceptions import DiskError
-
-@dataclass
-class LsblkInfo:
- size: int = 0
- log_sec: int = 0
- pttype: Optional[str] = None
- rota: bool = False
- tran: Optional[str] = None
- ptuuid: Optional[str] = None
- partuuid: Optional[str] = None
- uuid: Optional[str] = None
- fstype: Optional[str] = None
- type: Optional[str] = None
- mountpoints: List[str] = field(default_factory=list)
-
-
-def get_lsblk_info(dev_path: str) -> LsblkInfo:
- fields = [f.name for f in dataclasses.fields(LsblkInfo)]
- lsblk_fields = ','.join([f.upper().replace('_', '-') for f in fields])
-
- output = SysCommand(f'lsblk --json -b -o+{lsblk_fields} {dev_path}').decode('UTF-8')
-
- if output:
- block_devices = json.loads(output)
- info = block_devices['blockdevices'][0]
- lsblk_info = LsblkInfo()
-
- for f in fields:
- setattr(lsblk_info, f, info[f.replace('_', '-')])
-
- return lsblk_info
-
- raise DiskError(f'Failed to read disk "{dev_path}" with lsblk')
diff --git a/archinstall/lib/disk/dmcryptdev.py b/archinstall/lib/disk/dmcryptdev.py
deleted file mode 100644
index 63392ffb..00000000
--- a/archinstall/lib/disk/dmcryptdev.py
+++ /dev/null
@@ -1,48 +0,0 @@
-import pathlib
-import logging
-import json
-from dataclasses import dataclass
-from typing import Optional
-from ..exceptions import SysCallError
-from ..general import SysCommand
-from ..output import log
-from .mapperdev import MapperDev
-
-@dataclass
-class DMCryptDev:
- dev_path :pathlib.Path
-
- @property
- def name(self):
- with open(f"/sys/devices/virtual/block/{pathlib.Path(self.path).name}/dm/name", "r") as fh:
- return fh.read().strip()
-
- @property
- def path(self):
- return f"/dev/mapper/{self.dev_path}"
-
- @property
- def blockdev(self):
- pass
-
- @property
- def MapperDev(self):
- return MapperDev(mappername=self.name)
-
- @property
- def mountpoint(self) -> Optional[str]:
- try:
- data = json.loads(SysCommand(f"findmnt --json -R {self.dev_path}").decode())
- for filesystem in data['filesystems']:
- return filesystem.get('target')
-
- except SysCallError as error:
- # Not mounted anywhere most likely
- log(f"Could not locate mount information for {self.dev_path}: {error}", level=logging.WARNING, fg="yellow")
- pass
-
- return None
-
- @property
- def filesystem(self) -> Optional[str]:
- return self.MapperDev.filesystem \ No newline at end of file
diff --git a/archinstall/lib/disk/encryption.py b/archinstall/lib/disk/encryption.py
deleted file mode 100644
index c7496bfa..00000000
--- a/archinstall/lib/disk/encryption.py
+++ /dev/null
@@ -1,174 +0,0 @@
-from typing import Dict, Optional, Any, TYPE_CHECKING, List
-
-from ..menu.abstract_menu import Selector, AbstractSubMenu
-from ..menu.menu import MenuSelectionType
-from ..menu.table_selection_menu import TableMenu
-from ..models.disk_encryption import EncryptionType, DiskEncryption
-from ..user_interaction.partitioning_conf import current_partition_layout
-from ..user_interaction.utils import get_password
-from ..menu import Menu
-from ..general import secret
-from ..hsm.fido import Fido2Device, Fido2
-
-if TYPE_CHECKING:
- _: Any
-
-
-class DiskEncryptionMenu(AbstractSubMenu):
- def __init__(self, data_store: Dict[str, Any], preset: Optional[DiskEncryption], disk_layouts: Dict[str, Any]):
- if preset:
- self._preset = preset
- else:
- self._preset = DiskEncryption()
-
- self._disk_layouts = disk_layouts
- super().__init__(data_store=data_store)
-
- def _setup_selection_menu_options(self):
- self._menu_options['encryption_password'] = \
- Selector(
- _('Encryption password'),
- lambda x: select_encrypted_password(),
- display_func=lambda x: secret(x) if x else '',
- default=self._preset.encryption_password,
- enabled=True
- )
- self._menu_options['encryption_type'] = \
- Selector(
- _('Encryption type'),
- func=lambda preset: select_encryption_type(preset),
- display_func=lambda x: EncryptionType.type_to_text(x) if x else None,
- dependencies=['encryption_password'],
- default=self._preset.encryption_type,
- enabled=True
- )
- self._menu_options['partitions'] = \
- Selector(
- _('Partitions'),
- func=lambda preset: select_partitions_to_encrypt(self._disk_layouts, preset),
- display_func=lambda x: f'{sum([len(y) for y in x.values()])} {_("Partitions")}' if x else None,
- dependencies=['encryption_password'],
- default=self._preset.partitions,
- preview_func=self._prev_disk_layouts,
- enabled=True
- )
- self._menu_options['HSM'] = \
- Selector(
- description=_('Use HSM to unlock encrypted drive'),
- func=lambda preset: select_hsm(preset),
- display_func=lambda x: self._display_hsm(x),
- dependencies=['encryption_password'],
- default=self._preset.hsm_device,
- enabled=True
- )
-
- def run(self, allow_reset: bool = True) -> Optional[DiskEncryption]:
- super().run(allow_reset=allow_reset)
-
- if self._data_store.get('encryption_password', None):
- return DiskEncryption(
- encryption_password=self._data_store.get('encryption_password', None),
- encryption_type=self._data_store['encryption_type'],
- partitions=self._data_store.get('partitions', None),
- hsm_device=self._data_store.get('HSM', None)
- )
-
- return None
-
- def _display_hsm(self, device: Optional[Fido2Device]) -> Optional[str]:
- if device:
- return device.manufacturer
-
- if not Fido2.get_fido2_devices():
- return str(_('No HSM devices available'))
- return None
-
- def _prev_disk_layouts(self) -> Optional[str]:
- selector = self._menu_options['partitions']
- if selector.has_selection():
- partitions: Dict[str, Any] = selector.current_selection
-
- all_partitions = []
- for parts in partitions.values():
- all_partitions += parts
-
- output = str(_('Partitions to be encrypted')) + '\n'
- output += current_partition_layout(all_partitions, with_title=False)
- return output.rstrip()
- return None
-
-
-def select_encryption_type(preset: EncryptionType) -> Optional[EncryptionType]:
- title = str(_('Select disk encryption option'))
- options = [
- # _type_to_text(EncryptionType.FullDiskEncryption),
- EncryptionType.type_to_text(EncryptionType.Partition)
- ]
-
- preset_value = EncryptionType.type_to_text(preset)
- choice = Menu(title, options, preset_values=preset_value).run()
-
- match choice.type_:
- case MenuSelectionType.Reset: return None
- case MenuSelectionType.Skip: return preset
- case MenuSelectionType.Selection: return EncryptionType.text_to_type(choice.value) # type: ignore
-
-
-def select_encrypted_password() -> Optional[str]:
- if passwd := get_password(prompt=str(_('Enter disk encryption password (leave blank for no encryption): '))):
- return passwd
- return None
-
-
-def select_hsm(preset: Optional[Fido2Device] = None) -> Optional[Fido2Device]:
- title = _('Select a FIDO2 device to use for HSM')
- fido_devices = Fido2.get_fido2_devices()
-
- if fido_devices:
- choice = TableMenu(title, data=fido_devices).run()
- match choice.type_:
- case MenuSelectionType.Reset:
- return None
- case MenuSelectionType.Skip:
- return preset
- case MenuSelectionType.Selection:
- return choice.value # type: ignore
-
- return None
-
-
-def select_partitions_to_encrypt(disk_layouts: Dict[str, Any], preset: Dict[str, Any]) -> Dict[str, Any]:
- # If no partitions was marked as encrypted, but a password was supplied and we have some disks to format..
- # Then we need to identify which partitions to encrypt. This will default to / (root).
- all_partitions = []
- for blockdevice in disk_layouts.values():
- if partitions := blockdevice.get('partitions'):
- partitions = [p for p in partitions if p['mountpoint'] != '/boot']
- all_partitions += partitions
-
- if all_partitions:
- title = str(_('Select which partitions to encrypt'))
- partition_table = current_partition_layout(all_partitions, with_title=False).strip()
-
- choice = TableMenu(
- title,
- table_data=(all_partitions, partition_table),
- multi=True
- ).run()
-
- match choice.type_:
- case MenuSelectionType.Reset:
- return {}
- case MenuSelectionType.Skip:
- return preset
- case MenuSelectionType.Selection:
- selections: List[Any] = choice.value # type: ignore
- partitions = {}
-
- for path, device in disk_layouts.items():
- for part in selections:
- if part in device.get('partitions', []):
- partitions.setdefault(path, []).append(part)
-
- return partitions
- return {}
diff --git a/archinstall/lib/disk/encryption_menu.py b/archinstall/lib/disk/encryption_menu.py
new file mode 100644
index 00000000..b0e292ce
--- /dev/null
+++ b/archinstall/lib/disk/encryption_menu.py
@@ -0,0 +1,288 @@
+from pathlib import Path
+from typing import Dict, Optional, Any, TYPE_CHECKING, List
+
+from . import LvmConfiguration, LvmVolume
+from ..disk import (
+ DeviceModification,
+ DiskLayoutConfiguration,
+ PartitionModification,
+ DiskEncryption,
+ EncryptionType
+)
+from ..menu import (
+ Selector,
+ AbstractSubMenu,
+ MenuSelectionType,
+ TableMenu
+)
+from ..interactions.utils import get_password
+from ..menu import Menu
+from ..general import secret
+from .fido import Fido2Device, Fido2
+from ..output import FormattedOutput
+
+if TYPE_CHECKING:
+ _: Any
+
+
+class DiskEncryptionMenu(AbstractSubMenu):
+ def __init__(
+ self,
+ disk_config: DiskLayoutConfiguration,
+ data_store: Dict[str, Any],
+ preset: Optional[DiskEncryption] = None
+ ):
+ if preset:
+ self._preset = preset
+ else:
+ self._preset = DiskEncryption()
+
+ self._disk_config = disk_config
+ super().__init__(data_store=data_store)
+
+ def setup_selection_menu_options(self):
+ self._menu_options['encryption_type'] = \
+ Selector(
+ _('Encryption type'),
+ func=lambda preset: select_encryption_type(self._disk_config, preset),
+ display_func=lambda x: EncryptionType.type_to_text(x) if x else None,
+ default=self._preset.encryption_type,
+ enabled=True,
+ )
+ self._menu_options['encryption_password'] = \
+ Selector(
+ _('Encryption password'),
+ lambda x: select_encrypted_password(),
+ dependencies=[self._check_dep_enc_type],
+ display_func=lambda x: secret(x) if x else '',
+ default=self._preset.encryption_password,
+ enabled=True
+ )
+ self._menu_options['partitions'] = \
+ Selector(
+ _('Partitions'),
+ func=lambda preset: select_partitions_to_encrypt(self._disk_config.device_modifications, preset),
+ display_func=lambda x: f'{len(x)} {_("Partitions")}' if x else None,
+ dependencies=[self._check_dep_partitions],
+ default=self._preset.partitions,
+ preview_func=self._prev_partitions,
+ enabled=True
+ )
+ self._menu_options['lvm_vols'] = \
+ Selector(
+ _('LVM volumes'),
+ func=lambda preset: self._select_lvm_vols(preset),
+ display_func=lambda x: f'{len(x)} {_("LVM volumes")}' if x else None,
+ dependencies=[self._check_dep_lvm_vols],
+ default=self._preset.lvm_volumes,
+ preview_func=self._prev_lvm_vols,
+ enabled=True
+ )
+ self._menu_options['HSM'] = \
+ Selector(
+ description=_('Use HSM to unlock encrypted drive'),
+ func=lambda preset: select_hsm(preset),
+ display_func=lambda x: self._display_hsm(x),
+ preview_func=self._prev_hsm,
+ dependencies=[self._check_dep_enc_type],
+ default=self._preset.hsm_device,
+ enabled=True
+ )
+
+ def _select_lvm_vols(self, preset: List[LvmVolume]) -> List[LvmVolume]:
+ if self._disk_config.lvm_config:
+ return select_lvm_vols_to_encrypt(self._disk_config.lvm_config, preset=preset)
+ return []
+
+ def _check_dep_enc_type(self) -> bool:
+ enc_type: Optional[EncryptionType] = self._menu_options['encryption_type'].current_selection
+ if enc_type and enc_type != EncryptionType.NoEncryption:
+ return True
+ return False
+
+ def _check_dep_partitions(self) -> bool:
+ enc_type: Optional[EncryptionType] = self._menu_options['encryption_type'].current_selection
+ if enc_type and enc_type in [EncryptionType.Luks, EncryptionType.LvmOnLuks]:
+ return True
+ return False
+
+ def _check_dep_lvm_vols(self) -> bool:
+ enc_type: Optional[EncryptionType] = self._menu_options['encryption_type'].current_selection
+ if enc_type and enc_type == EncryptionType.LuksOnLvm:
+ return True
+ return False
+
+ def run(self, allow_reset: bool = True) -> Optional[DiskEncryption]:
+ super().run(allow_reset=allow_reset)
+
+ enc_type = self._data_store.get('encryption_type', None)
+ enc_password = self._data_store.get('encryption_password', None)
+ enc_partitions = self._data_store.get('partitions', None)
+ enc_lvm_vols = self._data_store.get('lvm_vols', None)
+
+ if enc_type in [EncryptionType.Luks, EncryptionType.LvmOnLuks] and enc_partitions:
+ enc_lvm_vols = []
+
+ if enc_type == EncryptionType.LuksOnLvm:
+ enc_partitions = []
+
+ if enc_type != EncryptionType.NoEncryption and enc_password and (enc_partitions or enc_lvm_vols):
+ return DiskEncryption(
+ encryption_password=enc_password,
+ encryption_type=enc_type,
+ partitions=enc_partitions,
+ lvm_volumes=enc_lvm_vols,
+ hsm_device=self._data_store.get('HSM', None)
+ )
+
+ return None
+
+ def _display_hsm(self, device: Optional[Fido2Device]) -> Optional[str]:
+ if device:
+ return device.manufacturer
+
+ return None
+
+ def _prev_partitions(self) -> Optional[str]:
+ partitions: Optional[List[PartitionModification]] = self._menu_options['partitions'].current_selection
+ if partitions:
+ output = str(_('Partitions to be encrypted')) + '\n'
+ output += FormattedOutput.as_table(partitions)
+ return output.rstrip()
+
+ return None
+
+ def _prev_lvm_vols(self) -> Optional[str]:
+ volumes: Optional[List[PartitionModification]] = self._menu_options['lvm_vols'].current_selection
+ if volumes:
+ output = str(_('LVM volumes to be encrypted')) + '\n'
+ output += FormattedOutput.as_table(volumes)
+ return output.rstrip()
+
+ return None
+
+ def _prev_hsm(self) -> Optional[str]:
+ try:
+ Fido2.get_fido2_devices()
+ except ValueError:
+ return str(_('Unable to determine fido2 devices. Is libfido2 installed?'))
+
+ fido_device: Optional[Fido2Device] = self._menu_options['HSM'].current_selection
+
+ if fido_device:
+ output = '{}: {}'.format(str(_('Path')), fido_device.path)
+ output += '{}: {}'.format(str(_('Manufacturer')), fido_device.manufacturer)
+ output += '{}: {}'.format(str(_('Product')), fido_device.product)
+ return output
+
+ return None
+
+
+def select_encryption_type(disk_config: DiskLayoutConfiguration, preset: EncryptionType) -> Optional[EncryptionType]:
+ title = str(_('Select disk encryption option'))
+
+ if disk_config.lvm_config:
+ options = [
+ EncryptionType.type_to_text(EncryptionType.LvmOnLuks),
+ EncryptionType.type_to_text(EncryptionType.LuksOnLvm)
+ ]
+ else:
+ options = [EncryptionType.type_to_text(EncryptionType.Luks)]
+
+ preset_value = EncryptionType.type_to_text(preset)
+
+ choice = Menu(title, options, preset_values=preset_value).run()
+
+ match choice.type_:
+ case MenuSelectionType.Reset: return None
+ case MenuSelectionType.Skip: return preset
+ case MenuSelectionType.Selection: return EncryptionType.text_to_type(choice.value) # type: ignore
+
+
+def select_encrypted_password() -> Optional[str]:
+ if passwd := get_password(prompt=str(_('Enter disk encryption password (leave blank for no encryption): '))):
+ return passwd
+ return None
+
+
+def select_hsm(preset: Optional[Fido2Device] = None) -> Optional[Fido2Device]:
+ title = _('Select a FIDO2 device to use for HSM')
+
+ try:
+ fido_devices = Fido2.get_fido2_devices()
+ except ValueError:
+ return None
+
+ if fido_devices:
+ choice = TableMenu(title, data=fido_devices).run()
+ match choice.type_:
+ case MenuSelectionType.Reset:
+ return None
+ case MenuSelectionType.Skip:
+ return preset
+ case MenuSelectionType.Selection:
+ return choice.value # type: ignore
+
+ return None
+
+
+def select_partitions_to_encrypt(
+ modification: List[DeviceModification],
+ preset: List[PartitionModification]
+) -> List[PartitionModification]:
+ partitions: List[PartitionModification] = []
+
+ # do not allow encrypting the boot partition
+ for mod in modification:
+ partitions += list(filter(lambda x: x.mountpoint != Path('/boot'), mod.partitions))
+
+ # do not allow encrypting existing partitions that are not marked as wipe
+ avail_partitions = list(filter(lambda x: not x.exists(), partitions))
+
+ if avail_partitions:
+ title = str(_('Select which partitions to encrypt'))
+ partition_table = FormattedOutput.as_table(avail_partitions)
+
+ choice = TableMenu(
+ title,
+ table_data=(avail_partitions, partition_table),
+ preset=preset,
+ multi=True
+ ).run()
+
+ match choice.type_:
+ case MenuSelectionType.Reset:
+ return []
+ case MenuSelectionType.Skip:
+ return preset
+ case MenuSelectionType.Selection:
+ return choice.multi_value
+ return []
+
+
+def select_lvm_vols_to_encrypt(
+ lvm_config: LvmConfiguration,
+ preset: List[LvmVolume]
+) -> List[LvmVolume]:
+ volumes: List[LvmVolume] = lvm_config.get_all_volumes()
+
+ if volumes:
+ title = str(_('Select which LVM volumes to encrypt'))
+ partition_table = FormattedOutput.as_table(volumes)
+
+ choice = TableMenu(
+ title,
+ table_data=(volumes, partition_table),
+ preset=preset,
+ multi=True
+ ).run()
+
+ match choice.type_:
+ case MenuSelectionType.Reset:
+ return []
+ case MenuSelectionType.Skip:
+ return preset
+ case MenuSelectionType.Selection:
+ return choice.multi_value
+
+ return []
diff --git a/archinstall/lib/hsm/fido.py b/archinstall/lib/disk/fido.py
index 1c226322..5a139534 100644
--- a/archinstall/lib/hsm/fido.py
+++ b/archinstall/lib/disk/fido.py
@@ -1,37 +1,13 @@
from __future__ import annotations
import getpass
-import logging
-
-from dataclasses import dataclass
from pathlib import Path
-from typing import List, Dict
+from typing import List
+from .device_model import Fido2Device
from ..general import SysCommand, SysCommandWorker, clear_vt100_escape_codes
-from ..disk.partition import Partition
-from ..general import log
-
-
-@dataclass
-class Fido2Device:
- path: Path
- manufacturer: str
- product: str
-
- def json(self) -> Dict[str, str]:
- return {
- 'path': str(self.path),
- 'manufacturer': self.manufacturer,
- 'product': self.product
- }
-
- @classmethod
- def parse_arg(cls, arg: Dict[str, str]) -> 'Fido2Device':
- return Fido2Device(
- Path(arg['path']),
- arg['manufacturer'],
- arg['product']
- )
+from ..output import error, info
+from ..exceptions import SysCallError
class Fido2:
@@ -58,15 +34,16 @@ class Fido2:
/dev/hidraw1 Yubico YubiKey OTP+FIDO+CCID
"""
- # to prevent continous reloading which will slow
+ # to prevent continuous reloading which will slow
# down moving the cursor in the menu
if not cls._loaded or reload:
- ret = SysCommand(f"systemd-cryptenroll --fido2-device=list").decode('UTF-8')
- if not ret:
- log('Unable to retrieve fido2 devices', level=logging.ERROR)
- return []
+ try:
+ ret = SysCommand("systemd-cryptenroll --fido2-device=list").decode()
+ except SysCallError:
+ error('fido2 support is most likely not installed')
+ raise ValueError('HSM devices can not be detected, is libfido2 installed?')
- fido_devices = clear_vt100_escape_codes(ret)
+ fido_devices: str = clear_vt100_escape_codes(ret) # type: ignore
manufacturer_pos = 0
product_pos = 0
@@ -83,7 +60,7 @@ class Fido2:
product = line[product_pos:]
devices.append(
- Fido2Device(path, manufacturer, product)
+ Fido2Device(Path(path), manufacturer, product)
)
cls._loaded = True
@@ -92,18 +69,24 @@ class Fido2:
return cls._fido2_devices
@classmethod
- def fido2_enroll(cls, hsm_device: Fido2Device, partition :Partition, password :str):
- worker = SysCommandWorker(f"systemd-cryptenroll --fido2-device={hsm_device.path} {partition.real_device}", peek_output=True)
+ def fido2_enroll(
+ cls,
+ hsm_device: Fido2Device,
+ dev_path: Path,
+ password: str
+ ):
+ worker = SysCommandWorker(f"systemd-cryptenroll --fido2-device={hsm_device.path} {dev_path}", peek_output=True)
pw_inputted = False
pin_inputted = False
while worker.is_alive():
- if pw_inputted is False and bytes(f"please enter current passphrase for disk {partition.real_device}", 'UTF-8') in worker._trace_log.lower():
- worker.write(bytes(password, 'UTF-8'))
- pw_inputted = True
-
- elif pin_inputted is False and bytes(f"please enter security token pin", 'UTF-8') in worker._trace_log.lower():
- worker.write(bytes(getpass.getpass(" "), 'UTF-8'))
- pin_inputted = True
-
- log(f"You might need to touch the FIDO2 device to unlock it if no prompt comes up after 3 seconds.", level=logging.INFO, fg="yellow")
+ if pw_inputted is False:
+ if bytes(f"please enter current passphrase for disk {dev_path}", 'UTF-8') in worker._trace_log.lower():
+ worker.write(bytes(password, 'UTF-8'))
+ pw_inputted = True
+ elif pin_inputted is False:
+ if bytes(f"please enter security token pin", 'UTF-8') in worker._trace_log.lower():
+ worker.write(bytes(getpass.getpass(" "), 'UTF-8'))
+ pin_inputted = True
+
+ info('You might need to touch the FIDO2 device to unlock it if no prompt comes up after 3 seconds')
diff --git a/archinstall/lib/disk/filesystem.py b/archinstall/lib/disk/filesystem.py
index 1083df53..5c11896e 100644
--- a/archinstall/lib/disk/filesystem.py
+++ b/archinstall/lib/disk/filesystem.py
@@ -1,301 +1,381 @@
from __future__ import annotations
+
+import signal
+import sys
import time
-import logging
-import json
-import pathlib
-from typing import Optional, Dict, Any, TYPE_CHECKING
-# https://stackoverflow.com/a/39757388/929999
-from ..models.disk_encryption import DiskEncryption
+from pathlib import Path
+from typing import Any, Optional, TYPE_CHECKING, List, Dict, Set
+
+from .device_handler import device_handler
+from .device_model import (
+ DiskLayoutConfiguration, DiskLayoutType, PartitionTable,
+ FilesystemType, DiskEncryption, LvmVolumeGroup,
+ Size, Unit, SectorSize, PartitionModification, EncryptionType,
+ LvmVolume, LvmConfiguration
+)
+from ..hardware import SysInfo
+from ..luks import Luks2
+from ..menu import Menu
+from ..output import debug, info
+from ..general import SysCommand
if TYPE_CHECKING:
- from .blockdevice import BlockDevice
_: Any
-from .partition import Partition
-from .validators import valid_fs_type
-from ..exceptions import DiskError, SysCallError
-from ..general import SysCommand
-from ..output import log
-from ..storage import storage
-GPT = 0b00000001
-MBR = 0b00000010
+class FilesystemHandler:
+ def __init__(
+ self,
+ disk_config: DiskLayoutConfiguration,
+ enc_conf: Optional[DiskEncryption] = None
+ ):
+ self._disk_config = disk_config
+ self._enc_config = enc_conf
+
+ def perform_filesystem_operations(self, show_countdown: bool = True):
+ if self._disk_config.config_type == DiskLayoutType.Pre_mount:
+ debug('Disk layout configuration is set to pre-mount, not performing any operations')
+ return
+
+ device_mods = list(filter(lambda x: len(x.partitions) > 0, self._disk_config.device_modifications))
+
+ if not device_mods:
+ debug('No modifications required')
+ return
+
+ device_paths = ', '.join([str(mod.device.device_info.path) for mod in device_mods])
+
+ # Issue a final warning before we continue with something un-revertable.
+ # We mention the drive one last time, and count from 5 to 0.
+ print(str(_(' ! Formatting {} in ')).format(device_paths))
+
+ if show_countdown:
+ self._do_countdown()
+
+ # Setup the blockdevice, filesystem (and optionally encryption).
+ # Once that's done, we'll hand over to perform_installation()
+ partition_table = PartitionTable.GPT
+ if SysInfo.has_uefi() is False:
+ partition_table = PartitionTable.MBR
+
+ for mod in device_mods:
+ device_handler.partition(mod, partition_table=partition_table)
+
+ if self._disk_config.lvm_config:
+ for mod in device_mods:
+ if boot_part := mod.get_boot_partition():
+ debug(f'Formatting boot partition: {boot_part.dev_path}')
+ self._format_partitions(
+ [boot_part],
+ mod.device_path
+ )
+
+ self.perform_lvm_operations()
+ else:
+ for mod in device_mods:
+ self._format_partitions(
+ mod.partitions,
+ mod.device_path
+ )
-# A sane default is 5MiB, that allows for plenty of buffer for GRUB on MBR
-# but also 4MiB for memory cards for instance. And another 1MiB to avoid issues.
-# (we've been pestered by disk issues since the start, so please let this be here for a few versions)
-DEFAULT_PARTITION_START = '5MiB'
+ for part_mod in mod.partitions:
+ if part_mod.fs_type == FilesystemType.Btrfs:
+ device_handler.create_btrfs_volumes(part_mod, enc_conf=self._enc_config)
-class Filesystem:
- # TODO:
- # When instance of a HDD is selected, check all usages and gracefully unmount them
- # as well as close any crypto handles.
- def __init__(self, blockdevice :BlockDevice, mode :int):
- self.blockdevice = blockdevice
- self.mode = mode
+ def _format_partitions(
+ self,
+ partitions: List[PartitionModification],
+ device_path: Path
+ ):
+ """
+ Format can be given an overriding path, for instance /dev/null to test
+ the formatting functionality and in essence the support for the given filesystem.
+ """
- def __enter__(self, *args :str, **kwargs :str) -> 'Filesystem':
- return self
+ # don't touch existing partitions
+ create_or_modify_parts = [p for p in partitions if p.is_create_or_modify()]
- def __repr__(self) -> str:
- return f"Filesystem(blockdevice={self.blockdevice}, mode={self.mode})"
+ self._validate_partitions(create_or_modify_parts)
- def __exit__(self, *args :str, **kwargs :str) -> bool:
- # TODO: https://stackoverflow.com/questions/28157929/how-to-safely-handle-an-exception-inside-a-context-manager
- if len(args) >= 2 and args[1]:
- raise args[1]
+ # make sure all devices are unmounted
+ device_handler.umount_all_existing(device_path)
- SysCommand('sync')
- return True
+ for part_mod in create_or_modify_parts:
+ # partition will be encrypted
+ if self._enc_config is not None and part_mod in self._enc_config.partitions:
+ device_handler.format_encrypted(
+ part_mod.safe_dev_path,
+ part_mod.mapper_name,
+ part_mod.safe_fs_type,
+ self._enc_config
+ )
+ else:
+ device_handler.format(part_mod.safe_fs_type, part_mod.safe_dev_path)
+
+ # synchronize with udev before using lsblk
+ SysCommand('udevadm settle')
+
+ lsblk_info = device_handler.fetch_part_info(part_mod.safe_dev_path)
+
+ part_mod.partn = lsblk_info.partn
+ part_mod.partuuid = lsblk_info.partuuid
+ part_mod.uuid = lsblk_info.uuid
+
+ def _validate_partitions(self, partitions: List[PartitionModification]):
+ checks = {
+ # verify that all partitions have a path set (which implies that they have been created)
+ lambda x: x.dev_path is None: ValueError('When formatting, all partitions must have a path set'),
+ # crypto luks is not a valid file system type
+ lambda x: x.fs_type is FilesystemType.Crypto_luks: ValueError(
+ 'Crypto luks cannot be set as a filesystem type'),
+ # file system type must be set
+ lambda x: x.fs_type is None: ValueError('File system type must be set for modification')
+ }
+
+ for check, exc in checks.items():
+ found = next(filter(check, partitions), None)
+ if found is not None:
+ raise exc
+
+ def perform_lvm_operations(self):
+ info('Setting up LVM config...')
+
+ if not self._disk_config.lvm_config:
+ return
+
+ if self._enc_config:
+ self._setup_lvm_encrypted(
+ self._disk_config.lvm_config,
+ self._enc_config
+ )
+ else:
+ self._setup_lvm(self._disk_config.lvm_config)
+ self._format_lvm_vols(self._disk_config.lvm_config)
+
+ def _setup_lvm_encrypted(self, lvm_config: LvmConfiguration, enc_config: DiskEncryption):
+ if enc_config.encryption_type == EncryptionType.LvmOnLuks:
+ enc_mods = self._encrypt_partitions(enc_config, lock_after_create=False)
+
+ self._setup_lvm(lvm_config, enc_mods)
+ self._format_lvm_vols(lvm_config)
+
+ # export the lvm group safely otherwise the Luks cannot be closed
+ self._safely_close_lvm(lvm_config)
+
+ for luks in enc_mods.values():
+ luks.lock()
+ elif enc_config.encryption_type == EncryptionType.LuksOnLvm:
+ self._setup_lvm(lvm_config)
+ enc_vols = self._encrypt_lvm_vols(lvm_config, enc_config, False)
+ self._format_lvm_vols(lvm_config, enc_vols)
+
+ for luks in enc_vols.values():
+ luks.lock()
+
+ self._safely_close_lvm(lvm_config)
+
+ def _safely_close_lvm(self, lvm_config: LvmConfiguration):
+ for vg in lvm_config.vol_groups:
+ for vol in vg.volumes:
+ device_handler.lvm_vol_change(vol, False)
+
+ device_handler.lvm_export_vg(vg)
+
+ def _setup_lvm(
+ self,
+ lvm_config: LvmConfiguration,
+ enc_mods: Dict[PartitionModification, Luks2] = {}
+ ):
+ self._lvm_create_pvs(lvm_config, enc_mods)
+
+ for vg in lvm_config.vol_groups:
+ pv_dev_paths = self._get_all_pv_dev_paths(vg.pvs, enc_mods)
+
+ device_handler.lvm_vg_create(pv_dev_paths, vg.name)
+
+ # figure out what the actual available size in the group is
+ vg_info = device_handler.lvm_group_info(vg.name)
+
+ if not vg_info:
+ raise ValueError('Unable to fetch VG info')
+
+ # the actual available LVM Group size will be smaller than the
+ # total PVs size due to reserved metadata storage etc.
+ # so we'll have a look at the total avail. size, check the delta
+ # to the desired sizes and subtract some equally from the actually
+ # created volume
+ avail_size = vg_info.vg_size
+ desired_size = sum([vol.length for vol in vg.volumes], Size(0, Unit.B, SectorSize.default()))
+
+ delta = desired_size - avail_size
+ max_vol_offset = delta.convert(Unit.B)
+
+ max_vol = max(vg.volumes, key=lambda x: x.length)
+
+ for lv in vg.volumes:
+ offset = max_vol_offset if lv == max_vol else None
+
+ debug(f'vg: {vg.name}, vol: {lv.name}, offset: {offset}')
+ device_handler.lvm_vol_create(vg.name, lv, offset)
- def partuuid_to_index(self, uuid :str) -> Optional[int]:
- for i in range(storage['DISK_RETRY_ATTEMPTS']):
- self.partprobe()
- time.sleep(max(0.1, storage['DISK_TIMEOUTS'] * i))
-
- # We'll use unreliable lbslk to grab children under the /dev/<device>
- output = json.loads(SysCommand(f"lsblk --json {self.blockdevice.device}").decode('UTF-8'))
-
- for device in output['blockdevices']:
- for index, partition in enumerate(device.get('children', [])):
- # But we'll use blkid to reliably grab the PARTUUID for that child device (partition)
- partition_uuid = SysCommand(f"blkid -s PARTUUID -o value /dev/{partition.get('name')}").decode().strip()
- if partition_uuid.lower() == uuid.lower():
- return index
-
- raise DiskError(f"Failed to convert PARTUUID {uuid} to a partition index number on blockdevice {self.blockdevice.device}")
-
- def load_layout(self, layout :Dict[str, Any]) -> None:
- from ..luks import luks2
- from .btrfs import BTRFSPartition
-
- # If the layout tells us to wipe the drive, we do so
- if layout.get('wipe', False):
- if self.mode == GPT:
- if not self.parted_mklabel(self.blockdevice.device, "gpt"):
- raise KeyError(f"Could not create a GPT label on {self}")
- elif self.mode == MBR:
- if not self.parted_mklabel(self.blockdevice.device, "msdos"):
- raise KeyError(f"Could not create a MS-DOS label on {self}")
-
- self.blockdevice.flush_cache()
- time.sleep(3)
-
- prev_partition = None
- # We then iterate the partitions in order
- for partition in layout.get('partitions', []):
- # We don't want to re-add an existing partition (those containing a UUID already)
- if partition.get('wipe', False) and not partition.get('PARTUUID', None):
- start = partition.get('start') or (
- prev_partition and f'{prev_partition["device_instance"].end_sectors}s' or DEFAULT_PARTITION_START)
- partition['device_instance'] = self.add_partition(partition.get('type', 'primary'),
- start=start,
- end=partition.get('size', '100%'),
- partition_format=partition.get('filesystem', {}).get('format', 'btrfs'),
- skip_mklabel=layout.get('wipe', False) is not False)
-
- elif (partition_uuid := partition.get('PARTUUID')):
- # We try to deal with both UUID and PARTUUID of a partition when it's being re-used.
- # We should re-name or separate this logi based on partition.get('PARTUUID') and partition.get('UUID')
- # but for now, lets just attempt to deal with both.
- try:
- partition['device_instance'] = self.blockdevice.get_partition(uuid=partition_uuid)
- except DiskError:
- partition['device_instance'] = self.blockdevice.get_partition(partuuid=partition_uuid)
-
- log(_("Re-using partition instance: {}").format(partition['device_instance']), level=logging.DEBUG, fg="gray")
+ while True:
+ debug('Fetching LVM volume info')
+ lv_info = device_handler.lvm_vol_info(lv.name)
+ if lv_info is not None:
+ break
+
+ time.sleep(1)
+
+ self._lvm_vol_handle_e2scrub(vg)
+
+ def _format_lvm_vols(
+ self,
+ lvm_config: LvmConfiguration,
+ enc_vols: Dict[LvmVolume, Luks2] = {}
+ ):
+ for vol in lvm_config.get_all_volumes():
+ if enc_vol := enc_vols.get(vol, None):
+ if not enc_vol.mapper_dev:
+ raise ValueError('No mapper device defined')
+ path = enc_vol.mapper_dev
else:
- log(f"{self}.load_layout() doesn't know how to work without 'wipe' being set or UUID ({partition.get('PARTUUID')}) was given and found.", fg="yellow", level=logging.WARNING)
- continue
-
- if partition.get('filesystem', {}).get('format', False):
- # needed for backward compatibility with the introduction of the new "format_options"
- format_options = partition.get('options',[]) + partition.get('filesystem',{}).get('format_options',[])
- disk_encryption: DiskEncryption = storage['arguments'].get('disk_encryption')
-
- if disk_encryption and partition in disk_encryption.all_partitions:
- if not partition['device_instance']:
- raise DiskError(f"Internal error caused us to loose the partition. Please report this issue upstream!")
-
- if partition.get('mountpoint',None):
- loopdev = f"{storage.get('ENC_IDENTIFIER', 'ai')}{pathlib.Path(partition['mountpoint']).name}loop"
- else:
- loopdev = f"{storage.get('ENC_IDENTIFIER', 'ai')}{pathlib.Path(partition['device_instance'].path).name}"
-
- partition['device_instance'].encrypt(password=disk_encryption.encryption_password)
- # Immediately unlock the encrypted device to format the inner volume
- with luks2(partition['device_instance'], loopdev, disk_encryption.encryption_password, auto_unmount=True) as unlocked_device:
- if not partition.get('wipe'):
- if storage['arguments'] == 'silent':
- raise ValueError(f"Missing fs-type to format on newly created encrypted partition {partition['device_instance']}")
- else:
- if not partition.get('filesystem'):
- partition['filesystem'] = {}
-
- if not partition['filesystem'].get('format', False):
- while True:
- partition['filesystem']['format'] = input(f"Enter a valid fs-type for newly encrypted partition {partition['filesystem']['format']}: ").strip()
- if not partition['filesystem']['format'] or valid_fs_type(partition['filesystem']['format']) is False:
- log(_("You need to enter a valid fs-type in order to continue. See `man parted` for valid fs-type's."))
- continue
- break
-
- unlocked_device.format(partition['filesystem']['format'], options=format_options)
-
- elif partition.get('wipe', False):
- if not partition['device_instance']:
- raise DiskError(f"Internal error caused us to loose the partition. Please report this issue upstream!")
-
- partition['device_instance'].format(partition['filesystem']['format'], options=format_options)
-
- if partition['filesystem']['format'] == 'btrfs':
- # We upgrade the device instance to a BTRFSPartition if we format it as such.
- # This is so that we can gain access to more features than otherwise available in Partition()
- partition['device_instance'] = BTRFSPartition(
- partition['device_instance'].path,
- block_device=partition['device_instance'].block_device,
- encrypted=False,
- filesystem='btrfs',
- autodetect_filesystem=False
- )
-
- if partition.get('boot', False):
- log(f"Marking partition {partition['device_instance']} as bootable.")
- self.set(self.partuuid_to_index(partition['device_instance'].part_uuid), 'boot on')
-
- prev_partition = partition
-
- def find_partition(self, mountpoint :str) -> Partition:
- for partition in self.blockdevice:
- if partition.target_mountpoint == mountpoint or partition.mountpoint == mountpoint:
- return partition
-
- def partprobe(self) -> bool:
- try:
- SysCommand(f'partprobe {self.blockdevice.device}')
- except SysCallError as error:
- log(f"Could not execute partprobe: {error!r}", level=logging.ERROR, fg="red")
- raise DiskError(f"Could not run partprobe on {self.blockdevice.device}: {error!r}")
+ path = vol.safe_dev_path
- return True
+ # wait a bit otherwise the mkfs will fail as it can't
+ # find the mapper device yet
+ device_handler.format(vol.fs_type, path)
- def raw_parted(self, string: str) -> SysCommand:
- try:
- cmd_handle = SysCommand(f'/usr/bin/parted -s {string}')
- time.sleep(0.5)
- return cmd_handle
- except SysCallError as error:
- log(f"Parted ended with a bad exit code: {error.exit_code} ({error})", level=logging.ERROR, fg="red")
- return error
+ if vol.fs_type == FilesystemType.Btrfs:
+ device_handler.create_lvm_btrfs_subvolumes(path, vol.btrfs_subvols, vol.mount_options)
- def parted(self, string: str) -> bool:
- """
- Performs a parted execution of the given string
+ def _lvm_create_pvs(
+ self,
+ lvm_config: LvmConfiguration,
+ enc_mods: Dict[PartitionModification, Luks2] = {}
+ ):
+ pv_paths: Set[Path] = set()
- :param string: A raw string passed to /usr/bin/parted -s <string>
- :type string: str
- """
- if (parted_handle := self.raw_parted(string)).exit_code == 0:
- return self.partprobe()
- else:
- raise DiskError(f"Parted failed to add a partition: {parted_handle}")
+ for vg in lvm_config.vol_groups:
+ pv_paths |= self._get_all_pv_dev_paths(vg.pvs, enc_mods)
- def use_entire_disk(self, root_filesystem_type :str = 'ext4') -> Partition:
- # TODO: Implement this with declarative profiles instead.
- raise ValueError("Installation().use_entire_disk() has to be re-worked.")
+ device_handler.lvm_pv_create(pv_paths)
- def add_partition(
+ def _get_all_pv_dev_paths(
self,
- partition_type :str,
- start :str,
- end :str,
- partition_format :Optional[str] = None,
- skip_mklabel :bool = False
- ) -> Partition:
- log(f'Adding partition to {self.blockdevice}, {start}->{end}', level=logging.INFO)
-
- if len(self.blockdevice.partitions) == 0 and skip_mklabel is False:
- # If it's a completely empty drive, and we're about to add partitions to it
- # we need to make sure there's a filesystem label.
- if self.mode == GPT:
- if not self.parted_mklabel(self.blockdevice.device, "gpt"):
- raise KeyError(f"Could not create a GPT label on {self}")
- elif self.mode == MBR:
- if not self.parted_mklabel(self.blockdevice.device, "msdos"):
- raise KeyError(f"Could not create a MS-DOS label on {self}")
-
- self.blockdevice.flush_cache()
-
- previous_partuuids = []
- for partition in self.blockdevice.partitions.values():
- try:
- previous_partuuids.append(partition.part_uuid)
- except DiskError:
- pass
-
- # TODO this check should probably run in the setup process rather than during the installation
- if self.mode == MBR:
- if len(self.blockdevice.partitions) > 3:
- DiskError("Too many partitions on disk, MBR disks can only have 3 primary partitions")
-
- if partition_format:
- parted_string = f'{self.blockdevice.device} mkpart {partition_type} {partition_format} {start} {end}'
- else:
- parted_string = f'{self.blockdevice.device} mkpart {partition_type} {start} {end}'
-
- log(f"Adding partition using the following parted command: {parted_string}", level=logging.DEBUG)
-
- if self.parted(parted_string):
- for count in range(storage.get('DISK_RETRY_ATTEMPTS', 3)):
- self.blockdevice.flush_cache()
-
- new_partition_uuids = [partition.part_uuid for partition in self.blockdevice.partitions.values()]
- new_partuuid_set = (set(previous_partuuids) ^ set(new_partition_uuids))
-
- if len(new_partuuid_set) and (new_partuuid := new_partuuid_set.pop()):
- try:
- return self.blockdevice.get_partition(partuuid=new_partuuid)
- except Exception as err:
- log(f'Blockdevice: {self.blockdevice}', level=logging.ERROR, fg="red")
- log(f'Partitions: {self.blockdevice.partitions}', level=logging.ERROR, fg="red")
- log(f'Partition set: {new_partuuid_set}', level=logging.ERROR, fg="red")
- log(f'New PARTUUID: {[new_partuuid]}', level=logging.ERROR, fg="red")
- log(f'get_partition(): {self.blockdevice.get_partition}', level=logging.ERROR, fg="red")
- raise err
- else:
- log(f"Could not get UUID for partition. Waiting {storage.get('DISK_TIMEOUTS', 1) * count}s before retrying.",level=logging.DEBUG)
- self.partprobe()
- time.sleep(max(0.1, storage.get('DISK_TIMEOUTS', 1)))
- else:
- print("Parted did not return True during partition creation")
+ pvs: List[PartitionModification],
+ enc_mods: Dict[PartitionModification, Luks2] = {}
+ ) -> Set[Path]:
+ pv_paths: Set[Path] = set()
+
+ for pv in pvs:
+ if enc_pv := enc_mods.get(pv, None):
+ if mapper := enc_pv.mapper_dev:
+ pv_paths.add(mapper)
+ else:
+ pv_paths.add(pv.safe_dev_path)
+
+ return pv_paths
+
+ def _encrypt_lvm_vols(
+ self,
+ lvm_config: LvmConfiguration,
+ enc_config: DiskEncryption,
+ lock_after_create: bool = True
+ ) -> Dict[LvmVolume, Luks2]:
+ enc_vols: Dict[LvmVolume, Luks2] = {}
+
+ for vol in lvm_config.get_all_volumes():
+ if vol in enc_config.lvm_volumes:
+ luks_handler = device_handler.encrypt(
+ vol.safe_dev_path,
+ vol.mapper_name,
+ enc_config.encryption_password,
+ lock_after_create
+ )
+
+ enc_vols[vol] = luks_handler
+
+ return enc_vols
+
+ def _encrypt_partitions(
+ self,
+ enc_config: DiskEncryption,
+ lock_after_create: bool = True
+ ) -> Dict[PartitionModification, Luks2]:
+ enc_mods: Dict[PartitionModification, Luks2] = {}
+
+ for mod in self._disk_config.device_modifications:
+ partitions = mod.partitions
+
+ # don't touch existing partitions
+ filtered_part = [p for p in partitions if not p.exists()]
+
+ self._validate_partitions(filtered_part)
+
+ # make sure all devices are unmounted
+ device_handler.umount_all_existing(mod.device_path)
+
+ enc_mods = {}
+
+ for part_mod in filtered_part:
+ if part_mod in enc_config.partitions:
+ luks_handler = device_handler.encrypt(
+ part_mod.safe_dev_path,
+ part_mod.mapper_name,
+ enc_config.encryption_password,
+ lock_after_create=lock_after_create
+ )
+
+ enc_mods[part_mod] = luks_handler
- total_partitions = set([partition.part_uuid for partition in self.blockdevice.partitions.values()])
- total_partitions.update(previous_partuuids)
+ return enc_mods
- # TODO: This should never be able to happen
- log(f"Could not find the new PARTUUID after adding the partition.", level=logging.ERROR, fg="red")
- log(f"Previous partitions: {previous_partuuids}", level=logging.ERROR, fg="red")
- log(f"New partitions: {total_partitions}", level=logging.ERROR, fg="red")
+ def _lvm_vol_handle_e2scrub(self, vol_gp: LvmVolumeGroup):
+ # from arch wiki:
+ # If a logical volume will be formatted with ext4, leave at least 256 MiB
+ # free space in the volume group to allow using e2scrub
+ if any([vol.fs_type == FilesystemType.Ext4 for vol in vol_gp.volumes]):
+ largest_vol = max(vol_gp.volumes, key=lambda x: x.length)
- raise DiskError(f"Could not add partition using: {parted_string}")
+ device_handler.lvm_vol_reduce(
+ largest_vol.safe_dev_path,
+ Size(256, Unit.MiB, SectorSize.default())
+ )
- def set_name(self, partition: int, name: str) -> bool:
- return self.parted(f'{self.blockdevice.device} name {partition + 1} "{name}"') == 0
+ def _do_countdown(self) -> bool:
+ SIG_TRIGGER = False
- def set(self, partition: int, string: str) -> bool:
- log(f"Setting {string} on (parted) partition index {partition+1}", level=logging.INFO)
- return self.parted(f'{self.blockdevice.device} set {partition + 1} {string}') == 0
+ def kill_handler(sig: int, frame: Any) -> None:
+ print()
+ exit(0)
- def parted_mklabel(self, device: str, disk_label: str) -> bool:
- log(f"Creating a new partition label on {device}", level=logging.INFO, fg="yellow")
- # Try to unmount devices before attempting to run mklabel
- try:
- SysCommand(f'bash -c "umount {device}?"')
- except:
- pass
+ def sig_handler(sig: int, frame: Any) -> None:
+ signal.signal(signal.SIGINT, kill_handler)
- self.partprobe()
- worked = self.raw_parted(f'{device} mklabel {disk_label}').exit_code == 0
- self.partprobe()
+ original_sigint_handler = signal.getsignal(signal.SIGINT)
+ signal.signal(signal.SIGINT, sig_handler)
- return worked
+ for i in range(5, 0, -1):
+ print(f"{i}", end='')
+
+ for x in range(4):
+ sys.stdout.flush()
+ time.sleep(0.25)
+ print(".", end='')
+
+ if SIG_TRIGGER:
+ prompt = _('Do you really want to abort?')
+ choice = Menu(prompt, Menu.yes_no(), skip=False).run()
+ if choice.value == Menu.yes():
+ exit(0)
+
+ if SIG_TRIGGER is False:
+ sys.stdin.read()
+
+ SIG_TRIGGER = False
+ signal.signal(signal.SIGINT, sig_handler)
+
+ print()
+ signal.signal(signal.SIGINT, original_sigint_handler)
+
+ return True
diff --git a/archinstall/lib/disk/helpers.py b/archinstall/lib/disk/helpers.py
deleted file mode 100644
index 80d0cb53..00000000
--- a/archinstall/lib/disk/helpers.py
+++ /dev/null
@@ -1,556 +0,0 @@
-from __future__ import annotations
-import json
-import logging
-import os # type: ignore
-import pathlib
-import re
-import time
-import glob
-
-from typing import Union, List, Iterator, Dict, Optional, Any, TYPE_CHECKING
-# https://stackoverflow.com/a/39757388/929999
-from .diskinfo import get_lsblk_info
-from ..models.subvolume import Subvolume
-
-from .blockdevice import BlockDevice
-from .dmcryptdev import DMCryptDev
-from .mapperdev import MapperDev
-from ..exceptions import SysCallError, DiskError
-from ..general import SysCommand
-from ..output import log
-from ..storage import storage
-
-if TYPE_CHECKING:
- from .partition import Partition
-
-
-ROOT_DIR_PATTERN = re.compile('^.*?/devices')
-GIGA = 2 ** 30
-
-def convert_size_to_gb(size :Union[int, float]) -> float:
- return round(size / GIGA,1)
-
-def sort_block_devices_based_on_performance(block_devices :List[BlockDevice]) -> Dict[BlockDevice, int]:
- result = {device: 0 for device in block_devices}
-
- for device, weight in result.items():
- if device.spinning:
- weight -= 10
- else:
- weight += 5
-
- if device.bus_type == 'nvme':
- weight += 20
- elif device.bus_type == 'sata':
- weight += 10
-
- result[device] = weight
-
- return result
-
-def filter_disks_below_size_in_gb(devices :List[BlockDevice], gigabytes :int) -> Iterator[BlockDevice]:
- for disk in devices:
- if disk.size >= gigabytes:
- yield disk
-
-def select_largest_device(devices :List[BlockDevice], gigabytes :int, filter_out :Optional[List[BlockDevice]] = None) -> BlockDevice:
- if not filter_out:
- filter_out = []
-
- copy_devices = [*devices]
- for filter_device in filter_out:
- if filter_device in copy_devices:
- copy_devices.pop(copy_devices.index(filter_device))
-
- copy_devices = list(filter_disks_below_size_in_gb(copy_devices, gigabytes))
-
- if not len(copy_devices):
- return None
-
- return max(copy_devices, key=(lambda device : device.size))
-
-def select_disk_larger_than_or_close_to(devices :List[BlockDevice], gigabytes :int, filter_out :Optional[List[BlockDevice]] = None) -> BlockDevice:
- if not filter_out:
- filter_out = []
-
- copy_devices = [*devices]
- for filter_device in filter_out:
- if filter_device in copy_devices:
- copy_devices.pop(copy_devices.index(filter_device))
-
- if not len(copy_devices):
- return None
-
- return min(copy_devices, key=(lambda device : abs(device.size - gigabytes)))
-
-def convert_to_gigabytes(string :str) -> float:
- unit = string.strip()[-1]
- size = float(string.strip()[:-1])
-
- if unit == 'M':
- size = size / 1024
- elif unit == 'T':
- size = size * 1024
-
- return size
-
-def device_state(name :str, *args :str, **kwargs :str) -> Optional[bool]:
- # Based out of: https://askubuntu.com/questions/528690/how-to-get-list-of-all-non-removable-disk-device-names-ssd-hdd-and-sata-ide-onl/528709#528709
- if os.path.isfile('/sys/block/{}/device/block/{}/removable'.format(name, name)):
- with open('/sys/block/{}/device/block/{}/removable'.format(name, name)) as f:
- if f.read(1) == '1':
- return
-
- path = ROOT_DIR_PATTERN.sub('', os.readlink('/sys/block/{}'.format(name)))
- hotplug_buses = ("usb", "ieee1394", "mmc", "pcmcia", "firewire")
- for bus in hotplug_buses:
- if os.path.exists('/sys/bus/{}'.format(bus)):
- for device_bus in os.listdir('/sys/bus/{}/devices'.format(bus)):
- device_link = ROOT_DIR_PATTERN.sub('', os.readlink('/sys/bus/{}/devices/{}'.format(bus, device_bus)))
- if re.search(device_link, path):
- return
- return True
-
-
-def cleanup_bash_escapes(data :str) -> str:
- return data.replace(r'\ ', ' ')
-
-def blkid(cmd :str) -> Dict[str, Any]:
- if '-o' in cmd and '-o export' not in cmd:
- raise ValueError(f"blkid() requires '-o export' to be used and can therefore not continue reliably.")
- elif '-o' not in cmd:
- cmd += ' -o export'
-
- try:
- raw_data = SysCommand(cmd).decode()
- except SysCallError as error:
- log(f"Could not get block device information using blkid() using command {cmd}", level=logging.DEBUG)
- raise error
-
- result = {}
- # Process the raw result
- devname = None
- for line in raw_data.split('\r\n'):
- if not len(line):
- devname = None
- continue
-
- key, val = line.split('=', 1)
- if key.lower() == 'devname':
- devname = val
- # Lowercase for backwards compatibility with all_disks() previous use cases
- result[devname] = {
- "path": devname,
- "PATH": devname
- }
- continue
-
- result[devname][key] = cleanup_bash_escapes(val)
-
- return result
-
-def get_loop_info(path :str) -> Dict[str, Any]:
- for drive in json.loads(SysCommand(['losetup', '--json']).decode('UTF_8'))['loopdevices']:
- if not drive['name'] == path:
- continue
-
- return {
- path: {
- **drive,
- 'type' : 'loop',
- 'TYPE' : 'loop',
- 'DEVTYPE' : 'loop',
- 'PATH' : drive['name'],
- 'path' : drive['name']
- }
- }
-
- return {}
-
-def enrich_blockdevice_information(information :Dict[str, Any]) -> Dict[str, Any]:
- result = {}
- for device_path, device_information in information.items():
- dev_name = pathlib.Path(device_information['PATH']).name
- if not device_information.get('TYPE') or not device_information.get('DEVTYPE'):
- with open(f"/sys/class/block/{dev_name}/uevent") as fh:
- device_information.update(uevent(fh.read()))
-
- if (dmcrypt_name := pathlib.Path(f"/sys/class/block/{dev_name}/dm/name")).exists():
- with dmcrypt_name.open('r') as fh:
- device_information['DMCRYPT_NAME'] = fh.read().strip()
-
- result[device_path] = device_information
-
- return result
-
-def uevent(data :str) -> Dict[str, Any]:
- information = {}
-
- for line in data.replace('\r\n', '\n').split('\n'):
- if len((line := line.strip())):
- key, val = line.split('=', 1)
- information[key] = val
-
- return information
-
-def get_blockdevice_uevent(dev_name :str) -> Dict[str, Any]:
- device_information = {}
- with open(f"/sys/class/block/{dev_name}/uevent") as fh:
- device_information.update(uevent(fh.read()))
-
- return {
- f"/dev/{dev_name}" : {
- **device_information,
- 'path' : f'/dev/{dev_name}',
- 'PATH' : f'/dev/{dev_name}',
- 'PTTYPE' : None
- }
- }
-
-
-def all_disks() -> List[BlockDevice]:
- log(f"[Deprecated] archinstall.all_disks() is deprecated. Use archinstall.all_blockdevices() with the appropriate filters instead.", level=logging.WARNING, fg="yellow")
- return all_blockdevices(partitions=False, mappers=False)
-
-def get_blockdevice_info(device_path, exclude_iso_dev :bool = True) -> Dict[str, Any]:
- for retry_attempt in range(storage['DISK_RETRY_ATTEMPTS']):
- partprobe(device_path)
- time.sleep(max(0.1, storage['DISK_TIMEOUTS'] * retry_attempt))
-
- try:
- if exclude_iso_dev:
- # exclude all devices associated with the iso boot locations
- iso_devs = ['/run/archiso/airootfs', '/run/archiso/bootmnt']
-
- try:
- lsblk_info = get_lsblk_info(device_path)
- except DiskError:
- continue
-
- if any([dev in lsblk_info.mountpoints for dev in iso_devs]):
- continue
-
- information = blkid(f'blkid -p -o export {device_path}')
- return enrich_blockdevice_information(information)
- except SysCallError as ex:
- if ex.exit_code == 2:
- # Assume that it's a loop device, and try to get info on it
- try:
- resolved_device_name = device_path.readlink().name
- except OSError:
- resolved_device_name = device_path.name
-
- try:
- information = get_loop_info(device_path)
- if not information:
- raise SysCallError(f"Could not get loop information for {resolved_device_name}", exit_code=1)
- return enrich_blockdevice_information(information)
-
- except SysCallError:
- information = get_blockdevice_uevent(resolved_device_name)
- return enrich_blockdevice_information(information)
- else:
- # We could not reliably get any information, perhaps the disk is clean of information?
- if retry_attempt == storage['DISK_RETRY_ATTEMPTS'] - 1:
- raise ex
-
-def all_blockdevices(
- mappers: bool = False,
- partitions: bool = False,
- error: bool = False,
- exclude_iso_dev: bool = True
-) -> Dict[str, Any]:
- """
- Returns BlockDevice() and Partition() objects for all available devices.
- """
- from .partition import Partition
-
- instances = {}
-
- # Due to lsblk being highly unreliable for this use case,
- # we'll iterate the /sys/class definitions and find the information
- # from there.
- for block_device in glob.glob("/sys/class/block/*"):
- try:
- device_path = pathlib.Path(f"/dev/{pathlib.Path(block_device).readlink().name}")
- except FileNotFoundError:
- log(f"Unknown device found by '/sys/class/block/*', ignoring: {device_path}", level=logging.WARNING, fg="yellow")
-
- if device_path.exists() is False:
- log(f"Unknown device found by '/sys/class/block/*', ignoring: {device_path}", level=logging.WARNING, fg="yellow")
- continue
-
- information = get_blockdevice_info(device_path)
- if not information:
- continue
-
- for path, path_info in information.items():
- if path_info.get('DMCRYPT_NAME'):
- instances[path] = DMCryptDev(dev_path=path)
- elif path_info.get('PARTUUID') or path_info.get('PART_ENTRY_NUMBER'):
- if partitions:
- instances[path] = Partition(path, block_device=BlockDevice(get_parent_of_partition(pathlib.Path(path))))
- elif path_info.get('PTTYPE', False) is not False or path_info.get('TYPE') == 'loop':
- instances[path] = BlockDevice(path, path_info)
- elif path_info.get('TYPE') in ('squashfs', 'erofs'):
- # We can ignore squashfs devices (usually /dev/loop0 on Arch ISO)
- continue
- else:
- log(f"Unknown device found by all_blockdevices(), ignoring: {information}", level=logging.WARNING, fg="yellow")
-
- if mappers:
- for block_device in glob.glob("/dev/mapper/*"):
- if (pathobj := pathlib.Path(block_device)).is_symlink():
- instances[f"/dev/mapper/{pathobj.name}"] = MapperDev(mappername=pathobj.name)
-
- return instances
-
-
-def get_parent_of_partition(path :pathlib.Path) -> pathlib.Path:
- partition_name = path.name
- pci_device = (pathlib.Path("/sys/class/block") / partition_name).resolve()
- return f"/dev/{pci_device.parent.name}"
-
-def harddrive(size :Optional[float] = None, model :Optional[str] = None, fuzzy :bool = False) -> Optional[BlockDevice]:
- collection = all_blockdevices(partitions=False)
- for drive in collection:
- if size and convert_to_gigabytes(collection[drive]['size']) != size:
- continue
- if model and (collection[drive]['model'] is None or collection[drive]['model'].lower() != model.lower()):
- continue
-
- return collection[drive]
-
-def split_bind_name(path :Union[pathlib.Path, str]) -> list:
- # log(f"[Deprecated] Partition().subvolumes now contain the split bind name via it's subvolume.name instead.", level=logging.WARNING, fg="yellow")
- # we check for the bind notation. if exist we'll only use the "true" device path
- if '[' in str(path) : # is a bind path (btrfs subvolume path)
- device_path, bind_path = str(path).split('[')
- bind_path = bind_path[:-1].strip() # remove the ]
- else:
- device_path = path
- bind_path = None
- return device_path,bind_path
-
-def find_mountpoint(device_path :str) -> Dict[str, Any]:
- try:
- for filesystem in json.loads(SysCommand(f'/usr/bin/findmnt -R --json {device_path}').decode())['filesystems']:
- yield filesystem
- except SysCallError:
- return {}
-
-def findmnt(path :pathlib.Path, traverse :bool = False, ignore :List = [], recurse :bool = True) -> Dict[str, Any]:
- for traversal in list(map(str, [str(path)] + list(path.parents))):
- if traversal in ignore:
- continue
-
- try:
- log(f"Getting mount information for device path {traversal}", level=logging.DEBUG)
- if (output := SysCommand(f"/usr/bin/findmnt --json {'--submounts' if recurse else ''} {traversal}").decode('UTF-8')):
- return json.loads(output)
-
- except SysCallError as error:
- log(f"Could not get mount information on {path} but continuing and ignoring: {error}", level=logging.INFO, fg="gray")
- pass
-
- if not traverse:
- break
-
- raise DiskError(f"Could not get mount information for path {path}")
-
-
-def get_mount_info(path :Union[pathlib.Path, str], traverse :bool = False, return_real_path :bool = False, ignore :List = []) -> Dict[str, Any]:
- import traceback
-
- log(f"Deprecated: archinstall.get_mount_info(). Use archinstall.findmnt() instead, which does not do any automatic parsing. Please change at:\n{''.join(traceback.format_stack())}")
- device_path, bind_path = split_bind_name(path)
- output = {}
-
- for traversal in list(map(str, [str(device_path)] + list(pathlib.Path(str(device_path)).parents))):
- if traversal in ignore:
- continue
-
- try:
- log(f"Getting mount information for device path {traversal}", level=logging.DEBUG)
- if (output := SysCommand(f'/usr/bin/findmnt --json {traversal}').decode('UTF-8')):
- break
-
- except SysCallError as error:
- print('ERROR:', error)
- pass
-
- if not traverse:
- break
-
- if not output:
- raise DiskError(f"Could not get mount information for device path {device_path}")
-
- output = json.loads(output)
-
- # for btrfs partitions we redice the filesystem list to the one with the source equals to the parameter
- # i.e. the subvolume filesystem we're searching for
- if 'filesystems' in output and len(output['filesystems']) > 1 and bind_path is not None:
- output['filesystems'] = [entry for entry in output['filesystems'] if entry['source'] == str(path)]
-
- if 'filesystems' in output:
- if len(output['filesystems']) > 1:
- raise DiskError(f"Path '{device_path}' contains multiple mountpoints: {output['filesystems']}")
-
- if return_real_path:
- return output['filesystems'][0], traversal
- else:
- return output['filesystems'][0]
-
- if return_real_path:
- return {}, traversal
- else:
- return {}
-
-
-def get_all_targets(data :Dict[str, Any], filters :Dict[str, None] = {}) -> Dict[str, None]:
- for info in data:
- if info.get('target') not in filters:
- filters[info.get('target')] = None
-
- filters.update(get_all_targets(info.get('children', [])))
-
- return filters
-
-def get_partitions_in_use(mountpoint :str) -> Dict[str, Any]:
- from .partition import Partition
-
- try:
- output = SysCommand(f"/usr/bin/findmnt --json -R {mountpoint}").decode('UTF-8')
- except SysCallError:
- return {}
-
- if not output:
- return {}
-
- output = json.loads(output)
-
- mounts = {}
-
- block_devices_available = all_blockdevices(mappers=True, partitions=True, error=True)
-
- block_devices_mountpoints = {}
- for blockdev in block_devices_available.values():
- if not type(blockdev) in (Partition, MapperDev):
- continue
-
- if isinstance(blockdev, Partition):
- if blockdev.mountpoints:
- for blockdev_mountpoint in blockdev.mountpoints:
- block_devices_mountpoints[blockdev_mountpoint] = blockdev
- else:
- if blockdev.mount_information:
- for blockdev_mountpoint in blockdev.mount_information:
- block_devices_mountpoints[blockdev_mountpoint['target']] = blockdev
-
- log(f'Filtering available mounts {block_devices_mountpoints} to those under {mountpoint}', level=logging.DEBUG)
-
- for mountpoint in list(get_all_targets(output['filesystems']).keys()):
- # Since all_blockdevices() returns PosixPath objects, we need to convert
- # findmnt paths to pathlib.Path() first:
- mountpoint = pathlib.Path(mountpoint)
-
- if mountpoint in block_devices_mountpoints:
- if mountpoint not in mounts:
- mounts[mountpoint] = block_devices_mountpoints[mountpoint]
- # If the already defined mountpoint is a DMCryptDev, and the newly found
- # mountpoint is a MapperDev, it has precedence and replaces the old mountpoint definition.
- elif type(mounts[mountpoint]) == DMCryptDev and type(block_devices_mountpoints[mountpoint]) == MapperDev:
- mounts[mountpoint] = block_devices_mountpoints[mountpoint]
-
- log(f"Available partitions: {mounts}", level=logging.DEBUG)
-
- return mounts
-
-
-def get_filesystem_type(path :str) -> Optional[str]:
- try:
- return SysCommand(f"blkid -o value -s TYPE {path}").decode('UTF-8').strip()
- except SysCallError:
- return None
-
-
-def disk_layouts() -> Optional[Dict[str, Any]]:
- try:
- if (handle := SysCommand("lsblk -f -o+TYPE,SIZE -J")).exit_code == 0:
- return {str(key): val for key, val in json.loads(handle.decode('UTF-8')).items()}
- else:
- log(f"Could not return disk layouts: {handle}", level=logging.WARNING, fg="yellow")
- return None
- except SysCallError as err:
- log(f"Could not return disk layouts: {err}", level=logging.WARNING, fg="yellow")
- return None
- except json.decoder.JSONDecodeError as err:
- log(f"Could not return disk layouts: {err}", level=logging.WARNING, fg="yellow")
- return None
-
-
-def find_partition_by_mountpoint(block_devices :List[BlockDevice], relative_mountpoint :str) -> Partition:
- for device in block_devices:
- for partition in block_devices[device]['partitions']:
- if partition.get('mountpoint', None) == relative_mountpoint:
- return partition
-
-def partprobe(path :str = '') -> bool:
- try:
- if SysCommand(f'bash -c "partprobe {path}"').exit_code == 0:
- return True
- except SysCallError:
- pass
- return False
-
-def convert_device_to_uuid(path :str) -> str:
- device_name, bind_name = split_bind_name(path)
-
- for i in range(storage['DISK_RETRY_ATTEMPTS']):
- partprobe(device_name)
- time.sleep(max(0.1, storage['DISK_TIMEOUTS'] * i)) # TODO: Remove, we should be relying on blkid instead of lsblk
-
- # TODO: Convert lsblk to blkid
- # (lsblk supports BlockDev and Partition UUID grabbing, blkid requires you to pick PTUUID and PARTUUID)
- output = json.loads(SysCommand(f"lsblk --json -o+UUID {device_name}").decode('UTF-8'))
-
- for device in output['blockdevices']:
- if (dev_uuid := device.get('uuid', None)):
- return dev_uuid
-
- raise DiskError(f"Could not retrieve the UUID of {path} within a timely manner.")
-
-
-def has_mountpoint(partition: Union[dict,Partition,MapperDev], target: str, strict: bool = True) -> bool:
- """ Determine if a certain partition is mounted (or has a mountpoint) as specific target (path)
- Coded for clarity rather than performance
-
- Input parms:
- :parm partition the partition we check
- :type Either a Partition object or a dict with the contents of a partition definition in the disk_layouts schema
-
- :parm target (a string representing a mount path we want to check for.
- :type str
-
- :parm strict if the check will be strict, target is exactly the mountpoint, or no, where the target is a leaf (f.i. to check if it is in /mnt/archinstall/). Not available for root check ('/') for obvious reasons
-
- """
- # we create the mountpoint list
- if isinstance(partition,dict):
- subvolumes: List[Subvolume] = partition.get('btrfs',{}).get('subvolumes', [])
- mountpoints = [partition.get('mountpoint')]
- mountpoints += [volume.mountpoint for volume in subvolumes]
- else:
- mountpoints = [partition.mountpoint,] + [subvol.target for subvol in partition.subvolumes]
-
- # we check
- if strict or target == '/':
- if target in mountpoints:
- return True
- else:
- return False
- else:
- for mp in mountpoints:
- if mp and mp.endswith(target):
- return True
- return False
diff --git a/archinstall/lib/disk/mapperdev.py b/archinstall/lib/disk/mapperdev.py
deleted file mode 100644
index bf1b3583..00000000
--- a/archinstall/lib/disk/mapperdev.py
+++ /dev/null
@@ -1,92 +0,0 @@
-import glob
-import pathlib
-import logging
-import json
-from dataclasses import dataclass
-from typing import Optional, List, Dict, Any, Iterator, TYPE_CHECKING
-
-from ..exceptions import SysCallError
-from ..general import SysCommand
-from ..output import log
-
-if TYPE_CHECKING:
- from .btrfs import BtrfsSubvolumeInfo
-
-@dataclass
-class MapperDev:
- mappername :str
-
- @property
- def name(self):
- return self.mappername
-
- @property
- def path(self):
- return f"/dev/mapper/{self.mappername}"
-
- @property
- def part_uuid(self):
- return self.partition.part_uuid
-
- @property
- def partition(self):
- from .helpers import uevent, get_parent_of_partition
- from .partition import Partition
- from .blockdevice import BlockDevice
-
- for mapper in glob.glob('/dev/mapper/*'):
- path_obj = pathlib.Path(mapper)
- if path_obj.name == self.mappername and pathlib.Path(mapper).is_symlink():
- dm_device = (pathlib.Path("/dev/mapper/") / path_obj.readlink()).resolve()
-
- for slave in glob.glob(f"/sys/class/block/{dm_device.name}/slaves/*"):
- partition_belonging_to_dmcrypt_device = pathlib.Path(slave).name
-
- try:
- uevent_data = SysCommand(f"blkid -o export /dev/{partition_belonging_to_dmcrypt_device}").decode()
- except SysCallError as error:
- log(f"Could not get information on device /dev/{partition_belonging_to_dmcrypt_device}: {error}", level=logging.ERROR, fg="red")
-
- information = uevent(uevent_data)
- block_device = BlockDevice(get_parent_of_partition('/dev/' / pathlib.Path(information['DEVNAME'])))
-
- return Partition(information['DEVNAME'], block_device=block_device)
-
- raise ValueError(f"Could not convert {self.mappername} to a real dm-crypt device")
-
- @property
- def mountpoint(self) -> Optional[pathlib.Path]:
- try:
- data = json.loads(SysCommand(f"findmnt --json -R {self.path}").decode())
- for filesystem in data['filesystems']:
- return pathlib.Path(filesystem.get('target'))
-
- except SysCallError as error:
- # Not mounted anywhere most likely
- log(f"Could not locate mount information for {self.path}: {error}", level=logging.WARNING, fg="yellow")
- pass
-
- return None
-
- @property
- def mountpoints(self) -> List[Dict[str, Any]]:
- return [obj['target'] for obj in self.mount_information]
-
- @property
- def mount_information(self) -> List[Dict[str, Any]]:
- from .helpers import find_mountpoint
- return [{**obj, 'target' : pathlib.Path(obj.get('target', '/dev/null'))} for obj in find_mountpoint(self.path)]
-
- @property
- def filesystem(self) -> Optional[str]:
- from .helpers import get_filesystem_type
- return get_filesystem_type(self.path)
-
- @property
- def subvolumes(self) -> Iterator['BtrfsSubvolumeInfo']:
- from .btrfs import subvolume_info_from_path
-
- for mountpoint in self.mount_information:
- if target := mountpoint.get('target'):
- if subvolume := subvolume_info_from_path(pathlib.Path(target)):
- yield subvolume
diff --git a/archinstall/lib/disk/partition.py b/archinstall/lib/disk/partition.py
deleted file mode 100644
index 87eaa6a7..00000000
--- a/archinstall/lib/disk/partition.py
+++ /dev/null
@@ -1,661 +0,0 @@
-import glob
-import time
-import logging
-import json
-import os
-import hashlib
-import typing
-from dataclasses import dataclass, field
-from pathlib import Path
-from typing import Optional, Dict, Any, List, Union, Iterator
-
-from .blockdevice import BlockDevice
-from .helpers import get_filesystem_type, convert_size_to_gb, split_bind_name
-from ..storage import storage
-from ..exceptions import DiskError, SysCallError, UnknownFilesystemFormat
-from ..output import log
-from ..general import SysCommand
-from .btrfs.btrfs_helpers import subvolume_info_from_path
-from .btrfs.btrfssubvolumeinfo import BtrfsSubvolumeInfo
-
-@dataclass
-class PartitionInfo:
- partition_object: 'Partition'
- device_path: str # This would be /dev/sda1 for instance
- bootable: bool
- size: float
- sector_size: int
- start: Optional[int]
- end: Optional[int]
- pttype: Optional[str]
- filesystem_type: Optional[str]
- partuuid: Optional[str]
- uuid: Optional[str]
- mountpoints: List[Path] = field(default_factory=list)
-
- def __post_init__(self):
- if not all([self.partuuid, self.uuid]):
- for i in range(storage['DISK_RETRY_ATTEMPTS']):
- lsblk_info = SysCommand(f"lsblk --json -b -o+LOG-SEC,SIZE,PTTYPE,PARTUUID,UUID,FSTYPE {self.device_path}").decode('UTF-8')
- try:
- lsblk_info = json.loads(lsblk_info)
- except json.decoder.JSONDecodeError:
- log(f"Could not decode JSON: {lsblk_info}", fg="red", level=logging.ERROR)
- raise DiskError(f'Failed to retrieve information for "{self.device_path}" with lsblk')
-
- if not (device := lsblk_info.get('blockdevices', [None])[0]):
- raise DiskError(f'Failed to retrieve information for "{self.device_path}" with lsblk')
-
- self.partuuid = device.get('partuuid')
- self.uuid = device.get('uuid')
-
- # Lets build a list of requirements that we would like
- # to retry and build (stuff that can take time between partprobes)
- requirements = []
- requirements.append(self.partuuid)
-
- # Unformatted partitions won't have a UUID
- if lsblk_info.get('fstype') is not None:
- requirements.append(self.uuid)
-
- if all(requirements):
- break
-
- self.partition_object.partprobe()
- time.sleep(max(0.1, storage['DISK_TIMEOUTS'] * i))
-
- def get_first_mountpoint(self) -> Optional[Path]:
- if len(self.mountpoints) > 0:
- return self.mountpoints[0]
- return None
-
-
-class Partition:
- def __init__(
- self,
- path: str,
- block_device: BlockDevice,
- part_id :Optional[str] = None,
- filesystem :Optional[str] = None,
- mountpoint :Optional[str] = None,
- encrypted :bool = False,
- autodetect_filesystem :bool = True,
- ):
- if not part_id:
- part_id = os.path.basename(path)
-
- if type(block_device) is str:
- raise ValueError(f"Partition()'s 'block_device' parameter has to be a archinstall.BlockDevice() instance!")
-
- self.block_device = block_device
- self._path = path
- self._part_id = part_id
- self._target_mountpoint = mountpoint
- self._encrypted = encrypted
- self._wipe = False
- self._type = 'primary'
-
- if mountpoint:
- self.mount(mountpoint)
-
- try:
- self._partition_info = self._fetch_information()
-
- if not autodetect_filesystem and filesystem:
- self._partition_info.filesystem_type = filesystem
-
- if self._partition_info.filesystem_type == 'crypto_LUKS':
- self._encrypted = True
- except DiskError:
- self._partition_info = None
-
- @typing.no_type_check # I hate doint this but I'm currently unsure where this is used.
- def __lt__(self, left_comparitor :BlockDevice) -> bool:
- if type(left_comparitor) == Partition:
- left_comparitor = left_comparitor.path
- else:
- left_comparitor = str(left_comparitor)
-
- # The goal is to check if /dev/nvme0n1p1 comes before /dev/nvme0n1p5
- return self._path < left_comparitor
-
- def __repr__(self, *args :str, **kwargs :str) -> str:
- mount_repr = ''
- if self._partition_info:
- if mountpoint := self._partition_info.get_first_mountpoint():
- mount_repr = f", mounted={mountpoint}"
- elif self._target_mountpoint:
- mount_repr = f", rel_mountpoint={self._target_mountpoint}"
-
- classname = self.__class__.__name__
-
- if not self._partition_info:
- return f'{classname}(path={self._path})'
- elif self._encrypted:
- return f'{classname}(path={self._path}, size={self.size}, PARTUUID={self.part_uuid}, parent={self.real_device}, fs={self._partition_info.filesystem_type}{mount_repr})'
- else:
- return f'{classname}(path={self._path}, size={self.size}, PARTUUID={self.part_uuid}, fs={self._partition_info.filesystem_type}{mount_repr})'
-
- def as_json(self) -> Dict[str, Any]:
- """
- this is used for the table representation of the partition (see FormattedOutput)
- """
- partition_info = {
- 'type': self._type,
- 'PARTUUID': self.part_uuid,
- 'wipe': self._wipe,
- 'boot': self.boot,
- 'ESP': self.boot,
- 'mountpoint': self._target_mountpoint,
- 'encrypted': self._encrypted,
- 'start': self.start,
- 'size': self.end,
- 'filesystem': self._partition_info.filesystem_type if self._partition_info else 'Unknown'
- }
-
- return partition_info
-
- def __dump__(self) -> Dict[str, Any]:
- # TODO remove this in favour of as_json
- return {
- 'type': self._type,
- 'PARTUUID': self.part_uuid,
- 'wipe': self._wipe,
- 'boot': self.boot,
- 'ESP': self.boot,
- 'mountpoint': self._target_mountpoint,
- 'encrypted': self._encrypted,
- 'start': self.start,
- 'size': self.end,
- 'filesystem': {
- 'format': self._partition_info.filesystem_type if self._partition_info else 'None'
- }
- }
-
- def _call_lsblk(self) -> Dict[str, Any]:
- for retry_attempt in range(storage['DISK_RETRY_ATTEMPTS']):
- self.partprobe()
- time.sleep(max(0.1, storage['DISK_TIMEOUTS'] * retry_attempt)) # TODO: Remove, we should be relying on blkid instead of lsblk
- # This sleep might be overkill, but lsblk is known to
- # work against a chaotic cache that can change during call
- # causing no information to be returned (blkid is better)
- # time.sleep(1)
-
- # TODO: Maybe incorporate a re-try system here based on time.sleep(max(0.1, storage.get('DISK_TIMEOUTS', 1)))
-
- try:
- output = SysCommand(f"lsblk --json -b -o+LOG-SEC,SIZE,PTTYPE,PARTUUID,UUID,FSTYPE {self.device_path}").decode('UTF-8')
- except SysCallError as error:
- # Get the output minus the message/info from lsblk if it returns a non-zero exit code.
- output = error.worker.decode('UTF-8')
- if '{' in output:
- output = output[output.find('{'):]
-
- if output:
- try:
- lsblk_info = json.loads(output)
- return lsblk_info
- except json.decoder.JSONDecodeError:
- log(f"Could not decode JSON: {output}", fg="red", level=logging.ERROR)
-
- raise DiskError(f'Failed to get partition information "{self.device_path}" with lsblk')
-
- def _call_sfdisk(self) -> Dict[str, Any]:
- output = SysCommand(f"sfdisk --json {self.block_device.path}").decode('UTF-8')
-
- if output:
- sfdisk_info = json.loads(output)
- partitions = sfdisk_info.get('partitiontable', {}).get('partitions', [])
- node = list(filter(lambda x: x['node'] == self._path, partitions))
-
- if len(node) > 0:
- return node[0]
-
- return {}
-
- raise DiskError(f'Failed to read disk "{self.block_device.path}" with sfdisk')
-
- def _fetch_information(self) -> PartitionInfo:
- lsblk_info = self._call_lsblk()
- sfdisk_info = self._call_sfdisk()
-
- if not (device := lsblk_info.get('blockdevices', [])):
- raise DiskError(f'Failed to retrieve information for "{self.device_path}" with lsblk')
-
- # Grab the first (and only) block device in the list as we're targeting a specific partition
- device = device[0]
-
- mountpoints = [Path(mountpoint) for mountpoint in device['mountpoints'] if mountpoint]
- bootable = sfdisk_info.get('bootable', False) or sfdisk_info.get('type', '') == 'C12A7328-F81F-11D2-BA4B-00A0C93EC93B'
-
- return PartitionInfo(
- partition_object=self,
- device_path=self._path,
- pttype=device['pttype'],
- partuuid=device['partuuid'],
- uuid=device['uuid'],
- sector_size=device['log-sec'],
- size=convert_size_to_gb(device['size']),
- start=sfdisk_info.get('start', None),
- end=sfdisk_info.get('size', None),
- bootable=bootable,
- filesystem_type=device['fstype'],
- mountpoints=mountpoints
- )
-
- @property
- def target_mountpoint(self) -> Optional[str]:
- return self._target_mountpoint
-
- @property
- def path(self) -> str:
- return self._path
-
- @property
- def filesystem(self) -> str:
- if self._partition_info:
- return self._partition_info.filesystem_type
-
- @property
- def mountpoint(self) -> Optional[Path]:
- if len(self.mountpoints) > 0:
- return self.mountpoints[0]
- return None
-
- @property
- def mountpoints(self) -> List[Path]:
- if self._partition_info:
- return self._partition_info.mountpoints
-
- @property
- def sector_size(self) -> int:
- if self._partition_info:
- return self._partition_info.sector_size
-
- @property
- def start(self) -> Optional[int]:
- if self._partition_info:
- return self._partition_info.start
-
- @property
- def end(self) -> Optional[int]:
- if self._partition_info:
- return self._partition_info.end
-
- @property
- def end_sectors(self) -> Optional[int]:
- if self._partition_info:
- start = self._partition_info.start
- end = self._partition_info.end
- if start and end:
- return start + end
-
- @property
- def size(self) -> Optional[float]:
- if self._partition_info:
- return self._partition_info.size
-
- @property
- def boot(self) -> bool:
- if self._partition_info:
- return self._partition_info.bootable
-
- @property
- def partition_type(self) -> Optional[str]:
- if self._partition_info:
- return self._partition_info.pttype
-
- @property
- def part_uuid(self) -> str:
- if self._partition_info:
- return self._partition_info.partuuid
-
- @property
- def uuid(self) -> Optional[str]:
- """
- Returns the UUID as returned by lsblk for the **partition**.
- This is more reliable than relying on /dev/disk/by-uuid as
- it doesn't seam to be able to detect md raid partitions.
- For bind mounts all the subvolumes share the same uuid
- """
- for i in range(storage['DISK_RETRY_ATTEMPTS']):
- if not self.partprobe():
- raise DiskError(f"Could not perform partprobe on {self.device_path}")
-
- time.sleep(storage.get('DISK_TIMEOUTS', 1) * i)
-
- partuuid = self._safe_uuid
- if partuuid:
- return partuuid
-
- raise DiskError(f"Could not get PARTUUID for {self.path} using 'blkid -s PARTUUID -o value {self.path}'")
-
- @property
- def _safe_uuid(self) -> Optional[str]:
- """
- A near copy of self.uuid but without any delays.
- This function should only be used where uuid is not crucial.
- For instance when you want to get a __repr__ of the class.
- """
- if not self.partprobe():
- if self.block_device.partition_type == 'iso9660':
- return None
-
- log(f"Could not reliably refresh PARTUUID of partition {self.device_path} due to partprobe error.", level=logging.DEBUG)
-
- try:
- return SysCommand(f'blkid -s UUID -o value {self.device_path}').decode('UTF-8').strip()
- except SysCallError as error:
- if self.block_device.partition_type == 'iso9660':
- # Parent device is a Optical Disk (.iso dd'ed onto a device for instance)
- return None
-
- log(f"Could not get PARTUUID of partition using 'blkid -s UUID -o value {self.device_path}': {error}")
-
- @property
- def _safe_part_uuid(self) -> Optional[str]:
- """
- A near copy of self.uuid but without any delays.
- This function should only be used where uuid is not crucial.
- For instance when you want to get a __repr__ of the class.
- """
- if not self.partprobe():
- if self.block_device.partition_type == 'iso9660':
- return None
-
- log(f"Could not reliably refresh PARTUUID of partition {self.device_path} due to partprobe error.", level=logging.DEBUG)
-
- try:
- return SysCommand(f'blkid -s PARTUUID -o value {self.device_path}').decode('UTF-8').strip()
- except SysCallError as error:
- if self.block_device.partition_type == 'iso9660':
- # Parent device is a Optical Disk (.iso dd'ed onto a device for instance)
- return None
-
- log(f"Could not get PARTUUID of partition using 'blkid -s PARTUUID -o value {self.device_path}': {error}")
-
- if self._partition_info:
- return self._partition_info.uuid
-
- @property
- def encrypted(self) -> Union[bool, None]:
- return self._encrypted
-
- @property
- def parent(self) -> str:
- return self.real_device
-
- @property
- def real_device(self) -> str:
- output = SysCommand('lsblk -J').decode('UTF-8')
-
- if output:
- for blockdevice in json.loads(output)['blockdevices']:
- if parent := self.find_parent_of(blockdevice, os.path.basename(self.device_path)):
- return f"/dev/{parent}"
- return self._path
-
- raise DiskError('Unable to get disk information for command "lsblk -J"')
-
- @property
- def device_path(self) -> str:
- """ for bind mounts returns the physical path of the partition
- """
- device_path, bind_name = split_bind_name(self._path)
- return device_path
-
- @property
- def bind_name(self) -> str:
- """ for bind mounts returns the bind name (subvolume path).
- Returns none if this property does not exist
- """
- device_path, bind_name = split_bind_name(self._path)
- return bind_name
-
- @property
- def subvolumes(self) -> Iterator[BtrfsSubvolumeInfo]:
- from .helpers import findmnt
-
- def iterate_children_recursively(information):
- for child in information.get('children', []):
- if target := child.get('target'):
- if child.get('fstype') == 'btrfs':
- if subvolume := subvolume_info_from_path(Path(target)):
- yield subvolume
-
- if child.get('children'):
- for subchild in iterate_children_recursively(child):
- yield subchild
-
- if self._partition_info.filesystem_type == 'btrfs':
- for mountpoint in self._partition_info.mountpoints:
- if result := findmnt(mountpoint):
- for filesystem in result.get('filesystems', []):
- if subvolume := subvolume_info_from_path(mountpoint):
- yield subvolume
-
- for child in iterate_children_recursively(filesystem):
- yield child
-
- def partprobe(self) -> bool:
- try:
- if self.block_device:
- return 0 == SysCommand(f'partprobe {self.block_device.device}').exit_code
- except SysCallError as error:
- log(f"Unreliable results might be given for {self._path} due to partprobe error: {error}", level=logging.DEBUG)
-
- return False
-
- def detect_inner_filesystem(self, password :str) -> Optional[str]:
- log(f'Trying to detect inner filesystem format on {self} (This might take a while)', level=logging.INFO)
- from ..luks import luks2
-
- try:
- with luks2(self, storage.get('ENC_IDENTIFIER', 'ai') + 'loop', password, auto_unmount=True) as unlocked_device:
- return unlocked_device.filesystem
- except SysCallError:
- pass
- return None
-
- def has_content(self) -> bool:
- fs_type = self._partition_info.filesystem_type
- if not fs_type or "swap" in fs_type:
- return False
-
- temporary_mountpoint = '/tmp/' + hashlib.md5(bytes(f"{time.time()}", 'UTF-8') + os.urandom(12)).hexdigest()
- temporary_path = Path(temporary_mountpoint)
-
- temporary_path.mkdir(parents=True, exist_ok=True)
- if (handle := SysCommand(f'/usr/bin/mount {self._path} {temporary_mountpoint}')).exit_code != 0:
- raise DiskError(f'Could not mount and check for content on {self._path} because: {handle}')
-
- files = len(glob.glob(f"{temporary_mountpoint}/*"))
- iterations = 0
- while SysCommand(f"/usr/bin/umount -R {temporary_mountpoint}").exit_code != 0 and (iterations := iterations + 1) < 10:
- time.sleep(1)
-
- temporary_path.rmdir()
-
- return True if files > 0 else False
-
- def encrypt(self, password: Optional[str] = None) -> str:
- """
- A wrapper function for luks2() instances and the .encrypt() method of that instance.
- """
- from ..luks import luks2
-
- handle = luks2(self, None, None)
- return handle.encrypt(self, password=password)
-
- def format(self, filesystem :Optional[str] = None, path :Optional[str] = None, log_formatting :bool = True, options :List[str] = [], retry :bool = True) -> bool:
- """
- Format can be given an overriding path, for instance /dev/null to test
- the formatting functionality and in essence the support for the given filesystem.
- """
- if filesystem is None:
- filesystem = self._partition_info.filesystem_type
-
- if path is None:
- path = self._path
-
- # This converts from fat32 -> vfat to unify filesystem names
- filesystem = get_mount_fs_type(filesystem)
-
- # To avoid "unable to open /dev/x: No such file or directory"
- start_wait = time.time()
- while Path(path).exists() is False and time.time() - start_wait < 10:
- time.sleep(0.025)
-
- if log_formatting:
- log(f'Formatting {path} -> {filesystem}', level=logging.INFO)
-
- try:
- if filesystem == 'btrfs':
- options = ['-f'] + options
-
- mkfs = SysCommand(f"/usr/bin/mkfs.btrfs {' '.join(options)} {path}").decode('UTF-8')
- if mkfs and 'UUID:' not in mkfs:
- raise DiskError(f'Could not format {path} with {filesystem} because: {mkfs}')
- self._partition_info.filesystem_type = filesystem
-
- elif filesystem == 'vfat':
- options = ['-F32'] + options
- log(f"/usr/bin/mkfs.vfat {' '.join(options)} {path}")
- if (handle := SysCommand(f"/usr/bin/mkfs.vfat {' '.join(options)} {path}")).exit_code != 0:
- raise DiskError(f"Could not format {path} with {filesystem} because: {handle.decode('UTF-8')}")
- self._partition_info.filesystem_type = filesystem
-
- elif filesystem == 'ext4':
- options = ['-F'] + options
-
- if (handle := SysCommand(f"/usr/bin/mkfs.ext4 {' '.join(options)} {path}")).exit_code != 0:
- raise DiskError(f"Could not format {path} with {filesystem} because: {handle.decode('UTF-8')}")
- self._partition_info.filesystem_type = filesystem
-
- elif filesystem == 'ext2':
- options = ['-F'] + options
-
- if (handle := SysCommand(f"/usr/bin/mkfs.ext2 {' '.join(options)} {path}")).exit_code != 0:
- raise DiskError(f"Could not format {path} with {filesystem} because: {handle.decode('UTF-8')}")
- self._partition_info.filesystem_type = 'ext2'
- elif filesystem == 'xfs':
- options = ['-f'] + options
-
- if (handle := SysCommand(f"/usr/bin/mkfs.xfs {' '.join(options)} {path}")).exit_code != 0:
- raise DiskError(f"Could not format {path} with {filesystem} because: {handle.decode('UTF-8')}")
- self._partition_info.filesystem_type = filesystem
-
- elif filesystem == 'f2fs':
- options = ['-f'] + options
-
- if (handle := SysCommand(f"/usr/bin/mkfs.f2fs {' '.join(options)} {path}")).exit_code != 0:
- raise DiskError(f"Could not format {path} with {filesystem} because: {handle.decode('UTF-8')}")
- self._partition_info.filesystem_type = filesystem
-
- elif filesystem == 'ntfs3':
- options = ['-f'] + options
-
- if (handle := SysCommand(f"/usr/bin/mkfs.ntfs -Q {' '.join(options)} {path}")).exit_code != 0:
- raise DiskError(f"Could not format {path} with {filesystem} because: {handle.decode('UTF-8')}")
- self._partition_info.filesystem_type = filesystem
-
- elif filesystem == 'crypto_LUKS':
- # from ..luks import luks2
- # encrypted_partition = luks2(self, None, None)
- # encrypted_partition.format(path)
- self._partition_info.filesystem_type = filesystem
-
- else:
- raise UnknownFilesystemFormat(f"Fileformat '{filesystem}' is not yet implemented.")
- except SysCallError as error:
- log(f"Formatting ran in to an error: {error}", level=logging.WARNING, fg="orange")
- if retry is True:
- log(f"Retrying in {storage.get('DISK_TIMEOUTS', 1)} seconds.", level=logging.WARNING, fg="orange")
- time.sleep(storage.get('DISK_TIMEOUTS', 1))
-
- return self.format(filesystem, path, log_formatting, options, retry=False)
-
- if get_filesystem_type(path) == 'crypto_LUKS' or get_filesystem_type(self.real_device) == 'crypto_LUKS':
- self._encrypted = True
- else:
- self._encrypted = False
-
- return True
-
- def find_parent_of(self, data :Dict[str, Any], name :str, parent :Optional[str] = None) -> Optional[str]:
- if data['name'] == name:
- return parent
- elif 'children' in data:
- for child in data['children']:
- if parent := self.find_parent_of(child, name, parent=data['name']):
- return parent
-
- return None
-
- def mount(self, target :str, fs :Optional[str] = None, options :str = '') -> bool:
- if not self._partition_info.get_first_mountpoint():
- log(f'Mounting {self} to {target}', level=logging.INFO)
-
- if not fs:
- fs = self._partition_info.filesystem_type
-
- fs_type = get_mount_fs_type(fs)
-
- Path(target).mkdir(parents=True, exist_ok=True)
-
- if self.bind_name:
- device_path = self.device_path
- # TODO options should be better be a list than a string
- if options:
- options = f"{options},subvol={self.bind_name}"
- else:
- options = f"subvol={self.bind_name}"
- else:
- device_path = self._path
- try:
- if options:
- mnt_handle = SysCommand(f"/usr/bin/mount -t {fs_type} -o {options} {device_path} {target}")
- else:
- mnt_handle = SysCommand(f"/usr/bin/mount -t {fs_type} {device_path} {target}")
-
- # TODO: Should be redundant to check for exit_code
- if mnt_handle.exit_code != 0:
- raise DiskError(f"Could not mount {self._path} to {target} using options {options}")
- except SysCallError as err:
- raise err
-
- # Update the partition info since the mount info has changed after this call.
- self._partition_info = self._fetch_information()
- return True
-
- return False
-
- def unmount(self) -> bool:
- SysCommand(f"/usr/bin/umount {self._path}")
-
- # Update the partition info since the mount info has changed after this call.
- self._partition_info = self._fetch_information()
- return True
-
- def filesystem_supported(self) -> bool:
- """
- The support for a filesystem (this partition) is tested by calling
- partition.format() with a path set to '/dev/null' which returns two exceptions:
- 1. SysCallError saying that /dev/null is not formattable - but the filesystem is supported
- 2. UnknownFilesystemFormat that indicates that we don't support the given filesystem type
- """
- try:
- self.format(self._partition_info.filesystem_type, '/dev/null', log_formatting=False)
- except (SysCallError, DiskError):
- pass # We supported it, but /dev/null is not formattable as expected so the mkfs call exited with an error code
- except UnknownFilesystemFormat as err:
- raise err
- return True
-
-
-def get_mount_fs_type(fs :str) -> str:
- if fs == 'ntfs':
- return 'ntfs3' # Needed to use the Paragon R/W NTFS driver
- elif fs == 'fat32':
- return 'vfat' # This is the actual type used for fat32 mounting
- return fs
diff --git a/archinstall/lib/disk/partitioning_menu.py b/archinstall/lib/disk/partitioning_menu.py
new file mode 100644
index 00000000..fb1eb74b
--- /dev/null
+++ b/archinstall/lib/disk/partitioning_menu.py
@@ -0,0 +1,429 @@
+from __future__ import annotations
+
+import re
+from pathlib import Path
+from typing import Any, TYPE_CHECKING, List, Optional, Tuple
+from dataclasses import dataclass
+
+from .device_model import (
+ PartitionModification, FilesystemType, BDevice,
+ Size, Unit, PartitionType, PartitionFlag,
+ ModificationStatus, DeviceGeometry, SectorSize, BtrfsMountOption
+)
+from ..hardware import SysInfo
+from ..menu import Menu, ListManager, MenuSelection, TextInput
+from ..output import FormattedOutput, warn
+from .subvolume_menu import SubvolumeMenu
+
+if TYPE_CHECKING:
+ _: Any
+
+
+@dataclass
+class DefaultFreeSector:
+ start: Size
+ end: Size
+
+
+class PartitioningList(ListManager):
+ """
+ subclass of ListManager for the managing of user accounts
+ """
+ def __init__(self, prompt: str, device: BDevice, device_partitions: List[PartitionModification]):
+ self._device = device
+ self._actions = {
+ 'create_new_partition': str(_('Create a new partition')),
+ 'suggest_partition_layout': str(_('Suggest partition layout')),
+ 'remove_added_partitions': str(_('Remove all newly added partitions')),
+ 'assign_mountpoint': str(_('Assign mountpoint')),
+ 'mark_formatting': str(_('Mark/Unmark to be formatted (wipes data)')),
+ 'mark_bootable': str(_('Mark/Unmark as bootable')),
+ 'set_filesystem': str(_('Change filesystem')),
+ 'btrfs_mark_compressed': str(_('Mark/Unmark as compressed')), # btrfs only
+ 'btrfs_mark_nodatacow': str(_('Mark/Unmark as nodatacow')), # btrfs only
+ 'btrfs_set_subvolumes': str(_('Set subvolumes')), # btrfs only
+ 'delete_partition': str(_('Delete partition'))
+ }
+
+ display_actions = list(self._actions.values())
+ super().__init__(prompt, device_partitions, display_actions[:2], display_actions[3:])
+
+ def selected_action_display(self, partition: PartitionModification) -> str:
+ return str(_('Partition'))
+
+ def filter_options(self, selection: PartitionModification, options: List[str]) -> List[str]:
+ not_filter = []
+
+ # only display formatting if the partition exists already
+ if not selection.exists():
+ not_filter += [self._actions['mark_formatting']]
+ else:
+ # only allow options if the existing partition
+ # was marked as formatting, otherwise we run into issues where
+ # 1. select a new fs -> potentially mark as wipe now
+ # 2. Switch back to old filesystem -> should unmark wipe now, but
+ # how do we know it was the original one?
+ not_filter += [
+ self._actions['set_filesystem'],
+ self._actions['mark_bootable'],
+ self._actions['btrfs_mark_compressed'],
+ self._actions['btrfs_mark_nodatacow'],
+ self._actions['btrfs_set_subvolumes']
+ ]
+
+ # non btrfs partitions shouldn't get btrfs options
+ if selection.fs_type != FilesystemType.Btrfs:
+ not_filter += [
+ self._actions['btrfs_mark_compressed'],
+ self._actions['btrfs_mark_nodatacow'],
+ self._actions['btrfs_set_subvolumes']
+ ]
+ else:
+ not_filter += [self._actions['assign_mountpoint']]
+
+ return [o for o in options if o not in not_filter]
+
+ def handle_action(
+ self,
+ action: str,
+ entry: Optional[PartitionModification],
+ data: List[PartitionModification]
+ ) -> List[PartitionModification]:
+ action_key = [k for k, v in self._actions.items() if v == action][0]
+
+ match action_key:
+ case 'create_new_partition':
+ new_partition = self._create_new_partition()
+ data += [new_partition]
+ case 'suggest_partition_layout':
+ new_partitions = self._suggest_partition_layout(data)
+ if len(new_partitions) > 0:
+ data = new_partitions
+ case 'remove_added_partitions':
+ choice = self._reset_confirmation()
+ if choice.value == Menu.yes():
+ data = [part for part in data if part.is_exists_or_modify()]
+ case 'assign_mountpoint' if entry:
+ entry.mountpoint = self._prompt_mountpoint()
+ if entry.mountpoint == Path('/boot'):
+ entry.set_flag(PartitionFlag.Boot)
+ if SysInfo.has_uefi():
+ entry.set_flag(PartitionFlag.ESP)
+ case 'mark_formatting' if entry:
+ self._prompt_formatting(entry)
+ case 'mark_bootable' if entry:
+ entry.invert_flag(PartitionFlag.Boot)
+ if SysInfo.has_uefi():
+ entry.invert_flag(PartitionFlag.ESP)
+ case 'set_filesystem' if entry:
+ fs_type = self._prompt_partition_fs_type()
+ if fs_type:
+ entry.fs_type = fs_type
+ # btrfs subvolumes will define mountpoints
+ if fs_type == FilesystemType.Btrfs:
+ entry.mountpoint = None
+ case 'btrfs_mark_compressed' if entry:
+ self._toggle_mount_option(entry, BtrfsMountOption.compress)
+ case 'btrfs_mark_nodatacow' if entry:
+ self._toggle_mount_option(entry, BtrfsMountOption.nodatacow)
+ case 'btrfs_set_subvolumes' if entry:
+ self._set_btrfs_subvolumes(entry)
+ case 'delete_partition' if entry:
+ data = self._delete_partition(entry, data)
+
+ return data
+
+ def _delete_partition(
+ self,
+ entry: PartitionModification,
+ data: List[PartitionModification]
+ ) -> List[PartitionModification]:
+ if entry.is_exists_or_modify():
+ entry.status = ModificationStatus.Delete
+ return data
+ else:
+ return [d for d in data if d != entry]
+
+ def _toggle_mount_option(
+ self,
+ partition: PartitionModification,
+ option: BtrfsMountOption
+ ):
+ if option.value not in partition.mount_options:
+ if option == BtrfsMountOption.compress:
+ partition.mount_options = [
+ o for o in partition.mount_options
+ if o != BtrfsMountOption.nodatacow.value
+ ]
+
+ partition.mount_options = [
+ o for o in partition.mount_options
+ if not o.startswith(BtrfsMountOption.compress.name)
+ ]
+
+ partition.mount_options.append(option.value)
+ else:
+ partition.mount_options = [
+ o for o in partition.mount_options if o != option.value
+ ]
+
+ def _set_btrfs_subvolumes(self, partition: PartitionModification):
+ partition.btrfs_subvols = SubvolumeMenu(
+ _("Manage btrfs subvolumes for current partition"),
+ partition.btrfs_subvols
+ ).run()
+
+ def _prompt_formatting(self, partition: PartitionModification):
+ # an existing partition can toggle between Exist or Modify
+ if partition.is_modify():
+ partition.status = ModificationStatus.Exist
+ return
+ elif partition.exists():
+ partition.status = ModificationStatus.Modify
+
+ # If we mark a partition for formatting, but the format is CRYPTO LUKS, there's no point in formatting it really
+ # without asking the user which inner-filesystem they want to use. Since the flag 'encrypted' = True is already set,
+ # it's safe to change the filesystem for this partition.
+ if partition.fs_type == FilesystemType.Crypto_luks:
+ prompt = str(_('This partition is currently encrypted, to format it a filesystem has to be specified'))
+ fs_type = self._prompt_partition_fs_type(prompt)
+ partition.fs_type = fs_type
+
+ if fs_type == FilesystemType.Btrfs:
+ partition.mountpoint = None
+
+ def _prompt_mountpoint(self) -> Path:
+ header = str(_('Partition mount-points are relative to inside the installation, the boot would be /boot as an example.')) + '\n'
+ header += str(_('If mountpoint /boot is set, then the partition will also be marked as bootable.')) + '\n'
+ prompt = str(_('Mountpoint: '))
+
+ print(header)
+
+ while True:
+ value = TextInput(prompt).run().strip()
+
+ if value:
+ mountpoint = Path(value)
+ break
+
+ return mountpoint
+
+ def _prompt_partition_fs_type(self, prompt: str = '') -> FilesystemType:
+ options = {fs.value: fs for fs in FilesystemType if fs != FilesystemType.Crypto_luks}
+
+ prompt = prompt + '\n' + str(_('Enter a desired filesystem type for the partition'))
+ choice = Menu(prompt, options, sort=False, skip=False).run()
+ return options[choice.single_value]
+
+ def _validate_value(
+ self,
+ sector_size: SectorSize,
+ total_size: Size,
+ text: str,
+ start: Optional[Size]
+ ) -> Optional[Size]:
+ match = re.match(r'([0-9]+)([a-zA-Z|%]*)', text, re.I)
+
+ if match:
+ str_value, unit = match.groups()
+
+ if unit == '%' and start:
+ available = total_size - start
+ value = int(available.value * (int(str_value) / 100))
+ unit = available.unit.name
+ else:
+ value = int(str_value)
+
+ if unit and unit not in Unit.get_all_units():
+ return None
+
+ unit = Unit[unit] if unit else Unit.sectors
+ return Size(value, unit, sector_size)
+
+ return None
+
+ def _enter_size(
+ self,
+ sector_size: SectorSize,
+ total_size: Size,
+ prompt: str,
+ default: Size,
+ start: Optional[Size],
+ ) -> Size:
+ while True:
+ value = TextInput(prompt).run().strip()
+ size: Optional[Size] = None
+ if not value:
+ size = default
+ else:
+ size = self._validate_value(sector_size, total_size, value, start)
+
+ if size:
+ return size
+
+ warn(f'Invalid value: {value}')
+
+ def _prompt_size(self) -> Tuple[Size, Size]:
+ device_info = self._device.device_info
+
+ text = str(_('Current free sectors on device {}:')).format(device_info.path) + '\n\n'
+ free_space_table = FormattedOutput.as_table(device_info.free_space_regions)
+ prompt = text + free_space_table + '\n'
+
+ total_sectors = device_info.total_size.format_size(Unit.sectors, device_info.sector_size)
+ total_bytes = device_info.total_size.format_size(Unit.B)
+
+ prompt += str(_('Total: {} / {}')).format(total_sectors, total_bytes) + '\n\n'
+ prompt += str(_('All entered values can be suffixed with a unit: %, B, KB, KiB, MB, MiB...')) + '\n'
+ prompt += str(_('If no unit is provided, the value is interpreted as sectors')) + '\n'
+ print(prompt)
+
+ default_free_sector = self._find_default_free_space()
+
+ if not default_free_sector:
+ default_free_sector = DefaultFreeSector(
+ Size(0, Unit.sectors, self._device.device_info.sector_size),
+ Size(0, Unit.sectors, self._device.device_info.sector_size)
+ )
+
+ # prompt until a valid start sector was entered
+ start_prompt = str(_('Enter start (default: sector {}): ')).format(default_free_sector.start.value)
+
+ start_size = self._enter_size(
+ device_info.sector_size,
+ device_info.total_size,
+ start_prompt,
+ default_free_sector.start,
+ None
+ )
+
+ if start_size.value == default_free_sector.start.value and default_free_sector.end.value != 0:
+ end_size = default_free_sector.end
+ else:
+ end_size = device_info.total_size
+
+ # prompt until valid end sector was entered
+ end_prompt = str(_('Enter end (default: {}): ')).format(end_size.as_text())
+ end_size = self._enter_size(
+ device_info.sector_size,
+ device_info.total_size,
+ end_prompt,
+ end_size,
+ start_size
+ )
+
+ return start_size, end_size
+
+ def _find_default_free_space(self) -> Optional[DefaultFreeSector]:
+ device_info = self._device.device_info
+
+ largest_free_area: Optional[DeviceGeometry] = None
+ largest_deleted_area: Optional[PartitionModification] = None
+
+ if len(device_info.free_space_regions) > 0:
+ largest_free_area = max(device_info.free_space_regions, key=lambda r: r.get_length())
+
+ deleted_partitions = list(filter(lambda x: x.status == ModificationStatus.Delete, self._data))
+ if len(deleted_partitions) > 0:
+ largest_deleted_area = max(deleted_partitions, key=lambda p: p.length)
+
+ def _free_space(space: DeviceGeometry) -> DefaultFreeSector:
+ start = Size(space.start, Unit.sectors, device_info.sector_size)
+ end = Size(space.end, Unit.sectors, device_info.sector_size)
+ return DefaultFreeSector(start, end)
+
+ def _free_deleted(space: PartitionModification) -> DefaultFreeSector:
+ start = space.start.convert(Unit.sectors, self._device.device_info.sector_size)
+ end = space.end.convert(Unit.sectors, self._device.device_info.sector_size)
+ return DefaultFreeSector(start, end)
+
+ if not largest_deleted_area and largest_free_area:
+ return _free_space(largest_free_area)
+ elif not largest_free_area and largest_deleted_area:
+ return _free_deleted(largest_deleted_area)
+ elif not largest_deleted_area and not largest_free_area:
+ return None
+ elif largest_free_area and largest_deleted_area:
+ free_space = _free_space(largest_free_area)
+ if free_space.start > largest_deleted_area.start:
+ return free_space
+ else:
+ return _free_deleted(largest_deleted_area)
+
+ return None
+
+ def _create_new_partition(self) -> PartitionModification:
+ fs_type = self._prompt_partition_fs_type()
+
+ start_size, end_size = self._prompt_size()
+ length = end_size - start_size
+
+ # new line for the next prompt
+ print()
+
+ mountpoint = None
+ if fs_type != FilesystemType.Btrfs:
+ mountpoint = self._prompt_mountpoint()
+
+ partition = PartitionModification(
+ status=ModificationStatus.Create,
+ type=PartitionType.Primary,
+ start=start_size,
+ length=length,
+ fs_type=fs_type,
+ mountpoint=mountpoint
+ )
+
+ if partition.mountpoint == Path('/boot'):
+ partition.set_flag(PartitionFlag.Boot)
+ if SysInfo.has_uefi():
+ partition.set_flag(PartitionFlag.ESP)
+
+ return partition
+
+ def _reset_confirmation(self) -> MenuSelection:
+ prompt = str(_('This will remove all newly added partitions, continue?'))
+ choice = Menu(prompt, Menu.yes_no(), default_option=Menu.no(), skip=False).run()
+ return choice
+
+ def _suggest_partition_layout(self, data: List[PartitionModification]) -> List[PartitionModification]:
+ # if modifications have been done already, inform the user
+ # that this operation will erase those modifications
+ if any([not entry.exists() for entry in data]):
+ choice = self._reset_confirmation()
+ if choice.value == Menu.no():
+ return []
+
+ from ..interactions.disk_conf import suggest_single_disk_layout
+
+ device_modification = suggest_single_disk_layout(self._device)
+ return device_modification.partitions
+
+
+def manual_partitioning(
+ device: BDevice,
+ prompt: str = '',
+ preset: List[PartitionModification] = []
+) -> List[PartitionModification]:
+ if not prompt:
+ prompt = str(_('Partition management: {}')).format(device.device_info.path) + '\n'
+ prompt += str(_('Total length: {}')).format(device.device_info.total_size.format_size(Unit.MiB))
+
+ manual_preset = []
+
+ if not preset:
+ # we'll display the existing partitions of the device
+ for partition in device.partition_infos:
+ manual_preset.append(
+ PartitionModification.from_existing_partition(partition)
+ )
+ else:
+ manual_preset = preset
+
+ menu_list = PartitioningList(prompt, device, manual_preset)
+ partitions: List[PartitionModification] = menu_list.run()
+
+ if menu_list.is_last_choice_cancel():
+ return preset
+
+ return partitions
diff --git a/archinstall/lib/disk/subvolume_menu.py b/archinstall/lib/disk/subvolume_menu.py
new file mode 100644
index 00000000..ea77149d
--- /dev/null
+++ b/archinstall/lib/disk/subvolume_menu.py
@@ -0,0 +1,61 @@
+from pathlib import Path
+from typing import List, Optional, Any, TYPE_CHECKING
+
+from .device_model import SubvolumeModification
+from ..menu import TextInput, ListManager
+
+if TYPE_CHECKING:
+ _: Any
+
+
+class SubvolumeMenu(ListManager):
+ def __init__(self, prompt: str, btrfs_subvols: List[SubvolumeModification]):
+ self._actions = [
+ str(_('Add subvolume')),
+ str(_('Edit subvolume')),
+ str(_('Delete subvolume'))
+ ]
+ super().__init__(prompt, btrfs_subvols, [self._actions[0]], self._actions[1:])
+
+ def selected_action_display(self, subvolume: SubvolumeModification) -> str:
+ return str(subvolume.name)
+
+ def _add_subvolume(self, editing: Optional[SubvolumeModification] = None) -> Optional[SubvolumeModification]:
+ name = TextInput(f'\n\n{_("Subvolume name")}: ', editing.name if editing else '').run()
+
+ if not name:
+ return None
+
+ mountpoint = TextInput(f'{_("Subvolume mountpoint")}: ', str(editing.mountpoint) if editing else '').run()
+
+ if not mountpoint:
+ return None
+
+ return SubvolumeModification(Path(name), Path(mountpoint))
+
+ def handle_action(
+ self,
+ action: str,
+ entry: Optional[SubvolumeModification],
+ data: List[SubvolumeModification]
+ ) -> List[SubvolumeModification]:
+ if action == self._actions[0]: # add
+ new_subvolume = self._add_subvolume()
+
+ if new_subvolume is not None:
+ # in case a user with the same username as an existing user
+ # was created we'll replace the existing one
+ data = [d for d in data if d.name != new_subvolume.name]
+ data += [new_subvolume]
+ elif entry is not None:
+ if action == self._actions[1]: # edit subvolume
+ new_subvolume = self._add_subvolume(entry)
+
+ if new_subvolume is not None:
+ # we'll remove the original subvolume and add the modified version
+ data = [d for d in data if d.name != entry.name and d.name != new_subvolume.name]
+ data += [new_subvolume]
+ elif action == self._actions[2]: # delete
+ data = [d for d in data if d != entry]
+
+ return data
diff --git a/archinstall/lib/disk/user_guides.py b/archinstall/lib/disk/user_guides.py
deleted file mode 100644
index 5809c073..00000000
--- a/archinstall/lib/disk/user_guides.py
+++ /dev/null
@@ -1,240 +0,0 @@
-from __future__ import annotations
-import logging
-from typing import Optional, Dict, Any, List, TYPE_CHECKING
-
-# https://stackoverflow.com/a/39757388/929999
-from ..models.subvolume import Subvolume
-
-if TYPE_CHECKING:
- from .blockdevice import BlockDevice
- _: Any
-
-from .helpers import sort_block_devices_based_on_performance, select_largest_device, select_disk_larger_than_or_close_to
-from ..hardware import has_uefi
-from ..output import log
-from ..menu import Menu
-
-
-def suggest_single_disk_layout(block_device :BlockDevice,
- default_filesystem :Optional[str] = None,
- advanced_options :bool = False) -> Dict[str, Any]:
-
- if not default_filesystem:
- from ..user_interaction import ask_for_main_filesystem_format
- default_filesystem = ask_for_main_filesystem_format(advanced_options)
-
- MIN_SIZE_TO_ALLOW_HOME_PART = 40 # GiB
- using_subvolumes = False
- using_home_partition = False
- compression = False
-
- if default_filesystem == 'btrfs':
- prompt = str(_('Would you like to use BTRFS subvolumes with a default structure?'))
- choice = Menu(prompt, Menu.yes_no(), skip=False, default_option=Menu.yes()).run()
- using_subvolumes = choice.value == Menu.yes()
-
- prompt = str(_('Would you like to use BTRFS compression?'))
- choice = Menu(prompt, Menu.yes_no(), skip=False, default_option=Menu.yes()).run()
- compression = choice.value == Menu.yes()
-
- layout = {
- block_device.path : {
- "wipe" : True,
- "partitions" : []
- }
- }
-
- # Used for reference: https://wiki.archlinux.org/title/partitioning
-
- # 2 MiB is unallocated for GRUB on BIOS. Potentially unneeded for
- # other bootloaders?
-
- # TODO: On BIOS, /boot partition is only needed if the drive will
- # be encrypted, otherwise it is not recommended. We should probably
- # add a check for whether the drive will be encrypted or not.
- layout[block_device.path]['partitions'].append({
- # Boot
- "type" : "primary",
- "start" : "3MiB",
- "size" : "203MiB",
- "boot" : True,
- "encrypted" : False,
- "wipe" : True,
- "mountpoint" : "/boot",
- "filesystem" : {
- "format" : "fat32"
- }
- })
-
- # Increase the UEFI partition if UEFI is detected.
- # Also re-align the start to 1MiB since we don't need the first sectors
- # like we do in MBR layouts where the boot loader is installed traditionally.
- if has_uefi():
- layout[block_device.path]['partitions'][-1]['start'] = '1MiB'
- layout[block_device.path]['partitions'][-1]['size'] = '512MiB'
-
- layout[block_device.path]['partitions'].append({
- # Root
- "type" : "primary",
- "start" : "206MiB",
- "encrypted" : False,
- "wipe" : True,
- "mountpoint" : "/" if not using_subvolumes else None,
- "filesystem" : {
- "format" : default_filesystem,
- "mount_options" : ["compress=zstd"] if compression else []
- }
- })
-
- if has_uefi():
- layout[block_device.path]['partitions'][-1]['start'] = '513MiB'
-
- if not using_subvolumes and block_device.size >= MIN_SIZE_TO_ALLOW_HOME_PART:
- prompt = str(_('Would you like to create a separate partition for /home?'))
- choice = Menu(prompt, Menu.yes_no(), skip=False, default_option=Menu.yes()).run()
- using_home_partition = choice.value == Menu.yes()
-
- # Set a size for / (/root)
- if using_subvolumes or block_device.size < MIN_SIZE_TO_ALLOW_HOME_PART or not using_home_partition:
- # We'll use subvolumes
- # Or the disk size is too small to allow for a separate /home
- # Or the user doesn't want to create a separate partition for /home
- layout[block_device.path]['partitions'][-1]['size'] = '100%'
- else:
- layout[block_device.path]['partitions'][-1]['size'] = f"{min(block_device.size, 20)}GiB"
-
- if default_filesystem == 'btrfs' and using_subvolumes:
- # if input('Do you want to use a recommended structure? (Y/n): ').strip().lower() in ('', 'y', 'yes'):
- # https://btrfs.wiki.kernel.org/index.php/FAQ
- # https://unix.stackexchange.com/questions/246976/btrfs-subvolume-uuid-clash
- # https://github.com/classy-giraffe/easy-arch/blob/main/easy-arch.sh
- layout[block_device.path]['partitions'][1]['btrfs'] = {
- 'subvolumes': [
- Subvolume('@', '/'),
- Subvolume('@home', '/home'),
- Subvolume('@log', '/var/log'),
- Subvolume('@pkg', '/var/cache/pacman/pkg'),
- Subvolume('@.snapshots', '/.snapshots')
- ]
- }
- elif using_home_partition:
- # If we don't want to use subvolumes,
- # But we want to be able to re-use data between re-installs..
- # A second partition for /home would be nice if we have the space for it
- layout[block_device.path]['partitions'].append({
- # Home
- "type" : "primary",
- "start" : f"{min(block_device.size, 20)}GiB",
- "size" : "100%",
- "encrypted" : False,
- "wipe" : True,
- "mountpoint" : "/home",
- "filesystem" : {
- "format" : default_filesystem,
- "mount_options" : ["compress=zstd"] if compression else []
- }
- })
-
- return layout
-
-
-def suggest_multi_disk_layout(block_devices :List[BlockDevice], default_filesystem :Optional[str] = None, advanced_options :bool = False):
-
- if not default_filesystem:
- from ..user_interaction import ask_for_main_filesystem_format
- default_filesystem = ask_for_main_filesystem_format(advanced_options)
-
- # Not really a rock solid foundation of information to stand on, but it's a start:
- # https://www.reddit.com/r/btrfs/comments/m287gp/partition_strategy_for_two_physical_disks/
- # https://www.reddit.com/r/btrfs/comments/9us4hr/what_is_your_btrfs_partitionsubvolumes_scheme/
-
- MIN_SIZE_TO_ALLOW_HOME_PART = 40 # GiB
- ARCH_LINUX_INSTALLED_SIZE = 20 # GiB, rough estimate taking in to account user desktops etc. TODO: Catch user packages to detect size?
-
- block_devices = sort_block_devices_based_on_performance(block_devices).keys()
-
- home_device = select_largest_device(block_devices, gigabytes=MIN_SIZE_TO_ALLOW_HOME_PART)
- root_device = select_disk_larger_than_or_close_to(block_devices, gigabytes=ARCH_LINUX_INSTALLED_SIZE, filter_out=[home_device])
-
- if home_device is None or root_device is None:
- text = _('The selected drives do not have the minimum capacity required for an automatic suggestion\n')
- text += _('Minimum capacity for /home partition: {}GB\n').format(MIN_SIZE_TO_ALLOW_HOME_PART)
- text += _('Minimum capacity for Arch Linux partition: {}GB').format(ARCH_LINUX_INSTALLED_SIZE)
- Menu(str(text), [str(_('Continue'))], skip=False).run()
- return None
-
- compression = False
-
- if default_filesystem == 'btrfs':
- # prompt = 'Would you like to use BTRFS subvolumes with a default structure?'
- # choice = Menu(prompt, ['yes', 'no'], skip=False, default_option='yes').run()
- # using_subvolumes = choice == 'yes'
-
- prompt = str(_('Would you like to use BTRFS compression?'))
- choice = Menu(prompt, Menu.yes_no(), skip=False, default_option=Menu.yes()).run()
- compression = choice.value == Menu.yes()
-
- log(f"Suggesting multi-disk-layout using {len(block_devices)} disks, where {root_device} will be /root and {home_device} will be /home", level=logging.DEBUG)
-
- layout = {
- root_device.path : {
- "wipe" : True,
- "partitions" : []
- },
- home_device.path : {
- "wipe" : True,
- "partitions" : []
- },
- }
-
- # TODO: Same deal as with the single disk layout, we should
- # probably check if the drive will be encrypted.
- layout[root_device.path]['partitions'].append({
- # Boot
- "type" : "primary",
- "start" : "3MiB",
- "size" : "203MiB",
- "boot" : True,
- "encrypted" : False,
- "wipe" : True,
- "mountpoint" : "/boot",
- "filesystem" : {
- "format" : "fat32"
- }
- })
-
- if has_uefi():
- layout[root_device.path]['partitions'][-1]['start'] = '1MiB'
- layout[root_device.path]['partitions'][-1]['size'] = '512MiB'
-
- layout[root_device.path]['partitions'].append({
- # Root
- "type" : "primary",
- "start" : "206MiB",
- "size" : "100%",
- "encrypted" : False,
- "wipe" : True,
- "mountpoint" : "/",
- "filesystem" : {
- "format" : default_filesystem,
- "mount_options" : ["compress=zstd"] if compression else []
- }
- })
- if has_uefi():
- layout[root_device.path]['partitions'][-1]['start'] = '513MiB'
-
- layout[home_device.path]['partitions'].append({
- # Home
- "type" : "primary",
- "start" : "1MiB",
- "size" : "100%",
- "encrypted" : False,
- "wipe" : True,
- "mountpoint" : "/home",
- "filesystem" : {
- "format" : default_filesystem,
- "mount_options" : ["compress=zstd"] if compression else []
- }
- })
-
- return layout
diff --git a/archinstall/lib/disk/validators.py b/archinstall/lib/disk/validators.py
deleted file mode 100644
index 076a8ba2..00000000
--- a/archinstall/lib/disk/validators.py
+++ /dev/null
@@ -1,48 +0,0 @@
-from typing import List
-
-def valid_parted_position(pos :str) -> bool:
- if not len(pos):
- return False
-
- if pos.isdigit():
- return True
-
- pos_lower = pos.lower()
-
- if (pos_lower.endswith('b') or pos_lower.endswith('s')) and pos[:-1].isdigit():
- return True
-
- if any(pos_lower.endswith(size) and pos[:-len(size)].replace(".", "", 1).isdigit()
- for size in ['%', 'kb', 'mb', 'gb', 'tb', 'kib', 'mib', 'gib', 'tib']):
- return True
-
- return False
-
-
-def fs_types() -> List[str]:
- # https://www.gnu.org/software/parted/manual/html_node/mkpart.html
- # Above link doesn't agree with `man parted` /mkpart documentation:
- """
- fs-type can
- be one of "btrfs", "ext2",
- "ext3", "ext4", "fat16",
- "fat32", "hfs", "hfs+",
- "linux-swap", "ntfs", "reis‐
- erfs", "udf", or "xfs".
- """
- return [
- "btrfs",
- "ext2",
- "ext3", "ext4", # `man parted` allows these
- "fat16", "fat32",
- "hfs", "hfs+", # "hfsx", not included in `man parted`
- "linux-swap",
- "ntfs",
- "reiserfs",
- "udf", # "ufs", not included in `man parted`
- "xfs", # `man parted` allows this
- ]
-
-
-def valid_fs_type(fstype :str) -> bool:
- return fstype.lower() in fs_types()
diff --git a/archinstall/lib/exceptions.py b/archinstall/lib/exceptions.py
index a66e4e04..80926e0b 100644
--- a/archinstall/lib/exceptions.py
+++ b/archinstall/lib/exceptions.py
@@ -3,23 +3,20 @@ from typing import Optional, TYPE_CHECKING
if TYPE_CHECKING:
from .general import SysCommandWorker
-class RequirementError(BaseException):
- pass
-
-class DiskError(BaseException):
+class RequirementError(Exception):
pass
-class UnknownFilesystemFormat(BaseException):
+class DiskError(Exception):
pass
-class ProfileError(BaseException):
+class UnknownFilesystemFormat(Exception):
pass
-class SysCallError(BaseException):
+class SysCallError(Exception):
def __init__(self, message :str, exit_code :Optional[int] = None, worker :Optional['SysCommandWorker'] = None) -> None:
super(SysCallError, self).__init__(message)
self.message = message
@@ -27,33 +24,17 @@ class SysCallError(BaseException):
self.worker = worker
-class PermissionError(BaseException):
+class HardwareIncompatibilityError(Exception):
pass
-class ProfileNotFound(BaseException):
+class ServiceException(Exception):
pass
-class HardwareIncompatibilityError(BaseException):
+class PackageError(Exception):
pass
-class UserError(BaseException):
+class Deprecated(Exception):
pass
-
-
-class ServiceException(BaseException):
- pass
-
-
-class PackageError(BaseException):
- pass
-
-
-class TranslationError(BaseException):
- pass
-
-
-class Deprecated(BaseException):
- pass \ No newline at end of file
diff --git a/archinstall/lib/general.py b/archinstall/lib/general.py
index 79ab024b..8dbf23ff 100644
--- a/archinstall/lib/general.py
+++ b/archinstall/lib/general.py
@@ -1,7 +1,6 @@
from __future__ import annotations
-import hashlib
+
import json
-import logging
import os
import secrets
import shlex
@@ -12,212 +11,117 @@ import sys
import time
import re
import urllib.parse
-import urllib.request
+from urllib.request import Request, urlopen
import urllib.error
import pathlib
from datetime import datetime, date
+from enum import Enum
from typing import Callable, Optional, Dict, Any, List, Union, Iterator, TYPE_CHECKING
-# https://stackoverflow.com/a/39757388/929999
-if TYPE_CHECKING:
- from .installer import Installer
-
-if sys.platform == 'linux':
- from select import epoll, EPOLLIN, EPOLLHUP
-else:
- import select
- EPOLLIN = 0
- EPOLLHUP = 0
-
- class epoll():
- """ #!if windows
- Create a epoll() implementation that simulates the epoll() behavior.
- This so that the rest of the code doesn't need to worry weither we're using select() or epoll().
- """
- def __init__(self) -> None:
- self.sockets: Dict[str, Any] = {}
- self.monitoring: Dict[int, Any] = {}
-
- def unregister(self, fileno :int, *args :List[Any], **kwargs :Dict[str, Any]) -> None:
- try:
- del(self.monitoring[fileno]) # noqa: E275
- except:
- pass
-
- def register(self, fileno :int, *args :int, **kwargs :Dict[str, Any]) -> None:
- self.monitoring[fileno] = True
-
- def poll(self, timeout: float = 0.05, *args :str, **kwargs :Dict[str, Any]) -> List[Any]:
- try:
- return [[fileno, 1] for fileno in select.select(list(self.monitoring.keys()), [], [], timeout)[0]]
- except OSError:
- return []
+from select import epoll, EPOLLIN, EPOLLHUP
+from shutil import which
from .exceptions import RequirementError, SysCallError
-from .output import log
+from .output import debug, error, info
from .storage import storage
-def gen_uid(entropy_length :int = 256) -> str:
- return hashlib.sha512(os.urandom(entropy_length)).hexdigest()
+
+if TYPE_CHECKING:
+ from .installer import Installer
+
def generate_password(length :int = 64) -> str:
- haystack = string.printable # digits, ascii_letters, punctiation (!"#$[] etc) and whitespace
+ haystack = string.printable # digits, ascii_letters, punctuation (!"#$[] etc) and whitespace
return ''.join(secrets.choice(haystack) for i in range(length))
-def multisplit(s :str, splitters :List[str]) -> str:
- s = [s, ]
- for key in splitters:
- ns = []
- for obj in s:
- x = obj.split(key)
- for index, part in enumerate(x):
- if len(part):
- ns.append(part)
- if index < len(x) - 1:
- ns.append(key)
- s = ns
- return s
def locate_binary(name :str) -> str:
- for PATH in os.environ['PATH'].split(':'):
- for root, folders, files in os.walk(PATH):
- for file in files:
- if file == name:
- return os.path.join(root, file)
- break # Don't recurse
-
+ if path := which(name):
+ return path
raise RequirementError(f"Binary {name} does not exist.")
-def clear_vt100_escape_codes(data :Union[bytes, str]):
- # https://stackoverflow.com/a/43627833/929999
- if type(data) == bytes:
- vt100_escape_regex = bytes(r'\x1B\[[?0-9;]*[a-zA-Z]', 'UTF-8')
- else:
- vt100_escape_regex = r'\x1B\[[?0-9;]*[a-zA-Z]'
-
- for match in re.findall(vt100_escape_regex, data, re.IGNORECASE):
- data = data.replace(match, '' if type(data) == str else b'')
- return data
-
-def json_dumps(*args :str, **kwargs :str) -> str:
- return json.dumps(*args, **{**kwargs, 'cls': JSON})
+def clear_vt100_escape_codes(data :Union[bytes, str]) -> Union[bytes, str]:
+ # https://stackoverflow.com/a/43627833/929999
+ vt100_escape_regex = r'\x1B\[[?0-9;]*[a-zA-Z]'
+ if isinstance(data, bytes):
+ return re.sub(vt100_escape_regex.encode(), b'', data)
+ return re.sub(vt100_escape_regex, '', data)
-class JsonEncoder:
- @staticmethod
- def _encode(obj :Any) -> Any:
- """
- This JSON encoder function will try it's best to convert
- any archinstall data structures, instances or variables into
- something that's understandable by the json.parse()/json.loads() lib.
- _encode() will skip any dictionary key starting with an exclamation mark (!)
- """
- if isinstance(obj, dict):
- # We'll need to iterate not just the value that default() usually gets passed
- # But also iterate manually over each key: value pair in order to trap the keys.
-
- copy = {}
- for key, val in list(obj.items()):
- if isinstance(val, dict):
- # This, is a EXTREMELY ugly hack.. but it's the only quick way I can think of to trigger a encoding of sub-dictionaries.
- val = json.loads(json.dumps(val, cls=JSON))
- else:
- val = JsonEncoder._encode(val)
-
- if type(key) == str and key[0] == '!':
- pass
- else:
- copy[JsonEncoder._encode(key)] = val
- return copy
- elif hasattr(obj, 'json'):
- # json() is a friendly name for json-helper, it should return
- # a dictionary representation of the object so that it can be
- # processed by the json library.
- return json.loads(json.dumps(obj.json(), cls=JSON))
- elif hasattr(obj, '__dump__'):
- return obj.__dump__()
- elif isinstance(obj, (datetime, date)):
- return obj.isoformat()
- elif isinstance(obj, (list, set, tuple)):
- return [json.loads(json.dumps(item, cls=JSON)) for item in obj]
- elif isinstance(obj, (pathlib.Path)):
- return str(obj)
- else:
- return obj
+def jsonify(obj: Any, safe: bool = True) -> Any:
+ """
+ Converts objects into json.dumps() compatible nested dictionaries.
+ Setting safe to True skips dictionary keys starting with a bang (!)
+ """
- @staticmethod
- def _unsafe_encode(obj :Any) -> Any:
- """
- Same as _encode() but it keeps dictionary keys starting with !
- """
- if isinstance(obj, dict):
- copy = {}
- for key, val in list(obj.items()):
- if isinstance(val, dict):
- # This, is a EXTREMELY ugly hack.. but it's the only quick way I can think of to trigger a encoding of sub-dictionaries.
- val = json.loads(json.dumps(val, cls=UNSAFE_JSON))
- else:
- val = JsonEncoder._unsafe_encode(val)
-
- copy[JsonEncoder._unsafe_encode(key)] = val
- return copy
- else:
- return JsonEncoder._encode(obj)
+ compatible_types = str, int, float, bool
+ if isinstance(obj, dict):
+ return {
+ key: jsonify(value, safe)
+ for key, value in obj.items()
+ if isinstance(key, compatible_types)
+ and not (isinstance(key, str) and key.startswith("!") and safe)
+ }
+ if isinstance(obj, Enum):
+ return obj.value
+ if hasattr(obj, 'json'):
+ # json() is a friendly name for json-helper, it should return
+ # a dictionary representation of the object so that it can be
+ # processed by the json library.
+ return jsonify(obj.json(), safe)
+ if isinstance(obj, (datetime, date)):
+ return obj.isoformat()
+ if isinstance(obj, (list, set, tuple)):
+ return [jsonify(item, safe) for item in obj]
+ if isinstance(obj, pathlib.Path):
+ return str(obj)
+ if hasattr(obj, "__dict__"):
+ return vars(obj)
+
+ return obj
class JSON(json.JSONEncoder, json.JSONDecoder):
"""
A safe JSON encoder that will omit private information in dicts (starting with !)
"""
- def _encode(self, obj :Any) -> Any:
- return JsonEncoder._encode(obj)
- def encode(self, obj :Any) -> Any:
- return super(JSON, self).encode(self._encode(obj))
+ def encode(self, obj: Any) -> str:
+ return super().encode(jsonify(obj))
+
class UNSAFE_JSON(json.JSONEncoder, json.JSONDecoder):
"""
UNSAFE_JSON will call/encode and keep private information in dicts (starting with !)
"""
- def _encode(self, obj :Any) -> Any:
- return JsonEncoder._unsafe_encode(obj)
- def encode(self, obj :Any) -> Any:
- return super(UNSAFE_JSON, self).encode(self._encode(obj))
+ def encode(self, obj: Any) -> str:
+ return super().encode(jsonify(obj, safe=False))
+
class SysCommandWorker:
- def __init__(self,
+ def __init__(
+ self,
cmd :Union[str, List[str]],
callbacks :Optional[Dict[str, Any]] = None,
peek_output :Optional[bool] = False,
- peak_output :Optional[bool] = False,
environment_vars :Optional[Dict[str, Any]] = None,
logfile :Optional[None] = None,
working_directory :Optional[str] = './',
- remove_vt100_escape_codes_from_lines :bool = True):
-
- if peak_output:
- log("SysCommandWorker()'s peak_output is deprecated, use peek_output instead.", level=logging.WARNING, fg='red')
-
- if not callbacks:
- callbacks = {}
- if not environment_vars:
- environment_vars = {}
+ remove_vt100_escape_codes_from_lines :bool = True
+ ):
+ callbacks = callbacks or {}
+ environment_vars = environment_vars or {}
- if type(cmd) is str:
+ if isinstance(cmd, str):
cmd = shlex.split(cmd)
- cmd = list(cmd) # This is to please mypy
- if cmd[0][0] != '/' and cmd[0][:2] != './':
- # "which" doesn't work as it's a builtin to bash.
- # It used to work, but for whatever reason it doesn't anymore.
- # We there for fall back on manual lookup in os.PATH
- cmd[0] = locate_binary(cmd[0])
+ if cmd:
+ if cmd[0][0] != '/' and cmd[0][:2] != './': # pathlib.Path does not work well
+ cmd[0] = locate_binary(cmd[0])
self.cmd = cmd
self.callbacks = callbacks
self.peek_output = peek_output
- if not self.peek_output and peak_output:
- self.peek_output = peak_output
# define the standard locale for command outputs. For now the C ascii one. Can be overridden
self.environment_vars = {**storage.get('CMD_LOCALE',{}),**environment_vars}
self.logfile = logfile
@@ -237,27 +141,36 @@ class SysCommandWorker:
Contains will also move the current buffert position forward.
This is to avoid re-checking the same data when looking for output.
"""
- assert type(key) == bytes
+ assert isinstance(key, bytes)
- if (contains := key in self._trace_log[self._trace_log_pos:]):
- self._trace_log_pos += self._trace_log[self._trace_log_pos:].find(key) + len(key)
+ index = self._trace_log.find(key, self._trace_log_pos)
+ if index >= 0:
+ self._trace_log_pos += index + len(key)
+ return True
- return contains
+ return False
def __iter__(self, *args :str, **kwargs :Dict[str, Any]) -> Iterator[bytes]:
- for line in self._trace_log[self._trace_log_pos:self._trace_log.rfind(b'\n')].split(b'\n'):
- if line:
- if self.remove_vt100_escape_codes_from_lines:
- line = clear_vt100_escape_codes(line)
+ last_line = self._trace_log.rfind(b'\n')
+ lines = filter(None, self._trace_log[self._trace_log_pos:last_line].splitlines())
+ for line in lines:
+ if self.remove_vt100_escape_codes_from_lines:
+ line = clear_vt100_escape_codes(line) # type: ignore
- yield line + b'\n'
+ yield line + b'\n'
- self._trace_log_pos = self._trace_log.rfind(b'\n')
+ self._trace_log_pos = last_line
def __repr__(self) -> str:
self.make_sure_we_are_executing()
return str(self._trace_log)
+ def __str__(self) -> str:
+ try:
+ return self._trace_log.decode('utf-8')
+ except UnicodeDecodeError:
+ return str(self._trace_log)
+
def __enter__(self) -> 'SysCommandWorker':
return self
@@ -278,10 +191,14 @@ class SysCommandWorker:
sys.stdout.flush()
if len(args) >= 2 and args[1]:
- log(args[1], level=logging.DEBUG, fg='red')
+ debug(args[1])
if self.exit_code != 0:
- raise SysCallError(f"{self.cmd} exited with abnormal exit code [{self.exit_code}]: {self._trace_log[-500:]}", self.exit_code, worker=self)
+ raise SysCallError(
+ f"{self.cmd} exited with abnormal exit code [{self.exit_code}]: {str(self)[-500:]}",
+ self.exit_code,
+ worker=self
+ )
def is_alive(self) -> bool:
self.poll()
@@ -292,12 +209,13 @@ class SysCommandWorker:
return False
def write(self, data: bytes, line_ending :bool = True) -> int:
- assert type(data) == bytes # TODO: Maybe we can support str as well and encode it
+ assert isinstance(data, bytes) # TODO: Maybe we can support str as well and encode it
self.make_sure_we_are_executing()
if self.child_fd:
return os.write(self.child_fd, data + (b'\n' if line_ending else b''))
+ os.fsync(self.child_fd)
return 0
@@ -317,7 +235,7 @@ class SysCommandWorker:
def peak(self, output: Union[str, bytes]) -> bool:
if self.peek_output:
- if type(output) == bytes:
+ if isinstance(output, bytes):
try:
output = output.decode('UTF-8')
except UnicodeDecodeError:
@@ -330,7 +248,7 @@ class SysCommandWorker:
change_perm = True
with peak_logfile.open("a") as peek_output_log:
- peek_output_log.write(output)
+ peek_output_log.write(str(output))
if change_perm:
os.chmod(str(peak_logfile), stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP)
@@ -355,7 +273,7 @@ class SysCommandWorker:
self.ended = time.time()
break
- if self.ended or (got_output is False and pid_exists(self.pid) is False):
+ if self.ended or (not got_output and not _pid_exists(self.pid)):
self.ended = time.time()
try:
wait_status = os.waitpid(self.pid, 0)[1]
@@ -394,22 +312,20 @@ class SysCommandWorker:
if change_perm:
os.chmod(str(history_logfile), stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP)
- except PermissionError:
- pass
- # If history_logfile does not exist, ignore the error
- except FileNotFoundError:
+ except (PermissionError, FileNotFoundError):
+ # If history_logfile does not exist, ignore the error
pass
except Exception as e:
exception_type = type(e).__name__
- log(f"Unexpected {exception_type} occurred in {self.cmd}: {e}", level=logging.ERROR)
+ error(f"Unexpected {exception_type} occurred in {self.cmd}: {e}")
raise e
os.execve(self.cmd[0], list(self.cmd), {**os.environ, **self.environment_vars})
if storage['arguments'].get('debug'):
- log(f"Executing: {self.cmd}", level=logging.DEBUG)
+ debug(f"Executing: {self.cmd}")
except FileNotFoundError:
- log(f"{self.cmd[0]} does not exist.", level=logging.ERROR, fg="red")
+ error(f"{self.cmd[0]} does not exist.")
self.exit_code = 1
return False
else:
@@ -428,29 +344,19 @@ class SysCommandWorker:
class SysCommand:
def __init__(self,
cmd :Union[str, List[str]],
- callbacks :Optional[Dict[str, Callable[[Any], Any]]] = None,
+ callbacks :Dict[str, Callable[[Any], Any]] = {},
start_callback :Optional[Callable[[Any], Any]] = None,
peek_output :Optional[bool] = False,
- peak_output :Optional[bool] = False,
environment_vars :Optional[Dict[str, Any]] = None,
working_directory :Optional[str] = './',
remove_vt100_escape_codes_from_lines :bool = True):
- if peak_output:
- log("SysCommandWorker()'s peak_output is deprecated, use peek_output instead.", level=logging.WARNING, fg='red')
-
- _callbacks = {}
- if callbacks:
- for hook, func in callbacks.items():
- _callbacks[hook] = func
+ self._callbacks = callbacks.copy()
if start_callback:
- _callbacks['on_start'] = start_callback
+ self._callbacks['on_start'] = start_callback
self.cmd = cmd
- self._callbacks = _callbacks
self.peek_output = peek_output
- if not self.peek_output and peak_output:
- self.peek_output = peak_output
self.environment_vars = environment_vars
self.working_directory = working_directory
self.remove_vt100_escape_codes_from_lines = remove_vt100_escape_codes_from_lines
@@ -466,7 +372,7 @@ class SysCommand:
# TODO: https://stackoverflow.com/questions/28157929/how-to-safely-handle-an-exception-inside-a-context-manager
if len(args) >= 2 and args[1]:
- log(args[1], level=logging.ERROR, fg='red')
+ error(args[1])
def __iter__(self, *args :List[Any], **kwargs :Dict[str, Any]) -> Iterator[bytes]:
if self.session:
@@ -477,17 +383,15 @@ class SysCommand:
if not self.session:
raise KeyError(f"SysCommand() does not have an active session.")
elif type(key) is slice:
- start = key.start if key.start else 0
- end = key.stop if key.stop else len(self.session._trace_log)
+ start = key.start or 0
+ end = key.stop or len(self.session._trace_log)
return self.session._trace_log[start:end]
else:
raise ValueError("SysCommand() doesn't have key & value pairs, only slices, SysCommand('ls')[:10] as an example.")
def __repr__(self, *args :List[Any], **kwargs :Dict[str, Any]) -> str:
- if self.session:
- return self.session._trace_log.decode('UTF-8', errors='backslashreplace')
- return ''
+ return self.decode('UTF-8', errors='backslashreplace') or ''
def __json__(self) -> Dict[str, Union[str, bool, List[str], Dict[str, Any], Optional[bool], Optional[Dict[str, Any]]]]:
return {
@@ -495,7 +399,7 @@ class SysCommand:
'callbacks': self._callbacks,
'peak': self.peek_output,
'environment_vars': self.environment_vars,
- 'session': True if self.session else False
+ 'session': self.session is not None
}
def create_session(self) -> bool:
@@ -505,7 +409,7 @@ class SysCommand:
clears any printed output if ``.peek_output=True``.
"""
if self.session:
- return self.session
+ return True
with SysCommandWorker(
self.cmd,
@@ -515,10 +419,9 @@ class SysCommand:
remove_vt100_escape_codes_from_lines=self.remove_vt100_escape_codes_from_lines,
working_directory=self.working_directory) as session:
- if not self.session:
- self.session = session
+ self.session = session
- while self.session.ended is None:
+ while not self.session.ended:
self.session.poll()
if self.peek_output:
@@ -527,10 +430,21 @@ class SysCommand:
return True
- def decode(self, fmt :str = 'UTF-8') -> Optional[str]:
- if self.session:
- return self.session._trace_log.decode(fmt)
- return None
+ def decode(self, encoding: str = 'utf-8', errors='backslashreplace', strip: bool = True) -> str:
+ if not self.session:
+ raise ValueError('No session available to decode')
+
+ val = self.session._trace_log.decode(encoding, errors=errors)
+
+ if strip:
+ return val.strip()
+ return val
+
+ def output(self) -> bytes:
+ if not self.session:
+ raise ValueError('No session available')
+
+ return self.session._trace_log.replace(b'\r\n', b'\n')
@property
def exit_code(self) -> Optional[int]:
@@ -546,22 +460,7 @@ class SysCommand:
return None
-def prerequisite_check() -> bool:
- """
- This function is used as a safety check before
- continuing with an installation.
-
- Could be anything from checking that /boot is big enough
- to check if nvidia hardware exists when nvidia driver was chosen.
- """
-
- return True
-
-def reboot():
- SysCommand("/usr/bin/reboot")
-
-
-def pid_exists(pid: int) -> bool:
+def _pid_exists(pid: int) -> bool:
try:
return any(subprocess.check_output(['/usr/bin/ps', '--no-headers', '-o', 'pid', '-p', str(pid)]).strip())
except subprocess.CalledProcessError:
@@ -570,56 +469,57 @@ def pid_exists(pid: int) -> bool:
def run_custom_user_commands(commands :List[str], installation :Installer) -> None:
for index, command in enumerate(commands):
- log(f'Executing custom command "{command}" ...', level=logging.INFO)
+ script_path = f"/var/tmp/user-command.{index}.sh"
+ chroot_path = f"{installation.target}/{script_path}"
- with open(f"{installation.target}/var/tmp/user-command.{index}.sh", "w") as temp_script:
- temp_script.write(command)
+ info(f'Executing custom command "{command}" ...')
+ with open(chroot_path, "w") as user_script:
+ user_script.write(command)
- execution_output = SysCommand(f"arch-chroot {installation.target} bash /var/tmp/user-command.{index}.sh")
+ SysCommand(f"arch-chroot {installation.target} bash {script_path}")
+
+ os.unlink(chroot_path)
- log(execution_output)
- os.unlink(f"{installation.target}/var/tmp/user-command.{index}.sh")
def json_stream_to_structure(configuration_identifier : str, stream :str, target :dict) -> bool :
"""
- Function to load a stream (file (as name) or valid JSON string into an existing dictionary
- Returns true if it could be done
- Return false if operation could not be executed
+ Load a JSON encoded dictionary from a stream and merge it into an existing dictionary.
+ A stream can be a filepath, a URL or a raw JSON string.
+ Returns True if the operation succeeded, False otherwise.
+configuration_identifier is just a parameter to get meaningful, but not so long messages
"""
- parsed_url = urllib.parse.urlparse(stream)
+ raw: Optional[str] = None
+ # Try using the stream as a URL that should be grabbed
+ if urllib.parse.urlparse(stream).scheme:
+ try:
+ with urlopen(Request(stream, headers={'User-Agent': 'ArchInstall'})) as response:
+ raw = response.read()
+ except urllib.error.HTTPError as err:
+ error(f"Could not fetch JSON from {stream} as {configuration_identifier}: {err}")
+ return False
- if parsed_url.scheme: # The stream is in fact a URL that should be grabbed
+ # Try using the stream as a filepath that should be read
+ if raw is None and (path := pathlib.Path(stream)).exists():
try:
- with urllib.request.urlopen(urllib.request.Request(stream, headers={'User-Agent': 'ArchInstall'})) as response:
- target.update(json.loads(response.read()))
- except urllib.error.HTTPError as error:
- log(f"Could not load {configuration_identifier} via {parsed_url} due to: {error}", level=logging.ERROR, fg="red")
+ raw = path.read_text()
+ except Exception as err:
+ error(f"Could not read file {stream} as {configuration_identifier}: {err}")
return False
- else:
- if pathlib.Path(stream).exists():
- try:
- with pathlib.Path(stream).open() as fh:
- target.update(json.load(fh))
- except Exception as error:
- log(f"{configuration_identifier} = {stream} does not contain a valid JSON format: {error}", level=logging.ERROR, fg="red")
- return False
- else:
- # NOTE: This is a rudimentary check if what we're trying parse is a dict structure.
- # Which is the only structure we tolerate anyway.
- if stream.strip().startswith('{') and stream.strip().endswith('}'):
- try:
- target.update(json.loads(stream))
- except Exception as e:
- log(f" {configuration_identifier} Contains an invalid JSON format : {e}",level=logging.ERROR, fg="red")
- return False
- else:
- log(f" {configuration_identifier} is neither a file nor is a JSON string:",level=logging.ERROR, fg="red")
- return False
+ try:
+ # We use `or` to try the stream as raw JSON to be parsed
+ structure = json.loads(raw or stream)
+ except Exception as err:
+ error(f"{configuration_identifier} contains an invalid JSON format: {err}")
+ return False
+ if not isinstance(structure, dict):
+ error(f"{stream} passed as {configuration_identifier} is not a JSON encoded dictionary")
+ return False
+ target.update(structure)
return True
+
def secret(x :str):
""" return * with len equal to to the input string """
return '*' * len(x)
diff --git a/archinstall/lib/global_menu.py b/archinstall/lib/global_menu.py
new file mode 100644
index 00000000..1b5e779b
--- /dev/null
+++ b/archinstall/lib/global_menu.py
@@ -0,0 +1,472 @@
+from __future__ import annotations
+
+from typing import Any, List, Optional, Dict, TYPE_CHECKING
+
+from . import disk
+from .general import secret
+from .hardware import SysInfo
+from .locale.locale_menu import LocaleConfiguration, LocaleMenu
+from .menu import Selector, AbstractMenu
+from .mirrors import MirrorConfiguration, MirrorMenu
+from .models import NetworkConfiguration, NicType
+from .models.bootloader import Bootloader
+from .models.audio_configuration import Audio, AudioConfiguration
+from .models.users import User
+from .output import FormattedOutput
+from .profile.profile_menu import ProfileConfiguration
+from .configuration import save_config
+from .interactions import add_number_of_parallel_downloads
+from .interactions import ask_additional_packages_to_install
+from .interactions import ask_for_additional_users
+from .interactions import ask_for_audio_selection
+from .interactions import ask_for_bootloader
+from .interactions import ask_for_uki
+from .interactions import ask_for_swap
+from .interactions import ask_hostname
+from .interactions import ask_to_configure_network
+from .interactions import get_password, ask_for_a_timezone
+from .interactions import select_additional_repositories
+from .interactions import select_kernel
+from .utils.util import format_cols
+from .interactions import ask_ntp
+
+if TYPE_CHECKING:
+ _: Any
+
+
+class GlobalMenu(AbstractMenu):
+ def __init__(self, data_store: Dict[str, Any]):
+ super().__init__(data_store=data_store, auto_cursor=True, preview_size=0.3)
+
+ def setup_selection_menu_options(self):
+ # archinstall.Language will not use preset values
+ self._menu_options['archinstall-language'] = \
+ Selector(
+ _('Archinstall language'),
+ lambda x: self._select_archinstall_language(x),
+ display_func=lambda x: x.display_name,
+ default=self.translation_handler.get_language_by_abbr('en'))
+ self._menu_options['locale_config'] = \
+ Selector(
+ _('Locales'),
+ lambda preset: self._locale_selection(preset),
+ preview_func=self._prev_locale,
+ display_func=lambda x: self.defined_text if x else '')
+ self._menu_options['mirror_config'] = \
+ Selector(
+ _('Mirrors'),
+ lambda preset: self._mirror_configuration(preset),
+ display_func=lambda x: self.defined_text if x else '',
+ preview_func=self._prev_mirror_config
+ )
+ self._menu_options['disk_config'] = \
+ Selector(
+ _('Disk configuration'),
+ lambda preset: self._select_disk_config(preset),
+ preview_func=self._prev_disk_config,
+ display_func=lambda x: self.defined_text if x else '',
+ )
+ self._menu_options['disk_encryption'] = \
+ Selector(
+ _('Disk encryption'),
+ lambda preset: self._disk_encryption(preset),
+ preview_func=self._prev_disk_encryption,
+ display_func=lambda x: self._display_disk_encryption(x),
+ dependencies=['disk_config']
+ )
+ self._menu_options['swap'] = \
+ Selector(
+ _('Swap'),
+ lambda preset: ask_for_swap(preset),
+ default=True)
+ self._menu_options['bootloader'] = \
+ Selector(
+ _('Bootloader'),
+ lambda preset: ask_for_bootloader(preset),
+ display_func=lambda x: x.value,
+ default=Bootloader.get_default())
+ self._menu_options['uki'] = \
+ Selector(
+ _('Unified kernel images'),
+ lambda preset: ask_for_uki(preset),
+ default=False)
+ self._menu_options['hostname'] = \
+ Selector(
+ _('Hostname'),
+ lambda preset: ask_hostname(preset),
+ default='archlinux')
+ # root password won't have preset value
+ self._menu_options['!root-password'] = \
+ Selector(
+ _('Root password'),
+ lambda preset:self._set_root_password(),
+ display_func=lambda x: secret(x) if x else '')
+ self._menu_options['!users'] = \
+ Selector(
+ _('User account'),
+ lambda x: self._create_user_account(x),
+ default=[],
+ display_func=lambda x: f'{len(x)} {_("User(s)")}' if len(x) > 0 else '',
+ preview_func=self._prev_users)
+ self._menu_options['profile_config'] = \
+ Selector(
+ _('Profile'),
+ lambda preset: self._select_profile(preset),
+ display_func=lambda x: x.profile.name if x else '',
+ preview_func=self._prev_profile
+ )
+ self._menu_options['audio_config'] = \
+ Selector(
+ _('Audio'),
+ lambda preset: self._select_audio(preset),
+ display_func=lambda x: self._display_audio(x)
+ )
+ self._menu_options['parallel downloads'] = \
+ Selector(
+ _('Parallel Downloads'),
+ lambda preset: add_number_of_parallel_downloads(preset),
+ display_func=lambda x: x if x else '0',
+ default=0
+ )
+ self._menu_options['kernels'] = \
+ Selector(
+ _('Kernels'),
+ lambda preset: select_kernel(preset),
+ display_func=lambda x: ', '.join(x) if x else None,
+ default=['linux'])
+ self._menu_options['packages'] = \
+ Selector(
+ _('Additional packages'),
+ lambda preset: ask_additional_packages_to_install(preset),
+ display_func=lambda x: self.defined_text if x else '',
+ preview_func=self._prev_additional_pkgs,
+ default=[])
+ self._menu_options['additional-repositories'] = \
+ Selector(
+ _('Optional repositories'),
+ lambda preset: select_additional_repositories(preset),
+ display_func=lambda x: ', '.join(x) if x else None,
+ default=[])
+ self._menu_options['network_config'] = \
+ Selector(
+ _('Network configuration'),
+ lambda preset: ask_to_configure_network(preset),
+ display_func=lambda x: self._display_network_conf(x),
+ preview_func=self._prev_network_config,
+ default={})
+ self._menu_options['timezone'] = \
+ Selector(
+ _('Timezone'),
+ lambda preset: ask_for_a_timezone(preset),
+ default='UTC')
+ self._menu_options['ntp'] = \
+ Selector(
+ _('Automatic time sync (NTP)'),
+ lambda preset: ask_ntp(preset),
+ default=True)
+ self._menu_options['__separator__'] = \
+ Selector('')
+ self._menu_options['save_config'] = \
+ Selector(
+ _('Save configuration'),
+ lambda preset: save_config(self._data_store),
+ no_store=True)
+ self._menu_options['install'] = \
+ Selector(
+ self._install_text(),
+ exec_func=lambda n, v: self._is_config_valid(),
+ preview_func=self._prev_install_invalid_config,
+ no_store=True)
+
+ self._menu_options['abort'] = Selector(_('Abort'), exec_func=lambda n,v:exit(1))
+
+ def _missing_configs(self) -> List[str]:
+ def check(s) -> bool:
+ obj = self._menu_options.get(s)
+ if obj and obj.has_selection():
+ return True
+ return False
+
+ def has_superuser() -> bool:
+ sel = self._menu_options['!users']
+ if sel.current_selection:
+ return any([u.sudo for u in sel.current_selection])
+ return False
+
+ mandatory_fields = dict(filter(lambda x: x[1].is_mandatory(), self._menu_options.items()))
+ missing = set()
+
+ for key, selector in mandatory_fields.items():
+ if key in ['!root-password', '!users']:
+ if not check('!root-password') and not has_superuser():
+ missing.add(
+ str(_('Either root-password or at least 1 user with sudo privileges must be specified'))
+ )
+ elif key == 'disk_config':
+ if not check('disk_config'):
+ missing.add(self._menu_options['disk_config'].description)
+
+ return list(missing)
+
+ def _is_config_valid(self) -> bool:
+ """
+ Checks the validity of the current configuration.
+ """
+ if len(self._missing_configs()) != 0:
+ return False
+ return self._validate_bootloader() is None
+
+ def _update_uki_display(self, name: Optional[str] = None):
+ if bootloader := self._menu_options['bootloader'].current_selection:
+ if not SysInfo.has_uefi() or not bootloader.has_uki_support():
+ self._menu_options['uki'].set_current_selection(False)
+ self._menu_options['uki'].set_enabled(False)
+ elif name and name == 'bootloader':
+ self._menu_options['uki'].set_enabled(True)
+
+ def _update_install_text(self, name: Optional[str] = None, value: Any = None):
+ text = self._install_text()
+ self._menu_options['install'].update_description(text)
+
+ def post_callback(self, name: Optional[str] = None, value: Any = None):
+ self._update_uki_display(name)
+ self._update_install_text(name, value)
+
+ def _install_text(self):
+ missing = len(self._missing_configs())
+ if missing > 0:
+ return _('Install ({} config(s) missing)').format(missing)
+ return _('Install')
+
+ def _display_network_conf(self, config: Optional[NetworkConfiguration]) -> str:
+ if not config:
+ return str(_('Not configured, unavailable unless setup manually'))
+
+ return config.type.display_msg()
+
+ def _disk_encryption(self, preset: Optional[disk.DiskEncryption]) -> Optional[disk.DiskEncryption]:
+ disk_config: Optional[disk.DiskLayoutConfiguration] = self._menu_options['disk_config'].current_selection
+
+ if not disk_config:
+ # this should not happen as the encryption menu has the disk_config as dependency
+ raise ValueError('No disk layout specified')
+
+ if not disk.DiskEncryption.validate_enc(disk_config):
+ return None
+
+ data_store: Dict[str, Any] = {}
+ disk_encryption = disk.DiskEncryptionMenu(disk_config, data_store, preset=preset).run()
+ return disk_encryption
+
+ def _locale_selection(self, preset: LocaleConfiguration) -> LocaleConfiguration:
+ data_store: Dict[str, Any] = {}
+ locale_config = LocaleMenu(data_store, preset).run()
+ return locale_config
+
+ def _prev_locale(self) -> Optional[str]:
+ selector = self._menu_options['locale_config']
+ if selector.has_selection():
+ config: LocaleConfiguration = selector.current_selection # type: ignore
+ output = '{}: {}\n'.format(str(_('Keyboard layout')), config.kb_layout)
+ output += '{}: {}\n'.format(str(_('Locale language')), config.sys_lang)
+ output += '{}: {}'.format(str(_('Locale encoding')), config.sys_enc)
+ return output
+ return None
+
+ def _prev_network_config(self) -> Optional[str]:
+ selector: Optional[NetworkConfiguration] = self._menu_options['network_config'].current_selection
+ if selector:
+ if selector.type == NicType.MANUAL:
+ output = FormattedOutput.as_table(selector.nics)
+ return output
+ return None
+
+ def _prev_additional_pkgs(self):
+ selector = self._menu_options['packages']
+ if selector.current_selection:
+ packages: List[str] = selector.current_selection
+ return format_cols(packages, None)
+ return None
+
+ def _prev_disk_config(self) -> Optional[str]:
+ selector = self._menu_options['disk_config']
+ disk_layout_conf: Optional[disk.DiskLayoutConfiguration] = selector.current_selection
+
+ output = ''
+ if disk_layout_conf:
+ output += str(_('Configuration type: {}')).format(disk_layout_conf.config_type.display_msg())
+
+ if disk_layout_conf.lvm_config:
+ output += '\n{}: {}'.format(str(_('LVM configuration type')), disk_layout_conf.lvm_config.config_type.display_msg())
+
+ if output:
+ return output
+
+ return None
+
+ def _display_disk_config(self, current_value: Optional[disk.DiskLayoutConfiguration] = None) -> str:
+ if current_value:
+ return current_value.config_type.display_msg()
+ return ''
+
+ def _prev_disk_encryption(self) -> Optional[str]:
+ disk_config: Optional[disk.DiskLayoutConfiguration] = self._menu_options['disk_config'].current_selection
+
+ if disk_config and not disk.DiskEncryption.validate_enc(disk_config):
+ return str(_('LVM disk encryption with more than 2 partitions is currently not supported'))
+
+ encryption: Optional[disk.DiskEncryption] = self._menu_options['disk_encryption'].current_selection
+
+ if encryption:
+ enc_type = disk.EncryptionType.type_to_text(encryption.encryption_type)
+ output = str(_('Encryption type')) + f': {enc_type}\n'
+ output += str(_('Password')) + f': {secret(encryption.encryption_password)}\n'
+
+ if encryption.partitions:
+ output += 'Partitions: {} selected'.format(len(encryption.partitions)) + '\n'
+ elif encryption.lvm_volumes:
+ output += 'LVM volumes: {} selected'.format(len(encryption.lvm_volumes)) + '\n'
+
+ if encryption.hsm_device:
+ output += f'HSM: {encryption.hsm_device.manufacturer}'
+
+ return output
+
+ return None
+
+ def _display_disk_encryption(self, current_value: Optional[disk.DiskEncryption]) -> str:
+ if current_value:
+ return disk.EncryptionType.type_to_text(current_value.encryption_type)
+ return ''
+
+ def _validate_bootloader(self) -> Optional[str]:
+ """
+ Checks the selected bootloader is valid for the selected filesystem
+ type of the boot partition.
+
+ Returns [`None`] if the bootloader is valid, otherwise returns a
+ string with the error message.
+
+ XXX: The caller is responsible for wrapping the string with the translation
+ shim if necessary.
+ """
+ bootloader = self._menu_options['bootloader'].current_selection
+ boot_partition: Optional[disk.PartitionModification] = None
+
+ if disk_config := self._menu_options['disk_config'].current_selection:
+ for layout in disk_config.device_modifications:
+ if boot_partition := layout.get_boot_partition():
+ break
+ else:
+ return "No disk layout selected"
+
+ if boot_partition is None:
+ return "Boot partition not found"
+
+ if bootloader == Bootloader.Limine:
+ if boot_partition.fs_type != disk.FilesystemType.Fat32:
+ return "Limine does not support booting from filesystems other than FAT32"
+
+ return None
+
+ def _prev_install_invalid_config(self) -> Optional[str]:
+ if missing := self._missing_configs():
+ text = str(_('Missing configurations:\n'))
+ for m in missing:
+ text += f'- {m}\n'
+ return text[:-1] # remove last new line
+
+ if error := self._validate_bootloader():
+ return str(_(f"Invalid configuration: {error}"))
+
+ return None
+
+ def _prev_users(self) -> Optional[str]:
+ selector = self._menu_options['!users']
+ users: Optional[List[User]] = selector.current_selection
+
+ if users:
+ return FormattedOutput.as_table(users)
+ return None
+
+ def _prev_profile(self) -> Optional[str]:
+ selector = self._menu_options['profile_config']
+ profile_config: Optional[ProfileConfiguration] = selector.current_selection
+
+ if profile_config and profile_config.profile:
+ output = str(_('Profiles')) + ': '
+ if profile_names := profile_config.profile.current_selection_names():
+ output += ', '.join(profile_names) + '\n'
+ else:
+ output += profile_config.profile.name + '\n'
+
+ if profile_config.gfx_driver:
+ output += str(_('Graphics driver')) + ': ' + profile_config.gfx_driver.value + '\n'
+
+ if profile_config.greeter:
+ output += str(_('Greeter')) + ': ' + profile_config.greeter.value + '\n'
+
+ return output
+
+ return None
+
+ def _set_root_password(self) -> Optional[str]:
+ prompt = str(_('Enter root password (leave blank to disable root): '))
+ password = get_password(prompt=prompt)
+ return password
+
+ def _select_disk_config(
+ self,
+ preset: Optional[disk.DiskLayoutConfiguration] = None
+ ) -> Optional[disk.DiskLayoutConfiguration]:
+ data_store: Dict[str, Any] = {}
+ disk_config = disk.DiskLayoutConfigurationMenu(preset, data_store).run()
+
+ if disk_config != preset:
+ self._menu_options['disk_encryption'].set_current_selection(None)
+
+ return disk_config
+
+ def _select_profile(self, current_profile: Optional[ProfileConfiguration]):
+ from .profile.profile_menu import ProfileMenu
+ store: Dict[str, Any] = {}
+ profile_config = ProfileMenu(store, preset=current_profile).run()
+ return profile_config
+
+ def _select_audio(
+ self,
+ current: Optional[AudioConfiguration] = None
+ ) -> Optional[AudioConfiguration]:
+ selection = ask_for_audio_selection(current)
+ return selection
+
+ def _display_audio(self, current: Optional[AudioConfiguration]) -> str:
+ if not current:
+ return Audio.no_audio_text()
+ else:
+ return current.audio.name
+
+ def _create_user_account(self, defined_users: List[User]) -> List[User]:
+ users = ask_for_additional_users(defined_users=defined_users)
+ return users
+
+ def _mirror_configuration(self, preset: Optional[MirrorConfiguration] = None) -> Optional[MirrorConfiguration]:
+ data_store: Dict[str, Any] = {}
+ mirror_configuration = MirrorMenu(data_store, preset=preset).run()
+ return mirror_configuration
+
+ def _prev_mirror_config(self) -> Optional[str]:
+ selector = self._menu_options['mirror_config']
+
+ if selector.has_selection():
+ mirror_config: MirrorConfiguration = selector.current_selection # type: ignore
+ output = ''
+ if mirror_config.regions:
+ output += '{}: {}\n\n'.format(str(_('Mirror regions')), mirror_config.regions)
+ if mirror_config.custom_mirrors:
+ table = FormattedOutput.as_table(mirror_config.custom_mirrors)
+ output += '{}\n{}'.format(str(_('Custom mirrors')), table)
+
+ return output.strip()
+
+ return None
diff --git a/archinstall/lib/hardware.py b/archinstall/lib/hardware.py
index 8400d338..c8001c19 100644
--- a/archinstall/lib/hardware.py
+++ b/archinstall/lib/hardware.py
@@ -1,192 +1,318 @@
import os
-import logging
-from functools import partial
+from enum import Enum
+from functools import cached_property
from pathlib import Path
-from typing import Iterator, Optional, Union
+from typing import Optional, Dict, List, TYPE_CHECKING, Any
+from .exceptions import SysCallError
from .general import SysCommand
from .networking import list_interfaces, enrich_iface_types
-from .exceptions import SysCallError
-from .output import log
-
-__packages__ = [
- "mesa",
- "xf86-video-amdgpu",
- "xf86-video-ati",
- "xf86-video-nouveau",
- "xf86-video-vmware",
- "xf86-video-intel",
- "xf86-video-qxl",
- "libva-mesa-driver",
- "libva-intel-driver",
- "vulkan-radeon",
- "vulkan-intel",
-]
-
-AVAILABLE_GFX_DRIVERS = {
- # Sub-dicts are layer-2 options to be selected
- # and lists are a list of packages to be installed
- "All open-source (default)": [
- "mesa",
- "xf86-video-amdgpu",
- "xf86-video-ati",
- "xf86-video-nouveau",
- "xf86-video-vmware",
- "xf86-video-intel",
- "xf86-video-qxl",
- "libva-mesa-driver",
- "libva-intel-driver",
- "vulkan-radeon",
- "vulkan-intel",
- ],
- "AMD / ATI (open-source)": [
- "mesa",
- "xf86-video-amdgpu",
- "xf86-video-ati",
- "libva-mesa-driver",
- "vulkan-radeon",
- ],
- "Intel (open-source, modern)": [
- "mesa",
- "libva-intel-driver",
- "vulkan-intel",
- ],
- "Intel (open-source, old)": [
- "mesa",
- "xf86-video-intel"
- ],
- "Nvidia (open-source nouveau driver)": [
- "mesa",
- "xf86-video-nouveau",
- "libva-mesa-driver"
- ],
- "VMware / VirtualBox / QXL (open-source)": ["mesa", "xf86-video-vmware", "xf86-video-qxl"],
-}
-
-CPUINFO = Path("/proc/cpuinfo")
-MEMINFO = Path("/proc/meminfo")
-
-
-def cpuinfo() -> Iterator[dict[str, str]]:
- """Yields information about the CPUs of the system."""
- cpu = {}
-
- with CPUINFO.open() as file:
- for line in file:
- if not (line := line.strip()):
- yield cpu
- cpu = {}
- continue
-
- key, value = line.split(":", maxsplit=1)
- cpu[key.strip()] = value.strip()
-
-
-def meminfo(key: Optional[str] = None) -> Union[dict[str, int], Optional[int]]:
- """Returns a dict with memory info if called with no args
- or the value of the given key of said dict.
- """
- with MEMINFO.open() as file:
- mem_info = {
- (columns := line.strip().split())[0].rstrip(':'): int(columns[1])
- for line in file
- }
-
- if key is None:
- return mem_info
-
- return mem_info.get(key)
-
-
-def has_wifi() -> bool:
- return 'WIRELESS' in enrich_iface_types(list_interfaces().values()).values()
-
-
-def has_cpu_vendor(vendor_id: str) -> bool:
- return any(cpu.get("vendor_id") == vendor_id for cpu in cpuinfo())
-
-
-has_amd_cpu = partial(has_cpu_vendor, "AuthenticAMD")
-
-
-has_intel_cpu = partial(has_cpu_vendor, "GenuineIntel")
-
-
-def has_uefi() -> bool:
- return os.path.isdir('/sys/firmware/efi')
-
-
-def graphics_devices() -> dict:
- cards = {}
- for line in SysCommand("lspci"):
- if b' VGA ' in line or b' 3D ' in line:
- _, identifier = line.split(b': ', 1)
- cards[identifier.strip().decode('UTF-8')] = line
- return cards
-
-
-def has_nvidia_graphics() -> bool:
- return any('nvidia' in x.lower() for x in graphics_devices())
-
-
-def has_amd_graphics() -> bool:
- return any('amd' in x.lower() for x in graphics_devices())
-
-
-def has_intel_graphics() -> bool:
- return any('intel' in x.lower() for x in graphics_devices())
+from .output import debug
+from .utils.util import format_cols
+
+if TYPE_CHECKING:
+ _: Any
+
+
+class CpuVendor(Enum):
+ AuthenticAMD = 'amd'
+ GenuineIntel = 'intel'
+ _Unknown = 'unknown'
+
+ @classmethod
+ def get_vendor(cls, name: str) -> 'CpuVendor':
+ if vendor := getattr(cls, name, None):
+ return vendor
+ else:
+ debug(f"Unknown CPU vendor '{name}' detected.")
+ return cls._Unknown
+
+ def _has_microcode(self) -> bool:
+ match self:
+ case CpuVendor.AuthenticAMD | CpuVendor.GenuineIntel:
+ return True
+ case _:
+ return False
+
+ def get_ucode(self) -> Optional[Path]:
+ if self._has_microcode():
+ return Path(self.value + '-ucode.img')
+ return None
+
+
+class GfxPackage(Enum):
+ Dkms = 'dkms'
+ IntelMediaDriver = 'intel-media-driver'
+ LibvaIntelDriver = 'libva-intel-driver'
+ LibvaMesaDriver = 'libva-mesa-driver'
+ Mesa = "mesa"
+ NvidiaDkms = 'nvidia-dkms'
+ NvidiaOpen = 'nvidia-open'
+ NvidiaOpenDkms = 'nvidia-open-dkms'
+ VulkanIntel = 'vulkan-intel'
+ VulkanRadeon = 'vulkan-radeon'
+ Xf86VideoAmdgpu = "xf86-video-amdgpu"
+ Xf86VideoAti = "xf86-video-ati"
+ Xf86VideoNouveau = 'xf86-video-nouveau'
+ Xf86VideoVmware = 'xf86-video-vmware'
+ XorgServer = 'xorg-server'
+ XorgXinit = 'xorg-xinit'
+
+
+class GfxDriver(Enum):
+ AllOpenSource = 'All open-source'
+ AmdOpenSource = 'AMD / ATI (open-source)'
+ IntelOpenSource = 'Intel (open-source)'
+ NvidiaOpenKernel = 'Nvidia (open kernel module for newer GPUs, Turing+)'
+ NvidiaOpenSource = 'Nvidia (open-source nouveau driver)'
+ NvidiaProprietary = 'Nvidia (proprietary)'
+ VMOpenSource = 'VMware / VirtualBox (open-source)'
+
+ def is_nvidia(self) -> bool:
+ match self:
+ case GfxDriver.NvidiaProprietary | \
+ GfxDriver.NvidiaOpenSource | \
+ GfxDriver.NvidiaOpenKernel:
+ return True
+ case _:
+ return False
+
+ def packages_text(self) -> str:
+ text = str(_('Installed packages')) + ':\n'
+ pkg_names = [p.value for p in self.gfx_packages()]
+ text += format_cols(sorted(pkg_names))
+ return text
+
+ def gfx_packages(self) -> List[GfxPackage]:
+ packages = [GfxPackage.XorgServer, GfxPackage.XorgXinit]
+
+ match self:
+ case GfxDriver.AllOpenSource:
+ packages += [
+ GfxPackage.Mesa,
+ GfxPackage.Xf86VideoAmdgpu,
+ GfxPackage.Xf86VideoAti,
+ GfxPackage.Xf86VideoNouveau,
+ GfxPackage.Xf86VideoVmware,
+ GfxPackage.LibvaMesaDriver,
+ GfxPackage.LibvaIntelDriver,
+ GfxPackage.IntelMediaDriver,
+ GfxPackage.VulkanRadeon,
+ GfxPackage.VulkanIntel
+ ]
+ case GfxDriver.AmdOpenSource:
+ packages += [
+ GfxPackage.Mesa,
+ GfxPackage.Xf86VideoAmdgpu,
+ GfxPackage.Xf86VideoAti,
+ GfxPackage.LibvaMesaDriver,
+ GfxPackage.VulkanRadeon
+ ]
+ case GfxDriver.IntelOpenSource:
+ packages += [
+ GfxPackage.Mesa,
+ GfxPackage.LibvaIntelDriver,
+ GfxPackage.IntelMediaDriver,
+ GfxPackage.VulkanIntel
+ ]
+ case GfxDriver.NvidiaOpenKernel:
+ packages += [
+ GfxPackage.NvidiaOpen,
+ GfxPackage.Dkms,
+ GfxPackage.NvidiaOpenDkms
+ ]
+ case GfxDriver.NvidiaOpenSource:
+ packages += [
+ GfxPackage.Mesa,
+ GfxPackage.Xf86VideoNouveau,
+ GfxPackage.LibvaMesaDriver
+ ]
+ case GfxDriver.NvidiaProprietary:
+ packages += [
+ GfxPackage.NvidiaDkms,
+ GfxPackage.Dkms,
+ ]
+ case GfxDriver.VMOpenSource:
+ packages += [
+ GfxPackage.Mesa,
+ GfxPackage.Xf86VideoVmware
+ ]
+
+ return packages
+
+class _SysInfo:
+ def __init__(self):
+ pass
+
+ @cached_property
+ def cpu_info(self) -> Dict[str, str]:
+ """
+ Returns system cpu information
+ """
+ cpu_info_path = Path("/proc/cpuinfo")
+ cpu: Dict[str, str] = {}
+
+ with cpu_info_path.open() as file:
+ for line in file:
+ if line := line.strip():
+ key, value = line.split(":", maxsplit=1)
+ cpu[key.strip()] = value.strip()
+
+ return cpu
+
+ @cached_property
+ def mem_info(self) -> Dict[str, int]:
+ """
+ Returns system memory information
+ """
+ mem_info_path = Path("/proc/meminfo")
+ mem_info: Dict[str, int] = {}
+
+ with mem_info_path.open() as file:
+ for line in file:
+ key, value = line.strip().split(':')
+ num = value.split()[0]
+ mem_info[key] = int(num)
+ return mem_info
-def cpu_vendor() -> Optional[str]:
- for cpu in cpuinfo():
- return cpu.get("vendor_id")
-
- return None
-
-
-def cpu_model() -> Optional[str]:
- for cpu in cpuinfo():
- return cpu.get("model name")
-
- return None
-
-
-def sys_vendor() -> Optional[str]:
- with open(f"/sys/devices/virtual/dmi/id/sys_vendor") as vendor:
- return vendor.read().strip()
-
-
-def product_name() -> Optional[str]:
- with open(f"/sys/devices/virtual/dmi/id/product_name") as product:
- return product.read().strip()
-
-
-def mem_available() -> Optional[int]:
- return meminfo('MemAvailable')
-
-
-def mem_free() -> Optional[int]:
- return meminfo('MemFree')
-
-
-def mem_total() -> Optional[int]:
- return meminfo('MemTotal')
-
-
-def virtualization() -> Optional[str]:
- try:
- return str(SysCommand("systemd-detect-virt")).strip('\r\n')
- except SysCallError as error:
- log(f"Could not detect virtual system: {error}", level=logging.DEBUG)
-
- return None
-
-
-def is_vm() -> bool:
- try:
- return b"none" not in b"".join(SysCommand("systemd-detect-virt")).lower()
- except SysCallError as error:
- log(f"System is not running in a VM: {error}", level=logging.DEBUG)
- return None
-
-# TODO: Add more identifiers
+ def mem_info_by_key(self, key: str) -> int:
+ return self.mem_info[key]
+
+ @cached_property
+ def loaded_modules(self) -> List[str]:
+ """
+ Returns loaded kernel modules
+ """
+ modules_path = Path('/proc/modules')
+ modules: List[str] = []
+
+ with modules_path.open() as file:
+ for line in file:
+ module = line.split(maxsplit=1)[0]
+ modules.append(module)
+
+ return modules
+
+
+_sys_info = _SysInfo()
+
+
+class SysInfo:
+ @staticmethod
+ def has_wifi() -> bool:
+ ifaces = list(list_interfaces().values())
+ return 'WIRELESS' in enrich_iface_types(ifaces).values()
+
+ @staticmethod
+ def has_uefi() -> bool:
+ return os.path.isdir('/sys/firmware/efi')
+
+ @staticmethod
+ def _graphics_devices() -> Dict[str, str]:
+ cards: Dict[str, str] = {}
+ for line in SysCommand("lspci"):
+ if b' VGA ' in line or b' 3D ' in line:
+ _, identifier = line.split(b': ', 1)
+ cards[identifier.strip().decode('UTF-8')] = str(line)
+ return cards
+
+ @staticmethod
+ def has_nvidia_graphics() -> bool:
+ return any('nvidia' in x.lower() for x in SysInfo._graphics_devices())
+
+ @staticmethod
+ def has_amd_graphics() -> bool:
+ return any('amd' in x.lower() for x in SysInfo._graphics_devices())
+
+ @staticmethod
+ def has_intel_graphics() -> bool:
+ return any('intel' in x.lower() for x in SysInfo._graphics_devices())
+
+ @staticmethod
+ def cpu_vendor() -> Optional[CpuVendor]:
+ if vendor := _sys_info.cpu_info.get('vendor_id'):
+ return CpuVendor.get_vendor(vendor)
+ return None
+
+ @staticmethod
+ def cpu_model() -> Optional[str]:
+ return _sys_info.cpu_info.get('model name', None)
+
+ @staticmethod
+ def sys_vendor() -> str:
+ with open(f"/sys/devices/virtual/dmi/id/sys_vendor") as vendor:
+ return vendor.read().strip()
+
+ @staticmethod
+ def product_name() -> str:
+ with open(f"/sys/devices/virtual/dmi/id/product_name") as product:
+ return product.read().strip()
+
+ @staticmethod
+ def mem_available() -> int:
+ return _sys_info.mem_info_by_key('MemAvailable')
+
+ @staticmethod
+ def mem_free() -> int:
+ return _sys_info.mem_info_by_key('MemFree')
+
+ @staticmethod
+ def mem_total() -> int:
+ return _sys_info.mem_info_by_key('MemTotal')
+
+ @staticmethod
+ def virtualization() -> Optional[str]:
+ try:
+ return str(SysCommand("systemd-detect-virt")).strip('\r\n')
+ except SysCallError as err:
+ debug(f"Could not detect virtual system: {err}")
+
+ return None
+
+ @staticmethod
+ def is_vm() -> bool:
+ try:
+ result = SysCommand("systemd-detect-virt")
+ return b"none" not in b"".join(result).lower()
+ except SysCallError as err:
+ debug(f"System is not running in a VM: {err}")
+
+ return False
+
+ @staticmethod
+ def requires_sof_fw() -> bool:
+ return 'snd_sof' in _sys_info.loaded_modules
+
+ @staticmethod
+ def requires_alsa_fw() -> bool:
+ modules = (
+ 'snd_asihpi',
+ 'snd_cs46xx',
+ 'snd_darla20',
+ 'snd_darla24',
+ 'snd_echo3g',
+ 'snd_emu10k1',
+ 'snd_gina20',
+ 'snd_gina24',
+ 'snd_hda_codec_ca0132',
+ 'snd_hdsp',
+ 'snd_indigo',
+ 'snd_indigodj',
+ 'snd_indigodjx',
+ 'snd_indigoio',
+ 'snd_indigoiox',
+ 'snd_layla20',
+ 'snd_layla24',
+ 'snd_mia',
+ 'snd_mixart',
+ 'snd_mona',
+ 'snd_pcxhr',
+ 'snd_vx_lib'
+ )
+
+ for loaded_module in _sys_info.loaded_modules:
+ if loaded_module in modules:
+ return True
+
+ return False
diff --git a/archinstall/lib/hsm/__init__.py b/archinstall/lib/hsm/__init__.py
deleted file mode 100644
index a3f64019..00000000
--- a/archinstall/lib/hsm/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-from .fido import Fido2
diff --git a/archinstall/lib/installer.py b/archinstall/lib/installer.py
index f1c7b3db..8292a3be 100644
--- a/archinstall/lib/installer.py
+++ b/archinstall/lib/installer.py
@@ -1,323 +1,427 @@
-import time
-import logging
+import glob
import os
import re
-import shutil
import shlex
-import pathlib
+import shutil
import subprocess
-import glob
-from types import ModuleType
-from typing import Union, Dict, Any, List, Optional, Iterator, Mapping, TYPE_CHECKING
-from .disk import get_partitions_in_use, Partition
-from .general import SysCommand, generate_password
-from .hardware import has_uefi, is_vm, cpu_vendor
-from .locale_helpers import verify_keyboard_layout, verify_x11_keyboard_layout
-from .disk.helpers import findmnt
-from .mirrors import use_mirrors
-from .models.disk_encryption import DiskEncryption
-from .plugins import plugins
-from .storage import storage
-from .output import log
-from .profiles import Profile
-from .disk.partition import get_mount_fs_type
+import time
+from pathlib import Path
+from typing import Any, List, Optional, TYPE_CHECKING, Union, Dict, Callable
+
+from . import disk
from .exceptions import DiskError, ServiceException, RequirementError, HardwareIncompatibilityError, SysCallError
+from .general import SysCommand
+from .hardware import SysInfo
+from .locale import LocaleConfiguration
+from .locale import verify_keyboard_layout, verify_x11_keyboard_layout
+from .luks import Luks2
+from .mirrors import MirrorConfiguration
+from .models.bootloader import Bootloader
+from .models.network_configuration import Nic
from .models.users import User
-from .models.subvolume import Subvolume
-from .hsm import Fido2
+from .output import log, error, info, warn, debug
+from . import pacman
+from .pacman import Pacman
+from .plugins import plugins
+from .storage import storage
if TYPE_CHECKING:
_: Any
-
# Any package that the Installer() is responsible for (optional and the default ones)
__packages__ = ["base", "base-devel", "linux-firmware", "linux", "linux-lts", "linux-zen", "linux-hardened"]
# Additional packages that are installed if the user is running the Live ISO with accessibility tools enabled
__accessibility_packages__ = ["brltty", "espeakup", "alsa-utils"]
-from .pacman import run_pacman
-from .models.network_configuration import NetworkConfiguration
-
-
-class InstallationFile:
- def __init__(self, installation :'Installer', filename :str, owner :str, mode :str = "w"):
- self.installation = installation
- self.filename = filename
- self.owner = owner
- self.mode = mode
- self.fh = None
-
- def __enter__(self) -> 'InstallationFile':
- self.fh = open(self.filename, self.mode)
- return self
-
- def __exit__(self, *args :str) -> None:
- self.fh.close()
- self.installation.chown(self.owner, self.filename)
-
- def write(self, data: Union[str, bytes]) -> int:
- return self.fh.write(data)
-
- def read(self, *args) -> Union[str, bytes]:
- return self.fh.read(*args)
-
-# def poll(self, *args) -> bool:
-# return self.fh.poll(*args)
-
def accessibility_tools_in_use() -> bool:
return os.system('systemctl is-active --quiet espeakup.service') == 0
class Installer:
- """
- `Installer()` is the wrapper for most basic installation steps.
- It also wraps :py:func:`~archinstall.Installer.pacstrap` among other things.
-
- :param partition: Requires a partition as the first argument, this is
- so that the installer can mount to `mountpoint` and strap packages there.
- :type partition: class:`archinstall.Partition`
-
- :param boot_partition: There's two reasons for needing a boot partition argument,
- The first being so that `mkinitcpio` can place the `vmlinuz` kernel at the right place
- during the `pacstrap` or `linux` and the base packages for a minimal installation.
- The second being when :py:func:`~archinstall.Installer.add_bootloader` is called,
- A `boot_partition` must be known to the installer before this is called.
- :type boot_partition: class:`archinstall.Partition`
-
- :param profile: A profile to install, this is optional and can be called later manually.
- This just simplifies the process by not having to call :py:func:`~archinstall.Installer.install_profile` later on.
- :type profile: str, optional
-
- :param hostname: The given /etc/hostname for the machine.
- :type hostname: str, optional
-
- """
-
- def __init__(self, target :str, *, base_packages :Optional[List[str]] = None, kernels :Optional[List[str]] = None):
- if base_packages is None:
- base_packages = __packages__[:3]
- if kernels is None:
- self.kernels = ['linux']
- else:
- self.kernels = kernels
- self.target = target
+ def __init__(
+ self,
+ target: Path,
+ disk_config: disk.DiskLayoutConfiguration,
+ disk_encryption: Optional[disk.DiskEncryption] = None,
+ base_packages: List[str] = [],
+ kernels: Optional[List[str]] = None
+ ):
+ """
+ `Installer()` is the wrapper for most basic installation steps.
+ It also wraps :py:func:`~archinstall.Installer.pacstrap` among other things.
+ """
+ self._base_packages = base_packages or __packages__[:3]
+ self.kernels = kernels or ['linux']
+ self._disk_config = disk_config
+
+ self._disk_encryption = disk_encryption or disk.DiskEncryption(disk.EncryptionType.NoEncryption)
+ self.target: Path = target
+
self.init_time = time.strftime('%Y-%m-%d_%H-%M-%S')
self.milliseconds = int(str(time.time()).split('.')[1])
+ self.helper_flags: Dict[str, Any] = {'base': False, 'bootloader': None}
- self.helper_flags = {
- 'base': False,
- 'bootloader': False
- }
-
- self.base_packages = base_packages.split(' ') if type(base_packages) is str else base_packages
for kernel in self.kernels:
- self.base_packages.append(kernel)
+ self._base_packages.append(kernel)
# If using accessibility tools in the live environment, append those to the packages list
if accessibility_tools_in_use():
- self.base_packages.extend(__accessibility_packages__)
+ self._base_packages.extend(__accessibility_packages__)
- self.post_base_install = []
+ self.post_base_install: List[Callable] = []
# TODO: Figure out which one of these two we'll use.. But currently we're mixing them..
storage['session'] = self
storage['installation_session'] = self
- self.MODULES = []
- self.BINARIES = []
- self.FILES = []
+ self._modules: List[str] = []
+ self._binaries: List[str] = []
+ self._files: List[str] = []
+
# systemd, sd-vconsole and sd-encrypt will be replaced by udev, keymap and encrypt
# if HSM is not used to encrypt the root volume. Check mkinitcpio() function for that override.
- self.HOOKS = ["base", "systemd", "autodetect", "keyboard", "sd-vconsole", "modconf", "block", "filesystems", "fsck"]
- self.KERNEL_PARAMS = []
- self.FSTAB_ENTRIES = []
+ self._hooks: List[str] = [
+ "base", "systemd", "autodetect", "microcode", "keyboard",
+ "sd-vconsole", "modconf", "block", "filesystems", "fsck"
+ ]
+ self._kernel_params: List[str] = []
+ self._fstab_entries: List[str] = []
self._zram_enabled = False
+ self._disable_fstrim = False
- self._disk_encryption: DiskEncryption = storage['arguments'].get('disk_encryption')
-
- def log(self, *args :str, level :int = logging.DEBUG, **kwargs :str):
- """
- installer.log() wraps output.log() mainly to set a default log-level for this install session.
- Any manual override can be done per log() call.
- """
- log(*args, level=level, **kwargs)
+ self.pacman = Pacman(self.target, storage['arguments'].get('silent', False))
- def __enter__(self, *args :str, **kwargs :str) -> 'Installer':
+ def __enter__(self) -> 'Installer':
return self
- def __exit__(self, *args :str, **kwargs :str) -> None:
- # TODO: https://stackoverflow.com/questions/28157929/how-to-safely-handle-an-exception-inside-a-context-manager
-
- if len(args) >= 2 and args[1]:
- self.log(args[1], level=logging.ERROR, fg='red')
+ def __exit__(self, exc_type, exc_val, exc_tb):
+ if exc_type is not None:
+ error(exc_val)
self.sync_log_to_install_medium()
# We avoid printing /mnt/<log path> because that might confuse people if they note it down
# and then reboot, and a identical log file will be found in the ISO medium anyway.
- print(_("[!] A log file has been created here: {}").format(os.path.join(storage['LOG_PATH'], storage['LOG_FILE'])))
+ print(_("[!] A log file has been created here: {}").format(
+ os.path.join(storage['LOG_PATH'], storage['LOG_FILE'])))
print(_(" Please submit this issue (and file) to https://github.com/archlinux/archinstall/issues"))
- raise args[1]
+ raise exc_val
if not (missing_steps := self.post_install_check()):
- self.log('Installation completed without any errors. You may now reboot.', fg='green', level=logging.INFO)
+ log('Installation completed without any errors. You may now reboot.', fg='green')
self.sync_log_to_install_medium()
-
return True
else:
- self.log('Some required steps were not successfully installed/configured before leaving the installer:', fg='red', level=logging.WARNING)
+ warn('Some required steps were not successfully installed/configured before leaving the installer:')
+
for step in missing_steps:
- self.log(f' - {step}', fg='red', level=logging.WARNING)
+ warn(f' - {step}')
- self.log(f"Detailed error logs can be found at: {storage['LOG_PATH']}", level=logging.WARNING)
- self.log("Submit this zip file as an issue to https://github.com/archlinux/archinstall/issues", level=logging.WARNING)
+ warn(f"Detailed error logs can be found at: {storage['LOG_PATH']}")
+ warn("Submit this zip file as an issue to https://github.com/archlinux/archinstall/issues")
self.sync_log_to_install_medium()
return False
- @property
- def partitions(self) -> List[Partition]:
- return get_partitions_in_use(self.target).values()
+ def remove_mod(self, mod: str):
+ if mod in self._modules:
+ self._modules.remove(mod)
- def sync_log_to_install_medium(self) -> bool:
- # Copy over the install log (if there is one) to the install medium if
- # at least the base has been strapped in, otherwise we won't have a filesystem/structure to copy to.
- if self.helper_flags.get('base-strapped', False) is True:
- if filename := storage.get('LOG_FILE', None):
- absolute_logfile = os.path.join(storage.get('LOG_PATH', './'), filename)
+ def append_mod(self, mod: str):
+ if mod not in self._modules:
+ self._modules.append(mod)
- if not os.path.isdir(f"{self.target}/{os.path.dirname(absolute_logfile)}"):
- os.makedirs(f"{self.target}/{os.path.dirname(absolute_logfile)}")
+ def _verify_service_stop(self):
+ """
+ Certain services might be running that affects the system during installation.
+ One such service is "reflector.service" which updates /etc/pacman.d/mirrorlist
+ We need to wait for it before we continue since we opted in to use a custom mirror/region.
+ """
- shutil.copy2(absolute_logfile, f"{self.target}/{absolute_logfile}")
+ if not storage['arguments'].get('skip_ntp', False):
+ info(_('Waiting for time sync (timedatectl show) to complete.'))
- return True
+ _started_wait = time.time()
+ _notified = False
+ while True:
+ if not _notified and time.time() - _started_wait > 5:
+ _notified = True
+ warn(
+ _("Time synchronization not completing, while you wait - check the docs for workarounds: https://archinstall.readthedocs.io/"))
- def _create_keyfile(self,luks_handle , partition :dict, password :str):
- """ roiutine to create keyfiles, so it can be moved elsewhere
- """
- if self._disk_encryption and self._disk_encryption.generate_encryption_file(partition):
- if not (cryptkey_dir := pathlib.Path(f"{self.target}/etc/cryptsetup-keys.d")).exists():
- cryptkey_dir.mkdir(parents=True)
- # Once we store the key as ../xyzloop.key systemd-cryptsetup can automatically load this key
- # if we name the device to "xyzloop".
- if partition.get('mountpoint',None):
- encryption_key_path = f"/etc/cryptsetup-keys.d/{pathlib.Path(partition['mountpoint']).name}loop.key"
- else:
- encryption_key_path = f"/etc/cryptsetup-keys.d/{pathlib.Path(partition['device_instance'].path).name}.key"
- with open(f"{self.target}{encryption_key_path}", "w") as keyfile:
- keyfile.write(generate_password(length=512))
+ time_val = SysCommand('timedatectl show --property=NTPSynchronized --value').decode()
+ if time_val and time_val.strip() == 'yes':
+ break
+ time.sleep(1)
+ else:
+ info(
+ _('Skipping waiting for automatic time sync (this can cause issues if time is out of sync during installation)'))
+
+ info('Waiting for automatic mirror selection (reflector) to complete.')
+ while self._service_state('reflector') not in ('dead', 'failed', 'exited'):
+ time.sleep(1)
+
+ # info('Waiting for pacman-init.service to complete.')
+ # while self._service_state('pacman-init') not in ('dead', 'failed', 'exited'):
+ # time.sleep(1)
- os.chmod(f"{self.target}{encryption_key_path}", 0o400)
+ info(_('Waiting for Arch Linux keyring sync (archlinux-keyring-wkd-sync) to complete.'))
+ # Wait for the timer to kick in
+ while self._service_started('archlinux-keyring-wkd-sync.timer') is None:
+ time.sleep(1)
- luks_handle.add_key(pathlib.Path(f"{self.target}{encryption_key_path}"), password=password)
- luks_handle.crypttab(self, encryption_key_path, options=["luks", "key-slot=1"])
+ # Wait for the service to enter a finished state
+ while self._service_state('archlinux-keyring-wkd-sync.service') not in ('dead', 'failed', 'exited'):
+ time.sleep(1)
- def _has_root(self, partition :dict) -> bool:
+ def _verify_boot_part(self):
"""
- Determine if an encrypted partition contains root in it
+ Check that mounted /boot device has at minimum size for installation
+ The reason this check is here is to catch pre-mounted device configuration and potentially
+ configured one that has not gone through any previous checks (e.g. --silence mode)
+
+ NOTE: this function should be run AFTER running the mount_ordered_layout function
"""
- if partition.get("mountpoint") is None:
- if (sub_list := partition.get("btrfs",{}).get('subvolumes',{})):
- for mountpoint in [sub_list[subvolume].get("mountpoint") if isinstance(subvolume, dict) else subvolume.mountpoint for subvolume in sub_list]:
- if mountpoint == '/':
- return True
- return False
- else:
- return False
- elif partition.get("mountpoint") == '/':
- return True
- else:
- return False
+ boot_mount = self.target / 'boot'
+ lsblk_info = disk.get_lsblk_by_mountpoint(boot_mount)
+
+ if len(lsblk_info) > 0:
+ if lsblk_info[0].size < disk.Size(200, disk.Unit.MiB, disk.SectorSize.default()):
+ raise DiskError(
+ f'The boot partition mounted at {boot_mount} is not large enough to install a boot loader. '
+ f'Please resize it to at least 200MiB and re-run the installation.'
+ )
- def mount_ordered_layout(self, layouts: Dict[str, Any]) -> None:
- from .luks import luks2
- from .disk.btrfs import setup_subvolumes, mount_subvolume
-
- # set the partitions as a list not part of a tree (which we don't need anymore (i think)
- list_part = []
- list_luks_handles = []
- for blockdevice in layouts:
- list_part.extend(layouts[blockdevice]['partitions'])
-
- # TODO: Implement a proper mount-queue system that does not depend on return values.
- mount_queue = {}
-
- # we manage the encrypted partititons
- if self._disk_encryption:
- for partition in self._disk_encryption.all_partitions:
- # open the luks device and all associate stuff
- loopdev = f"{storage.get('ENC_IDENTIFIER', 'ai')}{pathlib.Path(partition['device_instance'].path).name}"
-
- # note that we DON'T auto_unmount (i.e. close the encrypted device so it can be used
- with (luks_handle := luks2(partition['device_instance'], loopdev, self._disk_encryption.encryption_password, auto_unmount=False)) as unlocked_device:
- if self._disk_encryption.generate_encryption_file(partition) and not self._has_root(partition):
- list_luks_handles.append([luks_handle, partition, self._disk_encryption.encryption_password])
- # this way all the requesrs will be to the dm_crypt device and not to the physical partition
- partition['device_instance'] = unlocked_device
-
- if self._has_root(partition) and self._disk_encryption.generate_encryption_file(partition) is False:
- if self._disk_encryption.hsm_device:
- Fido2.fido2_enroll(self._disk_encryption.hsm_device, partition['device_instance'], self._disk_encryption.encryption_password)
-
- btrfs_subvolumes = [entry for entry in list_part if entry.get('btrfs', {}).get('subvolumes', [])]
-
- for partition in btrfs_subvolumes:
- device_instance = partition['device_instance']
- mount_options = partition.get('filesystem', {}).get('mount_options', [])
- self.mount(device_instance, "/", options=','.join(mount_options))
- setup_subvolumes(installation=self, partition_dict=partition)
- device_instance.unmount()
-
- # We then handle any special cases, such as btrfs
- for partition in btrfs_subvolumes:
- subvolumes: List[Subvolume] = partition['btrfs']['subvolumes']
- for subvolume in sorted(subvolumes, key=lambda item: item.mountpoint):
- # We cache the mount call for later
- mount_queue[subvolume.mountpoint] = lambda sub_vol=subvolume, device=partition['device_instance']: mount_subvolume(
- installation=self,
- device=device,
- subvolume=sub_vol
- )
+ def sanity_check(self):
+ # self._verify_boot_part()
+ self._verify_service_stop()
+
+ def mount_ordered_layout(self):
+ debug('Mounting ordered layout')
+
+ luks_handlers: Dict[Any, Luks2] = {}
+
+ match self._disk_encryption.encryption_type:
+ case disk.EncryptionType.NoEncryption:
+ self._mount_lvm_layout()
+ case disk.EncryptionType.Luks:
+ luks_handlers = self._prepare_luks_partitions(self._disk_encryption.partitions)
+ case disk.EncryptionType.LvmOnLuks:
+ luks_handlers = self._prepare_luks_partitions(self._disk_encryption.partitions)
+ self._import_lvm()
+ self._mount_lvm_layout(luks_handlers)
+ case disk.EncryptionType.LuksOnLvm:
+ self._import_lvm()
+ luks_handlers = self._prepare_luks_lvm(self._disk_encryption.lvm_volumes)
+ self._mount_lvm_layout(luks_handlers)
+
+ # mount all regular partitions
+ self._mount_partition_layout(luks_handlers)
+
+ def _mount_partition_layout(self, luks_handlers: Dict[Any, Luks2]):
+ debug('Mounting partition layout')
+
+ # do not mount any PVs part of the LVM configuration
+ pvs = []
+ if self._disk_config.lvm_config:
+ pvs = self._disk_config.lvm_config.get_all_pvs()
+
+ for mod in self._disk_config.device_modifications:
+ not_pv_part_mods = list(filter(lambda x: x not in pvs, mod.partitions))
+
+ # partitions have to mounted in the right order on btrfs the mountpoint will
+ # be empty as the actual subvolumes are getting mounted instead so we'll use
+ # '/' just for sorting
+ sorted_part_mods = sorted(not_pv_part_mods, key=lambda x: x.mountpoint or Path('/'))
+
+ for part_mod in sorted_part_mods:
+ if luks_handler := luks_handlers.get(part_mod):
+ self._mount_luks_partition(part_mod, luks_handler)
+ else:
+ self._mount_partition(part_mod)
- # We mount ordinary partitions, and we sort them by the mountpoint
- for partition in sorted([entry for entry in list_part if entry.get('mountpoint', False)], key=lambda part: part['mountpoint']):
- mountpoint = partition['mountpoint']
- log(f"Mounting {mountpoint} to {self.target}{mountpoint} using {partition['device_instance']}", level=logging.INFO)
+ def _mount_lvm_layout(self, luks_handlers: Dict[Any, Luks2] = {}):
+ lvm_config = self._disk_config.lvm_config
- if partition.get('filesystem',{}).get('mount_options',[]):
- mount_options = ','.join(partition['filesystem']['mount_options'])
- mount_queue[mountpoint] = lambda instance=partition['device_instance'], target=f"{self.target}{mountpoint}", options=mount_options: instance.mount(target, options=options)
- else:
- mount_queue[mountpoint] = lambda instance=partition['device_instance'], target=f"{self.target}{mountpoint}": instance.mount(target)
+ if not lvm_config:
+ debug('No lvm config defined to be mounted')
+ return
- log(f"Using mount order: {list(sorted(mount_queue.items(), key=lambda item: item[0]))}", level=logging.DEBUG, fg="white")
+ debug('Mounting LVM layout')
- # We mount everything by sorting on the mountpoint itself.
- for mountpoint, frozen_func in sorted(mount_queue.items(), key=lambda item: item[0]):
- frozen_func()
+ for vg in lvm_config.vol_groups:
+ sorted_vol = sorted(vg.volumes, key=lambda x: x.mountpoint or Path('/'))
- time.sleep(1)
+ for vol in sorted_vol:
+ if luks_handler := luks_handlers.get(vol):
+ self._mount_luks_volume(vol, luks_handler)
+ else:
+ self._mount_lvm_vol(vol)
+
+ def _prepare_luks_partitions(
+ self,
+ partitions: List[disk.PartitionModification]
+ ) -> Dict[disk.PartitionModification, Luks2]:
+ return {
+ part_mod: disk.device_handler.unlock_luks2_dev(
+ part_mod.dev_path,
+ part_mod.mapper_name,
+ self._disk_encryption.encryption_password
+ )
+ for part_mod in partitions
+ if part_mod.mapper_name and part_mod.dev_path
+ }
- try:
- findmnt(pathlib.Path(f"{self.target}{mountpoint}"), traverse=False)
- except DiskError:
- raise DiskError(f"Target {self.target}{mountpoint} never got mounted properly (unable to get mount information using findmnt).")
+ def _import_lvm(self):
+ lvm_config = self._disk_config.lvm_config
+
+ if not lvm_config:
+ debug('No lvm config defined to be imported')
+ return
+
+ for vg in lvm_config.vol_groups:
+ disk.device_handler.lvm_import_vg(vg)
+
+ for vol in vg.volumes:
+ disk.device_handler.lvm_vol_change(vol, True)
+
+ def _prepare_luks_lvm(
+ self,
+ lvm_volumes: List[disk.LvmVolume]
+ ) -> Dict[disk.LvmVolume, Luks2]:
+ return {
+ vol: disk.device_handler.unlock_luks2_dev(
+ vol.dev_path,
+ vol.mapper_name,
+ self._disk_encryption.encryption_password
+ )
+ for vol in lvm_volumes
+ if vol.mapper_name and vol.dev_path
+ }
+
+ def _mount_partition(self, part_mod: disk.PartitionModification):
+ # it would be none if it's btrfs as the subvolumes will have the mountpoints defined
+ if part_mod.mountpoint and part_mod.dev_path:
+ target = self.target / part_mod.relative_mountpoint
+ disk.device_handler.mount(part_mod.dev_path, target, options=part_mod.mount_options)
+
+ if part_mod.fs_type == disk.FilesystemType.Btrfs and part_mod.dev_path:
+ self._mount_btrfs_subvol(
+ part_mod.dev_path,
+ part_mod.btrfs_subvols,
+ part_mod.mount_options
+ )
+
+ def _mount_lvm_vol(self, volume: disk.LvmVolume):
+ if volume.fs_type != disk.FilesystemType.Btrfs:
+ if volume.mountpoint and volume.dev_path:
+ target = self.target / volume.relative_mountpoint
+ disk.device_handler.mount(volume.dev_path, target, options=volume.mount_options)
+
+ if volume.fs_type == disk.FilesystemType.Btrfs and volume.dev_path:
+ self._mount_btrfs_subvol(volume.dev_path, volume.btrfs_subvols, volume.mount_options)
+
+ def _mount_luks_partition(self, part_mod: disk.PartitionModification, luks_handler: Luks2):
+ if part_mod.fs_type != disk.FilesystemType.Btrfs:
+ if part_mod.mountpoint and luks_handler.mapper_dev:
+ target = self.target / part_mod.relative_mountpoint
+ disk.device_handler.mount(luks_handler.mapper_dev, target, options=part_mod.mount_options)
+
+ if part_mod.fs_type == disk.FilesystemType.Btrfs and luks_handler.mapper_dev:
+ self._mount_btrfs_subvol(luks_handler.mapper_dev, part_mod.btrfs_subvols, part_mod.mount_options)
+
+ def _mount_luks_volume(self, volume: disk.LvmVolume, luks_handler: Luks2):
+ if volume.fs_type != disk.FilesystemType.Btrfs:
+ if volume.mountpoint and luks_handler.mapper_dev:
+ target = self.target / volume.relative_mountpoint
+ disk.device_handler.mount(luks_handler.mapper_dev, target, options=volume.mount_options)
+
+ if volume.fs_type == disk.FilesystemType.Btrfs and luks_handler.mapper_dev:
+ self._mount_btrfs_subvol(luks_handler.mapper_dev, volume.btrfs_subvols, volume.mount_options)
+
+ def _mount_btrfs_subvol(
+ self,
+ dev_path: Path,
+ subvolumes: List[disk.SubvolumeModification],
+ mount_options: List[str] = []
+ ):
+ for subvol in subvolumes:
+ mountpoint = self.target / subvol.relative_mountpoint
+ mount_options = mount_options + [f'subvol={subvol.name}']
+ disk.device_handler.mount(dev_path, mountpoint, options=mount_options)
+
+ def generate_key_files(self):
+ match self._disk_encryption.encryption_type:
+ case disk.EncryptionType.Luks:
+ self._generate_key_files_partitions()
+ case disk.EncryptionType.LuksOnLvm:
+ self._generate_key_file_lvm_volumes()
+ case disk.EncryptionType.LvmOnLuks:
+ # currently LvmOnLuks only supports a single
+ # partitioning layout (boot + partition)
+ # so we won't need any keyfile generation atm
+ pass
+
+ def _generate_key_files_partitions(self):
+ for part_mod in self._disk_encryption.partitions:
+ gen_enc_file = self._disk_encryption.should_generate_encryption_file(part_mod)
+
+ luks_handler = Luks2(
+ part_mod.safe_dev_path,
+ mapper_name=part_mod.mapper_name,
+ password=self._disk_encryption.encryption_password
+ )
+
+ if gen_enc_file and not part_mod.is_root():
+ debug(f'Creating key-file: {part_mod.dev_path}')
+ luks_handler.create_keyfile(self.target)
+
+ if part_mod.is_root() and not gen_enc_file:
+ if self._disk_encryption.hsm_device:
+ disk.Fido2.fido2_enroll(
+ self._disk_encryption.hsm_device,
+ part_mod.safe_dev_path,
+ self._disk_encryption.encryption_password
+ )
+
+ def _generate_key_file_lvm_volumes(self):
+ for vol in self._disk_encryption.lvm_volumes:
+ gen_enc_file = self._disk_encryption.should_generate_encryption_file(vol)
+
+ luks_handler = Luks2(
+ vol.safe_dev_path,
+ mapper_name=vol.mapper_name,
+ password=self._disk_encryption.encryption_password
+ )
+
+ if gen_enc_file and not vol.is_root():
+ info(f'Creating key-file: {vol.dev_path}')
+ luks_handler.create_keyfile(self.target)
- # once everything is mounted, we generate the key files in the correct place
- for handle in list_luks_handles:
- ppath = handle[1]['device_instance'].path
- log(f"creating key-file for {ppath}",level=logging.INFO)
- self._create_keyfile(handle[0],handle[1],handle[2])
+ if vol.is_root() and not gen_enc_file:
+ if self._disk_encryption.hsm_device:
+ disk.Fido2.fido2_enroll(
+ self._disk_encryption.hsm_device,
+ vol.safe_dev_path,
+ self._disk_encryption.encryption_password
+ )
+
+ def sync_log_to_install_medium(self) -> bool:
+ # Copy over the install log (if there is one) to the install medium if
+ # at least the base has been strapped in, otherwise we won't have a filesystem/structure to copy to.
+ if self.helper_flags.get('base-strapped', False) is True:
+ if filename := storage.get('LOG_FILE', None):
+ absolute_logfile = os.path.join(storage.get('LOG_PATH', './'), filename)
+
+ if not os.path.isdir(f"{self.target}/{os.path.dirname(absolute_logfile)}"):
+ os.makedirs(f"{self.target}/{os.path.dirname(absolute_logfile)}")
- def mount(self, partition :Partition, mountpoint :str, create_mountpoint :bool = True, options='') -> None:
- if create_mountpoint and not os.path.isdir(f'{self.target}{mountpoint}'):
- os.makedirs(f'{self.target}{mountpoint}')
+ shutil.copy2(absolute_logfile, f"{self.target}/{absolute_logfile}")
- partition.mount(f'{self.target}{mountpoint}', options=options)
+ return True
def add_swapfile(self, size='4G', enable_resume=True, file='/swapfile'):
if file[:1] != '/':
@@ -329,176 +433,137 @@ class Installer:
SysCommand(f'chmod 0600 {self.target}{file}')
SysCommand(f'mkswap {self.target}{file}')
- self.FSTAB_ENTRIES.append(f'{file} none swap defaults 0 0')
+ self._fstab_entries.append(f'{file} none swap defaults 0 0')
if enable_resume:
- resume_uuid = SysCommand(f'findmnt -no UUID -T {self.target}{file}').decode('UTF-8').strip()
- resume_offset = SysCommand(f'/usr/bin/filefrag -v {self.target}{file}').decode('UTF-8').split('0:', 1)[1].split(":", 1)[1].split("..", 1)[0].strip()
+ resume_uuid = SysCommand(f'findmnt -no UUID -T {self.target}{file}').decode()
+ resume_offset = SysCommand(
+ f'/usr/bin/filefrag -v {self.target}{file}'
+ ).decode().split('0:', 1)[1].split(":", 1)[1].split("..", 1)[0].strip()
- self.HOOKS.append('resume')
- self.KERNEL_PARAMS.append(f'resume=UUID={resume_uuid}')
- self.KERNEL_PARAMS.append(f'resume_offset={resume_offset}')
+ self._hooks.append('resume')
+ self._kernel_params.append(f'resume=UUID={resume_uuid}')
+ self._kernel_params.append(f'resume_offset={resume_offset}')
- def post_install_check(self, *args :str, **kwargs :str) -> List[str]:
+ def post_install_check(self, *args: str, **kwargs: str) -> List[str]:
return [step for step, flag in self.helper_flags.items() if flag is False]
- def enable_multilib_repository(self):
- # Set up a regular expression pattern of a commented line containing 'multilib' within []
- pattern = re.compile(r"^#\s*\[multilib\]$")
-
- # This is used to track if the previous line is a match, so we end up uncommenting the line after the block.
- matched = False
-
- # Read in the lines from the original file
- with open("/etc/pacman.conf", "r") as pacman_conf:
- lines = pacman_conf.readlines()
-
- # Open the file again in write mode, to replace the contents
- with open("/etc/pacman.conf", "w") as pacman_conf:
- for line in lines:
- if pattern.match(line):
- # If this is the [] block containing 'multilib', uncomment it and set the matched tracking boolean.
- pacman_conf.write(line.lstrip('#'))
- matched = True
- elif matched:
- # The previous line was a match for [.*multilib.*].
- # This means we're on a line that looks like '#Include = /etc/pacman.d/mirrorlist'
- pacman_conf.write(line.lstrip('#'))
- matched = False # Reset the state of matched to False.
- else:
- pacman_conf.write(line)
-
- def enable_testing_repositories(self, enable_multilib_testing=False):
- # Set up a regular expression pattern of a commented line containing 'testing' within []
- pattern = re.compile("^#\\[.*testing.*\\]$")
-
- # This is used to track if the previous line is a match, so we end up uncommenting the line after the block.
- matched = False
-
- # Read in the lines from the original file
- with open("/etc/pacman.conf", "r") as pacman_conf:
- lines = pacman_conf.readlines()
-
- # Open the file again in write mode, to replace the contents
- with open("/etc/pacman.conf", "w") as pacman_conf:
- for line in lines:
- if pattern.match(line) and (enable_multilib_testing or 'multilib' not in line):
- # If this is the [] block containing 'testing', uncomment it and set the matched tracking boolean.
- pacman_conf.write(line.lstrip('#'))
- matched = True
- elif matched:
- # The previous line was a match for [.*testing.*].
- # This means we're on a line that looks like '#Include = /etc/pacman.d/mirrorlist'
- pacman_conf.write(line.lstrip('#'))
- matched = False # Reset the state of matched to False.
- else:
- pacman_conf.write(line)
-
- def pacstrap(self, *packages :str, **kwargs :str) -> bool:
- if type(packages[0]) in (list, tuple):
- packages = packages[0]
-
- for plugin in plugins.values():
- if hasattr(plugin, 'on_pacstrap'):
- if (result := plugin.on_pacstrap(packages)):
- packages = result
+ def set_mirrors(self, mirror_config: MirrorConfiguration, on_target: bool = False):
+ """
+ Set the mirror configuration for the installation.
- self.log(f'Installing packages: {packages}', level=logging.INFO)
+ :param mirror_config: The mirror configuration to use.
+ :type mirror_config: MirrorConfiguration
- # TODO: We technically only need to run the -Syy once.
- try:
- run_pacman('-Syy', default_cmd='/usr/bin/pacman')
- except SysCallError as error:
- self.log(f'Could not sync a new package database: {error}', level=logging.ERROR, fg="red")
+ :on_target: Whether to set the mirrors on the target system or the live system.
+ :param on_target: bool
+ """
+ debug('Setting mirrors')
- if storage['arguments'].get('silent', False) is False:
- if input('Would you like to re-try this download? (Y/n): ').lower().strip() in ('', 'y'):
- return self.pacstrap(*packages, **kwargs)
+ for plugin in plugins.values():
+ if hasattr(plugin, 'on_mirrors'):
+ if result := plugin.on_mirrors(mirror_config):
+ mirror_config = result
- raise RequirementError(f'Could not sync mirrors: {error}', level=logging.ERROR, fg="red")
+ if on_target:
+ local_pacman_conf = Path(f'{self.target}/etc/pacman.conf')
+ local_mirrorlist_conf = Path(f'{self.target}/etc/pacman.d/mirrorlist')
+ else:
+ local_pacman_conf = Path('/etc/pacman.conf')
+ local_mirrorlist_conf = Path('/etc/pacman.d/mirrorlist')
- try:
- SysCommand(f'/usr/bin/pacstrap -C /etc/pacman.conf -K {self.target} {" ".join(packages)} --noconfirm', peek_output=True)
- return True
- except SysCallError as error:
- self.log(f'Could not strap in packages: {error}', level=logging.ERROR, fg="red")
+ mirrorlist_config = mirror_config.mirrorlist_config()
+ pacman_config = mirror_config.pacman_config()
- if storage['arguments'].get('silent', False) is False:
- if input('Would you like to re-try this download? (Y/n): ').lower().strip() in ('', 'y'):
- return self.pacstrap(*packages, **kwargs)
+ if pacman_config:
+ debug(f'Pacman config: {pacman_config}')
- raise RequirementError("Pacstrap failed. See /var/log/archinstall/install.log or above message for error details.")
+ with local_pacman_conf.open('a') as fp:
+ fp.write(pacman_config)
- def set_mirrors(self, mirrors :Mapping[str, Iterator[str]]) -> None:
- for plugin in plugins.values():
- if hasattr(plugin, 'on_mirrors'):
- if result := plugin.on_mirrors(mirrors):
- mirrors = result
+ if mirrorlist_config:
+ debug(f'Mirrorlist: {mirrorlist_config}')
- return use_mirrors(mirrors, destination=f'{self.target}/etc/pacman.d/mirrorlist')
+ with local_mirrorlist_conf.open('a') as fp:
+ fp.write(mirrorlist_config)
- def genfstab(self, flags :str = '-pU') -> bool:
- self.log(f"Updating {self.target}/etc/fstab", level=logging.INFO)
+ def genfstab(self, flags: str = '-pU'):
+ fstab_path = self.target / "etc" / "fstab"
+ info(f"Updating {fstab_path}")
try:
- fstab = SysCommand(f'/usr/bin/genfstab {flags} {self.target}')
- except SysCallError as error:
- raise RequirementError(f'Could not generate fstab, strapping in packages most likely failed (disk out of space?)\n Error: {error}')
+ gen_fstab = SysCommand(f'/usr/bin/genfstab {flags} {self.target}').output()
+ except SysCallError as err:
+ raise RequirementError(
+ f'Could not generate fstab, strapping in packages most likely failed (disk out of space?)\n Error: {err}')
- with open(f"{self.target}/etc/fstab", 'a') as fstab_fh:
- fstab_fh.write(fstab.decode())
+ with open(fstab_path, 'ab') as fp:
+ fp.write(gen_fstab)
- if not os.path.isfile(f'{self.target}/etc/fstab'):
- raise RequirementError(f'Could not generate fstab, strapping in packages most likely failed (disk out of space?)\n Error: {fstab}')
+ if not fstab_path.is_file():
+ raise RequirementError(f'Could not create fstab file')
for plugin in plugins.values():
if hasattr(plugin, 'on_genfstab'):
if plugin.on_genfstab(self) is True:
break
- with open(f"{self.target}/etc/fstab", 'a') as fstab_fh:
- for entry in self.FSTAB_ENTRIES:
- fstab_fh.write(f'{entry}\n')
+ with open(fstab_path, 'a') as fp:
+ for entry in self._fstab_entries:
+ fp.write(f'{entry}\n')
- return True
-
- def set_hostname(self, hostname: str, *args :str, **kwargs :str) -> None:
+ def set_hostname(self, hostname: str):
with open(f'{self.target}/etc/hostname', 'w') as fh:
fh.write(hostname + '\n')
- def set_locale(self, locale :str, encoding :str = 'UTF-8', *args :str, **kwargs :str) -> bool:
- if not len(locale):
- return True
-
+ def set_locale(self, locale_config: LocaleConfiguration) -> bool:
modifier = ''
+ lang = locale_config.sys_lang
+ encoding = locale_config.sys_enc
# This is a temporary patch to fix #1200
- if '.' in locale:
- locale, potential_encoding = locale.split('.', 1)
+ if '.' in locale_config.sys_lang:
+ lang, potential_encoding = locale_config.sys_lang.split('.', 1)
# Override encoding if encoding is set to the default parameter
# and the "found" encoding differs.
- if encoding == 'UTF-8' and encoding != potential_encoding:
+ if locale_config.sys_enc == 'UTF-8' and locale_config.sys_enc != potential_encoding:
encoding = potential_encoding
# Make sure we extract the modifier, that way we can put it in if needed.
- if '@' in locale:
- locale, modifier = locale.split('@', 1)
+ if '@' in locale_config.sys_lang:
+ lang, modifier = locale_config.sys_lang.split('@', 1)
modifier = f"@{modifier}"
# - End patch
- with open(f'{self.target}/etc/locale.gen', 'a') as fh:
- fh.write(f'{locale}.{encoding}{modifier} {encoding}\n')
- with open(f'{self.target}/etc/locale.conf', 'w') as fh:
- fh.write(f'LANG={locale}.{encoding}{modifier}\n')
+ locale_gen = self.target / 'etc/locale.gen'
+ locale_gen_lines = locale_gen.read_text().splitlines(True)
+
+ # A locale entry in /etc/locale.gen may or may not contain the encoding
+ # in the first column of the entry; check for both cases.
+ entry_re = re.compile(rf'#{lang}(\.{encoding})?{modifier} {encoding}')
+
+ for index, line in enumerate(locale_gen_lines):
+ if entry_re.match(line):
+ uncommented_line = line.removeprefix('#')
+ locale_gen_lines[index] = uncommented_line
+ locale_gen.write_text(''.join(locale_gen_lines))
+ lang_value = uncommented_line.split()[0]
+ break
+ else:
+ error(f"Invalid locale: language '{locale_config.sys_lang}', encoding '{locale_config.sys_enc}'")
+ return False
try:
SysCommand(f'/usr/bin/arch-chroot {self.target} locale-gen')
- return True
- except SysCallError:
+ except SysCallError as e:
+ error(f'Failed to run locale-gen on target: {e}')
return False
- def set_timezone(self, zone :str, *args :str, **kwargs :str) -> bool:
+ (self.target / 'etc/locale.conf').write_text(f'LANG={lang_value}\n')
+ return True
+
+ def set_timezone(self, zone: str) -> bool:
if not zone:
return True
if not len(zone):
@@ -509,62 +574,49 @@ class Installer:
if result := plugin.on_timezone(zone):
zone = result
- if (pathlib.Path("/usr") / "share" / "zoneinfo" / zone).exists():
- (pathlib.Path(self.target) / "etc" / "localtime").unlink(missing_ok=True)
+ if (Path("/usr") / "share" / "zoneinfo" / zone).exists():
+ (Path(self.target) / "etc" / "localtime").unlink(missing_ok=True)
SysCommand(f'/usr/bin/arch-chroot {self.target} ln -s /usr/share/zoneinfo/{zone} /etc/localtime')
return True
else:
- self.log(
- f"Time zone {zone} does not exist, continuing with system default.",
- level=logging.WARNING,
- fg='red'
- )
+ warn(f'Time zone {zone} does not exist, continuing with system default')
return False
- def activate_ntp(self) -> None:
- log(f"activate_ntp() is deprecated, use activate_time_syncronization()", fg="yellow", level=logging.INFO)
- self.activate_time_syncronization()
-
- def activate_time_syncronization(self) -> None:
- self.log('Activating systemd-timesyncd for time synchronization using Arch Linux and ntp.org NTP servers.', level=logging.INFO)
+ def activate_time_synchronization(self) -> None:
+ info('Activating systemd-timesyncd for time synchronization using Arch Linux and ntp.org NTP servers')
self.enable_service('systemd-timesyncd')
- with open(f"{self.target}/etc/systemd/timesyncd.conf", "w") as fh:
- fh.write("[Time]\n")
- fh.write("NTP=0.arch.pool.ntp.org 1.arch.pool.ntp.org 2.arch.pool.ntp.org 3.arch.pool.ntp.org\n")
- fh.write("FallbackNTP=0.pool.ntp.org 1.pool.ntp.org 0.fr.pool.ntp.org\n")
-
- from .systemd import Boot
- with Boot(self) as session:
- session.SysCommand(["timedatectl", "set-ntp", 'true'])
-
def enable_espeakup(self) -> None:
- self.log('Enabling espeakup.service for speech synthesis (accessibility).', level=logging.INFO)
+ info('Enabling espeakup.service for speech synthesis (accessibility)')
self.enable_service('espeakup')
def enable_periodic_trim(self) -> None:
- self.log("Enabling periodic TRIM")
+ info("Enabling periodic TRIM")
# fstrim is owned by util-linux, a dependency of both base and systemd.
self.enable_service("fstrim.timer")
- def enable_service(self, *services :str) -> None:
+ def enable_service(self, services: Union[str, List[str]]) -> None:
+ if isinstance(services, str):
+ services = [services]
+
for service in services:
- self.log(f'Enabling service {service}', level=logging.INFO)
+ info(f'Enabling service {service}')
+
try:
self.arch_chroot(f'systemctl enable {service}')
- except SysCallError as error:
- raise ServiceException(f"Unable to start service {service}: {error}")
+ except SysCallError as err:
+ raise ServiceException(f"Unable to start service {service}: {err}")
for plugin in plugins.values():
if hasattr(plugin, 'on_service'):
plugin.on_service(service)
- def run_command(self, cmd :str, *args :str, **kwargs :str) -> None:
+ def run_command(self, cmd: str, *args: str, **kwargs: str) -> SysCommand:
return SysCommand(f'/usr/bin/arch-chroot {self.target} {cmd}')
- def arch_chroot(self, cmd :str, run_as :Optional[str] = None):
+ def arch_chroot(self, cmd: str, run_as: Optional[str] = None) -> SysCommand:
if run_as:
cmd = f"su - {run_as} -c {shlex.quote(cmd)}"
@@ -573,38 +625,23 @@ class Installer:
def drop_to_shell(self) -> None:
subprocess.check_call(f"/usr/bin/arch-chroot {self.target}", shell=True)
- def configure_nic(self, network_config: NetworkConfiguration) -> None:
- from .systemd import Networkd
-
- if network_config.dhcp:
- conf = Networkd(Match={"Name": network_config.iface}, Network={"DHCP": "yes"})
- else:
- network = {"Address": network_config.ip}
- if network_config.gateway:
- network["Gateway"] = network_config.gateway
- if network_config.dns:
- dns = network_config.dns
- network["DNS"] = dns if isinstance(dns, list) else [dns]
-
- conf = Networkd(Match={"Name": network_config.iface}, Network=network)
+ def configure_nic(self, nic: Nic):
+ conf = nic.as_systemd_config()
for plugin in plugins.values():
if hasattr(plugin, 'on_configure_nic'):
- new_conf = plugin.on_configure_nic(
- network_config.iface,
- network_config.dhcp,
- network_config.ip,
- network_config.gateway,
- network_config.dns
- )
-
- if new_conf:
- conf = new_conf
-
- with open(f"{self.target}/etc/systemd/network/10-{network_config.iface}.network", "a") as netconf:
+ conf = plugin.on_configure_nic(
+ nic.iface,
+ nic.dhcp,
+ nic.ip,
+ nic.gateway,
+ nic.dns
+ ) or conf
+
+ with open(f"{self.target}/etc/systemd/network/10-{nic.iface}.network", "a") as netconf:
netconf.write(str(conf))
- def copy_iso_network_config(self, enable_services :bool = False) -> bool:
+ def copy_iso_network_config(self, enable_services: bool = False) -> bool:
# Copy (if any) iwd password and config files
if os.path.isdir('/var/lib/iwd/'):
if psk_files := glob.glob('/var/lib/iwd/*.psk'):
@@ -614,19 +651,19 @@ class Installer:
if enable_services:
# If we haven't installed the base yet (function called pre-maturely)
if self.helper_flags.get('base', False) is False:
- self.base_packages.append('iwd')
+ self._base_packages.append('iwd')
# This function will be called after minimal_installation()
# as a hook for post-installs. This hook is only needed if
# base is not installed yet.
- def post_install_enable_iwd_service(*args :str, **kwargs :str):
+ def post_install_enable_iwd_service(*args: str, **kwargs: str):
self.enable_service('iwd')
self.post_base_install.append(post_install_enable_iwd_service)
# Otherwise, we can go ahead and add the required package
# and enable it's service:
else:
- self.pacstrap('iwd')
+ self.pacman.strap('iwd')
self.enable_service('iwd')
for psk in psk_files:
@@ -644,179 +681,208 @@ class Installer:
# If we haven't installed the base yet (function called pre-maturely)
if self.helper_flags.get('base', False) is False:
- def post_install_enable_networkd_resolved(*args :str, **kwargs :str):
- self.enable_service('systemd-networkd', 'systemd-resolved')
+ def post_install_enable_networkd_resolved(*args: str, **kwargs: str):
+ self.enable_service(['systemd-networkd', 'systemd-resolved'])
self.post_base_install.append(post_install_enable_networkd_resolved)
# Otherwise, we can go ahead and enable the services
else:
- self.enable_service('systemd-networkd', 'systemd-resolved')
+ self.enable_service(['systemd-networkd', 'systemd-resolved'])
return True
- def detect_encryption(self, partition :Partition) -> bool:
- from .disk.mapperdev import MapperDev
- from .disk.dmcryptdev import DMCryptDev
- from .disk.helpers import get_filesystem_type
-
- if type(partition) is MapperDev:
- # Returns MapperDev.partition
- return partition.partition
- elif type(partition) is DMCryptDev:
- return partition.MapperDev.partition
- elif get_filesystem_type(partition.path) == 'crypto_LUKS':
- return partition
-
- return False
-
- def mkinitcpio(self, *flags :str) -> bool:
+ def mkinitcpio(self, flags: List[str]) -> bool:
for plugin in plugins.values():
if hasattr(plugin, 'on_mkinitcpio'):
# Allow plugins to override the usage of mkinitcpio altogether.
if plugin.on_mkinitcpio(self):
return True
- # mkinitcpio will error out if there's no vconsole.
- if (vconsole := pathlib.Path(f"{self.target}/etc/vconsole.conf")).exists() is False:
- with vconsole.open('w') as fh:
- fh.write(f"KEYMAP={storage['arguments']['keyboard-layout']}\n")
-
with open(f'{self.target}/etc/mkinitcpio.conf', 'w') as mkinit:
- mkinit.write(f"MODULES=({' '.join(self.MODULES)})\n")
- mkinit.write(f"BINARIES=({' '.join(self.BINARIES)})\n")
- mkinit.write(f"FILES=({' '.join(self.FILES)})\n")
+ mkinit.write(f"MODULES=({' '.join(self._modules)})\n")
+ mkinit.write(f"BINARIES=({' '.join(self._binaries)})\n")
+ mkinit.write(f"FILES=({' '.join(self._files)})\n")
- if self._disk_encryption and not self._disk_encryption.hsm_device:
+ if not self._disk_encryption.hsm_device:
# For now, if we don't use HSM we revert to the old
# way of setting up encryption hooks for mkinitcpio.
# This is purely for stability reasons, we're going away from this.
# * systemd -> udev
# * sd-vconsole -> keymap
- self.HOOKS = [hook.replace('systemd', 'udev').replace('sd-vconsole', 'keymap') for hook in self.HOOKS]
+ self._hooks = [hook.replace('systemd', 'udev').replace('sd-vconsole', 'keymap') for hook in self._hooks]
- mkinit.write(f"HOOKS=({' '.join(self.HOOKS)})\n")
+ mkinit.write(f"HOOKS=({' '.join(self._hooks)})\n")
try:
- SysCommand(f'/usr/bin/arch-chroot {self.target} mkinitcpio {" ".join(flags)}')
+ SysCommand(f'/usr/bin/arch-chroot {self.target} mkinitcpio {" ".join(flags)}', peek_output=True)
return True
- except SysCallError:
+ except SysCallError as error:
+ if error.worker:
+ log(error.worker._trace_log.decode())
return False
- def minimal_installation(
- self, testing: bool = False, multilib: bool = False,
- hostname: str = 'archinstall', locales: List[str] = ['en_US.UTF-8 UTF-8']) -> bool:
- # Add necessary packages if encrypting the drive
- # (encrypted partitions default to btrfs for now, so we need btrfs-progs)
- # TODO: Perhaps this should be living in the function which dictates
- # the partitioning. Leaving here for now.
-
- for partition in self.partitions:
- if partition.filesystem == 'btrfs':
- # if partition.encrypted:
- if 'btrfs-progs' not in self.base_packages:
- self.base_packages.append('btrfs-progs')
- if partition.filesystem == 'xfs':
- if 'xfs' not in self.base_packages:
- self.base_packages.append('xfsprogs')
- if partition.filesystem == 'f2fs':
- if 'f2fs' not in self.base_packages:
- self.base_packages.append('f2fs-tools')
-
- # Configure mkinitcpio to handle some specific use cases.
- if partition.filesystem == 'btrfs':
- if 'btrfs' not in self.MODULES:
- self.MODULES.append('btrfs')
- if '/usr/bin/btrfs' not in self.BINARIES:
- self.BINARIES.append('/usr/bin/btrfs')
- # There is not yet an fsck tool for NTFS. If it's being used for the root filesystem, the hook should be removed.
- if partition.filesystem == 'ntfs3' and partition.mountpoint == self.target:
- if 'fsck' in self.HOOKS:
- self.HOOKS.remove('fsck')
-
- if self.detect_encryption(partition):
- if self._disk_encryption and self._disk_encryption.hsm_device:
- # Required bby mkinitcpio to add support for fido2-device options
- self.pacstrap('libfido2')
-
- if 'sd-encrypt' not in self.HOOKS:
- self.HOOKS.insert(self.HOOKS.index('filesystems'), 'sd-encrypt')
- else:
- if 'encrypt' not in self.HOOKS:
- self.HOOKS.insert(self.HOOKS.index('filesystems'), 'encrypt')
-
- if not has_uefi():
- self.base_packages.append('grub')
-
- if not is_vm():
- vendor = cpu_vendor()
- if vendor == "AuthenticAMD":
- self.base_packages.append("amd-ucode")
- if (ucode := pathlib.Path(f"{self.target}/boot/amd-ucode.img")).exists():
- ucode.unlink()
- elif vendor == "GenuineIntel":
- self.base_packages.append("intel-ucode")
- if (ucode := pathlib.Path(f"{self.target}/boot/intel-ucode.img")).exists():
- ucode.unlink()
+ def _get_microcode(self) -> Optional[Path]:
+ if not SysInfo.is_vm():
+ if vendor := SysInfo.cpu_vendor():
+ return vendor.get_ucode()
+ return None
+
+ def _handle_partition_installation(self):
+ pvs = []
+ if self._disk_config.lvm_config:
+ pvs = self._disk_config.lvm_config.get_all_pvs()
+
+ for mod in self._disk_config.device_modifications:
+ for part in mod.partitions:
+ if part in pvs or part.fs_type is None:
+ continue
+
+ if (pkg := part.fs_type.installation_pkg) is not None:
+ self._base_packages.append(pkg)
+ if (module := part.fs_type.installation_module) is not None:
+ self._modules.append(module)
+ if (binary := part.fs_type.installation_binary) is not None:
+ self._binaries.append(binary)
+
+ # https://github.com/archlinux/archinstall/issues/1837
+ if part.fs_type.fs_type_mount == 'btrfs':
+ self._disable_fstrim = True
+
+ # There is not yet an fsck tool for NTFS. If it's being used for the root filesystem, the hook should be removed.
+ if part.fs_type.fs_type_mount == 'ntfs3' and part.mountpoint == self.target:
+ if 'fsck' in self._hooks:
+ self._hooks.remove('fsck')
+
+ if part in self._disk_encryption.partitions:
+ if self._disk_encryption.hsm_device:
+ # Required by mkinitcpio to add support for fido2-device options
+ self.pacman.strap('libfido2')
+
+ if 'sd-encrypt' not in self._hooks:
+ self._hooks.insert(self._hooks.index('filesystems'), 'sd-encrypt')
+ else:
+ if 'encrypt' not in self._hooks:
+ self._hooks.insert(self._hooks.index('filesystems'), 'encrypt')
+
+ def _handle_lvm_installation(self):
+ if not self._disk_config.lvm_config:
+ return
+
+ self.add_additional_packages('lvm2')
+ self._hooks.insert(self._hooks.index('filesystems') - 1, 'lvm2')
+
+ for vg in self._disk_config.lvm_config.vol_groups:
+ for vol in vg.volumes:
+ if vol.fs_type is not None:
+ if (pkg := vol.fs_type.installation_pkg) is not None:
+ self._base_packages.append(pkg)
+ if (module := vol.fs_type.installation_module) is not None:
+ self._modules.append(module)
+ if (binary := vol.fs_type.installation_binary) is not None:
+ self._binaries.append(binary)
+
+ if vol.fs_type.fs_type_mount == 'btrfs':
+ self._disable_fstrim = True
+
+ # There is not yet an fsck tool for NTFS. If it's being used for the root filesystem, the hook should be removed.
+ if vol.fs_type.fs_type_mount == 'ntfs3' and vol.mountpoint == self.target:
+ if 'fsck' in self._hooks:
+ self._hooks.remove('fsck')
+
+ if self._disk_encryption.encryption_type in [disk.EncryptionType.LvmOnLuks, disk.EncryptionType.LuksOnLvm]:
+ if self._disk_encryption.hsm_device:
+ # Required by mkinitcpio to add support for fido2-device options
+ self.pacman.strap('libfido2')
+
+ if 'sd-encrypt' not in self._hooks:
+ self._hooks.insert(self._hooks.index('lvm2') - 1, 'sd-encrypt')
else:
- self.log(f"Unknown CPU vendor '{vendor}' detected. Archinstall won't install any ucode.", level=logging.DEBUG)
+ if 'encrypt' not in self._hooks:
+ self._hooks.insert(self._hooks.index('lvm2') - 1, 'encrypt')
+
+ def minimal_installation(
+ self,
+ testing: bool = False,
+ multilib: bool = False,
+ mkinitcpio: bool = True,
+ hostname: str = 'archinstall',
+ locale_config: LocaleConfiguration = LocaleConfiguration.default()
+ ):
+ if self._disk_config.lvm_config:
+ self._handle_lvm_installation()
+ else:
+ self._handle_partition_installation()
+
+ if not SysInfo.has_uefi():
+ self._base_packages.append('grub')
+
+ if ucode := self._get_microcode():
+ (self.target / 'boot' / ucode).unlink(missing_ok=True)
+ self._base_packages.append(ucode.stem)
+ else:
+ debug('Archinstall will not install any ucode.')
# Determine whether to enable multilib/testing repositories before running pacstrap if testing flag is set.
# This action takes place on the host system as pacstrap copies over package repository lists.
+ pacman_conf = pacman.Config(self.target)
if multilib:
- self.log("The multilib flag is set. This system will be installed with the multilib repository enabled.")
- self.enable_multilib_repository()
+ info("The multilib flag is set. This system will be installed with the multilib repository enabled.")
+ pacman_conf.enable(pacman.Repo.Multilib)
else:
- self.log("The multilib flag is not set. This system will be installed without multilib repositories enabled.")
+ info("The multilib flag is not set. This system will be installed without multilib repositories enabled.")
if testing:
- self.log("The testing flag is set. This system will be installed with testing repositories enabled.")
- self.enable_testing_repositories(multilib)
+ info("The testing flag is set. This system will be installed with testing repositories enabled.")
+ pacman_conf.enable(pacman.Repo.Testing)
else:
- self.log("The testing flag is not set. This system will be installed without testing repositories enabled.")
+ info("The testing flag is not set. This system will be installed without testing repositories enabled.")
+
+ pacman_conf.apply()
- self.pacstrap(self.base_packages)
+ self.pacman.strap(self._base_packages)
self.helper_flags['base-strapped'] = True
- # This handles making sure that the repositories we enabled persist on the installed system
- if multilib or testing:
- shutil.copy2("/etc/pacman.conf", f"{self.target}/etc/pacman.conf")
+ pacman_conf.persist()
# Periodic TRIM may improve the performance and longevity of SSDs whilst
# having no adverse effect on other devices. Most distributions enable
# periodic TRIM by default.
#
# https://github.com/archlinux/archinstall/issues/880
- self.enable_periodic_trim()
+ # https://github.com/archlinux/archinstall/issues/1837
+ # https://github.com/archlinux/archinstall/issues/1841
+ if not self._disable_fstrim:
+ self.enable_periodic_trim()
# TODO: Support locale and timezone
# os.remove(f'{self.target}/etc/localtime')
# sys_command(f'/usr/bin/arch-chroot {self.target} ln -s /usr/share/zoneinfo/{localtime} /etc/localtime')
# sys_command('/usr/bin/arch-chroot /mnt hwclock --hctosys --localtime')
self.set_hostname(hostname)
- self.set_locale(*locales[0].split())
+ self.set_locale(locale_config)
+ self.set_keyboard_language(locale_config.kb_layout)
# TODO: Use python functions for this
SysCommand(f'/usr/bin/arch-chroot {self.target} chmod 700 /root')
- self.mkinitcpio('-P')
+ if mkinitcpio and not self.mkinitcpio(['-P']):
+ error('Error generating initramfs (continuing anyway)')
self.helper_flags['base'] = True
# Run registered post-install hooks
for function in self.post_base_install:
- self.log(f"Running post-installation hook: {function}", level=logging.INFO)
+ info(f"Running post-installation hook: {function}")
function(self)
for plugin in plugins.values():
if hasattr(plugin, 'on_install'):
plugin.on_install(self)
- return True
-
- def setup_swap(self, kind :str = 'zram') -> bool:
+ def setup_swap(self, kind: str = 'zram'):
if kind == 'zram':
- self.log(f"Setting up swap on zram")
- self.pacstrap('zram-generator')
+ info(f"Setting up swap on zram")
+ self.pacman.strap('zram-generator')
# We could use the default example below, but maybe not the best idea: https://github.com/archlinux/archinstall/pull/678#issuecomment-962124813
# zram_example_location = '/usr/share/doc/zram-generator/zram-generator.conf.example'
@@ -827,224 +893,532 @@ class Installer:
self.enable_service('systemd-zram-setup@zram0.service')
self._zram_enabled = True
-
- return True
else:
raise ValueError(f"Archinstall currently only supports setting up swap on zram")
- def add_systemd_bootloader(self, boot_partition :Partition, root_partition :Partition) -> bool:
- self.pacstrap('efibootmgr')
+ def _get_efi_partition(self) -> Optional[disk.PartitionModification]:
+ for layout in self._disk_config.device_modifications:
+ if partition := layout.get_efi_partition():
+ return partition
+ return None
+
+ def _get_boot_partition(self) -> Optional[disk.PartitionModification]:
+ for layout in self._disk_config.device_modifications:
+ if boot := layout.get_boot_partition():
+ return boot
+ return None
+
+ def _get_root(self) -> Optional[disk.PartitionModification | disk.LvmVolume]:
+ if self._disk_config.lvm_config:
+ return self._disk_config.lvm_config.get_root_volume()
+ else:
+ for mod in self._disk_config.device_modifications:
+ if root := mod.get_root_partition():
+ return root
+ return None
+
+ def _get_luks_uuid_from_mapper_dev(self, mapper_dev_path: Path) -> str:
+ lsblk_info = disk.get_lsblk_info(mapper_dev_path, reverse=True, full_dev_path=True)
+
+ if not lsblk_info.children or not lsblk_info.children[0].uuid:
+ raise ValueError('Unable to determine UUID of luks superblock')
+
+ return lsblk_info.children[0].uuid
+
+ def _get_kernel_params_partition(
+ self,
+ root_partition: disk.PartitionModification,
+ id_root: bool = True,
+ partuuid: bool = True
+ ) -> List[str]:
+ kernel_parameters = []
+
+ if root_partition in self._disk_encryption.partitions:
+ # TODO: We need to detect if the encrypted device is a whole disk encryption,
+ # or simply a partition encryption. Right now we assume it's a partition (and we always have)
+
+ if self._disk_encryption and self._disk_encryption.hsm_device:
+ debug(f'Root partition is an encrypted device, identifying by UUID: {root_partition.uuid}')
+ # Note: UUID must be used, not PARTUUID for sd-encrypt to work
+ kernel_parameters.append(f'rd.luks.name={root_partition.uuid}=root')
+ # Note: tpm2-device and fido2-device don't play along very well:
+ # https://github.com/archlinux/archinstall/pull/1196#issuecomment-1129715645
+ kernel_parameters.append('rd.luks.options=fido2-device=auto,password-echo=no')
+ elif partuuid:
+ debug(f'Root partition is an encrypted device, identifying by PARTUUID: {root_partition.partuuid}')
+ kernel_parameters.append(f'cryptdevice=PARTUUID={root_partition.partuuid}:root')
+ else:
+ debug(f'Root partition is an encrypted device, identifying by UUID: {root_partition.uuid}')
+ kernel_parameters.append(f'cryptdevice=UUID={root_partition.uuid}:root')
+
+ if id_root:
+ kernel_parameters.append('root=/dev/mapper/root')
+ elif id_root:
+ if partuuid:
+ debug(f'Identifying root partition by PARTUUID: {root_partition.partuuid}')
+ kernel_parameters.append(f'root=PARTUUID={root_partition.partuuid}')
+ else:
+ debug(f'Identifying root partition by UUID: {root_partition.uuid}')
+ kernel_parameters.append(f'root=UUID={root_partition.uuid}')
+
+ return kernel_parameters
+
+ def _get_kernel_params_lvm(
+ self,
+ lvm: disk.LvmVolume
+ ) -> List[str]:
+ kernel_parameters = []
+
+ match self._disk_encryption.encryption_type:
+ case disk.EncryptionType.LvmOnLuks:
+ if not lvm.vg_name:
+ raise ValueError(f'Unable to determine VG name for {lvm.name}')
+
+ pv_seg_info = disk.device_handler.lvm_pvseg_info(lvm.vg_name, lvm.name)
- if not has_uefi():
+ if not pv_seg_info:
+ raise ValueError(f'Unable to determine PV segment info for {lvm.vg_name}/{lvm.name}')
+
+ uuid = self._get_luks_uuid_from_mapper_dev(pv_seg_info.pv_name)
+
+ if self._disk_encryption.hsm_device:
+ debug(f'LvmOnLuks, encrypted root partition, HSM, identifying by UUID: {uuid}')
+ kernel_parameters.append(f'rd.luks.name={uuid}=cryptlvm root={lvm.safe_dev_path}')
+ else:
+ debug(f'LvmOnLuks, encrypted root partition, identifying by UUID: {uuid}')
+ kernel_parameters.append(f'cryptdevice=UUID={uuid}:cryptlvm root={lvm.safe_dev_path}')
+ case disk.EncryptionType.LuksOnLvm:
+ uuid = self._get_luks_uuid_from_mapper_dev(lvm.mapper_path)
+
+ if self._disk_encryption.hsm_device:
+ debug(f'LuksOnLvm, encrypted root partition, HSM, identifying by UUID: {uuid}')
+ kernel_parameters.append(f'rd.luks.name={uuid}=root root=/dev/mapper/root')
+ else:
+ debug(f'LuksOnLvm, encrypted root partition, identifying by UUID: {uuid}')
+ kernel_parameters.append(f'cryptdevice=UUID={uuid}:root root=/dev/mapper/root')
+ case disk.EncryptionType.NoEncryption:
+ debug(f'Identifying root lvm by mapper device: {lvm.dev_path}')
+ kernel_parameters.append(f'root={lvm.safe_dev_path}')
+
+ return kernel_parameters
+
+ def _get_kernel_params(
+ self,
+ root: disk.PartitionModification | disk.LvmVolume,
+ id_root: bool = True,
+ partuuid: bool = True
+ ) -> List[str]:
+ kernel_parameters = []
+
+ if isinstance(root, disk.LvmVolume):
+ kernel_parameters = self._get_kernel_params_lvm(root)
+ else:
+ kernel_parameters = self._get_kernel_params_partition(root, id_root, partuuid)
+
+ # Zswap should be disabled when using zram.
+ # https://github.com/archlinux/archinstall/issues/881
+ if self._zram_enabled:
+ kernel_parameters.append('zswap.enabled=0')
+
+ if id_root:
+ for sub_vol in root.btrfs_subvols:
+ if sub_vol.is_root():
+ kernel_parameters.append(f'rootflags=subvol={sub_vol.name}')
+ break
+
+ kernel_parameters.append('rw')
+
+ kernel_parameters.append(f'rootfstype={root.safe_fs_type.fs_type_mount}')
+ kernel_parameters.extend(self._kernel_params)
+
+ debug(f'kernel parameters: {" ".join(kernel_parameters)}')
+
+ return kernel_parameters
+
+ def _add_systemd_bootloader(
+ self,
+ boot_partition: disk.PartitionModification,
+ root: disk.PartitionModification | disk.LvmVolume,
+ efi_partition: Optional[disk.PartitionModification],
+ uki_enabled: bool = False
+ ):
+ debug('Installing systemd bootloader')
+
+ self.pacman.strap('efibootmgr')
+
+ if not SysInfo.has_uefi():
raise HardwareIncompatibilityError
+
# TODO: Ideally we would want to check if another config
# points towards the same disk and/or partition.
# And in which case we should do some clean up.
+ bootctl_options = []
+
+ if efi_partition and boot_partition != efi_partition:
+ bootctl_options.append(f'--esp-path={efi_partition.mountpoint}')
+ bootctl_options.append(f'--boot-path={boot_partition.mountpoint}')
# Install the boot loader
try:
- SysCommand(f'/usr/bin/arch-chroot {self.target} bootctl --path=/boot install')
+ SysCommand(f"/usr/bin/arch-chroot {self.target} bootctl {' '.join(bootctl_options)} install")
except SysCallError:
# Fallback, try creating the boot loader without touching the EFI variables
- SysCommand(f'/usr/bin/arch-chroot {self.target} bootctl --no-variables --path=/boot install')
+ SysCommand(f"/usr/bin/arch-chroot {self.target} bootctl --no-variables {' '.join(bootctl_options)} install")
- # Ensure that the /boot/loader directory exists before we try to create files in it
- if not os.path.exists(f'{self.target}/boot/loader'):
- os.makedirs(f'{self.target}/boot/loader')
+ # Ensure that the $BOOT/loader/ directory exists before we try to create files in it.
+ #
+ # As mentioned in https://github.com/archlinux/archinstall/pull/1859 - we store the
+ # loader entries in $BOOT/loader/ rather than $ESP/loader/
+ # The current reasoning being that $BOOT works in both use cases as well
+ # as being tied to the current installation. This may change.
+ loader_dir = self.target / 'boot/loader'
+ loader_dir.mkdir(parents=True, exist_ok=True)
+
+ default_kernel = self.kernels[0]
+ if uki_enabled:
+ default_entry = f'arch-{default_kernel}.efi'
+ else:
+ entry_name = self.init_time + '_{kernel}{variant}.conf'
+ default_entry = entry_name.format(kernel=default_kernel, variant='')
+
+ default = f'default {default_entry}'
# Modify or create a loader.conf
- if os.path.isfile(f'{self.target}/boot/loader/loader.conf'):
- with open(f'{self.target}/boot/loader/loader.conf', 'r') as loader:
- loader_data = loader.read().split('\n')
- else:
+ loader_conf = loader_dir / 'loader.conf'
+
+ try:
+ loader_data = loader_conf.read_text().splitlines()
+ except FileNotFoundError:
loader_data = [
- f"default {self.init_time}",
- "timeout 15"
+ default,
+ 'timeout 15'
]
-
- with open(f'{self.target}/boot/loader/loader.conf', 'w') as loader:
- for line in loader_data:
- if line[:8] == 'default ':
- loader.write(f'default {self.init_time}_{self.kernels[0]}\n')
- elif line[:8] == '#timeout' and 'timeout 15' not in loader_data:
+ else:
+ for index, line in enumerate(loader_data):
+ if line.startswith('default'):
+ loader_data[index] = default
+ elif line.startswith('#timeout'):
# We add in the default timeout to support dual-boot
- loader.write(f"{line[1:]}\n")
- else:
- loader.write(f"{line}\n")
+ loader_data[index] = line.removeprefix('#')
+
+ loader_conf.write_text('\n'.join(loader_data) + '\n')
- # Ensure that the /boot/loader/entries directory exists before we try to create files in it
- if not os.path.exists(f'{self.target}/boot/loader/entries'):
- os.makedirs(f'{self.target}/boot/loader/entries')
+ if uki_enabled:
+ return
+
+ # Ensure that the $BOOT/loader/entries/ directory exists before we try to create files in it
+ entries_dir = loader_dir / 'entries'
+ entries_dir.mkdir(parents=True, exist_ok=True)
+
+ comments = (
+ '# Created by: archinstall',
+ f'# Created on: {self.init_time}'
+ )
+
+ options = 'options ' + ' '.join(self._get_kernel_params(root))
for kernel in self.kernels:
for variant in ("", "-fallback"):
# Setup the loader entry
- with open(f'{self.target}/boot/loader/entries/{self.init_time}_{kernel}{variant}.conf', 'w') as entry:
- entry.write('# Created by: archinstall\n')
- entry.write(f'# Created on: {self.init_time}\n')
- entry.write(f'title Arch Linux ({kernel}{variant})\n')
- entry.write(f"linux /vmlinuz-{kernel}\n")
- if not is_vm():
- vendor = cpu_vendor()
- if vendor == "AuthenticAMD":
- entry.write("initrd /amd-ucode.img\n")
- elif vendor == "GenuineIntel":
- entry.write("initrd /intel-ucode.img\n")
- else:
- self.log(f"Unknown CPU vendor '{vendor}' detected. Archinstall won't add any ucode to systemd-boot config.", level=logging.DEBUG)
- entry.write(f"initrd /initramfs-{kernel}{variant}.img\n")
- # blkid doesn't trigger on loopback devices really well,
- # so we'll use the old manual method until we get that sorted out.
- root_fs_type = get_mount_fs_type(root_partition.filesystem)
-
- if root_fs_type is not None:
- options_entry = f'rw rootfstype={root_fs_type} {" ".join(self.KERNEL_PARAMS)}\n'
- else:
- options_entry = f'rw {" ".join(self.KERNEL_PARAMS)}\n'
-
- for subvolume in root_partition.subvolumes:
- if subvolume.root is True and subvolume.name != '<FS_TREE>':
- options_entry = f"rootflags=subvol={subvolume.name} " + options_entry
-
- # Zswap should be disabled when using zram.
- #
- # https://github.com/archlinux/archinstall/issues/881
- if self._zram_enabled:
- options_entry = "zswap.enabled=0 " + options_entry
-
- if real_device := self.detect_encryption(root_partition):
- # TODO: We need to detect if the encrypted device is a whole disk encryption,
- # or simply a partition encryption. Right now we assume it's a partition (and we always have)
- log(f"Identifying root partition by PART-UUID on {real_device}: '{real_device.uuid}/{real_device.part_uuid}'.", level=logging.DEBUG)
-
- kernel_options = f"options"
-
- if self._disk_encryption.hsm_device:
- # Note: lsblk UUID must be used, not PARTUUID for sd-encrypt to work
- kernel_options += f" rd.luks.name={real_device.uuid}=luksdev"
- # Note: tpm2-device and fido2-device don't play along very well:
- # https://github.com/archlinux/archinstall/pull/1196#issuecomment-1129715645
- kernel_options += f" rd.luks.options=fido2-device=auto,password-echo=no"
- else:
- kernel_options += f" cryptdevice=PARTUUID={real_device.part_uuid}:luksdev"
-
- entry.write(f'{kernel_options} root=/dev/mapper/luksdev {options_entry}')
-
- if self._disk_encryption and self._disk_encryption.hsm_device:
- # Note: lsblk UUID must be used, not PARTUUID for sd-encrypt to work
- kernel_options += f" rd.luks.name={real_device.uuid}=luksdev"
- # Note: tpm2-device and fido2-device don't play along very well:
- # https://github.com/archlinux/archinstall/pull/1196#issuecomment-1129715645
- kernel_options += f" rd.luks.options=fido2-device=auto,password-echo=no"
- else:
- log(f"Identifying root partition by PARTUUID on {root_partition}, looking for '{root_partition.part_uuid}'.", level=logging.DEBUG)
- entry.write(f'options root=PARTUUID={root_partition.part_uuid} {options_entry}')
+ entry = [
+ *comments,
+ f'title Arch Linux ({kernel}{variant})',
+ f'linux /vmlinuz-{kernel}',
+ f'initrd /initramfs-{kernel}{variant}.img',
+ options,
+ ]
- self.helper_flags['bootloader'] = "systemd"
+ name = entry_name.format(kernel=kernel, variant=variant)
+ entry_conf = entries_dir / name
+ entry_conf.write_text('\n'.join(entry) + '\n')
- return True
+ self.helper_flags['bootloader'] = 'systemd'
- def add_grub_bootloader(self, boot_partition :Partition, root_partition :Partition) -> bool:
- self.pacstrap('grub') # no need?
+ def _add_grub_bootloader(
+ self,
+ boot_partition: disk.PartitionModification,
+ root: disk.PartitionModification | disk.LvmVolume,
+ efi_partition: Optional[disk.PartitionModification]
+ ):
+ debug('Installing grub bootloader')
- root_fs_type = get_mount_fs_type(root_partition.filesystem)
+ self.pacman.strap('grub') # no need?
- if real_device := self.detect_encryption(root_partition):
- root_uuid = SysCommand(f"blkid -s UUID -o value {real_device.path}").decode().rstrip()
- _file = "/etc/default/grub"
- add_to_CMDLINE_LINUX = f"sed -i 's/GRUB_CMDLINE_LINUX=\"\"/GRUB_CMDLINE_LINUX=\"cryptdevice=UUID={root_uuid}:cryptlvm rootfstype={root_fs_type}\"/'"
- enable_CRYPTODISK = "sed -i 's/#GRUB_ENABLE_CRYPTODISK=y/GRUB_ENABLE_CRYPTODISK=y/'"
+ grub_default = self.target / 'etc/default/grub'
+ config = grub_default.read_text()
- log(f"Using UUID {root_uuid} of {real_device} as encrypted root identifier.", level=logging.INFO)
- SysCommand(f"/usr/bin/arch-chroot {self.target} {add_to_CMDLINE_LINUX} {_file}")
- SysCommand(f"/usr/bin/arch-chroot {self.target} {enable_CRYPTODISK} {_file}")
- else:
- _file = "/etc/default/grub"
- add_to_CMDLINE_LINUX = f"sed -i 's/GRUB_CMDLINE_LINUX=\"\"/GRUB_CMDLINE_LINUX=\"rootfstype={root_fs_type}\"/'"
- SysCommand(f"/usr/bin/arch-chroot {self.target} {add_to_CMDLINE_LINUX} {_file}")
+ kernel_parameters = ' '.join(self._get_kernel_params(root, False, False))
+ config = re.sub(r'(GRUB_CMDLINE_LINUX=")("\n)', rf'\1{kernel_parameters}\2', config, 1)
+
+ grub_default.write_text(config)
+
+ info(f"GRUB boot partition: {boot_partition.dev_path}")
+
+ boot_dir = Path('/boot')
+
+ command = [
+ '/usr/bin/arch-chroot',
+ str(self.target),
+ 'grub-install',
+ '--debug'
+ ]
+
+ if SysInfo.has_uefi():
+ if not efi_partition:
+ raise ValueError('Could not detect efi partition')
+
+ info(f"GRUB EFI partition: {efi_partition.dev_path}")
+
+ self.pacman.strap('efibootmgr') # TODO: Do we need? Yes, but remove from minimal_installation() instead?
+
+ boot_dir_arg = []
+ if boot_partition.mountpoint and boot_partition.mountpoint != boot_dir:
+ boot_dir_arg.append(f'--boot-directory={boot_partition.mountpoint}')
+ boot_dir = boot_partition.mountpoint
+
+ add_options = [
+ '--target=x86_64-efi',
+ f'--efi-directory={efi_partition.mountpoint}',
+ *boot_dir_arg,
+ '--bootloader-id=GRUB',
+ '--removable'
+ ]
+
+ command.extend(add_options)
- log(f"GRUB uses {boot_partition.path} as the boot partition.", level=logging.INFO)
- if has_uefi():
- self.pacstrap('efibootmgr') # TODO: Do we need? Yes, but remove from minimal_installation() instead?
try:
- SysCommand(f'/usr/bin/arch-chroot {self.target} grub-install --debug --target=x86_64-efi --efi-directory=/boot --bootloader-id=GRUB --removable', peek_output=True)
+ SysCommand(command, peek_output=True)
except SysCallError:
try:
- SysCommand(f'/usr/bin/arch-chroot {self.target} grub-install --debug --target=x86_64-efi --efi-directory=/boot --bootloader-id=GRUB --removable', peek_output=True)
- except SysCallError as error:
- raise DiskError(f"Could not install GRUB to {self.target}/boot: {error}")
+ SysCommand(command, peek_output=True)
+ except SysCallError as err:
+ raise DiskError(f"Could not install GRUB to {self.target}{efi_partition.mountpoint}: {err}")
else:
+ info(f"GRUB boot partition: {boot_partition.dev_path}")
+
+ parent_dev_path = disk.device_handler.get_parent_device_path(boot_partition.safe_dev_path)
+
+ add_options = [
+ '--target=i386-pc',
+ '--recheck',
+ str(parent_dev_path)
+ ]
+
try:
- SysCommand(f'/usr/bin/arch-chroot {self.target} grub-install --debug --target=i386-pc --recheck {boot_partition.parent}', peek_output=True)
- except SysCallError as error:
- raise DiskError(f"Could not install GRUB to {boot_partition.path}: {error}")
+ SysCommand(command + add_options, peek_output=True)
+ except SysCallError as err:
+ raise DiskError(f"Failed to install GRUB boot on {boot_partition.dev_path}: {err}")
try:
- SysCommand(f'/usr/bin/arch-chroot {self.target} grub-mkconfig -o /boot/grub/grub.cfg')
- except SysCallError as error:
- raise DiskError(f"Could not configure GRUB: {error}")
+ SysCommand(
+ f'/usr/bin/arch-chroot {self.target} '
+ f'grub-mkconfig -o {boot_dir}/grub/grub.cfg'
+ )
+ except SysCallError as err:
+ raise DiskError(f"Could not configure GRUB: {err}")
self.helper_flags['bootloader'] = "grub"
- return True
+ def _add_limine_bootloader(
+ self,
+ boot_partition: disk.PartitionModification,
+ efi_partition: Optional[disk.PartitionModification],
+ root: disk.PartitionModification | disk.LvmVolume
+ ):
+ debug('Installing limine bootloader')
+
+ self.pacman.strap('limine')
+
+ info(f"Limine boot partition: {boot_partition.dev_path}")
+
+ limine_path = self.target / 'usr' / 'share' / 'limine'
+ hook_command = None
+
+ if SysInfo.has_uefi():
+ if not efi_partition:
+ raise ValueError('Could not detect efi partition')
+ elif not efi_partition.mountpoint:
+ raise ValueError('EFI partition is not mounted')
+
+ info(f"Limine EFI partition: {efi_partition.dev_path}")
+
+ try:
+ efi_dir_path = self.target / efi_partition.mountpoint.relative_to('/') / 'EFI' / 'BOOT'
+ efi_dir_path.mkdir(parents=True, exist_ok=True)
+
+ for file in ('BOOTIA32.EFI', 'BOOTX64.EFI'):
+ shutil.copy(limine_path / file, efi_dir_path)
+ except Exception as err:
+ raise DiskError(f'Failed to install Limine in {self.target}{efi_partition.mountpoint}: {err}')
+
+ hook_command = f'/usr/bin/cp /usr/share/limine/BOOTIA32.EFI {efi_partition.mountpoint}/EFI/BOOT/' \
+ f' && /usr/bin/cp /usr/share/limine/BOOTX64.EFI {efi_partition.mountpoint}/EFI/BOOT/'
+ else:
+ parent_dev_path = disk.device_handler.get_parent_device_path(boot_partition.safe_dev_path)
+
+ if unique_path := disk.device_handler.get_unique_path_for_device(parent_dev_path):
+ parent_dev_path = unique_path
+
+ try:
+ # The `limine-bios.sys` file contains stage 3 code.
+ shutil.copy(limine_path / 'limine-bios.sys', self.target / 'boot')
+
+ # `limine bios-install` deploys the stage 1 and 2 to the disk.
+ SysCommand(f'/usr/bin/arch-chroot {self.target} limine bios-install {parent_dev_path}', peek_output=True)
+ except Exception as err:
+ raise DiskError(f'Failed to install Limine on {parent_dev_path}: {err}')
+
+ hook_command = f'/usr/bin/limine bios-install {parent_dev_path}' \
+ f' && /usr/bin/cp /usr/share/limine/limine-bios.sys /boot/'
+
+ hook_contents = f'''[Trigger]
+Operation = Install
+Operation = Upgrade
+Type = Package
+Target = limine
+
+[Action]
+Description = Deploying Limine after upgrade...
+When = PostTransaction
+Exec = /bin/sh -c "{hook_command}"
+'''
+
+ hooks_dir = self.target / 'etc' / 'pacman.d' / 'hooks'
+ hooks_dir.mkdir(parents=True, exist_ok=True)
+
+ hook_path = hooks_dir / '99-limine.hook'
+ hook_path.write_text(hook_contents)
+
+ kernel_params = ' '.join(self._get_kernel_params(root))
+ config_contents = 'TIMEOUT=5\n'
+
+ for kernel in self.kernels:
+ for variant in ('', '-fallback'):
+ entry = [
+ f'PROTOCOL=linux',
+ f'KERNEL_PATH=boot:///vmlinuz-{kernel}',
+ f'MODULE_PATH=boot:///initramfs-{kernel}{variant}.img',
+ f'CMDLINE={kernel_params}',
+ ]
+
+ config_contents += f'\n:Arch Linux ({kernel}{variant})\n'
+ config_contents += '\n'.join([f' {it}' for it in entry]) + '\n'
- def add_efistub_bootloader(self, boot_partition :Partition, root_partition :Partition) -> bool:
- self.pacstrap('efibootmgr')
+ config_path = self.target / 'boot' / 'limine.cfg'
+ config_path.write_text(config_contents)
- if not has_uefi():
+ self.helper_flags['bootloader'] = "limine"
+
+ def _add_efistub_bootloader(
+ self,
+ boot_partition: disk.PartitionModification,
+ root: disk.PartitionModification | disk.LvmVolume,
+ uki_enabled: bool = False
+ ):
+ debug('Installing efistub bootloader')
+
+ self.pacman.strap('efibootmgr')
+
+ if not SysInfo.has_uefi():
raise HardwareIncompatibilityError
+
# TODO: Ideally we would want to check if another config
# points towards the same disk and/or partition.
# And in which case we should do some clean up.
- root_fs_type = get_mount_fs_type(root_partition.filesystem)
+ if not uki_enabled:
+ loader = '/vmlinuz-{kernel}'
+
+ entries = (
+ 'initrd=/initramfs-{kernel}.img',
+ *self._get_kernel_params(root)
+ )
+
+ cmdline = [' '.join(entries)]
+ else:
+ loader = '/EFI/Linux/arch-{kernel}.efi'
+ cmdline = []
+
+ parent_dev_path = disk.device_handler.get_parent_device_path(boot_partition.safe_dev_path)
+
+ cmd_template = (
+ 'efibootmgr',
+ '--create',
+ '--disk', str(parent_dev_path),
+ '--part', str(boot_partition.partn),
+ '--label', 'Arch Linux ({kernel})',
+ '--loader', loader,
+ '--unicode', *cmdline,
+ '--verbose'
+ )
for kernel in self.kernels:
# Setup the firmware entry
+ cmd = [arg.format(kernel=kernel) for arg in cmd_template]
+ SysCommand(cmd)
+
+ self.helper_flags['bootloader'] = "efistub"
- label = f'Arch Linux ({kernel})'
- loader = f"/vmlinuz-{kernel}"
+ def _config_uki(
+ self,
+ root: disk.PartitionModification | disk.LvmVolume,
+ efi_partition: Optional[disk.PartitionModification]
+ ):
+ if not efi_partition or not efi_partition.mountpoint:
+ raise ValueError(f'Could not detect ESP at mountpoint {self.target}')
- kernel_parameters = []
+ # Set up kernel command line
+ with open(self.target / 'etc/kernel/cmdline', 'w') as cmdline:
+ kernel_parameters = self._get_kernel_params(root)
+ cmdline.write(' '.join(kernel_parameters) + '\n')
- if not is_vm():
- vendor = cpu_vendor()
- if vendor == "AuthenticAMD":
- kernel_parameters.append("initrd=\\amd-ucode.img")
- elif vendor == "GenuineIntel":
- kernel_parameters.append("initrd=\\intel-ucode.img")
- else:
- self.log(f"Unknown CPU vendor '{vendor}' detected. Archinstall won't add any ucode to firmware boot entry.", level=logging.DEBUG)
+ diff_mountpoint = None
- kernel_parameters.append(f"initrd=\\initramfs-{kernel}.img")
+ if efi_partition.mountpoint != Path('/efi'):
+ diff_mountpoint = str(efi_partition.mountpoint)
- # blkid doesn't trigger on loopback devices really well,
- # so we'll use the old manual method until we get that sorted out.
- if real_device := self.detect_encryption(root_partition):
- # TODO: We need to detect if the encrypted device is a whole disk encryption,
- # or simply a partition encryption. Right now we assume it's a partition (and we always have)
- log(f"Identifying root partition by PART-UUID on {real_device}: '{real_device.part_uuid}'.", level=logging.DEBUG)
- kernel_parameters.append(f'cryptdevice=PARTUUID={real_device.part_uuid}:luksdev root=/dev/mapper/luksdev rw rootfstype={root_fs_type} {" ".join(self.KERNEL_PARAMS)}')
- else:
- log(f"Identifying root partition by PART-UUID on {root_partition}, looking for '{root_partition.part_uuid}'.", level=logging.DEBUG)
- kernel_parameters.append(f'root=PARTUUID={root_partition.part_uuid} rw rootfstype={root_fs_type} {" ".join(self.KERNEL_PARAMS)}')
+ image_re = re.compile('(.+_image="/([^"]+).+\n)')
+ uki_re = re.compile('#((.+_uki=")/[^/]+(.+\n))')
- SysCommand(f'efibootmgr --disk {boot_partition.path[:-1]} --part {boot_partition.path[-1]} --create --label "{label}" --loader {loader} --unicode \'{" ".join(kernel_parameters)}\' --verbose')
+ # Modify .preset files
+ for kernel in self.kernels:
+ preset = self.target / 'etc/mkinitcpio.d' / (kernel + '.preset')
+ config = preset.read_text().splitlines(True)
+
+ for index, line in enumerate(config):
+ # Avoid storing redundant image file
+ if m := image_re.match(line):
+ image = self.target / m.group(2)
+ image.unlink(missing_ok=True)
+ config[index] = '#' + m.group(1)
+ elif m := uki_re.match(line):
+ if diff_mountpoint:
+ config[index] = m.group(2) + diff_mountpoint + m.group(3)
+ else:
+ config[index] = m.group(1)
+ elif line.startswith('#default_options='):
+ config[index] = line.removeprefix('#')
- self.helper_flags['bootloader'] = "efistub"
+ preset.write_text(''.join(config))
- return True
+ # Directory for the UKIs
+ uki_dir = self.target / efi_partition.relative_mountpoint / 'EFI/Linux'
+ uki_dir.mkdir(parents=True, exist_ok=True)
- def add_bootloader(self, bootloader :str = 'systemd-bootctl') -> bool:
+ # Build the UKIs
+ if not self.mkinitcpio(['-P']):
+ error('Error generating initramfs (continuing anyway)')
+
+ def add_bootloader(self, bootloader: Bootloader, uki_enabled: bool = False):
"""
Adds a bootloader to the installation instance.
Archinstall supports one of three types:
* systemd-bootctl
* grub
+ * limine (beta)
* efistub (beta)
- :param bootloader: Can be one of the three strings
- 'systemd-bootctl', 'grub' or 'efistub' (beta)
+ :param bootloader: Type of bootloader to be added
"""
for plugin in plugins.values():
@@ -1054,61 +1428,41 @@ class Installer:
if plugin.on_add_bootloader(self):
return True
- if type(self.target) == str:
- self.target = pathlib.Path(self.target)
+ efi_partition = self._get_efi_partition()
+ boot_partition = self._get_boot_partition()
+ root = self._get_root()
- boot_partition = None
- root_partition = None
- for partition in self.partitions:
- if self.target / 'boot' in partition.mountpoints:
- boot_partition = partition
- elif self.target in partition.mountpoints:
- root_partition = partition
+ if boot_partition is None:
+ raise ValueError(f'Could not detect boot at mountpoint {self.target}')
- if boot_partition is None or root_partition is None:
- raise ValueError(f"Could not detect root ({root_partition}) or boot ({boot_partition}) in {self.target} based on: {self.partitions}")
+ if root is None:
+ raise ValueError(f'Could not detect root at mountpoint {self.target}')
- self.log(f'Adding bootloader {bootloader} to {boot_partition if boot_partition else root_partition}', level=logging.INFO)
+ info(f'Adding bootloader {bootloader.value} to {boot_partition.dev_path}')
- if bootloader == 'systemd-bootctl':
- self.add_systemd_bootloader(boot_partition, root_partition)
- elif bootloader == "grub-install":
- self.add_grub_bootloader(boot_partition, root_partition)
- elif bootloader == 'efistub':
- self.add_efistub_bootloader(boot_partition, root_partition)
- else:
- raise RequirementError(f"Unknown (or not yet implemented) bootloader requested: {bootloader}")
+ if uki_enabled:
+ self._config_uki(root, efi_partition)
- return True
-
- def add_additional_packages(self, *packages :str) -> bool:
- return self.pacstrap(*packages)
+ match bootloader:
+ case Bootloader.Systemd:
+ self._add_systemd_bootloader(boot_partition, root, efi_partition, uki_enabled)
+ case Bootloader.Grub:
+ self._add_grub_bootloader(boot_partition, root, efi_partition)
+ case Bootloader.Efistub:
+ self._add_efistub_bootloader(boot_partition, root, uki_enabled)
+ case Bootloader.Limine:
+ self._add_limine_bootloader(boot_partition, efi_partition, root)
- def install_profile(self, profile :str) -> ModuleType:
- """
- Installs a archinstall profile script (.py file).
- This profile can be either local, remote or part of the library.
+ def add_additional_packages(self, packages: Union[str, List[str]]) -> bool:
+ return self.pacman.strap(packages)
- :param profile: Can be a local path or a remote path (URL)
- :return: Returns the imported script as a module, this way
- you can access any remaining functions exposed by the profile.
- :rtype: module
- """
- storage['installation_session'] = self
-
- if type(profile) == str:
- profile = Profile(self, profile)
-
- self.log(f'Installing archinstall profile {profile}', level=logging.INFO)
- return profile.install()
-
- def enable_sudo(self, entity: str, group :bool = False):
- self.log(f'Enabling sudo permissions for {entity}.', level=logging.INFO)
+ def enable_sudo(self, entity: str, group: bool = False):
+ info(f'Enabling sudo permissions for {entity}')
sudoers_dir = f"{self.target}/etc/sudoers.d"
# Creates directory if not exists
- if not (sudoers_path := pathlib.Path(sudoers_dir)).exists():
+ if not (sudoers_path := Path(sudoers_dir)).exists():
sudoers_path.mkdir(parents=True)
# Guarantees sudoer confs directory recommended perms
os.chmod(sudoers_dir, 0o440)
@@ -1118,7 +1472,7 @@ class Installer:
# We count how many files are there already so we know which number to prefix the file with
num_of_rules_already = len(os.listdir(sudoers_dir))
- file_num_str = "{:02d}".format(num_of_rules_already) # We want 00_user1, 01_user2, etc
+ file_num_str = "{:02d}".format(num_of_rules_already) # We want 00_user1, 01_user2, etc
# Guarantees that entity str does not contain invalid characters for a linux file name:
# \ / : * ? " < > |
@@ -1130,7 +1484,7 @@ class Installer:
sudoers.write(f'{"%" if group else ""}{entity} ALL=(ALL) ALL\n')
# Guarantees sudoer conf file recommended perms
- os.chmod(pathlib.Path(rule_file_name), 0o440)
+ os.chmod(Path(rule_file_name), 0o440)
def create_users(self, users: Union[User, List[User]]):
if not isinstance(users, list):
@@ -1139,7 +1493,8 @@ class Installer:
for user in users:
self.user_create(user.username, user.password, user.groups, user.sudo)
- def user_create(self, user :str, password :Optional[str] = None, groups :Optional[List[str]] = None, sudo :bool = False) -> None:
+ def user_create(self, user: str, password: Optional[str] = None, groups: Optional[List[str]] = None,
+ sudo: bool = False) -> None:
if groups is None:
groups = []
@@ -1152,11 +1507,11 @@ class Installer:
handled_by_plugin = result
if not handled_by_plugin:
- self.log(f'Creating user {user}', level=logging.INFO)
+ info(f'Creating user {user}')
try:
SysCommand(f'/usr/bin/arch-chroot {self.target} useradd -m -G wheel {user}')
- except SysCallError as error:
- raise SystemError(f"Could not create user inside installation: {error}")
+ except SysCallError as err:
+ raise SystemError(f"Could not create user inside installation: {err}")
for plugin in plugins.values():
if hasattr(plugin, 'on_user_created'):
@@ -1173,8 +1528,8 @@ class Installer:
if sudo and self.enable_sudo(user):
self.helper_flags['user'] = True
- def user_set_pw(self, user :str, password :str) -> bool:
- self.log(f'Setting password for {user}', level=logging.INFO)
+ def user_set_pw(self, user: str, password: str) -> bool:
+ info(f'Setting password for {user}')
if user == 'root':
# This means the root account isn't locked/disabled with * in /etc/passwd
@@ -1190,8 +1545,8 @@ class Installer:
except SysCallError:
return False
- def user_set_shell(self, user :str, shell :str) -> bool:
- self.log(f'Setting shell for {user} to {shell}', level=logging.INFO)
+ def user_set_shell(self, user: str, shell: str) -> bool:
+ info(f'Setting shell for {user} to {shell}')
try:
SysCommand(f"/usr/bin/arch-chroot {self.target} sh -c \"chsh -s {shell} {user}\"")
@@ -1199,7 +1554,7 @@ class Installer:
except SysCallError:
return False
- def chown(self, owner :str, path :str, options :List[str] = []) -> bool:
+ def chown(self, owner: str, path: str, options: List[str] = []) -> bool:
cleaned_path = path.replace('\'', '\\\'')
try:
SysCommand(f"/usr/bin/arch-chroot {self.target} sh -c 'chown {' '.join(options)} {owner} {cleaned_path}'")
@@ -1207,53 +1562,75 @@ class Installer:
except SysCallError:
return False
- def create_file(self, filename :str, owner :Optional[str] = None) -> InstallationFile:
- return InstallationFile(self, filename, owner)
-
def set_keyboard_language(self, language: str) -> bool:
- log(f"Setting keyboard language to {language}", level=logging.INFO)
+ info(f"Setting keyboard language to {language}")
+
if len(language.strip()):
if not verify_keyboard_layout(language):
- self.log(f"Invalid keyboard language specified: {language}", fg="red", level=logging.ERROR)
+ error(f"Invalid keyboard language specified: {language}")
return False
# In accordance with https://github.com/archlinux/archinstall/issues/107#issuecomment-841701968
# Setting an empty keymap first, allows the subsequent call to set layout for both console and x11.
- from .systemd import Boot
+ from .boot import Boot
with Boot(self) as session:
os.system('/usr/bin/systemd-run --machine=archinstall --pty localectl set-keymap ""')
try:
session.SysCommand(["localectl", "set-keymap", language])
- except SysCallError as error:
- raise ServiceException(f"Unable to set locale '{language}' for console: {error}")
+ except SysCallError as err:
+ raise ServiceException(f"Unable to set locale '{language}' for console: {err}")
- self.log(f"Keyboard language for this installation is now set to: {language}")
+ info(f"Keyboard language for this installation is now set to: {language}")
else:
- self.log('Keyboard language was not changed from default (no language specified).', fg="yellow", level=logging.INFO)
+ info('Keyboard language was not changed from default (no language specified)')
return True
def set_x11_keyboard_language(self, language: str) -> bool:
- log(f"Setting x11 keyboard language to {language}", level=logging.INFO)
"""
A fallback function to set x11 layout specifically and separately from console layout.
This isn't strictly necessary since .set_keyboard_language() does this as well.
"""
+ info(f"Setting x11 keyboard language to {language}")
+
if len(language.strip()):
if not verify_x11_keyboard_layout(language):
- self.log(f"Invalid x11-keyboard language specified: {language}", fg="red", level=logging.ERROR)
+ error(f"Invalid x11-keyboard language specified: {language}")
return False
- from .systemd import Boot
+ from .boot import Boot
with Boot(self) as session:
session.SysCommand(["localectl", "set-x11-keymap", '""'])
try:
session.SysCommand(["localectl", "set-x11-keymap", language])
- except SysCallError as error:
- raise ServiceException(f"Unable to set locale '{language}' for X11: {error}")
+ except SysCallError as err:
+ raise ServiceException(f"Unable to set locale '{language}' for X11: {err}")
else:
- self.log(f'X11-Keyboard language was not changed from default (no language specified).', fg="yellow", level=logging.INFO)
+ info(f'X11-Keyboard language was not changed from default (no language specified)')
return True
+
+ def _service_started(self, service_name: str) -> Optional[str]:
+ if os.path.splitext(service_name)[1] not in ('.service', '.target', '.timer'):
+ service_name += '.service' # Just to be safe
+
+ last_execution_time = SysCommand(
+ f"systemctl show --property=ActiveEnterTimestamp --no-pager {service_name}",
+ environment_vars={'SYSTEMD_COLORS': '0'}
+ ).decode().lstrip('ActiveEnterTimestamp=')
+
+ if not last_execution_time:
+ return None
+
+ return last_execution_time
+
+ def _service_state(self, service_name: str) -> str:
+ if os.path.splitext(service_name)[1] not in ('.service', '.target', '.timer'):
+ service_name += '.service' # Just to be safe
+
+ return SysCommand(
+ f'systemctl show --no-pager -p SubState --value {service_name}',
+ environment_vars={'SYSTEMD_COLORS': '0'}
+ ).decode()
diff --git a/archinstall/lib/interactions/__init__.py b/archinstall/lib/interactions/__init__.py
new file mode 100644
index 00000000..4b696a78
--- /dev/null
+++ b/archinstall/lib/interactions/__init__.py
@@ -0,0 +1,19 @@
+from .manage_users_conf import UserList, ask_for_additional_users
+from .network_menu import ManualNetworkConfig, ask_to_configure_network
+from .utils import get_password
+
+from .disk_conf import (
+ select_devices, select_disk_config, get_default_partition_layout,
+ select_main_filesystem_format, suggest_single_disk_layout,
+ suggest_multi_disk_layout
+)
+
+from .general_conf import (
+ ask_ntp, ask_hostname, ask_for_a_timezone, ask_for_audio_selection,
+ select_archinstall_language, ask_additional_packages_to_install,
+ add_number_of_parallel_downloads, select_additional_repositories
+)
+
+from .system_conf import (
+ select_kernel, ask_for_bootloader, ask_for_uki, select_driver, ask_for_swap
+)
diff --git a/archinstall/lib/interactions/disk_conf.py b/archinstall/lib/interactions/disk_conf.py
new file mode 100644
index 00000000..4fce4fe5
--- /dev/null
+++ b/archinstall/lib/interactions/disk_conf.py
@@ -0,0 +1,572 @@
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Any, TYPE_CHECKING
+from typing import Optional, List, Tuple
+
+from .. import disk
+from ..disk.device_model import BtrfsMountOption
+from ..hardware import SysInfo
+from ..menu import Menu
+from ..menu import TableMenu
+from ..menu.menu import MenuSelectionType
+from ..output import FormattedOutput, debug
+from ..utils.util import prompt_dir
+from ..storage import storage
+
+if TYPE_CHECKING:
+ _: Any
+
+
+def select_devices(preset: List[disk.BDevice] = []) -> List[disk.BDevice]:
+ """
+ Asks the user to select one or multiple devices
+
+ :return: List of selected devices
+ :rtype: list
+ """
+
+ def _preview_device_selection(selection: disk._DeviceInfo) -> Optional[str]:
+ dev = disk.device_handler.get_device(selection.path)
+ if dev and dev.partition_infos:
+ return FormattedOutput.as_table(dev.partition_infos)
+ return None
+
+ if preset is None:
+ preset = []
+
+ title = str(_('Select one or more devices to use and configure'))
+ warning = str(_('If you reset the device selection this will also reset the current disk layout. Are you sure?'))
+
+ devices = disk.device_handler.devices
+ options = [d.device_info for d in devices]
+ preset_value = [p.device_info for p in preset]
+
+ choice = TableMenu(
+ title,
+ data=options,
+ multi=True,
+ preset=preset_value,
+ preview_command=_preview_device_selection,
+ preview_title=str(_('Existing Partitions')),
+ preview_size=0.2,
+ allow_reset=True,
+ allow_reset_warning_msg=warning
+ ).run()
+
+ match choice.type_:
+ case MenuSelectionType.Reset: return []
+ case MenuSelectionType.Skip: return preset
+ case MenuSelectionType.Selection:
+ selected_device_info: List[disk._DeviceInfo] = choice.single_value
+ selected_devices = []
+
+ for device in devices:
+ if device.device_info in selected_device_info:
+ selected_devices.append(device)
+
+ return selected_devices
+
+
+def get_default_partition_layout(
+ devices: List[disk.BDevice],
+ filesystem_type: Optional[disk.FilesystemType] = None,
+ advanced_option: bool = False
+) -> List[disk.DeviceModification]:
+ if len(devices) == 1:
+ device_modification = suggest_single_disk_layout(
+ devices[0],
+ filesystem_type=filesystem_type,
+ advanced_options=advanced_option
+ )
+ return [device_modification]
+ else:
+ return suggest_multi_disk_layout(
+ devices,
+ filesystem_type=filesystem_type,
+ advanced_options=advanced_option
+ )
+
+
+def _manual_partitioning(
+ preset: List[disk.DeviceModification],
+ devices: List[disk.BDevice]
+) -> List[disk.DeviceModification]:
+ modifications = []
+ for device in devices:
+ mod = next(filter(lambda x: x.device == device, preset), None)
+ if not mod:
+ mod = disk.DeviceModification(device, wipe=False)
+
+ if partitions := disk.manual_partitioning(device, preset=mod.partitions):
+ mod.partitions = partitions
+ modifications.append(mod)
+
+ return modifications
+
+
+def select_disk_config(
+ preset: Optional[disk.DiskLayoutConfiguration] = None,
+ advanced_option: bool = False
+) -> Optional[disk.DiskLayoutConfiguration]:
+ default_layout = disk.DiskLayoutType.Default.display_msg()
+ manual_mode = disk.DiskLayoutType.Manual.display_msg()
+ pre_mount_mode = disk.DiskLayoutType.Pre_mount.display_msg()
+
+ options = [default_layout, manual_mode, pre_mount_mode]
+ preset_value = preset.config_type.display_msg() if preset else None
+ warning = str(_('Are you sure you want to reset this setting?'))
+
+ choice = Menu(
+ _('Select a partitioning option'),
+ options,
+ allow_reset=True,
+ allow_reset_warning_msg=warning,
+ sort=False,
+ preview_size=0.2,
+ preset_values=preset_value
+ ).run()
+
+ match choice.type_:
+ case MenuSelectionType.Skip: return preset
+ case MenuSelectionType.Reset: return None
+ case MenuSelectionType.Selection:
+ if choice.single_value == pre_mount_mode:
+ output = 'You will use whatever drive-setup is mounted at the specified directory\n'
+ output += "WARNING: Archinstall won't check the suitability of this setup\n"
+
+ try:
+ path = prompt_dir(str(_('Enter the root directory of the mounted devices: ')), output)
+ except (KeyboardInterrupt, EOFError):
+ return preset
+ mods = disk.device_handler.detect_pre_mounted_mods(path)
+
+ storage['MOUNT_POINT'] = Path(path)
+
+ return disk.DiskLayoutConfiguration(
+ config_type=disk.DiskLayoutType.Pre_mount,
+ device_modifications=mods,
+ mountpoint=path
+ )
+
+ preset_devices = [mod.device for mod in preset.device_modifications] if preset else []
+ devices = select_devices(preset_devices)
+
+ if not devices:
+ return None
+
+ if choice.value == default_layout:
+ modifications = get_default_partition_layout(devices, advanced_option=advanced_option)
+ if modifications:
+ return disk.DiskLayoutConfiguration(
+ config_type=disk.DiskLayoutType.Default,
+ device_modifications=modifications
+ )
+ elif choice.value == manual_mode:
+ preset_mods = preset.device_modifications if preset else []
+ modifications = _manual_partitioning(preset_mods, devices)
+
+ if modifications:
+ return disk.DiskLayoutConfiguration(
+ config_type=disk.DiskLayoutType.Manual,
+ device_modifications=modifications
+ )
+
+ return None
+
+
+def select_lvm_config(
+ disk_config: disk.DiskLayoutConfiguration,
+ preset: Optional[disk.LvmConfiguration] = None,
+) -> Optional[disk.LvmConfiguration]:
+ default_mode = disk.LvmLayoutType.Default.display_msg()
+
+ options = [default_mode]
+
+ preset_value = preset.config_type.display_msg() if preset else None
+ warning = str(_('Are you sure you want to reset this setting?'))
+
+ choice = Menu(
+ _('Select a LVM option'),
+ options,
+ allow_reset=True,
+ allow_reset_warning_msg=warning,
+ sort=False,
+ preview_size=0.2,
+ preset_values=preset_value
+ ).run()
+
+ match choice.type_:
+ case MenuSelectionType.Skip: return preset
+ case MenuSelectionType.Reset: return None
+ case MenuSelectionType.Selection:
+ if choice.single_value == default_mode:
+ return suggest_lvm_layout(disk_config)
+ return preset
+
+
+def _boot_partition(sector_size: disk.SectorSize, using_gpt: bool) -> disk.PartitionModification:
+ flags = [disk.PartitionFlag.Boot]
+ if using_gpt:
+ start = disk.Size(1, disk.Unit.MiB, sector_size)
+ size = disk.Size(1, disk.Unit.GiB, sector_size)
+ flags.append(disk.PartitionFlag.ESP)
+ else:
+ start = disk.Size(3, disk.Unit.MiB, sector_size)
+ size = disk.Size(203, disk.Unit.MiB, sector_size)
+
+ # boot partition
+ return disk.PartitionModification(
+ status=disk.ModificationStatus.Create,
+ type=disk.PartitionType.Primary,
+ start=start,
+ length=size,
+ mountpoint=Path('/boot'),
+ fs_type=disk.FilesystemType.Fat32,
+ flags=flags
+ )
+
+
+def select_main_filesystem_format(advanced_options: bool = False) -> disk.FilesystemType:
+ options = {
+ 'btrfs': disk.FilesystemType.Btrfs,
+ 'ext4': disk.FilesystemType.Ext4,
+ 'xfs': disk.FilesystemType.Xfs,
+ 'f2fs': disk.FilesystemType.F2fs
+ }
+
+ if advanced_options:
+ options.update({'ntfs': disk.FilesystemType.Ntfs})
+
+ prompt = _('Select which filesystem your main partition should use')
+ choice = Menu(prompt, options, skip=False, sort=False).run()
+ return options[choice.single_value]
+
+
+def select_mount_options() -> List[str]:
+ prompt = str(_('Would you like to use compression or disable CoW?'))
+ options = [str(_('Use compression')), str(_('Disable Copy-on-Write'))]
+ choice = Menu(prompt, options, sort=False).run()
+
+ if choice.type_ == MenuSelectionType.Selection:
+ if choice.single_value == options[0]:
+ return [BtrfsMountOption.compress.value]
+ else:
+ return [BtrfsMountOption.nodatacow.value]
+
+ return []
+
+
+def process_root_partition_size(available_space: disk.Size, sector_size: disk.SectorSize) -> disk.Size:
+ # root partition size processing
+ total_device_size = available_space.convert(disk.Unit.GiB)
+ if total_device_size.value > 500:
+ # maximum size
+ return disk.Size(value=50, unit=disk.Unit.GiB, sector_size=sector_size)
+ elif total_device_size.value < 200:
+ # minimum size
+ return disk.Size(value=20, unit=disk.Unit.GiB, sector_size=sector_size)
+ else:
+ # 10% of total size
+ length = total_device_size.value // 10
+ return disk.Size(value=length, unit=disk.Unit.GiB, sector_size=sector_size)
+
+
+def suggest_single_disk_layout(
+ device: disk.BDevice,
+ filesystem_type: Optional[disk.FilesystemType] = None,
+ advanced_options: bool = False,
+ separate_home: Optional[bool] = None
+) -> disk.DeviceModification:
+ if not filesystem_type:
+ filesystem_type = select_main_filesystem_format(advanced_options)
+
+ sector_size = device.device_info.sector_size
+ total_size = device.device_info.total_size
+ min_size_to_allow_home_part = disk.Size(40, disk.Unit.GiB, sector_size)
+ root_partition_size = process_root_partition_size(available_space=total_size, sector_size=sector_size)
+ using_subvolumes = False
+ using_home_partition = False
+ mount_options = []
+ device_size_gib = device.device_info.total_size
+
+ if filesystem_type == disk.FilesystemType.Btrfs:
+ prompt = str(_('Would you like to use BTRFS subvolumes with a default structure?'))
+ choice = Menu(prompt, Menu.yes_no(), skip=False, default_option=Menu.yes()).run()
+ using_subvolumes = choice.value == Menu.yes()
+ mount_options = select_mount_options()
+
+ device_modification = disk.DeviceModification(device, wipe=True)
+
+ using_gpt = SysInfo.has_uefi()
+
+ # Used for reference: https://wiki.archlinux.org/title/partitioning
+ # 2 MiB is unallocated for GRUB on BIOS. Potentially unneeded for other bootloaders?
+
+ # TODO: On BIOS, /boot partition is only needed if the drive will
+ # be encrypted, otherwise it is not recommended. We should probably
+ # add a check for whether the drive will be encrypted or not.
+
+ # Increase the UEFI partition if UEFI is detected.
+ # Also re-align the start to 1MiB since we don't need the first sectors
+ # like we do in MBR layouts where the boot loader is installed traditionally.
+
+ boot_partition = _boot_partition(sector_size, using_gpt)
+ device_modification.add_partition(boot_partition)
+
+ if not using_subvolumes:
+ if device_size_gib >= min_size_to_allow_home_part:
+ if separate_home is None:
+ prompt = str(_('Would you like to create a separate partition for /home?'))
+ choice = Menu(prompt, Menu.yes_no(), skip=False, default_option=Menu.yes()).run()
+ using_home_partition = choice.value == Menu.yes()
+ elif separate_home is True:
+ using_home_partition = True
+ else:
+ using_home_partition = False
+
+ align_buffer = disk.Size(1, disk.Unit.MiB, sector_size)
+
+ # root partition
+ root_start = boot_partition.start + boot_partition.length
+
+ # Set a size for / (/root)
+ if using_subvolumes or device_size_gib < min_size_to_allow_home_part or not using_home_partition:
+ root_length = total_size - root_start
+ else:
+ root_length = min(total_size, root_partition_size)
+
+ if using_gpt and not using_home_partition:
+ root_length -= align_buffer
+
+ root_partition = disk.PartitionModification(
+ status=disk.ModificationStatus.Create,
+ type=disk.PartitionType.Primary,
+ start=root_start,
+ length=root_length,
+ mountpoint=Path('/') if not using_subvolumes else None,
+ fs_type=filesystem_type,
+ mount_options=mount_options
+ )
+
+ device_modification.add_partition(root_partition)
+
+ if using_subvolumes:
+ # https://btrfs.wiki.kernel.org/index.php/FAQ
+ # https://unix.stackexchange.com/questions/246976/btrfs-subvolume-uuid-clash
+ # https://github.com/classy-giraffe/easy-arch/blob/main/easy-arch.sh
+ subvolumes = [
+ disk.SubvolumeModification(Path('@'), Path('/')),
+ disk.SubvolumeModification(Path('@home'), Path('/home')),
+ disk.SubvolumeModification(Path('@log'), Path('/var/log')),
+ disk.SubvolumeModification(Path('@pkg'), Path('/var/cache/pacman/pkg')),
+ disk.SubvolumeModification(Path('@.snapshots'), Path('/.snapshots'))
+ ]
+ root_partition.btrfs_subvols = subvolumes
+ elif using_home_partition:
+ # If we don't want to use subvolumes,
+ # But we want to be able to reuse data between re-installs..
+ # A second partition for /home would be nice if we have the space for it
+ home_start = root_partition.start + root_partition.length
+ home_length = total_size - home_start
+
+ if using_gpt:
+ home_length -= align_buffer
+
+ home_partition = disk.PartitionModification(
+ status=disk.ModificationStatus.Create,
+ type=disk.PartitionType.Primary,
+ start=home_start,
+ length=home_length,
+ mountpoint=Path('/home'),
+ fs_type=filesystem_type,
+ mount_options=mount_options
+ )
+ device_modification.add_partition(home_partition)
+
+ return device_modification
+
+
+def suggest_multi_disk_layout(
+ devices: List[disk.BDevice],
+ filesystem_type: Optional[disk.FilesystemType] = None,
+ advanced_options: bool = False
+) -> List[disk.DeviceModification]:
+ if not devices:
+ return []
+
+ # Not really a rock solid foundation of information to stand on, but it's a start:
+ # https://www.reddit.com/r/btrfs/comments/m287gp/partition_strategy_for_two_physical_disks/
+ # https://www.reddit.com/r/btrfs/comments/9us4hr/what_is_your_btrfs_partitionsubvolumes_scheme/
+ min_home_partition_size = disk.Size(40, disk.Unit.GiB, disk.SectorSize.default())
+ # rough estimate taking in to account user desktops etc. TODO: Catch user packages to detect size?
+ desired_root_partition_size = disk.Size(20, disk.Unit.GiB, disk.SectorSize.default())
+ mount_options = []
+
+ if not filesystem_type:
+ filesystem_type = select_main_filesystem_format(advanced_options)
+
+ # find proper disk for /home
+ possible_devices = list(filter(lambda x: x.device_info.total_size >= min_home_partition_size, devices))
+ home_device = max(possible_devices, key=lambda d: d.device_info.total_size) if possible_devices else None
+
+ # find proper device for /root
+ devices_delta = {}
+ for device in devices:
+ if device is not home_device:
+ delta = device.device_info.total_size - desired_root_partition_size
+ devices_delta[device] = delta
+
+ sorted_delta: List[Tuple[disk.BDevice, Any]] = sorted(devices_delta.items(), key=lambda x: x[1])
+ root_device: Optional[disk.BDevice] = sorted_delta[0][0]
+
+ if home_device is None or root_device is None:
+ text = _('The selected drives do not have the minimum capacity required for an automatic suggestion\n')
+ text += _('Minimum capacity for /home partition: {}GiB\n').format(min_home_partition_size.format_size(disk.Unit.GiB))
+ text += _('Minimum capacity for Arch Linux partition: {}GiB').format(desired_root_partition_size.format_size(disk.Unit.GiB))
+ Menu(str(text), [str(_('Continue'))], skip=False).run()
+ return []
+
+ if filesystem_type == disk.FilesystemType.Btrfs:
+ mount_options = select_mount_options()
+
+ device_paths = ', '.join([str(d.device_info.path) for d in devices])
+
+ debug(f'Suggesting multi-disk-layout for devices: {device_paths}')
+ debug(f'/root: {root_device.device_info.path}')
+ debug(f'/home: {home_device.device_info.path}')
+
+ root_device_modification = disk.DeviceModification(root_device, wipe=True)
+ home_device_modification = disk.DeviceModification(home_device, wipe=True)
+
+ root_device_sector_size = root_device_modification.device.device_info.sector_size
+ home_device_sector_size = home_device_modification.device.device_info.sector_size
+
+ root_align_buffer = disk.Size(1, disk.Unit.MiB, root_device_sector_size)
+ home_align_buffer = disk.Size(1, disk.Unit.MiB, home_device_sector_size)
+
+ using_gpt = SysInfo.has_uefi()
+
+ # add boot partition to the root device
+ boot_partition = _boot_partition(root_device_sector_size, using_gpt)
+ root_device_modification.add_partition(boot_partition)
+
+ root_start = boot_partition.start + boot_partition.length
+ root_length = root_device.device_info.total_size - root_start
+
+ if using_gpt:
+ root_length -= root_align_buffer
+
+ # add root partition to the root device
+ root_partition = disk.PartitionModification(
+ status=disk.ModificationStatus.Create,
+ type=disk.PartitionType.Primary,
+ start=root_start,
+ length=root_length,
+ mountpoint=Path('/'),
+ mount_options=mount_options,
+ fs_type=filesystem_type
+ )
+ root_device_modification.add_partition(root_partition)
+
+ home_start = home_align_buffer
+ home_length = home_device.device_info.total_size - home_start
+
+ if using_gpt:
+ home_length -= home_align_buffer
+
+ # add home partition to home device
+ home_partition = disk.PartitionModification(
+ status=disk.ModificationStatus.Create,
+ type=disk.PartitionType.Primary,
+ start=home_start,
+ length=home_length,
+ mountpoint=Path('/home'),
+ mount_options=mount_options,
+ fs_type=filesystem_type,
+ )
+ home_device_modification.add_partition(home_partition)
+
+ return [root_device_modification, home_device_modification]
+
+
+def suggest_lvm_layout(
+ disk_config: disk.DiskLayoutConfiguration,
+ filesystem_type: Optional[disk.FilesystemType] = None,
+ vg_grp_name: str = 'ArchinstallVg',
+) -> disk.LvmConfiguration:
+ if disk_config.config_type != disk.DiskLayoutType.Default:
+ raise ValueError('LVM suggested volumes are only available for default partitioning')
+
+ using_subvolumes = False
+ btrfs_subvols = []
+ home_volume = True
+ mount_options = []
+
+ if not filesystem_type:
+ filesystem_type = select_main_filesystem_format()
+
+ if filesystem_type == disk.FilesystemType.Btrfs:
+ prompt = str(_('Would you like to use BTRFS subvolumes with a default structure?'))
+ choice = Menu(prompt, Menu.yes_no(), skip=False, default_option=Menu.yes()).run()
+ using_subvolumes = choice.value == Menu.yes()
+
+ mount_options = select_mount_options()
+
+ if using_subvolumes:
+ btrfs_subvols = [
+ disk.SubvolumeModification(Path('@'), Path('/')),
+ disk.SubvolumeModification(Path('@home'), Path('/home')),
+ disk.SubvolumeModification(Path('@log'), Path('/var/log')),
+ disk.SubvolumeModification(Path('@pkg'), Path('/var/cache/pacman/pkg')),
+ disk.SubvolumeModification(Path('@.snapshots'), Path('/.snapshots')),
+ ]
+
+ home_volume = False
+
+ boot_part: Optional[disk.PartitionModification] = None
+ other_part: List[disk.PartitionModification] = []
+
+ for mod in disk_config.device_modifications:
+ for part in mod.partitions:
+ if part.is_boot():
+ boot_part = part
+ else:
+ other_part.append(part)
+
+ if not boot_part:
+ raise ValueError('Unable to find boot partition in partition modifications')
+
+ total_vol_available = sum(
+ [p.length for p in other_part],
+ disk.Size(0, disk.Unit.B, disk.SectorSize.default()),
+ )
+ root_vol_size = disk.Size(20, disk.Unit.GiB, disk.SectorSize.default())
+ home_vol_size = total_vol_available - root_vol_size
+
+ lvm_vol_group = disk.LvmVolumeGroup(vg_grp_name, pvs=other_part, )
+
+ root_vol = disk.LvmVolume(
+ status=disk.LvmVolumeStatus.Create,
+ name='root',
+ fs_type=filesystem_type,
+ length=root_vol_size,
+ mountpoint=Path('/'),
+ btrfs_subvols=btrfs_subvols,
+ mount_options=mount_options
+ )
+
+ lvm_vol_group.volumes.append(root_vol)
+
+ if home_volume:
+ home_vol = disk.LvmVolume(
+ status=disk.LvmVolumeStatus.Create,
+ name='home',
+ fs_type=filesystem_type,
+ length=home_vol_size,
+ mountpoint=Path('/home'),
+ )
+
+ lvm_vol_group.volumes.append(home_vol)
+
+ return disk.LvmConfiguration(disk.LvmLayoutType.Default, [lvm_vol_group])
diff --git a/archinstall/lib/interactions/general_conf.py b/archinstall/lib/interactions/general_conf.py
new file mode 100644
index 00000000..a879552e
--- /dev/null
+++ b/archinstall/lib/interactions/general_conf.py
@@ -0,0 +1,209 @@
+from __future__ import annotations
+
+import pathlib
+from typing import List, Any, Optional, TYPE_CHECKING
+
+from ..locale import list_timezones
+from ..menu import MenuSelectionType, Menu, TextInput
+from ..models.audio_configuration import Audio, AudioConfiguration
+from ..output import warn
+from ..packages.packages import validate_package_list
+from ..storage import storage
+from ..translationhandler import Language
+
+if TYPE_CHECKING:
+ _: Any
+
+
+def ask_ntp(preset: bool = True) -> bool:
+ prompt = str(_('Would you like to use automatic time synchronization (NTP) with the default time servers?\n'))
+ prompt += str(_('Hardware time and other post-configuration steps might be required in order for NTP to work.\nFor more information, please check the Arch wiki'))
+ if preset:
+ preset_val = Menu.yes()
+ else:
+ preset_val = Menu.no()
+ choice = Menu(prompt, Menu.yes_no(), skip=False, preset_values=preset_val, default_option=Menu.yes()).run()
+
+ return False if choice.value == Menu.no() else True
+
+
+def ask_hostname(preset: str = '') -> str:
+ hostname = TextInput(
+ str(_('Desired hostname for the installation: ')),
+ preset
+ ).run().strip()
+
+ if not hostname:
+ return preset
+
+ return hostname
+
+
+def ask_for_a_timezone(preset: Optional[str] = None) -> Optional[str]:
+ timezones = list_timezones()
+ default = 'UTC'
+
+ choice = Menu(
+ _('Select a timezone'),
+ timezones,
+ preset_values=preset,
+ default_option=default
+ ).run()
+
+ match choice.type_:
+ case MenuSelectionType.Skip: return preset
+ case MenuSelectionType.Selection: return choice.single_value
+
+ return None
+
+
+def ask_for_audio_selection(
+ current: Optional[AudioConfiguration] = None
+) -> Optional[AudioConfiguration]:
+ choices = [
+ Audio.Pipewire.name,
+ Audio.Pulseaudio.name,
+ Audio.no_audio_text()
+ ]
+
+ preset = current.audio.name if current else None
+
+ choice = Menu(
+ _('Choose an audio server'),
+ choices,
+ preset_values=preset
+ ).run()
+
+ match choice.type_:
+ case MenuSelectionType.Skip: return current
+ case MenuSelectionType.Selection:
+ value = choice.single_value
+ if value == Audio.no_audio_text():
+ return None
+ else:
+ return AudioConfiguration(Audio[value])
+
+ return None
+
+
+def select_language(preset: Optional[str] = None) -> Optional[str]:
+ from ..locale.locale_menu import select_kb_layout
+
+ # We'll raise an exception in an upcoming version.
+ # from ..exceptions import Deprecated
+ # raise Deprecated("select_language() has been deprecated, use select_kb_layout() instead.")
+
+ # No need to translate this i feel, as it's a short lived message.
+ warn("select_language() is deprecated, use select_kb_layout() instead. select_language() will be removed in a future version")
+
+ return select_kb_layout(preset)
+
+
+def select_archinstall_language(languages: List[Language], preset: Language) -> Language:
+ # these are the displayed language names which can either be
+ # the english name of a language or, if present, the
+ # name of the language in its own language
+ options = {lang.display_name: lang for lang in languages}
+
+ title = 'NOTE: If a language can not displayed properly, a proper font must be set manually in the console.\n'
+ title += 'All available fonts can be found in "/usr/share/kbd/consolefonts"\n'
+ title += 'e.g. setfont LatGrkCyr-8x16 (to display latin/greek/cyrillic characters)\n'
+
+ choice = Menu(
+ title,
+ list(options.keys()),
+ default_option=preset.display_name,
+ preview_size=0.5
+ ).run()
+
+ match choice.type_:
+ case MenuSelectionType.Skip: return preset
+ case MenuSelectionType.Selection: return options[choice.single_value]
+
+ raise ValueError('Language selection not handled')
+
+
+def ask_additional_packages_to_install(preset: List[str] = []) -> List[str]:
+ # Additional packages (with some light weight error handling for invalid package names)
+ print(_('Only packages such as base, base-devel, linux, linux-firmware, efibootmgr and optional profile packages are installed.'))
+ print(_('If you desire a web browser, such as firefox or chromium, you may specify it in the following prompt.'))
+
+ def read_packages(p: List = []) -> list:
+ display = ' '.join(p)
+ input_packages = TextInput(_('Write additional packages to install (space separated, leave blank to skip): '), display).run().strip()
+ return input_packages.split() if input_packages else []
+
+ preset = preset if preset else []
+ packages = read_packages(preset)
+
+ if not storage['arguments']['offline'] and not storage['arguments']['no_pkg_lookups']:
+ while True:
+ if len(packages):
+ # Verify packages that were given
+ print(_("Verifying that additional packages exist (this might take a few seconds)"))
+ valid, invalid = validate_package_list(packages)
+
+ if invalid:
+ warn(f"Some packages could not be found in the repository: {invalid}")
+ packages = read_packages(valid)
+ continue
+ break
+
+ return packages
+
+
+def add_number_of_parallel_downloads(input_number :Optional[int] = None) -> Optional[int]:
+ max_recommended = 5
+ print(_(f"This option enables the number of parallel downloads that can occur during package downloads"))
+ print(_("Enter the number of parallel downloads to be enabled.\n\nNote:\n"))
+ print(str(_(" - Maximum recommended value : {} ( Allows {} parallel downloads at a time )")).format(max_recommended, max_recommended))
+ print(_(" - Disable/Default : 0 ( Disables parallel downloading, allows only 1 download at a time )\n"))
+
+ while True:
+ try:
+ input_number = int(TextInput(_("[Default value: 0] > ")).run().strip() or 0)
+ if input_number <= 0:
+ input_number = 0
+ break
+ except:
+ print(str(_("Invalid input! Try again with a valid input [or 0 to disable]")).format(max_recommended))
+
+ pacman_conf_path = pathlib.Path("/etc/pacman.conf")
+ with pacman_conf_path.open() as f:
+ pacman_conf = f.read().split("\n")
+
+ with pacman_conf_path.open("w") as fwrite:
+ for line in pacman_conf:
+ if "ParallelDownloads" in line:
+ fwrite.write(f"ParallelDownloads = {input_number}\n") if not input_number == 0 else fwrite.write("#ParallelDownloads = 0\n")
+ else:
+ fwrite.write(f"{line}\n")
+
+ return input_number
+
+
+def select_additional_repositories(preset: List[str]) -> List[str]:
+ """
+ Allows the user to select additional repositories (multilib, and testing) if desired.
+
+ :return: The string as a selected repository
+ :rtype: string
+ """
+
+ repositories = ["multilib", "testing"]
+
+ choice = Menu(
+ _('Choose which optional additional repositories to enable'),
+ repositories,
+ sort=False,
+ multi=True,
+ preset_values=preset,
+ allow_reset=True
+ ).run()
+
+ match choice.type_:
+ case MenuSelectionType.Skip: return preset
+ case MenuSelectionType.Reset: return []
+ case MenuSelectionType.Selection: return choice.single_value
+
+ return [] \ No newline at end of file
diff --git a/archinstall/lib/user_interaction/manage_users_conf.py b/archinstall/lib/interactions/manage_users_conf.py
index 84ce3556..886f85b6 100644
--- a/archinstall/lib/user_interaction/manage_users_conf.py
+++ b/archinstall/lib/interactions/manage_users_conf.py
@@ -1,13 +1,11 @@
from __future__ import annotations
import re
-from typing import Any, Dict, TYPE_CHECKING, List, Optional
+from typing import Any, TYPE_CHECKING, List, Optional
from .utils import get_password
-from ..menu import Menu
-from ..menu.list_manager import ListManager
+from ..menu import Menu, ListManager
from ..models.users import User
-from ..output import FormattedOutput
if TYPE_CHECKING:
_: Any
@@ -27,21 +25,6 @@ class UserList(ListManager):
]
super().__init__(prompt, lusers, [self._actions[0]], self._actions[1:])
- def reformat(self, data: List[User]) -> Dict[str, User]:
- table = FormattedOutput.as_table(data)
- rows = table.split('\n')
-
- # these are the header rows of the table and do not map to any User obviously
- # we're adding 2 spaces as prefix because the menu selector '> ' will be put before
- # the selectable rows so the header has to be aligned
- display_data = {f' {rows[0]}': None, f' {rows[1]}': None}
-
- for row, user in zip(rows[2:], data):
- row = row.replace('|', '\\|')
- display_data[row] = user
-
- return display_data
-
def selected_action_display(self, user: User) -> str:
return user.username
@@ -53,16 +36,16 @@ class UserList(ListManager):
# was created we'll replace the existing one
data = [d for d in data if d.username != new_user.username]
data += [new_user]
- elif action == self._actions[1]: # change password
+ elif action == self._actions[1] and entry: # change password
prompt = str(_('Password for user "{}": ').format(entry.username))
new_password = get_password(prompt=prompt)
if new_password:
user = next(filter(lambda x: x == entry, data))
user.password = new_password
- elif action == self._actions[2]: # promote/demote
+ elif action == self._actions[2] and entry: # promote/demote
user = next(filter(lambda x: x == entry, data))
user.sudo = False if user.sudo else True
- elif action == self._actions[3]: # delete
+ elif action == self._actions[3] and entry: # delete
data = [d for d in data if d != entry]
return data
@@ -76,20 +59,28 @@ class UserList(ListManager):
prompt = '\n\n' + str(_('Enter username (leave blank to skip): '))
while True:
- username = input(prompt).strip(' ')
+ try:
+ username = input(prompt).strip(' ')
+ except (KeyboardInterrupt, EOFError):
+ return None
+
if not username:
return None
if not self._check_for_correct_username(username):
- prompt = str(_("The username you entered is invalid. Try again")) + '\n' + prompt
+ error_prompt = str(_("The username you entered is invalid. Try again"))
+ print(error_prompt)
else:
break
password = get_password(prompt=str(_('Password for user "{}": ').format(username)))
+ if not password:
+ return None
+
choice = Menu(
str(_('Should "{}" be a superuser (sudo)?')).format(username), Menu.yes_no(),
skip=False,
- default_option=Menu.no(),
+ default_option=Menu.yes(),
clear_screen=False,
show_search_hint=False
).run()
diff --git a/archinstall/lib/user_interaction/network_conf.py b/archinstall/lib/interactions/network_menu.py
index 5e637f23..14fc5785 100644
--- a/archinstall/lib/user_interaction/network_conf.py
+++ b/archinstall/lib/interactions/network_menu.py
@@ -1,17 +1,14 @@
from __future__ import annotations
import ipaddress
-import logging
-from typing import Any, Optional, TYPE_CHECKING, List, Union, Dict
+from typing import Any, Optional, TYPE_CHECKING, List, Dict
-from ..menu.menu import MenuSelectionType
-from ..menu.text_input import TextInput
-from ..models.network_configuration import NetworkConfiguration, NicType
+from ..menu import MenuSelectionType, TextInput
+from ..models.network_configuration import NetworkConfiguration, NicType, Nic
from ..networking import list_interfaces
-from ..menu import Menu
-from ..output import log, FormattedOutput
-from ..menu.list_manager import ListManager
+from ..output import FormattedOutput, warn
+from ..menu import ListManager, Menu
if TYPE_CHECKING:
_: Any
@@ -22,23 +19,22 @@ class ManualNetworkConfig(ListManager):
subclass of ListManager for the managing of network configurations
"""
- def __init__(self, prompt: str, ifaces: List[NetworkConfiguration]):
+ def __init__(self, prompt: str, preset: List[Nic]):
self._actions = [
str(_('Add interface')),
str(_('Edit interface')),
str(_('Delete interface'))
]
+ super().__init__(prompt, preset, [self._actions[0]], self._actions[1:])
- super().__init__(prompt, ifaces, [self._actions[0]], self._actions[1:])
-
- def reformat(self, data: List[NetworkConfiguration]) -> Dict[str, Optional[NetworkConfiguration]]:
+ def reformat(self, data: List[Nic]) -> Dict[str, Optional[Nic]]:
table = FormattedOutput.as_table(data)
rows = table.split('\n')
# these are the header rows of the table and do not map to any User obviously
# we're adding 2 spaces as prefix because the menu selector '> ' will be put before
# the selectable rows so the header has to be aligned
- display_data: Dict[str, Optional[NetworkConfiguration]] = {f' {rows[0]}': None, f' {rows[1]}': None}
+ display_data: Dict[str, Optional[Nic]] = {f' {rows[0]}': None, f' {rows[1]}': None}
for row, iface in zip(rows[2:], data):
row = row.replace('|', '\\|')
@@ -46,16 +42,16 @@ class ManualNetworkConfig(ListManager):
return display_data
- def selected_action_display(self, iface: NetworkConfiguration) -> str:
- return iface.iface if iface.iface else ''
+ def selected_action_display(self, nic: Nic) -> str:
+ return nic.iface if nic.iface else ''
- def handle_action(self, action: str, entry: Optional[NetworkConfiguration], data: List[NetworkConfiguration]):
+ def handle_action(self, action: str, entry: Optional[Nic], data: List[Nic]):
if action == self._actions[0]: # add
- iface_name = self._select_iface(data)
- if iface_name:
- iface = NetworkConfiguration(NicType.MANUAL, iface=iface_name)
- iface = self._edit_iface(iface)
- data += [iface]
+ iface = self._select_iface(data)
+ if iface:
+ nic = Nic(iface=iface)
+ nic = self._edit_iface(nic)
+ data += [nic]
elif entry:
if action == self._actions[1]: # edit interface
data = [d for d in data if d.iface != entry.iface]
@@ -65,7 +61,7 @@ class ManualNetworkConfig(ListManager):
return data
- def _select_iface(self, data: List[NetworkConfiguration]) -> Optional[Any]:
+ def _select_iface(self, data: List[Nic]) -> Optional[str]:
all_ifaces = list_interfaces().values()
existing_ifaces = [d.iface for d in data]
available = set(all_ifaces) - set(existing_ifaces)
@@ -74,10 +70,10 @@ class ManualNetworkConfig(ListManager):
if choice.type_ == MenuSelectionType.Skip:
return None
- return choice.value
+ return choice.single_value
- def _edit_iface(self, edit_iface: NetworkConfiguration):
- iface_name = edit_iface.iface
+ def _edit_iface(self, edit_nic: Nic) -> Nic:
+ iface_name = edit_nic.iface
modes = ['DHCP (auto detect)', 'IP (static)']
default_mode = 'DHCP (auto detect)'
@@ -87,13 +83,13 @@ class ManualNetworkConfig(ListManager):
if mode.value == 'IP (static)':
while 1:
prompt = _('Enter the IP and subnet for {} (example: 192.168.0.5/24): ').format(iface_name)
- ip = TextInput(prompt, edit_iface.ip).run().strip()
+ ip = TextInput(prompt, edit_nic.ip).run().strip()
# Implemented new check for correct IP/subnet input
try:
ipaddress.ip_interface(ip)
break
except ValueError:
- log("You need to enter a valid IP in IP-config mode.", level=logging.WARNING, fg='red')
+ warn("You need to enter a valid IP in IP-config mode")
# Implemented new check for correct gateway IP address
gateway = None
@@ -101,17 +97,17 @@ class ManualNetworkConfig(ListManager):
while 1:
gateway = TextInput(
_('Enter your gateway (router) IP address or leave blank for none: '),
- edit_iface.gateway
+ edit_nic.gateway
).run().strip()
try:
if len(gateway) > 0:
ipaddress.ip_address(gateway)
break
except ValueError:
- log("You need to enter a valid gateway (router) IP address.", level=logging.WARNING, fg='red')
+ warn("You need to enter a valid gateway (router) IP address")
- if edit_iface.dns:
- display_dns = ' '.join(edit_iface.dns)
+ if edit_nic.dns:
+ display_dns = ' '.join(edit_nic.dns)
else:
display_dns = None
dns_input = TextInput(_('Enter your DNS servers (space separated, blank for none): '), display_dns).run().strip()
@@ -120,39 +116,24 @@ class ManualNetworkConfig(ListManager):
if len(dns_input):
dns = dns_input.split(' ')
- return NetworkConfiguration(NicType.MANUAL, iface=iface_name, ip=ip, gateway=gateway, dns=dns, dhcp=False)
+ return Nic(iface=iface_name, ip=ip, gateway=gateway, dns=dns, dhcp=False)
else:
# this will contain network iface names
- return NetworkConfiguration(NicType.MANUAL, iface=iface_name)
+ return Nic(iface=iface_name)
-def ask_to_configure_network(
- preset: Union[NetworkConfiguration, List[NetworkConfiguration]]
-) -> Optional[NetworkConfiguration | List[NetworkConfiguration]]:
+def ask_to_configure_network(preset: Optional[NetworkConfiguration]) -> Optional[NetworkConfiguration]:
"""
Configure the network on the newly installed system
"""
- network_options = {
- 'none': str(_('No network configuration')),
- 'iso_config': str(_('Copy ISO network configuration to installation')),
- 'network_manager': str(_('Use NetworkManager (necessary to configure internet graphically in GNOME and KDE)')),
- 'manual': str(_('Manual configuration'))
- }
- # for this routine it's easier to set the cursor position rather than a preset value
- cursor_idx = None
-
- if preset and not isinstance(preset, list):
- if preset.type == 'iso_config':
- cursor_idx = 0
- elif preset.type == 'network_manager':
- cursor_idx = 1
-
+ options = {n.display_msg(): n for n in NicType}
+ preset_val = preset.type.display_msg() if preset else None
warning = str(_('Are you sure you want to reset this setting?'))
choice = Menu(
_('Select one network interface to configure'),
- list(network_options.values()),
- cursor_index=cursor_idx,
+ list(options.keys()),
+ preset_values=preset_val,
sort=False,
allow_reset=True,
allow_reset_warning_msg=warning
@@ -161,15 +142,18 @@ def ask_to_configure_network(
match choice.type_:
case MenuSelectionType.Skip: return preset
case MenuSelectionType.Reset: return None
-
- if choice.value == network_options['none']:
- return None
- elif choice.value == network_options['iso_config']:
- return NetworkConfiguration(NicType.ISO)
- elif choice.value == network_options['network_manager']:
- return NetworkConfiguration(NicType.NM)
- elif choice.value == network_options['manual']:
- preset_ifaces = preset if isinstance(preset, list) else []
- return ManualNetworkConfig('Configure interfaces', preset_ifaces).run()
+ case MenuSelectionType.Selection:
+ nic_type = options[choice.single_value]
+
+ match nic_type:
+ case NicType.ISO:
+ return NetworkConfiguration(NicType.ISO)
+ case NicType.NM:
+ return NetworkConfiguration(NicType.NM)
+ case NicType.MANUAL:
+ preset_nics = preset.nics if preset else []
+ nics = ManualNetworkConfig('Configure interfaces', preset_nics).run()
+ if nics:
+ return NetworkConfiguration(NicType.MANUAL, nics)
return preset
diff --git a/archinstall/lib/interactions/system_conf.py b/archinstall/lib/interactions/system_conf.py
new file mode 100644
index 00000000..35ba5a8b
--- /dev/null
+++ b/archinstall/lib/interactions/system_conf.py
@@ -0,0 +1,138 @@
+from __future__ import annotations
+
+from typing import List, Any, TYPE_CHECKING, Optional
+
+from ..hardware import SysInfo, GfxDriver
+from ..menu import MenuSelectionType, Menu
+from ..models.bootloader import Bootloader
+
+if TYPE_CHECKING:
+ _: Any
+
+
+def select_kernel(preset: List[str] = []) -> List[str]:
+ """
+ Asks the user to select a kernel for system.
+
+ :return: The string as a selected kernel
+ :rtype: string
+ """
+
+ kernels = ["linux", "linux-lts", "linux-zen", "linux-hardened"]
+ default_kernel = "linux"
+
+ warning = str(_('Are you sure you want to reset this setting?'))
+
+ choice = Menu(
+ _('Choose which kernels to use or leave blank for default "{}"').format(default_kernel),
+ kernels,
+ sort=True,
+ multi=True,
+ preset_values=preset,
+ allow_reset_warning_msg=warning
+ ).run()
+
+ match choice.type_:
+ case MenuSelectionType.Skip: return preset
+ case MenuSelectionType.Selection: return choice.single_value
+
+ return []
+
+
+def ask_for_bootloader(preset: Bootloader) -> Bootloader:
+ # Systemd is UEFI only
+ if not SysInfo.has_uefi():
+ options = [Bootloader.Grub.value, Bootloader.Limine.value]
+ default = Bootloader.Grub.value
+ else:
+ options = Bootloader.values()
+ default = Bootloader.Systemd.value
+
+ preset_value = preset.value if preset else None
+
+ choice = Menu(
+ _('Choose a bootloader'),
+ options,
+ preset_values=preset_value,
+ sort=False,
+ default_option=default
+ ).run()
+
+ match choice.type_:
+ case MenuSelectionType.Skip: return preset
+ case MenuSelectionType.Selection: return Bootloader(choice.value)
+
+ return preset
+
+
+def ask_for_uki(preset: bool = True) -> bool:
+ if preset:
+ preset_val = Menu.yes()
+ else:
+ preset_val = Menu.no()
+
+ prompt = _('Would you like to use unified kernel images?')
+ choice = Menu(prompt, Menu.yes_no(), default_option=Menu.no(), preset_values=preset_val).run()
+
+ match choice.type_:
+ case MenuSelectionType.Skip: return preset
+ case MenuSelectionType.Selection: return False if choice.value == Menu.no() else True
+
+ return preset
+
+
+def select_driver(options: List[GfxDriver] = [], current_value: Optional[GfxDriver] = None) -> Optional[GfxDriver]:
+ """
+ Some what convoluted function, whose job is simple.
+ Select a graphics driver from a pre-defined set of popular options.
+
+ (The template xorg is for beginner users, not advanced, and should
+ there for appeal to the general public first and edge cases later)
+ """
+ if not options:
+ options = [driver for driver in GfxDriver]
+
+ drivers = sorted([o.value for o in options])
+
+ if drivers:
+ title = ''
+ if SysInfo.has_amd_graphics():
+ title += str(_('For the best compatibility with your AMD hardware, you may want to use either the all open-source or AMD / ATI options.')) + '\n'
+ if SysInfo.has_intel_graphics():
+ title += str(_('For the best compatibility with your Intel hardware, you may want to use either the all open-source or Intel options.\n'))
+ if SysInfo.has_nvidia_graphics():
+ title += str(_('For the best compatibility with your Nvidia hardware, you may want to use the Nvidia proprietary driver.\n'))
+
+ preset = current_value.value if current_value else None
+
+ choice = Menu(
+ title,
+ drivers,
+ preset_values=preset,
+ default_option=GfxDriver.AllOpenSource.value,
+ preview_command=lambda x: GfxDriver(x).packages_text(),
+ preview_size=0.3
+ ).run()
+
+ if choice.type_ != MenuSelectionType.Selection:
+ return current_value
+
+ return GfxDriver(choice.single_value)
+
+ return current_value
+
+
+def ask_for_swap(preset: bool = True) -> bool:
+ if preset:
+ preset_val = Menu.yes()
+ else:
+ preset_val = Menu.no()
+
+ prompt = _('Would you like to use swap on zram?')
+ choice = Menu(prompt, Menu.yes_no(), default_option=Menu.yes(), preset_values=preset_val).run()
+
+ match choice.type_:
+ case MenuSelectionType.Skip: return preset
+ case MenuSelectionType.Selection: return False if choice.value == Menu.no() else True
+
+ return preset
diff --git a/archinstall/lib/interactions/utils.py b/archinstall/lib/interactions/utils.py
new file mode 100644
index 00000000..fdbb4625
--- /dev/null
+++ b/archinstall/lib/interactions/utils.py
@@ -0,0 +1,39 @@
+from __future__ import annotations
+
+import getpass
+from typing import Any, Optional, TYPE_CHECKING
+
+from ..models import PasswordStrength
+from ..output import log, error
+
+if TYPE_CHECKING:
+ _: Any
+
+# used for signal handler
+SIG_TRIGGER = None
+
+
+def get_password(prompt: str = '') -> Optional[str]:
+ if not prompt:
+ prompt = _("Enter a password: ")
+
+ while True:
+ try:
+ password = getpass.getpass(prompt)
+ except (KeyboardInterrupt, EOFError):
+ break
+
+ if len(password.strip()) <= 0:
+ break
+
+ strength = PasswordStrength.strength(password)
+ log(f'Password strength: {strength.value}', fg=strength.color())
+
+ passwd_verification = getpass.getpass(prompt=_('And one more time for verification: '))
+ if password != passwd_verification:
+ error(' * Passwords did not match * ')
+ continue
+
+ return password
+
+ return None
diff --git a/archinstall/lib/locale/__init__.py b/archinstall/lib/locale/__init__.py
new file mode 100644
index 00000000..90f1aecc
--- /dev/null
+++ b/archinstall/lib/locale/__init__.py
@@ -0,0 +1,10 @@
+from .locale_menu import LocaleConfiguration
+from .utils import (
+ list_keyboard_languages,
+ list_locales,
+ list_x11_keyboard_languages,
+ verify_keyboard_layout,
+ verify_x11_keyboard_layout,
+ list_timezones,
+ set_kb_layout
+)
diff --git a/archinstall/lib/locale/locale_menu.py b/archinstall/lib/locale/locale_menu.py
new file mode 100644
index 00000000..db119f20
--- /dev/null
+++ b/archinstall/lib/locale/locale_menu.py
@@ -0,0 +1,158 @@
+from dataclasses import dataclass
+from typing import Dict, Any, TYPE_CHECKING, Optional
+
+from .utils import list_keyboard_languages, list_locales, set_kb_layout
+from ..menu import Selector, AbstractSubMenu, MenuSelectionType, Menu
+
+if TYPE_CHECKING:
+ _: Any
+
+
+@dataclass
+class LocaleConfiguration:
+ kb_layout: str
+ sys_lang: str
+ sys_enc: str
+
+ @staticmethod
+ def default() -> 'LocaleConfiguration':
+ return LocaleConfiguration('us', 'en_US', 'UTF-8')
+
+ def json(self) -> Dict[str, str]:
+ return {
+ 'kb_layout': self.kb_layout,
+ 'sys_lang': self.sys_lang,
+ 'sys_enc': self.sys_enc
+ }
+
+ @classmethod
+ def _load_config(cls, config: 'LocaleConfiguration', args: Dict[str, Any]) -> 'LocaleConfiguration':
+ if 'sys_lang' in args:
+ config.sys_lang = args['sys_lang']
+ if 'sys_enc' in args:
+ config.sys_enc = args['sys_enc']
+ if 'kb_layout' in args:
+ config.kb_layout = args['kb_layout']
+
+ return config
+
+ @classmethod
+ def parse_arg(cls, args: Dict[str, Any]) -> 'LocaleConfiguration':
+ default = cls.default()
+
+ if 'locale_config' in args:
+ default = cls._load_config(default, args['locale_config'])
+ else:
+ default = cls._load_config(default, args)
+
+ return default
+
+
+class LocaleMenu(AbstractSubMenu):
+ def __init__(
+ self,
+ data_store: Dict[str, Any],
+ locale_conf: LocaleConfiguration
+ ):
+ self._preset = locale_conf
+ super().__init__(data_store=data_store)
+
+ def setup_selection_menu_options(self):
+ self._menu_options['keyboard-layout'] = \
+ Selector(
+ _('Keyboard layout'),
+ lambda preset: self._select_kb_layout(preset),
+ default=self._preset.kb_layout,
+ enabled=True)
+ self._menu_options['sys-language'] = \
+ Selector(
+ _('Locale language'),
+ lambda preset: select_locale_lang(preset),
+ default=self._preset.sys_lang,
+ enabled=True)
+ self._menu_options['sys-encoding'] = \
+ Selector(
+ _('Locale encoding'),
+ lambda preset: select_locale_enc(preset),
+ default=self._preset.sys_enc,
+ enabled=True)
+
+ def run(self, allow_reset: bool = True) -> LocaleConfiguration:
+ super().run(allow_reset=allow_reset)
+
+ if not self._data_store:
+ return LocaleConfiguration.default()
+
+ return LocaleConfiguration(
+ self._data_store['keyboard-layout'],
+ self._data_store['sys-language'],
+ self._data_store['sys-encoding']
+ )
+
+ def _select_kb_layout(self, preset: Optional[str]) -> Optional[str]:
+ kb_lang = select_kb_layout(preset)
+ if kb_lang:
+ set_kb_layout(kb_lang)
+ return kb_lang
+
+
+def select_locale_lang(preset: Optional[str] = None) -> Optional[str]:
+ locales = list_locales()
+ locale_lang = set([locale.split()[0] for locale in locales])
+
+ choice = Menu(
+ _('Choose which locale language to use'),
+ list(locale_lang),
+ sort=True,
+ preset_values=preset
+ ).run()
+
+ match choice.type_:
+ case MenuSelectionType.Selection: return choice.single_value
+ case MenuSelectionType.Skip: return preset
+
+ return None
+
+
+def select_locale_enc(preset: Optional[str] = None) -> Optional[str]:
+ locales = list_locales()
+ locale_enc = set([locale.split()[1] for locale in locales])
+
+ choice = Menu(
+ _('Choose which locale encoding to use'),
+ list(locale_enc),
+ sort=True,
+ preset_values=preset
+ ).run()
+
+ match choice.type_:
+ case MenuSelectionType.Selection: return choice.single_value
+ case MenuSelectionType.Skip: return preset
+
+ return None
+
+
+def select_kb_layout(preset: Optional[str] = None) -> Optional[str]:
+ """
+ Asks the user to select a language
+ Usually this is combined with :ref:`archinstall.list_keyboard_languages`.
+
+ :return: The language/dictionary key of the selected language
+ :rtype: str
+ """
+ kb_lang = list_keyboard_languages()
+ # sort alphabetically and then by length
+ sorted_kb_lang = sorted(kb_lang, key=lambda x: (len(x), x))
+
+ choice = Menu(
+ _('Select keyboard layout'),
+ sorted_kb_lang,
+ preset_values=preset,
+ sort=False
+ ).run()
+
+ match choice.type_:
+ case MenuSelectionType.Skip: return preset
+ case MenuSelectionType.Selection: return choice.single_value
+
+ return None
diff --git a/archinstall/lib/locale/utils.py b/archinstall/lib/locale/utils.py
new file mode 100644
index 00000000..d7641d50
--- /dev/null
+++ b/archinstall/lib/locale/utils.py
@@ -0,0 +1,67 @@
+from typing import List
+
+from ..exceptions import ServiceException, SysCallError
+from ..general import SysCommand
+from ..output import error
+
+
+def list_keyboard_languages() -> List[str]:
+ return SysCommand(
+ "localectl --no-pager list-keymaps",
+ environment_vars={'SYSTEMD_COLORS': '0'}
+ ).decode().splitlines()
+
+
+def list_locales() -> List[str]:
+ locales = []
+
+ with open('/usr/share/i18n/SUPPORTED') as file:
+ for line in file:
+ if line != 'C.UTF-8 UTF-8\n':
+ locales.append(line.rstrip())
+
+ return locales
+
+
+def list_x11_keyboard_languages() -> List[str]:
+ return SysCommand(
+ "localectl --no-pager list-x11-keymap-layouts",
+ environment_vars={'SYSTEMD_COLORS': '0'}
+ ).decode().splitlines()
+
+
+def verify_keyboard_layout(layout :str) -> bool:
+ for language in list_keyboard_languages():
+ if layout.lower() == language.lower():
+ return True
+ return False
+
+
+def verify_x11_keyboard_layout(layout :str) -> bool:
+ for language in list_x11_keyboard_languages():
+ if layout.lower() == language.lower():
+ return True
+ return False
+
+
+def set_kb_layout(locale :str) -> bool:
+ if len(locale.strip()):
+ if not verify_keyboard_layout(locale):
+ error(f"Invalid keyboard locale specified: {locale}")
+ return False
+
+ try:
+ SysCommand(f'localectl set-keymap {locale}')
+ except SysCallError as err:
+ raise ServiceException(f"Unable to set locale '{locale}' for console: {err}")
+
+ return True
+
+ return False
+
+
+def list_timezones() -> List[str]:
+ return SysCommand(
+ "timedatectl --no-pager list-timezones",
+ environment_vars={'SYSTEMD_COLORS': '0'}
+ ).decode().splitlines()
diff --git a/archinstall/lib/locale_helpers.py b/archinstall/lib/locale_helpers.py
deleted file mode 100644
index 5580fa91..00000000
--- a/archinstall/lib/locale_helpers.py
+++ /dev/null
@@ -1,168 +0,0 @@
-import logging
-from typing import Iterator, List, Callable
-
-from .exceptions import ServiceException
-from .general import SysCommand
-from .output import log
-from .storage import storage
-
-def list_keyboard_languages() -> Iterator[str]:
- for line in SysCommand("localectl --no-pager list-keymaps", environment_vars={'SYSTEMD_COLORS': '0'}):
- yield line.decode('UTF-8').strip()
-
-
-def list_locales() -> List[str]:
- with open('/etc/locale.gen', 'r') as fp:
- locales = []
- # before the list of locales begins there's an empty line with a '#' in front
- # so we'll collect the localels from bottom up and halt when we're donw
- entries = fp.readlines()
- entries.reverse()
-
- for entry in entries:
- text = entry.replace('#', '').strip()
- if text == '':
- break
- locales.append(text)
-
- locales.reverse()
- return locales
-
-def get_locale_mode_text(mode):
- if mode == 'LC_ALL':
- mode_text = "general (LC_ALL)"
- elif mode == "LC_CTYPE":
- mode_text = "Character set"
- elif mode == "LC_NUMERIC":
- mode_text = "Numeric values"
- elif mode == "LC_TIME":
- mode_text = "Time Values"
- elif mode == "LC_COLLATE":
- mode_text = "sort order"
- elif mode == "LC_MESSAGES":
- mode_text = "text messages"
- else:
- mode_text = "Unassigned"
- return mode_text
-
-def reset_cmd_locale():
- """ sets the cmd_locale to its saved default """
- storage['CMD_LOCALE'] = storage.get('CMD_LOCALE_DEFAULT',{})
-
-def unset_cmd_locale():
- """ archinstall will use the execution environment default """
- storage['CMD_LOCALE'] = {}
-
-def set_cmd_locale(general :str = None,
- charset :str = 'C',
- numbers :str = 'C',
- time :str = 'C',
- collate :str = 'C',
- messages :str = 'C'):
- """
- Set the cmd locale.
- If the parameter general is specified, it takes precedence over the rest (might as well not exist)
- The rest define some specific settings above the installed default language. If anyone of this parameters is none means the installation default
- """
- installed_locales = list_installed_locales()
- result = {}
- if general:
- if general in installed_locales:
- storage['CMD_LOCALE'] = {'LC_ALL':general}
- else:
- log(f"{get_locale_mode_text('LC_ALL')} {general} is not installed. Defaulting to C",fg="yellow",level=logging.WARNING)
- return
-
- if numbers:
- if numbers in installed_locales:
- result["LC_NUMERIC"] = numbers
- else:
- log(f"{get_locale_mode_text('LC_NUMERIC')} {numbers} is not installed. Defaulting to installation language",fg="yellow",level=logging.WARNING)
- if charset:
- if charset in installed_locales:
- result["LC_CTYPE"] = charset
- else:
- log(f"{get_locale_mode_text('LC_CTYPE')} {charset} is not installed. Defaulting to installation language",fg="yellow",level=logging.WARNING)
- if time:
- if time in installed_locales:
- result["LC_TIME"] = time
- else:
- log(f"{get_locale_mode_text('LC_TIME')} {time} is not installed. Defaulting to installation language",fg="yellow",level=logging.WARNING)
- if collate:
- if collate in installed_locales:
- result["LC_COLLATE"] = collate
- else:
- log(f"{get_locale_mode_text('LC_COLLATE')} {collate} is not installed. Defaulting to installation language",fg="yellow",level=logging.WARNING)
- if messages:
- if messages in installed_locales:
- result["LC_MESSAGES"] = messages
- else:
- log(f"{get_locale_mode_text('LC_MESSAGES')} {messages} is not installed. Defaulting to installation language",fg="yellow",level=logging.WARNING)
- storage['CMD_LOCALE'] = result
-
-def host_locale_environ(func :Callable):
- """ decorator when we want a function executing in the host's locale environment """
- def wrapper(*args, **kwargs):
- unset_cmd_locale()
- result = func(*args,**kwargs)
- reset_cmd_locale()
- return result
- return wrapper
-
-def c_locale_environ(func :Callable):
- """ decorator when we want a function executing in the C locale environment """
- def wrapper(*args, **kwargs):
- set_cmd_locale(general='C')
- result = func(*args,**kwargs)
- reset_cmd_locale()
- return result
- return wrapper
-
-def list_installed_locales() -> List[str]:
- lista = []
- for line in SysCommand('locale -a'):
- lista.append(line.decode('UTF-8').strip())
- return lista
-
-def list_x11_keyboard_languages() -> Iterator[str]:
- for line in SysCommand("localectl --no-pager list-x11-keymap-layouts", environment_vars={'SYSTEMD_COLORS': '0'}):
- yield line.decode('UTF-8').strip()
-
-
-def verify_keyboard_layout(layout :str) -> bool:
- for language in list_keyboard_languages():
- if layout.lower() == language.lower():
- return True
- return False
-
-
-def verify_x11_keyboard_layout(layout :str) -> bool:
- for language in list_x11_keyboard_languages():
- if layout.lower() == language.lower():
- return True
- return False
-
-
-def search_keyboard_layout(layout :str) -> Iterator[str]:
- for language in list_keyboard_languages():
- if layout.lower() in language.lower():
- yield language
-
-
-def set_keyboard_language(locale :str) -> bool:
- if len(locale.strip()):
- if not verify_keyboard_layout(locale):
- log(f"Invalid keyboard locale specified: {locale}", fg="red", level=logging.ERROR)
- return False
-
- if (output := SysCommand(f'localectl set-keymap {locale}')).exit_code != 0:
- raise ServiceException(f"Unable to set locale '{locale}' for console: {output}")
-
- return True
-
- return False
-
-
-def list_timezones() -> Iterator[str]:
- for line in SysCommand("timedatectl --no-pager list-timezones", environment_vars={'SYSTEMD_COLORS': '0'}):
- yield line.decode('UTF-8').strip()
diff --git a/archinstall/lib/luks.py b/archinstall/lib/luks.py
index ad6bf093..50e15cee 100644
--- a/archinstall/lib/luks.py
+++ b/archinstall/lib/luks.py
@@ -1,92 +1,77 @@
from __future__ import annotations
-import json
-import logging
-import os
-import pathlib
+
import shlex
import time
-from typing import Optional, List,TYPE_CHECKING
-# https://stackoverflow.com/a/39757388/929999
-if TYPE_CHECKING:
- from .installer import Installer
-
-from .disk import Partition, convert_device_to_uuid
-from .general import SysCommand, SysCommandWorker
-from .output import log
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Optional, List
+
+from . import disk
+from .general import SysCommand, generate_password, SysCommandWorker
+from .output import info, debug
from .exceptions import SysCallError, DiskError
from .storage import storage
-from .disk.helpers import get_filesystem_type
-from .disk.mapperdev import MapperDev
-from .disk.btrfs import BTRFSPartition
-
-
-class luks2:
- def __init__(self,
- partition: Partition,
- mountpoint: Optional[str],
- password: Optional[str],
- key_file :Optional[str] = None,
- auto_unmount :bool = False,
- *args :str,
- **kwargs :str):
-
- self.password = password
- self.partition = partition
- self.mountpoint = mountpoint
- self.args = args
- self.kwargs = kwargs
- self.key_file = key_file
- self.auto_unmount = auto_unmount
- self.filesystem = 'crypto_LUKS'
- self.mapdev = None
- def __enter__(self) -> Partition:
- if not self.key_file:
- self.key_file = f"/tmp/{os.path.basename(self.partition.path)}.disk_pw" # TODO: Make disk-pw-file randomly unique?
- if type(self.password) != bytes:
- self.password = bytes(self.password, 'UTF-8')
+@dataclass
+class Luks2:
+ luks_dev_path: Path
+ mapper_name: Optional[str] = None
+ password: Optional[str] = None
+ key_file: Optional[Path] = None
+ auto_unmount: bool = False
- with open(self.key_file, 'wb') as fh:
- fh.write(self.password)
+ # will be set internally after unlocking the device
+ _mapper_dev: Optional[Path] = None
- return self.unlock(self.partition, self.mountpoint, self.key_file)
+ @property
+ def mapper_dev(self) -> Optional[Path]:
+ if self.mapper_name:
+ return Path(f'/dev/mapper/{self.mapper_name}')
+ return None
- def __exit__(self, *args :str, **kwargs :str) -> bool:
- # TODO: https://stackoverflow.com/questions/28157929/how-to-safely-handle-an-exception-inside-a-context-manager
+ def __post_init__(self):
+ if self.luks_dev_path is None:
+ raise ValueError('Partition must have a path set')
+
+ def __enter__(self):
+ self.unlock(self.key_file)
+
+ def __exit__(self, *args: str, **kwargs: str):
if self.auto_unmount:
- self.close()
+ self.lock()
+
+ def _default_key_file(self) -> Path:
+ return Path(f'/tmp/{self.luks_dev_path.name}.disk_pw')
- if len(args) >= 2 and args[1]:
- raise args[1]
+ def _password_bytes(self) -> bytes:
+ if not self.password:
+ raise ValueError('Password for luks2 device was not specified')
- return True
+ if isinstance(self.password, bytes):
+ return self.password
+ else:
+ return bytes(self.password, 'UTF-8')
- def encrypt(self, partition :Partition,
- password :Optional[str] = None,
- key_size :int = 512,
- hash_type :str = 'sha512',
- iter_time :int = 10000,
- key_file :Optional[str] = None) -> str:
+ def encrypt(
+ self,
+ key_size: int = 512,
+ hash_type: str = 'sha512',
+ iter_time: int = 10000,
+ key_file: Optional[Path] = None
+ ) -> Path:
+ debug(f'Luks2 encrypting: {self.luks_dev_path}')
- log(f'Encrypting {partition} (This might take a while)', level=logging.INFO)
+ byte_password = self._password_bytes()
if not key_file:
if self.key_file:
key_file = self.key_file
else:
- key_file = f"/tmp/{os.path.basename(self.partition.path)}.disk_pw" # TODO: Make disk-pw-file randomly unique?
+ key_file = self._default_key_file()
- if not password:
- password = self.password
-
- if type(password) != bytes:
- password = bytes(password, 'UTF-8')
-
- with open(key_file, 'wb') as fh:
- fh.write(password)
-
- partition.partprobe()
+ with open(key_file, 'wb') as fh:
+ fh.write(byte_password)
cryptsetup_args = shlex.join([
'/usr/bin/cryptsetup',
@@ -97,120 +82,170 @@ class luks2:
'--hash', hash_type,
'--key-size', str(key_size),
'--iter-time', str(iter_time),
- '--key-file', os.path.abspath(key_file),
+ '--key-file', str(key_file),
'--use-urandom',
- 'luksFormat', partition.path,
+ 'luksFormat', str(self.luks_dev_path),
])
- try:
- # Retry formatting the volume because archinstall can some times be too quick
- # which generates a "Device /dev/sdX does not exist or access denied." between
- # setting up partitions and us trying to encrypt it.
- for i in range(storage['DISK_RETRY_ATTEMPTS']):
- if (cmd_handle := SysCommand(cryptsetup_args)).exit_code != 0:
- time.sleep(storage['DISK_TIMEOUTS'])
+ debug(f'cryptsetup format: {cryptsetup_args}')
+
+ # Retry formatting the volume because archinstall can some times be too quick
+ # which generates a "Device /dev/sdX does not exist or access denied." between
+ # setting up partitions and us trying to encrypt it.
+ for retry_attempt in range(storage['DISK_RETRY_ATTEMPTS'] + 1):
+ try:
+ result = SysCommand(cryptsetup_args).decode()
+ debug(f'cryptsetup luksFormat output: {result}')
+ break
+ except SysCallError as err:
+ time.sleep(storage['DISK_TIMEOUTS'])
+
+ if retry_attempt != storage['DISK_RETRY_ATTEMPTS']:
+ continue
+
+ if err.exit_code == 1:
+ info(f'luks2 partition currently in use: {self.luks_dev_path}')
+ info('Attempting to unmount, crypt-close and trying encryption again')
+
+ self.lock()
+ # Then try again to set up the crypt-device
+ result = SysCommand(cryptsetup_args).decode()
+ debug(f'cryptsetup luksFormat output: {result}')
else:
- break
+ raise DiskError(f'Could not encrypt volume "{self.luks_dev_path}": {err}')
- if cmd_handle.exit_code != 0:
- raise DiskError(f'Could not encrypt volume "{partition.path}": {b"".join(cmd_handle)}')
- except SysCallError as err:
- if err.exit_code == 1:
- log(f'{partition} is being used, trying to unmount and crypt-close the device and running one more attempt at encrypting the device.', level=logging.DEBUG)
- # Partition was in use, unmount it and try again
- partition.unmount()
-
- # Get crypt-information about the device by doing a reverse lookup starting with the partition path
- # For instance: /dev/sda
- SysCommand(f'bash -c "partprobe"')
- devinfo = json.loads(b''.join(SysCommand(f"lsblk --fs -J {partition.path}")).decode('UTF-8'))['blockdevices'][0]
-
- # For each child (sub-partition/sub-device)
- if len(children := devinfo.get('children', [])):
- for child in children:
- # Unmount the child location
- if child_mountpoint := child.get('mountpoint', None):
- log(f'Unmounting {child_mountpoint}', level=logging.DEBUG)
- SysCommand(f"umount -R {child_mountpoint}")
-
- # And close it if possible.
- log(f"Closing crypt device {child['name']}", level=logging.DEBUG)
- SysCommand(f"cryptsetup close {child['name']}")
-
- # Then try again to set up the crypt-device
- cmd_handle = SysCommand(cryptsetup_args)
- else:
- raise err
+ self.key_file = key_file
return key_file
- def unlock(self, partition :Partition, mountpoint :str, key_file :str) -> Partition:
+ def _get_luks_uuid(self) -> str:
+ command = f'/usr/bin/cryptsetup luksUUID {self.luks_dev_path}'
+
+ try:
+ return SysCommand(command).decode()
+ except SysCallError as err:
+ info(f'Unable to get UUID for Luks device: {self.luks_dev_path}')
+ raise err
+
+ def is_unlocked(self) -> bool:
+ return self.mapper_name is not None and Path(f'/dev/mapper/{self.mapper_name}').exists()
+
+ def unlock(self, key_file: Optional[Path] = None):
"""
- Mounts a luks2 compatible partition to a certain mountpoint.
- Keyfile must be specified as there's no way to interact with the pw-prompt atm.
+ Unlocks the luks device, an optional key file location for unlocking can be specified,
+ otherwise a default location for the key file will be used.
- :param mountpoint: The name without absolute path, for instance "luksdev" will point to /dev/mapper/luksdev
- :type mountpoint: str
+ :param key_file: An alternative key file
+ :type key_file: Path
"""
+ debug(f'Unlocking luks2 device: {self.luks_dev_path}')
- if '/' in mountpoint:
- os.path.basename(mountpoint) # TODO: Raise exception instead?
+ if not self.mapper_name:
+ raise ValueError('mapper name missing')
+
+ byte_password = self._password_bytes()
+
+ if not key_file:
+ if self.key_file:
+ key_file = self.key_file
+ else:
+ key_file = self._default_key_file()
+
+ with open(key_file, 'wb') as fh:
+ fh.write(byte_password)
wait_timer = time.time()
- while pathlib.Path(partition.path).exists() is False and time.time() - wait_timer < 10:
+ while Path(self.luks_dev_path).exists() is False and time.time() - wait_timer < 10:
time.sleep(0.025)
- SysCommand(f'/usr/bin/cryptsetup open {partition.path} {mountpoint} --key-file {os.path.abspath(key_file)} --type luks2')
- if os.path.islink(f'/dev/mapper/{mountpoint}'):
- self.mapdev = f'/dev/mapper/{mountpoint}'
-
- if (filesystem_type := get_filesystem_type(pathlib.Path(self.mapdev))) == 'btrfs':
- return BTRFSPartition(
- self.mapdev,
- block_device=MapperDev(mountpoint).partition.block_device,
- encrypted=True,
- filesystem=filesystem_type,
- autodetect_filesystem=False
- )
-
- return Partition(
- self.mapdev,
- block_device=MapperDev(mountpoint).partition.block_device,
- encrypted=True,
- filesystem=get_filesystem_type(self.mapdev),
- autodetect_filesystem=False
- )
-
- def close(self, mountpoint :Optional[str] = None) -> bool:
- if not mountpoint:
- mountpoint = self.mapdev
-
- SysCommand(f'/usr/bin/cryptsetup close {self.mapdev}')
- return os.path.islink(self.mapdev) is False
-
- def format(self, path :str) -> None:
- if (handle := SysCommand(f"/usr/bin/cryptsetup -q -v luksErase {path}")).exit_code != 0:
- raise DiskError(f'Could not format {path} with {self.filesystem} because: {b"".join(handle)}')
-
- def add_key(self, path :pathlib.Path, password :str) -> bool:
- if not path.exists():
- raise OSError(2, f"Could not import {path} as a disk encryption key, file is missing.", str(path))
-
- log(f'Adding additional key-file {path} for {self.partition}', level=logging.INFO)
- worker = SysCommandWorker(f"/usr/bin/cryptsetup -q -v luksAddKey {self.partition.path} {path}",
- environment_vars={'LC_ALL':'C'})
+ result = SysCommand(
+ '/usr/bin/cryptsetup open '
+ f'{self.luks_dev_path} '
+ f'{self.mapper_name} '
+ f'--key-file {key_file} '
+ f'--type luks2'
+ ).decode()
+
+ debug(f'cryptsetup open output: {result}')
+
+ if not self.mapper_dev or not self.mapper_dev.is_symlink():
+ raise DiskError(f'Failed to open luks2 device: {self.luks_dev_path}')
+
+ def lock(self):
+ disk.device_handler.umount(self.luks_dev_path)
+
+ # Get crypt-information about the device by doing a reverse lookup starting with the partition path
+ # For instance: /dev/sda
+ lsblk_info = disk.get_lsblk_info(self.luks_dev_path)
+
+ # For each child (sub-partition/sub-device)
+ for child in lsblk_info.children:
+ # Unmount the child location
+ for mountpoint in child.mountpoints:
+ debug(f'Unmounting {mountpoint}')
+ disk.device_handler.umount(mountpoint, recursive=True)
+
+ # And close it if possible.
+ debug(f"Closing crypt device {child.name}")
+ SysCommand(f"cryptsetup close {child.name}")
+
+ self._mapper_dev = None
+
+ def create_keyfile(self, target_path: Path, override: bool = False):
+ """
+ Routine to create keyfiles, so it can be moved elsewhere
+ """
+ if self.mapper_name is None:
+ raise ValueError('Mapper name must be provided')
+
+ # Once we store the key as ../xyzloop.key systemd-cryptsetup can
+ # automatically load this key if we name the device to "xyzloop"
+ kf_path = Path(f'/etc/cryptsetup-keys.d/{self.mapper_name}.key')
+ key_file = target_path / kf_path.relative_to(kf_path.root)
+ crypttab_path = target_path / 'etc/crypttab'
+
+ if key_file.exists():
+ if not override:
+ info(f'Key file {key_file} already exists, keeping existing')
+ return
+ else:
+ info(f'Key file {key_file} already exists, overriding')
+
+ key_file.parent.mkdir(parents=True, exist_ok=True)
+
+ pwd = generate_password(length=512)
+ key_file.write_text(pwd)
+
+ key_file.chmod(0o400)
+
+ self._add_key(key_file)
+ self._crypttab(crypttab_path, kf_path, options=["luks", "key-slot=1"])
+
+ def _add_key(self, key_file: Path):
+ debug(f'Adding additional key-file {key_file}')
+
+ command = f'/usr/bin/cryptsetup -q -v luksAddKey {self.luks_dev_path} {key_file}'
+ worker = SysCommandWorker(command, environment_vars={'LC_ALL': 'C'})
pw_injected = False
+
while worker.is_alive():
if b'Enter any existing passphrase' in worker and pw_injected is False:
- worker.write(bytes(password, 'UTF-8'))
+ worker.write(self._password_bytes())
pw_injected = True
if worker.exit_code != 0:
- raise DiskError(f'Could not add encryption key {path} to {self.partition} because: {worker}')
-
- return True
-
- def crypttab(self, installation :Installer, key_path :str, options :List[str] = ["luks", "key-slot=1"]) -> None:
- log(f'Adding a crypttab entry for key {key_path} in {installation}', level=logging.INFO)
- with open(f"{installation.target}/etc/crypttab", "a") as crypttab:
- crypttab.write(f"{self.mountpoint} UUID={convert_device_to_uuid(self.partition.path)} {key_path} {','.join(options)}\n")
+ raise DiskError(f'Could not add encryption key {key_file} to {self.luks_dev_path}: {worker.decode()}')
+
+ def _crypttab(
+ self,
+ crypttab_path: Path,
+ key_file: Path,
+ options: List[str]
+ ) -> None:
+ debug(f'Adding crypttab entry for key {key_file}')
+
+ with open(crypttab_path, 'a') as crypttab:
+ opt = ','.join(options)
+ uuid = self._get_luks_uuid()
+ row = f"{self.mapper_name} UUID={uuid} {key_file} {opt}\n"
+ crypttab.write(row)
diff --git a/archinstall/lib/menu/__init__.py b/archinstall/lib/menu/__init__.py
index 9b0adb8b..9c86faf5 100644
--- a/archinstall/lib/menu/__init__.py
+++ b/archinstall/lib/menu/__init__.py
@@ -1,2 +1,9 @@
-from .menu import Menu as Menu
-from .global_menu import GlobalMenu as GlobalMenu \ No newline at end of file
+from .abstract_menu import Selector, AbstractMenu, AbstractSubMenu
+from .list_manager import ListManager
+from .menu import (
+ MenuSelectionType,
+ MenuSelection,
+ Menu,
+)
+from .table_selection_menu import TableMenu
+from .text_input import TextInput
diff --git a/archinstall/lib/menu/abstract_menu.py b/archinstall/lib/menu/abstract_menu.py
index d659d709..ee55f5c9 100644
--- a/archinstall/lib/menu/abstract_menu.py
+++ b/archinstall/lib/menu/abstract_menu.py
@@ -1,13 +1,11 @@
from __future__ import annotations
-import logging
from typing import Callable, Any, List, Iterator, Tuple, Optional, Dict, TYPE_CHECKING
from .menu import Menu, MenuSelectionType
-from ..locale_helpers import set_keyboard_language
-from ..output import log
+from ..output import error
+from ..output import unicode_ljust
from ..translationhandler import TranslationHandler, Language
-from ..user_interaction.general_conf import select_archinstall_language
if TYPE_CHECKING:
_: Any
@@ -16,17 +14,17 @@ if TYPE_CHECKING:
class Selector:
def __init__(
self,
- description :str,
- func :Optional[Callable] = None,
- display_func :Optional[Callable] = None,
- default :Any = None,
- enabled :bool = False,
- dependencies :List = [],
- dependencies_not :List = [],
- exec_func :Optional[Callable] = None,
- preview_func :Optional[Callable] = None,
- mandatory :bool = False,
- no_store :bool = False
+ description: str,
+ func: Optional[Callable[[Any], Any]] = None,
+ display_func: Optional[Callable] = None,
+ default: Optional[Any] = None,
+ enabled: bool = False,
+ dependencies: List = [],
+ dependencies_not: List = [],
+ exec_func: Optional[Callable] = None,
+ preview_func: Optional[Callable] = None,
+ mandatory: bool = False,
+ no_store: bool = False
):
"""
Create a new menu selection entry
@@ -71,84 +69,66 @@ class Selector:
:param no_store: A boolean which determines that the field should or shouldn't be stored in the data storage
:type no_store: bool
"""
- self._description = description
- self.func = func
self._display_func = display_func
- self._current_selection = default
+ self._no_store = no_store
+
+ self.description = description
+ self.func = func
+ self.current_selection = default
self.enabled = enabled
- self._dependencies = dependencies
- self._dependencies_not = dependencies_not
+ self.dependencies = dependencies
+ self.dependencies_not = dependencies_not
self.exec_func = exec_func
- self._preview_func = preview_func
+ self.preview_func = preview_func
self.mandatory = mandatory
- self._no_store = no_store
-
- @property
- def description(self) -> str:
- return self._description
-
- @property
- def dependencies(self) -> List:
- return self._dependencies
-
- @property
- def dependencies_not(self) -> List:
- return self._dependencies_not
-
- @property
- def current_selection(self):
- return self._current_selection
-
- @property
- def preview_func(self):
- return self._preview_func
+ self.default = default
def do_store(self) -> bool:
return self._no_store is False
- def set_enabled(self, status :bool = True):
+ def set_enabled(self, status: bool = True):
self.enabled = status
- def update_description(self, description :str):
- self._description = description
+ def update_description(self, description: str):
+ self.description = description
def menu_text(self, padding: int = 0) -> str:
- if self._description == '': # special menu option for __separator__
+ if self.description == '': # special menu option for __separator__
return ''
current = ''
if self._display_func:
- current = self._display_func(self._current_selection)
+ current = self._display_func(self.current_selection)
else:
- if self._current_selection is not None:
- current = str(self._current_selection)
+ if self.current_selection is not None:
+ current = str(self.current_selection)
if current:
padding += 5
- description = str(self._description).ljust(padding, ' ')
- current = str(_('set: {}').format(current))
+ description = unicode_ljust(str(self.description), padding, ' ')
+ current = current
else:
- description = self._description
+ description = self.description
current = ''
return f'{description} {current}'
- def set_current_selection(self, current :Optional[str]):
- self._current_selection = current
+ def set_current_selection(self, current: Optional[Any]):
+ self.current_selection = current
def has_selection(self) -> bool:
- if not self._current_selection:
+ if not self.current_selection:
return False
return True
def get_selection(self) -> Any:
- return self._current_selection
+ return self.current_selection
def is_empty(self) -> bool:
- if self._current_selection is None:
+ if self.current_selection is None:
return True
- elif isinstance(self._current_selection, (str, list, dict)) and len(self._current_selection) == 0:
+ elif isinstance(self.current_selection, (str, list, dict)) and len(self.current_selection) == 0:
return True
return False
@@ -158,14 +138,17 @@ class Selector:
def is_mandatory(self) -> bool:
return self.mandatory
- def set_mandatory(self, status :bool = True):
- self.mandatory = status
- if status and not self.is_enabled():
- self.set_enabled(True)
+ def set_mandatory(self, value: bool):
+ self.mandatory = value
class AbstractMenu:
- def __init__(self, data_store: Optional[Dict[str, Any]] = None, auto_cursor=False, preview_size :float = 0.2):
+ def __init__(
+ self,
+ data_store: Dict[str, Any] = {},
+ auto_cursor: bool = False,
+ preview_size: float = 0.2
+ ):
"""
Create a new selection menu.
@@ -179,29 +162,34 @@ class AbstractMenu:
;type preview_size: float (range 0..1)
"""
- self._enabled_order :List[str] = []
+ self._enabled_order: List[str] = []
self._translation_handler = TranslationHandler()
self.is_context_mgr = False
- self._data_store = data_store if data_store is not None else {}
+ self._data_store = data_store
self.auto_cursor = auto_cursor
self._menu_options: Dict[str, Selector] = {}
- self._setup_selection_menu_options()
self.preview_size = preview_size
self._last_choice = None
+ self.setup_selection_menu_options()
+ self._sync_all()
+ self._populate_default_values()
+
+ self.defined_text = str(_('Defined'))
+
@property
def last_choice(self):
return self._last_choice
- def __enter__(self, *args :Any, **kwargs :Any) -> AbstractMenu:
+ def __enter__(self, *args: Any, **kwargs: Any) -> AbstractMenu:
self.is_context_mgr = True
return self
- def __exit__(self, *args :Any, **kwargs :Any) -> None:
+ def __exit__(self, *args: Any, **kwargs: Any) -> None:
# TODO: https://stackoverflow.com/questions/28157929/how-to-safely-handle-an-exception-inside-a-context-manager
# TODO: skip processing when it comes from a planified exit
if len(args) >= 2 and args[1]:
- log(args[1], level=logging.ERROR, fg='red')
+ error(args[1])
print(" Please submit this issue (and file) to https://github.com/archlinux/archinstall/issues")
raise args[1]
@@ -216,7 +204,25 @@ class AbstractMenu:
def translation_handler(self) -> TranslationHandler:
return self._translation_handler
- def _setup_selection_menu_options(self):
+ def _populate_default_values(self):
+ for config_key, selector in self._menu_options.items():
+ if selector.default is not None and config_key not in self._data_store:
+ self._data_store[config_key] = selector.default
+
+ def _sync_all(self):
+ for key in self._menu_options.keys():
+ self._sync(key)
+
+ def _sync(self, selector_name: str):
+ value = self._data_store.get(selector_name, None)
+ selector = self._menu_options.get(selector_name, None)
+
+ if value is not None:
+ self._menu_options[selector_name].set_current_selection(value)
+ elif selector is not None and selector.has_selection():
+ self._data_store[selector_name] = selector.current_selection
+
+ def setup_selection_menu_options(self):
""" Define the menu options.
Menu options can be defined here in a subclass or done per program calling self.set_option()
"""
@@ -234,31 +240,16 @@ class AbstractMenu:
""" will be called at the end of the processing of the menu """
return
- def synch(self, selector_name :str, omit_if_set :bool = False,omit_if_disabled :bool = False):
- """ loads menu options with data_store value """
- arg = self._data_store.get(selector_name, None)
- # don't display the menu option if it was defined already
- if arg is not None and omit_if_set:
- return
-
- if not self.option(selector_name).is_enabled() and omit_if_disabled:
- return
-
- if arg is not None:
- self._menu_options[selector_name].set_current_selection(arg)
-
def _update_enabled_order(self, selector_name: str):
self._enabled_order.append(selector_name)
- def enable(self, selector_name :str, omit_if_set :bool = False , mandatory :bool = False):
+ def enable(self, selector_name: str, mandatory: bool = False):
""" activates menu options """
if self._menu_options.get(selector_name, None):
self._menu_options[selector_name].set_enabled(True)
self._update_enabled_order(selector_name)
-
- if mandatory:
- self._menu_options[selector_name].set_mandatory(True)
- self.synch(selector_name,omit_if_set)
+ self._menu_options[selector_name].set_mandatory(mandatory)
+ self._sync(selector_name)
else:
raise ValueError(f'No selector found: {selector_name}')
@@ -274,7 +265,11 @@ class AbstractMenu:
def _find_selection(self, selection_name: str) -> Tuple[str, Selector]:
enabled_menus = self._menus_to_enable()
padding = self._get_menu_text_padding(list(enabled_menus.values()))
- option = [(k, v) for k, v in self._menu_options.items() if v.menu_text(padding).strip() == selection_name.strip()]
+
+ option = []
+ for k, v in self._menu_options.items():
+ if v.menu_text(padding).strip() == selection_name.strip():
+ option.append((k, v))
if len(option) != 1:
raise ValueError(f'Selection not found: {selection_name}')
@@ -283,18 +278,11 @@ class AbstractMenu:
return config_name, selector
def run(self, allow_reset: bool = False):
- """ Calls the Menu framework"""
- # we synch all the options just in case
- for item in self.list_options():
- self.synch(item)
-
- self.post_callback() # as all the values can vary i have to exec this callback
+ self._sync_all()
+ self.post_callback()
cursor_pos = None
while True:
- # Before continuing, set the preferred keyboard layout/language in the current terminal.
- # This will just help the user with the next following questions.
- self._set_kb_language()
enabled_menus = self._menus_to_enable()
padding = self._get_menu_text_padding(list(enabled_menus.values()))
@@ -336,18 +324,18 @@ class AbstractMenu:
value = value.strip()
# if this calls returns false, we exit the menu
- # we allow for an callback for special processing on realeasing control
+ # we allow for an callback for special processing on releasing control
if not self._process_selection(value):
break
# we get the last action key
- actions = {str(v.description):k for k,v in self._menu_options.items()}
+ actions = {str(v.description): k for k, v in self._menu_options.items()}
self._last_choice = actions[selection.value.strip()] # type: ignore
if not self.is_context_mgr:
self.__exit__()
- def _process_selection(self, selection_name :str) -> bool:
+ def _process_selection(self, selection_name: str) -> bool:
""" determines and executes the selection y
Can / Should be extended to handle specific selection issues
Returns true if the menu shall continue, False if it has ended
@@ -356,7 +344,7 @@ class AbstractMenu:
config_name, selector = self._find_selection(selection_name)
return self.exec_option(config_name, selector)
- def exec_option(self, config_name :str, p_selector :Optional[Selector] = None) -> bool:
+ def exec_option(self, config_name: str, p_selector: Optional[Selector] = None) -> bool:
""" processes the execution of a given menu entry
- pre process callback
- selection function
@@ -372,40 +360,42 @@ class AbstractMenu:
self.pre_callback(config_name)
result = None
+
if selector.func is not None:
- presel_val = self.option(config_name).get_selection()
- result = selector.func(presel_val)
+ cur_value = self.option(config_name).get_selection()
+ result = selector.func(cur_value)
self._menu_options[config_name].set_current_selection(result)
+
if selector.do_store():
self._data_store[config_name] = result
- exec_ret_val = selector.exec_func(config_name,result) if selector.exec_func is not None else False
- self.post_callback(config_name,result)
- if exec_ret_val and self._check_mandatory_status():
+ exec_ret_val = selector.exec_func(config_name, result) if selector.exec_func else False
+
+ self.post_callback(config_name, result)
+
+ if exec_ret_val:
return False
- return True
- def _set_kb_language(self):
- """ general for ArchInstall"""
- # Before continuing, set the preferred keyboard layout/language in the current terminal.
- # This will just help the user with the next following questions.
- if self._data_store.get('keyboard-layout', None) and len(self._data_store['keyboard-layout']):
- set_keyboard_language(self._data_store['keyboard-layout'])
+ return True
- def _verify_selection_enabled(self, selection_name :str) -> bool:
- """ general """
+ def _verify_selection_enabled(self, selection_name: str) -> bool:
if selection := self._menu_options.get(selection_name, None):
if not selection.enabled:
return False
if len(selection.dependencies) > 0:
- for d in selection.dependencies:
- if not self._verify_selection_enabled(d) or self._menu_options[d].is_empty():
- return False
+ for dep in selection.dependencies:
+ if isinstance(dep, str):
+ if not self._verify_selection_enabled(dep) or self._menu_options[dep].is_empty():
+ return False
+ elif callable(dep): # callable dependency eval
+ return dep()
+ else:
+ raise ValueError(f'Unsupported dependency: {selection_name}')
if len(selection.dependencies_not) > 0:
- for d in selection.dependencies_not:
- if not self._menu_options[d].is_empty():
+ for dep in selection.dependencies_not:
+ if not self._menu_options[dep].is_empty():
return False
return True
@@ -429,16 +419,10 @@ class AbstractMenu:
return ordered_menus
- def option(self,name :str) -> Selector:
+ def option(self, name: str) -> Selector:
# TODO check inexistent name
return self._menu_options[name]
- def list_options(self) -> Iterator:
- """ Iterator to retrieve the enabled menu option names
- """
- for item in self._menu_options:
- yield item
-
def list_enabled_options(self) -> Iterator:
""" Iterator to retrieve the enabled menu options at a given time.
The results are dynamic (if between calls to the iterator some elements -still not retrieved- are (de)activated
@@ -447,44 +431,21 @@ class AbstractMenu:
if item in self._menus_to_enable():
yield item
- def set_option(self, name :str, selector :Selector):
- self._menu_options[name] = selector
- self.synch(name)
-
- def _check_mandatory_status(self) -> bool:
- for field in self._menu_options:
- option = self._menu_options[field]
- if option.is_mandatory() and not option.has_selection():
- return False
- return True
-
- def set_mandatory(self, field :str, status :bool):
- self.option(field).set_mandatory(status)
-
- def mandatory_overview(self) -> Tuple[int, int]:
- mandatory_fields = 0
- mandatory_waiting = 0
- for field, option in self._menu_options.items():
- if option.is_mandatory():
- mandatory_fields += 1
- if not option.has_selection():
- mandatory_waiting += 1
- return mandatory_fields, mandatory_waiting
-
- def _select_archinstall_language(self, preset_value: Language) -> Language:
- language = select_archinstall_language(self.translation_handler.translated_languages, preset_value)
+ 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)
return language
class AbstractSubMenu(AbstractMenu):
- def __init__(self, data_store: Optional[Dict[str, Any]] = None):
- super().__init__(data_store=data_store)
+ def __init__(self, data_store: Dict[str, Any] = {}, preview_size: float = 0.2):
+ super().__init__(data_store=data_store, preview_size=preview_size)
self._menu_options['__separator__'] = Selector('')
self._menu_options['back'] = \
Selector(
- _('Back'),
+ Menu.back(),
no_store=True,
enabled=True,
exec_func=lambda n, v: True,
diff --git a/archinstall/lib/menu/global_menu.py b/archinstall/lib/menu/global_menu.py
deleted file mode 100644
index 7c5b153e..00000000
--- a/archinstall/lib/menu/global_menu.py
+++ /dev/null
@@ -1,429 +0,0 @@
-from __future__ import annotations
-
-from typing import Any, List, Optional, Union, Dict, TYPE_CHECKING
-
-import archinstall
-from ..disk.encryption import DiskEncryptionMenu
-from ..general import SysCommand, secret
-from ..hardware import has_uefi
-from ..menu import Menu
-from ..menu.abstract_menu import Selector, AbstractMenu
-from ..models import NetworkConfiguration
-from ..models.disk_encryption import DiskEncryption, EncryptionType
-from ..models.users import User
-from ..output import FormattedOutput
-from ..profiles import is_desktop_profile, Profile
-from ..storage import storage
-from ..user_interaction import add_number_of_parrallel_downloads
-from ..user_interaction import ask_additional_packages_to_install
-from ..user_interaction import ask_for_additional_users
-from ..user_interaction import ask_for_audio_selection
-from ..user_interaction import ask_for_bootloader
-from ..user_interaction import ask_for_swap
-from ..user_interaction import ask_hostname
-from ..user_interaction import ask_ntp
-from ..user_interaction import ask_to_configure_network
-from ..user_interaction import get_password, ask_for_a_timezone, save_config
-from ..user_interaction import select_additional_repositories
-from ..user_interaction import select_disk_layout
-from ..user_interaction import select_harddrives
-from ..user_interaction import select_kernel
-from ..user_interaction import select_language
-from ..user_interaction import select_locale_enc
-from ..user_interaction import select_locale_lang
-from ..user_interaction import select_mirror_regions
-from ..user_interaction import select_profile
-from ..user_interaction.partitioning_conf import current_partition_layout
-
-if TYPE_CHECKING:
- _: Any
-
-
-class GlobalMenu(AbstractMenu):
- def __init__(self,data_store):
- self._disk_check = True
- super().__init__(data_store=data_store, auto_cursor=True, preview_size=0.3)
-
- def _setup_selection_menu_options(self):
- # archinstall.Language will not use preset values
- self._menu_options['archinstall-language'] = \
- Selector(
- _('Archinstall language'),
- lambda x: self._select_archinstall_language(x),
- display_func=lambda x: x.display_name,
- default=self.translation_handler.get_language_by_abbr('en'))
- self._menu_options['keyboard-layout'] = \
- Selector(
- _('Keyboard layout'),
- lambda preset: select_language(preset),
- default='us')
- self._menu_options['mirror-region'] = \
- Selector(
- _('Mirror region'),
- lambda preset: select_mirror_regions(preset),
- display_func=lambda x: list(x.keys()) if x else '[]',
- default={})
- self._menu_options['sys-language'] = \
- Selector(
- _('Locale language'),
- lambda preset: select_locale_lang(preset),
- default='en_US')
- self._menu_options['sys-encoding'] = \
- Selector(
- _('Locale encoding'),
- lambda preset: select_locale_enc(preset),
- default='UTF-8')
- self._menu_options['harddrives'] = \
- Selector(
- _('Drive(s)'),
- lambda preset: self._select_harddrives(preset),
- display_func=lambda x: f'{len(x)} ' + str(_('Drive(s)')) if x is not None and len(x) > 0 else '',
- preview_func=self._prev_harddrives,
- )
- self._menu_options['disk_layouts'] = \
- Selector(
- _('Disk layout'),
- lambda preset: select_disk_layout(
- preset,
- storage['arguments'].get('harddrives', []),
- storage['arguments'].get('advanced', False)
- ),
- preview_func=self._prev_disk_layouts,
- display_func=lambda x: self._display_disk_layout(x),
- dependencies=['harddrives'])
- self._menu_options['disk_encryption'] = \
- Selector(
- _('Disk encryption'),
- lambda preset: self._disk_encryption(preset),
- preview_func=self._prev_disk_encryption,
- display_func=lambda x: self._display_disk_encryption(x),
- dependencies=['disk_layouts'])
- self._menu_options['swap'] = \
- Selector(
- _('Swap'),
- lambda preset: ask_for_swap(preset),
- default=True)
- self._menu_options['bootloader'] = \
- Selector(
- _('Bootloader'),
- lambda preset: ask_for_bootloader(storage['arguments'].get('advanced', False),preset),
- default="systemd-bootctl" if has_uefi() else "grub-install")
- self._menu_options['hostname'] = \
- Selector(
- _('Hostname'),
- ask_hostname,
- default='archlinux')
- # root password won't have preset value
- self._menu_options['!root-password'] = \
- Selector(
- _('Root password'),
- lambda preset:self._set_root_password(),
- display_func=lambda x: secret(x) if x else 'None')
- self._menu_options['!users'] = \
- Selector(
- _('User account'),
- lambda x: self._create_user_account(x),
- default={},
- display_func=lambda x: f'{len(x)} {_("User(s)")}' if len(x) > 0 else None,
- preview_func=self._prev_users)
- self._menu_options['profile'] = \
- Selector(
- _('Profile'),
- lambda preset: self._select_profile(preset),
- display_func=lambda x: x if x else 'None'
- )
- self._menu_options['audio'] = \
- Selector(
- _('Audio'),
- lambda preset: ask_for_audio_selection(is_desktop_profile(storage['arguments'].get('profile', None)),preset),
- display_func=lambda x: x if x else 'None',
- default=None
- )
-
- self._menu_options['parallel downloads'] = \
- Selector(
- _('Parallel Downloads'),
- add_number_of_parrallel_downloads,
- display_func=lambda x: x if x else '0',
- default=0
- )
-
- self._menu_options['kernels'] = \
- Selector(
- _('Kernels'),
- lambda preset: select_kernel(preset),
- default=['linux'])
- self._menu_options['packages'] = \
- Selector(
- _('Additional packages'),
- # lambda x: ask_additional_packages_to_install(storage['arguments'].get('packages', None)),
- ask_additional_packages_to_install,
- default=[])
- self._menu_options['additional-repositories'] = \
- Selector(
- _('Optional repositories'),
- select_additional_repositories,
- default=[])
- self._menu_options['nic'] = \
- Selector(
- _('Network configuration'),
- ask_to_configure_network,
- display_func=lambda x: self._display_network_conf(x),
- preview_func=self._prev_network_config,
- default={})
- self._menu_options['timezone'] = \
- Selector(
- _('Timezone'),
- lambda preset: ask_for_a_timezone(preset),
- default='UTC')
- self._menu_options['ntp'] = \
- Selector(
- _('Automatic time sync (NTP)'),
- lambda preset: self._select_ntp(preset),
- default=True)
- self._menu_options['__separator__'] = \
- Selector('')
- self._menu_options['save_config'] = \
- Selector(
- _('Save configuration'),
- lambda preset: save_config(self._data_store),
- no_store=True)
- self._menu_options['install'] = \
- Selector(
- self._install_text(),
- exec_func=lambda n,v: True if len(self._missing_configs()) == 0 else False,
- preview_func=self._prev_install_missing_config,
- no_store=True)
-
- self._menu_options['abort'] = Selector(_('Abort'), exec_func=lambda n,v:exit(1))
-
- def _update_install_text(self, name :Optional[str] = None, result :Any = None):
- text = self._install_text()
- self._menu_options['install'].update_description(text)
-
- def post_callback(self,name :Optional[str] = None ,result :Any = None):
- self._update_install_text(name, result)
-
- def _install_text(self):
- missing = len(self._missing_configs())
- if missing > 0:
- return _('Install ({} config(s) missing)').format(missing)
- return _('Install')
-
- def _display_network_conf(self, cur_value: Union[NetworkConfiguration, List[NetworkConfiguration]]) -> str:
- if not cur_value:
- return _('Not configured, unavailable unless setup manually')
- else:
- if isinstance(cur_value, list):
- return str(_('Configured {} interfaces')).format(len(cur_value))
- else:
- return str(cur_value)
-
- def _disk_encryption(self, preset: Optional[DiskEncryption]) -> Optional[DiskEncryption]:
- data_store: Dict[str, Any] = {}
-
- selector = self._menu_options['disk_layouts']
-
- if selector.has_selection():
- layouts: Dict[str, Dict[str, Any]] = selector.current_selection
- else:
- # this should not happen as the encryption menu has the disk layout as dependency
- raise ValueError('No disk layout specified')
-
- disk_encryption = DiskEncryptionMenu(data_store, preset, layouts).run()
- return disk_encryption
-
- def _prev_network_config(self) -> Optional[str]:
- selector = self._menu_options['nic']
- if selector.has_selection():
- ifaces = selector.current_selection
- if isinstance(ifaces, list):
- return FormattedOutput.as_table(ifaces)
- return None
-
- def _prev_harddrives(self) -> Optional[str]:
- selector = self._menu_options['harddrives']
- if selector.has_selection():
- drives = selector.current_selection
- return FormattedOutput.as_table(drives)
- return None
-
- def _prev_disk_layouts(self) -> Optional[str]:
- selector = self._menu_options['disk_layouts']
- if selector.has_selection():
- layouts: Dict[str, Dict[str, Any]] = selector.current_selection
-
- output = ''
- for device, layout in layouts.items():
- output += f'{_("Device")}: {device}\n\n'
- output += current_partition_layout(layout['partitions'], with_title=False)
- output += '\n\n'
-
- return output.rstrip()
-
- return None
-
- def _display_disk_layout(self, current_value: Optional[Dict[str, Any]]) -> str:
- if current_value:
- total_partitions = [entry['partitions'] for entry in current_value.values()]
- total_nr = sum([len(p) for p in total_partitions])
- return f'{total_nr} {_("Partitions")}'
- return ''
-
- def _prev_disk_encryption(self) -> Optional[str]:
- selector = self._menu_options['disk_encryption']
- if selector.has_selection():
- encryption: DiskEncryption = selector.current_selection
-
- enc_type = EncryptionType.type_to_text(encryption.encryption_type)
- output = str(_('Encryption type')) + f': {enc_type}\n'
- output += str(_('Password')) + f': {secret(encryption.encryption_password)}\n'
-
- if encryption.all_partitions:
- output += 'Partitions: {} selected'.format(len(encryption.all_partitions)) + '\n'
-
- if encryption.hsm_device:
- output += f'HSM: {encryption.hsm_device.manufacturer}'
-
- return output
-
- return None
-
- def _display_disk_encryption(self, current_value: Optional[DiskEncryption]) -> str:
- if current_value:
- return EncryptionType.type_to_text(current_value.encryption_type)
- return ''
-
- def _prev_install_missing_config(self) -> Optional[str]:
- if missing := self._missing_configs():
- text = str(_('Missing configurations:\n'))
- for m in missing:
- text += f'- {m}\n'
- return text[:-1] # remove last new line
- return None
-
- def _prev_users(self) -> Optional[str]:
- selector = self._menu_options['!users']
- if selector.has_selection():
- users: List[User] = selector.current_selection
- return FormattedOutput.as_table(users)
- return None
-
- def _missing_configs(self) -> List[str]:
- def check(s):
- return self._menu_options.get(s).has_selection()
-
- def has_superuser() -> bool:
- users = self._menu_options['!users'].current_selection
- return any([u.sudo for u in users])
-
- missing = []
- if not check('bootloader'):
- missing += ['Bootloader']
- if not check('hostname'):
- missing += ['Hostname']
- if not check('!root-password') and not has_superuser():
- missing += [str(_('Either root-password or at least 1 user with sudo privileges must be specified'))]
- if self._disk_check:
- if not check('harddrives'):
- missing += [str(_('Drive(s)'))]
- if check('harddrives'):
- if not self._menu_options['harddrives'].is_empty() and not check('disk_layouts'):
- missing += [str(_('Disk layout'))]
-
- return missing
-
- def _set_root_password(self) -> Optional[str]:
- prompt = str(_('Enter root password (leave blank to disable root): '))
- password = get_password(prompt=prompt)
- return password
-
- # def _select_encrypted_password(self) -> Optional[str]:
- # if passwd := get_password(prompt=str(_('Enter disk encryption password (leave blank for no encryption): '))):
- # return passwd
- # return None
-
- def _select_ntp(self, preset :bool = True) -> bool:
- ntp = ask_ntp(preset)
-
- value = str(ntp).lower()
- SysCommand(f'timedatectl set-ntp {value}')
-
- return ntp
-
- def _select_harddrives(self, old_harddrives: List[str] = []) -> List:
- harddrives = select_harddrives(old_harddrives)
-
- if harddrives is not None:
- if len(harddrives) == 0:
- prompt = _(
- "You decided to skip harddrive selection\nand will use whatever drive-setup is mounted at {} (experimental)\n"
- "WARNING: Archinstall won't check the suitability of this setup\n"
- "Do you wish to continue?"
- ).format(storage['MOUNT_POINT'])
-
- choice = Menu(prompt, Menu.yes_no(), default_option=Menu.yes(), skip=False).run()
-
- if choice.value == Menu.no():
- self._disk_check = True
- return self._select_harddrives(old_harddrives)
- else:
- self._disk_check = False
-
- # in case the harddrives got changed we have to reset the disk layout as well
- if old_harddrives != harddrives:
- self._menu_options['disk_layouts'].set_current_selection(None)
- storage['arguments']['disk_layouts'] = {}
-
- return harddrives
-
- def _select_profile(self, preset) -> Optional[Profile]:
- ret: Optional[Profile] = None
- profile = select_profile(preset)
-
- if profile is None:
- if any([
- archinstall.storage.get('profile_minimal', False),
- archinstall.storage.get('_selected_servers', None),
- archinstall.storage.get('_desktop_profile', None),
- archinstall.arguments.get('desktop-environment', None),
- archinstall.arguments.get('gfx_driver_packages', None)
- ]):
- return preset
- else: # ctrl+c was actioned and all profile settings have been reset
- return None
-
- servers = archinstall.storage.get('_selected_servers', [])
- desktop = archinstall.storage.get('_desktop_profile', None)
- desktop_env = archinstall.arguments.get('desktop-environment', None)
- gfx_driver = archinstall.arguments.get('gfx_driver_packages', None)
-
- # Check the potentially selected profiles preparations to get early checks if some additional questions are needed.
- if profile and profile.has_prep_function():
- namespace = f'{profile.namespace}.py'
- with profile.load_instructions(namespace=namespace) as imported:
- if imported._prep_function(servers=servers, desktop=desktop, desktop_env=desktop_env, gfx_driver=gfx_driver):
- ret = profile
-
- match ret.name:
- case 'minimal':
- reset = ['_selected_servers', '_desktop_profile', 'desktop-environment', 'gfx_driver_packages']
- case 'server':
- reset = ['_desktop_profile', 'desktop-environment']
- case 'desktop':
- reset = ['_selected_servers']
- case 'xorg':
- reset = ['_selected_servers', '_desktop_profile', 'desktop-environment']
-
- for r in reset:
- archinstall.storage[r] = None
- else:
- return self._select_profile(preset)
- elif profile:
- ret = profile
-
- return ret
-
- def _create_user_account(self, defined_users: List[User]) -> List[User]:
- users = ask_for_additional_users(defined_users=defined_users)
- return users
diff --git a/archinstall/lib/menu/list_manager.py b/archinstall/lib/menu/list_manager.py
index 1e09d987..de18791c 100644
--- a/archinstall/lib/menu/list_manager.py
+++ b/archinstall/lib/menu/list_manager.py
@@ -3,6 +3,7 @@ from os import system
from typing import Any, TYPE_CHECKING, Dict, Optional, Tuple, List
from .menu import Menu
+from ..output import FormattedOutput
if TYPE_CHECKING:
_: Any
@@ -34,7 +35,7 @@ class ListManager:
self._data = copy.deepcopy(entries)
explainer = str(_('\n Choose an object from the list, and select one of the available actions for it to execute'))
- self._prompt = prompt + explainer if prompt else explainer
+ self._prompt = prompt if prompt else explainer
self._separator = ''
self._confirm_action = str(_('Confirm and exit'))
@@ -44,13 +45,18 @@ class ListManager:
self._base_actions = base_actions
self._sub_menu_actions = sub_menu_actions
- self._last_choice = None
+ self._last_choice: Optional[str] = None
@property
- def last_choice(self):
+ def last_choice(self) -> Optional[str]:
return self._last_choice
- def run(self):
+ def is_last_choice_cancel(self) -> bool:
+ if self._last_choice is not None:
+ return self._last_choice == self._cancel_action
+ return False
+
+ def run(self) -> List[Any]:
while True:
# this will return a dictionary with the key as the menu entry to be displayed
# and the value is the original value from the self._data container
@@ -75,11 +81,12 @@ class ListManager:
self._data = self.handle_action(choice.value, None, self._data)
elif choice.value in self._terminate_actions:
break
- else: # an entry of the existing selection was choosen
- selected_entry = data_formatted[choice.value]
+ else: # an entry of the existing selection was chosen
+ selected_entry = data_formatted[choice.value] # type: ignore
self._run_actions_on_entry(selected_entry)
- self._last_choice = choice
+ self._last_choice = choice.value # type: ignore
+
if choice.value == self._cancel_action:
return self._original_data # return the original list
else:
@@ -121,22 +128,41 @@ class ListManager:
if choice.value and choice.value != self._cancel_action:
self._data = self.handle_action(choice.value, entry, self._data)
- def selected_action_display(self, selection: Any) -> str:
- # this will return the value to be displayed in the
- # "Select an action for '{}'" string
- raise NotImplementedError('Please implement me in the child class')
+ def reformat(self, data: List[Any]) -> Dict[str, Optional[Any]]:
+ """
+ Default implementation of the table to be displayed.
+ Override if any custom formatting is needed
+ """
+ table = FormattedOutput.as_table(data)
+ rows = table.split('\n')
+
+ # these are the header rows of the table and do not map to any User obviously
+ # we're adding 2 spaces as prefix because the menu selector '> ' will be put before
+ # the selectable rows so the header has to be aligned
+ display_data: Dict[str, Optional[Any]] = {f' {rows[0]}': None, f' {rows[1]}': None}
+
+ for row, entry in zip(rows[2:], data):
+ row = row.replace('|', '\\|')
+ display_data[row] = entry
- def reformat(self, data: List[Any]) -> Dict[str, Any]:
- # this should return a dictionary of display string to actual data entry
- # mapping; if the value for a given display string is None it will be used
- # in the header value (useful when displaying tables)
+ return display_data
+
+ def selected_action_display(self, selection: Any) -> str:
+ """
+ this will return the value to be displayed in the
+ "Select an action for '{}'" string
+ """
raise NotImplementedError('Please implement me in the child class')
def handle_action(self, action: Any, entry: Optional[Any], data: List[Any]) -> List[Any]:
- # this function is called when a base action or
- # a specific action for an entry is triggered
+ """
+ this function is called when a base action or
+ a specific action for an entry is triggered
+ """
raise NotImplementedError('Please implement me in the child class')
- def filter_options(self, selection :Any, options :List[str]) -> List[str]:
- # filter which actions to show for an specific selection
+ def filter_options(self, selection: Any, options: List[str]) -> List[str]:
+ """
+ filter which actions to show for an specific selection
+ """
return options
diff --git a/archinstall/lib/menu/menu.py b/archinstall/lib/menu/menu.py
index 09685c55..38301d3a 100644
--- a/archinstall/lib/menu/menu.py
+++ b/archinstall/lib/menu/menu.py
@@ -3,14 +3,11 @@ from enum import Enum, auto
from os import system
from typing import Dict, List, Union, Any, TYPE_CHECKING, Optional, Callable
-from .simple_menu import TerminalMenu
+from simple_term_menu import TerminalMenu # type: ignore
from ..exceptions import RequirementError
-from ..output import log
+from ..output import debug
-from collections.abc import Iterable
-import sys
-import logging
if TYPE_CHECKING:
_: Any
@@ -27,42 +24,61 @@ class MenuSelection:
type_: MenuSelectionType
value: Optional[Union[str, List[str]]] = None
+ @property
+ def single_value(self) -> Any:
+ return self.value # type: ignore
+
+ @property
+ def multi_value(self) -> List[Any]:
+ return self.value # type: ignore
+
class Menu(TerminalMenu):
+ _menu_is_active: bool = False
+
+ @staticmethod
+ def is_menu_active() -> bool:
+ return Menu._menu_is_active
+
+ @classmethod
+ def back(cls) -> str:
+ return str(_('← Back'))
@classmethod
- def yes(cls):
+ def yes(cls) -> str:
return str(_('yes'))
@classmethod
- def no(cls):
+ def no(cls) -> str:
return str(_('no'))
@classmethod
- def yes_no(cls):
+ def yes_no(cls) -> List[str]:
return [cls.yes(), cls.no()]
def __init__(
self,
- title :str,
- p_options :Union[List[str], Dict[str, Any]],
- skip :bool = True,
- multi :bool = False,
- default_option : Optional[str] = None,
- sort :bool = True,
- preset_values :Union[str, List[str]] = None,
- cursor_index : Optional[int] = None,
- preview_command: Optional[Callable] = None,
+ title: str,
+ p_options: Union[List[str], Dict[str, Any]],
+ skip: bool = True,
+ multi: bool = False,
+ default_option: Optional[str] = None,
+ sort: bool = True,
+ preset_values: Optional[Union[str, List[str]]] = None,
+ cursor_index: Optional[int] = None,
+ preview_command: Optional[Callable[[Any], str | None]] = None,
preview_size: float = 0.0,
preview_title: str = 'Info',
- header :Union[List[str],str] = None,
- allow_reset :bool = False,
- allow_reset_warning_msg :str = '',
+ header: Union[List[str], str] = [],
+ allow_reset: bool = False,
+ allow_reset_warning_msg: Optional[str] = None,
clear_screen: bool = True,
show_search_hint: bool = True,
cycle_cursor: bool = True,
clear_menu_on_exit: bool = True,
- skip_empty_entries: bool = False
+ skip_empty_entries: bool = False,
+ display_back_option: bool = False,
+ extra_bottom_space: bool = False
):
"""
Creates a new menu
@@ -72,7 +88,7 @@ class Menu(TerminalMenu):
:param p_options: Options to be displayed in the menu to chose from;
if dict is specified then the keys of such will be used as options
- :type options: list, dict
+ :type p_options: list, dict
:param skip: Indicate if the selection is not mandatory and can be skipped
:type skip: bool
@@ -101,46 +117,27 @@ class Menu(TerminalMenu):
:param preview_title: Title of the preview window
:type preview_title: str
- param header: one or more header lines for the menu
- type param: string or list
+ :param header: one or more header lines for the menu
+ :type header: string or list
- param raise_error_on_interrupt: This will explicitly handle a ctrl+c instead and return that specific state
- type param: bool
+ :param allow_reset: This will explicitly handle a ctrl+c instead and return that specific state
+ :type allow_reset: bool
- param raise_error_warning_msg: If raise_error_on_interrupt is True and this is non-empty, there will be a warning with a user confirmation displayed
- type param: str
+ param allow_reset_warning_msg: If raise_error_on_interrupt is True the warning is set, a user confirmation is displayed
+ type allow_reset_warning_msg: str
- :param kwargs : any SimpleTerminal parameter
+ :param extra_bottom_space: Add an extra empty line at the end of the menu
+ :type extra_bottom_space: bool
"""
- # we guarantee the inmutability of the options outside the class.
- # an unknown number of iterables (.keys(),.values(),generator,...) can't be directly copied, in this case
- # we recourse to make them lists before, but thru an exceptions
- # this is the old code, which is not maintenable with more types
- # options = copy(list(p_options) if isinstance(p_options,(type({}.keys()),type({}.values()))) else p_options)
- # We check that the options are iterable. If not we abort. Else we copy them to lists
- # it options is a dictionary we use the values as entries of the list
- # if options is a string object, each character becomes an entry
- # if options is a list, we implictily build a copy to maintain immutability
- if not isinstance(p_options,Iterable):
- log(f"Objects of type {type(p_options)} is not iterable, and are not supported at Menu",fg="red")
- log(f"invalid parameter at Menu() call was at <{sys._getframe(1).f_code.co_name}>",level=logging.WARNING)
- raise RequirementError("Menu() requires an iterable as option.")
-
- self._default_str = str(_('(default)'))
-
- if isinstance(p_options,dict):
+ if isinstance(p_options, Dict):
options = list(p_options.keys())
else:
options = list(p_options)
if not options:
- log(" * Menu didn't find any options to choose from * ", fg='red')
- log(f"invalid parameter at Menu() call was at <{sys._getframe(1).f_code.co_name}>",level=logging.WARNING)
raise RequirementError('Menu.__init__() requires at least one option to proceed.')
if any([o for o in options if not isinstance(o, str)]):
- log(" * Menu options must be of type string * ", fg='red')
- log(f"invalid parameter at Menu() call was at <{sys._getframe(1).f_code.co_name}>",level=logging.WARNING)
raise RequirementError('Menu.__init__() requires the options to be of type string')
if sort:
@@ -152,7 +149,6 @@ class Menu(TerminalMenu):
self._multi = multi
self._raise_error_on_interrupt = allow_reset
self._raise_error_warning_msg = allow_reset_warning_msg
- self._preview_command = preview_command
action_info = ''
if skip:
@@ -179,10 +175,28 @@ class Menu(TerminalMenu):
if default_option:
# if a default value was specified we move that one
# to the top of the list and mark it as default as well
- default = f'{default_option} {self._default_str}'
- self._menu_options = [default] + [o for o in self._menu_options if default_option != o]
+ self._menu_options = [self._default_menu_value] + [o for o in self._menu_options if default_option != o]
+
+ if display_back_option and not multi and skip:
+ skip_empty_entries = True
+ self._menu_options += ['', self.back()]
+
+ if extra_bottom_space:
+ skip_empty_entries = True
+ self._menu_options += ['']
+
+ preset_list: Optional[List[str]] = None
+
+ if preset_values and isinstance(preset_values, str):
+ preset_list = [preset_values]
- self._preselection(preset_values,cursor_index)
+ calc_cursor_idx = self._determine_cursor_pos(preset_list, cursor_index)
+
+ # when we're not in multi selection mode we don't care about
+ # passing the pre-selection list to the menu as the position
+ # of the cursor is the one determining the pre-selection
+ if not self._multi:
+ preset_values = None
cursor = "> "
main_menu_cursor_style = ("fg_cyan", "bold")
@@ -194,13 +208,10 @@ class Menu(TerminalMenu):
menu_cursor=cursor,
menu_cursor_style=main_menu_cursor_style,
menu_highlight_style=main_menu_style,
- # cycle_cursor=True,
- # clear_screen=True,
multi_select=multi,
- # show_search_hint=True,
- preselected_entries=self.preset_values,
- cursor_index=self.cursor_index,
- preview_command=lambda x: self._preview_wrapper(preview_command, x),
+ preselected_entries=preset_values,
+ cursor_index=calc_cursor_idx,
+ preview_command=lambda x: self._show_preview(preview_command, x),
preview_size=preview_size,
preview_title=preview_title,
raise_error_on_interrupt=self._raise_error_on_interrupt,
@@ -212,6 +223,28 @@ class Menu(TerminalMenu):
skip_empty_entries=skip_empty_entries
)
+ @property
+ def _default_menu_value(self) -> str:
+ default_str = str(_('(default)'))
+ return f'{self._default_option} {default_str}'
+
+ def _show_preview(
+ self,
+ preview_command: Optional[Callable[[Any], str | None]],
+ selection: str
+ ) -> Optional[str]:
+ if selection == self.back():
+ return None
+
+ if preview_command:
+ if self._default_option is not None and self._default_menu_value == selection:
+ selection = self._default_option
+
+ if res := preview_command(selection):
+ return res.rstrip('\n')
+
+ return None
+
def _show(self) -> MenuSelection:
try:
idx = self.show()
@@ -219,45 +252,47 @@ class Menu(TerminalMenu):
return MenuSelection(type_=MenuSelectionType.Reset)
def check_default(elem):
- if self._default_option is not None and f'{self._default_option} {self._default_str}' in elem:
+ if self._default_option is not None and self._default_menu_value in elem:
return self._default_option
else:
return elem
if idx is not None:
- if isinstance(idx, (list, tuple)):
+ if isinstance(idx, (list, tuple)): # on multi selection
results = []
for i in idx:
option = check_default(self._menu_options[i])
results.append(option)
return MenuSelection(type_=MenuSelectionType.Selection, value=results)
- else:
+ else: # on single selection
result = check_default(self._menu_options[idx])
return MenuSelection(type_=MenuSelectionType.Selection, value=result)
else:
return MenuSelection(type_=MenuSelectionType.Skip)
- def _preview_wrapper(self, preview_command: Optional[Callable], current_selection: str) -> Optional[str]:
- if preview_command:
- if self._default_option is not None and f'{self._default_option} {self._default_str}' == current_selection:
- current_selection = self._default_option
- return preview_command(current_selection)
- return None
-
def run(self) -> MenuSelection:
- ret = self._show()
+ Menu._menu_is_active = True
+
+ selection = self._show()
- if ret.type_ == MenuSelectionType.Reset:
- if self._raise_error_on_interrupt and len(self._raise_error_warning_msg) > 0:
+ if selection.type_ == MenuSelectionType.Reset:
+ if self._raise_error_on_interrupt and self._raise_error_warning_msg is not None:
response = Menu(self._raise_error_warning_msg, Menu.yes_no(), skip=False).run()
if response.value == Menu.no():
return self.run()
- elif ret.type_ is MenuSelectionType.Skip:
+ elif selection.type_ is MenuSelectionType.Skip:
if not self._skip:
system('clear')
return self.run()
- return ret
+ if selection.type_ == MenuSelectionType.Selection:
+ if selection.value == self.back():
+ selection.type_ = MenuSelectionType.Skip
+ selection.value = None
+
+ Menu._menu_is_active = False
+
+ return selection
def set_cursor_pos(self,pos :int):
if pos and 0 < pos < len(self._menu_entries):
@@ -269,31 +304,47 @@ class Menu(TerminalMenu):
pos = self._menu_entries.index(value)
self.set_cursor_pos(pos)
- def _preselection(self,preset_values :Union[str, List[str]] = [], cursor_index : Optional[int] = None):
- def from_preset_to_cursor():
- if preset_values:
- # if the value is not extant return 0 as cursor index
+ def _determine_cursor_pos(
+ self,
+ preset: Optional[List[str]] = None,
+ cursor_index: Optional[int] = None
+ ) -> Optional[int]:
+ """
+ The priority order to determine the cursor position is:
+ 1. A static cursor position was provided
+ 2. Preset values have been provided so the cursor will be
+ positioned on those
+ 3. A default value for a selection is given so the cursor
+ will be placed on such
+ """
+ if cursor_index:
+ return cursor_index
+
+ if preset:
+ indexes = []
+
+ for p in preset:
try:
- if isinstance(preset_values,str):
- self.cursor_index = self._menu_options.index(self.preset_values)
- else: # should return an error, but this is smoother
- self.cursor_index = self._menu_options.index(self.preset_values[0])
- except ValueError:
- self.cursor_index = 0
-
- self.cursor_index = cursor_index
- if not preset_values:
- self.preset_values = None
- return
-
- self.preset_values = preset_values
+ # the options of the table selection menu
+ # are already escaped so we have to escape
+ # the preset values as well for the comparison
+ if '|' in p:
+ p = p.replace('|', '\\|')
+
+ if p in self._menu_options:
+ idx = self._menu_options.index(p)
+ else:
+ idx = self._menu_options.index(self._default_menu_value)
+ indexes.append(idx)
+ except (IndexError, ValueError):
+ debug(f'Error finding index of {p}: {self._menu_options}')
+
+ if len(indexes) == 0:
+ indexes.append(0)
+
+ return indexes[0]
+
if self._default_option:
- if isinstance(preset_values,str) and self._default_option == preset_values:
- self.preset_values = f"{preset_values} {self._default_str}"
- elif isinstance(preset_values,(list,tuple)) and self._default_option in preset_values:
- idx = preset_values.index(self._default_option)
- self.preset_values[idx] = f"{preset_values[idx]} {self._default_str}"
- if cursor_index is None or not self._multi:
- from_preset_to_cursor()
- if not self._multi: # Not supported by the infraestructure
- self.preset_values = None
+ return self._menu_options.index(self._default_menu_value)
+
+ return None
diff --git a/archinstall/lib/menu/simple_menu.py b/archinstall/lib/menu/simple_menu.py
deleted file mode 100644
index 1980e2ce..00000000
--- a/archinstall/lib/menu/simple_menu.py
+++ /dev/null
@@ -1,2002 +0,0 @@
-"""
-This file is copied over from the simple-term-menu project
-(https://github.com/IngoMeyer441/simple-term-menu)
-In order to comply with installation methods of Arch Linux.
-We here by copy the MIT license attached to the project at the time of copy:
-
-Copyright 2021 Forschungszentrum Jülich GmbH
-
-Permission is hereby granted, free of charge, to any person obtaining a copy of
-this software and associated documentation files (the "Software"), to deal in
-the Software without restriction, including without limitation the rights to
-use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
-the Software, and to permit persons to whom the Software is furnished to do so,
-subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
-FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
-COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
-IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
-CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
-"""
-import argparse
-import copy
-import ctypes
-import io
-import locale
-import os
-import platform
-import re
-import shlex
-import signal
-import string
-import subprocess
-import sys
-from locale import getlocale
-from types import FrameType
-from typing import (
- Any,
- Callable,
- Dict,
- Iterable,
- Iterator,
- List,
- Match,
- Optional,
- Pattern,
- Sequence,
- Set,
- TextIO,
- Tuple,
- Union,
- cast,
-)
-
-try:
- import termios
-except ImportError as e:
- raise NotImplementedError('"{}" is currently not supported.'.format(platform.system())) from e
-
-__author__ = "Ingo Meyer"
-__email__ = "i.meyer@fz-juelich.de"
-__copyright__ = "Copyright © 2021 Forschungszentrum Jülich GmbH. All rights reserved."
-__license__ = "MIT"
-__version_info__ = (1, 5, 0)
-__version__ = ".".join(map(str, __version_info__))
-
-
-DEFAULT_ACCEPT_KEYS = ("enter",)
-DEFAULT_CLEAR_MENU_ON_EXIT = True
-DEFAULT_CLEAR_SCREEN = False
-DEFAULT_CYCLE_CURSOR = True
-DEFAULT_EXIT_ON_SHORTCUT = True
-DEFAULT_MENU_CURSOR = "> "
-DEFAULT_MENU_CURSOR_STYLE = ("fg_red", "bold")
-DEFAULT_MENU_HIGHLIGHT_STYLE = ("standout",)
-DEFAULT_MULTI_SELECT = False
-DEFAULT_MULTI_SELECT_CURSOR = "[*] "
-DEFAULT_MULTI_SELECT_CURSOR_BRACKETS_STYLE = ("fg_gray",)
-DEFAULT_MULTI_SELECT_CURSOR_STYLE = ("fg_yellow", "bold")
-DEFAULT_MULTI_SELECT_KEYS = (" ", "tab")
-DEFAULT_MULTI_SELECT_SELECT_ON_ACCEPT = True
-DEFAULT_PREVIEW_BORDER = True
-DEFAULT_PREVIEW_SIZE = 0.25
-DEFAULT_PREVIEW_TITLE = "preview"
-DEFAULT_QUIT_KEYS = ("escape", "q")
-DEFAULT_SEARCH_CASE_SENSITIVE = False
-DEFAULT_SEARCH_HIGHLIGHT_STYLE = ("fg_black", "bg_yellow", "bold")
-DEFAULT_SEARCH_KEY = "/"
-DEFAULT_SHORTCUT_BRACKETS_HIGHLIGHT_STYLE = ("fg_gray",)
-DEFAULT_SHORTCUT_KEY_HIGHLIGHT_STYLE = ("fg_blue",)
-DEFAULT_SHOW_MULTI_SELECT_HINT = False
-DEFAULT_SHOW_SEARCH_HINT = False
-DEFAULT_SHOW_SHORTCUT_HINTS = False
-DEFAULT_SHOW_SHORTCUT_HINTS_IN_STATUS_BAR = True
-DEFAULT_STATUS_BAR_BELOW_PREVIEW = False
-DEFAULT_STATUS_BAR_STYLE = ("fg_yellow", "bg_black")
-MIN_VISIBLE_MENU_ENTRIES_COUNT = 3
-
-
-class InvalidParameterCombinationError(Exception):
- pass
-
-
-class InvalidStyleError(Exception):
- pass
-
-
-class NoMenuEntriesError(Exception):
- pass
-
-
-class PreviewCommandFailedError(Exception):
- pass
-
-
-class UnknownMenuEntryError(Exception):
- pass
-
-
-def get_locale() -> str:
- user_locale = locale.getlocale()[1]
- if user_locale is None:
- return "ascii"
- else:
- return user_locale.lower()
-
-
-def wcswidth(text: str) -> int:
- if not hasattr(wcswidth, "libc"):
- if platform.system() == "Darwin":
- wcswidth.libc = ctypes.cdll.LoadLibrary("libSystem.dylib") # type: ignore
- else:
- wcswidth.libc = ctypes.cdll.LoadLibrary("libc.so.6") # type: ignore
- user_locale = get_locale()
- # First replace any null characters with the unicode replacement character (U+FFFD) since they cannot be passed
- # in a `c_wchar_p`
- encoded_text = text.replace("\0", "\uFFFD").encode(encoding=user_locale, errors="replace")
- return wcswidth.libc.wcswidth( # type: ignore
- ctypes.c_wchar_p(encoded_text.decode(encoding=user_locale)), len(encoded_text)
- )
-
-
-def static_variables(**variables: Any) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
- def decorator(f: Callable[..., Any]) -> Callable[..., Any]:
- for key, value in variables.items():
- setattr(f, key, value)
- return f
-
- return decorator
-
-
-class BoxDrawingCharacters:
- if getlocale()[1] == "UTF-8":
- # Unicode box characters
- horizontal = "─"
- vertical = "│"
- upper_left = "┌"
- upper_right = "┐"
- lower_left = "└"
- lower_right = "┘"
- else:
- # ASCII box characters
- horizontal = "-"
- vertical = "|"
- upper_left = "+"
- upper_right = "+"
- lower_left = "+"
- lower_right = "+"
-
-
-class TerminalMenu:
- class Search:
- def __init__(
- self,
- menu_entries: Iterable[str],
- search_text: Optional[str] = None,
- case_senitive: bool = False,
- show_search_hint: bool = False,
- ):
- self._menu_entries = menu_entries
- self._case_sensitive = case_senitive
- self._show_search_hint = show_search_hint
- self._matches = [] # type: List[Tuple[int, Match[str]]]
- self._search_regex = None # type: Optional[Pattern[str]]
- self._change_callback = None # type: Optional[Callable[[], None]]
- # Use the property setter since it has some more logic
- self.search_text = search_text
-
- def _update_matches(self) -> None:
- if self._search_regex is None:
- self._matches = []
- else:
- matches = []
- for i, menu_entry in enumerate(self._menu_entries):
- match_obj = self._search_regex.search(menu_entry)
- if match_obj:
- matches.append((i, match_obj))
- self._matches = matches
-
- @property
- def matches(self) -> List[Tuple[int, Match[str]]]:
- return list(self._matches)
-
- @property
- def search_regex(self) -> Optional[Pattern[str]]:
- return self._search_regex
-
- @property
- def search_text(self) -> Optional[str]:
- return self._search_text
-
- @search_text.setter
- def search_text(self, text: Optional[str]) -> None:
- self._search_text = text
- search_text = self._search_text
- self._search_regex = None
- while search_text and self._search_regex is None:
- try:
- self._search_regex = re.compile(search_text, flags=re.IGNORECASE if not self._case_sensitive else 0)
- except re.error:
- search_text = search_text[:-1]
- self._update_matches()
- if self._change_callback:
- self._change_callback()
-
- @property
- def change_callback(self) -> Optional[Callable[[], None]]:
- return self._change_callback
-
- @change_callback.setter
- def change_callback(self, callback: Optional[Callable[[], None]]) -> None:
- self._change_callback = callback
-
- @property
- def occupied_lines_count(self) -> int:
- if not self and not self._show_search_hint:
- return 0
- else:
- return 1
-
- def __bool__(self) -> bool:
- return self._search_text is not None
-
- def __contains__(self, menu_index: int) -> bool:
- return any(i == menu_index for i, _ in self._matches)
-
- def __len__(self) -> int:
- return wcswidth(self._search_text) if self._search_text is not None else 0
-
- class Selection:
- def __init__(self, num_menu_entries: int, preselected_indices: Optional[Iterable[int]] = None):
- self._num_menu_entries = num_menu_entries
- self._selected_menu_indices = set(preselected_indices) if preselected_indices is not None else set()
-
- def clear(self) -> None:
- self._selected_menu_indices.clear()
-
- def add(self, menu_index: int) -> None:
- self[menu_index] = True
-
- def remove(self, menu_index: int) -> None:
- self[menu_index] = False
-
- def toggle(self, menu_index: int) -> bool:
- self[menu_index] = menu_index not in self._selected_menu_indices
- return self[menu_index]
-
- def __bool__(self) -> bool:
- return bool(self._selected_menu_indices)
-
- def __contains__(self, menu_index: int) -> bool:
- return menu_index in self._selected_menu_indices
-
- def __getitem__(self, menu_index: int) -> bool:
- return menu_index in self._selected_menu_indices
-
- def __setitem__(self, menu_index: int, is_selected: bool) -> None:
- if is_selected:
- self._selected_menu_indices.add(menu_index)
- else:
- self._selected_menu_indices.remove(menu_index)
-
- def __iter__(self) -> Iterator[int]:
- return iter(self._selected_menu_indices)
-
- @property
- def selected_menu_indices(self) -> Tuple[int, ...]:
- return tuple(sorted(self._selected_menu_indices))
-
- class View:
- def __init__(
- self,
- menu_entries: Iterable[str],
- search: "TerminalMenu.Search",
- selection: "TerminalMenu.Selection",
- viewport: "TerminalMenu.Viewport",
- cycle_cursor: bool = True,
- skip_indices: List[int] = [],
- ):
- self._menu_entries = list(menu_entries)
- self._search = search
- self._selection = selection
- self._viewport = viewport
- self._cycle_cursor = cycle_cursor
- self._active_displayed_index = None # type: Optional[int]
- self._skip_indices = skip_indices
- self.update_view()
-
- def update_view(self) -> None:
- if self._search and self._search.search_text != "":
- self._displayed_index_to_menu_index = tuple(i for i, match_obj in self._search.matches)
- else:
- self._displayed_index_to_menu_index = tuple(range(len(self._menu_entries)))
- self._menu_index_to_displayed_index = {
- menu_index: displayed_index
- for displayed_index, menu_index in enumerate(self._displayed_index_to_menu_index)
- }
- self._active_displayed_index = 0 if self._displayed_index_to_menu_index else None
- self._viewport.search_lines_count = self._search.occupied_lines_count
- self._viewport.keep_visible(self._active_displayed_index)
-
- def increment_active_index(self) -> None:
- if self._active_displayed_index is not None:
- if self._active_displayed_index + 1 < len(self._displayed_index_to_menu_index):
- self._active_displayed_index += 1
- elif self._cycle_cursor:
- self._active_displayed_index = 0
- self._viewport.keep_visible(self._active_displayed_index)
-
- if self._active_displayed_index in self._skip_indices:
- self.increment_active_index()
-
- def decrement_active_index(self) -> None:
- if self._active_displayed_index is not None:
- if self._active_displayed_index > 0:
- self._active_displayed_index -= 1
- elif self._cycle_cursor:
- self._active_displayed_index = len(self._displayed_index_to_menu_index) - 1
- self._viewport.keep_visible(self._active_displayed_index)
-
- if self._active_displayed_index in self._skip_indices:
- self.decrement_active_index()
-
- def is_visible(self, menu_index: int) -> bool:
- return menu_index in self._menu_index_to_displayed_index and (
- self._viewport.lower_index
- <= self._menu_index_to_displayed_index[menu_index]
- <= self._viewport.upper_index
- )
-
- def convert_menu_index_to_displayed_index(self, menu_index: int) -> Optional[int]:
- if menu_index in self._menu_index_to_displayed_index:
- return self._menu_index_to_displayed_index[menu_index]
- else:
- return None
-
- def convert_displayed_index_to_menu_index(self, displayed_index: int) -> int:
- return self._displayed_index_to_menu_index[displayed_index]
-
- @property
- def active_menu_index(self) -> Optional[int]:
- if self._active_displayed_index is not None:
- return self._displayed_index_to_menu_index[self._active_displayed_index]
- else:
- return None
-
- @active_menu_index.setter
- def active_menu_index(self, value: int) -> None:
- self._selected_index = value
- self._active_displayed_index = [
- displayed_index
- for displayed_index, menu_index in enumerate(self._displayed_index_to_menu_index)
- if menu_index == value
- ][0]
- self._viewport.keep_visible(self._active_displayed_index)
-
- @property
- def active_displayed_index(self) -> Optional[int]:
- return self._active_displayed_index
-
- @property
- def displayed_selected_indices(self) -> List[int]:
- return [
- self._menu_index_to_displayed_index[selected_index]
- for selected_index in self._selection
- if selected_index in self._menu_index_to_displayed_index
- ]
-
- def __bool__(self) -> bool:
- return self._active_displayed_index is not None
-
- def __iter__(self) -> Iterator[Tuple[int, int, str]]:
- for displayed_index, menu_index in enumerate(self._displayed_index_to_menu_index):
- if self._viewport.lower_index <= displayed_index <= self._viewport.upper_index:
- yield (displayed_index, menu_index, self._menu_entries[menu_index])
-
- class Viewport:
- def __init__(
- self,
- num_menu_entries: int,
- title_lines_count: int,
- status_bar_lines_count: int,
- preview_lines_count: int,
- search_lines_count: int,
- ):
- self._num_menu_entries = num_menu_entries
- self._title_lines_count = title_lines_count
- self._status_bar_lines_count = status_bar_lines_count
- # Use the property setter since it has some more logic
- self.preview_lines_count = preview_lines_count
- self.search_lines_count = search_lines_count
- self._num_lines = self._calculate_num_lines()
- self._viewport = (0, min(self._num_menu_entries, self._num_lines) - 1)
- self.keep_visible(cursor_position=None, refresh_terminal_size=False)
-
- def _calculate_num_lines(self) -> int:
- return (
- TerminalMenu._num_lines()
- - self._title_lines_count
- - self._status_bar_lines_count
- - self._preview_lines_count
- - self._search_lines_count
- )
-
- def keep_visible(self, cursor_position: Optional[int], refresh_terminal_size: bool = True) -> None:
- # Treat `cursor_position=None` like `cursor_position=0`
- if cursor_position is None:
- cursor_position = 0
- if refresh_terminal_size:
- self.update_terminal_size()
- if self._viewport[0] <= cursor_position <= self._viewport[1]:
- # Cursor is already visible
- return
- if cursor_position < self._viewport[0]:
- scroll_num = cursor_position - self._viewport[0]
- else:
- scroll_num = cursor_position - self._viewport[1]
- self._viewport = (self._viewport[0] + scroll_num, self._viewport[1] + scroll_num)
-
- def update_terminal_size(self) -> None:
- num_lines = self._calculate_num_lines()
- if num_lines != self._num_lines:
- # First let the upper index grow or shrink
- upper_index = min(num_lines, self._num_menu_entries) - 1
- # Then, use as much space as possible for the `lower_index`
- lower_index = max(0, upper_index - num_lines)
- self._viewport = (lower_index, upper_index)
- self._num_lines = num_lines
-
- @property
- def lower_index(self) -> int:
- return self._viewport[0]
-
- @property
- def upper_index(self) -> int:
- return self._viewport[1]
-
- @property
- def viewport(self) -> Tuple[int, int]:
- return self._viewport
-
- @property
- def size(self) -> int:
- return self._viewport[1] - self._viewport[0] + 1
-
- @property
- def num_menu_entries(self) -> int:
- return self._num_menu_entries
-
- @property
- def title_lines_count(self) -> int:
- return self._title_lines_count
-
- @property
- def status_bar_lines_count(self) -> int:
- return self._status_bar_lines_count
-
- @status_bar_lines_count.setter
- def status_bar_lines_count(self, value: int) -> None:
- self._status_bar_lines_count = value
-
- @property
- def preview_lines_count(self) -> int:
- return self._preview_lines_count
-
- @preview_lines_count.setter
- def preview_lines_count(self, value: int) -> None:
- self._preview_lines_count = min(
- value if value >= 3 else 0,
- TerminalMenu._num_lines()
- - self._title_lines_count
- - self._status_bar_lines_count
- - MIN_VISIBLE_MENU_ENTRIES_COUNT,
- )
-
- @property
- def search_lines_count(self) -> int:
- return self._search_lines_count
-
- @search_lines_count.setter
- def search_lines_count(self, value: int) -> None:
- self._search_lines_count = value
-
- @property
- def must_scroll(self) -> bool:
- return self._num_menu_entries > self._num_lines
-
- _codename_to_capname = {
- "bg_black": "setab 0",
- "bg_blue": "setab 4",
- "bg_cyan": "setab 6",
- "bg_gray": "setab 7",
- "bg_green": "setab 2",
- "bg_purple": "setab 5",
- "bg_red": "setab 1",
- "bg_yellow": "setab 3",
- "bold": "bold",
- "clear": "clear",
- "colors": "colors",
- "cursor_down": "cud1",
- "cursor_invisible": "civis",
- "cursor_left": "cub1",
- "cursor_right": "cuf1",
- "cursor_up": "cuu1",
- "cursor_visible": "cnorm",
- "delete_line": "dl1",
- "down": "kcud1",
- "enter_application_mode": "smkx",
- "exit_application_mode": "rmkx",
- "fg_black": "setaf 0",
- "fg_blue": "setaf 4",
- "fg_cyan": "setaf 6",
- "fg_gray": "setaf 7",
- "fg_green": "setaf 2",
- "fg_purple": "setaf 5",
- "fg_red": "setaf 1",
- "fg_yellow": "setaf 3",
- "italics": "sitm",
- "reset_attributes": "sgr0",
- "standout": "smso",
- "underline": "smul",
- "up": "kcuu1",
- }
- _name_to_control_character = {
- "backspace": "", # Is assigned later in `self._init_backspace_control_character`
- "ctrl-j": "\012",
- "ctrl-k": "\013",
- "enter": "\015",
- "escape": "\033",
- "tab": "\t",
- }
- _codenames = tuple(_codename_to_capname.keys())
- _codename_to_terminal_code = None # type: Optional[Dict[str, str]]
- _terminal_code_to_codename = None # type: Optional[Dict[str, str]]
-
- def __init__(
- self,
- menu_entries: Iterable[str],
- *,
- accept_keys: Iterable[str] = DEFAULT_ACCEPT_KEYS,
- clear_menu_on_exit: bool = DEFAULT_CLEAR_MENU_ON_EXIT,
- clear_screen: bool = DEFAULT_CLEAR_SCREEN,
- cursor_index: Optional[int] = None,
- cycle_cursor: bool = DEFAULT_CYCLE_CURSOR,
- exit_on_shortcut: bool = DEFAULT_EXIT_ON_SHORTCUT,
- menu_cursor: Optional[str] = DEFAULT_MENU_CURSOR,
- menu_cursor_style: Optional[Iterable[str]] = DEFAULT_MENU_CURSOR_STYLE,
- menu_highlight_style: Optional[Iterable[str]] = DEFAULT_MENU_HIGHLIGHT_STYLE,
- multi_select: bool = DEFAULT_MULTI_SELECT,
- multi_select_cursor: str = DEFAULT_MULTI_SELECT_CURSOR,
- multi_select_cursor_brackets_style: Optional[Iterable[str]] = DEFAULT_MULTI_SELECT_CURSOR_BRACKETS_STYLE,
- multi_select_cursor_style: Optional[Iterable[str]] = DEFAULT_MULTI_SELECT_CURSOR_STYLE,
- multi_select_empty_ok: bool = False,
- multi_select_keys: Optional[Iterable[str]] = DEFAULT_MULTI_SELECT_KEYS,
- multi_select_select_on_accept: bool = DEFAULT_MULTI_SELECT_SELECT_ON_ACCEPT,
- preselected_entries: Optional[Iterable[Union[str, int]]] = None,
- preview_border: bool = DEFAULT_PREVIEW_BORDER,
- preview_command: Optional[Union[str, Callable[[str], str]]] = None,
- preview_size: float = DEFAULT_PREVIEW_SIZE,
- preview_title: str = DEFAULT_PREVIEW_TITLE,
- quit_keys: Iterable[str] = DEFAULT_QUIT_KEYS,
- raise_error_on_interrupt: bool = False,
- search_case_sensitive: bool = DEFAULT_SEARCH_CASE_SENSITIVE,
- search_highlight_style: Optional[Iterable[str]] = DEFAULT_SEARCH_HIGHLIGHT_STYLE,
- search_key: Optional[str] = DEFAULT_SEARCH_KEY,
- shortcut_brackets_highlight_style: Optional[Iterable[str]] = DEFAULT_SHORTCUT_BRACKETS_HIGHLIGHT_STYLE,
- shortcut_key_highlight_style: Optional[Iterable[str]] = DEFAULT_SHORTCUT_KEY_HIGHLIGHT_STYLE,
- show_multi_select_hint: bool = DEFAULT_SHOW_MULTI_SELECT_HINT,
- show_multi_select_hint_text: Optional[str] = None,
- show_search_hint: bool = DEFAULT_SHOW_SEARCH_HINT,
- show_search_hint_text: Optional[str] = None,
- show_shortcut_hints: bool = DEFAULT_SHOW_SHORTCUT_HINTS,
- show_shortcut_hints_in_status_bar: bool = DEFAULT_SHOW_SHORTCUT_HINTS_IN_STATUS_BAR,
- skip_empty_entries: bool = False,
- status_bar: Optional[Union[str, Iterable[str], Callable[[str], str]]] = None,
- status_bar_below_preview: bool = DEFAULT_STATUS_BAR_BELOW_PREVIEW,
- status_bar_style: Optional[Iterable[str]] = DEFAULT_STATUS_BAR_STYLE,
- title: Optional[Union[str, Iterable[str]]] = None
- ):
- def extract_shortcuts_menu_entries_and_preview_arguments(
- entries: Iterable[str],
- ) -> Tuple[List[str], List[Optional[str]], List[Optional[str]], List[int]]:
- separator_pattern = re.compile(r"([^\\])\|")
- escaped_separator_pattern = re.compile(r"\\\|")
- menu_entry_pattern = re.compile(r"^(?:\[(\S)\]\s*)?([^\x1F]+)(?:\x1F([^\x1F]*))?")
- shortcut_keys = [] # type: List[Optional[str]]
- menu_entries = [] # type: List[str]
- preview_arguments = [] # type: List[Optional[str]]
- skip_indices = [] # type: List[int]
-
- for idx, entry in enumerate(entries):
- if entry is None or (entry == "" and skip_empty_entries):
- shortcut_keys.append(None)
- menu_entries.append("")
- preview_arguments.append(None)
- skip_indices.append(idx)
- else:
- unit_separated_entry = escaped_separator_pattern.sub("|", separator_pattern.sub("\\1\x1F", entry))
- match_obj = menu_entry_pattern.match(unit_separated_entry)
- # this is none in case the entry was an emtpy string which
- # will be interpreted as a separator
- assert match_obj is not None
- shortcut_key = match_obj.group(1)
- display_text = match_obj.group(2)
- preview_argument = match_obj.group(3)
- shortcut_keys.append(shortcut_key)
- menu_entries.append(display_text)
- preview_arguments.append(preview_argument)
-
- return menu_entries, shortcut_keys, preview_arguments, skip_indices
-
- def convert_preselected_entries_to_indices(
- preselected_indices_or_entries: Iterable[Union[str, int]]
- ) -> Set[int]:
- menu_entry_to_indices = {} # type: Dict[str, Set[int]]
- for menu_index, menu_entry in enumerate(self._menu_entries):
- menu_entry_to_indices.setdefault(menu_entry, set())
- menu_entry_to_indices[menu_entry].add(menu_index)
- preselected_indices = set()
- for item in preselected_indices_or_entries:
- if isinstance(item, int):
- if 0 <= item < len(self._menu_entries):
- preselected_indices.add(item)
- else:
- raise IndexError(
- "Error: {} is outside the allowable range of 0..{}.".format(
- item, len(self._menu_entries) - 1
- )
- )
- elif isinstance(item, str):
- try:
- preselected_indices.update(menu_entry_to_indices[item])
- except KeyError as e:
- raise UnknownMenuEntryError('Pre-selection "{}" is not a valid menu entry.'.format(item)) from e
- else:
- raise ValueError('"preselected_entries" must either contain integers or strings.')
- return preselected_indices
-
- def setup_title_or_status_bar_lines(
- title_or_status_bar: Optional[Union[str, Iterable[str]]],
- show_shortcut_hints: bool,
- menu_entries: Iterable[str],
- shortcut_keys: Iterable[Optional[str]],
- shortcut_hints_in_parentheses: bool,
- ) -> Tuple[str, ...]:
- if title_or_status_bar is None:
- lines = [] # type: List[str]
- elif isinstance(title_or_status_bar, str):
- lines = title_or_status_bar.split("\n")
- else:
- lines = list(title_or_status_bar)
- if show_shortcut_hints:
- shortcut_hints_line = self._get_shortcut_hints_line(
- menu_entries, shortcut_keys, shortcut_hints_in_parentheses
- )
- if shortcut_hints_line is not None:
- lines.append(shortcut_hints_line)
- return tuple(lines)
-
- (
- self._menu_entries,
- self._shortcut_keys,
- self._preview_arguments,
- self._skip_indices,
- ) = extract_shortcuts_menu_entries_and_preview_arguments(menu_entries)
- self._shortcuts_defined = any(key is not None for key in self._shortcut_keys)
- self._accept_keys = tuple(accept_keys)
- self._clear_menu_on_exit = clear_menu_on_exit
- self._clear_screen = clear_screen
- self._cycle_cursor = cycle_cursor
- self._multi_select_empty_ok = multi_select_empty_ok
- self._exit_on_shortcut = exit_on_shortcut
- self._menu_cursor = menu_cursor if menu_cursor is not None else ""
- self._menu_cursor_style = tuple(menu_cursor_style) if menu_cursor_style is not None else ()
- self._menu_highlight_style = tuple(menu_highlight_style) if menu_highlight_style is not None else ()
- self._multi_select = multi_select
- self._multi_select_cursor = multi_select_cursor
- self._multi_select_cursor_brackets_style = (
- tuple(multi_select_cursor_brackets_style) if multi_select_cursor_brackets_style is not None else ()
- )
- self._multi_select_cursor_style = (
- tuple(multi_select_cursor_style) if multi_select_cursor_style is not None else ()
- )
- self._multi_select_keys = tuple(multi_select_keys) if multi_select_keys is not None else ()
- self._multi_select_select_on_accept = multi_select_select_on_accept
- if preselected_entries and not self._multi_select:
- raise InvalidParameterCombinationError(
- "Multi-select mode must be enabled when preselected entries are given."
- )
- self._preselected_indices = (
- convert_preselected_entries_to_indices(preselected_entries) if preselected_entries is not None else None
- )
- self._preview_border = preview_border
- self._preview_command = preview_command
- self._preview_size = preview_size
- self._preview_title = preview_title
- self._quit_keys = tuple(quit_keys)
- self._raise_error_on_interrupt = raise_error_on_interrupt
- self._search_case_sensitive = search_case_sensitive
- self._search_highlight_style = tuple(search_highlight_style) if search_highlight_style is not None else ()
- self._search_key = search_key
- self._shortcut_brackets_highlight_style = (
- tuple(shortcut_brackets_highlight_style) if shortcut_brackets_highlight_style is not None else ()
- )
- self._shortcut_key_highlight_style = (
- tuple(shortcut_key_highlight_style) if shortcut_key_highlight_style is not None else ()
- )
- self._show_search_hint = show_search_hint
- self._show_search_hint_text = show_search_hint_text
- self._show_shortcut_hints = show_shortcut_hints
- self._show_shortcut_hints_in_status_bar = show_shortcut_hints_in_status_bar
- self._status_bar_func = None # type: Optional[Callable[[str], str]]
- self._status_bar_lines = None # type: Optional[Tuple[str, ...]]
- if callable(status_bar):
- self._status_bar_func = status_bar
- else:
- self._status_bar_lines = setup_title_or_status_bar_lines(
- status_bar,
- show_shortcut_hints and show_shortcut_hints_in_status_bar,
- self._menu_entries,
- self._shortcut_keys,
- False,
- )
- self._status_bar_below_preview = status_bar_below_preview
- self._status_bar_style = tuple(status_bar_style) if status_bar_style is not None else ()
- self._title_lines = setup_title_or_status_bar_lines(
- title,
- show_shortcut_hints and not show_shortcut_hints_in_status_bar,
- self._menu_entries,
- self._shortcut_keys,
- True,
- )
- self._show_multi_select_hint = show_multi_select_hint
- self._show_multi_select_hint_text = show_multi_select_hint_text
- self._chosen_accept_key = None # type: Optional[str]
- self._chosen_menu_index = None # type: Optional[int]
- self._chosen_menu_indices = None # type: Optional[Tuple[int, ...]]
- self._paint_before_next_read = False
- self._previous_displayed_menu_height = None # type: Optional[int]
- self._reading_next_key = False
- self._search = self.Search(
- self._menu_entries,
- case_senitive=self._search_case_sensitive,
- show_search_hint=self._show_search_hint,
- )
- self._selection = self.Selection(len(self._menu_entries), self._preselected_indices)
- self._viewport = self.Viewport(
- len(self._menu_entries),
- len(self._title_lines),
- len(self._status_bar_lines) if self._status_bar_lines is not None else 0,
- 0,
- 0,
- )
- self._view = self.View(
- self._menu_entries, self._search, self._selection, self._viewport, self._cycle_cursor, self._skip_indices
- )
- if cursor_index and 0 < cursor_index < len(self._menu_entries):
- self._view.active_menu_index = cursor_index
- self._search.change_callback = self._view.update_view
- self._old_term = None # type: Optional[List[Union[int, List[bytes]]]]
- self._new_term = None # type: Optional[List[Union[int, List[bytes]]]]
- self._tty_in = None # type: Optional[TextIO]
- self._tty_out = None # type: Optional[TextIO]
- self._user_locale = get_locale()
- self._check_for_valid_styles()
- # backspace can be queried from the terminal database but is unreliable, query the terminal directly instead
- self._init_backspace_control_character()
- self._add_missing_control_characters_for_keys(self._accept_keys)
- self._add_missing_control_characters_for_keys(self._quit_keys)
- self._init_terminal_codes()
-
- @staticmethod
- def _get_shortcut_hints_line(
- menu_entries: Iterable[str],
- shortcut_keys: Iterable[Optional[str]],
- shortcut_hints_in_parentheses: bool,
- ) -> Optional[str]:
- shortcut_hints_line = ", ".join(
- "[{}]: {}".format(shortcut_key, menu_entry)
- for shortcut_key, menu_entry in zip(shortcut_keys, menu_entries)
- if shortcut_key is not None
- )
- if shortcut_hints_line != "":
- if shortcut_hints_in_parentheses:
- return "(" + shortcut_hints_line + ")"
- else:
- return shortcut_hints_line
- return None
-
- @staticmethod
- def _get_keycode_for_key(key: str) -> str:
- if len(key) == 1:
- # One letter keys represent themselves
- return key
- alt_modified_regex = re.compile(r"[Aa]lt-(\S)")
- ctrl_modified_regex = re.compile(r"[Cc]trl-(\S)")
- match_obj = alt_modified_regex.match(key)
- if match_obj:
- return "\033" + match_obj.group(1)
- match_obj = ctrl_modified_regex.match(key)
- if match_obj:
- # Ctrl + key is interpreted by terminals as the ascii code of that key minus 64
- ctrl_code_ascii = ord(match_obj.group(1).upper()) - 64
- if ctrl_code_ascii < 0:
- # Interpret negative ascii codes as unsigned 7-Bit integers
- ctrl_code_ascii = ctrl_code_ascii & 0x80 - 1
- return chr(ctrl_code_ascii)
- raise ValueError('Cannot interpret the given key "{}".'.format(key))
-
- @classmethod
- def _init_backspace_control_character(self) -> None:
- try:
- with open("/dev/tty", "r") as tty:
- stty_output = subprocess.check_output(["stty", "-a"], universal_newlines=True, stdin=tty)
- name_to_keycode_regex = re.compile(r"^\s*(\S+)\s*=\s*\^(\S+)\s*$")
- for field in stty_output.split(";"):
- match_obj = name_to_keycode_regex.match(field)
- if not match_obj:
- continue
- name, ctrl_code = match_obj.group(1), match_obj.group(2)
- if name != "erase":
- continue
- self._name_to_control_character["backspace"] = self._get_keycode_for_key("ctrl-" + ctrl_code)
- return
- except subprocess.CalledProcessError:
- pass
- # Backspace control character could not be queried, assume `<Ctrl-?>` (is most often used)
- self._name_to_control_character["backspace"] = "\177"
-
- @classmethod
- def _add_missing_control_characters_for_keys(cls, keys: Iterable[str]) -> None:
- for key in keys:
- if key not in cls._name_to_control_character and key not in string.ascii_letters:
- cls._name_to_control_character[key] = cls._get_keycode_for_key(key)
-
- @classmethod
- def _init_terminal_codes(cls) -> None:
- if cls._codename_to_terminal_code is not None:
- return
- supported_colors = int(cls._query_terminfo_database("colors"))
- cls._codename_to_terminal_code = {
- codename: cls._query_terminfo_database(codename)
- if not (codename.startswith("bg_") or codename.startswith("fg_")) or supported_colors >= 8
- else ""
- for codename in cls._codenames
- }
- cls._codename_to_terminal_code.update(cls._name_to_control_character)
- cls._terminal_code_to_codename = {
- terminal_code: codename for codename, terminal_code in cls._codename_to_terminal_code.items()
- }
-
- @classmethod
- def _query_terminfo_database(cls, codename: str) -> str:
- if codename in cls._codename_to_capname:
- capname = cls._codename_to_capname[codename]
- else:
- capname = codename
- try:
- return subprocess.check_output(["tput"] + capname.split(), universal_newlines=True)
- except subprocess.CalledProcessError as e:
- # The return code 1 indicates a missing terminal capability
- if e.returncode == 1:
- return ""
- raise e
-
- @classmethod
- def _num_lines(self) -> int:
- return int(self._query_terminfo_database("lines"))
-
- @classmethod
- def _num_cols(self) -> int:
- return int(self._query_terminfo_database("cols"))
-
- def _check_for_valid_styles(self) -> None:
- invalid_styles = []
- for style_tuple in (
- self._menu_cursor_style,
- self._menu_highlight_style,
- self._search_highlight_style,
- self._shortcut_key_highlight_style,
- self._shortcut_brackets_highlight_style,
- self._status_bar_style,
- self._multi_select_cursor_brackets_style,
- self._multi_select_cursor_style,
- ):
- for style in style_tuple:
- if style not in self._codename_to_capname:
- invalid_styles.append(style)
- if invalid_styles:
- if len(invalid_styles) == 1:
- raise InvalidStyleError('The style "{}" does not exist.'.format(invalid_styles[0]))
- else:
- raise InvalidStyleError('The styles ("{}") do not exist.'.format('", "'.join(invalid_styles)))
-
- def _init_term(self) -> None:
- # pylint: disable=unsubscriptable-object
- assert self._codename_to_terminal_code is not None
- self._tty_in = open("/dev/tty", "r", encoding=self._user_locale)
- self._tty_out = open("/dev/tty", "w", encoding=self._user_locale, errors="replace")
- self._old_term = termios.tcgetattr(self._tty_in.fileno())
- self._new_term = termios.tcgetattr(self._tty_in.fileno())
- # set the terminal to: unbuffered, no echo and no <CR> to <NL> translation (so <enter> sends <CR> instead of
- # <NL, this is necessary to distinguish between <enter> and <Ctrl-j> since <Ctrl-j> generates <NL>)
- self._new_term[3] = cast(int, self._new_term[3]) & ~termios.ICANON & ~termios.ECHO & ~termios.ICRNL
- self._new_term[0] = cast(int, self._new_term[0]) & ~termios.ICRNL
- termios.tcsetattr(
- self._tty_in.fileno(), termios.TCSAFLUSH, cast(List[Union[int, List[Union[bytes, int]]]], self._new_term)
- )
- # Enter terminal application mode to get expected escape codes for arrow keys
- self._tty_out.write(self._codename_to_terminal_code["enter_application_mode"])
- self._tty_out.write(self._codename_to_terminal_code["cursor_invisible"])
- if self._clear_screen:
- self._tty_out.write(self._codename_to_terminal_code["clear"])
-
- def _reset_term(self) -> None:
- # pylint: disable=unsubscriptable-object
- assert self._codename_to_terminal_code is not None
- assert self._tty_in is not None
- assert self._tty_out is not None
- assert self._old_term is not None
- termios.tcsetattr(
- self._tty_out.fileno(), termios.TCSAFLUSH, cast(List[Union[int, List[Union[bytes, int]]]], self._old_term)
- )
- self._tty_out.write(self._codename_to_terminal_code["cursor_visible"])
- self._tty_out.write(self._codename_to_terminal_code["exit_application_mode"])
- if self._clear_screen:
- self._tty_out.write(self._codename_to_terminal_code["clear"])
- self._tty_in.close()
- self._tty_out.close()
-
- def _paint_menu(self) -> None:
- def get_status_bar_lines() -> Tuple[str, ...]:
- def get_multi_select_hint() -> str:
- def get_string_from_keys(keys: Sequence[str]) -> str:
- string_to_key = {
- " ": "space",
- }
- keys_string = ", ".join(
- "<" + string_to_key.get(accept_key, accept_key) + ">" for accept_key in keys
- )
- return keys_string
-
- accept_keys_string = get_string_from_keys(self._accept_keys)
- multi_select_keys_string = get_string_from_keys(self._multi_select_keys)
- if self._show_multi_select_hint_text is not None:
- return self._show_multi_select_hint_text.format(
- multi_select_keys=multi_select_keys_string, accept_keys=accept_keys_string
- )
- else:
- return "Press {} for multi-selection and {} to {}accept".format(
- multi_select_keys_string,
- accept_keys_string,
- "select and " if self._multi_select_select_on_accept else "",
- )
-
- if self._status_bar_func is not None and self._view.active_menu_index is not None:
- status_bar_lines = tuple(
- self._status_bar_func(self._menu_entries[self._view.active_menu_index]).strip().split("\n")
- )
- if self._show_shortcut_hints and self._show_shortcut_hints_in_status_bar:
- shortcut_hints_line = self._get_shortcut_hints_line(self._menu_entries, self._shortcut_keys, False)
- if shortcut_hints_line is not None:
- status_bar_lines += (shortcut_hints_line,)
- elif self._status_bar_lines is not None:
- status_bar_lines = self._status_bar_lines
- else:
- status_bar_lines = tuple()
- if self._multi_select and self._show_multi_select_hint:
- status_bar_lines += (get_multi_select_hint(),)
- return status_bar_lines
-
- def apply_style(
- style_iterable: Optional[Iterable[str]] = None, reset: bool = True, file: Optional[TextIO] = None
- ) -> None:
- # pylint: disable=unsubscriptable-object
- assert self._codename_to_terminal_code is not None
- assert self._tty_out is not None
- if file is None:
- file = self._tty_out
- if reset or style_iterable is None:
- file.write(self._codename_to_terminal_code["reset_attributes"])
- if style_iterable is not None:
- for style in style_iterable:
- file.write(self._codename_to_terminal_code[style])
-
- def print_menu_entries() -> int:
- # pylint: disable=unsubscriptable-object
- assert self._codename_to_terminal_code is not None
- assert self._tty_out is not None
- all_cursors_width = wcswidth(self._menu_cursor) + (
- wcswidth(self._multi_select_cursor) if self._multi_select else 0
- )
- current_menu_block_displayed_height = 0 # sum all written lines
- num_cols = self._num_cols()
- if self._title_lines:
- self._tty_out.write(
- len(self._title_lines) * self._codename_to_terminal_code["cursor_up"]
- + "\r"
- + "\n".join(
- (title_line[:num_cols] + (num_cols - wcswidth(title_line)) * " ")
- for title_line in self._title_lines
- )
- + "\n"
- )
- shortcut_string_len = 4 if self._shortcuts_defined else 0
- displayed_index = -1
- for displayed_index, menu_index, menu_entry in self._view:
- current_shortcut_key = self._shortcut_keys[menu_index]
- self._tty_out.write(all_cursors_width * self._codename_to_terminal_code["cursor_right"])
- if self._shortcuts_defined:
- if current_shortcut_key is not None:
- apply_style(self._shortcut_brackets_highlight_style)
- self._tty_out.write("[")
- apply_style(self._shortcut_key_highlight_style)
- self._tty_out.write(current_shortcut_key)
- apply_style(self._shortcut_brackets_highlight_style)
- self._tty_out.write("]")
- apply_style()
- else:
- self._tty_out.write(3 * " ")
- self._tty_out.write(" ")
- if menu_index == self._view.active_menu_index:
- apply_style(self._menu_highlight_style)
- if self._search and self._search.search_text != "":
- match_obj = self._search.matches[displayed_index][1]
- self._tty_out.write(
- menu_entry[: min(match_obj.start(), num_cols - all_cursors_width - shortcut_string_len)]
- )
- apply_style(self._search_highlight_style)
- self._tty_out.write(
- menu_entry[
- match_obj.start() : min(match_obj.end(), num_cols - all_cursors_width - shortcut_string_len)
- ]
- )
- apply_style()
- if menu_index == self._view.active_menu_index:
- apply_style(self._menu_highlight_style)
- self._tty_out.write(
- menu_entry[match_obj.end() : num_cols - all_cursors_width - shortcut_string_len]
- )
- else:
- self._tty_out.write(menu_entry[: num_cols - all_cursors_width - shortcut_string_len])
- if menu_index == self._view.active_menu_index:
- apply_style()
- self._tty_out.write((num_cols - wcswidth(menu_entry) - all_cursors_width - shortcut_string_len) * " ")
- if displayed_index < self._viewport.upper_index:
- self._tty_out.write("\n")
- empty_menu_lines = self._viewport.upper_index - displayed_index
- self._tty_out.write(
- max(0, empty_menu_lines - 1) * (num_cols * " " + "\n") + min(1, empty_menu_lines) * (num_cols * " ")
- )
- self._tty_out.write("\r" + (self._viewport.size - 1) * self._codename_to_terminal_code["cursor_up"])
- current_menu_block_displayed_height += self._viewport.size - 1 # sum all written lines
- return current_menu_block_displayed_height
-
- def print_search_line(current_menu_height: int) -> int:
- # pylint: disable=unsubscriptable-object
- assert self._codename_to_terminal_code is not None
- assert self._tty_out is not None
- current_menu_block_displayed_height = 0
- num_cols = self._num_cols()
- if self._search or self._show_search_hint:
- self._tty_out.write((current_menu_height + 1) * self._codename_to_terminal_code["cursor_down"])
- if self._search:
- assert self._search.search_text is not None
- self._tty_out.write(
- (
- (self._search_key if self._search_key is not None else DEFAULT_SEARCH_KEY)
- + self._search.search_text
- )[:num_cols]
- )
- self._tty_out.write((num_cols - len(self._search) - 1) * " ")
- elif self._show_search_hint:
- if self._show_search_hint_text is not None:
- search_hint = self._show_search_hint_text.format(key=self._search_key)[:num_cols]
- elif self._search_key is not None:
- search_hint = '(Press "{key}" to search)'.format(key=self._search_key)[:num_cols]
- else:
- search_hint = "(Press any letter key to search)"[:num_cols]
- self._tty_out.write(search_hint)
- self._tty_out.write((num_cols - wcswidth(search_hint)) * " ")
- if self._search or self._show_search_hint:
- self._tty_out.write("\r" + (current_menu_height + 1) * self._codename_to_terminal_code["cursor_up"])
- current_menu_block_displayed_height = 1
- return current_menu_block_displayed_height
-
- def print_status_bar(current_menu_height: int, status_bar_lines: Tuple[str, ...]) -> int:
- # pylint: disable=unsubscriptable-object
- assert self._codename_to_terminal_code is not None
- assert self._tty_out is not None
- current_menu_block_displayed_height = 0 # sum all written lines
- num_cols = self._num_cols()
- if status_bar_lines:
- self._tty_out.write((current_menu_height + 1) * self._codename_to_terminal_code["cursor_down"])
- apply_style(self._status_bar_style)
- self._tty_out.write(
- "\r"
- + "\n".join(
- (status_bar_line[:num_cols] + (num_cols - wcswidth(status_bar_line)) * " ")
- for status_bar_line in status_bar_lines
- )
- + "\r"
- )
- apply_style()
- self._tty_out.write(
- (current_menu_height + len(status_bar_lines)) * self._codename_to_terminal_code["cursor_up"]
- )
- current_menu_block_displayed_height += len(status_bar_lines)
- return current_menu_block_displayed_height
-
- def print_preview(current_menu_height: int, preview_max_num_lines: int) -> int:
- # pylint: disable=unsubscriptable-object
- assert self._codename_to_terminal_code is not None
- assert self._tty_out is not None
- if self._preview_command is None or preview_max_num_lines < 3:
- return 0
-
- def get_preview_string() -> Optional[str]:
- assert self._preview_command is not None
- if self._view.active_menu_index is None:
- return None
- preview_argument = (
- self._preview_arguments[self._view.active_menu_index]
- if self._preview_arguments[self._view.active_menu_index] is not None
- else self._menu_entries[self._view.active_menu_index]
- )
- if preview_argument == "":
- return None
- if isinstance(self._preview_command, str):
- try:
- preview_process = subprocess.Popen(
- [cmd_part.format(preview_argument) for cmd_part in shlex.split(self._preview_command)],
- stdout=subprocess.PIPE,
- stderr=subprocess.PIPE,
- )
- assert preview_process.stdout is not None
- preview_string = (
- io.TextIOWrapper(preview_process.stdout, encoding=self._user_locale, errors="replace")
- .read()
- .strip()
- )
- except subprocess.CalledProcessError as e:
- raise PreviewCommandFailedError(
- e.stderr.decode(encoding=self._user_locale, errors="replace").strip()
- ) from e
- else:
- preview_string = self._preview_command(preview_argument) if preview_argument is not None else ""
- return preview_string
-
- @static_variables(
- # Regex taken from https://stackoverflow.com/a/14693789/5958465
- ansi_escape_regex=re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])"),
- # Modified version of https://stackoverflow.com/a/2188410/5958465
- ansi_sgr_regex=re.compile(r"\x1B\[[;\d]*m"),
- )
- def strip_ansi_codes_except_styling(string: str) -> str:
- stripped_string = strip_ansi_codes_except_styling.ansi_escape_regex.sub( # type: ignore
- lambda match_obj: match_obj.group(0)
- if strip_ansi_codes_except_styling.ansi_sgr_regex.match(match_obj.group(0)) # type: ignore
- else "",
- string,
- )
- return cast(str, stripped_string)
-
- @static_variables(
- regular_text_regex=re.compile(r"([^\x1B]+)(.*)"),
- ansi_escape_regex=re.compile(r"(\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]))(.*)"),
- )
- def limit_string_with_escape_codes(string: str, max_len: int) -> Tuple[str, int]:
- if max_len <= 0:
- return "", 0
- string_parts = []
- string_len = 0
- while string:
- regular_text_match = limit_string_with_escape_codes.regular_text_regex.match(string) # type: ignore
- if regular_text_match is not None:
- regular_text = regular_text_match.group(1)
- regular_text_len = wcswidth(regular_text)
- if string_len + regular_text_len > max_len:
- string_parts.append(regular_text[: max_len - string_len])
- string_len = max_len
- break
- string_parts.append(regular_text)
- string_len += regular_text_len
- string = regular_text_match.group(2)
- else:
- ansi_escape_match = limit_string_with_escape_codes.ansi_escape_regex.match( # type: ignore
- string
- )
- if ansi_escape_match is not None:
- # Adopt the ansi escape code but do not count its length
- ansi_escape_code_text = ansi_escape_match.group(1)
- string_parts.append(ansi_escape_code_text)
- string = ansi_escape_match.group(2)
- else:
- # It looks like an escape code (starts with escape), but it is something else
- # -> skip the escape character and continue the loop
- string_parts.append("\x1B")
- string = string[1:]
- return "".join(string_parts), string_len
-
- num_cols = self._num_cols()
- try:
- preview_string = get_preview_string()
- if preview_string is not None:
- preview_string = strip_ansi_codes_except_styling(preview_string)
- except PreviewCommandFailedError as e:
- preview_string = "The preview command failed with error message:\n\n" + str(e)
- self._tty_out.write(current_menu_height * self._codename_to_terminal_code["cursor_down"])
- if preview_string is not None:
- self._tty_out.write(self._codename_to_terminal_code["cursor_down"] + "\r")
- if self._preview_border:
- self._tty_out.write(
- (
- BoxDrawingCharacters.upper_left
- + (2 * BoxDrawingCharacters.horizontal + " " + self._preview_title)[: num_cols - 3]
- + " "
- + (num_cols - len(self._preview_title) - 6) * BoxDrawingCharacters.horizontal
- + BoxDrawingCharacters.upper_right
- )[:num_cols]
- + "\n"
- )
- # `finditer` can be used as a generator version of `str.join`
- for i, line in enumerate(
- match.group(0) for match in re.finditer(r"^.*$", preview_string, re.MULTILINE)
- ):
- if i >= preview_max_num_lines - (2 if self._preview_border else 0):
- preview_num_lines = preview_max_num_lines
- break
- limited_line, limited_line_len = limit_string_with_escape_codes(
- line, num_cols - (3 if self._preview_border else 0)
- )
- self._tty_out.write(
- (
- ((BoxDrawingCharacters.vertical + " ") if self._preview_border else "")
- + limited_line
- + self._codename_to_terminal_code["reset_attributes"]
- + max(num_cols - limited_line_len - (3 if self._preview_border else 0), 0) * " "
- + (BoxDrawingCharacters.vertical if self._preview_border else "")
- )
- )
- else:
- preview_num_lines = i + (3 if self._preview_border else 1)
- if self._preview_border:
- self._tty_out.write(
- "\n"
- + (
- BoxDrawingCharacters.lower_left
- + (num_cols - 2) * BoxDrawingCharacters.horizontal
- + BoxDrawingCharacters.lower_right
- )[:num_cols]
- )
- self._tty_out.write("\r")
- else:
- preview_num_lines = 0
- self._tty_out.write(
- (current_menu_height + preview_num_lines) * self._codename_to_terminal_code["cursor_up"]
- )
- return preview_num_lines
-
- def delete_old_menu_lines(displayed_menu_height: int) -> None:
- # pylint: disable=unsubscriptable-object
- assert self._codename_to_terminal_code is not None
- assert self._tty_out is not None
- if (
- self._previous_displayed_menu_height is not None
- and self._previous_displayed_menu_height > displayed_menu_height
- ):
- self._tty_out.write((displayed_menu_height + 1) * self._codename_to_terminal_code["cursor_down"])
- self._tty_out.write(
- (self._previous_displayed_menu_height - displayed_menu_height)
- * self._codename_to_terminal_code["delete_line"]
- )
- self._tty_out.write((displayed_menu_height + 1) * self._codename_to_terminal_code["cursor_up"])
-
- def position_cursor() -> None:
- # pylint: disable=unsubscriptable-object
- assert self._codename_to_terminal_code is not None
- assert self._tty_out is not None
- if self._view.active_displayed_index is None:
- return
-
- cursor_width = wcswidth(self._menu_cursor)
- for displayed_index in range(self._viewport.lower_index, self._viewport.upper_index + 1):
- if displayed_index == self._view.active_displayed_index:
- apply_style(self._menu_cursor_style)
- self._tty_out.write(self._menu_cursor)
- apply_style()
- else:
- self._tty_out.write(cursor_width * " ")
- self._tty_out.write("\r")
- if displayed_index < self._viewport.upper_index:
- self._tty_out.write(self._codename_to_terminal_code["cursor_down"])
- self._tty_out.write((self._viewport.size - 1) * self._codename_to_terminal_code["cursor_up"])
-
- def print_multi_select_column() -> None:
- # pylint: disable=unsubscriptable-object
- assert self._codename_to_terminal_code is not None
- assert self._tty_out is not None
- if not self._multi_select:
- return
-
- def prepare_multi_select_cursors() -> Tuple[str, str]:
- bracket_characters = "([{<)]}>"
- bracket_style_escape_codes_io = io.StringIO()
- multi_select_cursor_style_escape_codes_io = io.StringIO()
- reset_codes_io = io.StringIO()
- apply_style(self._multi_select_cursor_brackets_style, file=bracket_style_escape_codes_io)
- apply_style(self._multi_select_cursor_style, file=multi_select_cursor_style_escape_codes_io)
- apply_style(file=reset_codes_io)
- bracket_style_escape_codes = bracket_style_escape_codes_io.getvalue()
- multi_select_cursor_style_escape_codes = multi_select_cursor_style_escape_codes_io.getvalue()
- reset_codes = reset_codes_io.getvalue()
-
- cursor_with_brackets_only = re.sub(
- r"[^{}]".format(re.escape(bracket_characters)), " ", self._multi_select_cursor
- )
- cursor_with_brackets_only_styled = re.sub(
- r"[{}]+".format(re.escape(bracket_characters)),
- lambda match_obj: bracket_style_escape_codes + match_obj.group(0) + reset_codes,
- cursor_with_brackets_only,
- )
- cursor_styled = re.sub(
- r"[{brackets}]+|[^{brackets}\s]+".format(brackets=re.escape(bracket_characters)),
- lambda match_obj: (
- bracket_style_escape_codes
- if match_obj.group(0)[0] in bracket_characters
- else multi_select_cursor_style_escape_codes
- )
- + match_obj.group(0)
- + reset_codes,
- self._multi_select_cursor,
- )
- return cursor_styled, cursor_with_brackets_only_styled
-
- if not self._view:
- return
- checked_multi_select_cursor, unchecked_multi_select_cursor = prepare_multi_select_cursors()
- cursor_width = wcswidth(self._menu_cursor)
- displayed_selected_indices = self._view.displayed_selected_indices
- displayed_index = 0
- for displayed_index, _, _ in self._view:
- self._tty_out.write("\r" + cursor_width * self._codename_to_terminal_code["cursor_right"])
- if displayed_index in self._skip_indices:
- self._tty_out.write("")
- elif displayed_index in displayed_selected_indices:
- self._tty_out.write(checked_multi_select_cursor)
- else:
- self._tty_out.write(unchecked_multi_select_cursor)
- if displayed_index < self._viewport.upper_index:
- self._tty_out.write(self._codename_to_terminal_code["cursor_down"])
- self._tty_out.write("\r")
- self._tty_out.write(
- (displayed_index + (1 if displayed_index < self._viewport.upper_index else 0))
- * self._codename_to_terminal_code["cursor_up"]
- )
-
- # pylint: disable=unsubscriptable-object
- assert self._codename_to_terminal_code is not None
- assert self._tty_out is not None
- displayed_menu_height = 0 # sum all written lines
- status_bar_lines = get_status_bar_lines()
- self._viewport.status_bar_lines_count = len(status_bar_lines)
- if self._preview_command is not None:
- self._viewport.preview_lines_count = int(self._preview_size * self._num_lines())
- preview_max_num_lines = self._viewport.preview_lines_count
- self._viewport.keep_visible(self._view.active_displayed_index)
- displayed_menu_height += print_menu_entries()
- displayed_menu_height += print_search_line(displayed_menu_height)
- if not self._status_bar_below_preview:
- displayed_menu_height += print_status_bar(displayed_menu_height, status_bar_lines)
- if self._preview_command is not None:
- displayed_menu_height += print_preview(displayed_menu_height, preview_max_num_lines)
- if self._status_bar_below_preview:
- displayed_menu_height += print_status_bar(displayed_menu_height, status_bar_lines)
- delete_old_menu_lines(displayed_menu_height)
- position_cursor()
- if self._multi_select:
- print_multi_select_column()
- self._previous_displayed_menu_height = displayed_menu_height
- self._tty_out.flush()
-
- def _clear_menu(self) -> None:
- # pylint: disable=unsubscriptable-object
- assert self._codename_to_terminal_code is not None
- assert self._previous_displayed_menu_height is not None
- assert self._tty_out is not None
- if self._clear_menu_on_exit:
- if self._title_lines:
- self._tty_out.write(len(self._title_lines) * self._codename_to_terminal_code["cursor_up"])
- self._tty_out.write(len(self._title_lines) * self._codename_to_terminal_code["delete_line"])
- self._tty_out.write(
- (self._previous_displayed_menu_height + 1) * self._codename_to_terminal_code["delete_line"]
- )
- else:
- self._tty_out.write(
- (self._previous_displayed_menu_height + 1) * self._codename_to_terminal_code["cursor_down"]
- )
- self._tty_out.flush()
-
- def _read_next_key(self, ignore_case: bool = True) -> str:
- # pylint: disable=unsubscriptable-object,unsupported-membership-test
- assert self._terminal_code_to_codename is not None
- assert self._tty_in is not None
- # Needed for asynchronous handling of terminal resize events
- self._reading_next_key = True
- if self._paint_before_next_read:
- self._paint_menu()
- self._paint_before_next_read = False
- # blocks until any amount of bytes is available
- code = os.read(self._tty_in.fileno(), 80).decode("ascii", errors="ignore")
- self._reading_next_key = False
- if code in self._terminal_code_to_codename:
- return self._terminal_code_to_codename[code]
- elif ignore_case:
- return code.lower()
- else:
- return code
-
- def show(self) -> Optional[Union[int, Tuple[int, ...]]]:
- def init_signal_handling() -> None:
- # `SIGWINCH` is send on terminal resizes
- def handle_sigwinch(signum: signal.Signals, frame: FrameType) -> None:
- # pylint: disable=unused-argument
- if self._reading_next_key:
- self._paint_menu()
- else:
- self._paint_before_next_read = True
-
- signal.signal(signal.SIGWINCH, handle_sigwinch)
-
- def reset_signal_handling() -> None:
- signal.signal(signal.SIGWINCH, signal.SIG_DFL)
-
- def remove_letter_keys(menu_action_to_keys: Dict[str, Set[Optional[str]]]) -> None:
- letter_keys = frozenset(string.ascii_lowercase) | frozenset(" ")
- for keys in menu_action_to_keys.values():
- keys -= letter_keys
-
- # pylint: disable=unsubscriptable-object
- assert self._codename_to_terminal_code is not None
- self._init_term()
- if self._preselected_indices is None:
- self._selection.clear()
- self._chosen_accept_key = None
- self._chosen_menu_indices = None
- self._chosen_menu_index = None
- assert self._tty_out is not None
- if self._title_lines:
- # `print_menu` expects the cursor on the first menu item -> reserve one line for the title
- self._tty_out.write(len(self._title_lines) * self._codename_to_terminal_code["cursor_down"])
- menu_was_interrupted = False
- try:
- init_signal_handling()
- menu_action_to_keys = {
- "menu_up": set(("up", "ctrl-k", "k")),
- "menu_down": set(("down", "ctrl-j", "j")),
- "accept": set(self._accept_keys),
- "multi_select": set(self._multi_select_keys),
- "quit": set(self._quit_keys),
- "search_start": set((self._search_key,)),
- "backspace": set(("backspace",)),
- } # type: Dict[str, Set[Optional[str]]]
- while True:
- self._paint_menu()
- current_menu_action_to_keys = copy.deepcopy(menu_action_to_keys)
- next_key = self._read_next_key(ignore_case=False)
- if self._search or self._search_key is None:
- remove_letter_keys(current_menu_action_to_keys)
- else:
- next_key = next_key.lower()
- if self._search_key is not None and not self._search and next_key in self._shortcut_keys:
- shortcut_menu_index = self._shortcut_keys.index(next_key)
- if self._exit_on_shortcut:
- self._selection.add(shortcut_menu_index)
- break
- else:
- if self._multi_select:
- self._selection.toggle(shortcut_menu_index)
- else:
- self._view.active_menu_index = shortcut_menu_index
- elif next_key in current_menu_action_to_keys["menu_up"]:
- self._view.decrement_active_index()
- elif next_key in current_menu_action_to_keys["menu_down"]:
- self._view.increment_active_index()
- elif self._multi_select and next_key in current_menu_action_to_keys["multi_select"]:
- if self._view.active_menu_index is not None:
- self._selection.toggle(self._view.active_menu_index)
- elif next_key in current_menu_action_to_keys["accept"]:
- if self._view.active_menu_index is not None:
- if self._multi_select_select_on_accept or (
- not self._selection and self._multi_select_empty_ok is False
- ):
- self._selection.add(self._view.active_menu_index)
- self._chosen_accept_key = next_key
- break
- elif next_key in current_menu_action_to_keys["quit"]:
- if not self._search:
- menu_was_interrupted = True
- break
- else:
- self._search.search_text = None
- elif not self._search:
- if next_key in current_menu_action_to_keys["search_start"] or (
- self._search_key is None and next_key == DEFAULT_SEARCH_KEY
- ):
- self._search.search_text = ""
- elif self._search_key is None:
- self._search.search_text = next_key
- else:
- assert self._search.search_text is not None
- if next_key in ("backspace",):
- if self._search.search_text != "":
- self._search.search_text = self._search.search_text[:-1]
- else:
- self._search.search_text = None
- elif wcswidth(next_key) >= 0 and not (
- next_key in current_menu_action_to_keys["search_start"] and self._search.search_text == ""
- ):
- # Only append `next_key` if it is a printable character and the first character is not the
- # `search_start` key
- self._search.search_text += next_key
- except KeyboardInterrupt as e:
- if self._raise_error_on_interrupt:
- raise e
- menu_was_interrupted = True
- finally:
- reset_signal_handling()
- self._clear_menu()
- self._reset_term()
- if not menu_was_interrupted:
- chosen_menu_indices = self._selection.selected_menu_indices
- if chosen_menu_indices:
- if self._multi_select:
- self._chosen_menu_indices = chosen_menu_indices
- else:
- self._chosen_menu_index = chosen_menu_indices[0]
- return self._chosen_menu_indices if self._multi_select else self._chosen_menu_index
-
- @property
- def chosen_accept_key(self) -> Optional[str]:
- return self._chosen_accept_key
-
- @property
- def chosen_menu_entry(self) -> Optional[str]:
- return self._menu_entries[self._chosen_menu_index] if self._chosen_menu_index is not None else None
-
- @property
- def chosen_menu_entries(self) -> Optional[Tuple[str, ...]]:
- return (
- tuple(self._menu_entries[menu_index] for menu_index in self._chosen_menu_indices)
- if self._chosen_menu_indices is not None
- else None
- )
-
- @property
- def chosen_menu_index(self) -> Optional[int]:
- return self._chosen_menu_index
-
- @property
- def chosen_menu_indices(self) -> Optional[Tuple[int, ...]]:
- return self._chosen_menu_indices
-
-
-class AttributeDict(dict): # type: ignore
- def __getattr__(self, attr: str) -> Any:
- return self[attr]
-
- def __setattr__(self, attr: str, value: Any) -> None:
- self[attr] = value
-
-
-def get_argumentparser() -> argparse.ArgumentParser:
- parser = argparse.ArgumentParser(
- formatter_class=argparse.RawDescriptionHelpFormatter,
- description="""
-%(prog)s creates simple interactive menus in the terminal and returns the selected entry as exit code.
-""",
- )
- parser.add_argument(
- "-s", "--case-sensitive", action="store_true", dest="case_sensitive", help="searches are case sensitive"
- )
- parser.add_argument(
- "-X",
- "--no-clear-menu-on-exit",
- action="store_false",
- dest="clear_menu_on_exit",
- help="do not clear the menu on exit",
- )
- parser.add_argument(
- "-l",
- "--clear-screen",
- action="store_true",
- dest="clear_screen",
- help="clear the screen before the menu is shown",
- )
- parser.add_argument(
- "--cursor",
- action="store",
- dest="cursor",
- default=DEFAULT_MENU_CURSOR,
- help='menu cursor (default: "%(default)s")',
- )
- parser.add_argument(
- "-i",
- "--cursor-index",
- action="store",
- dest="cursor_index",
- type=int,
- default=0,
- help="initially selected item index",
- )
- parser.add_argument(
- "--cursor-style",
- action="store",
- dest="cursor_style",
- default=",".join(DEFAULT_MENU_CURSOR_STYLE),
- help='style for the menu cursor as comma separated list (default: "%(default)s")',
- )
- parser.add_argument("-C", "--no-cycle", action="store_false", dest="cycle", help="do not cycle the menu selection")
- parser.add_argument(
- "-E",
- "--no-exit-on-shortcut",
- action="store_false",
- dest="exit_on_shortcut",
- help="do not exit on shortcut keys",
- )
- parser.add_argument(
- "--highlight-style",
- action="store",
- dest="highlight_style",
- default=",".join(DEFAULT_MENU_HIGHLIGHT_STYLE),
- help='style for the selected menu entry as comma separated list (default: "%(default)s")',
- )
- parser.add_argument(
- "-m",
- "--multi-select",
- action="store_true",
- dest="multi_select",
- help="Allow the selection of multiple entries (implies `--stdout`)",
- )
- parser.add_argument(
- "--multi-select-cursor",
- action="store",
- dest="multi_select_cursor",
- default=DEFAULT_MULTI_SELECT_CURSOR,
- help='multi-select menu cursor (default: "%(default)s")',
- )
- parser.add_argument(
- "--multi-select-cursor-brackets-style",
- action="store",
- dest="multi_select_cursor_brackets_style",
- default=",".join(DEFAULT_MULTI_SELECT_CURSOR_BRACKETS_STYLE),
- help='style for brackets of the multi-select menu cursor as comma separated list (default: "%(default)s")',
- )
- parser.add_argument(
- "--multi-select-cursor-style",
- action="store",
- dest="multi_select_cursor_style",
- default=",".join(DEFAULT_MULTI_SELECT_CURSOR_STYLE),
- help='style for the multi-select menu cursor as comma separated list (default: "%(default)s")',
- )
- parser.add_argument(
- "--multi-select-keys",
- action="store",
- dest="multi_select_keys",
- default=",".join(DEFAULT_MULTI_SELECT_KEYS),
- help=('key for toggling a selected item in a multi-selection (default: "%(default)s", '),
- )
- parser.add_argument(
- "--multi-select-no-select-on-accept",
- action="store_false",
- dest="multi_select_select_on_accept",
- help=(
- "do not select the currently highlighted menu item when the accept key is pressed "
- "(it is still selected if no other item was selected before)"
- ),
- )
- parser.add_argument(
- "--multi-select-empty-ok",
- action="store_true",
- dest="multi_select_empty_ok",
- help=("when used together with --multi-select-no-select-on-accept allows returning no selection at all"),
- )
- parser.add_argument(
- "-p",
- "--preview",
- action="store",
- dest="preview_command",
- help=(
- "Command to generate a preview for the selected menu entry. "
- '"{}" can be used as placeholder for the menu text. '
- 'If the menu entry has a data component (separated by "|"), this is used instead.'
- ),
- )
- parser.add_argument(
- "--no-preview-border",
- action="store_false",
- dest="preview_border",
- help="do not draw a border around the preview window",
- )
- parser.add_argument(
- "--preview-size",
- action="store",
- dest="preview_size",
- type=float,
- default=DEFAULT_PREVIEW_SIZE,
- help='maximum height of the preview window in fractions of the terminal height (default: "%(default)s")',
- )
- parser.add_argument(
- "--preview-title",
- action="store",
- dest="preview_title",
- default=DEFAULT_PREVIEW_TITLE,
- help='title of the preview window (default: "%(default)s")',
- )
- parser.add_argument(
- "--search-highlight-style",
- action="store",
- dest="search_highlight_style",
- default=",".join(DEFAULT_SEARCH_HIGHLIGHT_STYLE),
- help='style of matched search patterns (default: "%(default)s")',
- )
- parser.add_argument(
- "--search-key",
- action="store",
- dest="search_key",
- default=DEFAULT_SEARCH_KEY,
- help=(
- 'key to start a search (default: "%(default)s", '
- '"none" is treated a special value which activates the search on any letter key)'
- ),
- )
- parser.add_argument(
- "--shortcut-brackets-highlight-style",
- action="store",
- dest="shortcut_brackets_highlight_style",
- default=",".join(DEFAULT_SHORTCUT_BRACKETS_HIGHLIGHT_STYLE),
- help='style of brackets enclosing shortcut keys (default: "%(default)s")',
- )
- parser.add_argument(
- "--shortcut-key-highlight-style",
- action="store",
- dest="shortcut_key_highlight_style",
- default=",".join(DEFAULT_SHORTCUT_KEY_HIGHLIGHT_STYLE),
- help='style of shortcut keys (default: "%(default)s")',
- )
- parser.add_argument(
- "--show-multi-select-hint",
- action="store_true",
- dest="show_multi_select_hint",
- help="show a multi-select hint in the status bar",
- )
- parser.add_argument(
- "--show-multi-select-hint-text",
- action="store",
- dest="show_multi_select_hint_text",
- help=(
- "Custom text which will be shown as multi-select hint. Use the placeholders {multi_select_keys} and "
- "{accept_keys} if appropriately."
- ),
- )
- parser.add_argument(
- "--show-search-hint",
- action="store_true",
- dest="show_search_hint",
- help="show a search hint in the search line",
- )
- parser.add_argument(
- "--show-search-hint-text",
- action="store",
- dest="show_search_hint_text",
- help=(
- "Custom text which will be shown as search hint. Use the placeholders {key} for the search key "
- "if appropriately."
- ),
- )
- parser.add_argument(
- "--show-shortcut-hints",
- action="store_true",
- dest="show_shortcut_hints",
- help="show shortcut hints in the status bar",
- )
- parser.add_argument(
- "--show-shortcut-hints-in-title",
- action="store_false",
- dest="show_shortcut_hints_in_status_bar",
- default=True,
- help="show shortcut hints in the menu title",
- )
- parser.add_argument(
- "--skip-empty-entries",
- action="store_true",
- dest="skip_empty_entries",
- help="Interpret an empty string in menu entries as an empty menu entry",
- )
- parser.add_argument(
- "-b",
- "--status-bar",
- action="store",
- dest="status_bar",
- help="status bar text",
- )
- parser.add_argument(
- "-d",
- "--status-bar-below-preview",
- action="store_true",
- dest="status_bar_below_preview",
- help="show the status bar below the preview window if any",
- )
- parser.add_argument(
- "--status-bar-style",
- action="store",
- dest="status_bar_style",
- default=",".join(DEFAULT_STATUS_BAR_STYLE),
- help='style of the status bar lines (default: "%(default)s")',
- )
- parser.add_argument(
- "--stdout",
- action="store_true",
- dest="stdout",
- help=(
- "Print the selected menu index or indices to stdout (in addition to the exit status). "
- 'Multiple indices are separated by ";".'
- ),
- )
- parser.add_argument("-t", "--title", action="store", dest="title", help="menu title")
- parser.add_argument(
- "-V", "--version", action="store_true", dest="print_version", help="print the version number and exit"
- )
- parser.add_argument("entries", action="store", nargs="*", help="the menu entries to show")
- group = parser.add_mutually_exclusive_group()
- group.add_argument(
- "-r",
- "--preselected_entries",
- action="store",
- dest="preselected_entries",
- help="Comma separated list of strings matching menu items to start pre-selected in a multi-select menu.",
- )
- group.add_argument(
- "-R",
- "--preselected_indices",
- action="store",
- dest="preselected_indices",
- help="Comma separated list of numeric indexes of menu items to start pre-selected in a multi-select menu.",
- )
- return parser
-
-
-def parse_arguments() -> AttributeDict:
- parser = get_argumentparser()
- args = AttributeDict({key: value for key, value in vars(parser.parse_args()).items()})
- if not args.print_version and not args.entries:
- raise NoMenuEntriesError("No menu entries given!")
- if args.skip_empty_entries:
- args.entries = [entry if entry != "None" else None for entry in args.entries]
- if args.cursor_style != "":
- args.cursor_style = tuple(args.cursor_style.split(","))
- else:
- args.cursor_style = None
- if args.highlight_style != "":
- args.highlight_style = tuple(args.highlight_style.split(","))
- else:
- args.highlight_style = None
- if args.search_highlight_style != "":
- args.search_highlight_style = tuple(args.search_highlight_style.split(","))
- else:
- args.search_highlight_style = None
- if args.shortcut_key_highlight_style != "":
- args.shortcut_key_highlight_style = tuple(args.shortcut_key_highlight_style.split(","))
- else:
- args.shortcut_key_highlight_style = None
- if args.shortcut_brackets_highlight_style != "":
- args.shortcut_brackets_highlight_style = tuple(args.shortcut_brackets_highlight_style.split(","))
- else:
- args.shortcut_brackets_highlight_style = None
- if args.status_bar_style != "":
- args.status_bar_style = tuple(args.status_bar_style.split(","))
- else:
- args.status_bar_style = None
- if args.multi_select_cursor_brackets_style != "":
- args.multi_select_cursor_brackets_style = tuple(args.multi_select_cursor_brackets_style.split(","))
- else:
- args.multi_select_cursor_brackets_style = None
- if args.multi_select_cursor_style != "":
- args.multi_select_cursor_style = tuple(args.multi_select_cursor_style.split(","))
- else:
- args.multi_select_cursor_style = None
- if args.multi_select_keys != "":
- args.multi_select_keys = tuple(args.multi_select_keys.split(","))
- else:
- args.multi_select_keys = None
- if args.search_key.lower() == "none":
- args.search_key = None
- if args.show_shortcut_hints_in_status_bar:
- args.show_shortcut_hints = True
- if args.multi_select:
- args.stdout = True
- if args.preselected_entries is not None:
- args.preselected = list(args.preselected_entries.split(","))
- elif args.preselected_indices is not None:
- args.preselected = list(map(int, args.preselected_indices.split(",")))
- else:
- args.preselected = None
- return args
-
-
-def main() -> None:
- try:
- args = parse_arguments()
- except SystemExit:
- sys.exit(0) # Error code 0 is the error case in this program
- except NoMenuEntriesError as e:
- print(str(e), file=sys.stderr)
- sys.exit(0)
- if args.print_version:
- print("{}, version {}".format(os.path.basename(sys.argv[0]), __version__))
- sys.exit(0)
- try:
- terminal_menu = TerminalMenu(
- menu_entries=args.entries,
- clear_menu_on_exit=args.clear_menu_on_exit,
- clear_screen=args.clear_screen,
- cursor_index=args.cursor_index,
- cycle_cursor=args.cycle,
- exit_on_shortcut=args.exit_on_shortcut,
- menu_cursor=args.cursor,
- menu_cursor_style=args.cursor_style,
- menu_highlight_style=args.highlight_style,
- multi_select=args.multi_select,
- multi_select_cursor=args.multi_select_cursor,
- multi_select_cursor_brackets_style=args.multi_select_cursor_brackets_style,
- multi_select_cursor_style=args.multi_select_cursor_style,
- multi_select_empty_ok=args.multi_select_empty_ok,
- multi_select_keys=args.multi_select_keys,
- multi_select_select_on_accept=args.multi_select_select_on_accept,
- preselected_entries=args.preselected,
- preview_border=args.preview_border,
- preview_command=args.preview_command,
- preview_size=args.preview_size,
- preview_title=args.preview_title,
- search_case_sensitive=args.case_sensitive,
- search_highlight_style=args.search_highlight_style,
- search_key=args.search_key,
- shortcut_brackets_highlight_style=args.shortcut_brackets_highlight_style,
- shortcut_key_highlight_style=args.shortcut_key_highlight_style,
- show_multi_select_hint=args.show_multi_select_hint,
- show_multi_select_hint_text=args.show_multi_select_hint_text,
- show_search_hint=args.show_search_hint,
- show_search_hint_text=args.show_search_hint_text,
- show_shortcut_hints=args.show_shortcut_hints,
- show_shortcut_hints_in_status_bar=args.show_shortcut_hints_in_status_bar,
- skip_empty_entries=args.skip_empty_entries,
- status_bar=args.status_bar,
- status_bar_below_preview=args.status_bar_below_preview,
- status_bar_style=args.status_bar_style,
- title=args.title,
- )
- except (InvalidParameterCombinationError, InvalidStyleError, UnknownMenuEntryError) as e:
- print(str(e), file=sys.stderr)
- sys.exit(0)
- chosen_entries = terminal_menu.show()
- if chosen_entries is None:
- sys.exit(0)
- else:
- if isinstance(chosen_entries, Iterable):
- if args.stdout:
- print(",".join(str(entry + 1) for entry in chosen_entries))
- sys.exit(chosen_entries[0] + 1)
- else:
- chosen_entry = chosen_entries
- if args.stdout:
- print(chosen_entry + 1)
- sys.exit(chosen_entry + 1)
-
-
-if __name__ == "__main__":
- main()
diff --git a/archinstall/lib/menu/table_selection_menu.py b/archinstall/lib/menu/table_selection_menu.py
index 09cd6ee2..fec6ae59 100644
--- a/archinstall/lib/menu/table_selection_menu.py
+++ b/archinstall/lib/menu/table_selection_menu.py
@@ -1,19 +1,25 @@
-from typing import Any, Tuple, List, Dict, Optional
+from typing import Any, Tuple, List, Dict, Optional, Callable
-from .menu import MenuSelectionType, MenuSelection
+from .menu import MenuSelectionType, MenuSelection, Menu
from ..output import FormattedOutput
-from ..menu import Menu
class TableMenu(Menu):
def __init__(
self,
title: str,
- data: List[Any] = [],
+ data: Optional[List[Any]] = None,
table_data: Optional[Tuple[List[Any], str]] = None,
+ preset: List[Any] = [],
custom_menu_options: List[str] = [],
default: Any = None,
- multi: bool = False
+ multi: bool = False,
+ preview_command: Optional[Callable] = None,
+ preview_title: str = 'Info',
+ preview_size: float = 0.0,
+ allow_reset: bool = True,
+ allow_reset_warning_msg: Optional[str] = None,
+ skip: bool = True
):
"""
param title: Text that will be displayed above the menu
@@ -29,10 +35,10 @@ class TableMenu(Menu):
param custom_options: List of custom options that will be displayed under the table
:type custom_menu_options: List
- """
- if not data and not table_data:
- raise ValueError('Either "data" or "table_data" must be provided')
+ :param preview_command: A function that should return a string that will be displayed in a preview window when a menu selection item is in focus
+ :type preview_command: Callable
+ """
self._custom_options = custom_menu_options
self._multi = multi
@@ -41,7 +47,7 @@ class TableMenu(Menu):
else:
header_padding = 2
- if len(data):
+ if data is not None:
table_text = FormattedOutput.as_table(data)
rows = table_text.split('\n')
table = self._create_table(data, rows, header_padding=header_padding)
@@ -53,20 +59,54 @@ class TableMenu(Menu):
data = table_data[0]
rows = table_data[1].split('\n')
table = self._create_table(data, rows, header_padding=header_padding)
+ else:
+ raise ValueError('Either "data" or "table_data" must be provided')
self._options, header = self._prepare_selection(table)
+ preset_values = self._preset_values(preset)
+
+ extra_bottom_space = True if preview_command else False
+
super().__init__(
title,
self._options,
+ preset_values=preset_values,
header=header,
skip_empty_entries=True,
show_search_hint=False,
- allow_reset=True,
multi=multi,
- default_option=default
+ default_option=default,
+ preview_command=lambda x: self._table_show_preview(preview_command, x),
+ preview_size=preview_size,
+ preview_title=preview_title,
+ extra_bottom_space=extra_bottom_space,
+ allow_reset=allow_reset,
+ allow_reset_warning_msg=allow_reset_warning_msg,
+ skip=skip
)
+ def _preset_values(self, preset: List[Any]) -> List[str]:
+ # when we create the table of just the preset values it will
+ # be formatted a bit different due to spacing, so to determine
+ # correct rows lets remove all the spaces and compare apples with apples
+ preset_table = FormattedOutput.as_table(preset).strip()
+ data_rows = preset_table.split('\n')[2:] # get all data rows
+ pure_data_rows = [self._escape_row(row.replace(' ', '')) for row in data_rows]
+
+ # the actual preset value has to be in non-escaped form
+ pure_option_rows = {o.replace(' ', ''): self._unescape_row(o) for o in self._options.keys()}
+ preset_rows = [row for pure, row in pure_option_rows.items() if pure in pure_data_rows]
+
+ return preset_rows
+
+ def _table_show_preview(self, preview_command: Optional[Callable], selection: Any) -> Optional[str]:
+ if preview_command:
+ row = self._escape_row(selection)
+ obj = self._options[row]
+ return preview_command(obj)
+ return None
+
def run(self) -> MenuSelection:
choice = super().run()
@@ -79,6 +119,12 @@ class TableMenu(Menu):
return choice
+ def _escape_row(self, row: str) -> str:
+ return row.replace('|', '\\|')
+
+ def _unescape_row(self, row: str) -> str:
+ return row.replace('\\|', '|')
+
def _create_table(self, data: List[Any], rows: List[str], header_padding: int = 2) -> Dict[str, Any]:
# these are the header rows of the table and do not map to any data obviously
# we're adding 2 spaces as prefix because the menu selector '> ' will be put before
@@ -87,7 +133,7 @@ class TableMenu(Menu):
display_data = {f'{padding}{rows[0]}': None, f'{padding}{rows[1]}': None}
for row, entry in zip(rows[2:], data):
- row = row.replace('|', '\\|')
+ row = self._escape_row(row)
display_data[row] = entry
return display_data
diff --git a/archinstall/lib/menu/text_input.py b/archinstall/lib/menu/text_input.py
index 05ca0f22..971df5fd 100644
--- a/archinstall/lib/menu/text_input.py
+++ b/archinstall/lib/menu/text_input.py
@@ -1,4 +1,5 @@
import readline
+import sys
class TextInput:
@@ -12,6 +13,14 @@ class TextInput:
def run(self) -> str:
readline.set_pre_input_hook(self._hook)
- result = input(self._prompt)
+ try:
+ result = input(self._prompt)
+ except (KeyboardInterrupt, EOFError):
+ # To make sure any output that may follow
+ # will be on the line after the prompt
+ sys.stdout.write('\n')
+ sys.stdout.flush()
+
+ result = ''
readline.set_pre_input_hook()
return result
diff --git a/archinstall/lib/mirrors.py b/archinstall/lib/mirrors.py
index f78a8b18..18ffffcd 100644
--- a/archinstall/lib/mirrors.py
+++ b/archinstall/lib/mirrors.py
@@ -1,187 +1,318 @@
-import logging
import pathlib
-import urllib.error
-import urllib.request
-from typing import Union, Mapping, Iterable, Dict, Any, List
+from dataclasses import dataclass, field
+from enum import Enum
+from typing import Dict, Any, List, Optional, TYPE_CHECKING
-from .general import SysCommand
-from .output import log
+from .menu import AbstractSubMenu, Selector, MenuSelectionType, Menu, ListManager, TextInput
+from .networking import fetch_data_from_url
+from .output import warn, FormattedOutput
from .storage import storage
-def sort_mirrorlist(raw_data :bytes, sort_order=["https", "http"]) -> bytes:
- """
- This function can sort /etc/pacman.d/mirrorlist according to the
- mirror's URL prefix. By default places HTTPS before HTTP but it also
- preserves the country/rank-order.
-
- This assumes /etc/pacman.d/mirrorlist looks like the following:
-
- ## Comment
- Server = url
-
- or
-
- ## Comment
- #Server = url
-
- But the Comments need to start with double-hashmarks to be distringuished
- from server url definitions (commented or uncommented).
- """
- comments_and_whitespaces = b""
-
- categories = {key: [] for key in sort_order + ["Unknown"]}
- for line in raw_data.split(b"\n"):
- if line[0:2] in (b'##', b''):
- comments_and_whitespaces += line + b'\n'
- elif line[:6].lower() == b'server' or line[:7].lower() == b'#server':
- opening, url = line.split(b'=', 1)
- opening, url = opening.strip(), url.strip()
- if (category := url.split(b'://',1)[0].decode('UTF-8')) in categories:
- categories[category].append(comments_and_whitespaces)
- categories[category].append(opening + b' = ' + url + b'\n')
- else:
- categories["Unknown"].append(comments_and_whitespaces)
- categories["Unknown"].append(opening + b' = ' + url + b'\n')
-
- comments_and_whitespaces = b""
-
- new_raw_data = b''
- for category in sort_order + ["Unknown"]:
- for line in categories[category]:
- new_raw_data += line
-
- return new_raw_data
-
-
-def filter_mirrors_by_region(regions :str,
- destination :str = '/etc/pacman.d/mirrorlist',
- sort_order :List[str] = ["https", "http"],
- *args :str,
- **kwargs :str
-) -> Union[bool, bytes]:
+if TYPE_CHECKING:
+ _: Any
+
+
+class SignCheck(Enum):
+ Never = 'Never'
+ Optional = 'Optional'
+ Required = 'Required'
+
+
+class SignOption(Enum):
+ TrustedOnly = 'TrustedOnly'
+ TrustAll = 'TrustAll'
+
+
+@dataclass
+class CustomMirror:
+ name: str
+ url: str
+ sign_check: SignCheck
+ sign_option: SignOption
+
+ def table_data(self) -> Dict[str, str]:
+ return {
+ 'Name': self.name,
+ 'Url': self.url,
+ 'Sign check': self.sign_check.value,
+ 'Sign options': self.sign_option.value
+ }
+
+ def json(self) -> Dict[str, str]:
+ return {
+ 'name': self.name,
+ 'url': self.url,
+ 'sign_check': self.sign_check.value,
+ 'sign_option': self.sign_option.value
+ }
+
+ @classmethod
+ def parse_args(cls, args: List[Dict[str, str]]) -> List['CustomMirror']:
+ configs = []
+ for arg in args:
+ configs.append(
+ CustomMirror(
+ arg['name'],
+ arg['url'],
+ SignCheck(arg['sign_check']),
+ SignOption(arg['sign_option'])
+ )
+ )
+
+ return configs
+
+
+@dataclass
+class MirrorConfiguration:
+ mirror_regions: Dict[str, List[str]] = field(default_factory=dict)
+ custom_mirrors: List[CustomMirror] = field(default_factory=list)
+
+ @property
+ def regions(self) -> str:
+ return ', '.join(self.mirror_regions.keys())
+
+ def json(self) -> Dict[str, Any]:
+ return {
+ 'mirror_regions': self.mirror_regions,
+ 'custom_mirrors': [c.json() for c in self.custom_mirrors]
+ }
+
+ def mirrorlist_config(self) -> str:
+ config = ''
+
+ for region, mirrors in self.mirror_regions.items():
+ for mirror in mirrors:
+ config += f'\n\n## {region}\nServer = {mirror}\n'
+
+ for cm in self.custom_mirrors:
+ config += f'\n\n## {cm.name}\nServer = {cm.url}\n'
+
+ return config
+
+ def pacman_config(self) -> str:
+ config = ''
+
+ for mirror in self.custom_mirrors:
+ config += f'\n\n[{mirror.name}]\n'
+ config += f'SigLevel = {mirror.sign_check.value} {mirror.sign_option.value}\n'
+ config += f'Server = {mirror.url}\n'
+
+ return config
+
+ @classmethod
+ def parse_args(cls, args: Dict[str, Any]) -> 'MirrorConfiguration':
+ config = MirrorConfiguration()
+
+ if 'mirror_regions' in args:
+ config.mirror_regions = args['mirror_regions']
+
+ if 'custom_mirrors' in args:
+ config.custom_mirrors = CustomMirror.parse_args(args['custom_mirrors'])
+
+ return config
+
+
+class CustomMirrorList(ListManager):
+ def __init__(self, prompt: str, custom_mirrors: List[CustomMirror]):
+ self._actions = [
+ str(_('Add a custom mirror')),
+ str(_('Change custom mirror')),
+ str(_('Delete custom mirror'))
+ ]
+ super().__init__(prompt, custom_mirrors, [self._actions[0]], self._actions[1:])
+
+ def selected_action_display(self, mirror: CustomMirror) -> str:
+ return mirror.name
+
+ def handle_action(
+ self,
+ action: str,
+ entry: Optional[CustomMirror],
+ data: List[CustomMirror]
+ ) -> List[CustomMirror]:
+ if action == self._actions[0]: # add
+ new_mirror = self._add_custom_mirror()
+ if new_mirror is not None:
+ data = [d for d in data if d.name != new_mirror.name]
+ data += [new_mirror]
+ elif action == self._actions[1] and entry: # modify mirror
+ new_mirror = self._add_custom_mirror(entry)
+ if new_mirror is not None:
+ data = [d for d in data if d.name != entry.name]
+ data += [new_mirror]
+ elif action == self._actions[2] and entry: # delete
+ data = [d for d in data if d != entry]
+
+ return data
+
+ def _add_custom_mirror(self, mirror: Optional[CustomMirror] = None) -> Optional[CustomMirror]:
+ prompt = '\n\n' + str(_('Enter name (leave blank to skip): '))
+ existing_name = mirror.name if mirror else ''
+
+ while True:
+ name = TextInput(prompt, existing_name).run()
+ if not name:
+ return mirror
+ break
+
+ prompt = '\n' + str(_('Enter url (leave blank to skip): '))
+ existing_url = mirror.url if mirror else ''
+
+ while True:
+ url = TextInput(prompt, existing_url).run()
+ if not url:
+ return mirror
+ break
+
+ sign_check_choice = Menu(
+ str(_('Select signature check option')),
+ [s.value for s in SignCheck],
+ skip=False,
+ clear_screen=False,
+ preset_values=mirror.sign_check.value if mirror else None
+ ).run()
+
+ sign_option_choice = Menu(
+ str(_('Select signature option')),
+ [s.value for s in SignOption],
+ skip=False,
+ clear_screen=False,
+ preset_values=mirror.sign_option.value if mirror else None
+ ).run()
+
+ return CustomMirror(
+ name,
+ url,
+ SignCheck(sign_check_choice.single_value),
+ SignOption(sign_option_choice.single_value)
+ )
+
+
+class MirrorMenu(AbstractSubMenu):
+ def __init__(
+ self,
+ data_store: Dict[str, Any],
+ preset: Optional[MirrorConfiguration] = None
+ ):
+ if preset:
+ self._preset = preset
+ else:
+ self._preset = MirrorConfiguration()
+
+ super().__init__(data_store=data_store)
+
+ def setup_selection_menu_options(self):
+ self._menu_options['mirror_regions'] = \
+ Selector(
+ _('Mirror region'),
+ lambda preset: select_mirror_regions(preset),
+ display_func=lambda x: ', '.join(x.keys()) if x else '',
+ default=self._preset.mirror_regions,
+ enabled=True)
+ self._menu_options['custom_mirrors'] = \
+ Selector(
+ _('Custom mirrors'),
+ lambda preset: select_custom_mirror(preset=preset),
+ display_func=lambda x: str(_('Defined')) if x else '',
+ preview_func=self._prev_custom_mirror,
+ default=self._preset.custom_mirrors,
+ enabled=True
+ )
+
+ def _prev_custom_mirror(self) -> Optional[str]:
+ selector = self._menu_options['custom_mirrors']
+
+ if selector.has_selection():
+ custom_mirrors: List[CustomMirror] = selector.current_selection # type: ignore
+ output = FormattedOutput.as_table(custom_mirrors)
+ return output.strip()
+
+ return None
+
+ def run(self, allow_reset: bool = True) -> Optional[MirrorConfiguration]:
+ super().run(allow_reset=allow_reset)
+
+ if self._data_store.get('mirror_regions', None) or self._data_store.get('custom_mirrors', None):
+ return MirrorConfiguration(
+ mirror_regions=self._data_store['mirror_regions'],
+ custom_mirrors=self._data_store['custom_mirrors'],
+ )
+
+ return None
+
+
+def select_mirror_regions(preset_values: Dict[str, List[str]] = {}) -> Dict[str, List[str]]:
"""
- This function will change the active mirrors on the live medium by
- filtering which regions are active based on `regions`.
+ Asks the user to select a mirror or region
+ Usually this is combined with :ref:`archinstall.list_mirrors`.
- :param regions: A series of country codes separated by `,`. For instance `SE,US` for sweden and United States.
- :type regions: str
+ :return: The dictionary information about a mirror/region.
+ :rtype: dict
"""
- region_list = [f'country={region}' for region in regions.split(',')]
- response = urllib.request.urlopen(urllib.request.Request(f"https://archlinux32.org/mirrorlist/?{'&'.join(region_list)}&protocol=https&protocol=http&ip_version=4&ip_version=6&use_mirror_status=on'", headers={'User-Agent': 'ArchInstall'}))
- new_list = response.read().replace(b"#Server", b"Server")
-
- if sort_order:
- new_list = sort_mirrorlist(new_list, sort_order=sort_order)
+ if preset_values is None:
+ preselected = None
+ else:
+ preselected = list(preset_values.keys())
- if destination:
- with open(destination, "wb") as mirrorlist:
- mirrorlist.write(new_list)
+ mirrors = list_mirrors()
- return True
- else:
- return new_list.decode('UTF-8')
+ choice = Menu(
+ _('Select one of the regions to download packages from'),
+ list(mirrors.keys()),
+ preset_values=preselected,
+ multi=True,
+ allow_reset=True
+ ).run()
+ match choice.type_:
+ case MenuSelectionType.Reset:
+ return {}
+ case MenuSelectionType.Skip:
+ return preset_values
+ case MenuSelectionType.Selection:
+ return {selected: mirrors[selected] for selected in choice.multi_value}
-def add_custom_mirrors(mirrors: List[str], *args :str, **kwargs :str) -> bool:
- """
- This will append custom mirror definitions in pacman.conf
+ return {}
- :param mirrors: A list of mirror data according to: `{'url': 'http://url.com', 'signcheck': 'Optional', 'signoptions': 'TrustAll', 'name': 'testmirror'}`
- :type mirrors: dict
- """
- with open('/etc/pacman.conf', 'a') as pacman:
- for mirror in mirrors:
- pacman.write(f"[{mirror['name']}]\n")
- pacman.write(f"SigLevel = {mirror['signcheck']} {mirror['signoptions']}\n")
- pacman.write(f"Server = {mirror['url']}\n")
- return True
+def select_custom_mirror(prompt: str = '', preset: List[CustomMirror] = []):
+ custom_mirrors = CustomMirrorList(prompt, preset).run()
+ return custom_mirrors
-def insert_mirrors(mirrors :Dict[str, Any], *args :str, **kwargs :str) -> bool:
- """
- This function will insert a given mirror-list at the top of `/etc/pacman.d/mirrorlist`.
- It will not flush any other mirrors, just insert new ones.
+def _parse_mirror_list(mirrorlist: str) -> Dict[str, List[str]]:
+ file_content = mirrorlist.split('\n')
+ file_content = list(filter(lambda x: x, file_content)) # filter out empty lines
+ first_srv_idx = [idx for idx, line in enumerate(file_content) if 'server' in line.lower()][0]
+ mirrors = file_content[first_srv_idx - 1:]
- :param mirrors: A dictionary of `{'url' : 'country', 'url2' : 'country'}`
- :type mirrors: dict
- """
- original_mirrorlist = ''
- with open('/etc/pacman.d/mirrorlist', 'r') as original:
- original_mirrorlist = original.read()
-
- with open('/etc/pacman.d/mirrorlist', 'w') as new_mirrorlist:
- for mirror, country in mirrors.items():
- new_mirrorlist.write(f'## {country}\n')
- new_mirrorlist.write(f'Server = {mirror}\n')
- new_mirrorlist.write('\n')
- new_mirrorlist.write(original_mirrorlist)
-
- return True
-
-
-def use_mirrors(
- regions: Mapping[str, Iterable[str]],
- destination: str = '/etc/pacman.d/mirrorlist'
-) -> None:
- log(f'A new package mirror-list has been created: {destination}', level=logging.INFO)
- with open(destination, 'w') as mirrorlist:
- for region, mirrors in regions.items():
- for mirror in mirrors:
- mirrorlist.write(f'## {region}\n')
- mirrorlist.write(f'Server = {mirror}\n')
+ mirror_list: Dict[str, List[str]] = {}
+ for idx in range(0, len(mirrors), 2):
+ region = mirrors[idx].removeprefix('## ')
+ url = mirrors[idx + 1].removeprefix('#').removeprefix('Server = ')
+ mirror_list.setdefault(region, []).append(url)
-def re_rank_mirrors(
- top: int = 10,
- src: str = '/etc/pacman.d/mirrorlist',
- dst: str = '/etc/pacman.d/mirrorlist',
-) -> bool:
- cmd = SysCommand(f"/usr/bin/rankmirrors -n {top} {src}")
- if cmd.exit_code != 0:
- return False
- with open(dst, 'w') as f:
- f.write(str(cmd))
- return True
+ return mirror_list
-def list_mirrors(sort_order :List[str] = ["https", "http"]) -> Dict[str, Any]:
- regions = {}
+def list_mirrors() -> Dict[str, List[str]]:
+ regions: Dict[str, List[str]] = {}
if storage['arguments']['offline']:
- with pathlib.Path('/etc/pacman.d/mirrorlist').open('rb') as fh:
- mirrorlist = fh.read()
+ with pathlib.Path('/etc/pacman.d/mirrorlist').open('r') as fp:
+ mirrorlist = fp.read()
else:
url = "https://archlinux32.org/mirrorlist/?protocol=https&protocol=http&ip_version=4&ip_version=6&use_mirror_status=on"
-
try:
- response = urllib.request.urlopen(url)
- except urllib.error.URLError as err:
- log(f'Could not fetch an active mirror-list: {err}', level=logging.WARNING, fg="orange")
+ mirrorlist = fetch_data_from_url(url)
+ except ValueError as err:
+ warn(f'Could not fetch an active mirror-list: {err}')
return regions
- mirrorlist = response.read()
-
- if sort_order:
- mirrorlist = sort_mirrorlist(mirrorlist, sort_order=sort_order)
-
- region = 'Unknown region'
- for line in mirrorlist.split(b'\n'):
- if len(line.strip()) == 0:
- continue
-
- line = line.decode('UTF-8').strip('\n').strip('\r')
- if line[:3] == '## ':
- region = line[3:]
- elif line[:10] == '#Server = ':
- regions.setdefault(region, {})
-
- url = line.lstrip('#Server = ')
- regions[region][url] = True
- elif line.startswith('Server = '):
- regions.setdefault(region, {})
-
- url = line.lstrip('Server = ')
- regions[region][url] = True
+ regions = _parse_mirror_list(mirrorlist)
+ sorted_regions = {}
+ for region, urls in regions.items():
+ sorted_regions[region] = sorted(urls, reverse=True)
- return regions
+ return sorted_regions
diff --git a/archinstall/lib/models/__init__.py b/archinstall/lib/models/__init__.py
index 4a018b2c..a1c90e48 100644
--- a/archinstall/lib/models/__init__.py
+++ b/archinstall/lib/models/__init__.py
@@ -1 +1,9 @@
-from .network_configuration import NetworkConfiguration as NetworkConfiguration \ No newline at end of file
+from .network_configuration import (
+ NetworkConfiguration,
+ NicType,
+ Nic
+)
+from .bootloader import Bootloader
+from .gen import VersionDef, PackageSearchResult, PackageSearch, LocalPackage
+from .users import PasswordStrength, User
+from .audio_configuration import Audio, AudioConfiguration
diff --git a/archinstall/lib/models/audio_configuration.py b/archinstall/lib/models/audio_configuration.py
new file mode 100644
index 00000000..88cd5d8e
--- /dev/null
+++ b/archinstall/lib/models/audio_configuration.py
@@ -0,0 +1,54 @@
+from dataclasses import dataclass
+from enum import Enum
+from typing import Any, TYPE_CHECKING, Dict
+
+from ..hardware import SysInfo
+from ..output import info
+from ...default_profiles.applications.pipewire import PipewireProfile
+
+if TYPE_CHECKING:
+ _: Any
+
+
+@dataclass
+class Audio(Enum):
+ Pipewire = 'pipewire'
+ Pulseaudio = 'pulseaudio'
+
+ @staticmethod
+ def no_audio_text() -> str:
+ return str(_('No audio server'))
+
+
+@dataclass
+class AudioConfiguration:
+ audio: Audio
+
+ def json(self) -> Dict[str, Any]:
+ return {
+ 'audio': self.audio.value
+ }
+
+ @staticmethod
+ def parse_arg(arg: Dict[str, Any]) -> 'AudioConfiguration':
+ return AudioConfiguration(
+ Audio(arg['audio'])
+ )
+
+ def install_audio_config(
+ self,
+ installation: Any
+ ):
+ info(f'Installing audio server: {self.audio.name}')
+
+ match self.audio:
+ case Audio.Pipewire:
+ PipewireProfile().install(installation)
+ case Audio.Pulseaudio:
+ installation.add_additional_packages("pulseaudio")
+
+ if SysInfo.requires_sof_fw():
+ installation.add_additional_packages('sof-firmware')
+
+ if SysInfo.requires_alsa_fw():
+ installation.add_additional_packages('alsa-firmware')
diff --git a/archinstall/lib/models/bootloader.py b/archinstall/lib/models/bootloader.py
new file mode 100644
index 00000000..aa1a8e27
--- /dev/null
+++ b/archinstall/lib/models/bootloader.py
@@ -0,0 +1,47 @@
+from __future__ import annotations
+
+import sys
+from enum import Enum
+from typing import List
+
+from ..hardware import SysInfo
+from ..output import warn
+
+
+class Bootloader(Enum):
+ Systemd = 'Systemd-boot'
+ Grub = 'Grub'
+ Efistub = 'Efistub'
+ Limine = 'Limine'
+
+ def has_uki_support(self) -> bool:
+ match self:
+ case Bootloader.Efistub | Bootloader.Systemd:
+ return True
+ case _:
+ return False
+
+ def json(self) -> str:
+ return self.value
+
+ @staticmethod
+ def values() -> List[str]:
+ return [e.value for e in Bootloader]
+
+ @classmethod
+ def get_default(cls) -> Bootloader:
+ if SysInfo.has_uefi():
+ return Bootloader.Systemd
+ else:
+ return Bootloader.Grub
+
+ @classmethod
+ def from_arg(cls, bootloader: str) -> Bootloader:
+ # to support old configuration files
+ bootloader = bootloader.capitalize()
+
+ if bootloader not in cls.values():
+ values = ', '.join(cls.values())
+ warn(f'Invalid bootloader value "{bootloader}". Allowed values: {values}')
+ sys.exit(1)
+ return Bootloader(bootloader)
diff --git a/archinstall/lib/models/disk_encryption.py b/archinstall/lib/models/disk_encryption.py
deleted file mode 100644
index a4a501d9..00000000
--- a/archinstall/lib/models/disk_encryption.py
+++ /dev/null
@@ -1,90 +0,0 @@
-from __future__ import annotations
-
-from dataclasses import dataclass, field
-from enum import Enum
-from typing import Optional, List, Dict, TYPE_CHECKING, Any
-
-from ..hsm.fido import Fido2Device
-
-if TYPE_CHECKING:
- _: Any
-
-
-class EncryptionType(Enum):
- Partition = 'partition'
-
- @classmethod
- def _encryption_type_mapper(cls) -> Dict[str, 'EncryptionType']:
- return {
- # str(_('Full disk encryption')): EncryptionType.FullDiskEncryption,
- str(_('Partition encryption')): EncryptionType.Partition
- }
-
- @classmethod
- def text_to_type(cls, text: str) -> 'EncryptionType':
- mapping = cls._encryption_type_mapper()
- return mapping[text]
-
- @classmethod
- def type_to_text(cls, type_: 'EncryptionType') -> str:
- mapping = cls._encryption_type_mapper()
- type_to_text = {type_: text for text, type_ in mapping.items()}
- return type_to_text[type_]
-
-
-@dataclass
-class DiskEncryption:
- encryption_type: EncryptionType = EncryptionType.Partition
- encryption_password: str = ''
- partitions: Dict[str, List[Dict[str, Any]]] = field(default_factory=dict)
- hsm_device: Optional[Fido2Device] = None
-
- @property
- def all_partitions(self) -> List[Dict[str, Any]]:
- _all: List[Dict[str, Any]] = []
- for parts in self.partitions.values():
- _all += parts
- return _all
-
- def generate_encryption_file(self, partition) -> bool:
- return partition in self.all_partitions and partition['mountpoint'] != '/'
-
- def json(self) -> Dict[str, Any]:
- obj = {
- 'encryption_type': self.encryption_type.value,
- 'partitions': self.partitions
- }
-
- if self.hsm_device:
- obj['hsm_device'] = self.hsm_device.json()
-
- return obj
-
- @classmethod
- def parse_arg(
- cls,
- disk_layout: Dict[str, Any],
- arg: Dict[str, Any],
- password: str = ''
- ) -> 'DiskEncryption':
- # we have to map the enc partition config to the disk layout objects
- # they both need to point to the same object as it will get modified
- # during the installation process
- enc_partitions: Dict[str, List[Dict[str, Any]]] = {}
-
- for path, partitions in disk_layout.items():
- conf_partitions = arg['partitions'].get(path, [])
- for part in partitions['partitions']:
- if part in conf_partitions:
- enc_partitions.setdefault(path, []).append(part)
-
- enc = DiskEncryption(
- EncryptionType(arg['encryption_type']),
- password,
- enc_partitions
- )
-
- if hsm := arg.get('hsm_device', None):
- enc.hsm_device = Fido2Device.parse_arg(hsm)
-
- return enc
diff --git a/archinstall/lib/models/dataclasses.py b/archinstall/lib/models/gen.py
index 99221fe3..fb7e5751 100644
--- a/archinstall/lib/models/dataclasses.py
+++ b/archinstall/lib/models/gen.py
@@ -1,16 +1,17 @@
from dataclasses import dataclass
-from typing import Optional, List
+from typing import Optional, List, Dict, Any
+
@dataclass
class VersionDef:
version_string: str
@classmethod
- def parse_version(self) -> List[str]:
- if '.' in self.version_string:
- versions = self.version_string.split('.')
+ def parse_version(cls) -> List[str]:
+ if '.' in cls.version_string:
+ versions = cls.version_string.split('.')
else:
- versions = [self.version_string]
+ versions = [cls.version_string]
return versions
@@ -19,37 +20,44 @@ class VersionDef:
return self.parse_version()[0]
@classmethod
- def minor(self) -> str:
- versions = self.parse_version()
+ def minor(cls) -> Optional[str]:
+ versions = cls.parse_version()
if len(versions) >= 2:
return versions[1]
+ return None
+
@classmethod
- def patch(self) -> str:
- versions = self.parse_version()
+ def patch(cls) -> Optional[str]:
+ versions = cls.parse_version()
if '-' in versions[-1]:
_, patch_version = versions[-1].split('-', 1)
return patch_version
- def __eq__(self, other :'VersionDef') -> bool:
+ return None
+
+ def __eq__(self, other) -> bool:
if other.major == self.major and \
other.minor == self.minor and \
other.patch == self.patch:
return True
return False
-
- def __lt__(self, other :'VersionDef') -> bool:
- if self.major > other.major:
+
+ def __lt__(self, other) -> bool:
+ if self.major() > other.major():
return False
- elif self.minor and other.minor and self.minor > other.minor:
+ elif self.minor() and other.minor() and self.minor() > other.minor():
return False
- elif self.patch and other.patch and self.patch > other.patch:
+ elif self.patch() and other.patch() and self.patch() > other.patch():
return False
+ return True
+
def __str__(self) -> str:
return self.version_string
+
@dataclass
class PackageSearchResult:
pkgname: str
@@ -79,16 +87,21 @@ class PackageSearchResult:
makedepends: List[str]
checkdepends: List[str]
+ @staticmethod
+ def from_json(data: Dict[str, Any]) -> 'PackageSearchResult':
+ return PackageSearchResult(**data)
+
@property
def pkg_version(self) -> str:
return self.pkgver
- def __eq__(self, other :'VersionDef') -> bool:
+ def __eq__(self, other) -> bool:
return self.pkg_version == other.pkg_version
- def __lt__(self, other :'VersionDef') -> bool:
+ def __lt__(self, other) -> bool:
return self.pkg_version < other.pkg_version
+
@dataclass
class PackageSearch:
version: int
@@ -98,8 +111,19 @@ class PackageSearch:
page: int
results: List[PackageSearchResult]
- def __post_init__(self):
- self.results = [PackageSearchResult(**x) for x in self.results]
+ @staticmethod
+ def from_json(data: Dict[str, Any]) -> 'PackageSearch':
+ results = [PackageSearchResult.from_json(r) for r in data['results']]
+
+ return PackageSearch(
+ version=data['version'],
+ limit=data['limit'],
+ valid=data['valid'],
+ num_pages=data['num_pages'],
+ page=data['page'],
+ results=results
+ )
+
@dataclass
class LocalPackage:
@@ -129,8 +153,8 @@ class LocalPackage:
def pkg_version(self) -> str:
return self.version
- def __eq__(self, other :'VersionDef') -> bool:
+ def __eq__(self, other) -> bool:
return self.pkg_version == other.pkg_version
- def __lt__(self, other :'VersionDef') -> bool:
- return self.pkg_version < other.pkg_version \ No newline at end of file
+ def __lt__(self, other) -> bool:
+ return self.pkg_version < other.pkg_version
diff --git a/archinstall/lib/models/network_configuration.py b/archinstall/lib/models/network_configuration.py
index e026e97b..dfd8b8cb 100644
--- a/archinstall/lib/models/network_configuration.py
+++ b/archinstall/lib/models/network_configuration.py
@@ -1,183 +1,144 @@
from __future__ import annotations
-from dataclasses import dataclass
+from dataclasses import dataclass, field
from enum import Enum
-from typing import List, Optional, Dict, Union, Any, TYPE_CHECKING
+from typing import List, Optional, Dict, Any, TYPE_CHECKING, Tuple
-from ..output import log
-from ..storage import storage
+from ..profile import ProfileConfiguration
if TYPE_CHECKING:
_: Any
-class NicType(str, Enum):
+class NicType(Enum):
ISO = "iso"
NM = "nm"
MANUAL = "manual"
+ def display_msg(self) -> str:
+ match self:
+ case NicType.ISO:
+ return str(_('Copy ISO network configuration to installation'))
+ case NicType.NM:
+ return str(_('Use NetworkManager (necessary to configure internet graphically in GNOME and KDE Plasma)'))
+ case NicType.MANUAL:
+ return str(_('Manual configuration'))
+
@dataclass
-class NetworkConfiguration:
- type: NicType
+class Nic:
iface: Optional[str] = None
ip: Optional[str] = None
dhcp: bool = True
gateway: Optional[str] = None
- dns: Union[None, List[str]] = None
-
- def __str__(self):
- if self.is_iso():
- return "Copy ISO configuration"
- elif self.is_network_manager():
- return "Use NetworkManager"
- elif self.is_manual():
- if self.dhcp:
- return f'iface={self.iface}, dhcp=auto'
- else:
- return f'iface={self.iface}, ip={self.ip}, dhcp=staticIp, gateway={self.gateway}, dns={self.dns}'
+ dns: List[str] = field(default_factory=list)
+
+ def table_data(self) -> Dict[str, Any]:
+ return {
+ 'iface': self.iface if self.iface else '',
+ 'ip': self.ip if self.ip else '',
+ 'dhcp': self.dhcp,
+ 'gateway': self.gateway if self.gateway else '',
+ 'dns': self.dns
+ }
+
+ def json(self) -> Dict[str, Any]:
+ return {
+ 'iface': self.iface,
+ 'ip': self.ip,
+ 'dhcp': self.dhcp,
+ 'gateway': self.gateway,
+ 'dns': self.dns
+ }
+
+ @staticmethod
+ def parse_arg(arg: Dict[str, Any]) -> Nic:
+ return Nic(
+ iface=arg.get('iface', None),
+ ip=arg.get('ip', None),
+ dhcp=arg.get('dhcp', True),
+ gateway=arg.get('gateway', None),
+ dns=arg.get('dns', []),
+ )
+
+ def as_systemd_config(self) -> str:
+ match: List[Tuple[str, str]] = []
+ network: List[Tuple[str, str]] = []
+
+ if self.iface:
+ match.append(('Name', self.iface))
+
+ if self.dhcp:
+ network.append(('DHCP', 'yes'))
else:
- return 'Unknown type'
-
- def as_json(self) -> Dict:
- exclude_fields = ['type']
- data = {}
- for k, v in self.__dict__.items():
- if k not in exclude_fields:
- if isinstance(v, list) and len(v) == 0:
- v = ''
- elif v is None:
- v = ''
-
- data[k] = v
-
- return data
+ if self.ip:
+ network.append(('Address', self.ip))
+ if self.gateway:
+ network.append(('Gateway', self.gateway))
+ for dns in self.dns:
+ network.append(('DNS', dns))
- def json(self) -> Dict:
- # for json serialization when calling json.dumps(...) on this class
- return self.__dict__
+ config = {'Match': match, 'Network': network}
- def is_iso(self) -> bool:
- return self.type == NicType.ISO
+ config_str = ''
+ for top, entries in config.items():
+ config_str += f'[{top}]\n'
+ config_str += '\n'.join([f'{k}={v}' for k, v in entries])
+ config_str += '\n\n'
- def is_network_manager(self) -> bool:
- return self.type == NicType.NM
+ return config_str
- def is_manual(self) -> bool:
- return self.type == NicType.MANUAL
+@dataclass
+class NetworkConfiguration:
+ type: NicType
+ nics: List[Nic] = field(default_factory=list)
-class NetworkConfigurationHandler:
- def __init__(self, config: Union[None, NetworkConfiguration, List[NetworkConfiguration]] = None):
- self._configuration = config
-
- @property
- def configuration(self):
- return self._configuration
+ def json(self) -> Dict[str, Any]:
+ config: Dict[str, Any] = {'type': self.type.value}
+ if self.nics:
+ config['nics'] = [n.json() for n in self.nics]
- def config_installer(self, installation: Any):
- if self._configuration is None:
- return
+ return config
- if isinstance(self._configuration, list):
- for config in self._configuration:
- installation.configure_nic(config)
+ @staticmethod
+ def parse_arg(config: Dict[str, Any]) -> Optional[NetworkConfiguration]:
+ nic_type = config.get('type', None)
+ if not nic_type:
+ return None
- installation.enable_service('systemd-networkd')
- installation.enable_service('systemd-resolved')
- else:
- # If user selected to copy the current ISO network configuration
- # Perform a copy of the config
- if self._configuration.is_iso():
+ match NicType(nic_type):
+ case NicType.ISO:
+ return NetworkConfiguration(NicType.ISO)
+ case NicType.NM:
+ return NetworkConfiguration(NicType.NM)
+ case NicType.MANUAL:
+ nics_arg = config.get('nics', [])
+ if nics_arg:
+ nics = [Nic.parse_arg(n) for n in nics_arg]
+ return NetworkConfiguration(NicType.MANUAL, nics)
+
+ return None
+
+ def install_network_config(
+ self,
+ installation: Any,
+ profile_config: Optional[ProfileConfiguration] = None
+ ):
+ match self.type:
+ case NicType.ISO:
installation.copy_iso_network_config(
- enable_services=True) # Sources the ISO network configuration to the install medium.
- elif self._configuration.is_network_manager():
+ enable_services=True # Sources the ISO network configuration to the install medium.
+ )
+ case NicType.NM:
installation.add_additional_packages(["networkmanager"])
- if (profile := storage['arguments'].get('profile')) and profile.is_desktop_profile:
- installation.add_additional_packages(["network-manager-applet"])
+ if profile_config and profile_config.profile:
+ if profile_config.profile.is_desktop_profile():
+ installation.add_additional_packages(["network-manager-applet"])
installation.enable_service('NetworkManager.service')
+ case NicType.MANUAL:
+ for nic in self.nics:
+ installation.configure_nic(nic)
- def _backwards_compability_config(self, config: Union[str,Dict[str, str]]) -> Union[List[NetworkConfiguration], NetworkConfiguration, None]:
- def get(config: Dict[str, str], key: str) -> List[str]:
- if (value := config.get(key, None)) is not None:
- return [value]
- return []
-
- if isinstance(config, str): # is a ISO network
- return NetworkConfiguration(NicType.ISO)
- elif config.get('NetworkManager'): # is a network manager configuration
- return NetworkConfiguration(NicType.NM)
- elif 'ip' in config:
- return [NetworkConfiguration(
- NicType.MANUAL,
- iface=config.get('nic', ''),
- ip=config.get('ip'),
- gateway=config.get('gateway', ''),
- dns=get(config, 'dns'),
- dhcp=False
- )]
- elif 'nic' in config:
- return [NetworkConfiguration(
- NicType.MANUAL,
- iface=config.get('nic', ''),
- dhcp=True
- )]
- else: # not recognized
- return None
-
- def _parse_manual_config(self, configs: List[Dict[str, Any]]) -> Optional[List[NetworkConfiguration]]:
- configurations = []
-
- for manual_config in configs:
- iface = manual_config.get('iface', None)
-
- if iface is None:
- log(_('No iface specified for manual configuration'))
- exit(1)
-
- if manual_config.get('dhcp', False) or not any([manual_config.get(v, '') for v in ['ip', 'gateway', 'dns']]):
- configurations.append(
- NetworkConfiguration(NicType.MANUAL, iface=iface)
- )
- else:
- ip = manual_config.get('ip', '')
- if not ip:
- log(_('Manual nic configuration with no auto DHCP requires an IP address'), fg='red')
- exit(1)
-
- configurations.append(
- NetworkConfiguration(
- NicType.MANUAL,
- iface=iface,
- ip=ip,
- gateway=manual_config.get('gateway', ''),
- dns=manual_config.get('dns', []),
- dhcp=False
- )
- )
-
- return configurations
-
- def _parse_nic_type(self, nic_type: str) -> NicType:
- try:
- return NicType(nic_type)
- except ValueError:
- options = [e.value for e in NicType]
- log(_('Unknown nic type: {}. Possible values are {}').format(nic_type, options), fg='red')
- exit(1)
-
- def parse_arguments(self, config: Any):
- if isinstance(config, list): # new data format
- self._configuration = self._parse_manual_config(config)
- elif nic_type := config.get('type', None): # new data format
- type_ = self._parse_nic_type(nic_type)
-
- if type_ != NicType.MANUAL:
- self._configuration = NetworkConfiguration(type_)
- else: # manual configuration settings
- self._configuration = self._parse_manual_config([config])
- else: # old style definitions
- network_config = self._backwards_compability_config(config)
- if network_config:
- return network_config
- return None
+ installation.enable_service('systemd-networkd')
+ installation.enable_service('systemd-resolved')
diff --git a/archinstall/lib/models/password_strength.py b/archinstall/lib/models/password_strength.py
deleted file mode 100644
index 61986bf0..00000000
--- a/archinstall/lib/models/password_strength.py
+++ /dev/null
@@ -1,85 +0,0 @@
-from enum import Enum
-
-
-class PasswordStrength(Enum):
- VERY_WEAK = 'very weak'
- WEAK = 'weak'
- MODERATE = 'moderate'
- STRONG = 'strong'
-
- @property
- def value(self):
- match self:
- case PasswordStrength.VERY_WEAK: return str(_('very weak'))
- case PasswordStrength.WEAK: return str(_('weak'))
- case PasswordStrength.MODERATE: return str(_('moderate'))
- case PasswordStrength.STRONG: return str(_('strong'))
-
- def color(self):
- match self:
- case PasswordStrength.VERY_WEAK: return 'red'
- case PasswordStrength.WEAK: return 'red'
- case PasswordStrength.MODERATE: return 'yellow'
- case PasswordStrength.STRONG: return 'green'
-
- @classmethod
- def strength(cls, password: str) -> 'PasswordStrength':
- digit = any(character.isdigit() for character in password)
- upper = any(character.isupper() for character in password)
- lower = any(character.islower() for character in password)
- symbol = any(not character.isalnum() for character in password)
- return cls._check_password_strength(digit, upper, lower, symbol, len(password))
-
- @classmethod
- def _check_password_strength(
- cls,
- digit: bool,
- upper: bool,
- lower: bool,
- symbol: bool,
- length: int
- ) -> 'PasswordStrength':
- # suggested evaluation
- # https://github.com/archlinux/archinstall/issues/1304#issuecomment-1146768163
- if digit and upper and lower and symbol:
- match length:
- case num if 13 <= num:
- return PasswordStrength.STRONG
- case num if 11 <= num <= 12:
- return PasswordStrength.MODERATE
- case num if 7 <= num <= 10:
- return PasswordStrength.WEAK
- case num if num <= 6:
- return PasswordStrength.VERY_WEAK
- elif digit and upper and lower:
- match length:
- case num if 14 <= num:
- return PasswordStrength.STRONG
- case num if 11 <= num <= 13:
- return PasswordStrength.MODERATE
- case num if 7 <= num <= 10:
- return PasswordStrength.WEAK
- case num if num <= 6:
- return PasswordStrength.VERY_WEAK
- elif upper and lower:
- match length:
- case num if 15 <= num:
- return PasswordStrength.STRONG
- case num if 12 <= num <= 14:
- return PasswordStrength.MODERATE
- case num if 7 <= num <= 11:
- return PasswordStrength.WEAK
- case num if num <= 6:
- return PasswordStrength.VERY_WEAK
- elif lower or upper:
- match length:
- case num if 18 <= num:
- return PasswordStrength.STRONG
- case num if 14 <= num <= 17:
- return PasswordStrength.MODERATE
- case num if 9 <= num <= 13:
- return PasswordStrength.WEAK
- case num if num <= 8:
- return PasswordStrength.VERY_WEAK
-
- return PasswordStrength.VERY_WEAK
diff --git a/archinstall/lib/models/pydantic.py b/archinstall/lib/models/pydantic.py
deleted file mode 100644
index 799e92af..00000000
--- a/archinstall/lib/models/pydantic.py
+++ /dev/null
@@ -1,134 +0,0 @@
-from typing import Optional, List
-from pydantic import BaseModel
-
-"""
-This python file is not in use.
-Pydantic is not a builtin, and we use the dataclasses.py instead!
-"""
-
-class VersionDef(BaseModel):
- version_string: str
-
- @classmethod
- def parse_version(self) -> List[str]:
- if '.' in self.version_string:
- versions = self.version_string.split('.')
- else:
- versions = [self.version_string]
-
- return versions
-
- @classmethod
- def major(self) -> str:
- return self.parse_version()[0]
-
- @classmethod
- def minor(self) -> str:
- versions = self.parse_version()
- if len(versions) >= 2:
- return versions[1]
-
- @classmethod
- def patch(self) -> str:
- versions = self.parse_version()
- if '-' in versions[-1]:
- _, patch_version = versions[-1].split('-', 1)
- return patch_version
-
- def __eq__(self, other :'VersionDef') -> bool:
- if other.major == self.major and \
- other.minor == self.minor and \
- other.patch == self.patch:
-
- return True
- return False
-
- def __lt__(self, other :'VersionDef') -> bool:
- if self.major > other.major:
- return False
- elif self.minor and other.minor and self.minor > other.minor:
- return False
- elif self.patch and other.patch and self.patch > other.patch:
- return False
-
- def __str__(self) -> str:
- return self.version_string
-
-
-class PackageSearchResult(BaseModel):
- pkgname: str
- pkgbase: str
- repo: str
- arch: str
- pkgver: str
- pkgrel: str
- epoch: int
- pkgdesc: str
- url: str
- filename: str
- compressed_size: int
- installed_size: int
- build_date: str
- last_update: str
- flag_date: Optional[str]
- maintainers: List[str]
- packager: str
- groups: List[str]
- licenses: List[str]
- conflicts: List[str]
- provides: List[str]
- replaces: List[str]
- depends: List[str]
- optdepends: List[str]
- makedepends: List[str]
- checkdepends: List[str]
-
- @property
- def pkg_version(self) -> str:
- return self.pkgver
-
- def __eq__(self, other :'VersionDef') -> bool:
- return self.pkg_version == other.pkg_version
-
- def __lt__(self, other :'VersionDef') -> bool:
- return self.pkg_version < other.pkg_version
-
-
-class PackageSearch(BaseModel):
- version: int
- limit: int
- valid: bool
- results: List[PackageSearchResult]
-
-
-class LocalPackage(BaseModel):
- name: str
- version: str
- description:str
- architecture: str
- url: str
- licenses: str
- groups: str
- depends_on: str
- optional_deps: str
- required_by: str
- optional_for: str
- conflicts_with: str
- replaces: str
- installed_size: str
- packager: str
- build_date: str
- install_date: str
- install_reason: str
- install_script: str
- validated_by: str
-
- @property
- def pkg_version(self) -> str:
- return self.version
-
- def __eq__(self, other :'VersionDef') -> bool:
- return self.pkg_version == other.pkg_version
-
- def __lt__(self, other :'VersionDef') -> bool:
- return self.pkg_version < other.pkg_version \ No newline at end of file
diff --git a/archinstall/lib/models/subvolume.py b/archinstall/lib/models/subvolume.py
deleted file mode 100644
index 34a09227..00000000
--- a/archinstall/lib/models/subvolume.py
+++ /dev/null
@@ -1,68 +0,0 @@
-from dataclasses import dataclass
-from typing import List, Any, Dict
-
-
-@dataclass
-class Subvolume:
- name: str
- mountpoint: str
- compress: bool = False
- nodatacow: bool = False
-
- def display(self) -> str:
- options_str = ','.join(self.options)
- return f'{_("Subvolume")}: {self.name:15} {_("Mountpoint")}: {self.mountpoint:20} {_("Options")}: {options_str}'
-
- @property
- def options(self) -> List[str]:
- options = [
- 'compress' if self.compress else '',
- 'nodatacow' if self.nodatacow else ''
- ]
- return [o for o in options if len(o)]
-
- def json(self) -> Dict[str, Any]:
- return {
- 'name': self.name,
- 'mountpoint': self.mountpoint,
- 'compress': self.compress,
- 'nodatacow': self.nodatacow
- }
-
- @classmethod
- def _parse(cls, config_subvolumes: List[Dict[str, Any]]) -> List['Subvolume']:
- subvolumes = []
- for entry in config_subvolumes:
- if not entry.get('name', None) or not entry.get('mountpoint', None):
- continue
-
- subvolumes.append(
- Subvolume(
- entry['name'],
- entry['mountpoint'],
- entry.get('compress', False),
- entry.get('nodatacow', False)
- )
- )
-
- return subvolumes
-
- @classmethod
- def _parse_backwards_compatible(cls, config_subvolumes) -> List['Subvolume']:
- subvolumes = []
- for name, mountpoint in config_subvolumes.items():
- if not name or not mountpoint:
- continue
-
- subvolumes.append(Subvolume(name, mountpoint))
-
- return subvolumes
-
- @classmethod
- def parse_arguments(cls, config_subvolumes: Any) -> List['Subvolume']:
- if isinstance(config_subvolumes, list):
- return cls._parse(config_subvolumes)
- elif isinstance(config_subvolumes, dict):
- return cls._parse_backwards_compatible(config_subvolumes)
-
- raise ValueError('Unknown disk layout btrfs subvolume format')
diff --git a/archinstall/lib/models/users.py b/archinstall/lib/models/users.py
index a8feb9ef..9ed70eef 100644
--- a/archinstall/lib/models/users.py
+++ b/archinstall/lib/models/users.py
@@ -1,12 +1,95 @@
from dataclasses import dataclass
from typing import Dict, List, Union, Any, TYPE_CHECKING
-
-from .password_strength import PasswordStrength
+from enum import Enum
if TYPE_CHECKING:
_: Any
+class PasswordStrength(Enum):
+ VERY_WEAK = 'very weak'
+ WEAK = 'weak'
+ MODERATE = 'moderate'
+ STRONG = 'strong'
+
+ @property
+ def value(self):
+ match self:
+ case PasswordStrength.VERY_WEAK: return str(_('very weak'))
+ case PasswordStrength.WEAK: return str(_('weak'))
+ case PasswordStrength.MODERATE: return str(_('moderate'))
+ case PasswordStrength.STRONG: return str(_('strong'))
+
+ def color(self):
+ match self:
+ case PasswordStrength.VERY_WEAK: return 'red'
+ case PasswordStrength.WEAK: return 'red'
+ case PasswordStrength.MODERATE: return 'yellow'
+ case PasswordStrength.STRONG: return 'green'
+
+ @classmethod
+ def strength(cls, password: str) -> 'PasswordStrength':
+ digit = any(character.isdigit() for character in password)
+ upper = any(character.isupper() for character in password)
+ lower = any(character.islower() for character in password)
+ symbol = any(not character.isalnum() for character in password)
+ return cls._check_password_strength(digit, upper, lower, symbol, len(password))
+
+ @classmethod
+ def _check_password_strength(
+ cls,
+ digit: bool,
+ upper: bool,
+ lower: bool,
+ symbol: bool,
+ length: int
+ ) -> 'PasswordStrength':
+ # suggested evaluation
+ # https://github.com/archlinux/archinstall/issues/1304#issuecomment-1146768163
+ if digit and upper and lower and symbol:
+ match length:
+ case num if 13 <= num:
+ return PasswordStrength.STRONG
+ case num if 11 <= num <= 12:
+ return PasswordStrength.MODERATE
+ case num if 7 <= num <= 10:
+ return PasswordStrength.WEAK
+ case num if num <= 6:
+ return PasswordStrength.VERY_WEAK
+ elif digit and upper and lower:
+ match length:
+ case num if 14 <= num:
+ return PasswordStrength.STRONG
+ case num if 11 <= num <= 13:
+ return PasswordStrength.MODERATE
+ case num if 7 <= num <= 10:
+ return PasswordStrength.WEAK
+ case num if num <= 6:
+ return PasswordStrength.VERY_WEAK
+ elif upper and lower:
+ match length:
+ case num if 15 <= num:
+ return PasswordStrength.STRONG
+ case num if 12 <= num <= 14:
+ return PasswordStrength.MODERATE
+ case num if 7 <= num <= 11:
+ return PasswordStrength.WEAK
+ case num if num <= 6:
+ return PasswordStrength.VERY_WEAK
+ elif lower or upper:
+ match length:
+ case num if 18 <= num:
+ return PasswordStrength.STRONG
+ case num if 14 <= num <= 17:
+ return PasswordStrength.MODERATE
+ case num if 9 <= num <= 13:
+ return PasswordStrength.WEAK
+ case num if num <= 8:
+ return PasswordStrength.VERY_WEAK
+
+ return PasswordStrength.VERY_WEAK
+
+
@dataclass
class User:
username: str
@@ -26,13 +109,6 @@ class User:
'sudo': self.sudo
}
- def display(self) -> str:
- password = '*' * (len(self.password) if self.password else 0)
- if password:
- strength = PasswordStrength.strength(self.password)
- password += f' ({strength.value})'
- return f'{_("Username")}: {self.username:16} {_("Password")}: {password:20} sudo: {str(self.sudo)}'
-
@classmethod
def _parse(cls, config_users: List[Dict[str, Any]]) -> List['User']:
users = []
diff --git a/archinstall/lib/networking.py b/archinstall/lib/networking.py
index 5a60886f..bfc4b7d5 100644
--- a/archinstall/lib/networking.py
+++ b/archinstall/lib/networking.py
@@ -1,21 +1,22 @@
-import logging
import os
import socket
+import ssl
import struct
-from typing import Union, Dict, Any, List
+from typing import Union, Dict, Any, List, Optional
+from urllib.error import URLError
+from urllib.parse import urlencode
+from urllib.request import urlopen
-from .exceptions import HardwareIncompatibilityError, SysCallError
-from .general import SysCommand
-from .output import log
-from .pacman import run_pacman
-from .storage import storage
+from .exceptions import SysCallError
+from .output import error, info
+from .pacman import Pacman
def get_hw_addr(ifname :str) -> str:
import fcntl
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
- info = fcntl.ioctl(s.fileno(), 0x8927, struct.pack('256s', bytes(ifname, 'utf-8')[:15]))
- return ':'.join('%02x' % b for b in info[18:24])
+ ret = fcntl.ioctl(s.fileno(), 0x8927, struct.pack('256s', bytes(ifname, 'utf-8')[:15]))
+ return ':'.join('%02x' % b for b in ret[18:24])
def list_interfaces(skip_loopback :bool = True) -> Dict[str, str]:
@@ -31,26 +32,14 @@ def list_interfaces(skip_loopback :bool = True) -> Dict[str, str]:
return interfaces
-def check_mirror_reachable() -> bool:
- log("Testing connectivity to the Arch Linux mirrors ...", level=logging.INFO)
- try:
- if run_pacman("-Sy").exit_code == 0:
- return True
- elif os.geteuid() != 0:
- log("check_mirror_reachable() uses 'pacman -Sy' which requires root.", level=logging.ERROR, fg="red")
- except SysCallError as err:
- log(err, level=logging.DEBUG)
-
- return False
-
-
def update_keyring() -> bool:
- log("Updating archlinux-keyring ...", level=logging.INFO)
- if run_pacman("-Sy --noconfirm archlinux-keyring").exit_code == 0:
+ info("Updating archlinux-keyring ...")
+ try:
+ Pacman.run("-Sy --noconfirm archlinux-keyring")
return True
-
- elif os.geteuid() != 0:
- log("update_keyring() uses 'pacman -Sy archlinux-keyring' which requires root.", level=logging.ERROR, fg="red")
+ except SysCallError:
+ if os.geteuid() != 0:
+ error("update_keyring() uses 'pacman -Sy archlinux-keyring' which requires root.")
return False
@@ -86,35 +75,20 @@ def enrich_iface_types(interfaces: Union[Dict[str, Any], List[str]]) -> Dict[str
return result
-def get_interface_from_mac(mac :str) -> str:
- return list_interfaces().get(mac.lower(), None)
+def fetch_data_from_url(url: str, params: Optional[Dict] = None) -> str:
+ ssl_context = ssl.create_default_context()
+ ssl_context.check_hostname = False
+ ssl_context.verify_mode = ssl.CERT_NONE
+ if params is not None:
+ encoded = urlencode(params)
+ full_url = f'{url}?{encoded}'
+ else:
+ full_url = url
-def wireless_scan(interface :str) -> None:
- interfaces = enrich_iface_types(list_interfaces().values())
- if interfaces[interface] != 'WIRELESS':
- raise HardwareIncompatibilityError(f"Interface {interface} is not a wireless interface: {interfaces}")
-
- if not (output := SysCommand(f"iwctl station {interface} scan")).exit_code == 0:
- raise SystemError(f"Could not scan for wireless networks: {output}")
-
- if '_WIFI' not in storage:
- storage['_WIFI'] = {}
- if interface not in storage['_WIFI']:
- storage['_WIFI'][interface] = {}
-
- storage['_WIFI'][interface]['scanning'] = True
-
-
-# TODO: Full WiFi experience might get evolved in the future, pausing for now 2021-01-25
-def get_wireless_networks(interface :str) -> None:
- # TODO: Make this oneliner pritter to check if the interface is scanning or not.
- # TODO: Rename this to list_wireless_networks() as it doesn't return anything
- if '_WIFI' not in storage or interface not in storage['_WIFI'] or storage['_WIFI'][interface].get('scanning', False) is False:
- import time
-
- wireless_scan(interface)
- time.sleep(5)
-
- for line in SysCommand(f"iwctl station {interface} get-networks"):
- print(line)
+ try:
+ response = urlopen(full_url, context=ssl_context)
+ data = response.read().decode('UTF-8')
+ return data
+ except URLError:
+ raise ValueError(f'Unable to fetch data from url: {url}')
diff --git a/archinstall/lib/output.py b/archinstall/lib/output.py
index 709a7382..62a1ba27 100644
--- a/archinstall/lib/output.py
+++ b/archinstall/lib/output.py
@@ -1,19 +1,28 @@
import logging
import os
import sys
+import unicodedata
+from enum import Enum
+
from pathlib import Path
-from typing import Dict, Union, List, Any, Callable
+from typing import Dict, Union, List, Any, Callable, Optional
+from dataclasses import asdict, is_dataclass
from .storage import storage
-from dataclasses import asdict, is_dataclass
class FormattedOutput:
@classmethod
- def values(cls, o: Any, class_formatter: str = None, filter_list: List[str] = None) -> Dict[str, Any]:
- """ the original values returned a dataclass as dict thru the call to some specific methods
- this version allows thru the parameter class_formatter to call a dynamicly selected formatting method.
+ def _get_values(
+ cls,
+ o: Any,
+ class_formatter: Optional[Union[str, Callable]] = None,
+ filter_list: List[str] = []
+ ) -> Dict[str, Any]:
+ """
+ the original values returned a dataclass as dict thru the call to some specific methods
+ this version allows thru the parameter class_formatter to call a dynamically selected formatting method.
Can transmit a filter list to the class_formatter,
"""
if class_formatter:
@@ -25,9 +34,10 @@ class FormattedOutput:
elif hasattr(o, class_formatter) and callable(getattr(o, class_formatter)):
func = getattr(o, class_formatter)
return func(filter_list)
- # kept as to make it backward compatible
- elif hasattr(o, 'as_json'):
- return o.as_json()
+
+ raise ValueError('Unsupported formatting call')
+ elif hasattr(o, 'table_data'):
+ return o.table_data()
elif hasattr(o, 'json'):
return o.json()
elif is_dataclass(o):
@@ -36,7 +46,13 @@ class FormattedOutput:
return o.__dict__
@classmethod
- def as_table(cls, obj: List[Any], class_formatter: Union[str, Callable] = None, filter_list: List[str] = None) -> str:
+ def as_table(
+ cls,
+ obj: List[Any],
+ class_formatter: Optional[Union[str, Callable]] = None,
+ filter_list: List[str] = [],
+ capitalize: bool = False
+ ) -> str:
""" variant of as_table (subtly different code) which has two additional parameters
filter which is a list of fields which will be shon
class_formatter a special method to format the outgoing data
@@ -45,7 +61,8 @@ class FormattedOutput:
is for compatibility with a print statement
As_table_filter can be a drop in replacement for as_table
"""
- raw_data = [cls.values(o, class_formatter, filter_list) for o in obj]
+ raw_data = [cls._get_values(o, class_formatter, filter_list) for o in obj]
+
# determine the maximum column size
column_width: Dict[str, int] = {}
for o in raw_data:
@@ -55,14 +72,20 @@ class FormattedOutput:
column_width[k] = max([column_width[k], len(str(v)), len(k)])
if not filter_list:
- filter_list = (column_width.keys())
+ filter_list = list(column_width.keys())
+
# create the header lines
output = ''
key_list = []
for key in filter_list:
width = column_width[key]
- key = key.replace('!', '')
- key_list.append(key.ljust(width))
+ key = key.replace('!', '').replace('_', ' ')
+
+ if capitalize:
+ key = key.capitalize()
+
+ key_list.append(unicode_ljust(key, width))
+
output += ' | '.join(key_list) + '\n'
output += '-' * len(output) + '\n'
@@ -72,20 +95,40 @@ class FormattedOutput:
for key in filter_list:
width = column_width.get(key, len(key))
value = record.get(key, '')
+
if '!' in key:
value = '*' * width
- if isinstance(value,(int, float)) or (isinstance(value, str) and value.isnumeric()):
- obj_data.append(str(value).rjust(width))
+
+ if isinstance(value, (int, float)) or (isinstance(value, str) and value.isnumeric()):
+ obj_data.append(unicode_rjust(str(value), width))
else:
- obj_data.append(str(value).ljust(width))
+ obj_data.append(unicode_ljust(str(value), width))
+
output += ' | '.join(obj_data) + '\n'
return output
+ @classmethod
+ def as_columns(cls, entries: List[str], cols: int) -> str:
+ """
+ Will format a list into a given number of columns
+ """
+ chunks = []
+ output = ''
+
+ for i in range(0, len(entries), cols):
+ chunks.append(entries[i:i + cols])
+
+ for row in chunks:
+ out_fmt = '{: <30} ' * len(row)
+ output += out_fmt.format(*row) + '\n'
+
+ return output
+
class Journald:
@staticmethod
- def log(message :str, level :int = logging.DEBUG) -> None:
+ def log(message: str, level: int = logging.DEBUG) -> None:
try:
import systemd.journal # type: ignore
except ModuleNotFoundError:
@@ -101,16 +144,39 @@ class Journald:
log_adapter.log(level, message)
-# TODO: Replace log() for session based logging.
-class SessionLogging:
- def __init__(self):
- pass
+def _check_log_permissions():
+ filename = storage.get('LOG_FILE', None)
+ log_dir = storage.get('LOG_PATH', Path('./'))
+
+ if not filename:
+ raise ValueError('No log file name defined')
+
+ log_file = log_dir / filename
+
+ try:
+ log_dir.mkdir(exist_ok=True, parents=True)
+ log_file.touch(exist_ok=True)
+ with log_file.open('a') as fp:
+ fp.write('')
+ except PermissionError:
+ # Fallback to creating the log file in the current folder
+ fallback_dir = Path('./').absolute()
+ fallback_log_file = fallback_dir / filename
-# Found first reference here: https://stackoverflow.com/questions/7445658/how-to-detect-if-the-console-does-support-ansi-escape-codes-in-python
-# And re-used this: https://github.com/django/django/blob/master/django/core/management/color.py#L12
-def supports_color() -> bool:
+ fallback_log_file.touch(exist_ok=True)
+
+ storage['LOG_PATH'] = fallback_dir
+ warn(f'Not enough permission to place log file at {log_file}, creating it in {fallback_log_file} instead')
+
+
+def _supports_color() -> bool:
"""
+ Found first reference here:
+ https://stackoverflow.com/questions/7445658/how-to-detect-if-the-console-does-support-ansi-escape-codes-in-python
+ And re-used this:
+ https://github.com/django/django/blob/master/django/core/management/color.py#L12
+
Return True if the running system's terminal supports color,
and False otherwise.
"""
@@ -121,13 +187,30 @@ def supports_color() -> bool:
return supported_platform and is_a_tty
-# Heavily influenced by: https://github.com/django/django/blob/ae8338daf34fd746771e0678081999b656177bae/django/utils/termcolors.py#L13
-# Color options here: https://askubuntu.com/questions/528928/how-to-do-underline-bold-italic-strikethrough-color-background-and-size-i
-def stylize_output(text: str, *opts :str, **kwargs) -> str:
+class Font(Enum):
+ bold = '1'
+ italic = '3'
+ underscore = '4'
+ blink = '5'
+ reverse = '7'
+ conceal = '8'
+
+
+def _stylize_output(
+ text: str,
+ fg: str,
+ bg: Optional[str],
+ reset: bool,
+ font: List[Font] = [],
+) -> str:
"""
+ Heavily influenced by:
+ https://github.com/django/django/blob/ae8338daf34fd746771e0678081999b656177bae/django/utils/termcolors.py#L13
+ Color options here:
+ https://askubuntu.com/questions/528928/how-to-do-underline-bold-italic-strikethrough-color-background-and-size-i
+
Adds styling to a text given a set of color arguments.
"""
- opt_dict = {'bold': '1', 'italic': '3', 'underscore': '4', 'blink': '5', 'reverse': '7', 'conceal': '8'}
colors = {
'black' : '0',
'red' : '1',
@@ -145,65 +228,132 @@ def stylize_output(text: str, *opts :str, **kwargs) -> str:
'darkgray' : '8;5;240',
'lightgray' : '8;5;256'
}
+
foreground = {key: f'3{colors[key]}' for key in colors}
background = {key: f'4{colors[key]}' for key in colors}
- reset = '0'
-
code_list = []
- if text == '' and len(opts) == 1 and opts[0] == 'reset':
- return '\x1b[%sm' % reset
- for k, v in kwargs.items():
- if k == 'fg':
- code_list.append(foreground[str(v)])
- elif k == 'bg':
- code_list.append(background[str(v)])
+ if text == '' and reset:
+ return '\x1b[%sm' % '0'
- for o in opts:
- if o in opt_dict:
- code_list.append(opt_dict[o])
+ code_list.append(foreground[str(fg)])
- if 'noreset' not in opts:
- text = '%s\x1b[%sm' % (text or '', reset)
+ if bg:
+ code_list.append(background[str(bg)])
- return '%s%s' % (('\x1b[%sm' % ';'.join(code_list)), text or '')
+ for o in font:
+ code_list.append(o.value)
+ ansi = ';'.join(code_list)
-def log(*args :str, **kwargs :Union[str, int, Dict[str, Union[str, int]]]) -> None:
- string = orig_string = ' '.join([str(x) for x in args])
+ return f'\033[{ansi}m{text}\033[0m'
- # Attempt to colorize the output if supported
- # Insert default colors and override with **kwargs
- if supports_color():
- kwargs = {'fg': 'white', **kwargs}
- string = stylize_output(string, **kwargs)
- # If a logfile is defined in storage,
- # we use that one to output everything
- if filename := storage.get('LOG_FILE', None):
- absolute_logfile = os.path.join(storage.get('LOG_PATH', './'), filename)
+def info(
+ *msgs: str,
+ level: int = logging.INFO,
+ fg: str = 'white',
+ bg: Optional[str] = None,
+ reset: bool = False,
+ font: List[Font] = []
+):
+ log(*msgs, level=level, fg=fg, bg=bg, reset=reset, font=font)
- try:
- Path(absolute_logfile).parents[0].mkdir(exist_ok=True, parents=True)
- with open(absolute_logfile, 'a') as log_file:
- log_file.write("")
- except PermissionError:
- # Fallback to creating the log file in the current folder
- err_string = f"Not enough permission to place log file at {absolute_logfile}, creating it in {Path('./').absolute() / filename} instead."
- absolute_logfile = Path('./').absolute() / filename
- absolute_logfile.parents[0].mkdir(exist_ok=True)
- absolute_logfile = str(absolute_logfile)
- storage['LOG_PATH'] = './'
- log(err_string, fg="red")
-
- with open(absolute_logfile, 'a') as log_file:
- log_file.write(f"{orig_string}\n")
-
- Journald.log(string, level=int(str(kwargs.get('level', logging.INFO))))
-
- # Finally, print the log unless we skipped it based on level.
- # We use sys.stdout.write()+flush() instead of print() to try and
- # fix issue #94
- if kwargs.get('level', logging.INFO) != logging.DEBUG or storage['arguments'].get('verbose', False):
- sys.stdout.write(f"{string}\n")
- sys.stdout.flush()
+
+def debug(
+ *msgs: str,
+ level: int = logging.DEBUG,
+ fg: str = 'white',
+ bg: Optional[str] = None,
+ reset: bool = False,
+ font: List[Font] = []
+):
+ log(*msgs, level=level, fg=fg, bg=bg, reset=reset, font=font)
+
+
+def error(
+ *msgs: str,
+ level: int = logging.ERROR,
+ fg: str = 'red',
+ bg: Optional[str] = None,
+ reset: bool = False,
+ font: List[Font] = []
+):
+ log(*msgs, level=level, fg=fg, bg=bg, reset=reset, font=font)
+
+
+def warn(
+ *msgs: str,
+ level: int = logging.WARN,
+ fg: str = 'yellow',
+ bg: Optional[str] = None,
+ reset: bool = False,
+ font: List[Font] = []
+):
+ log(*msgs, level=level, fg=fg, bg=bg, reset=reset, font=font)
+
+
+def log(
+ *msgs: str,
+ level: int = logging.INFO,
+ fg: str = 'white',
+ bg: Optional[str] = None,
+ reset: bool = False,
+ font: List[Font] = []
+):
+ # leave this check here as we need to setup the logging
+ # right from the beginning when the modules are loaded
+ _check_log_permissions()
+
+ text = orig_string = ' '.join([str(x) for x in msgs])
+
+ # Attempt to colorize the output if supported
+ # Insert default colors and override with **kwargs
+ if _supports_color():
+ text = _stylize_output(text, fg, bg, reset, font)
+
+ log_file: Path = storage['LOG_PATH'] / storage['LOG_FILE']
+
+ with log_file.open('a') as fp:
+ fp.write(f"{orig_string}\n")
+
+ Journald.log(text, level=level)
+
+ from .menu import Menu
+ if not Menu.is_menu_active():
+ # Finally, print the log unless we skipped it based on level.
+ # We use sys.stdout.write()+flush() instead of print() to try and
+ # fix issue #94
+ if level != logging.DEBUG or storage.get('arguments', {}).get('verbose', False):
+ sys.stdout.write(f"{text}\n")
+ sys.stdout.flush()
+
+def _count_wchars(string: str) -> int:
+ "Count the total number of wide characters contained in a string"
+ return sum(unicodedata.east_asian_width(c) in 'FW' for c in string)
+
+def unicode_ljust(string: str, width: int, fillbyte: str = ' ') -> str:
+ """Return a left-justified unicode string of length width.
+ >>> unicode_ljust('Hello', 15, '*')
+ 'Hello**********'
+ >>> unicode_ljust('你好', 15, '*')
+ '你好***********'
+ >>> unicode_ljust('안녕하세요', 15, '*')
+ '안녕하세요*****'
+ >>> unicode_ljust('こんにちは', 15, '*')
+ 'こんにちは*****'
+ """
+ return string.ljust(width - _count_wchars(string), fillbyte)
+
+def unicode_rjust(string: str, width: int, fillbyte: str = ' ') -> str:
+ """Return a right-justified unicode string of length width.
+ >>> unicode_rjust('Hello', 15, '*')
+ '**********Hello'
+ >>> unicode_rjust('你好', 15, '*')
+ '***********你好'
+ >>> unicode_rjust('안녕하세요', 15, '*')
+ '*****안녕하세요'
+ >>> unicode_rjust('こんにちは', 15, '*')
+ '*****こんにちは'
+ """
+ return string.rjust(width - _count_wchars(string), fillbyte)
diff --git a/archinstall/lib/packages/__init__.py b/archinstall/lib/packages/__init__.py
index e69de29b..e2aab577 100644
--- a/archinstall/lib/packages/__init__.py
+++ b/archinstall/lib/packages/__init__.py
@@ -0,0 +1,4 @@
+from .packages import (
+ group_search, package_search, find_package,
+ find_packages, validate_package_list, installed_package
+)
diff --git a/archinstall/lib/packages/packages.py b/archinstall/lib/packages/packages.py
index 0743e83b..e495b03f 100644
--- a/archinstall/lib/packages/packages.py
+++ b/archinstall/lib/packages/packages.py
@@ -7,8 +7,8 @@ from urllib.parse import urlencode
from urllib.request import urlopen
from ..exceptions import PackageError, SysCallError
-from ..models.dataclasses import PackageSearch, PackageSearchResult, LocalPackage
-from ..pacman import run_pacman
+from ..models.gen import PackageSearch, PackageSearchResult, LocalPackage
+from ..pacman import Pacman
BASE_URL_PKG_SEARCH = 'https://archlinux.org/packages/search/json/'
# BASE_URL_PKG_CONTENT = 'https://archlinux.org/packages/search/json/'
@@ -37,7 +37,7 @@ def group_search(name :str) -> List[PackageSearchResult]:
raise err
# Just to be sure some code didn't slip through the exception
- data = response.read().decode('UTF-8')
+ data = response.read().decode('utf-8')
return [PackageSearchResult(**package) for package in json.loads(data)['results']]
@@ -55,8 +55,8 @@ def package_search(package :str) -> PackageSearch:
raise PackageError(f"Could not locate package: [{response.code}] {response}")
data = response.read().decode('UTF-8')
-
- return PackageSearch(**json.loads(data))
+ json_data = json.loads(data)
+ return PackageSearch.from_json(json_data)
def find_package(package :str) -> List[PackageSearchResult]:
@@ -106,11 +106,11 @@ def validate_package_list(packages :list) -> Tuple[list, list]:
def installed_package(package :str) -> LocalPackage:
package_info = {}
try:
- for line in run_pacman(f"-Q --info {package}"):
+ for line in Pacman.run(f"-Q --info {package}"):
if b':' in line:
key, value = line.decode().split(':', 1)
package_info[key.strip().lower().replace(' ', '_')] = value.strip()
except SysCallError:
pass
- return LocalPackage({field.name: package_info.get(field.name) for field in dataclasses.fields(LocalPackage)})
+ return LocalPackage({field.name: package_info.get(field.name) for field in dataclasses.fields(LocalPackage)}) # type: ignore
diff --git a/archinstall/lib/pacman.py b/archinstall/lib/pacman.py
deleted file mode 100644
index 9c427aff..00000000
--- a/archinstall/lib/pacman.py
+++ /dev/null
@@ -1,28 +0,0 @@
-import logging
-import pathlib
-import time
-
-from .general import SysCommand
-from .output import log
-
-
-def run_pacman(args :str, default_cmd :str = 'pacman') -> SysCommand:
- """
- A centralized function to call `pacman` from.
- It also protects us from colliding with other running pacman sessions (if used locally).
- The grace period is set to 10 minutes before exiting hard if another pacman instance is running.
- """
- pacman_db_lock = pathlib.Path('/var/lib/pacman/db.lck')
-
- if pacman_db_lock.exists():
- log(_('Pacman is already running, waiting maximum 10 minutes for it to terminate.'), level=logging.WARNING, fg="red")
-
- started = time.time()
- while pacman_db_lock.exists():
- time.sleep(0.25)
-
- if time.time() - started > (60 * 10):
- log(_('Pre-existing pacman lock never exited. Please clean up any existing pacman sessions before using archinstall.'), level=logging.WARNING, fg="red")
- exit(1)
-
- return SysCommand(f'{default_cmd} {args}')
diff --git a/archinstall/lib/pacman/__init__.py b/archinstall/lib/pacman/__init__.py
new file mode 100644
index 00000000..6478f0cc
--- /dev/null
+++ b/archinstall/lib/pacman/__init__.py
@@ -0,0 +1,88 @@
+from pathlib import Path
+import time
+import re
+from typing import TYPE_CHECKING, Any, List, Callable, Union
+from shutil import copy2
+
+from ..general import SysCommand
+from ..output import warn, error, info
+from .repo import Repo
+from .config import Config
+from ..exceptions import RequirementError
+from ..plugins import plugins
+
+if TYPE_CHECKING:
+ _: Any
+
+
+class Pacman:
+
+ def __init__(self, target: Path, silent: bool = False):
+ self.synced = False
+ self.silent = silent
+ self.target = target
+
+ @staticmethod
+ def run(args :str, default_cmd :str = 'pacman') -> SysCommand:
+ """
+ A centralized function to call `pacman` from.
+ It also protects us from colliding with other running pacman sessions (if used locally).
+ The grace period is set to 10 minutes before exiting hard if another pacman instance is running.
+ """
+ pacman_db_lock = Path('/var/lib/pacman/db.lck')
+
+ if pacman_db_lock.exists():
+ warn(_('Pacman is already running, waiting maximum 10 minutes for it to terminate.'))
+
+ started = time.time()
+ while pacman_db_lock.exists():
+ time.sleep(0.25)
+
+ if time.time() - started > (60 * 10):
+ error(_('Pre-existing pacman lock never exited. Please clean up any existing pacman sessions before using archinstall.'))
+ exit(1)
+
+ return SysCommand(f'{default_cmd} {args}')
+
+ def ask(self, error_message: str, bail_message: str, func: Callable, *args, **kwargs):
+ while True:
+ try:
+ func(*args, **kwargs)
+ break
+ except Exception as err:
+ error(f'{error_message}: {err}')
+ if not self.silent and input('Would you like to re-try this download? (Y/n): ').lower().strip() in 'y':
+ continue
+ raise RequirementError(f'{bail_message}: {err}')
+
+ def sync(self):
+ if self.synced:
+ return
+ self.ask(
+ 'Could not sync a new package database',
+ 'Could not sync mirrors',
+ self.run,
+ '-Syy',
+ default_cmd='/usr/bin/pacman'
+ )
+ self.synced = True
+
+ def strap(self, packages: Union[str, List[str]]):
+ self.sync()
+ if isinstance(packages, str):
+ packages = [packages]
+
+ for plugin in plugins.values():
+ if hasattr(plugin, 'on_pacstrap'):
+ if (result := plugin.on_pacstrap(packages)):
+ packages = result
+
+ info(f'Installing packages: {packages}')
+
+ self.ask(
+ 'Could not strap in packages',
+ 'Pacstrap failed. See /var/log/archinstall/install.log or above message for error details',
+ SysCommand,
+ f'/usr/bin/pacstrap -C /etc/pacman.conf -K {self.target} {" ".join(packages)} --noconfirm',
+ peek_output=True
+ )
diff --git a/archinstall/lib/pacman/config.py b/archinstall/lib/pacman/config.py
new file mode 100644
index 00000000..6686f4a9
--- /dev/null
+++ b/archinstall/lib/pacman/config.py
@@ -0,0 +1,44 @@
+import re
+from pathlib import Path
+from shutil import copy2
+from typing import List
+
+from .repo import Repo
+
+
+class Config:
+ def __init__(self, target: Path):
+ self.path = Path("/etc") / "pacman.conf"
+ self.chroot_path = target / "etc" / "pacman.conf"
+ self.repos: List[Repo] = []
+
+ def enable(self, repo: Repo):
+ self.repos.append(repo)
+
+ def apply(self):
+ if not self.repos:
+ return
+
+ if Repo.Testing in self.repos:
+ if Repo.Multilib in self.repos:
+ repos_pattern = f'({Repo.Multilib.value}|.+-{Repo.Testing.value})'
+ else:
+ repos_pattern = f'(?!{Repo.Multilib.value}).+-{Repo.Testing.value}'
+ else:
+ repos_pattern = Repo.Multilib.value
+
+ pattern = re.compile(rf"^#\s*\[{repos_pattern}\]$")
+
+ lines = iter(self.path.read_text().splitlines(keepends=True))
+ with open(self.path, 'w') as f:
+ for line in lines:
+ if pattern.match(line):
+ # Uncomment this line and the next.
+ f.write(line.lstrip('#'))
+ f.write(next(lines).lstrip('#'))
+ else:
+ f.write(line)
+
+ def persist(self):
+ if self.repos:
+ copy2(self.path, self.chroot_path)
diff --git a/archinstall/lib/pacman/repo.py b/archinstall/lib/pacman/repo.py
new file mode 100644
index 00000000..7a461431
--- /dev/null
+++ b/archinstall/lib/pacman/repo.py
@@ -0,0 +1,5 @@
+from enum import Enum
+
+class Repo(Enum):
+ Multilib = "multilib"
+ Testing = "testing"
diff --git a/archinstall/lib/plugins.py b/archinstall/lib/plugins.py
index 0ff63610..4ccb0666 100644
--- a/archinstall/lib/plugins.py
+++ b/archinstall/lib/plugins.py
@@ -1,105 +1,120 @@
import hashlib
import importlib
-import logging
import os
import sys
-import pathlib
import urllib.parse
import urllib.request
from importlib import metadata
+from pathlib import Path
from typing import Optional, List
-from types import ModuleType
-from .output import log
+from .output import error, info, warn
from .storage import storage
plugins = {}
+
# 1: List archinstall.plugin definitions
# 2: Load the plugin entrypoint
# 3: Initiate the plugin and store it as .name in plugins
for plugin_definition in metadata.entry_points().select(group='archinstall.plugin'):
plugin_entrypoint = plugin_definition.load()
+
try:
plugins[plugin_definition.name] = plugin_entrypoint()
except Exception as err:
- log(err, level=logging.ERROR)
- log(f"The above error was detected when loading the plugin: {plugin_definition}", fg="red", level=logging.ERROR)
+ error(
+ f'Error: {err}',
+ f"The above error was detected when loading the plugin: {plugin_definition}"
+ )
+
+def _localize_path(path: Path) -> Path:
+ """
+ Support structures for load_plugin()
+ """
+ url = urllib.parse.urlparse(str(path))
-# The following functions and core are support structures for load_plugin()
-def localize_path(profile_path :str) -> str:
- if (url := urllib.parse.urlparse(profile_path)).scheme and url.scheme in ('https', 'http'):
- converted_path = f"/tmp/{os.path.basename(profile_path).replace('.py', '')}_{hashlib.md5(os.urandom(12)).hexdigest()}.py"
+ if url.scheme and url.scheme in ('https', 'http'):
+ converted_path = Path(f'/tmp/{path.stem}_{hashlib.md5(os.urandom(12)).hexdigest()}.py')
with open(converted_path, "w") as temp_file:
temp_file.write(urllib.request.urlopen(url.geturl()).read().decode('utf-8'))
return converted_path
else:
- return profile_path
+ return path
-def import_via_path(path :str, namespace :Optional[str] = None) -> ModuleType:
+def _import_via_path(path: Path, namespace: Optional[str] = None) -> Optional[str]:
if not namespace:
namespace = os.path.basename(path)
if namespace == '__init__.py':
- path = pathlib.PurePath(path)
namespace = path.parent.name
try:
spec = importlib.util.spec_from_file_location(namespace, path)
- imported = importlib.util.module_from_spec(spec)
- sys.modules[namespace] = imported
- spec.loader.exec_module(sys.modules[namespace])
+ if spec and spec.loader:
+ imported = importlib.util.module_from_spec(spec)
+ sys.modules[namespace] = imported
+ spec.loader.exec_module(sys.modules[namespace])
return namespace
except Exception as err:
- log(err, level=logging.ERROR)
- log(f"The above error was detected when loading the plugin: {path}", fg="red", level=logging.ERROR)
+ error(
+ f'Error: {err}',
+ f"The above error was detected when loading the plugin: {path}"
+ )
try:
- del(sys.modules[namespace]) # noqa: E275
- except:
+ del sys.modules[namespace]
+ except Exception:
pass
-def find_nth(haystack :List[str], needle :str, n :int) -> int:
- start = haystack.find(needle)
- while start >= 0 and n > 1:
- start = haystack.find(needle, start + len(needle))
- n -= 1
- return start
+ return namespace
+
+
+def _find_nth(haystack: List[str], needle: str, n: int) -> Optional[int]:
+ indices = [idx for idx, elem in enumerate(haystack) if elem == needle]
+ if n <= len(indices):
+ return indices[n - 1]
+ return None
+
-def load_plugin(path :str) -> ModuleType:
- parsed_url = urllib.parse.urlparse(path)
- log(f"Loading plugin {parsed_url}.", fg="gray", level=logging.INFO)
+def load_plugin(path: Path):
+ namespace: Optional[str] = None
+ parsed_url = urllib.parse.urlparse(str(path))
+ info(f"Loading plugin from url {parsed_url}")
# The Profile was not a direct match on a remote URL
if not parsed_url.scheme:
# Path was not found in any known examples, check if it's an absolute path
if os.path.isfile(path):
- namespace = import_via_path(path)
+ namespace = _import_via_path(path)
elif parsed_url.scheme in ('https', 'http'):
- namespace = import_via_path(localize_path(path))
+ localized = _localize_path(path)
+ namespace = _import_via_path(localized)
- if namespace in sys.modules:
+ if namespace and namespace in sys.modules:
# Version dependency via __archinstall__version__ variable (if present) in the plugin
# Any errors in version inconsistency will be handled through normal error handling if not defined.
if hasattr(sys.modules[namespace], '__archinstall__version__'):
- archinstall_major_and_minor_version = float(storage['__version__'][:find_nth(storage['__version__'], '.', 2)])
+ archinstall_major_and_minor_version = float(storage['__version__'][:_find_nth(storage['__version__'], '.', 2)])
if sys.modules[namespace].__archinstall__version__ < archinstall_major_and_minor_version:
- log(f"Plugin {sys.modules[namespace]} does not support the current Archinstall version.", fg="red", level=logging.ERROR)
+ error(f"Plugin {sys.modules[namespace]} does not support the current Archinstall version.")
# Locate the plugin entry-point called Plugin()
# This in accordance with the entry_points() from setup.cfg above
if hasattr(sys.modules[namespace], 'Plugin'):
try:
plugins[namespace] = sys.modules[namespace].Plugin()
- log(f"Plugin {plugins[namespace]} has been loaded.", fg="gray", level=logging.INFO)
+ info(f"Plugin {plugins[namespace]} has been loaded.")
except Exception as err:
- log(err, level=logging.ERROR)
- log(f"The above error was detected when initiating the plugin: {path}", fg="red", level=logging.ERROR)
+ error(
+ f'Error: {err}',
+ f"The above error was detected when initiating the plugin: {path}"
+ )
else:
- log(f"Plugin '{path}' is missing a valid entry-point or is corrupt.", fg="yellow", level=logging.WARNING)
+ warn(f"Plugin '{path}' is missing a valid entry-point or is corrupt.")
diff --git a/archinstall/lib/profile/__init__.py b/archinstall/lib/profile/__init__.py
new file mode 100644
index 00000000..6e74b0d8
--- /dev/null
+++ b/archinstall/lib/profile/__init__.py
@@ -0,0 +1,3 @@
+from .profile_menu import ProfileMenu, select_greeter, select_profile
+from .profiles_handler import profile_handler
+from .profile_model import ProfileConfiguration
diff --git a/archinstall/lib/profile/profile_menu.py b/archinstall/lib/profile/profile_menu.py
new file mode 100644
index 00000000..aba75a88
--- /dev/null
+++ b/archinstall/lib/profile/profile_menu.py
@@ -0,0 +1,218 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any, Optional, Dict
+
+from archinstall.default_profiles.profile import Profile, GreeterType
+from .profile_model import ProfileConfiguration
+from ..menu import Menu, MenuSelectionType, AbstractSubMenu, Selector
+from ..interactions.system_conf import select_driver
+from ..hardware import GfxDriver
+
+if TYPE_CHECKING:
+ _: Any
+
+
+class ProfileMenu(AbstractSubMenu):
+ def __init__(
+ self,
+ data_store: Dict[str, Any],
+ preset: Optional[ProfileConfiguration] = None
+ ):
+ if preset:
+ self._preset = preset
+ else:
+ self._preset = ProfileConfiguration()
+
+ super().__init__(data_store=data_store)
+
+ def setup_selection_menu_options(self):
+ self._menu_options['profile'] = Selector(
+ _('Type'),
+ lambda x: self._select_profile(x),
+ display_func=lambda x: x.name if x else None,
+ preview_func=self._preview_profile,
+ default=self._preset.profile,
+ enabled=True
+ )
+
+ self._menu_options['gfx_driver'] = Selector(
+ _('Graphics driver'),
+ lambda preset: self._select_gfx_driver(preset),
+ display_func=lambda x: x.value if x else None,
+ dependencies=['profile'],
+ preview_func=self._preview_gfx,
+ default=self._preset.gfx_driver if self._preset.profile and self._preset.profile.is_graphic_driver_supported() else None,
+ enabled=self._preset.profile.is_graphic_driver_supported() if self._preset.profile else False
+ )
+
+ self._menu_options['greeter'] = Selector(
+ _('Greeter'),
+ lambda preset: select_greeter(self._menu_options['profile'].current_selection, preset),
+ display_func=lambda x: x.value if x else None,
+ dependencies=['profile'],
+ default=self._preset.greeter if self._preset.profile and self._preset.profile.is_greeter_supported() else None,
+ enabled=self._preset.profile.is_greeter_supported() if self._preset.profile else False
+ )
+
+ def run(self, allow_reset: bool = True) -> Optional[ProfileConfiguration]:
+ super().run(allow_reset=allow_reset)
+
+ if self._data_store.get('profile', None):
+ return ProfileConfiguration(
+ self._menu_options['profile'].current_selection,
+ self._menu_options['gfx_driver'].current_selection,
+ self._menu_options['greeter'].current_selection
+ )
+
+ return None
+
+ def _select_profile(self, preset: Optional[Profile]) -> Optional[Profile]:
+ profile = select_profile(preset)
+
+ if profile is not None:
+ if not profile.is_graphic_driver_supported():
+ self._menu_options['gfx_driver'].set_enabled(False)
+ self._menu_options['gfx_driver'].set_current_selection(None)
+ else:
+ self._menu_options['gfx_driver'].set_enabled(True)
+ self._menu_options['gfx_driver'].set_current_selection(GfxDriver.AllOpenSource)
+
+ if not profile.is_greeter_supported():
+ self._menu_options['greeter'].set_enabled(False)
+ self._menu_options['greeter'].set_current_selection(None)
+ else:
+ self._menu_options['greeter'].set_enabled(True)
+ self._menu_options['greeter'].set_current_selection(profile.default_greeter_type)
+ else:
+ self._menu_options['gfx_driver'].set_current_selection(None)
+ self._menu_options['greeter'].set_current_selection(None)
+
+ return profile
+
+ def _select_gfx_driver(self, preset: Optional[GfxDriver] = None) -> Optional[GfxDriver]:
+ driver = preset
+ profile: Optional[Profile] = self._menu_options['profile'].current_selection
+
+ if profile:
+ if profile.is_graphic_driver_supported():
+ driver = select_driver(current_value=preset)
+
+ if driver and 'Sway' in profile.current_selection_names():
+ if driver.is_nvidia():
+ prompt = str(_('The proprietary Nvidia driver is not supported by Sway. It is likely that you will run into issues, are you okay with that?'))
+ choice = Menu(prompt, Menu.yes_no(), default_option=Menu.no(), skip=False).run()
+
+ if choice.value == Menu.no():
+ return None
+
+ return driver
+
+ def _preview_gfx(self) -> Optional[str]:
+ driver: Optional[GfxDriver] = self._menu_options['gfx_driver'].current_selection
+
+ if driver:
+ return driver.packages_text()
+
+ return None
+
+ def _preview_profile(self) -> Optional[str]:
+ profile: Optional[Profile] = self._menu_options['profile'].current_selection
+ text = ''
+
+ if profile:
+ if (sub_profiles := profile.current_selection) is not None:
+ text += str(_('Selected profiles: '))
+ text += ', '.join([p.name for p in sub_profiles]) + '\n'
+
+ if packages := profile.packages_text(include_sub_packages=True):
+ text += f'{packages}'
+
+ if text:
+ return text
+
+ return None
+
+
+def select_greeter(
+ profile: Optional[Profile] = None,
+ preset: Optional[GreeterType] = None
+) -> Optional[GreeterType]:
+ if not profile or profile.is_greeter_supported():
+ title = str(_('Please chose which greeter to install'))
+ greeter_options = [greeter.value for greeter in GreeterType]
+
+ default: Optional[GreeterType] = None
+
+ if preset is not None:
+ default = preset
+ elif profile is not None:
+ default_greeter = profile.default_greeter_type
+ default = default_greeter if default_greeter else None
+
+ choice = Menu(
+ title,
+ greeter_options,
+ skip=True,
+ default_option=default.value if default else None
+ ).run()
+
+ match choice.type_:
+ case MenuSelectionType.Skip:
+ return default
+
+ return GreeterType(choice.single_value)
+
+ return None
+
+
+def select_profile(
+ current_profile: Optional[Profile] = None,
+ title: Optional[str] = None,
+ allow_reset: bool = True,
+ multi: bool = False
+) -> Optional[Profile]:
+ from archinstall.lib.profile.profiles_handler import profile_handler
+ top_level_profiles = profile_handler.get_top_level_profiles()
+
+ display_title = title
+ if not display_title:
+ display_title = str(_('This is a list of pre-programmed default_profiles'))
+
+ choice = profile_handler.select_profile(
+ top_level_profiles,
+ current_profile=current_profile,
+ title=display_title,
+ allow_reset=allow_reset,
+ multi=multi
+ )
+
+ match choice.type_:
+ case MenuSelectionType.Selection:
+ profile_selection: Profile = choice.single_value
+ select_result = profile_selection.do_on_select()
+
+ if not select_result:
+ return select_profile(
+ current_profile=current_profile,
+ title=title,
+ allow_reset=allow_reset,
+ multi=multi
+ )
+
+ # we're going to reset the currently selected profile(s) to avoid
+ # any stale data laying around
+ match select_result:
+ case select_result.NewSelection:
+ profile_handler.reset_top_level_profiles(exclude=[profile_selection])
+ current_profile = profile_selection
+ case select_result.ResetCurrent:
+ profile_handler.reset_top_level_profiles()
+ current_profile = None
+ case select_result.SameSelection:
+ pass
+
+ return current_profile
+ case MenuSelectionType.Reset:
+ return None
+ case MenuSelectionType.Skip:
+ return current_profile
diff --git a/archinstall/lib/profile/profile_model.py b/archinstall/lib/profile/profile_model.py
new file mode 100644
index 00000000..8c955733
--- /dev/null
+++ b/archinstall/lib/profile/profile_model.py
@@ -0,0 +1,39 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import TYPE_CHECKING, Any, Optional, Dict
+
+from ..hardware import GfxDriver
+from archinstall.default_profiles.profile import Profile, GreeterType
+
+if TYPE_CHECKING:
+ _: Any
+
+
+@dataclass
+class ProfileConfiguration:
+ profile: Optional[Profile] = None
+ gfx_driver: Optional[GfxDriver] = None
+ greeter: Optional[GreeterType] = None
+
+ def json(self) -> Dict[str, Any]:
+ from .profiles_handler import profile_handler
+ return {
+ 'profile': profile_handler.to_json(self.profile),
+ 'gfx_driver': self.gfx_driver.value if self.gfx_driver else None,
+ 'greeter': self.greeter.value if self.greeter else None
+ }
+
+ @classmethod
+ def parse_arg(cls, arg: Dict[str, Any]) -> 'ProfileConfiguration':
+ from .profiles_handler import profile_handler
+
+ profile = profile_handler.parse_profile_config(arg['profile'])
+ greeter = arg.get('greeter', None)
+ gfx_driver = arg.get('gfx_driver', None)
+
+ return ProfileConfiguration(
+ profile,
+ GfxDriver(gfx_driver) if gfx_driver else None,
+ GreeterType(greeter) if greeter else None
+ )
diff --git a/archinstall/lib/profile/profiles_handler.py b/archinstall/lib/profile/profiles_handler.py
new file mode 100644
index 00000000..b9acb4fe
--- /dev/null
+++ b/archinstall/lib/profile/profiles_handler.py
@@ -0,0 +1,413 @@
+from __future__ import annotations
+
+import importlib.util
+import sys
+import inspect
+from collections import Counter
+from functools import cached_property
+from pathlib import Path
+from tempfile import NamedTemporaryFile
+from types import ModuleType
+from typing import List, TYPE_CHECKING, Any, Optional, Dict, Union
+
+from archinstall.default_profiles.profile import Profile, TProfile, GreeterType
+from .profile_model import ProfileConfiguration
+from ..hardware import GfxDriver
+from ..menu import MenuSelectionType, Menu, MenuSelection
+from ..networking import list_interfaces, fetch_data_from_url
+from ..output import error, debug, info
+from ..storage import storage
+
+if TYPE_CHECKING:
+ from ..installer import Installer
+ _: Any
+
+
+class ProfileHandler:
+ def __init__(self):
+ self._profiles_path: Path = storage['PROFILE']
+ self._profiles = None
+
+ # special variable to keep track of a profile url configuration
+ # it is merely used to be able to export the path again when a user
+ # wants to save the configuration
+ self._url_path = None
+
+ def to_json(self, profile: Optional[Profile]) -> Dict[str, Any]:
+ """
+ Serialize the selected profile setting to JSON
+ """
+ data: Dict[str, Any] = {}
+
+ if profile is not None:
+ data = {
+ 'main': profile.name,
+ 'details': [profile.name for profile in profile.current_selection],
+ 'custom_settings': {profile.name: profile.custom_settings for profile in profile.current_selection}
+ }
+
+ if self._url_path is not None:
+ data['path'] = self._url_path
+
+ return data
+
+ def parse_profile_config(self, profile_config: Dict[str, Any]) -> Optional[Profile]:
+ """
+ Deserialize JSON configuration for profile
+ """
+ profile: Optional[Profile] = None
+
+ # the order of these is important, we want to
+ # load all the default_profiles from url and custom
+ # so that we can then apply whatever was specified
+ # in the main/detail sections
+ if url_path := profile_config.get('path', None):
+ self._url_path = url_path
+ local_path = Path(url_path)
+
+ if local_path.is_file():
+ profiles = self._process_profile_file(local_path)
+ self.remove_custom_profiles(profiles)
+ self.add_custom_profiles(profiles)
+ else:
+ self._import_profile_from_url(url_path)
+
+ # if custom := profile_config.get('custom', None):
+ # from archinstall.default_profiles.custom import CustomTypeProfile
+ # custom_types = []
+ #
+ # for entry in custom:
+ # custom_types.append(
+ # CustomTypeProfile(
+ # entry['name'],
+ # entry['enabled'],
+ # entry.get('packages', []),
+ # entry.get('services', [])
+ # )
+ # )
+ #
+ # self.remove_custom_profiles(custom_types)
+ # self.add_custom_profiles(custom_types)
+ #
+ # # this doesn't mean it's actual going to be set as a selection
+ # # but we are simply populating the custom profile with all
+ # # possible custom definitions
+ # if custom_profile := self.get_profile_by_name('Custom'):
+ # custom_profile.set_current_selection(custom_types)
+
+ if main := profile_config.get('main', None):
+ profile = self.get_profile_by_name(main) if main else None
+
+ if not profile:
+ return None
+
+ valid_sub_profiles: List[Profile] = []
+ invalid_sub_profiles: List[str] = []
+ details: List[str] = profile_config.get('details', [])
+
+ if details:
+ for detail in filter(None, details):
+ # [2024-04-19] TODO: Backwards compatibility after naming change: https://github.com/archlinux/archinstall/pull/2421
+ # 'Kde' is deprecated, remove this block in a future version
+ if detail == 'Kde':
+ detail = 'KDE Plasma'
+
+ if sub_profile := self.get_profile_by_name(detail):
+ valid_sub_profiles.append(sub_profile)
+ else:
+ invalid_sub_profiles.append(detail)
+
+ if invalid_sub_profiles:
+ info('No profile definition found: {}'.format(', '.join(invalid_sub_profiles)))
+
+ custom_settings = profile_config.get('custom_settings', {})
+ profile.set_custom_settings(custom_settings)
+ profile.set_current_selection(valid_sub_profiles)
+
+ return profile
+
+ @property
+ def profiles(self) -> List[Profile]:
+ """
+ List of all available default_profiles
+ """
+ self._profiles = self._profiles or self._find_available_profiles()
+ return self._profiles
+
+ @cached_property
+ def _local_mac_addresses(self) -> List[str]:
+ return list(list_interfaces())
+
+ def add_custom_profiles(self, profiles: Union[TProfile, List[TProfile]]):
+ if not isinstance(profiles, list):
+ profiles = [profiles]
+
+ for profile in profiles:
+ self.profiles.append(profile)
+
+ self._verify_unique_profile_names(self.profiles)
+
+ def remove_custom_profiles(self, profiles: Union[TProfile, List[TProfile]]):
+ if not isinstance(profiles, list):
+ profiles = [profiles]
+
+ remove_names = [p.name for p in profiles]
+ self._profiles = [p for p in self.profiles if p.name not in remove_names]
+
+ def get_profile_by_name(self, name: str) -> Optional[Profile]:
+ return next(filter(lambda x: x.name == name, self.profiles), None) # type: ignore
+
+ def get_top_level_profiles(self) -> List[Profile]:
+ return list(filter(lambda x: x.is_top_level_profile(), self.profiles))
+
+ def get_server_profiles(self) -> List[Profile]:
+ return list(filter(lambda x: x.is_server_type_profile(), self.profiles))
+
+ def get_desktop_profiles(self) -> List[Profile]:
+ return list(filter(lambda x: x.is_desktop_type_profile(), self.profiles))
+
+ def get_custom_profiles(self) -> List[Profile]:
+ return list(filter(lambda x: x.is_custom_type_profile(), self.profiles))
+
+ def get_mac_addr_profiles(self) -> List[Profile]:
+ tailored = list(filter(lambda x: x.is_tailored(), self.profiles))
+ match_mac_addr_profiles = list(filter(lambda x: x.name in self._local_mac_addresses, tailored))
+ return match_mac_addr_profiles
+
+ def install_greeter(self, install_session: 'Installer', greeter: GreeterType):
+ packages = []
+ service = None
+
+ match greeter:
+ case GreeterType.LightdmSlick:
+ packages = ['lightdm', 'lightdm-slick-greeter']
+ service = ['lightdm']
+ case GreeterType.Lightdm:
+ packages = ['lightdm', 'lightdm-gtk-greeter']
+ service = ['lightdm']
+ case GreeterType.Sddm:
+ packages = ['sddm']
+ service = ['sddm']
+ case GreeterType.Gdm:
+ packages = ['gdm']
+ service = ['gdm']
+ case GreeterType.Ly:
+ packages = ['ly']
+ service = ['ly']
+
+ if packages:
+ install_session.add_additional_packages(packages)
+ if service:
+ install_session.enable_service(service)
+
+ # slick-greeter requires a config change
+ if greeter == GreeterType.LightdmSlick:
+ path = install_session.target.joinpath('etc/lightdm/lightdm.conf')
+ with open(path, 'r') as file:
+ filedata = file.read()
+
+ filedata = filedata.replace('#greeter-session=example-gtk-gnome', 'greeter-session=lightdm-slick-greeter')
+
+ with open(path, 'w') as file:
+ file.write(filedata)
+
+ def install_gfx_driver(self, install_session: 'Installer', driver: GfxDriver):
+ debug(f'Installing GFX driver: {driver.value}')
+
+ if driver in [GfxDriver.NvidiaOpenKernel, GfxDriver.NvidiaProprietary]:
+ headers = [f'{kernel}-headers' for kernel in install_session.kernels]
+ # Fixes https://github.com/archlinux/archinstall/issues/585
+ install_session.add_additional_packages(headers)
+ elif driver in [GfxDriver.AllOpenSource, GfxDriver.AmdOpenSource]:
+ # The order of these two are important if amdgpu is installed #808
+ install_session.remove_mod('amdgpu')
+ install_session.remove_mod('radeon')
+
+ install_session.append_mod('amdgpu')
+ install_session.append_mod('radeon')
+
+ driver_pkgs = driver.gfx_packages()
+ pkg_names = [p.value for p in driver_pkgs]
+ install_session.add_additional_packages(pkg_names)
+
+ def install_profile_config(self, install_session: 'Installer', profile_config: ProfileConfiguration):
+ profile = profile_config.profile
+
+ if not profile:
+ return
+
+ profile.install(install_session)
+
+ if profile_config.gfx_driver and (profile.is_xorg_type_profile() or profile.is_desktop_profile()):
+ self.install_gfx_driver(install_session, profile_config.gfx_driver)
+
+ if profile_config.greeter:
+ self.install_greeter(install_session, profile_config.greeter)
+
+ def _import_profile_from_url(self, url: str):
+ """
+ Import default_profiles from a url path
+ """
+ try:
+ data = fetch_data_from_url(url)
+ b_data = bytes(data, 'utf-8')
+
+ with NamedTemporaryFile(delete=False, suffix='.py') as fp:
+ fp.write(b_data)
+ filepath = Path(fp.name)
+
+ profiles = self._process_profile_file(filepath)
+ self.remove_custom_profiles(profiles)
+ self.add_custom_profiles(profiles)
+ except ValueError:
+ err = str(_('Unable to fetch profile from specified url: {}')).format(url)
+ error(err)
+
+ def _load_profile_class(self, module: ModuleType) -> List[Profile]:
+ """
+ Load all default_profiles defined in a module
+ """
+ profiles = []
+ for k, v in module.__dict__.items():
+ if isinstance(v, type) and v.__module__ == module.__name__:
+ bases = inspect.getmro(v)
+
+ if Profile in bases:
+ try:
+ cls_ = v()
+ if isinstance(cls_, Profile):
+ profiles.append(cls_)
+ except Exception:
+ debug(f'Cannot import {module}, it does not appear to be a Profile class')
+
+ return profiles
+
+ def _verify_unique_profile_names(self, profiles: List[Profile]):
+ """
+ All profile names have to be unique, this function will verify
+ that the provided list contains only default_profiles with unique names
+ """
+ counter = Counter([p.name for p in profiles])
+ duplicates = list(filter(lambda x: x[1] != 1, counter.items()))
+
+ if len(duplicates) > 0:
+ err = str(_('Profiles must have unique name, but profile definitions with duplicate name found: {}')).format(duplicates[0][0])
+ error(err)
+ sys.exit(1)
+
+ def _is_legacy(self, file: Path) -> bool:
+ """
+ Check if the provided profile file contains a
+ legacy profile definition
+ """
+ with open(file, 'r') as fp:
+ for line in fp.readlines():
+ if '__packages__' in line:
+ return True
+ return False
+
+ def _process_profile_file(self, file: Path) -> List[Profile]:
+ """
+ Process a file for profile definitions
+ """
+ if self._is_legacy(file):
+ info(f'Cannot import {file} because it is no longer supported, please use the new profile format')
+ return []
+
+ if not file.is_file():
+ info(f'Cannot find profile file {file}')
+ return []
+
+ name = file.name.removesuffix(file.suffix)
+ debug(f'Importing profile: {file}')
+
+ try:
+ if spec := importlib.util.spec_from_file_location(name, file):
+ imported = importlib.util.module_from_spec(spec)
+ if spec.loader is not None:
+ spec.loader.exec_module(imported)
+ return self._load_profile_class(imported)
+ except Exception as e:
+ error(f'Unable to parse file {file}: {e}')
+
+ return []
+
+ def _find_available_profiles(self) -> List[Profile]:
+ """
+ Search the profile path for profile definitions
+ """
+ profiles = []
+ for file in self._profiles_path.glob('**/*.py'):
+ # ignore the abstract default_profiles class
+ if 'profile.py' in file.name:
+ continue
+ profiles += self._process_profile_file(file)
+
+ self._verify_unique_profile_names(profiles)
+ return profiles
+
+ def reset_top_level_profiles(self, exclude: List[Profile] = []):
+ """
+ Reset all top level profile configurations, this is usually necessary
+ when a new top level profile is selected
+ """
+ excluded_profiles = [p.name for p in exclude]
+ for profile in self.get_top_level_profiles():
+ if profile.name not in excluded_profiles:
+ profile.reset()
+
+ def select_profile(
+ self,
+ selectable_profiles: List[Profile],
+ current_profile: Optional[Union[TProfile, List[TProfile]]] = None,
+ title: str = '',
+ allow_reset: bool = True,
+ multi: bool = False,
+ ) -> MenuSelection:
+ """
+ Helper function to perform a profile selection
+ """
+ options = {p.name: p for p in selectable_profiles}
+ options = dict((k, v) for k, v in sorted(options.items(), key=lambda x: x[0].upper()))
+
+ warning = str(_('Are you sure you want to reset this setting?'))
+
+ preset_value: Optional[Union[str, List[str]]] = None
+ if current_profile is not None:
+ if isinstance(current_profile, list):
+ preset_value = [p.name for p in current_profile]
+ else:
+ preset_value = current_profile.name
+
+ choice = Menu(
+ title=title,
+ preset_values=preset_value,
+ p_options=options,
+ allow_reset=allow_reset,
+ allow_reset_warning_msg=warning,
+ multi=multi,
+ sort=False,
+ preview_command=self.preview_text,
+ preview_size=0.5
+ ).run()
+
+ if choice.type_ == MenuSelectionType.Selection:
+ value = choice.value
+ if multi:
+ # this is quite dirty and should eb switched to a
+ # dedicated return type instead
+ choice.value = [options[val] for val in value] # type: ignore
+ else:
+ choice.value = options[value] # type: ignore
+
+ return choice
+
+ def preview_text(self, selection: str) -> Optional[str]:
+ """
+ Callback for preview display on profile selection
+ """
+ profile = self.get_profile_by_name(selection)
+ return profile.preview_text() if profile is not None else None
+
+
+profile_handler = ProfileHandler()
diff --git a/archinstall/lib/profiles.py b/archinstall/lib/profiles.py
deleted file mode 100644
index a4fbe490..00000000
--- a/archinstall/lib/profiles.py
+++ /dev/null
@@ -1,340 +0,0 @@
-from __future__ import annotations
-import hashlib
-import importlib.util
-import json
-import os
-import re
-import ssl
-import sys
-import urllib.error
-import urllib.parse
-import urllib.request
-from typing import Optional, Dict, Union, TYPE_CHECKING, Any
-from types import ModuleType
-# https://stackoverflow.com/a/39757388/929999
-if TYPE_CHECKING:
- from .installer import Installer
- _: Any
-
-from .general import multisplit
-from .networking import list_interfaces
-from .storage import storage
-from .exceptions import ProfileNotFound
-
-
-def grab_url_data(path :str) -> str:
- safe_path = path[: path.find(':') + 1] + ''.join([item if item in ('/', '?', '=', '&') else urllib.parse.quote(item) for item in multisplit(path[path.find(':') + 1:], ('/', '?', '=', '&'))])
- ssl_context = ssl.create_default_context()
- ssl_context.check_hostname = False
- ssl_context.verify_mode = ssl.CERT_NONE
- response = urllib.request.urlopen(safe_path, context=ssl_context)
- return response.read() # bytes?
-
-
-def is_desktop_profile(profile :str) -> bool:
- if str(profile) == 'Profile(desktop)':
- return True
-
- desktop_profile = Profile(None, "desktop")
- with open(desktop_profile.path, 'r') as source:
- source_data = source.read()
-
- if '__name__' in source_data and '__supported__' in source_data:
- with desktop_profile.load_instructions(namespace=f"{desktop_profile.namespace}.py") as imported:
- if hasattr(imported, '__supported__'):
- desktop_profiles = imported.__supported__
- return str(profile) in [f"Profile({s})" for s in desktop_profiles]
-
- return False
-
-
-def list_profiles(
- filter_irrelevant_macs :bool = True,
- subpath :str = '',
- filter_top_level_profiles :bool = False
-) -> Dict[str, Dict[str, Union[str, bool]]]:
- # TODO: Grab from github page as well, not just local static files
-
- if filter_irrelevant_macs:
- local_macs = list_interfaces()
-
- cache = {}
- # Grab all local profiles found in PROFILE_PATH
- for PATH_ITEM in storage['PROFILE_PATH']:
- for root, folders, files in os.walk(os.path.abspath(os.path.expanduser(PATH_ITEM + subpath))):
- for file in files:
- if file == '__init__.py':
- continue
- if os.path.splitext(file)[1] == '.py':
- tailored = False
- if len(mac := re.findall('(([a-zA-z0-9]{2}[-:]){5}([a-zA-z0-9]{2}))', file)):
- if filter_irrelevant_macs and mac[0][0].lower() not in local_macs:
- continue
- tailored = True
-
- description = ''
- with open(os.path.join(root, file), 'r') as fh:
- first_line = fh.readline()
- if len(first_line) and first_line[0] == '#':
- description = first_line[1:].strip()
-
- cache[file[:-3]] = {'path': os.path.join(root, file), 'description': description, 'tailored': tailored}
- break
-
- # Grab profiles from upstream URL
- if storage['PROFILE_DB']:
- profiles_url = os.path.join(storage["UPSTREAM_URL"] + subpath, storage['PROFILE_DB'])
- try:
- profile_list = json.loads(grab_url_data(profiles_url))
- except urllib.error.HTTPError as err:
- print(_('Error: Listing profiles on URL "{}" resulted in:').format(profiles_url), err)
- return cache
- except json.decoder.JSONDecodeError as err:
- print(_('Error: Could not decode "{}" result as JSON:').format(profiles_url), err)
- return cache
-
- for profile in profile_list:
- if os.path.splitext(profile)[1] == '.py':
- tailored = False
- if len(mac := re.findall('(([a-zA-z0-9]{2}[-:]){5}([a-zA-z0-9]{2}))', profile)):
- if filter_irrelevant_macs and mac[0][0].lower() not in local_macs:
- continue
- tailored = True
-
- cache[profile[:-3]] = {'path': os.path.join(storage["UPSTREAM_URL"] + subpath, profile), 'description': profile_list[profile], 'tailored': tailored}
-
- if filter_top_level_profiles:
- for profile in list(cache.keys()):
- if Profile(None, profile).is_top_level_profile() is False:
- del cache[profile]
-
- return cache
-
-
-class Script:
- def __init__(self, profile :str, installer :Optional[Installer] = None):
- """
- :param profile: A string representing either a boundled profile, a local python file
- or a remote path (URL) to a python script-profile. Three examples:
- * profile: https://archlinux.org/some_profile.py
- * profile: desktop
- * profile: /path/to/profile.py
- """
- self.profile = profile
- self.installer = installer # TODO: Appears not to be used anymore?
- self.converted_path = None
- self.spec = None
- self.examples = {}
- self.namespace = os.path.splitext(os.path.basename(self.path))[0]
- self.original_namespace = self.namespace
-
- def __enter__(self, *args :str, **kwargs :str) -> ModuleType:
- self.execute()
- return sys.modules[self.namespace]
-
- def __exit__(self, *args :str, **kwargs :str) -> None:
- # TODO: https://stackoverflow.com/questions/28157929/how-to-safely-handle-an-exception-inside-a-context-manager
- if len(args) >= 2 and args[1]:
- raise args[1]
-
- if self.original_namespace:
- self.namespace = self.original_namespace
-
- def localize_path(self, profile_path :str) -> str:
- if (url := urllib.parse.urlparse(profile_path)).scheme and url.scheme in ('https', 'http'):
- if not self.converted_path:
- self.converted_path = f"/tmp/{os.path.basename(self.profile).replace('.py', '')}_{hashlib.md5(os.urandom(12)).hexdigest()}.py"
-
- with open(self.converted_path, "w") as temp_file:
- temp_file.write(urllib.request.urlopen(url.geturl()).read().decode('utf-8'))
-
- return self.converted_path
- else:
- return profile_path
-
- @property
- def path(self) -> str:
- parsed_url = urllib.parse.urlparse(self.profile)
-
- # The Profile was not a direct match on a remote URL
- if not parsed_url.scheme:
- # Try to locate all local or known URL's
- if not self.examples:
- self.examples = list_profiles()
-
- if f"{self.profile}" in self.examples:
- return self.localize_path(self.examples[self.profile]['path'])
- # TODO: Redundant, the below block shouldn't be needed as profiles are stripped of their .py, but just in case for now:
- elif f"{self.profile}.py" in self.examples:
- return self.localize_path(self.examples[f"{self.profile}.py"]['path'])
-
- # Path was not found in any known examples, check if it's an absolute path
- if os.path.isfile(self.profile):
- return self.profile
-
- raise ProfileNotFound(f"File {self.profile} does not exist in {storage['PROFILE_PATH']}")
- elif parsed_url.scheme in ('https', 'http'):
- return self.localize_path(self.profile)
- else:
- raise ProfileNotFound(f"Cannot handle scheme {parsed_url.scheme}")
-
- def load_instructions(self, namespace :Optional[str] = None) -> 'Script':
- if namespace:
- self.namespace = namespace
-
- self.spec = importlib.util.spec_from_file_location(self.namespace, self.path)
- imported = importlib.util.module_from_spec(self.spec)
- sys.modules[self.namespace] = imported
-
- return self
-
- def execute(self) -> ModuleType:
- if self.namespace not in sys.modules or self.spec is None:
- self.load_instructions()
-
- self.spec.loader.exec_module(sys.modules[self.namespace])
-
- return sys.modules[self.namespace]
-
-
-class Profile(Script):
- def __init__(self, installer :Optional[Installer], path :str):
- super(Profile, self).__init__(path, installer)
-
- def __dump__(self, *args :str, **kwargs :str) -> Dict[str, str]:
- return {'path': self.path}
-
- def __repr__(self, *args :str, **kwargs :str) -> str:
- return f'Profile({os.path.basename(self.profile)})'
-
- @property
- def name(self) -> str:
- return os.path.basename(self.profile)
-
- @property
- def is_desktop_profile(self) -> bool:
- return is_desktop_profile(repr(self))
-
- def install(self) -> ModuleType:
- # Before installing, revert any temporary changes to the namespace.
- # This ensures that the namespace during installation is the original initiation namespace.
- # (For instance awesome instead of aweosme.py or app-awesome.py)
- self.namespace = self.original_namespace
- return self.execute()
-
- def has_prep_function(self) -> bool:
- with open(self.path, 'r') as source:
- source_data = source.read()
-
- # Some crude safety checks, make sure the imported profile has
- # a __name__ check and if so, check if it's got a _prep_function()
- # we can call to ask for more user input.
- #
- # If the requirements are met, import with .py in the namespace to not
- # trigger a traditional:
- # if __name__ == 'moduleName'
- if '__name__' in source_data and '_prep_function' in source_data:
- with self.load_instructions(namespace=f"{self.namespace}.py") as imported:
- if hasattr(imported, '_prep_function'):
- return True
- return False
-
- def has_post_install(self) -> bool:
- with open(self.path, 'r') as source:
- source_data = source.read()
-
- # Some crude safety checks, make sure the imported profile has
- # a __name__ check and if so, check if it's got a _prep_function()
- # we can call to ask for more user input.
- #
- # If the requirements are met, import with .py in the namespace to not
- # trigger a traditional:
- # if __name__ == 'moduleName'
- if '__name__' in source_data and '_post_install' in source_data:
- with self.load_instructions(namespace=f"{self.namespace}.py") as imported:
- if hasattr(imported, '_post_install'):
- return True
-
- def is_top_level_profile(self) -> bool:
- with open(self.path, 'r') as source:
- source_data = source.read()
-
- if '__name__' in source_data and 'is_top_level_profile' in source_data:
- with self.load_instructions(namespace=f"{self.namespace}.py") as imported:
- if hasattr(imported, 'is_top_level_profile'):
- return imported.is_top_level_profile
-
- # Default to True if nothing is specified,
- # since developers like less code - omitting it should assume they want to present it.
- return True
-
- def get_profile_description(self) -> str:
- with open(self.path, 'r') as source:
- source_data = source.read()
-
- if '__description__' in source_data:
- with self.load_instructions(namespace=f"{self.namespace}.py") as imported:
- if hasattr(imported, '__description__'):
- return imported.__description__
-
- # Default to this string if the profile does not have a description.
- return "This profile does not have the __description__ attribute set."
-
- @property
- def packages(self) -> Optional[list]:
- """
- Returns a list of packages baked into the profile definition.
- If no package definition has been done, .packages() will return None.
- """
- with open(self.path, 'r') as source:
- source_data = source.read()
-
- # Some crude safety checks, make sure the imported profile has
- # a __name__ check before importing.
- #
- # If the requirements are met, import with .py in the namespace to not
- # trigger a traditional:
- # if __name__ == 'moduleName'
- if '__name__' in source_data and '__packages__' in source_data:
- with self.load_instructions(namespace=f"{self.namespace}.py") as imported:
- if hasattr(imported, '__packages__'):
- return imported.__packages__
- return None
-
-
-class Application(Profile):
- def __repr__(self, *args :str, **kwargs :str):
- return f'Application({os.path.basename(self.profile)})'
-
- @property
- def path(self) -> str:
- parsed_url = urllib.parse.urlparse(self.profile)
-
- # The Profile was not a direct match on a remote URL
- if not parsed_url.scheme:
- # Try to locate all local or known URL's
- if not self.examples:
- self.examples = list_profiles(subpath='/applications')
-
- if f"{self.profile}" in self.examples:
- return self.localize_path(self.examples[self.profile]['path'])
- # TODO: Redundant, the below block shouldn't be needed as profiles are stripped of their .py, but just in case for now:
- elif f"{self.profile}.py" in self.examples:
- return self.localize_path(self.examples[f"{self.profile}.py"]['path'])
-
- # Path was not found in any known examples, check if it's an absolute path
- if os.path.isfile(self.profile):
- return os.path.basename(self.profile)
-
- raise ProfileNotFound(f"Application file {self.profile} does not exist in {storage['PROFILE_PATH']}")
- elif parsed_url.scheme in ('https', 'http'):
- return self.localize_path(self.profile)
- else:
- raise ProfileNotFound(f"Application cannot handle scheme {parsed_url.scheme}")
-
- def install(self) -> ModuleType:
- # Before installing, revert any temporary changes to the namespace.
- # This ensures that the namespace during installation is the original initiation namespace.
- # (For instance awesome instead of aweosme.py or app-awesome.py)
- self.namespace = self.original_namespace
- return self.execute()
diff --git a/archinstall/lib/services.py b/archinstall/lib/services.py
deleted file mode 100644
index b177052b..00000000
--- a/archinstall/lib/services.py
+++ /dev/null
@@ -1,11 +0,0 @@
-import os
-from .general import SysCommand
-
-
-def service_state(service_name: str) -> str:
- if os.path.splitext(service_name)[1] != '.service':
- service_name += '.service' # Just to be safe
-
- state = b''.join(SysCommand(f'systemctl show --no-pager -p SubState --value {service_name}', environment_vars={'SYSTEMD_COLORS': '0'}))
-
- return state.strip().decode('UTF-8')
diff --git a/archinstall/lib/storage.py b/archinstall/lib/storage.py
index 8c358161..2f256e5d 100644
--- a/archinstall/lib/storage.py
+++ b/archinstall/lib/storage.py
@@ -1,26 +1,19 @@
-import os
-
# There's a few scenarios of execution:
-# 1. In the git repository, where ./profiles/ exist
+# 1. In the git repository, where ./profiles_bck/ exist
# 2. When executing from a remote directory, but targeted a script that starts from the git repository
-# 3. When executing as a python -m archinstall module where profiles exist one step back for library reasons.
+# 3. When executing as a python -m archinstall module where profiles_bck exist one step back for library reasons.
# (4. Added the ~/.config directory as an additional option for future reasons)
#
# And Keeping this in dict ensures that variables are shared across imports.
from typing import Any, Dict
+from pathlib import Path
+
storage: Dict[str, Any] = {
- 'PROFILE_PATH': [
- './profiles',
- '~/.config/archinstall/profiles',
- os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'profiles'),
- # os.path.abspath(f'{os.path.dirname(__file__)}/../examples')
- ],
- 'UPSTREAM_URL': 'https://raw.githubusercontent.com/archlinux/archinstall/master/profiles',
- 'PROFILE_DB': None, # Used in cases when listing profiles is desired, not mandatory for direct profile grabbing.
- 'LOG_PATH': '/var/log/archinstall',
- 'LOG_FILE': 'install.log',
- 'MOUNT_POINT': '/mnt/archinstall',
+ 'PROFILE': Path(__file__).parent.parent.joinpath('default_profiles'),
+ 'LOG_PATH': Path('/var/log/archinstall'),
+ 'LOG_FILE': Path('install.log'),
+ 'MOUNT_POINT': Path('/mnt/archinstall'),
'ENC_IDENTIFIER': 'ainst',
'DISK_TIMEOUTS' : 1, # seconds
'DISK_RETRY_ATTEMPTS' : 5, # RETRY_ATTEMPTS * DISK_TIMEOUTS is used in disk operations
diff --git a/archinstall/lib/translationhandler.py b/archinstall/lib/translationhandler.py
index 0d74f974..3ea4c70e 100644
--- a/archinstall/lib/translationhandler.py
+++ b/archinstall/lib/translationhandler.py
@@ -1,14 +1,14 @@
from __future__ import annotations
import json
-import logging
import os
import gettext
from dataclasses import dataclass
from pathlib import Path
from typing import List, Dict, Any, TYPE_CHECKING, Optional
-from .exceptions import TranslationError
+
+from .output import error, debug
if TYPE_CHECKING:
_: Any
@@ -80,8 +80,8 @@ class TranslationHandler:
language = Language(abbr, lang, translation, percent, translated_lang)
languages.append(language)
- except FileNotFoundError as error:
- raise TranslationError(f"Could not locate language file for '{lang}': {error}")
+ except FileNotFoundError as err:
+ raise FileNotFoundError(f"Could not locate language file for '{lang}': {err}")
return languages
@@ -89,12 +89,12 @@ class TranslationHandler:
"""
Set the provided font as the new terminal font
"""
- from .general import SysCommand, log
+ from .general import SysCommand
try:
- log(f'Setting font: {font}', level=logging.DEBUG)
+ debug(f'Setting font: {font}')
SysCommand(f'setfont {font}')
except Exception:
- log(f'Unable to set font {font}', level=logging.ERROR)
+ error(f'Unable to set font {font}')
def _load_language_mappings(self) -> List[Dict[str, Any]]:
"""
@@ -138,7 +138,7 @@ class TranslationHandler:
def get_language_by_abbr(self, abbr: str) -> Language:
"""
- Get a language object by its abbrevation, e.g. en
+ Get a language object by its abbreviation, e.g. en
"""
try:
return next(filter(lambda x: x.abbr == abbr, self._translated_languages))
@@ -168,7 +168,7 @@ class TranslationHandler:
translation_files = []
for filename in filenames:
- if len(filename) == 2 or filename == 'pt_BR':
+ if len(filename) == 2 or filename in ['pt_BR', 'zh-CN', 'zh-TW']:
translation_files.append(filename)
return translation_files
@@ -206,4 +206,4 @@ class DeferredTranslation:
@classmethod
def install(cls):
import builtins
- builtins._ = cls
+ builtins._ = cls # type: ignore
diff --git a/archinstall/lib/udev/__init__.py b/archinstall/lib/udev/__init__.py
deleted file mode 100644
index 86c8cc29..00000000
--- a/archinstall/lib/udev/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-from .udevadm import udevadm_info \ No newline at end of file
diff --git a/archinstall/lib/udev/udevadm.py b/archinstall/lib/udev/udevadm.py
deleted file mode 100644
index 84ec9cfd..00000000
--- a/archinstall/lib/udev/udevadm.py
+++ /dev/null
@@ -1,17 +0,0 @@
-import typing
-import pathlib
-from ..general import SysCommand
-
-def udevadm_info(path :pathlib.Path) -> typing.Dict[str, str]:
- if path.resolve().exists() is False:
- return {}
-
- result = SysCommand(f"udevadm info {path.resolve()}")
- data = {}
- for line in result:
- if b': ' in line and b'=' in line:
- _, obj = line.split(b': ', 1)
- key, value = obj.split(b'=', 1)
- data[key.decode('UTF-8').lower()] = value.decode('UTF-8').strip()
-
- return data \ No newline at end of file
diff --git a/archinstall/lib/user_interaction/__init__.py b/archinstall/lib/user_interaction/__init__.py
deleted file mode 100644
index 2bc46759..00000000
--- a/archinstall/lib/user_interaction/__init__.py
+++ /dev/null
@@ -1,12 +0,0 @@
-from .save_conf import save_config
-from .manage_users_conf import ask_for_additional_users
-from .backwards_compatible_conf import generic_select, generic_multi_select
-from .locale_conf import select_locale_lang, select_locale_enc
-from .system_conf import select_kernel, select_harddrives, select_driver, ask_for_bootloader, ask_for_swap
-from .network_conf import ask_to_configure_network
-from .partitioning_conf import select_partition
-from .general_conf import (ask_ntp, ask_for_a_timezone, ask_for_audio_selection, select_language, select_mirror_regions,
- select_profile, select_archinstall_language, ask_additional_packages_to_install,
- select_additional_repositories, ask_hostname, add_number_of_parrallel_downloads)
-from .disk_conf import ask_for_main_filesystem_format, select_individual_blockdevice_usage, select_disk_layout, select_disk
-from .utils import get_password, do_countdown
diff --git a/archinstall/lib/user_interaction/backwards_compatible_conf.py b/archinstall/lib/user_interaction/backwards_compatible_conf.py
deleted file mode 100644
index 296572d2..00000000
--- a/archinstall/lib/user_interaction/backwards_compatible_conf.py
+++ /dev/null
@@ -1,95 +0,0 @@
-from __future__ import annotations
-
-import logging
-import sys
-from collections.abc import Iterable
-from typing import Any, Union, TYPE_CHECKING
-
-from ..exceptions import RequirementError
-from ..menu import Menu
-from ..output import log
-
-if TYPE_CHECKING:
- _: Any
-
-
-def generic_select(
- p_options: Union[list, dict],
- input_text: str = '',
- allow_empty_input: bool = True,
- options_output: bool = True, # function not available
- sort: bool = False,
- multi: bool = False,
- default: Any = None) -> Any:
- """
- A generic select function that does not output anything
- other than the options and their indexes. As an example:
-
- generic_select(["first", "second", "third option"])
- > first
- second
- third option
- When the user has entered the option correctly,
- this function returns an item from list, a string, or None
-
- Options can be any iterable.
- Duplicate entries are not checked, but the results with them are unreliable. Which element to choose from the duplicates depends on the return of the index()
- Default value if not on the list of options will be added as the first element
- sort will be handled by Menu()
- """
- # We check that the options are iterable. If not we abort. Else we copy them to lists
- # it options is a dictionary we use the values as entries of the list
- # if options is a string object, each character becomes an entry
- # if options is a list, we implictily build a copy to maintain immutability
- if not isinstance(p_options, Iterable):
- log(f"Objects of type {type(p_options)} is not iterable, and are not supported at generic_select", fg="red")
- log(f"invalid parameter at Menu() call was at <{sys._getframe(1).f_code.co_name}>", level=logging.WARNING)
- raise RequirementError("generic_select() requires an iterable as option.")
-
- input_text = input_text if input_text else _('Select one of the values shown below: ')
-
- if isinstance(p_options, dict):
- options = list(p_options.values())
- else:
- options = list(p_options)
- # check that the default value is in the list. If not it will become the first entry
- if default and default not in options:
- options.insert(0, default)
-
- # one of the drawbacks of the new interface is that in only allows string like options, so we do a conversion
- # also for the default value if it exists
- soptions = list(map(str, options))
- default_value = options[options.index(default)] if default else None
-
- selected_option = Menu(input_text,
- soptions,
- skip=allow_empty_input,
- multi=multi,
- default_option=default_value,
- sort=sort).run()
- # we return the original objects, not the strings.
- # options is the list with the original objects and soptions the list with the string values
- # thru the map, we get from the value selected in soptions it index, and thu it the original object
- if not selected_option:
- return selected_option
- elif isinstance(selected_option, list): # for multi True
- selected_option = list(map(lambda x: options[soptions.index(x)], selected_option))
- else: # for multi False
- selected_option = options[soptions.index(selected_option)]
- return selected_option
-
-
-def generic_multi_select(p_options: Union[list, dict],
- text: str = '',
- sort: bool = False,
- default: Any = None,
- allow_empty: bool = False) -> Any:
-
- text = text if text else _("Select one or more of the options below: ")
-
- return generic_select(p_options,
- input_text=text,
- allow_empty_input=allow_empty,
- sort=sort,
- multi=True,
- default=default)
diff --git a/archinstall/lib/user_interaction/disk_conf.py b/archinstall/lib/user_interaction/disk_conf.py
deleted file mode 100644
index 554d13ef..00000000
--- a/archinstall/lib/user_interaction/disk_conf.py
+++ /dev/null
@@ -1,86 +0,0 @@
-from __future__ import annotations
-
-from typing import Any, Dict, TYPE_CHECKING, Optional
-
-from .partitioning_conf import manage_new_and_existing_partitions, get_default_partition_layout
-from ..disk import BlockDevice
-from ..exceptions import DiskError
-from ..menu import Menu
-from ..menu.menu import MenuSelectionType
-
-if TYPE_CHECKING:
- _: Any
-
-
-def ask_for_main_filesystem_format(advanced_options=False) -> str:
- options = {'btrfs': 'btrfs', 'ext4': 'ext4', 'xfs': 'xfs', 'f2fs': 'f2fs'}
-
- advanced = {'ntfs': 'ntfs'}
-
- if advanced_options:
- options.update(advanced)
-
- prompt = _('Select which filesystem your main partition should use')
- choice = Menu(prompt, options, skip=False).run()
- return choice.value
-
-
-def select_individual_blockdevice_usage(block_devices: list) -> Dict[str, Any]:
- result = {}
-
- for device in block_devices:
- layout = manage_new_and_existing_partitions(device)
- result[device.path] = layout
-
- return result
-
-
-def select_disk_layout(preset: Optional[Dict[str, Any]], block_devices: list, advanced_options=False) -> Optional[Dict[str, Any]]:
- wipe_mode = str(_('Wipe all selected drives and use a best-effort default partition layout'))
- custome_mode = str(_('Select what to do with each individual drive (followed by partition usage)'))
- modes = [wipe_mode, custome_mode]
-
- warning = str(_('Are you sure you want to reset this setting?'))
-
- choice = Menu(
- _('Select what you wish to do with the selected block devices'),
- modes,
- allow_reset=True,
- allow_reset_warning_msg=warning
- ).run()
-
- match choice.type_:
- case MenuSelectionType.Skip: return preset
- case MenuSelectionType.Reset: return None
- case MenuSelectionType.Selection:
- if choice.value == wipe_mode:
- return get_default_partition_layout(block_devices, advanced_options)
- else:
- return select_individual_blockdevice_usage(block_devices)
-
-
-def select_disk(dict_o_disks: Dict[str, BlockDevice]) -> Optional[BlockDevice]:
- """
- Asks the user to select a harddrive from the `dict_o_disks` selection.
- Usually this is combined with :ref:`archinstall.list_drives`.
-
- :param dict_o_disks: A `dict` where keys are the drive-name, value should be a dict containing drive information.
- :type dict_o_disks: dict
-
- :return: The name/path (the dictionary key) of the selected drive
- :rtype: str
- """
- drives = sorted(list(dict_o_disks.keys()))
- if len(drives) >= 1:
- title = str(_('You can skip selecting a drive and partitioning and use whatever drive-setup is mounted at /mnt (experimental)')) + '\n'
- title += str(_('Select one of the disks or skip and use /mnt as default'))
-
- choice = Menu(title, drives).run()
-
- if choice.type_ == MenuSelectionType.Skip:
- return None
-
- drive = dict_o_disks[choice.value]
- return drive
-
- raise DiskError('select_disk() requires a non-empty dictionary of disks to select from.')
diff --git a/archinstall/lib/user_interaction/general_conf.py b/archinstall/lib/user_interaction/general_conf.py
deleted file mode 100644
index fc7ded45..00000000
--- a/archinstall/lib/user_interaction/general_conf.py
+++ /dev/null
@@ -1,271 +0,0 @@
-from __future__ import annotations
-
-import logging
-import pathlib
-from typing import List, Any, Optional, Dict, TYPE_CHECKING
-
-from ..locale_helpers import list_keyboard_languages, list_timezones
-from ..menu import Menu
-from ..menu.menu import MenuSelectionType
-from ..menu.text_input import TextInput
-from ..mirrors import list_mirrors
-from ..output import log
-from ..packages.packages import validate_package_list
-from ..profiles import Profile, list_profiles
-from ..storage import storage
-from ..translationhandler import Language
-
-if TYPE_CHECKING:
- _: Any
-
-
-def ask_ntp(preset: bool = True) -> bool:
- prompt = str(_('Would you like to use automatic time synchronization (NTP) with the default time servers?\n'))
- prompt += str(_('Hardware time and other post-configuration steps might be required in order for NTP to work.\nFor more information, please check the Arch wiki'))
- if preset:
- preset_val = Menu.yes()
- else:
- preset_val = Menu.no()
- choice = Menu(prompt, Menu.yes_no(), skip=False, preset_values=preset_val, default_option=Menu.yes()).run()
-
- return False if choice.value == Menu.no() else True
-
-
-def ask_hostname(preset: str = None) -> str:
- hostname = TextInput(_('Desired hostname for the installation: '), preset).run().strip(' ')
- return hostname
-
-
-def ask_for_a_timezone(preset: str = None) -> str:
- timezones = list_timezones()
- default = 'UTC'
-
- choice = Menu(
- _('Select a timezone'),
- list(timezones),
- preset_values=preset,
- default_option=default
- ).run()
-
- match choice.type_:
- case MenuSelectionType.Skip: return preset
- case MenuSelectionType.Selection: return choice.value
-
-
-def ask_for_audio_selection(desktop: bool = True, preset: str = None) -> str:
- no_audio = str(_('No audio server'))
- choices = ['pipewire', 'pulseaudio'] if desktop else ['pipewire', 'pulseaudio', no_audio]
- default = 'pipewire' if desktop else no_audio
-
- choice = Menu(_('Choose an audio server'), choices, preset_values=preset, default_option=default).run()
-
- match choice.type_:
- case MenuSelectionType.Skip: return preset
- case MenuSelectionType.Selection: return choice.value
-
-
-def select_language(preset_value: str = None) -> str:
- """
- Asks the user to select a language
- Usually this is combined with :ref:`archinstall.list_keyboard_languages`.
-
- :return: The language/dictionary key of the selected language
- :rtype: str
- """
- kb_lang = list_keyboard_languages()
- # sort alphabetically and then by length
- sorted_kb_lang = sorted(sorted(list(kb_lang)), key=len)
-
- selected_lang = Menu(
- _('Select keyboard layout'),
- sorted_kb_lang,
- preset_values=preset_value,
- sort=False
- ).run()
-
- if selected_lang.value is None:
- return preset_value
-
- return selected_lang.value
-
-
-def select_mirror_regions(preset_values: Dict[str, Any] = {}) -> Dict[str, Any]:
- """
- Asks the user to select a mirror or region
- Usually this is combined with :ref:`archinstall.list_mirrors`.
-
- :return: The dictionary information about a mirror/region.
- :rtype: dict
- """
- if preset_values is None:
- preselected = None
- else:
- preselected = list(preset_values.keys())
- mirrors = list_mirrors()
- selected_mirror = Menu(
- _('Select one of the regions to download packages from'),
- list(mirrors.keys()),
- preset_values=preselected,
- multi=True,
- allow_reset=True
- ).run()
-
- match selected_mirror.type_:
- case MenuSelectionType.Reset: return {}
- case MenuSelectionType.Skip: return preset_values
- case _: return {selected: mirrors[selected] for selected in selected_mirror.value}
-
-
-def select_archinstall_language(languages: List[Language], preset_value: Language) -> Language:
- # these are the displayed language names which can either be
- # the english name of a language or, if present, the
- # name of the language in its own language
- options = {lang.display_name: lang for lang in languages}
-
- title = 'NOTE: If a language can not displayed properly, a proper font must be set manually in the console.\n'
- title += 'All available fonts can be found in "/usr/share/kbd/consolefonts"\n'
- title += 'e.g. setfont LatGrkCyr-8x16 (to display latin/greek/cyrillic characters)\n'
-
- choice = Menu(
- title,
- list(options.keys()),
- default_option=preset_value.display_name,
- preview_size=0.5
- ).run()
-
- match choice.type_:
- case MenuSelectionType.Skip:
- return preset_value
- case MenuSelectionType.Selection:
- return options[choice.value]
-
-
-def select_profile(preset) -> Optional[Profile]:
- """
- # Asks the user to select a profile from the available profiles.
- #
- # :return: The name/dictionary key of the selected profile
- # :rtype: str
- # """
- top_level_profiles = sorted(list(list_profiles(filter_top_level_profiles=True)))
- options = {}
-
- for profile in top_level_profiles:
- profile = Profile(None, profile)
- description = profile.get_profile_description()
-
- option = f'{profile.profile}: {description}'
- options[option] = profile
-
- title = _('This is a list of pre-programmed profiles, they might make it easier to install things like desktop environments')
- warning = str(_('Are you sure you want to reset this setting?'))
-
- selection = Menu(
- title=title,
- p_options=list(options.keys()),
- allow_reset=True,
- allow_reset_warning_msg=warning
- ).run()
-
- match selection.type_:
- case MenuSelectionType.Selection:
- return options[selection.value] if selection.value is not None else None
- case MenuSelectionType.Reset:
- storage['profile_minimal'] = False
- storage['_selected_servers'] = []
- storage['_desktop_profile'] = None
- storage['sway_sys_priv_ctrl'] = None
- storage['arguments']['sway_sys_priv_ctrl'] = None
- storage['arguments']['desktop-environment'] = None
- storage['arguments']['gfx_driver'] = None
- storage['arguments']['gfx_driver_packages'] = None
- return None
- case MenuSelectionType.Skip:
- return None
-
-
-def ask_additional_packages_to_install(pre_set_packages: List[str] = []) -> List[str]:
- # Additional packages (with some light weight error handling for invalid package names)
- print(_('Only packages such as base, base-devel, linux, linux-firmware, efibootmgr and optional profile packages are installed.'))
- print(_('If you desire a web browser, such as firefox or chromium, you may specify it in the following prompt.'))
-
- def read_packages(already_defined: list = []) -> list:
- display = ' '.join(already_defined)
- input_packages = TextInput(_('Write additional packages to install (space separated, leave blank to skip): '), display).run().strip()
- return input_packages.split() if input_packages else []
-
- pre_set_packages = pre_set_packages if pre_set_packages else []
- packages = read_packages(pre_set_packages)
-
- if not storage['arguments']['offline'] and not storage['arguments']['no_pkg_lookups']:
- while True:
- if len(packages):
- # Verify packages that were given
- print(_("Verifying that additional packages exist (this might take a few seconds)"))
- valid, invalid = validate_package_list(packages)
-
- if invalid:
- log(f"Some packages could not be found in the repository: {invalid}", level=logging.WARNING, fg='red')
- packages = read_packages(valid)
- continue
- break
-
- return packages
-
-
-def add_number_of_parrallel_downloads(input_number :Optional[int] = None) -> Optional[int]:
- max_downloads = 5
- print(_(f"This option enables the number of parallel downloads that can occur during installation"))
- print(_(f"Enter the number of parallel downloads to be enabled.\n (Enter a value between 1 to {max_downloads})\nNote:"))
- print(_(f" - Maximum value : {max_downloads} ( Allows {max_downloads} parallel downloads, allows {max_downloads+1} downloads at a time )"))
- print(_(f" - Minimum value : 1 ( Allows 1 parallel download, allows 2 downloads at a time )"))
- print(_(f" - Disable/Default : 0 ( Disables parallel downloading, allows only 1 download at a time )"))
-
- while True:
- try:
- input_number = int(TextInput(_("[Default value: 0] > ")).run().strip() or 0)
- if input_number <= 0:
- input_number = 0
- elif input_number > max_downloads:
- input_number = max_downloads
- break
- except:
- print(_(f"Invalid input! Try again with a valid input [1 to {max_downloads}, or 0 to disable]"))
-
- pacman_conf_path = pathlib.Path("/etc/pacman.conf")
- with pacman_conf_path.open() as f:
- pacman_conf = f.read().split("\n")
-
- with pacman_conf_path.open("w") as fwrite:
- for line in pacman_conf:
- if "ParallelDownloads" in line:
- fwrite.write(f"ParallelDownloads = {input_number+1}\n") if not input_number == 0 else fwrite.write("#ParallelDownloads = 0\n")
- else:
- fwrite.write(f"{line}\n")
-
- return input_number
-
-
-def select_additional_repositories(preset: List[str]) -> List[str]:
- """
- Allows the user to select additional repositories (multilib, and testing) if desired.
-
- :return: The string as a selected repository
- :rtype: string
- """
-
- repositories = ["multilib", "testing"]
-
- choice = Menu(
- _('Choose which optional additional repositories to enable'),
- repositories,
- sort=False,
- multi=True,
- preset_values=preset,
- allow_reset=True
- ).run()
-
- match choice.type_:
- case MenuSelectionType.Skip: return preset
- case MenuSelectionType.Reset: return []
- case MenuSelectionType.Selection: return choice.value
diff --git a/archinstall/lib/user_interaction/locale_conf.py b/archinstall/lib/user_interaction/locale_conf.py
deleted file mode 100644
index bbbe070b..00000000
--- a/archinstall/lib/user_interaction/locale_conf.py
+++ /dev/null
@@ -1,42 +0,0 @@
-from __future__ import annotations
-
-from typing import Any, TYPE_CHECKING
-
-from ..locale_helpers import list_locales
-from ..menu import Menu
-from ..menu.menu import MenuSelectionType
-
-if TYPE_CHECKING:
- _: Any
-
-
-def select_locale_lang(preset: str = None) -> str:
- locales = list_locales()
- locale_lang = set([locale.split()[0] for locale in locales])
-
- selected_locale = Menu(
- _('Choose which locale language to use'),
- list(locale_lang),
- sort=True,
- preset_values=preset
- ).run()
-
- match selected_locale.type_:
- case MenuSelectionType.Selection: return selected_locale.value
- case MenuSelectionType.Skip: return preset
-
-
-def select_locale_enc(preset: str = None) -> str:
- locales = list_locales()
- locale_enc = set([locale.split()[1] for locale in locales])
-
- selected_locale = Menu(
- _('Choose which locale encoding to use'),
- list(locale_enc),
- sort=True,
- preset_values=preset
- ).run()
-
- match selected_locale.type_:
- case MenuSelectionType.Selection: return selected_locale.value
- case MenuSelectionType.Skip: return preset
diff --git a/archinstall/lib/user_interaction/partitioning_conf.py b/archinstall/lib/user_interaction/partitioning_conf.py
deleted file mode 100644
index 0a5ede51..00000000
--- a/archinstall/lib/user_interaction/partitioning_conf.py
+++ /dev/null
@@ -1,362 +0,0 @@
-from __future__ import annotations
-
-import copy
-from typing import List, Any, Dict, Union, TYPE_CHECKING, Callable, Optional
-
-from ..menu import Menu
-from ..menu.menu import MenuSelectionType
-from ..output import log, FormattedOutput
-
-from ..disk.validators import fs_types
-
-if TYPE_CHECKING:
- from ..disk import BlockDevice
- from ..disk.partition import Partition
- _: Any
-
-
-def partition_overlap(partitions: list, start: str, end: str) -> bool:
- # TODO: Implement sanity check
- return False
-
-
-def current_partition_layout(partitions: List[Dict[str, Any]], with_idx: bool = False, with_title: bool = True) -> str:
-
- def do_padding(name: str, max_len: int):
- spaces = abs(len(str(name)) - max_len) + 2
- pad_left = int(spaces / 2)
- pad_right = spaces - pad_left
- return f'{pad_right * " "}{name}{pad_left * " "}|'
-
- def flatten_data(data: Dict[str, Any]) -> Dict[str, Any]:
- flattened = {}
- for k, v in data.items():
- if k == 'filesystem':
- flat = flatten_data(v)
- flattened.update(flat)
- elif k == 'btrfs':
- # we're going to create a separate table for the btrfs subvolumes
- pass
- else:
- flattened[k] = v
- return flattened
-
- display_data: List[Dict[str, Any]] = [flatten_data(entry) for entry in partitions]
-
- column_names = {}
-
- # this will add an initial index to the table for each partition
- if with_idx:
- column_names['index'] = max([len(str(len(display_data))), len('index')])
-
- # determine all attribute names and the max length
- # of the value among all display_data to know the width
- # of the table cells
- for p in display_data:
- for attribute, value in p.items():
- if attribute in column_names.keys():
- column_names[attribute] = max([column_names[attribute], len(str(value)), len(attribute)])
- else:
- column_names[attribute] = max([len(str(value)), len(attribute)])
-
- current_layout = ''
- for name, max_len in column_names.items():
- current_layout += do_padding(name, max_len)
-
- current_layout = f'{current_layout[:-1]}\n{"-" * len(current_layout)}\n'
-
- for idx, p in enumerate(display_data):
- row = ''
- for name, max_len in column_names.items():
- if name == 'index':
- row += do_padding(str(idx), max_len)
- elif name in p:
- row += do_padding(p[name], max_len)
- else:
- row += ' ' * (max_len + 2) + '|'
-
- current_layout += f'{row[:-1]}\n'
-
- # we'll create a separate table for the btrfs subvolumes
- btrfs_subvolumes = [partition['btrfs']['subvolumes'] for partition in partitions if partition.get('btrfs', None)]
- if len(btrfs_subvolumes) > 0:
- for subvolumes in btrfs_subvolumes:
- output = FormattedOutput.as_table(subvolumes)
- current_layout += f'\n{output}'
-
- if with_title:
- title = str(_('Current partition layout'))
- return f'\n\n{title}:\n\n{current_layout}'
-
- return current_layout
-
-
-def _get_partitions(partitions :List[Partition], filter_ :Callable = None) -> List[str]:
- """
- filter allows to filter out the indexes once they are set. Should return True if element is to be included
- """
- partition_indexes = []
- for i in range(len(partitions)):
- if filter_:
- if filter_(partitions[i]):
- partition_indexes.append(str(i))
- else:
- partition_indexes.append(str(i))
-
- return partition_indexes
-
-
-def select_partition(
- title :str,
- partitions :List[Partition],
- multiple :bool = False,
- filter_ :Callable = None
-) -> Optional[int, List[int]]:
- partition_indexes = _get_partitions(partitions, filter_)
-
- if len(partition_indexes) == 0:
- return None
-
- choice = Menu(title, partition_indexes, multi=multiple).run()
-
- if choice.type_ == MenuSelectionType.Skip:
- return None
-
- if isinstance(choice.value, list):
- return [int(p) for p in choice.value]
- else:
- return int(choice.value)
-
-
-def get_default_partition_layout(
- block_devices: Union['BlockDevice', List['BlockDevice']],
- advanced_options: bool = False
-) -> Optional[Dict[str, Any]]:
- from ..disk import suggest_single_disk_layout, suggest_multi_disk_layout
-
- if len(block_devices) == 1:
- return suggest_single_disk_layout(block_devices[0], advanced_options=advanced_options)
- else:
- return suggest_multi_disk_layout(block_devices, advanced_options=advanced_options)
-
-
-def manage_new_and_existing_partitions(block_device: 'BlockDevice') -> Dict[str, Any]: # noqa: max-complexity: 50
- block_device_struct = {"partitions": [partition.__dump__() for partition in block_device.partitions.values()]}
- original_layout = copy.deepcopy(block_device_struct)
-
- new_partition = str(_('Create a new partition'))
- suggest_partition_layout = str(_('Suggest partition layout'))
- delete_partition = str(_('Delete a partition'))
- delete_all_partitions = str(_('Clear/Delete all partitions'))
- assign_mount_point = str(_('Assign mount-point for a partition'))
- mark_formatted = str(_('Mark/Unmark a partition to be formatted (wipes data)'))
- mark_compressed = str(_('Mark/Unmark a partition as compressed (btrfs only)'))
- mark_bootable = str(_('Mark/Unmark a partition as bootable (automatic for /boot)'))
- set_filesystem_partition = str(_('Set desired filesystem for a partition'))
- set_btrfs_subvolumes = str(_('Set desired subvolumes on a btrfs partition'))
- save_and_exit = str(_('Save and exit'))
- cancel = str(_('Cancel'))
-
- while True:
- modes = [new_partition, suggest_partition_layout]
-
- if len(block_device_struct['partitions']) > 0:
- modes += [
- delete_partition,
- delete_all_partitions,
- assign_mount_point,
- mark_formatted,
- mark_bootable,
- mark_compressed,
- set_filesystem_partition,
- ]
-
- indexes = _get_partitions(
- block_device_struct["partitions"],
- filter_=lambda x: True if x.get('filesystem', {}).get('format') == 'btrfs' else False
- )
-
- if len(indexes) > 0:
- modes += [set_btrfs_subvolumes]
-
- title = _('Select what to do with\n{}').format(block_device)
-
- # show current partition layout:
- if len(block_device_struct["partitions"]):
- title += current_partition_layout(block_device_struct['partitions']) + '\n'
-
- modes += [save_and_exit, cancel]
-
- task = Menu(title, modes, sort=False, skip=False).run()
- task = task.value
-
- if task == cancel:
- return original_layout
- elif task == save_and_exit:
- break
-
- if task == new_partition:
- from ..disk import valid_parted_position
-
- # if partition_type == 'gpt':
- # # https://www.gnu.org/software/parted/manual/html_node/mkpart.html
- # # https://www.gnu.org/software/parted/manual/html_node/mklabel.html
- # name = input("Enter a desired name for the partition: ").strip()
-
- fs_choice = Menu(_('Enter a desired filesystem type for the partition'), fs_types()).run()
-
- if fs_choice.type_ == MenuSelectionType.Skip:
- continue
-
- prompt = str(_('Enter the start location (in parted units: s, GB, %, etc. ; default: {}): ')).format(
- block_device.first_free_sector
- )
- start = input(prompt).strip()
-
- if not start.strip():
- start = block_device.first_free_sector
- end_suggested = block_device.first_end_sector
- else:
- end_suggested = '100%'
-
- prompt = str(_('Enter the end location (in parted units: s, GB, %, etc. ; ex: {}): ')).format(
- end_suggested
- )
- end = input(prompt).strip()
-
- if not end.strip():
- end = end_suggested
-
- if valid_parted_position(start) and valid_parted_position(end):
- if partition_overlap(block_device_struct["partitions"], start, end):
- log(f"This partition overlaps with other partitions on the drive! Ignoring this partition creation.",
- fg="red")
- continue
-
- block_device_struct["partitions"].append({
- "type": "primary", # Strictly only allowed under MS-DOS, but GPT accepts it so it's "safe" to inject
- "start": start,
- "size": end,
- "mountpoint": None,
- "wipe": True,
- "filesystem": {
- "format": fs_choice.value
- }
- })
- else:
- log(f"Invalid start ({valid_parted_position(start)}) or end ({valid_parted_position(end)}) for this partition. Ignoring this partition creation.",
- fg="red")
- continue
- elif task == suggest_partition_layout:
- from ..disk import suggest_single_disk_layout
-
- if len(block_device_struct["partitions"]):
- prompt = _('{}\ncontains queued partitions, this will remove those, are you sure?').format(block_device)
- choice = Menu(prompt, Menu.yes_no(), default_option=Menu.no(), skip=False).run()
-
- if choice.value == Menu.no():
- continue
-
- block_device_struct.update(suggest_single_disk_layout(block_device)[block_device.path])
- else:
- current_layout = current_partition_layout(block_device_struct['partitions'], with_idx=True)
-
- if task == delete_partition:
- title = _('{}\n\nSelect by index which partitions to delete').format(current_layout)
- to_delete = select_partition(title, block_device_struct["partitions"], multiple=True)
-
- if to_delete:
- block_device_struct['partitions'] = [
- p for idx, p in enumerate(block_device_struct['partitions']) if idx not in to_delete
- ]
- elif task == mark_compressed:
- title = _('{}\n\nSelect which partition to mark as bootable').format(current_layout)
- partition = select_partition(title, block_device_struct["partitions"])
-
- if partition is not None:
- if "filesystem" not in block_device_struct["partitions"][partition]:
- block_device_struct["partitions"][partition]["filesystem"] = {}
- if "mount_options" not in block_device_struct["partitions"][partition]["filesystem"]:
- block_device_struct["partitions"][partition]["filesystem"]["mount_options"] = []
-
- if "compress=zstd" not in block_device_struct["partitions"][partition]["filesystem"]["mount_options"]:
- block_device_struct["partitions"][partition]["filesystem"]["mount_options"].append("compress=zstd")
- elif task == delete_all_partitions:
- block_device_struct["partitions"] = []
- block_device_struct["wipe"] = True
- elif task == assign_mount_point:
- title = _('{}\n\nSelect by index which partition to mount where').format(current_layout)
- partition = select_partition(title, block_device_struct["partitions"])
-
- if partition is not None:
- print(_(' * Partition mount-points are relative to inside the installation, the boot would be /boot as an example.'))
- mountpoint = input(_('Select where to mount partition (leave blank to remove mountpoint): ')).strip()
-
- if len(mountpoint):
- block_device_struct["partitions"][partition]['mountpoint'] = mountpoint
- if mountpoint == '/boot':
- log(f"Marked partition as bootable because mountpoint was set to /boot.", fg="yellow")
- block_device_struct["partitions"][partition]['boot'] = True
- else:
- del (block_device_struct["partitions"][partition]['mountpoint'])
-
- elif task == mark_formatted:
- title = _('{}\n\nSelect which partition to mask for formatting').format(current_layout)
- partition = select_partition(title, block_device_struct["partitions"])
-
- if partition is not None:
- # If we mark a partition for formatting, but the format is CRYPTO LUKS, there's no point in formatting it really
- # without asking the user which inner-filesystem they want to use. Since the flag 'encrypted' = True is already set,
- # it's safe to change the filesystem for this partition.
- if block_device_struct["partitions"][partition].get('filesystem',{}).get('format', 'crypto_LUKS') == 'crypto_LUKS':
- if not block_device_struct["partitions"][partition].get('filesystem', None):
- block_device_struct["partitions"][partition]['filesystem'] = {}
-
- fs_choice = Menu(_('Enter a desired filesystem type for the partition'), fs_types()).run()
-
- if fs_choice.type_ == MenuSelectionType.Selection:
- block_device_struct["partitions"][partition]['filesystem']['format'] = fs_choice.value
-
- # Negate the current wipe marking
- block_device_struct["partitions"][partition]['wipe'] = not block_device_struct["partitions"][partition].get('wipe', False)
-
- elif task == mark_bootable:
- title = _('{}\n\nSelect which partition to mark as bootable').format(current_layout)
- partition = select_partition(title, block_device_struct["partitions"])
-
- if partition is not None:
- block_device_struct["partitions"][partition]['boot'] = \
- not block_device_struct["partitions"][partition].get('boot', False)
-
- elif task == set_filesystem_partition:
- title = _('{}\n\nSelect which partition to set a filesystem on').format(current_layout)
- partition = select_partition(title, block_device_struct["partitions"])
-
- if partition is not None:
- if not block_device_struct["partitions"][partition].get('filesystem', None):
- block_device_struct["partitions"][partition]['filesystem'] = {}
-
- fstype_title = _('Enter a desired filesystem type for the partition: ')
- fs_choice = Menu(fstype_title, fs_types()).run()
-
- if fs_choice.type_ == MenuSelectionType.Selection:
- block_device_struct["partitions"][partition]['filesystem']['format'] = fs_choice.value
-
- elif task == set_btrfs_subvolumes:
- from .subvolume_config import SubvolumeList
-
- # TODO get preexisting partitions
- title = _('{}\n\nSelect which partition to set subvolumes on').format(current_layout)
- partition = select_partition(title, block_device_struct["partitions"],filter_=lambda x:True if x.get('filesystem',{}).get('format') == 'btrfs' else False)
-
- if partition is not None:
- if not block_device_struct["partitions"][partition].get('btrfs', {}):
- block_device_struct["partitions"][partition]['btrfs'] = {}
- if not block_device_struct["partitions"][partition]['btrfs'].get('subvolumes', []):
- block_device_struct["partitions"][partition]['btrfs']['subvolumes'] = []
-
- prev = block_device_struct["partitions"][partition]['btrfs']['subvolumes']
- result = SubvolumeList(_("Manage btrfs subvolumes for current partition"), prev).run()
- block_device_struct["partitions"][partition]['btrfs']['subvolumes'] = result
-
- return block_device_struct
diff --git a/archinstall/lib/user_interaction/save_conf.py b/archinstall/lib/user_interaction/save_conf.py
deleted file mode 100644
index 5b4ae2b3..00000000
--- a/archinstall/lib/user_interaction/save_conf.py
+++ /dev/null
@@ -1,135 +0,0 @@
-from __future__ import annotations
-
-import logging
-
-from pathlib import Path
-from typing import Any, Dict, TYPE_CHECKING
-
-from ..configuration import ConfigurationOutput
-from ..general import SysCommand
-from ..menu import Menu
-from ..menu.menu import MenuSelectionType
-from ..output import log
-
-if TYPE_CHECKING:
- _: Any
-
-
-def save_config(config: Dict):
-
- def preview(selection: str):
- if options['user_config'] == selection:
- json_config = config_output.user_config_to_json()
- return f'{config_output.user_configuration_file}\n{json_config}'
- elif options['user_creds'] == selection:
- if json_config := config_output.user_credentials_to_json():
- return f'{config_output.user_credentials_file}\n{json_config}'
- else:
- return str(_('No configuration'))
- elif options['disk_layout'] == selection:
- if json_config := config_output.disk_layout_to_json():
- return f'{config_output.disk_layout_file}\n{json_config}'
- else:
- return str(_('No configuration'))
- elif options['all'] == selection:
- output = f'{config_output.user_configuration_file}\n'
- if json_config := config_output.user_credentials_to_json():
- output += f'{config_output.user_credentials_file}\n'
- if json_config := config_output.disk_layout_to_json():
- output += f'{config_output.disk_layout_file}\n'
- return output[:-1]
- return None
-
- config_output = ConfigurationOutput(config)
-
- options = {
- 'user_config': str(_('Save user configuration')),
- 'user_creds': str(_('Save user credentials')),
- 'disk_layout': str(_('Save disk layout')),
- 'all': str(_('Save all'))
- }
-
- choice = Menu(
- _('Choose which configuration to save'),
- list(options.values()),
- sort=False,
- skip=True,
- preview_size=0.75,
- preview_command=preview
- ).run()
-
- if choice.type_ == MenuSelectionType.Skip:
- return
-
- dirs_to_exclude = [
- '/bin',
- '/dev',
- '/lib',
- '/lib64',
- '/lost+found',
- '/opt',
- '/proc',
- '/run',
- '/sbin',
- '/srv',
- '/sys',
- '/usr',
- '/var',
- ]
- log(
- _('When picking a directory to save configuration files to,'
- ' by default we will ignore the following folders: ') + ','.join(dirs_to_exclude),
- level=logging.DEBUG
- )
-
- log(_('Finding possible directories to save configuration files ...'), level=logging.INFO)
-
- find_exclude = '-path ' + ' -prune -o -path '.join(dirs_to_exclude) + ' -prune '
- file_picker_command = f'find / {find_exclude} -o -type d -print0'
- possible_save_dirs = list(
- filter(None, SysCommand(file_picker_command).decode().split('\x00'))
- )
-
- selection = Menu(
- _('Select directory (or directories) for saving configuration files'),
- possible_save_dirs,
- multi=True,
- skip=True,
- allow_reset=False,
- ).run()
-
- match selection.type_:
- case MenuSelectionType.Skip:
- return
- case _:
- save_dirs = selection.value
-
- prompt = _('Do you want to save {} configuration file(s) in the following locations?\n\n{}').format(
- list(options.keys())[list(options.values()).index(choice.value)],
- save_dirs
- )
- save_confirmation = Menu(prompt, Menu.yes_no(), default_option=Menu.yes()).run()
- if save_confirmation == Menu.no():
- return
-
- log(
- _('Saving {} configuration files to {}').format(
- list(options.keys())[list(options.values()).index(choice.value)],
- save_dirs
- ),
- level=logging.DEBUG
- )
-
- if save_dirs is not None:
- for save_dir_str in save_dirs:
- save_dir = Path(save_dir_str)
- if options['user_config'] == choice.value:
- config_output.save_user_config(save_dir)
- elif options['user_creds'] == choice.value:
- config_output.save_user_creds(save_dir)
- elif options['disk_layout'] == choice.value:
- config_output.save_disk_layout(save_dir)
- elif options['all'] == choice.value:
- config_output.save_user_config(save_dir)
- config_output.save_user_creds(save_dir)
- config_output.save_disk_layout(save_dir)
diff --git a/archinstall/lib/user_interaction/subvolume_config.py b/archinstall/lib/user_interaction/subvolume_config.py
deleted file mode 100644
index 94150dee..00000000
--- a/archinstall/lib/user_interaction/subvolume_config.py
+++ /dev/null
@@ -1,98 +0,0 @@
-from typing import Dict, List, Optional, Any, TYPE_CHECKING
-
-from ..menu.list_manager import ListManager
-from ..menu.menu import MenuSelectionType
-from ..menu.text_input import TextInput
-from ..menu import Menu
-from ..models.subvolume import Subvolume
-from ... import FormattedOutput
-
-if TYPE_CHECKING:
- _: Any
-
-
-class SubvolumeList(ListManager):
- def __init__(self, prompt: str, subvolumes: List[Subvolume]):
- self._actions = [
- str(_('Add subvolume')),
- str(_('Edit subvolume')),
- str(_('Delete subvolume'))
- ]
- super().__init__(prompt, subvolumes, [self._actions[0]], self._actions[1:])
-
- def reformat(self, data: List[Subvolume]) -> Dict[str, Optional[Subvolume]]:
- table = FormattedOutput.as_table(data)
- rows = table.split('\n')
-
- # these are the header rows of the table and do not map to any User obviously
- # we're adding 2 spaces as prefix because the menu selector '> ' will be put before
- # the selectable rows so the header has to be aligned
- display_data: Dict[str, Optional[Subvolume]] = {f' {rows[0]}': None, f' {rows[1]}': None}
-
- for row, subvol in zip(rows[2:], data):
- row = row.replace('|', '\\|')
- display_data[row] = subvol
-
- return display_data
-
- def selected_action_display(self, subvolume: Subvolume) -> str:
- return subvolume.name
-
- def _prompt_options(self, editing: Optional[Subvolume] = None) -> List[str]:
- preset_options = []
- if editing:
- preset_options = editing.options
-
- choice = Menu(
- str(_("Select the desired subvolume options ")),
- ['nodatacow','compress'],
- skip=True,
- preset_values=preset_options,
- multi=True
- ).run()
-
- if choice.type_ == MenuSelectionType.Selection:
- return choice.value # type: ignore
-
- return []
-
- def _add_subvolume(self, editing: Optional[Subvolume] = None) -> Optional[Subvolume]:
- name = TextInput(f'\n\n{_("Subvolume name")}: ', editing.name if editing else '').run()
-
- if not name:
- return None
-
- mountpoint = TextInput(f'\n{_("Subvolume mountpoint")}: ', editing.mountpoint if editing else '').run()
-
- if not mountpoint:
- return None
-
- options = self._prompt_options(editing)
-
- subvolume = Subvolume(name, mountpoint)
- subvolume.compress = 'compress' in options
- subvolume.nodatacow = 'nodatacow' in options
-
- return subvolume
-
- def handle_action(self, action: str, entry: Optional[Subvolume], data: List[Subvolume]) -> List[Subvolume]:
- if action == self._actions[0]: # add
- new_subvolume = self._add_subvolume()
-
- if new_subvolume is not None:
- # in case a user with the same username as an existing user
- # was created we'll replace the existing one
- data = [d for d in data if d.name != new_subvolume.name]
- data += [new_subvolume]
- elif entry is not None:
- if action == self._actions[1]: # edit subvolume
- new_subvolume = self._add_subvolume(entry)
-
- if new_subvolume is not None:
- # we'll remove the original subvolume and add the modified version
- data = [d for d in data if d.name != entry.name and d.name != new_subvolume.name]
- data += [new_subvolume]
- elif action == self._actions[2]: # delete
- data = [d for d in data if d != entry]
-
- return data
diff --git a/archinstall/lib/user_interaction/system_conf.py b/archinstall/lib/user_interaction/system_conf.py
deleted file mode 100644
index 68a1a7d2..00000000
--- a/archinstall/lib/user_interaction/system_conf.py
+++ /dev/null
@@ -1,168 +0,0 @@
-from __future__ import annotations
-
-from typing import List, Any, Dict, TYPE_CHECKING
-
-from ..disk import all_blockdevices
-from ..exceptions import RequirementError
-from ..hardware import AVAILABLE_GFX_DRIVERS, has_uefi, has_amd_graphics, has_intel_graphics, has_nvidia_graphics
-from ..menu import Menu
-from ..menu.menu import MenuSelectionType
-from ..storage import storage
-
-if TYPE_CHECKING:
- _: Any
-
-
-def select_kernel(preset: List[str] = None) -> List[str]:
- """
- Asks the user to select a kernel for system.
-
- :return: The string as a selected kernel
- :rtype: string
- """
-
- kernels = ["linux", "linux-lts", "linux-zen", "linux-pae"]
- default_kernel = "linux"
-
- warning = str(_('Are you sure you want to reset this setting?'))
-
- choice = Menu(
- _('Choose which kernels to use or leave blank for default "{}"').format(default_kernel),
- kernels,
- sort=True,
- multi=True,
- preset_values=preset,
- allow_reset=True,
- allow_reset_warning_msg=warning
- ).run()
-
- match choice.type_:
- case MenuSelectionType.Skip: return preset
- case MenuSelectionType.Reset: return []
- case MenuSelectionType.Selection: return choice.value
-
-
-def select_harddrives(preset: List[str] = []) -> List[str]:
- """
- Asks the user to select one or multiple hard drives
-
- :return: List of selected hard drives
- :rtype: list
- """
- hard_drives = all_blockdevices(partitions=False).values()
- options = {f'{option}': option for option in hard_drives}
-
- title = str(_('Select one or more hard drives to use and configure\n'))
- title += str(_('Any modifications to the existing setting will reset the disk layout!'))
-
- warning = str(_('If you reset the harddrive selection this will also reset the current disk layout. Are you sure?'))
-
- selected_harddrive = Menu(
- title,
- list(options.keys()),
- multi=True,
- allow_reset=True,
- allow_reset_warning_msg=warning
- ).run()
-
- match selected_harddrive.type_:
- case MenuSelectionType.Reset: return []
- case MenuSelectionType.Skip: return preset
- case MenuSelectionType.Selection: return [options[i] for i in selected_harddrive.value]
-
-
-def select_driver(options: Dict[str, Any] = AVAILABLE_GFX_DRIVERS) -> str:
- """
- Some what convoluted function, whose job is simple.
- Select a graphics driver from a pre-defined set of popular options.
-
- (The template xorg is for beginner users, not advanced, and should
- there for appeal to the general public first and edge cases later)
- """
-
- drivers = sorted(list(options))
-
- if drivers:
- arguments = storage.get('arguments', {})
- title = ''
-
- if has_amd_graphics():
- title += str(_(
- 'For the best compatibility with your AMD hardware, you may want to use either the all open-source or AMD / ATI options.'
- )) + '\n'
- if has_intel_graphics():
- title += str(_(
- 'For the best compatibility with your Intel hardware, you may want to use either the all open-source or Intel options.\n'
- ))
- if has_nvidia_graphics():
- title += str(_(
- 'For the best compatibility with your Nvidia hardware, you may want to use the Nvidia proprietary driver.\n'
- ))
-
- title += str(_('\n\nSelect a graphics driver or leave blank to install all open-source drivers'))
- choice = Menu(title, drivers).run()
-
- if choice.type_ != MenuSelectionType.Selection:
- return arguments.get('gfx_driver')
-
- arguments['gfx_driver'] = choice.value
- return options.get(choice.value)
-
- raise RequirementError("Selecting drivers require a least one profile to be given as an option.")
-
-
-def ask_for_bootloader(advanced_options: bool = False, preset: str = None) -> str:
- if preset == 'systemd-bootctl':
- preset_val = 'systemd-boot' if advanced_options else Menu.no()
- elif preset == 'grub-install':
- preset_val = 'grub' if advanced_options else Menu.yes()
- else:
- preset_val = preset
-
- bootloader = "systemd-bootctl" if has_uefi() else "grub-install"
-
- if has_uefi():
- if not advanced_options:
- selection = Menu(
- _('Would you like to use GRUB as a bootloader instead of systemd-boot?'),
- Menu.yes_no(),
- preset_values=preset_val,
- default_option=Menu.no()
- ).run()
-
- match selection.type_:
- case MenuSelectionType.Skip: return preset
- case MenuSelectionType.Selection: bootloader = 'grub-install' if selection.value == Menu.yes() else bootloader
- else:
- # We use the common names for the bootloader as the selection, and map it back to the expected values.
- choices = ['systemd-boot', 'grub', 'efistub']
- selection = Menu(_('Choose a bootloader'), choices, preset_values=preset_val).run()
-
- value = ''
- match selection.type_:
- case MenuSelectionType.Skip: value = preset_val
- case MenuSelectionType.Selection: value = selection.value
-
- if value != "":
- if value == 'systemd-boot':
- bootloader = 'systemd-bootctl'
- elif value == 'grub':
- bootloader = 'grub-install'
- else:
- bootloader = value
-
- return bootloader
-
-
-def ask_for_swap(preset: bool = True) -> bool:
- if preset:
- preset_val = Menu.yes()
- else:
- preset_val = Menu.no()
-
- prompt = _('Would you like to use swap on zram?')
- choice = Menu(prompt, Menu.yes_no(), default_option=Menu.yes(), preset_values=preset_val).run()
-
- match choice.type_:
- case MenuSelectionType.Skip: return preset
- case MenuSelectionType.Selection: return False if choice.value == Menu.no() else True
diff --git a/archinstall/lib/user_interaction/utils.py b/archinstall/lib/user_interaction/utils.py
deleted file mode 100644
index 7ee6fc07..00000000
--- a/archinstall/lib/user_interaction/utils.py
+++ /dev/null
@@ -1,79 +0,0 @@
-from __future__ import annotations
-
-import getpass
-import signal
-import sys
-import time
-from typing import Any, Optional, TYPE_CHECKING
-
-from ..menu import Menu
-from ..models.password_strength import PasswordStrength
-from ..output import log
-
-if TYPE_CHECKING:
- _: Any
-
-# used for signal handler
-SIG_TRIGGER = None
-
-
-def get_password(prompt: str = '') -> Optional[str]:
- if not prompt:
- prompt = _("Enter a password: ")
-
- while password := getpass.getpass(prompt):
- if len(password.strip()) <= 0:
- break
-
- strength = PasswordStrength.strength(password)
- log(f'Password strength: {strength.value}', fg=strength.color())
-
- passwd_verification = getpass.getpass(prompt=_('And one more time for verification: '))
- if password != passwd_verification:
- log(' * Passwords did not match * ', fg='red')
- continue
-
- return password
-
- return None
-
-
-def do_countdown() -> bool:
- SIG_TRIGGER = False
-
- def kill_handler(sig: int, frame: Any) -> None:
- print()
- exit(0)
-
- def sig_handler(sig: int, frame: Any) -> None:
- global SIG_TRIGGER
- SIG_TRIGGER = True
- signal.signal(signal.SIGINT, kill_handler)
-
- original_sigint_handler = signal.getsignal(signal.SIGINT)
- signal.signal(signal.SIGINT, sig_handler)
-
- for i in range(5, 0, -1):
- print(f"{i}", end='')
-
- for x in range(4):
- sys.stdout.flush()
- time.sleep(0.25)
- print(".", end='')
-
- if SIG_TRIGGER:
- prompt = _('Do you really want to abort?')
- choice = Menu(prompt, Menu.yes_no(), skip=False).run()
- if choice.value == Menu.yes():
- exit(0)
-
- if SIG_TRIGGER is False:
- sys.stdin.read()
-
- SIG_TRIGGER = False
- signal.signal(signal.SIGINT, sig_handler)
-
- print()
- signal.signal(signal.SIGINT, original_sigint_handler)
-
- return True
diff --git a/archinstall/lib/utils/__init__.py b/archinstall/lib/utils/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/archinstall/lib/utils/__init__.py
diff --git a/archinstall/lib/utils/singleton.py b/archinstall/lib/utils/singleton.py
new file mode 100644
index 00000000..55be70eb
--- /dev/null
+++ b/archinstall/lib/utils/singleton.py
@@ -0,0 +1,15 @@
+from typing import Dict, Any
+
+
+class _Singleton(type):
+ """ A metaclass that creates a Singleton base class when called. """
+ _instances: Dict[Any, Any] = {}
+
+ def __call__(cls, *args, **kwargs):
+ if cls not in cls._instances:
+ cls._instances[cls] = super().__call__(*args, **kwargs)
+ return cls._instances[cls]
+
+
+class Singleton(_Singleton('SingletonMeta', (object,), {})): # type: ignore
+ pass
diff --git a/archinstall/lib/utils/util.py b/archinstall/lib/utils/util.py
new file mode 100644
index 00000000..2e42b3cf
--- /dev/null
+++ b/archinstall/lib/utils/util.py
@@ -0,0 +1,51 @@
+from pathlib import Path
+from typing import Any, TYPE_CHECKING, Optional, List
+
+from ..output import FormattedOutput
+from ..output import info
+
+if TYPE_CHECKING:
+ _: Any
+
+
+def prompt_dir(text: str, header: Optional[str] = None) -> Path:
+ if header:
+ print(header)
+
+ while True:
+ path = input(text).strip(' ')
+ dest_path = Path(path)
+ if dest_path.exists() and dest_path.is_dir():
+ return dest_path
+ info(_('Not a valid directory: {}').format(dest_path))
+
+
+def is_subpath(first: Path, second: Path):
+ """
+ Check if _first_ a subpath of _second_
+ """
+ try:
+ first.relative_to(second)
+ return True
+ except ValueError:
+ return False
+
+
+def format_cols(items: List[str], header: Optional[str] = None) -> str:
+ if header:
+ text = f'{header}:\n'
+ else:
+ text = ''
+
+ nr_items = len(items)
+ if nr_items <= 4:
+ col = 1
+ elif nr_items <= 8:
+ col = 2
+ elif nr_items <= 12:
+ col = 3
+ else:
+ col = 4
+
+ text += FormattedOutput.as_columns(items, col)
+ return text