Send patches - preferably formatted by git format-patch - to patches at archlinux32 dot org.
summaryrefslogtreecommitdiff
path: root/archinstall/lib
diff options
context:
space:
mode:
Diffstat (limited to 'archinstall/lib')
-rw-r--r--archinstall/lib/configuration.py69
-rw-r--r--archinstall/lib/disk/__init__.py47
-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.py599
-rw-r--r--archinstall/lib/disk/device_model.py1033
-rw-r--r--archinstall/lib/disk/diskinfo.py40
-rw-r--r--archinstall/lib/disk/dmcryptdev.py48
-rw-r--r--archinstall/lib/disk/encryption_menu.py (renamed from archinstall/lib/disk/encryption.py)91
-rw-r--r--archinstall/lib/disk/fido.py (renamed from archinstall/lib/hsm/fido.py)63
-rw-r--r--archinstall/lib/disk/filesystem.py343
-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.py335
-rw-r--r--archinstall/lib/disk/subvolume_menu.py (renamed from archinstall/lib/user_interaction/subvolume_config.py)43
-rw-r--r--archinstall/lib/disk/user_guides.py240
-rw-r--r--archinstall/lib/disk/validators.py48
-rw-r--r--archinstall/lib/general.py31
-rw-r--r--archinstall/lib/global_menu.py364
-rw-r--r--archinstall/lib/hsm/__init__.py1
-rw-r--r--archinstall/lib/installer.py670
-rw-r--r--archinstall/lib/locale_helpers.py20
-rw-r--r--archinstall/lib/luks.py331
-rw-r--r--archinstall/lib/menu/__init__.py11
-rw-r--r--archinstall/lib/menu/abstract_menu.py200
-rw-r--r--archinstall/lib/menu/global_menu.py429
-rw-r--r--archinstall/lib/menu/list_manager.py46
-rw-r--r--archinstall/lib/menu/menu.py114
-rw-r--r--archinstall/lib/menu/simple_menu.py2002
-rw-r--r--archinstall/lib/menu/table_selection_menu.py68
-rw-r--r--archinstall/lib/mirrors.py4
-rw-r--r--archinstall/lib/models/__init__.py5
-rw-r--r--archinstall/lib/models/bootloader.py40
-rw-r--r--archinstall/lib/models/disk_encryption.py90
-rw-r--r--archinstall/lib/models/gen.py (renamed from archinstall/lib/models/dataclasses.py)48
-rw-r--r--archinstall/lib/models/network_configuration.py2
-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.py33
-rw-r--r--archinstall/lib/output.py47
-rw-r--r--archinstall/lib/packages/__init__.py4
-rw-r--r--archinstall/lib/packages/packages.py4
-rw-r--r--archinstall/lib/pacman.py4
-rw-r--r--archinstall/lib/profile/__init__.py0
-rw-r--r--archinstall/lib/profile/profile_menu.py203
-rw-r--r--archinstall/lib/profile/profile_model.py35
-rw-r--r--archinstall/lib/profile/profiles_handler.py391
-rw-r--r--archinstall/lib/profiles.py340
-rw-r--r--archinstall/lib/storage.py19
-rw-r--r--archinstall/lib/udev/__init__.py1
-rw-r--r--archinstall/lib/udev/udevadm.py17
-rw-r--r--archinstall/lib/user_interaction/__init__.py16
-rw-r--r--archinstall/lib/user_interaction/backwards_compatible_conf.py95
-rw-r--r--archinstall/lib/user_interaction/disk_conf.py403
-rw-r--r--archinstall/lib/user_interaction/general_conf.py59
-rw-r--r--archinstall/lib/user_interaction/locale_conf.py3
-rw-r--r--archinstall/lib/user_interaction/manage_users_conf.py21
-rw-r--r--archinstall/lib/user_interaction/network_conf.py6
-rw-r--r--archinstall/lib/user_interaction/partitioning_conf.py362
-rw-r--r--archinstall/lib/user_interaction/save_conf.py74
-rw-r--r--archinstall/lib/user_interaction/system_conf.py133
-rw-r--r--archinstall/lib/user_interaction/utils.py47
-rw-r--r--archinstall/lib/utils/__init__.py0
-rw-r--r--archinstall/lib/utils/singleton.py15
-rw-r--r--archinstall/lib/utils/util.py30
71 files changed, 4735 insertions, 7586 deletions
diff --git a/archinstall/lib/configuration.py b/archinstall/lib/configuration.py
index c036783f..77fed755 100644
--- a/archinstall/lib/configuration.py
+++ b/archinstall/lib/configuration.py
@@ -2,27 +2,15 @@ import os
import json
import stat
import logging
-import pathlib
-from typing import Optional, Dict
+from pathlib import Path
+from typing import Optional, Dict, Any, TYPE_CHECKING
-from .hsm.fido import Fido2
-from .models.disk_encryption import DiskEncryption
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'."
- )
+
+if TYPE_CHECKING:
+ _: Any
class ConfigurationOutput:
@@ -35,13 +23,11 @@ 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 = Path(storage.get('LOG_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._ignore = ['abort', 'install', 'config', 'creds', 'dry_run']
@@ -56,23 +42,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:
if key in self._sensitive:
self._user_credentials[key] = self._config[key]
- elif key == 'disk_layouts':
- self._disk_layout = self._config[key]
elif key in self._ignore:
pass
else:
self._user_config[key] = self._config[key]
- 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 self._config[key] is not None:
+ self._user_credentials['encryption_password'] = self._config[key].encryption_password
def user_config_to_json(self) -> str:
return json.dumps({
@@ -81,11 +62,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)
@@ -96,15 +72,11 @@ class ConfigurationOutput:
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)
-
print()
- def _is_valid_path(self, dest_path :pathlib.Path) -> bool:
+ def _is_valid_path(self, dest_path: 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()),
@@ -113,7 +85,7 @@ class ConfigurationOutput:
return False
return True
- 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
@@ -122,7 +94,7 @@ class ConfigurationOutput:
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
@@ -132,21 +104,10 @@ class ConfigurationOutput:
os.chmod(str(target), stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP)
- 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)
-
- os.chmod(str(target), stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP)
-
- def save(self, dest_path :pathlib.Path = None):
+ def save(self, dest_path: Optional[Path] = None):
if not dest_path:
dest_path = self._default_save_path
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)
diff --git a/archinstall/lib/disk/__init__.py b/archinstall/lib/disk/__init__.py
index 352d04b9..cdc96373 100644
--- a/archinstall/lib/disk/__init__.py
+++ b/archinstall/lib/disk/__init__.py
@@ -1,7 +1,40 @@
-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,
+ PartitionTable,
+ Unit,
+ Size,
+ 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,
+)
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..12cf18ea
--- /dev/null
+++ b/archinstall/lib/disk/device_handler.py
@@ -0,0 +1,599 @@
+from __future__ import annotations
+
+import json
+import logging
+import os
+import time
+from pathlib import Path
+from typing import List, Dict, Any, Optional, TYPE_CHECKING
+
+from parted import ( # type: ignore
+ Disk, Geometry, FileSystem,
+ PartitionException, DiskLabelException,
+ getAllDevices, freshDisk, Partition,
+)
+
+from .device_model import (
+ DeviceModification, PartitionModification,
+ BDevice, _DeviceInfo, _PartitionInfo,
+ FilesystemType, Unit, PartitionTable,
+ ModificationStatus, get_lsblk_info, LsblkInfo,
+ _BtrfsSubvolumeInfo, get_all_lsblk_info, DiskEncryption
+)
+
+from ..exceptions import DiskError, UnknownFilesystemFormat
+from ..general import SysCommand, SysCallError, JSON
+from ..luks import Luks2
+from ..output import 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 = {}
+
+ for device in getAllDevices():
+ try:
+ disk = Disk(device)
+ except DiskLabelException as error:
+ if 'unrecognised disk label' in getattr(error, 'message', str(error)):
+ disk = freshDisk(device, PartitionTable.GPT.value)
+ else:
+ log(f'Unable to get disk from device: {device}', level=logging.DEBUG)
+ 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.partuuid,
+ 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:
+ log(f'Could not determine the filesystem: {partition.fileSystem}', level=logging.DEBUG)
+
+ 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:
+ return partition.disk.device
+ 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_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}')
+ except SysCallError as err:
+ log(f'Failed to read btrfs subvolume information: {err}', level=logging.DEBUG)
+ return subvol_infos
+
+ if result.exit_code == 0:
+ try:
+ if decoded := result.decode('utf-8'):
+ # ID 256 gen 16 top level 5 path @
+ for line in decoded.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:
+ log(f"Could not decode lsblk JSON: {result}", fg="red", level=logging.ERROR)
+ raise err
+
+ if not lsblk_info.mountpoint:
+ self.umount(dev_path)
+
+ return subvol_infos
+
+ def _perform_formatting(
+ 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)
+
+ log(f'Formatting filesystem: /usr/bin/{command} {options_str} {path}')
+
+ try:
+ if (handle := SysCommand(f"/usr/bin/{command} {options_str} {path}")).exit_code != 0:
+ mkfs_error = handle.decode()
+ raise DiskError(f'Could not format {path} with {fs_type.value}: {mkfs_error}')
+ except SysCallError as error:
+ msg = f'Could not format {path} with {fs_type.value}: {error.message}'
+ log(msg, fg='red')
+ raise DiskError(msg) from error
+
+ def _perform_enc_formatting(
+ 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()
+
+ log(f'Unlocking luks2 device: {dev_path}', level=logging.DEBUG)
+ luks_handler.unlock(key_file=key_file)
+
+ if not luks_handler.mapper_dev:
+ raise DiskError('Failed to unlock luks device')
+
+ log(f'luks2 formatting mapper dev: {luks_handler.mapper_dev}', level=logging.INFO)
+ self._perform_formatting(fs_type, luks_handler.mapper_dev)
+
+ log(f'luks2 locking device: {dev_path}', level=logging.INFO)
+ luks_handler.lock()
+
+ def format(
+ self,
+ modification: DeviceModification,
+ enc_conf: Optional['DiskEncryption'] = None
+ ):
+ """
+ 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.
+ """
+
+ # verify that all partitions have a path set (which implies that they have been created)
+ missing_path = next(filter(lambda x: x.dev_path is None, modification.partitions), None)
+ if missing_path is not None:
+ raise ValueError('When formatting, all partitions must have a path set')
+
+ # crypto luks is not known to parted and can therefore not
+ # be used as a filesystem type in that sense;
+ invalid_fs_type = next(filter(lambda x: x.fs_type is FilesystemType.Crypto_luks, modification.partitions), None)
+ if invalid_fs_type is not None:
+ raise ValueError('Crypto luks cannot be set as a filesystem type')
+
+ # make sure all devices are unmounted
+ self._umount_all_existing(modification)
+
+ for part_mod in modification.partitions:
+ # partition will be encrypted
+ if enc_conf is not None and part_mod in enc_conf.partitions:
+ self._perform_enc_formatting(
+ part_mod.real_dev_path,
+ part_mod.mapper_name,
+ part_mod.fs_type,
+ enc_conf
+ )
+ else:
+ self._perform_formatting(part_mod.fs_type, part_mod.real_dev_path)
+
+ def _perform_partitioning(
+ 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]:
+ log(f'Delete existing partition: {part_mod.real_dev_path}', level=logging.INFO)
+ part_info = self.find_partition(part_mod.real_dev_path)
+
+ if not part_info:
+ raise DiskError(f'No partition for dev path found: {part_mod.real_dev_path}')
+
+ disk.deletePartition(part_info.partition)
+ disk.commit()
+
+ 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.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)
+
+ log(f'\tType: {part_mod.type.value}', level=logging.DEBUG)
+ log(f'\tFilesystem: {part_mod.fs_type.value}', level=logging.DEBUG)
+ log(f'\tGeometry: {start_sector.value} start sector, {length_sector.value} length', level=logging.DEBUG)
+
+ try:
+ disk.addPartition(partition=partition, constraint=disk.device.optimalAlignedConstraint)
+ disk.commit()
+
+ # the creation will take a bit of time
+ time.sleep(3)
+
+ # the partition has a real path now as it was created
+ part_mod.dev_path = Path(partition.path)
+
+ info = get_lsblk_info(part_mod.dev_path)
+
+ if not info.partuuid:
+ raise DiskError(f'Unable to determine new partition uuid: {part_mod.dev_path}')
+
+ part_mod.partuuid = info.partuuid
+ part_mod.uuid = info.uuid
+ except PartitionException as ex:
+ raise DiskError(f'Unable to add partition, most likely due to overlapping sectors: {ex}') from ex
+
+ def create_btrfs_volumes(
+ self,
+ part_mod: PartitionModification,
+ enc_conf: Optional['DiskEncryption'] = None
+ ):
+ log(f'Creating subvolumes: {part_mod.real_dev_path}', level=logging.INFO)
+
+ 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.real_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)
+ else:
+ self.mount(part_mod.real_dev_path, self._TMP_BTRFS_MOUNT, create_target_mountpoint=True)
+
+ for sub_vol in part_mod.btrfs_subvols:
+ log(f'Creating subvolume: {sub_vol.name}', level=logging.DEBUG)
+
+ 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 sub_vol.nodatacow:
+ if (result := SysCommand(f'chattr +C {subvol_path}')).exit_code != 0:
+ raise DiskError(f'Could not set nodatacow attribute at {subvol_path}: {result.decode()}')
+
+ if sub_vol.compress:
+ if (result := SysCommand(f'chattr +c {subvol_path}')).exit_code != 0:
+ raise DiskError(f'Could not set compress attribute at {subvol_path}: {result}')
+
+ 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.real_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, modification: DeviceModification):
+ log(f'Unmounting all partitions: {modification.device_path}', level=logging.INFO)
+
+ existing_partitions = self._devices[modification.device_path].partition_infos
+
+ for partition in existing_partitions:
+ log(f'Unmounting: {partition.path}', level=logging.DEBUG)
+
+ # 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)
+
+ # 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:
+ log(f'Use existing device: {modification.device_path}')
+ disk = modification.device.disk
+
+ log(f'Creating partitions: {modification.device_path}')
+
+ # TODO sort by delete first
+
+ for part_mod in modification.partitions:
+ # don't touch existing partitions
+ if part_mod.exists():
+ continue
+
+ # 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._perform_partitioning(part_mod, modification.device, disk, requires_delete=requires_delete)
+
+ self.partprobe(modification.device.device_info.path)
+
+ 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:
+ log(f'Device already mounted at {target_mountpoint}')
+ return
+
+ str_options = ','.join(options)
+ str_options = f'-o {str_options}' if str_options else ''
+
+ mount_fs = f'-t {mount_fs}' if mount_fs else ''
+
+ command = f'mount {mount_fs} {str_options} {dev_path} {target_mountpoint}'
+
+ log(f'Mounting {dev_path}: command', level=logging.DEBUG)
+
+ try:
+ result = SysCommand(command)
+ if result.exit_code != 0:
+ raise DiskError(f'Could not mount {dev_path}: {command}\n{result.decode()}')
+ 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:
+ log(f'Partition {mountpoint} is currently mounted at: {[str(m) for m in lsblk_info.mountpoints]}', level=logging.DEBUG)
+
+ for mountpoint in lsblk_info.mountpoints:
+ log(f'Unmounting mountpoint: {mountpoint}', level=logging.DEBUG)
+
+ 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_mods[path].append(PartitionModification.from_existing_partition(part_info))
+ 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:
+ result = SysCommand(command)
+ if result.exit_code != 0:
+ log(f'Error calling partprobe: {result.decode()}', level=logging.DEBUG)
+ raise DiskError(f'Could not perform partprobe on {path}: {result.decode()}')
+ except SysCallError as error:
+ log(f"partprobe experienced an error with {path}: {error}", level=logging.DEBUG)
+
+ 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.
+ """
+ log(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:
+ log(f"Could not return disk layouts: {err}", level=logging.WARNING, fg="yellow")
+ return ''
+ except json.decoder.JSONDecodeError as err:
+ log(f"Could not return disk layouts: {err}", level=logging.WARNING, fg="yellow")
+ return ''
diff --git a/archinstall/lib/disk/device_model.py b/archinstall/lib/disk/device_model.py
new file mode 100644
index 00000000..0270a4dd
--- /dev/null
+++ b/archinstall/lib/disk/device_model.py
@@ -0,0 +1,1033 @@
+from __future__ import annotations
+
+import dataclasses
+import json
+import logging
+import math
+import time
+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
+from parted import Disk, Geometry, Partition
+
+from ..exceptions import DiskError, SysCallError
+from ..general import SysCommand
+from ..output import log
+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)
+ # used for pre-mounted config
+ relative_mountpoint: Optional[Path] = None
+
+ def __post_init__(self):
+ if self.config_type == DiskLayoutType.Pre_mount and self.relative_mountpoint is None:
+ raise ValueError('Must set a relative mountpoint when layout type is pre-mount"')
+
+ def __dump__(self) -> Dict[str, Any]:
+ return {
+ 'config_type': self.config_type.value,
+ 'device_modifications': [mod.__dump__() for mod in self.device_modifications]
+ }
+
+ @classmethod
+ def parse_arg(cls, disk_config: Dict[str, List[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
+ )
+
+ 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']),
+ start=Size.parse_args(partition['start']),
+ length=Size.parse_args(partition['length']),
+ mount_options=partition['mount_options'],
+ mountpoint=Path(partition['mountpoint']) if partition['mountpoint'] 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)
+
+ 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
+
+ Percent = '%' # size in percentile
+
+
+@dataclass
+class Size:
+ value: int
+ unit: Unit
+ sector_size: Optional[Size] = None # only required when unit is sector
+ total_size: Optional[Size] = None # required when operating on percentages
+
+ def __post_init__(self):
+ if self.unit == Unit.sectors and self.sector_size is None:
+ raise ValueError('Sector size is required when unit is sectors')
+ elif self.unit == Unit.Percent:
+ if self.value < 0 or self.value > 100:
+ raise ValueError('Percentage must be between 0 and 100')
+ elif self.total_size is None:
+ raise ValueError('Total size is required when unit is percentage')
+
+ @property
+ def _total_size(self) -> Size:
+ """
+ Save method to get the total size, mainly to satisfy mypy
+ This shouldn't happen as the Size object fails instantiation on missing total size
+ """
+ if self.unit == Unit.Percent and self.total_size is None:
+ raise ValueError('Percent unit size must specify a total size')
+ return self.total_size # type: ignore
+
+ def __dump__(self) -> Dict[str, Any]:
+ return {
+ 'value': self.value,
+ 'unit': self.unit.name,
+ 'sector_size': self.sector_size.__dump__() if self.sector_size else None,
+ 'total_size': self._total_size.__dump__() if self._total_size else None
+ }
+
+ @classmethod
+ def parse_args(cls, size_arg: Dict[str, Any]) -> Size:
+ sector_size = size_arg['sector_size']
+ total_size = size_arg['total_size']
+
+ return Size(
+ size_arg['value'],
+ Unit[size_arg['unit']],
+ Size.parse_args(sector_size) if sector_size else None,
+ Size.parse_args(total_size) if total_size else None
+ )
+
+ def convert(
+ self,
+ target_unit: Unit,
+ sector_size: Optional[Size] = None,
+ total_size: Optional[Size] = 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')
+
+ # not sure why we would ever wanna convert to percentages
+ if target_unit == Unit.Percent and total_size is None:
+ raise ValueError('Missing paramter total size to be able to convert to percentage')
+
+ if self.unit == target_unit:
+ return self
+ elif self.unit == Unit.Percent:
+ amount = int(self._total_size._normalize() * (self.value / 100))
+ return Size(amount, Unit.B)
+ elif self.unit == Unit.sectors:
+ norm = self._normalize()
+ return Size(norm, Unit.B).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)
+
+ def format_size(
+ self,
+ target_unit: Unit,
+ sector_size: Optional[Size] = None
+ ) -> str:
+ if self.unit == Unit.Percent:
+ return f'{self.value}%'
+ else:
+ target_size = self.convert(target_unit, sector_size)
+ return f'{target_size.value} {target_unit.name}'
+
+ def _normalize(self) -> int:
+ """
+ will normalize the value of the unit to Byte
+ """
+ if self.unit == Unit.Percent:
+ return self.convert(Unit.B).value
+ elif 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)
+
+ 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()
+
+
+@dataclass
+class _BtrfsSubvolumeInfo:
+ name: Path
+ mountpoint: Optional[Path]
+
+
+@dataclass
+class _PartitionInfo:
+ partition: Partition
+ name: str
+ type: PartitionType
+ fs_type: FilesystemType
+ path: Path
+ start: Size
+ length: Size
+ flags: List[PartitionFlag]
+ partuuid: str
+ disk: Disk
+ mountpoints: List[Path]
+ btrfs_subvol_infos: List[_BtrfsSubvolumeInfo] = field(default_factory=list)
+
+ def as_json(self) -> Dict[str, Any]:
+ 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.MiB),
+ 'Length': self.length.format_size(Unit.MiB),
+ 'Flags': ', '.join([f.name for f in self.flags])
+ }
+
+ if self.btrfs_subvol_infos:
+ info['Btrfs vol.'] = f'{len(self.btrfs_subvol_infos)} subvolumes'
+
+ return info
+
+ @classmethod
+ def from_partition(
+ cls,
+ partition: Partition,
+ fs_type: FilesystemType,
+ partuuid: 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,
+ Size(partition.disk.device.sectorSize, Unit.B)
+ )
+
+ length = Size(int(partition.getLength(unit='B')), 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,
+ partuuid=partuuid,
+ 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: Size
+ read_only: bool
+ dirty: bool
+
+ def as_json(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_size(Unit.MiB),
+ '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
+ device_type = parted.devices[device.type]
+
+ sector_size = Size(device.sectorSize, Unit.B)
+ free_space = [DeviceGeometry(g, sector_size) for g in disk.getFreeSpaceRegions()]
+
+ 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),
+ free_space_regions=free_space,
+ read_only=device.readOnly,
+ dirty=device.dirty
+ )
+
+
+@dataclass
+class SubvolumeModification:
+ name: Path
+ mountpoint: Optional[Path] = None
+ compress: bool = False
+ nodatacow: bool = False
+
+ @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):
+ log(f'Subvolume arg is missing name: {entry}', level=logging.DEBUG)
+ continue
+
+ mountpoint = Path(entry['mountpoint']) if entry['mountpoint'] else None
+
+ mods.append(
+ SubvolumeModification(
+ entry['name'],
+ mountpoint,
+ entry.get('compress', False),
+ entry.get('nodatacow', False)
+ )
+ )
+
+ return mods
+
+ @property
+ def mount_options(self) -> List[str]:
+ options = []
+ options += ['compress'] if self.compress else []
+ options += ['nodatacow'] if self.nodatacow else []
+ return options
+
+ @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, relative_mountpoint: Optional[Path] = None) -> bool:
+ if self.mountpoint:
+ if relative_mountpoint is not None:
+ return self.mountpoint.relative_to(relative_mountpoint) == Path('.')
+ return self.mountpoint == Path('/')
+ return False
+
+ def __dump__(self) -> Dict[str, Any]:
+ return {
+ 'name': str(self.name),
+ 'mountpoint': str(self.mountpoint),
+ 'compress': self.compress,
+ 'nodatacow': self.nodatacow
+ }
+
+ def as_json(self) -> Dict[str, Any]:
+ return {
+ 'name': str(self.name),
+ 'mountpoint': str(self.mountpoint),
+ 'compress': self.compress,
+ 'nodatacow': self.nodatacow
+ }
+
+
+class DeviceGeometry:
+ def __init__(self, geometry: Geometry, sector_size: Size):
+ 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 as_json(self) -> Dict[str, Any]:
+ return {
+ 'Sector size': self._sector_size.value,
+ 'Start sector': self._geometry.start,
+ 'End sector': self._geometry.end,
+ 'Length': self._geometry.getLength()
+ }
+
+
+@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'
+
+ @classmethod
+ def get_type_from_code(cls, code: int) -> PartitionType:
+ if code == parted.PARTITION_NORMAL:
+ return PartitionType.Primary
+
+ raise DiskError(f'Partition code not supported: {code}')
+
+ 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):
+ Boot = 1
+
+
+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: FilesystemType
+ 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
+ partuuid: Optional[str] = None
+ uuid: Optional[str] = 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()
+
+ if self.is_exists_or_modify() and not self.dev_path:
+ raise ValueError('If partition marked as existing a path must be set')
+
+ 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 real_dev_path(self) -> Path:
+ if self.dev_path is None:
+ raise ValueError('Device path was not set')
+ return self.dev_path
+
+ @classmethod
+ def from_existing_partition(cls, partition_info: _PartitionInfo) -> PartitionModification:
+ if partition_info.btrfs_subvol_infos:
+ mountpoint = None
+ subvol_mods = []
+ for info in partition_info.btrfs_subvol_infos:
+ subvol_mods.append(
+ SubvolumeModification.from_existing_subvol_info(info)
+ )
+ 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,
+ 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_boot(self) -> bool:
+ return PartitionFlag.Boot in self.flags
+
+ def is_root(self, relative_mountpoint: Optional[Path] = None) -> bool:
+ if relative_mountpoint is not None and self.mountpoint is not None:
+ return self.mountpoint.relative_to(relative_mountpoint) == Path('.')
+ elif self.mountpoint is not None:
+ return Path('/') == self.mountpoint
+ else:
+ for subvol in self.btrfs_subvols:
+ if subvol.is_root(relative_mountpoint):
+ 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]
+
+ @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.__dump__(),
+ 'length': self.length.__dump__(),
+ 'fs_type': self.fs_type.value,
+ 'mountpoint': str(self.mountpoint) if self.mountpoint else None,
+ 'mount_options': self.mount_options,
+ 'flags': [f.name for f in self.flags],
+ 'btrfs': [vol.__dump__() for vol in self.btrfs_subvols]
+ }
+
+ def as_json(self) -> Dict[str, Any]:
+ """
+ Called for displaying data in table format
+ """
+ info = {
+ 'Status': self.status.value,
+ 'Device': str(self.dev_path) if self.dev_path else '',
+ 'Type': self.type.value,
+ 'Start': self.start.format_size(Unit.MiB),
+ 'Length': self.length.format_size(Unit.MiB),
+ 'FS type': self.fs_type.value,
+ '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:
+ info['Btrfs vol.'] = f'{len(self.btrfs_subvols)} subvolumes'
+
+ return info
+
+
+@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_boot_partition(self) -> Optional[PartitionModification]:
+ liltered = filter(lambda x: x.is_boot(), self.partitions)
+ return next(liltered, None)
+
+ def get_root_partition(self, relative_path: Optional[Path]) -> Optional[PartitionModification]:
+ filtered = filter(lambda x: x.is_root(relative_path), self.partitions)
+ return next(filtered, None)
+
+ def __dump__(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"
+ 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: List[PartitionModification] = field(default_factory=list)
+ hsm_device: Optional[Fido2Device] = None
+
+ def should_generate_encryption_file(self, part_mod: PartitionModification) -> bool:
+ return part_mod in self.partitions and part_mod.mountpoint != Path('/')
+
+ 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]
+ }
+
+ if self.hsm_device:
+ obj['hsm_device'] = self.hsm_device.json()
+
+ return obj
+
+ @classmethod
+ def parse_arg(
+ cls,
+ disk_config: DiskLayoutConfiguration,
+ arg: Dict[str, Any],
+ password: str = ''
+ ) -> 'DiskEncryption':
+ 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)
+
+ 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
+
+
+@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']
+ )
+
+
+@dataclass
+class LsblkInfo:
+ name: str = ''
+ path: Path = Path()
+ pkname: str = ''
+ size: Size = Size(0, Unit.B)
+ log_sec: int = 0
+ pttype: str = ''
+ ptuuid: str = ''
+ rota: bool = False
+ tran: Optional[str] = None
+ partuuid: 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,
+ 'partuuid': self.partuuid,
+ '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:
+ 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(info, data_field), Path):
+ val = Path(blockdevice[lsblk_field])
+ elif isinstance(getattr(info, data_field), Size):
+ val = Size(blockdevice[lsblk_field], Unit.B)
+ else:
+ val = blockdevice[lsblk_field]
+
+ setattr(info, data_field, val)
+
+ info.children = [LsblkInfo.from_json(child) for child in blockdevice.get('children', [])]
+
+ # sometimes lsblk returns 'mountpoints': [null]
+ info.mountpoints = [Path(mnt) for mnt in info.mountpoints if mnt]
+
+ fs_roots = []
+ for r in info.fsroots:
+ if r:
+ path = Path(r)
+ # store the fsroot entries without the leading /
+ fs_roots.append(path.relative_to(path.anchor))
+ info.fsroots = fs_roots
+
+ return 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, retry: int = 3) -> List[LsblkInfo]:
+ fields = [_clean_field(f, CleanType.Lsblk) for f in LsblkInfo.fields()]
+ lsblk_fields = ','.join(fields)
+
+ if not dev_path:
+ dev_path = ''
+
+ if retry == 0:
+ retry = 1
+
+ result = None
+
+ for i in range(retry):
+ try:
+ result = SysCommand(f'lsblk --json -b -o+{lsblk_fields} {dev_path}')
+ except SysCallError as error:
+ # Get the output minus the message/info from lsblk if it returns a non-zero exit code.
+ if error.worker:
+ err = error.worker.decode('UTF-8')
+ log(f'Error calling lsblk: {err}', level=logging.DEBUG)
+ time.sleep(1)
+ else:
+ raise error
+
+ if result and result.exit_code == 0:
+ try:
+ if decoded := result.decode('utf-8'):
+ block_devices = json.loads(decoded)
+ blockdevices = block_devices['blockdevices']
+ return [LsblkInfo.from_json(device) for device in blockdevices]
+ except json.decoder.JSONDecodeError as err:
+ log(f"Could not decode lsblk JSON: {result}", fg="red", level=logging.ERROR)
+ raise err
+
+ raise DiskError(f'Failed to read disk "{dev_path}" with lsblk')
+
+
+def get_lsblk_info(dev_path: Union[Path, str]) -> LsblkInfo:
+ if infos := _fetch_lsblk_info(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/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_menu.py
index c7496bfa..285270fb 100644
--- a/archinstall/lib/disk/encryption.py
+++ b/archinstall/lib/disk/encryption_menu.py
@@ -1,30 +1,44 @@
+from pathlib import Path
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 ..disk import (
+ DeviceModification,
+ PartitionModification,
+ DiskEncryption,
+ EncryptionType
+)
+from ..menu import (
+ Selector,
+ AbstractSubMenu,
+ MenuSelectionType,
+ TableMenu
+)
from ..user_interaction.utils import get_password
from ..menu import Menu
from ..general import secret
-from ..hsm.fido import Fido2Device, Fido2
+from .fido import Fido2Device, Fido2
+from ..output import FormattedOutput
if TYPE_CHECKING:
_: Any
class DiskEncryptionMenu(AbstractSubMenu):
- def __init__(self, data_store: Dict[str, Any], preset: Optional[DiskEncryption], disk_layouts: Dict[str, Any]):
+ def __init__(
+ self,
+ mods: List[DeviceModification],
+ data_store: Dict[str, Any],
+ preset: Optional[DiskEncryption] = None
+ ):
if preset:
self._preset = preset
else:
self._preset = DiskEncryption()
- self._disk_layouts = disk_layouts
+ self._modifications = mods
super().__init__(data_store=data_store)
- def _setup_selection_menu_options(self):
+ def setup_selection_menu_options(self):
self._menu_options['encryption_password'] = \
Selector(
_('Encryption password'),
@@ -45,8 +59,8 @@ class DiskEncryptionMenu(AbstractSubMenu):
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,
+ func=lambda preset: select_partitions_to_encrypt(self._modifications.device_modifications, preset),
+ display_func=lambda x: f'{len(x)} {_("Partitions")}' if x else None,
dependencies=['encryption_password'],
default=self._preset.partitions,
preview_func=self._prev_disk_layouts,
@@ -84,24 +98,18 @@ class DiskEncryptionMenu(AbstractSubMenu):
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
-
+ partitions: Optional[List[PartitionModification]] = self._menu_options['partitions'].current_selection
+ if partitions:
output = str(_('Partitions to be encrypted')) + '\n'
- output += current_partition_layout(all_partitions, with_title=False)
+ output += FormattedOutput.as_table(partitions)
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)
]
@@ -137,38 +145,35 @@ def select_hsm(preset: Optional[Fido2Device] = None) -> Optional[Fido2Device]:
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
+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))
- if all_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 = current_partition_layout(all_partitions, with_title=False).strip()
+ partition_table = FormattedOutput.as_table(avail_partitions)
choice = TableMenu(
title,
- table_data=(all_partitions, partition_table),
+ table_data=(avail_partitions, partition_table),
+ preset=preset,
multi=True
).run()
match choice.type_:
case MenuSelectionType.Reset:
- return {}
+ 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 {}
+ return choice.multi_value
+ return []
diff --git a/archinstall/lib/hsm/fido.py b/archinstall/lib/disk/fido.py
index 1c226322..436be4d4 100644
--- a/archinstall/lib/hsm/fido.py
+++ b/archinstall/lib/disk/fido.py
@@ -2,36 +2,11 @@ from __future__ import annotations
import getpass
import logging
+from typing import List
-from dataclasses import dataclass
-from pathlib import Path
-from typing import List, Dict
-
+from .device_model import PartitionModification, 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 log
class Fido2:
@@ -92,18 +67,28 @@ 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,
+ part_mod: PartitionModification,
+ password: str
+ ):
+ worker = SysCommandWorker(f"systemd-cryptenroll --fido2-device={hsm_device.path} {part_mod.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 {part_mod.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
+
+ 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"
+ )
diff --git a/archinstall/lib/disk/filesystem.py b/archinstall/lib/disk/filesystem.py
index 1083df53..6ea99340 100644
--- a/archinstall/lib/disk/filesystem.py
+++ b/archinstall/lib/disk/filesystem.py
@@ -1,301 +1,98 @@
from __future__ import annotations
-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
-if TYPE_CHECKING:
- from .blockdevice import BlockDevice
- _: Any
+import logging
+import signal
+import sys
+import time
+from typing import Any, Optional, TYPE_CHECKING
-from .partition import Partition
-from .validators import valid_fs_type
-from ..exceptions import DiskError, SysCallError
-from ..general import SysCommand
+from .device_model import DiskLayoutConfiguration, DiskLayoutType, PartitionTable, FilesystemType, DiskEncryption
+from .device_handler import device_handler
+from ..hardware import has_uefi
from ..output import log
-from ..storage import storage
-
-GPT = 0b00000001
-MBR = 0b00000010
-
-# 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'
-
-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 __enter__(self, *args :str, **kwargs :str) -> 'Filesystem':
- return self
-
- def __repr__(self) -> str:
- return f"Filesystem(blockdevice={self.blockdevice}, mode={self.mode})"
+from ..menu import Menu
- 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]
-
- SysCommand('sync')
- return True
-
- 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'))
+if TYPE_CHECKING:
+ _: Any
- 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}")
+class FilesystemHandler:
+ def __init__(
+ self,
+ disk_config: DiskLayoutConfiguration,
+ enc_conf: Optional[DiskEncryption] = None
+ ):
+ self._disk_config = disk_config
+ self._enc_config = enc_conf
- def load_layout(self, layout :Dict[str, Any]) -> None:
- from ..luks import luks2
- from .btrfs import BTRFSPartition
+ def perform_filesystem_operations(self, show_countdown: bool = True):
+ if self._disk_config.config_type == DiskLayoutType.Pre_mount:
+ log('Disk layout configuration is set to pre-mount, not performing any operations', level=logging.DEBUG)
+ return
- # 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}")
+ device_mods = list(filter(lambda x: len(x.partitions) > 0, self._disk_config.device_modifications))
- self.blockdevice.flush_cache()
- time.sleep(3)
+ if not device_mods:
+ log('No modifications required', level=logging.DEBUG)
+ return
- 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)
+ device_paths = ', '.join([str(mod.device.device_info.path) for mod in device_mods])
- 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)
+ # 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))
- log(_("Re-using partition instance: {}").format(partition['device_instance']), level=logging.DEBUG, fg="gray")
- 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 show_countdown:
+ self._do_countdown()
- 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')
+ # Setup the blockdevice, filesystem (and optionally encryption).
+ # Once that's done, we'll hand over to perform_installation()
+ partition_table = PartitionTable.GPT
+ if has_uefi() is False:
+ partition_table = PartitionTable.MBR
- 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!")
+ for mod in device_mods:
+ device_handler.partition(mod, partition_table=partition_table)
+ device_handler.format(mod, enc_conf=self._enc_config)
- 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}"
+ 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)
- 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'] = {}
+ def _do_countdown(self) -> bool:
+ SIG_TRIGGER = False
- 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
+ def kill_handler(sig: int, frame: Any) -> None:
+ print()
+ exit(0)
- unlocked_device.format(partition['filesystem']['format'], options=format_options)
+ def sig_handler(sig: int, frame: Any) -> None:
+ signal.signal(signal.SIGINT, kill_handler)
- 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!")
+ original_sigint_handler = signal.getsignal(signal.SIGINT)
+ signal.signal(signal.SIGINT, sig_handler)
- partition['device_instance'].format(partition['filesystem']['format'], options=format_options)
+ for i in range(5, 0, -1):
+ print(f"{i}", end='')
- 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
- )
+ for x in range(4):
+ sys.stdout.flush()
+ time.sleep(0.25)
+ print(".", end='')
- 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')
+ 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)
- prev_partition = partition
+ if SIG_TRIGGER is False:
+ sys.stdin.read()
- def find_partition(self, mountpoint :str) -> Partition:
- for partition in self.blockdevice:
- if partition.target_mountpoint == mountpoint or partition.mountpoint == mountpoint:
- return partition
+ SIG_TRIGGER = False
+ signal.signal(signal.SIGINT, sig_handler)
- 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}")
+ print()
+ signal.signal(signal.SIGINT, original_sigint_handler)
return True
-
- 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
-
- def parted(self, string: str) -> bool:
- """
- Performs a parted execution of the given string
-
- :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}")
-
- 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.")
-
- def add_partition(
- 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")
-
- total_partitions = set([partition.part_uuid for partition in self.blockdevice.partitions.values()])
- total_partitions.update(previous_partuuids)
-
- # 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")
-
- raise DiskError(f"Could not add partition using: {parted_string}")
-
- def set_name(self, partition: int, name: str) -> bool:
- return self.parted(f'{self.blockdevice.device} name {partition + 1} "{name}"') == 0
-
- 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 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
-
- self.partprobe()
- worked = self.raw_parted(f'{device} mklabel {disk_label}').exit_code == 0
- self.partprobe()
-
- return worked
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..686e8c29
--- /dev/null
+++ b/archinstall/lib/disk/partitioning_menu.py
@@ -0,0 +1,335 @@
+from __future__ import annotations
+
+import logging
+from pathlib import Path
+from typing import Any, Dict, TYPE_CHECKING, List, Optional, Tuple
+
+from .device_model import PartitionModification, FilesystemType, BDevice, Size, Unit, PartitionType, PartitionFlag, \
+ ModificationStatus
+from ..menu import Menu, ListManager, MenuSelection, TextInput
+from ..output import FormattedOutput, log
+from .subvolume_menu import SubvolumeMenu
+
+if TYPE_CHECKING:
+ _: Any
+
+
+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_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 reformat(self, data: List[PartitionModification]) -> Dict[str, Optional[PartitionModification]]:
+ 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[PartitionModification]] = {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, 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 these 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['assign_mountpoint'],
+ self._actions['mark_bootable'],
+ self._actions['btrfs_mark_compressed'],
+ 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_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)
+ case 'mark_formatting' if entry:
+ self._prompt_formatting(entry)
+ case 'mark_bootable' if entry:
+ entry.invert_flag(PartitionFlag.Boot)
+ 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._set_compressed(entry)
+ 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 _set_compressed(self, partition: PartitionModification):
+ compression = 'compress=zstd'
+
+ if compression in partition.mount_options:
+ partition.mount_options = [o for o in partition.mount_options if o != compression]
+ else:
+ partition.mount_options.append(compression)
+
+ 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_sector(self, start_sector: str, end_sector: Optional[str] = None) -> bool:
+ if not start_sector.isdigit():
+ return False
+
+ if end_sector:
+ if end_sector.endswith('%'):
+ if not end_sector[:-1].isdigit():
+ return False
+ elif not end_sector.isdigit():
+ return False
+ elif int(start_sector) > int(end_sector):
+ return False
+
+ return True
+
+ def _prompt_sectors(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)
+ prompt += str(_('Total sectors: {}')).format(total_sectors) + '\n'
+ print(prompt)
+
+ largest_free_area = max(device_info.free_space_regions, key=lambda r: r.get_length())
+
+ # prompt until a valid start sector was entered
+ while True:
+ start_prompt = str(_('Enter the start sector (default: {}): ')).format(largest_free_area.start)
+ start_sector = TextInput(start_prompt).run().strip()
+
+ if not start_sector or self._validate_sector(start_sector):
+ break
+
+ log(f'Invalid start sector entered: {start_sector}', fg='red', level=logging.INFO)
+
+ if not start_sector:
+ start_sector = str(largest_free_area.start)
+ end_sector = str(largest_free_area.end)
+ else:
+ end_sector = '100%'
+
+ # prompt until valid end sector was entered
+ while True:
+ end_prompt = str(_('Enter the end sector of the partition (percentage or block number, default: {}): ')).format(end_sector)
+ end_value = TextInput(end_prompt).run().strip()
+
+ if not end_value or self._validate_sector(start_sector, end_value):
+ break
+
+ log(f'Invalid end sector entered: {start_sector}', fg='red', level=logging.INFO)
+
+ # override the default value with the user value
+ if end_value:
+ end_sector = end_value
+
+ start_size = Size(int(start_sector), Unit.sectors, device_info.sector_size)
+
+ if end_sector.endswith('%'):
+ end_size = Size(int(end_sector[:-1]), Unit.Percent, device_info.sector_size, device_info.total_size)
+ else:
+ end_size = Size(int(end_sector), Unit.sectors, device_info.sector_size)
+
+ return start_size, end_size
+
+ def _create_new_partition(self) -> PartitionModification:
+ fs_type = self._prompt_partition_fs_type()
+
+ start_size, end_size = self._prompt_sectors()
+ 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)
+
+ 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 ..user_interaction.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 = menu_list.run()
+
+ if menu_list.is_last_choice_cancel():
+ return preset
+
+ return partitions
diff --git a/archinstall/lib/user_interaction/subvolume_config.py b/archinstall/lib/disk/subvolume_menu.py
index 94150dee..32a0e616 100644
--- a/archinstall/lib/user_interaction/subvolume_config.py
+++ b/archinstall/lib/disk/subvolume_menu.py
@@ -1,33 +1,31 @@
+from pathlib import Path
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
+from .device_model import SubvolumeModification
+from ..menu import Menu, TextInput, MenuSelectionType, ListManager
+from ..output import FormattedOutput
if TYPE_CHECKING:
_: Any
-class SubvolumeList(ListManager):
- def __init__(self, prompt: str, subvolumes: List[Subvolume]):
+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, subvolumes, [self._actions[0]], self._actions[1:])
+ super().__init__(prompt, btrfs_subvols, [self._actions[0]], self._actions[1:])
- def reformat(self, data: List[Subvolume]) -> Dict[str, Optional[Subvolume]]:
+ def reformat(self, data: List[SubvolumeModification]) -> Dict[str, Optional[SubvolumeModification]]:
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}
+ display_data: Dict[str, Optional[SubvolumeModification]] = {f' {rows[0]}': None, f' {rows[1]}': None}
for row, subvol in zip(rows[2:], data):
row = row.replace('|', '\\|')
@@ -35,17 +33,17 @@ class SubvolumeList(ListManager):
return display_data
- def selected_action_display(self, subvolume: Subvolume) -> str:
- return subvolume.name
+ def selected_action_display(self, subvolume: SubvolumeModification) -> str:
+ return str(subvolume.name)
- def _prompt_options(self, editing: Optional[Subvolume] = None) -> List[str]:
+ def _prompt_options(self, editing: Optional[SubvolumeModification] = None) -> List[str]:
preset_options = []
if editing:
- preset_options = editing.options
+ preset_options = editing.mount_options
choice = Menu(
str(_("Select the desired subvolume options ")),
- ['nodatacow','compress'],
+ ['nodatacow', 'compress'],
skip=True,
preset_values=preset_options,
multi=True
@@ -56,26 +54,31 @@ class SubvolumeList(ListManager):
return []
- def _add_subvolume(self, editing: Optional[Subvolume] = None) -> Optional[Subvolume]:
+ 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'\n{_("Subvolume mountpoint")}: ', editing.mountpoint if editing else '').run()
+ mountpoint = TextInput(f'{_("Subvolume mountpoint")}: ', str(editing.mountpoint) if editing else '').run()
if not mountpoint:
return None
options = self._prompt_options(editing)
- subvolume = Subvolume(name, mountpoint)
+ subvolume = SubvolumeModification(Path(name), Path(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]:
+ 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()
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/general.py b/archinstall/lib/general.py
index 79ab024b..57f13288 100644
--- a/archinstall/lib/general.py
+++ b/archinstall/lib/general.py
@@ -1,4 +1,5 @@
from __future__ import annotations
+
import hashlib
import json
import logging
@@ -17,7 +18,7 @@ import urllib.error
import pathlib
from datetime import datetime, date
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
@@ -140,7 +141,7 @@ class JsonEncoder:
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)):
+ elif isinstance(obj, pathlib.Path):
return str(obj)
else:
return obj
@@ -184,22 +185,21 @@ class UNSAFE_JSON(json.JSONEncoder, json.JSONDecoder):
def encode(self, obj :Any) -> Any:
return super(UNSAFE_JSON, self).encode(self._encode(obj))
+
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')
-
+ remove_vt100_escape_codes_from_lines :bool = True
+ ):
if not callbacks:
callbacks = {}
+
if not environment_vars:
environment_vars = {}
@@ -216,8 +216,6 @@ class SysCommandWorker:
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
@@ -396,7 +394,7 @@ class SysCommandWorker:
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
+ # If history_logfile does not exist, ignore the error
except FileNotFoundError:
pass
except Exception as e:
@@ -431,14 +429,10 @@ class SysCommand:
callbacks :Optional[Dict[str, Callable[[Any], Any]]] = None,
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():
@@ -449,8 +443,6 @@ class SysCommand:
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
@@ -575,9 +567,8 @@ def run_custom_user_commands(commands :List[str], installation :Installer) -> No
with open(f"{installation.target}/var/tmp/user-command.{index}.sh", "w") as temp_script:
temp_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 /var/tmp/user-command.{index}.sh")
- 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 :
diff --git a/archinstall/lib/global_menu.py b/archinstall/lib/global_menu.py
new file mode 100644
index 00000000..bc9164ee
--- /dev/null
+++ b/archinstall/lib/global_menu.py
@@ -0,0 +1,364 @@
+from __future__ import annotations
+
+from typing import Any, List, Optional, Union, Dict, TYPE_CHECKING
+
+from . import disk
+from .general import SysCommand, secret
+from .menu import Selector, AbstractMenu
+from .models import NetworkConfiguration
+from .models.bootloader import Bootloader
+from .models.users import User
+from .output import FormattedOutput
+from .profile.profile_menu import ProfileConfiguration
+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
+from .user_interaction import select_additional_repositories
+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.disk_conf import select_disk_config
+from .user_interaction.save_conf import save_config
+
+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['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['disk_config'] = \
+ Selector(
+ _('Disk configuration'),
+ lambda preset: self._select_disk_config(preset),
+ preview_func=self._prev_disk_layouts,
+ display_func=lambda x: self._display_disk_layout(x),
+ )
+ 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['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 '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_config'] = \
+ Selector(
+ _('Profile'),
+ lambda preset: self._select_profile(preset),
+ display_func=lambda x: x.profile.name if x else 'None',
+ preview_func=self._prev_profile
+ )
+ self._menu_options['audio'] = \
+ Selector(
+ _('Audio'),
+ lambda preset: self._select_audio(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),
+ display_func=lambda x: ', '.join(x) if x else None,
+ 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,
+ display_func=lambda x: ', '.join(x) if x else None,
+ 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: str, value: str):
+ text = self._install_text()
+ self._menu_options['install'].update_description(text)
+
+ def post_callback(self, name: str, value: str):
+ 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, 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[disk.DiskEncryption]) -> Optional[disk.DiskEncryption]:
+ mods: Optional[List[disk.DeviceModification]] = self._menu_options['disk_config'].current_selection
+
+ if not mods:
+ # this should not happen as the encryption menu has the disk_config as dependency
+ raise ValueError('No disk layout specified')
+
+ data_store: Dict[str, Any] = {}
+ disk_encryption = disk.DiskEncryptionMenu(mods, data_store, preset=preset).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_disk_layouts(self) -> Optional[str]:
+ selector = self._menu_options['disk_config']
+ disk_layout_conf: Optional[disk.DiskLayoutConfiguration] = selector.current_selection
+
+ if disk_layout_conf:
+ device_mods: List[disk.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 _display_disk_layout(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]:
+ 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'
+
+ 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 _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']
+ 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 + '\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_ntp(self, preset :bool = True) -> bool:
+ ntp = ask_ntp(preset)
+
+ value = str(ntp).lower()
+ SysCommand(f'timedatectl set-ntp {value}')
+
+ return ntp
+
+ def _select_disk_config(
+ self,
+ preset: Optional[disk.DiskLayoutConfiguration] = None
+ ) -> Optional[disk.DiskLayoutConfiguration]:
+ disk_config = select_disk_config(
+ preset,
+ storage['arguments'].get('advanced', False)
+ )
+
+ 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: Union[str, None]) -> Optional[str]:
+ profile_config: Optional[ProfileConfiguration] = self._menu_options['profile_config'].current_selection
+ if profile_config and profile_config.profile:
+ is_desktop = profile_config.profile.is_desktop_profile() if profile_config else False
+ selection = ask_for_audio_selection(is_desktop, current)
+ return selection
+ return None
+
+ 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/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 b4d253b3..ddbcc2f2 100644
--- a/archinstall/lib/installer.py
+++ b/archinstall/lib/installer.py
@@ -1,30 +1,29 @@
-import time
+import glob
import logging
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
+import time
+from pathlib import Path
+from typing import Any, Iterator, List, Mapping, Optional, TYPE_CHECKING, Union, Dict
+
+from . import disk
+from .exceptions import DiskError, ServiceException, RequirementError, HardwareIncompatibilityError, SysCallError
+from .general import SysCommand
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 .luks import Luks2
from .mirrors import use_mirrors
-from .models.disk_encryption import DiskEncryption
+from .models.bootloader import Bootloader
+from .models.network_configuration import NetworkConfiguration
+from .models.users import User
+from .output import log
+from .pacman import run_pacman
from .plugins import plugins
+from .services import service_state
from .storage import storage
-from .output import log
-from .profiles import Profile
-from .disk.partition import get_mount_fs_type
-from .exceptions import DiskError, ServiceException, RequirementError, HardwareIncompatibilityError, SysCallError
-from .models.users import User
-from .models.subvolume import Subvolume
-from .hsm import Fido2
if TYPE_CHECKING:
_: Any
@@ -36,9 +35,6 @@ __packages__ = ["base", "base-devel", "linux-firmware", "linux", "linux-lts", "l
# 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"):
@@ -92,26 +88,35 @@ class Installer:
: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:
+ def __init__(
+ self,
+ target: Path,
+ disk_config: disk.DiskLayoutConfiguration,
+ disk_encryption: Optional[disk.DiskEncryption] = None,
+ base_packages: List[str] = [],
+ kernels: Optional[List[str]] = None
+ ):
+ if not base_packages:
base_packages = __packages__[:3]
+
if kernels is None:
self.kernels = ['linux']
else:
self.kernels = kernels
+
+ self._disk_config = disk_config
+ self._disk_encryption = disk_encryption
+
+ if self._disk_encryption is None:
+ self._disk_encryption = disk.DiskEncryption(disk.EncryptionType.NoEncryption)
+
self.target = target
self.init_time = time.strftime('%Y-%m-%d_%H-%M-%S')
self.milliseconds = int(str(time.time()).split('.')[1])
+ self.helper_flags = {'base': False, 'bootloader': False}
+ self.base_packages = base_packages
- 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)
@@ -136,19 +141,10 @@ class Installer:
self._zram_enabled = 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)
-
- def __enter__(self, *args :str, **kwargs :str) -> 'Installer':
+ def __enter__(self, *args: str, **kwargs: str) -> 'Installer':
return self
- def __exit__(self, *args :str, **kwargs :str) -> 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
if len(args) >= 2 and args[1]:
@@ -165,7 +161,6 @@ class Installer:
if not (missing_steps := self.post_install_check()):
self.log('Installation completed without any errors. You may now reboot.', fg='green', level=logging.INFO)
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)
@@ -178,146 +173,168 @@ class Installer:
self.sync_log_to_install_medium()
return False
- @property
- def partitions(self) -> List[Partition]:
- return get_partitions_in_use(self.target).values()
+ 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)
- 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 _verify_service_stop(self):
+ """
+ Certain services might be running that affects the system during installation.
+ Currently, only 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.
+ """
+ log('Waiting for automatic mirror selection (reflector) to complete...', level=logging.INFO)
+ while service_state('reflector') not in ('dead', 'failed', 'exited'):
+ time.sleep(1)
- if not os.path.isdir(f"{self.target}/{os.path.dirname(absolute_logfile)}"):
- os.makedirs(f"{self.target}/{os.path.dirname(absolute_logfile)}")
+ log('Waiting pacman-init.service to complete.', level=logging.INFO)
+ while service_state('pacman-init') not in ('dead', 'failed', 'exited'):
+ time.sleep(1)
- shutil.copy2(absolute_logfile, f"{self.target}/{absolute_logfile}")
+ log('Waiting Arch Linux keyring sync (archlinux-keyring-wkd-sync) to complete.', level=logging.INFO)
+ while service_state('archlinux-keyring-wkd-sync') not in ('dead', 'failed', 'exited'):
+ time.sleep(1)
- return True
+ def _verify_boot_part(self):
+ """
+ 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)
- def _create_keyfile(self,luks_handle , partition :dict, password :str):
- """ roiutine to create keyfiles, so it can be moved elsewhere
+ NOTE: this function should be run AFTER running the mount_ordered_layout function
"""
- 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))
+ 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):
+ 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.'
+ )
- os.chmod(f"{self.target}{encryption_key_path}", 0o400)
+ def sanity_check(self):
+ self._verify_boot_part()
+ self._verify_service_stop()
- 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"])
+ def mount_ordered_layout(self):
+ log('Mounting partitions in order', level=logging.INFO)
- def _has_root(self, partition :dict) -> bool:
- """
- Determine if an encrypted partition contains root in it
- """
- 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
+ for mod in self._disk_config.device_modifications:
+ # 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(mod.partitions, key=lambda x: x.mountpoint if x.mountpoint else Path('/'))
+
+ if self._disk_encryption.encryption_type is not disk.EncryptionType.NoEncryption:
+ enc_partitions = list(filter(lambda x: x in self._disk_encryption.partitions, sorted_part_mods))
else:
- return False
- elif partition.get("mountpoint") == '/':
- return True
- else:
- return False
+ enc_partitions = []
- 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
- )
+ # attempt to decrypt all luks partitions
+ luks_handlers = self._prepare_luks_partitions(enc_partitions)
- # 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)
+ for part_mod in sorted_part_mods:
+ if part_mod not in luks_handlers: # partition is not encrypted
+ self._mount_partition(part_mod)
+ else: # mount encrypted partition
+ self._mount_luks_partiton(part_mod, luks_handlers[part_mod])
- 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)
+ def _prepare_luks_partitions(self, partitions: List[disk.PartitionModification]) -> Dict[disk.PartitionModification, Luks2]:
+ luks_handlers = {}
- log(f"Using mount order: {list(sorted(mount_queue.items(), key=lambda item: item[0]))}", level=logging.DEBUG, fg="white")
+ for part_mod in partitions:
+ luks_handler = disk.device_handler.unlock_luks2_dev(
+ part_mod.dev_path,
+ part_mod.mapper_name,
+ self._disk_encryption.encryption_password
+ )
+ luks_handlers[part_mod] = luks_handler
+
+ return luks_handlers
+
+ 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 is not None:
+ 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:
+ self._mount_btrfs_subvol(part_mod.dev_path, part_mod.btrfs_subvols)
+
+ def _mount_luks_partiton(self, part_mod: disk.PartitionModification, luks_handler: Luks2):
+ # it would be none if it's btrfs as the subvolumes will have the mountpoints defined
+ if part_mod.mountpoint is not None:
+ 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:
+ self._mount_btrfs_subvol(luks_handler.mapper_dev, part_mod.btrfs_subvols)
+
+ def _mount_btrfs_subvol(self, dev_path: Path, subvolumes: List[disk.SubvolumeModification]):
+ for subvol in subvolumes:
+ mountpoint = self.target / subvol.relative_mountpoint
+ mount_options = subvol.mount_options + [f'subvol={subvol.name}']
+ disk.device_handler.mount(dev_path, mountpoint, options=mount_options)
+
+ def generate_key_files(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.dev_path,
+ mapper_name=part_mod.mapper_name,
+ password=self._disk_encryption.encryption_password
+ )
- # 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()
+ if gen_enc_file and not part_mod.is_root():
+ log(f'Creating key-file: {part_mod.dev_path}', level=logging.INFO)
+ 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,
+ self._disk_encryption.encryption_password
+ )
+
+ def activate_ntp(self):
+ """
+ If NTP is activated, confirm activiation in the ISO and at least one time-sync finishes
+ """
+ SysCommand('timedatectl set-ntp true')
+
+ logged = False
+ while service_state('dbus-org.freedesktop.timesync1.service') not in ['running']:
+ if not logged:
+ log(f"Waiting for dbus-org.freedesktop.timesync1.service to enter running state", level=logging.INFO)
+ logged = True
time.sleep(1)
- 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).")
+ logged = False
+ while 'Server: n/a' in SysCommand('timedatectl timesync-status --no-pager --property=Server --value'):
+ if not logged:
+ log(f"Waiting for timedatectl timesync-status to report a timesync against a server", level=logging.INFO)
+ logged = True
+ time.sleep(1)
- # 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])
+ 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] != '/':
@@ -394,7 +411,7 @@ class Installer:
else:
pacman_conf.write(line)
- def pacstrap(self, *packages :str, **kwargs :str) -> bool:
+ def pacstrap(self, *packages: Union[str, List[str]], **kwargs :str) -> bool:
if type(packages[0]) in (list, tuple):
packages = packages[0]
@@ -437,7 +454,7 @@ class Installer:
return use_mirrors(mirrors, destination=f'{self.target}/etc/pacman.d/mirrorlist')
- def genfstab(self, flags :str = '-pU') -> bool:
+ def genfstab(self, flags :str = '-pU'):
self.log(f"Updating {self.target}/etc/fstab", level=logging.INFO)
try:
@@ -460,7 +477,37 @@ class Installer:
for entry in self.FSTAB_ENTRIES:
fstab_fh.write(f'{entry}\n')
- return True
+ for mod in self._disk_config.device_modifications:
+ for part_mod in mod.partitions:
+ if part_mod.fs_type != disk.FilesystemType.Btrfs:
+ continue
+
+ fstab_file = Path(f'{self.target}/etc/fstab')
+
+ with fstab_file.open('r') as fp:
+ fstab = fp.readlines()
+
+ # Replace the {installation}/etc/fstab with entries
+ # using the compress=zstd where the mountpoint has compression set.
+ for index, line in enumerate(fstab):
+ # 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
+ subvoldef = re.findall(',.*?subvol=.*?[\t ]', line)
+ mountpoint = re.findall('[\t ]/.*?[\t ]', line)
+
+ if not subvoldef or not mountpoint:
+ continue
+
+ for sub_vol in part_mod.btrfs_subvols:
+ # We then locate the correct subvolume and check if it's compressed,
+ # and skip entries where compression is already defined
+ # We then sneak in the compress=zstd option if it doesn't already exist:
+ if sub_vol.compress and str(sub_vol.mountpoint) == Path(mountpoint[0].strip()) and ',compress=zstd,' not in line:
+ fstab[index] = line.replace(subvoldef[0], f',compress=zstd{subvoldef[0]}')
+ break
+
+ with fstab_file.open('w') as fp:
+ fp.writelines(fstab)
def set_hostname(self, hostname: str, *args :str, **kwargs :str) -> None:
with open(f'{self.target}/etc/hostname', 'w') as fh:
@@ -509,8 +556,8 @@ 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
@@ -523,10 +570,6 @@ class Installer:
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)
self.enable_service('systemd-timesyncd')
@@ -540,7 +583,10 @@ class Installer:
# 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 type(services[0]) in (list, tuple):
+ services = services[0]
+
for service in services:
self.log(f'Enabling service {service}', level=logging.INFO)
try:
@@ -552,10 +598,10 @@ class Installer:
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)}"
@@ -645,21 +691,6 @@ class Installer:
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:
for plugin in plugins.values():
if hasattr(plugin, 'on_mkinitcpio'):
@@ -668,7 +699,7 @@ class Installer:
return True
# mkinitcpio will error out if there's no vconsole.
- if (vconsole := pathlib.Path(f"{self.target}/etc/vconsole.conf")).exists() is False:
+ if (vconsole := 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")
@@ -677,7 +708,7 @@ class Installer:
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.
@@ -694,46 +725,36 @@ class Installer:
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')
+ self,
+ testing: bool = False,
+ multilib: bool = False,
+ hostname: str = 'archinstall',
+ locales: List[str] = ['en_US.UTF-8 UTF-8']
+ ):
+ for mod in self._disk_config.device_modifications:
+ for part in mod.partitions:
+ 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)
+
+ # 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 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')
@@ -742,11 +763,11 @@ class Installer:
vendor = cpu_vendor()
if vendor == "AuthenticAMD":
self.base_packages.append("amd-ucode")
- if (ucode := pathlib.Path(f"{self.target}/boot/amd-ucode.img")).exists():
+ if (ucode := 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():
+ if (ucode := Path(f"{self.target}/boot/intel-ucode.img")).exists():
ucode.unlink()
else:
self.log(f"Unknown CPU vendor '{vendor}' detected. Archinstall won't install any ucode.", level=logging.DEBUG)
@@ -802,9 +823,7 @@ class Installer:
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')
@@ -818,16 +837,27 @@ 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:
+ 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_partition(self) -> Optional[disk.PartitionModification]:
+ for mod in self._disk_config.device_modifications:
+ if root := mod.get_root_partition(self._disk_config.relative_mountpoint):
+ return root
+ return None
+
+ def _add_systemd_bootloader(self, root_partition: disk.PartitionModification):
self.pacstrap('efibootmgr')
if not 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.
@@ -882,74 +912,73 @@ class Installer:
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)
+ 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'
+ options_entry = f'rw rootfstype={root_partition.fs_type.fs_type_mount} {" ".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
+ for sub_vol in root_partition.btrfs_subvols:
+ if sub_vol.is_root():
+ options_entry = f"rootflags=subvol={sub_vol.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):
+ if root_partition.fs_type.is_crypto():
# 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)
+ log('Root partition is an encrypted device, identifying by PARTUUID: {root_partition.partuuid}', level=logging.DEBUG)
kernel_options = f"options"
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"
+ kernel_options += f' rd.luks.name={root_partition.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"
+ kernel_options += f' rd.luks.options=fido2-device=auto,password-echo=no'
else:
- kernel_options += f" cryptdevice=PARTUUID={real_device.part_uuid}:luksdev"
+ kernel_options += f' cryptdevice=PARTUUID={root_partition.partuuid}:luksdev'
entry.write(f'{kernel_options} root=/dev/mapper/luksdev {options_entry}')
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}')
+ log(f'Identifying root partition by PARTUUID: {root_partition.partuuid}', level=logging.DEBUG)
+ entry.write(f'options root=PARTUUID={root_partition.partuuid} {options_entry}')
- self.helper_flags['bootloader'] = "systemd"
-
- return True
+ self.helper_flags['bootloader'] = 'systemd'
- def add_grub_bootloader(self, boot_partition :Partition, root_partition :Partition) -> bool:
+ def _add_grub_bootloader(
+ self,
+ boot_partition: disk.PartitionModification,
+ root_partition: disk.PartitionModification
+ ):
self.pacstrap('grub') # no need?
- root_fs_type = get_mount_fs_type(root_partition.filesystem)
+ _file = "/etc/default/grub"
- 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/'"
+ if root_partition.fs_type.is_crypto():
+ log(f"Using UUID {root_partition.uuid} as encrypted root identifier", level=logging.DEBUG)
- 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}")
+ cmd_line_linux = f"sed -i 's/GRUB_CMDLINE_LINUX=\"\"/GRUB_CMDLINE_LINUX=\"cryptdevice=UUID={root_partition.uuid}:cryptlvm rootfstype={root_partition.fs_type.value}\"/'"
+ enable_cryptdisk = "sed -i 's/#GRUB_ENABLE_CRYPTODISK=y/GRUB_ENABLE_CRYPTODISK=y/'"
+
+ SysCommand(f"/usr/bin/arch-chroot {self.target} {enable_cryptdisk} {_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}")
+ cmd_line_linux = f"sed -i 's/GRUB_CMDLINE_LINUX=\"\"/GRUB_CMDLINE_LINUX=\"rootfstype={root_partition.fs_type.value}\"/'"
+
+ SysCommand(f"/usr/bin/arch-chroot {self.target} {cmd_line_linux} {_file}")
+
+ log(f"GRUB boot partition: {boot_partition.dev_path}", level=logging.INFO)
- 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)
except SysCallError:
@@ -961,7 +990,7 @@ class Installer:
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}")
+ raise DiskError(f"Failed to install GRUB boot on {boot_partition.dev_path}: {error}")
try:
SysCommand(f'/usr/bin/arch-chroot {self.target} grub-mkconfig -o /boot/grub/grub.cfg')
@@ -970,22 +999,22 @@ class Installer:
self.helper_flags['bootloader'] = "grub"
- return True
-
- def add_efistub_bootloader(self, boot_partition :Partition, root_partition :Partition) -> bool:
+ def _add_efistub_bootloader(
+ self,
+ boot_partition: disk.PartitionModification,
+ root_partition: disk.PartitionModification
+ ):
self.pacstrap('efibootmgr')
if not 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)
-
for kernel in self.kernels:
# Setup the firmware entry
-
label = f'Arch Linux ({kernel})'
loader = f"/vmlinuz-{kernel}"
@@ -1004,22 +1033,22 @@ class Installer:
# 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):
+
+ if root_partition.fs_type.is_crypto():
# 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)}')
+ log(f'Identifying root partition by PARTUUID: {root_partition.partuuid}', level=logging.DEBUG)
+ kernel_parameters.append(f'cryptdevice=PARTUUID={root_partition.partuuid}:luksdev root=/dev/mapper/luksdev rw rootfstype={root_partition.fs_type.value} {" ".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)}')
+ log(f'Root partition is an encrypted device identifying by PARTUUID: {root_partition.partuuid}', level=logging.DEBUG)
+ kernel_parameters.append(f'root=PARTUUID={root_partition.partuuid} rw rootfstype={root_partition.fs_type.value} {" ".join(self.KERNEL_PARAMS)}')
- SysCommand(f'efibootmgr --disk {boot_partition.path[:-1]} --part {boot_partition.path[-1]} --create --label "{label}" --loader {loader} --unicode \'{" ".join(kernel_parameters)}\' --verbose')
+ device = disk.device_handler.get_device_by_partition_path(boot_partition.dev_path)
+ SysCommand(f'efibootmgr --disk {device.path} --part {device.path} --create --label "{label}" --loader {loader} --unicode \'{" ".join(kernel_parameters)}\' --verbose')
self.helper_flags['bootloader'] = "efistub"
- return True
-
- def add_bootloader(self, bootloader :str = 'systemd-bootctl') -> bool:
+ def add_bootloader(self, bootloader: Bootloader) -> bool:
"""
Adds a bootloader to the installation instance.
Archinstall supports one of three types:
@@ -1039,52 +1068,33 @@ class Installer:
return True
if type(self.target) == str:
- self.target = pathlib.Path(self.target)
-
- 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 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}")
-
- self.log(f'Adding bootloader {bootloader} to {boot_partition if boot_partition else root_partition}', level=logging.INFO)
-
- 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}")
+ self.target = Path(self.target)
- return True
+ boot_partition = self._get_boot_partition()
+ root_partition = self._get_root_partition()
- def add_additional_packages(self, *packages :str) -> bool:
- return self.pacstrap(*packages)
+ if boot_partition is None:
+ raise ValueError(f'Could not detect boot at mountpoint {self.target}')
- 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.
+ if root_partition is None:
+ raise ValueError(f'Could not detect root at mountpoint {self.target}')
- :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
+ self.log(f'Adding bootloader {bootloader.value} to {boot_partition.dev_path}', level=logging.INFO)
+
+ match bootloader:
+ case Bootloader.Systemd:
+ self._add_systemd_bootloader(root_partition)
+ case Bootloader.Grub:
+ self._add_grub_bootloader(boot_partition, root_partition)
+ case Bootloader.Efistub:
+ self._add_efistub_bootloader(boot_partition, root_partition)
- if type(profile) == str:
- profile = Profile(self, profile)
+ def add_additional_packages(self, *packages: Union[str, List[str]]) -> bool:
+ return self.pacstrap(*packages)
- self.log(f'Installing archinstall profile {profile}', level=logging.INFO)
- return profile.install()
+ def _enable_users(self, service: str, users: List[User]):
+ for user in users:
+ self.arch_chroot(f'systemctl enable --user {service}', run_as=user.username)
def enable_sudo(self, entity: str, group :bool = False):
self.log(f'Enabling sudo permissions for {entity}.', level=logging.INFO)
@@ -1092,7 +1102,7 @@ class Installer:
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)
@@ -1114,7 +1124,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):
diff --git a/archinstall/lib/locale_helpers.py b/archinstall/lib/locale_helpers.py
index 5580fa91..d1fb4562 100644
--- a/archinstall/lib/locale_helpers.py
+++ b/archinstall/lib/locale_helpers.py
@@ -1,11 +1,12 @@
import logging
-from typing import Iterator, List, Callable
+from typing import Iterator, List, Callable, Optional
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()
@@ -45,20 +46,25 @@ def get_locale_mode_text(mode):
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'):
+
+def set_cmd_locale(
+ general: Optional[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)
diff --git a/archinstall/lib/luks.py b/archinstall/lib/luks.py
index ad6bf093..fc531a06 100644
--- a/archinstall/lib/luks.py
+++ b/archinstall/lib/luks.py
@@ -1,92 +1,78 @@
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 dataclasses import dataclass
+from pathlib import Path
+from typing import Optional, List
-from .disk import Partition, convert_device_to_uuid
-from .general import SysCommand, SysCommandWorker
+from . import disk
+from .general import SysCommand, generate_password, SysCommandWorker
from .output import log
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')
-
- with open(self.key_file, 'wb') as fh:
- fh.write(self.password)
-
- return self.unlock(self.partition, self.mountpoint, self.key_file)
-
- def __exit__(self, *args :str, **kwargs :str) -> bool:
- # TODO: https://stackoverflow.com/questions/28157929/how-to-safely-handle-an-exception-inside-a-context-manager
+
+
+@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
+
+ # will be set internally after unlocking the device
+ _mapper_dev: Optional[Path] = None
+
+ @property
+ def mapper_dev(self) -> Optional[Path]:
+ if self.mapper_name:
+ return Path(f'/dev/mapper/{self.mapper_name}')
+ return None
+
+ 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:
+ log(f'Luks2 encrypting: {self.luks_dev_path}', level=logging.INFO)
- 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?
-
- if not password:
- password = self.password
-
- if type(password) != bytes:
- password = bytes(password, 'UTF-8')
+ key_file = self._default_key_file()
- 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 +83,163 @@ 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.
+ cmd_handle = None
for i in range(storage['DISK_RETRY_ATTEMPTS']):
if (cmd_handle := SysCommand(cryptsetup_args)).exit_code != 0:
time.sleep(storage['DISK_TIMEOUTS'])
else:
break
- if cmd_handle.exit_code != 0:
- raise DiskError(f'Could not encrypt volume "{partition.path}": {b"".join(cmd_handle)}')
+ if cmd_handle is not None and cmd_handle.exit_code != 0:
+ output = str(b''.join(cmd_handle))
+ raise DiskError(f'Could not encrypt volume "{self.luks_dev_path}": {output}')
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']}")
+ log(f'luks2 partition currently in use: {self.luks_dev_path}')
+ log('Attempting to unmount, crypt-close and trying encryption again')
+ self.lock()
# Then try again to set up the crypt-device
- cmd_handle = SysCommand(cryptsetup_args)
+ SysCommand(cryptsetup_args)
else:
raise err
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:
+ result = SysCommand(command)
+ if result.exit_code != 0:
+ raise DiskError(f'Unable to get UUID for Luks device: {result.decode()}')
+
+ return result.decode() # type: ignore
+ except SysCallError as err:
+ log(f'Unable to get UUID for Luks device: {self.luks_dev_path}', level=logging.INFO)
+ 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
"""
+ log(f'Unlocking luks2 device: {self.luks_dev_path}', level=logging.DEBUG)
+
+ 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()
- if '/' in mountpoint:
- os.path.basename(mountpoint) # TODO: Raise exception instead?
+ 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'})
+ SysCommand(f'/usr/bin/cryptsetup open {self.luks_dev_path} {self.mapper_name} --key-file {key_file} --type luks2')
+
+ 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
+ disk.device_handler.partprobe(self.luks_dev_path)
+ 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:
+ log(f'Unmounting {mountpoint}', level=logging.DEBUG)
+ disk.device_handler.umount(mountpoint, recursive=True)
+
+ # And close it if possible.
+ log(f"Closing crypt device {child.name}", level=logging.DEBUG)
+ 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"
+ key_file_path = target_path / 'etc/cryptsetup-keys.d/' / self.mapper_name
+ key_file = key_file_path / '.key'
+ crypttab_path = target_path / 'etc/crypttab'
+
+ if key_file.exists():
+ if not override:
+ log(f'Key file {key_file} already exists, keeping existing')
+ return
+ else:
+ log(f'Key file {key_file} already exists, overriding')
+
+ key_file_path.mkdir(parents=True, exist_ok=True)
+
+ with open(key_file, "w") as keyfile:
+ keyfile.write(generate_password(length=512))
+
+ key_file_path.chmod(0o400)
+
+ self._add_key(key_file)
+ self._crypttab(crypttab_path, key_file, options=["luks", "key-slot=1"])
+
+ def _add_key(self, key_file: Path):
+ log(f'Adding additional key-file {key_file}', level=logging.INFO)
+
+ 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:
+ log(f'Adding crypttab entry for key {key_file}', level=logging.INFO)
+
+ 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..53816655 100644
--- a/archinstall/lib/menu/abstract_menu.py
+++ b/archinstall/lib/menu/abstract_menu.py
@@ -7,7 +7,6 @@ from .menu import Menu, MenuSelectionType
from ..locale_helpers import set_keyboard_language
from ..output import log
from ..translationhandler import TranslationHandler, Language
-from ..user_interaction.general_conf import select_archinstall_language
if TYPE_CHECKING:
_: Any
@@ -16,17 +15,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[[str], 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
@@ -82,6 +81,11 @@ class Selector:
self._preview_func = preview_func
self.mandatory = mandatory
self._no_store = no_store
+ self._default = default
+
+ @property
+ def default(self) -> Any:
+ return self._default
@property
def description(self) -> str:
@@ -96,7 +100,7 @@ class Selector:
return self._dependencies_not
@property
- def current_selection(self):
+ def current_selection(self) -> Optional[Any]:
return self._current_selection
@property
@@ -106,14 +110,14 @@ class Selector:
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):
+ 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 = ''
@@ -134,7 +138,7 @@ class Selector:
return f'{description} {current}'
- def set_current_selection(self, current :Optional[str]):
+ def set_current_selection(self, current: Optional[Any]):
self._current_selection = current
def has_selection(self) -> bool:
@@ -158,14 +162,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,25 +186,28 @@ 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()
+
@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]:
@@ -216,7 +226,50 @@ 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 _missing_configs(self) -> List[str]:
+ def check(s):
+ return self._menu_options.get(s).has_selection()
+
+ 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 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()
"""
@@ -226,7 +279,7 @@ class AbstractMenu:
""" will be called before each action in the menu """
return
- def post_callback(self, selection_name: Optional[str] = None, value: Any = None):
+ def post_callback(self, selection_name: str, value: Any):
""" will be called after each action in the menu """
return True
@@ -234,31 +287,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 +312,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,12 +325,7 @@ 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()
cursor_pos = None
while True:
@@ -341,13 +378,13 @@ class AbstractMenu:
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 +393,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,17 +409,21 @@ 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)
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):
@@ -392,7 +433,7 @@ class AbstractMenu:
if self._data_store.get('keyboard-layout', None) and len(self._data_store['keyboard-layout']):
set_keyboard_language(self._data_store['keyboard-layout'])
- def _verify_selection_enabled(self, selection_name :str) -> bool:
+ def _verify_selection_enabled(self, selection_name: str) -> bool:
""" general """
if selection := self._menu_options.get(selection_name, None):
if not selection.enabled:
@@ -429,16 +470,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 +482,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:
+ from ..user_interaction.general_conf import select_archinstall_language
language = select_archinstall_language(self.translation_handler.translated_languages, preset_value)
self._translation_handler.activate(language)
return language
class AbstractSubMenu(AbstractMenu):
- def __init__(self, data_store: Optional[Dict[str, Any]] = None):
+ def __init__(self, data_store: Dict[str, Any] = {}):
super().__init__(data_store=data_store)
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..be31fdf0 100644
--- a/archinstall/lib/menu/list_manager.py
+++ b/archinstall/lib/menu/list_manager.py
@@ -34,7 +34,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 +44,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
@@ -76,10 +81,11 @@ class ListManager:
elif choice.value in self._terminate_actions:
break
else: # an entry of the existing selection was choosen
- selected_entry = data_formatted[choice.value]
+ 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:
@@ -122,21 +128,29 @@ class ListManager:
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
+ """
+ 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, 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)
+ def reformat(self, data: List[Any]) -> Dict[str, Optional[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)
+ """
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..44ac33a6 100644
--- a/archinstall/lib/menu/menu.py
+++ b/archinstall/lib/menu/menu.py
@@ -3,7 +3,7 @@ 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
from ..exceptions import RequirementError
from ..output import log
@@ -27,42 +27,56 @@ class MenuSelection:
type_: MenuSelectionType
value: Optional[Union[str, List[str]]] = None
+ @property
+ def single_value(self) -> Any:
+ return self.value
+
+ @property
+ def multi_value(self) -> List[Any]:
+ return self.value
+
class Menu(TerminalMenu):
@classmethod
- def yes(cls):
+ def back(cls) -> str:
+ return str(_('← Back'))
+
+ @classmethod
+ 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,
+ 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] = 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] = None,
+ 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 +86,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,16 +115,17 @@ 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 warnign 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
@@ -152,7 +167,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:
@@ -182,6 +196,14 @@ class Menu(TerminalMenu):
default = f'{default_option} {self._default_str}'
self._menu_options = [default] + [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 += ['']
+
self._preselection(preset_values,cursor_index)
cursor = "> "
@@ -194,13 +216,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),
+ 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 +231,17 @@ class Menu(TerminalMenu):
skip_empty_entries=skip_empty_entries
)
+ def _show_preview(self, preview_command: Optional[Callable], selection: str) -> Optional[str]:
+ if selection == self.back():
+ return None
+
+ if preview_command:
+ if self._default_option is not None and f'{self._default_option} {self._default_str}' == selection:
+ selection = self._default_option
+ return preview_command(selection)
+
+ return None
+
def _show(self) -> MenuSelection:
try:
idx = self.show()
@@ -225,39 +255,37 @@ class Menu(TerminalMenu):
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()
+ 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
+
+ return selection
def set_cursor_pos(self,pos :int):
if pos and 0 < pos < len(self._menu_entries):
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..4cff7216 100644
--- a/archinstall/lib/menu/table_selection_menu.py
+++ b/archinstall/lib/menu/table_selection_menu.py
@@ -1,19 +1,24 @@
-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,
):
"""
param title: Text that will be displayed above the menu
@@ -29,10 +34,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 +46,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 +58,53 @@ 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
)
+ 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 +117,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 +131,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/mirrors.py b/archinstall/lib/mirrors.py
index d76e0473..4bae6d8b 100644
--- a/archinstall/lib/mirrors.py
+++ b/archinstall/lib/mirrors.py
@@ -2,7 +2,7 @@ import logging
import pathlib
import urllib.error
import urllib.request
-from typing import Union, Mapping, Iterable, Dict, Any, List
+from typing import Union, Iterable, Dict, Any, List
from .general import SysCommand
from .output import log
@@ -121,7 +121,7 @@ def insert_mirrors(mirrors :Dict[str, Any], *args :str, **kwargs :str) -> bool:
def use_mirrors(
- regions: Mapping[str, Iterable[str]],
+ regions: Dict[str, Iterable[str]],
destination: str = '/etc/pacman.d/mirrorlist'
) -> None:
log(f'A new package mirror-list has been created: {destination}', level=logging.INFO)
diff --git a/archinstall/lib/models/__init__.py b/archinstall/lib/models/__init__.py
index 4a018b2c..8cc49ea0 100644
--- a/archinstall/lib/models/__init__.py
+++ b/archinstall/lib/models/__init__.py
@@ -1 +1,4 @@
-from .network_configuration import NetworkConfiguration as NetworkConfiguration \ No newline at end of file
+from .network_configuration import NetworkConfiguration, NicType, NetworkConfigurationHandler
+from .bootloader import Bootloader
+from .gen import VersionDef, PackageSearchResult, PackageSearch, LocalPackage
+from .users import PasswordStrength, User
diff --git a/archinstall/lib/models/bootloader.py b/archinstall/lib/models/bootloader.py
new file mode 100644
index 00000000..38254c99
--- /dev/null
+++ b/archinstall/lib/models/bootloader.py
@@ -0,0 +1,40 @@
+from __future__ import annotations
+
+import logging
+import sys
+from enum import Enum
+from typing import List
+
+from ..hardware import has_uefi
+from ..output import log
+
+
+class Bootloader(Enum):
+ Systemd = 'Systemd-boot'
+ Grub = 'Grub'
+ Efistub = 'Efistub'
+
+ def json(self):
+ return self.value
+
+ @classmethod
+ def values(cls) -> List[str]:
+ return [e.value for e in cls]
+
+ @classmethod
+ def get_default(cls) -> Bootloader:
+ if 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())
+ log(f'Invalid bootloader value "{bootloader}". Allowed values: {values}', level=logging.WARN)
+ 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..cc8d7605 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
+
@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
@@ -83,12 +91,13 @@ class PackageSearchResult:
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
@@ -101,6 +110,7 @@ class PackageSearch:
def __post_init__(self):
self.results = [PackageSearchResult(**x) for x in self.results]
+
@dataclass
class LocalPackage:
name: str
@@ -129,8 +139,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..b7ab690d 100644
--- a/archinstall/lib/models/network_configuration.py
+++ b/archinstall/lib/models/network_configuration.py
@@ -93,7 +93,7 @@ class NetworkConfigurationHandler:
enable_services=True) # Sources the ISO network configuration to the install medium.
elif self._configuration.is_network_manager():
installation.add_additional_packages(["networkmanager"])
- if (profile := storage['arguments'].get('profile')) and profile.is_desktop_profile:
+ if (profile := storage['arguments'].get('profile_config')) and profile.is_desktop_type_profile:
installation.add_additional_packages(["network-manager-applet"])
installation.enable_service('NetworkManager.service')
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 96e8f3a1..3516aac4 100644
--- a/archinstall/lib/networking.py
+++ b/archinstall/lib/networking.py
@@ -1,8 +1,12 @@
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
@@ -39,7 +43,7 @@ def check_mirror_reachable() -> bool:
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)
+ log(f'exit_code: {err.exit_code}, Error: {err.message}', level=logging.DEBUG)
return False
@@ -75,12 +79,8 @@ 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 wireless_scan(interface :str) -> None:
- interfaces = enrich_iface_types(list_interfaces().values())
+ interfaces = enrich_iface_types(list(list_interfaces().values()))
if interfaces[interface] != 'WIRELESS':
raise HardwareIncompatibilityError(f"Interface {interface} is not a wireless interface: {interfaces}")
@@ -107,3 +107,22 @@ def get_wireless_networks(interface :str) -> None:
for line in SysCommand(f"iwctl station {interface} get-networks"):
print(line)
+
+
+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
+
+ 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..d65f835f 100644
--- a/archinstall/lib/output.py
+++ b/archinstall/lib/output.py
@@ -2,7 +2,7 @@ import logging
import os
import sys
from pathlib import Path
-from typing import Dict, Union, List, Any, Callable
+from typing import Dict, Union, List, Any, Callable, Optional
from .storage import storage
from dataclasses import asdict, is_dataclass
@@ -11,7 +11,12 @@ 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]:
+ def 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 dynamicly selected formatting method.
Can transmit a filter list to the class_formatter,
@@ -25,7 +30,8 @@ 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
+
+ raise ValueError('Unsupported formatting call')
elif hasattr(o, 'as_json'):
return o.as_json()
elif hasattr(o, 'json'):
@@ -36,7 +42,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
@@ -46,6 +58,7 @@ class FormattedOutput:
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]
+
# determine the maximum column size
column_width: Dict[str, int] = {}
for o in raw_data:
@@ -55,14 +68,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 = key.replace('!', '').replace('_', ' ')
+
+ if capitalize:
+ key = key.capitalize()
+
key_list.append(key.ljust(width))
+
output += ' | '.join(key_list) + '\n'
output += '-' * len(output) + '\n'
@@ -82,6 +101,20 @@ class FormattedOutput:
return output
+ @classmethod
+ def as_columns(cls, entries: List[str], cols: int) -> str:
+ 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
@@ -204,6 +237,6 @@ def log(*args :str, **kwargs :Union[str, int, Dict[str, Union[str, int]]]) -> No
# 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):
+ if kwargs.get('level', logging.INFO) != logging.DEBUG or storage.get('arguments', {}).get('verbose', False):
sys.stdout.write(f"{string}\n")
sys.stdout.flush()
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..71818ca5 100644
--- a/archinstall/lib/packages/packages.py
+++ b/archinstall/lib/packages/packages.py
@@ -7,7 +7,7 @@ from urllib.parse import urlencode
from urllib.request import urlopen
from ..exceptions import PackageError, SysCallError
-from ..models.dataclasses import PackageSearch, PackageSearchResult, LocalPackage
+from ..models.gen import PackageSearch, PackageSearchResult, LocalPackage
from ..pacman import run_pacman
BASE_URL_PKG_SEARCH = 'https://archlinux.org/packages/search/json/'
@@ -113,4 +113,4 @@ def installed_package(package :str) -> LocalPackage:
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
index 9c427aff..0dfd5afa 100644
--- a/archinstall/lib/pacman.py
+++ b/archinstall/lib/pacman.py
@@ -1,10 +1,14 @@
import logging
import pathlib
import time
+from typing import TYPE_CHECKING, Any
from .general import SysCommand
from .output import log
+if TYPE_CHECKING:
+ _: Any
+
def run_pacman(args :str, default_cmd :str = 'pacman') -> SysCommand:
"""
diff --git a/archinstall/lib/profile/__init__.py b/archinstall/lib/profile/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/archinstall/lib/profile/__init__.py
diff --git a/archinstall/lib/profile/profile_menu.py b/archinstall/lib/profile/profile_menu.py
new file mode 100644
index 00000000..6462685a
--- /dev/null
+++ b/archinstall/lib/profile/profile_menu.py
@@ -0,0 +1,203 @@
+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 ..hardware import AVAILABLE_GFX_DRIVERS
+from ..menu import Menu, MenuSelectionType, AbstractSubMenu, Selector
+from ..user_interaction.system_conf import select_driver
+
+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(
+ _('Profile'),
+ 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 if x else None,
+ dependencies=['profile'],
+ 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('All open-source (default)')
+
+ 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[str] = None) -> Optional[str]:
+ 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():
+ packages = AVAILABLE_GFX_DRIVERS[driver]
+
+ if packages and "nvidia" in packages:
+ 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_profile(self) -> Optional[str]:
+ profile: Optional[Profile] = self._menu_options['profile'].current_selection
+
+ if profile:
+ names = profile.current_selection_names()
+ return '\n'.join(names)
+
+ 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..ad3015ae
--- /dev/null
+++ b/archinstall/lib/profile/profile_model.py
@@ -0,0 +1,35 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import TYPE_CHECKING, Any, Optional, Dict
+
+from archinstall.default_profiles.profile import Profile, GreeterType
+
+if TYPE_CHECKING:
+ _: Any
+
+
+@dataclass
+class ProfileConfiguration:
+ profile: Optional[Profile] = None
+ gfx_driver: Optional[str] = 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,
+ '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
+ greeter = arg.get('greeter', None)
+
+ return ProfileConfiguration(
+ profile_handler.parse_profile_config(arg['profile']),
+ arg.get('gfx_driver', 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..063b12ea
--- /dev/null
+++ b/archinstall/lib/profile/profiles_handler.py
@@ -0,0 +1,391 @@
+from __future__ import annotations
+
+import importlib.util
+import logging
+import sys
+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 AVAILABLE_GFX_DRIVERS
+from ..menu import MenuSelectionType, Menu, MenuSelection
+from ..networking import list_interfaces, fetch_data_from_url
+from ..output import log
+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],
+ }
+
+ 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
+ """
+ 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
+
+ valid: List[Profile] = []
+ if details := profile_config.get('details', []):
+ resolved = {detail: self.get_profile_by_name(detail) for detail in details if detail}
+ valid = [p for p in resolved.values() if p is not None]
+ invalid = ', '.join([k for k, v in resolved.items() if v is None])
+
+ if invalid:
+ log(f'No profile definition found: {invalid}')
+
+ if profile is not None:
+ profile.set_current_selection(valid)
+
+ return profile
+
+ @property
+ def profiles(self) -> List[Profile]:
+ """
+ List of all available default_profiles
+ """
+ if self._profiles is None:
+ self._profiles = self._find_available_profiles()
+ return self._profiles
+
+ @cached_property
+ def _local_mac_addresses(self) -> List[str]:
+ ifaces = list_interfaces()
+ return list(ifaces.keys())
+
+ 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.Lightdm:
+ packages = ['lightdm', 'lightdm-gtk-greeter']
+ service = ['lightdm']
+ case GreeterType.Sddm:
+ packages = ['sddm']
+ service = ['sddm']
+ case GreeterType.Gdm:
+ packages = ['gdm']
+ service = ['gdm']
+
+ if packages:
+ install_session.add_additional_packages(packages)
+ if service:
+ install_session.enable_service(service)
+
+ def install_gfx_driver(self, install_session: 'Installer', driver: str):
+ try:
+ driver_pkgs = AVAILABLE_GFX_DRIVERS[driver] if driver else []
+ additional_pkg = ' '.join(['xorg-server', 'xorg-xinit'] + driver_pkgs)
+
+ if driver is not None:
+ if 'nvidia' in driver:
+ if "linux-zen" in install_session.base_packages or "linux-lts" in install_session.base_packages:
+ for kernel in install_session.kernels:
+ # Fixes https://github.com/archlinux/archinstall/issues/585
+ install_session.add_additional_packages(f"{kernel}-headers")
+
+ # I've had kernel regen fail if it wasn't installed before nvidia-dkms
+ install_session.add_additional_packages("dkms xorg-server xorg-xinit nvidia-dkms")
+ return
+ elif 'amdgpu' in driver_pkgs:
+ # The order of these two are important if amdgpu is installed #808
+ if 'amdgpu' in install_session.MODULES:
+ install_session.MODULES.remove('amdgpu')
+ install_session.MODULES.append('amdgpu')
+
+ if 'radeon' in install_session.MODULES:
+ install_session.MODULES.remove('radeon')
+ install_session.MODULES.append('radeon')
+
+ install_session.add_additional_packages(additional_pkg)
+ except Exception as err:
+ log(f"Could not handle nvidia and linuz-zen specific situations during xorg installation: {err}", level=logging.WARNING, fg="yellow")
+ # Prep didn't run, so there's no driver to install
+ install_session.add_additional_packages("xorg-server xorg-xinit")
+
+ def install_profile_config(self, install_session: 'Installer', profile_config: ProfileConfiguration):
+ profile = profile_config.profile
+
+ if profile:
+ profile.install(install_session)
+
+ if profile and profile_config.gfx_driver:
+ if profile.is_xorg_type_profile() or profile.is_desktop_type_profile():
+ self.install_gfx_driver(install_session, profile_config.gfx_driver)
+
+ if profile and 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)
+ log(err, level=logging.ERROR, fg="red")
+
+ 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__:
+ try:
+ cls_ = v()
+ if isinstance(cls_, Profile):
+ profiles.append(cls_)
+ except Exception:
+ log(f'Cannot import {module}, it does not appear to be a Profile class', level=logging.DEBUG)
+
+ 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])
+ log(err, level=logging.ERROR, fg="red")
+ 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):
+ log(f'Cannot import {file} because it is no longer supported, please use the new profile format')
+ return []
+
+ if not file.is_file():
+ log(f'Cannot find profile file {file}')
+ return []
+
+ name = file.name.removesuffix(file.suffix)
+ log(f'Importing profile: {file}', level=logging.DEBUG)
+
+ try:
+ spec = importlib.util.spec_from_file_location(name, file)
+ if spec is not None:
+ 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:
+ log(f'Unable to parse file {file}: {e}', level=logging.ERROR)
+
+ 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}
+
+ 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=True,
+ 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/storage.py b/archinstall/lib/storage.py
index 8c358161..5a54d816 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.
+ 'PROFILE': Path(__file__).parent.parent.joinpath('default_profiles'),
'LOG_PATH': '/var/log/archinstall',
'LOG_FILE': 'install.log',
- 'MOUNT_POINT': '/mnt/archinstall',
+ '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/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
index 2bc46759..5ee89de0 100644
--- a/archinstall/lib/user_interaction/__init__.py
+++ b/archinstall/lib/user_interaction/__init__.py
@@ -1,12 +1,10 @@
-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 .system_conf import select_kernel, 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
+from .general_conf import (
+ ask_ntp, ask_for_a_timezone, ask_for_audio_selection, select_language, select_mirror_regions,
+ select_archinstall_language, ask_additional_packages_to_install,
+ select_additional_repositories, ask_hostname, add_number_of_parrallel_downloads
+)
+from .utils import get_password
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
index 554d13ef..a77e950a 100644
--- a/archinstall/lib/user_interaction/disk_conf.py
+++ b/archinstall/lib/user_interaction/disk_conf.py
@@ -1,86 +1,391 @@
from __future__ import annotations
-from typing import Any, Dict, TYPE_CHECKING, Optional
+import logging
+from pathlib import Path
+from typing import Any, TYPE_CHECKING, Optional, List, Tuple
-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
+from .. import disk
+from ..hardware import has_uefi
+from ..menu import Menu, MenuSelectionType, TableMenu
+from ..output import FormattedOutput
+from ..output import log
+from ..utils.util import prompt_dir
if TYPE_CHECKING:
_: Any
-def ask_for_main_filesystem_format(advanced_options=False) -> str:
- options = {'btrfs': 'btrfs', 'ext4': 'ext4', 'xfs': 'xfs', 'f2fs': 'f2fs'}
+def select_devices(preset: List[disk.BDevice] = []) -> List[disk.BDevice]:
+ """
+ Asks the user to select one or multiple devices
- advanced = {'ntfs': 'ntfs'}
+ :return: List of selected devices
+ :rtype: list
+ """
- if advanced_options:
- options.update(advanced)
+ 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
- prompt = _('Select which filesystem your main partition should use')
- choice = Menu(prompt, options, skip=False).run()
- return choice.value
+ 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.value # type: ignore
+ selected_devices = []
+
+ for device in devices:
+ if device.device_info in selected_device_info:
+ selected_devices.append(device)
+
+ return selected_devices
-def select_individual_blockdevice_usage(block_devices: list) -> Dict[str, Any]:
- result = {}
+def get_default_partition_layout(
+ devices: List[disk.BDevice],
+ filesystem_type: Optional[disk.FilesystemType] = None,
+ advanced_option: bool = False
+) -> List[disk.DeviceModification]:
- for device in block_devices:
- layout = manage_new_and_existing_partitions(device)
- result[device.path] = layout
+ 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
+ )
- return result
+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)
-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]
+ 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 what you wish to do with the selected block devices'),
- modes,
+ _('Select a partitioning option'),
+ options,
allow_reset=True,
- allow_reset_warning_msg=warning
+ 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.value == wipe_mode:
- return get_default_partition_layout(block_devices, advanced_options)
+ 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"
+
+ path = prompt_dir(str(_('Enter the root directory of the mounted devices: ')), output)
+ mods = disk.device_handler.detect_pre_mounted_mods(path)
+
+ return disk.DiskLayoutConfiguration(
+ config_type=disk.DiskLayoutType.Pre_mount,
+ relative_mountpoint=path,
+ device_modifications=mods
+ )
+
+ 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 _boot_partition() -> disk.PartitionModification:
+ if has_uefi():
+ start = disk.Size(1, disk.Unit.MiB)
+ size = disk.Size(512, disk.Unit.MiB)
+ else:
+ start = disk.Size(3, disk.Unit.MiB)
+ size = disk.Size(203, disk.Unit.MiB)
+
+ # 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=[disk.PartitionFlag.Boot]
+ )
+
+
+def ask_for_main_filesystem_format(advanced_options=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 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 = ask_for_main_filesystem_format(advanced_options)
+
+ min_size_to_allow_home_part = disk.Size(40, disk.Unit.GiB)
+ root_partition_size = disk.Size(20, disk.Unit.GiB)
+ using_subvolumes = False
+ using_home_partition = False
+ compression = False
+ 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()
+
+ 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()
+
+ device_modification = disk.DeviceModification(device, wipe=True)
+
+ # 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()
+ 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:
- return select_individual_blockdevice_usage(block_devices)
+ using_home_partition = False
+ # root partition
+ start = disk.Size(513, disk.Unit.MiB) if has_uefi() else disk.Size(206, disk.Unit.MiB)
-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`.
+ # Set a size for / (/root)
+ if using_subvolumes or device_size_gib < min_size_to_allow_home_part or not using_home_partition:
+ length = disk.Size(100, disk.Unit.Percent, total_size=device.device_info.total_size)
+ else:
+ length = min(device.device_info.total_size, root_partition_size)
- :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
+ root_partition = disk.PartitionModification(
+ status=disk.ModificationStatus.Create,
+ type=disk.PartitionType.Primary,
+ start=start,
+ length=length,
+ mountpoint=Path('/') if not using_subvolumes else None,
+ fs_type=filesystem_type,
+ mount_options=['compress=zstd'] if compression else [],
+ )
+ device_modification.add_partition(root_partition)
- :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'))
+ 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 re-use data between re-installs..
+ # A second partition for /home would be nice if we have the space for it
+ home_partition = disk.PartitionModification(
+ status=disk.ModificationStatus.Create,
+ type=disk.PartitionType.Primary,
+ start=root_partition.length,
+ length=disk.Size(100, disk.Unit.Percent, total_size=device.device_info.total_size),
+ mountpoint=Path('/home'),
+ fs_type=filesystem_type,
+ mount_options=['compress=zstd'] if compression else []
+ )
+ 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)
+ # 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)
+ compression = False
+
+ if not filesystem_type:
+ filesystem_type = ask_for_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:
+ 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()
+
+ device_paths = ', '.join([str(d.device_info.path) for d in devices])
+ log(f"Suggesting multi-disk-layout for devices: {device_paths}", level=logging.DEBUG)
+ log(f"/root: {root_device.device_info.path}", level=logging.DEBUG)
+ log(f"/home: {home_device.device_info.path}", level=logging.DEBUG)
+
+ root_device_modification = disk.DeviceModification(root_device, wipe=True)
+ home_device_modification = disk.DeviceModification(home_device, wipe=True)
- choice = Menu(title, drives).run()
+ # add boot partition to the root device
+ boot_partition = _boot_partition()
+ root_device_modification.add_partition(boot_partition)
- if choice.type_ == MenuSelectionType.Skip:
- return None
+ # add root partition to the root device
+ root_partition = disk.PartitionModification(
+ status=disk.ModificationStatus.Create,
+ type=disk.PartitionType.Primary,
+ start=disk.Size(513, disk.Unit.MiB) if has_uefi() else disk.Size(206, disk.Unit.MiB),
+ length=disk.Size(100, disk.Unit.Percent, total_size=root_device.device_info.total_size),
+ mountpoint=Path('/'),
+ mount_options=['compress=zstd'] if compression else [],
+ fs_type=filesystem_type
+ )
+ root_device_modification.add_partition(root_partition)
- drive = dict_o_disks[choice.value]
- return drive
+ # add home partition to home device
+ home_partition = disk.PartitionModification(
+ status=disk.ModificationStatus.Create,
+ type=disk.PartitionType.Primary,
+ start=disk.Size(1, disk.Unit.MiB),
+ length=disk.Size(100, disk.Unit.Percent, total_size=home_device.device_info.total_size),
+ mountpoint=Path('/home'),
+ mount_options=['compress=zstd'] if compression else [],
+ fs_type=filesystem_type,
+ )
+ home_device_modification.add_partition(home_partition)
- raise DiskError('select_disk() requires a non-empty dictionary of disks to select from.')
+ return [root_device_modification, home_device_modification]
diff --git a/archinstall/lib/user_interaction/general_conf.py b/archinstall/lib/user_interaction/general_conf.py
index fc7ded45..7a6bb358 100644
--- a/archinstall/lib/user_interaction/general_conf.py
+++ b/archinstall/lib/user_interaction/general_conf.py
@@ -3,15 +3,13 @@ from __future__ import annotations
import logging
import pathlib
from typing import List, Any, Optional, Dict, TYPE_CHECKING
+from typing import Union
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 ..menu import MenuSelectionType, Menu, 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
@@ -32,9 +30,10 @@ def ask_ntp(preset: bool = True) -> bool:
def ask_hostname(preset: str = None) -> str:
- hostname = TextInput(_('Desired hostname for the installation: '), preset).run().strip(' ')
- return hostname
-
+ while True:
+ hostname = TextInput(_('Desired hostname for the installation: '), preset).run().strip()
+ if hostname:
+ return hostname
def ask_for_a_timezone(preset: str = None) -> str:
timezones = list_timezones()
@@ -52,7 +51,7 @@ def ask_for_a_timezone(preset: str = None) -> str:
case MenuSelectionType.Selection: return choice.value
-def ask_for_audio_selection(desktop: bool = True, preset: str = None) -> str:
+def ask_for_audio_selection(desktop: bool = True, preset: Union[str, None] = None) -> Union[str, None]:
no_audio = str(_('No audio server'))
choices = ['pipewire', 'pulseaudio'] if desktop else ['pipewire', 'pulseaudio', no_audio]
default = 'pipewire' if desktop else no_audio
@@ -140,50 +139,6 @@ def select_archinstall_language(languages: List[Language], preset_value: Languag
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.'))
diff --git a/archinstall/lib/user_interaction/locale_conf.py b/archinstall/lib/user_interaction/locale_conf.py
index bbbe070b..88aec64e 100644
--- a/archinstall/lib/user_interaction/locale_conf.py
+++ b/archinstall/lib/user_interaction/locale_conf.py
@@ -3,8 +3,7 @@ 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
+from ..menu import Menu, MenuSelectionType
if TYPE_CHECKING:
_: Any
diff --git a/archinstall/lib/user_interaction/manage_users_conf.py b/archinstall/lib/user_interaction/manage_users_conf.py
index 84ce3556..879578da 100644
--- a/archinstall/lib/user_interaction/manage_users_conf.py
+++ b/archinstall/lib/user_interaction/manage_users_conf.py
@@ -4,8 +4,7 @@ import re
from typing import Any, Dict, 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
@@ -27,14 +26,14 @@ class UserList(ListManager):
]
super().__init__(prompt, lusers, [self._actions[0]], self._actions[1:])
- def reformat(self, data: List[User]) -> Dict[str, User]:
+ def reformat(self, data: List[User]) -> Dict[str, Any]:
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}
+ display_data: Dict[str, Optional[User]] = {f' {rows[0]}': None, f' {rows[1]}': None}
for row, user in zip(rows[2:], data):
row = row.replace('|', '\\|')
@@ -53,16 +52,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
@@ -80,16 +79,20 @@ class UserList(ListManager):
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/user_interaction/network_conf.py
index 5e637f23..b682c1d2 100644
--- a/archinstall/lib/user_interaction/network_conf.py
+++ b/archinstall/lib/user_interaction/network_conf.py
@@ -4,14 +4,12 @@ import ipaddress
import logging
from typing import Any, Optional, TYPE_CHECKING, List, Union, Dict
-from ..menu.menu import MenuSelectionType
-from ..menu.text_input import TextInput
+from ..menu import MenuSelectionType, TextInput
from ..models.network_configuration import NetworkConfiguration, NicType
from ..networking import list_interfaces
-from ..menu import Menu
from ..output import log, FormattedOutput
-from ..menu.list_manager import ListManager
+from ..menu import ListManager, Menu
if TYPE_CHECKING:
_: Any
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
index 5b4ae2b3..e05b9afe 100644
--- a/archinstall/lib/user_interaction/save_conf.py
+++ b/archinstall/lib/user_interaction/save_conf.py
@@ -5,38 +5,30 @@ 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
+from ..configuration import ConfigurationOutput
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}'
+ serialized = config_output.user_config_to_json()
+ return f'{config_output.user_configuration_file}\n{serialized}'
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}'
+ if maybe_serial := config_output.user_credentials_to_json():
+ return f'{config_output.user_credentials_file}\n{maybe_serial}'
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():
+ if 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
@@ -61,6 +53,9 @@ def save_config(config: Dict):
if choice.type_ == MenuSelectionType.Skip:
return
+ save_config_value = choice.single_value
+ saving_key = [k for k, v in options.items() if v == save_config_value][0]
+
dirs_to_exclude = [
'/bin',
'/dev',
@@ -76,19 +71,19 @@ def save_config(config: Dict):
'/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('Ignore configuration option 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'))
- )
+
+ directories = SysCommand(file_picker_command).decode()
+
+ if directories is None:
+ raise ValueError('Failed to retrieve possible configuration directories')
+
+ possible_save_dirs = list(filter(None, directories.split('\x00')))
selection = Menu(
_('Select directory (or directories) for saving configuration files'),
@@ -101,35 +96,18 @@ def save_config(config: Dict):
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
- )
-
+
+ save_dirs = selection.multi_value
+
+ log(f'Saving {saving_key} configuration files to {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:
+ if options['user_config'] == save_config_value:
config_output.save_user_config(save_dir)
- elif options['user_creds'] == choice.value:
+ elif options['user_creds'] == save_config_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:
+ elif options['all'] == save_config_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/system_conf.py b/archinstall/lib/user_interaction/system_conf.py
index e1581677..3f57d0e7 100644
--- a/archinstall/lib/user_interaction/system_conf.py
+++ b/archinstall/lib/user_interaction/system_conf.py
@@ -1,19 +1,16 @@
from __future__ import annotations
-from typing import List, Any, Dict, TYPE_CHECKING
+from typing import List, Any, Dict, TYPE_CHECKING, Optional
-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
+from ..menu import MenuSelectionType, Menu
+from ..models.bootloader import Bootloader
if TYPE_CHECKING:
_: Any
-def select_kernel(preset: List[str] = None) -> List[str]:
+def select_kernel(preset: List[str] = []) -> List[str]:
"""
Asks the user to select a kernel for system.
@@ -39,39 +36,36 @@ def select_kernel(preset: List[str] = None) -> List[str]:
match choice.type_:
case MenuSelectionType.Skip: return preset
case MenuSelectionType.Reset: return []
- case MenuSelectionType.Selection: return choice.value
+ case MenuSelectionType.Selection: return choice.value # type: ignore
-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!'))
+def ask_for_bootloader(preset: Bootloader) -> Bootloader:
+ # when the system only supports grub
+ if not has_uefi():
+ options = [Bootloader.Grub.value]
+ default = Bootloader.Grub.value
+ else:
+ options = Bootloader.values()
+ default = Bootloader.Systemd.value
- warning = str(_('If you reset the harddrive selection this will also reset the current disk layout. Are you sure?'))
+ preset_value = preset.value if preset else None
- selected_harddrive = Menu(
- title,
- list(options.keys()),
- multi=True,
- allow_reset=True,
- allow_reset_warning_msg=warning
+ choice = Menu(
+ _('Choose a bootloader'),
+ options,
+ preset_values=preset_value,
+ sort=False,
+ default_option=default
).run()
- match selected_harddrive.type_:
- case MenuSelectionType.Reset: return []
+ match choice.type_:
case MenuSelectionType.Skip: return preset
- case MenuSelectionType.Selection: return [options[i] for i in selected_harddrive.value]
+ case MenuSelectionType.Selection: return Bootloader(choice.value)
+
+ return preset
-def select_driver(options: Dict[str, Any] = AVAILABLE_GFX_DRIVERS) -> str:
+def select_driver(options: Dict[str, Any] = {}, current_value: Optional[str] = None) -> Optional[str]:
"""
Some what convoluted function, whose job is simple.
Select a graphics driver from a pre-defined set of popular options.
@@ -80,78 +74,31 @@ def select_driver(options: Dict[str, Any] = AVAILABLE_GFX_DRIVERS) -> str:
there for appeal to the general public first and edge cases later)
"""
- drivers = sorted(list(options))
+ if not options:
+ options = AVAILABLE_GFX_DRIVERS
+
+ drivers = sorted(list(options.keys()))
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'
+ 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'
- ))
+ 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(_('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()
+ title += str(_('\nSelect a graphics driver or leave blank to install all open-source drivers'))
- if choice.type_ != MenuSelectionType.Selection:
- return arguments.get('gfx_driver')
+ preset = current_value if current_value else None
+ choice = Menu(title, drivers, preset_values=preset).run()
- 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.")
+ if choice.type_ != MenuSelectionType.Selection:
+ return None
+ return choice.value # type: ignore
-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
+ return current_value
def ask_for_swap(preset: bool = True) -> bool:
@@ -166,3 +113,5 @@ def ask_for_swap(preset: bool = True) -> bool:
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/user_interaction/utils.py b/archinstall/lib/user_interaction/utils.py
index 7ee6fc07..918945c0 100644
--- a/archinstall/lib/user_interaction/utils.py
+++ b/archinstall/lib/user_interaction/utils.py
@@ -1,13 +1,9 @@
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 ..models import PasswordStrength
from ..output import log
if TYPE_CHECKING:
@@ -36,44 +32,3 @@ def get_password(prompt: str = '') -> Optional[str]:
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..ded480ae
--- /dev/null
+++ b/archinstall/lib/utils/util.py
@@ -0,0 +1,30 @@
+from pathlib import Path
+from typing import Any, TYPE_CHECKING, Optional
+
+from ..output import log
+
+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
+ log(_('Not a valid directory: {}').format(dest_path), fg='red')
+
+
+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