Add simple menu for better UX (#660)

* Add simple menu for better UX
* Add remove external dependency
* Fix harddisk return value on skip
* Table output for partitioning process
* Switch partitioning to simple menu
* fixup! Switch partitioning to simple menu
* Ignoring complexity and binary operator issues
Only in simple_menu.py
* Added license text to the MIT licensed file
* Added in versioning information
* Fixed some imports and removed the last generic_select() from user_interaction. Also fixed a revert/merged fork of ask_for_main_filesystem_format()
* Update color scheme to match Arch style better
* Use cyan as default cursor color
* Leave simple menu the same

Co-authored-by: Daniel Girtler <girtler.daniel@gmail.com>
Co-authored-by: Anton Hvornum <anton.feeds+github@gmail.com>
Co-authored-by: Dylan M. Taylor <dylan@dylanmtaylor.com>
This commit is contained in:
Daniel 2021-12-03 07:17:51 +11:00 committed by GitHub
parent 22ee2d90a1
commit 908c7b8cc0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 2322 additions and 381 deletions

View File

@ -6,4 +6,4 @@ max-complexity = 40
max-line-length = 236
show-source = True
statistics = True
per-file-ignores = __init__.py:F401,F403,F405
per-file-ignores = __init__.py:F401,F403,F405 simple_menu.py:C901,W503

3
.gitignore vendored
View File

@ -25,5 +25,6 @@ SAFETY_LOCK
/guided.py
/install.log
venv
.venv
.idea/**
**/install.log
**/install.log

View File

@ -20,6 +20,7 @@ from .lib.services import *
from .lib.storage import *
from .lib.systemd import *
from .lib.user_interaction import *
from .lib.menu import Menu
parser = ArgumentParser()
@ -88,7 +89,6 @@ if arguments.get('plugin', None):
# TODO: Learn the dark arts of argparse... (I summon thee dark spawn of cPython)
def run_as_a_module():
"""
Since we're running this as a 'python -m archinstall' module OR

View File

@ -16,7 +16,8 @@ def valid_parted_position(pos :str):
return False
def valid_fs_type(fstype :str) -> bool:
def fs_types():
# https://www.gnu.org/software/parted/manual/html_node/mkpart.html
# Above link doesn't agree with `man parted` /mkpart documentation:
"""
@ -27,16 +28,19 @@ def valid_fs_type(fstype :str) -> bool:
"linux-swap", "ntfs", "reis
erfs", "udf", or "xfs".
"""
return fstype.lower() in [
return [
"btrfs",
"ext2",
"ext3", "ext4", # `man parted` allows these
"ext3", "ext4", # `man parted` allows these
"fat16", "fat32",
"hfs", "hfs+", # "hfsx", not included in `man parted`
"hfs", "hfs+", # "hfsx", not included in `man parted`
"linux-swap",
"ntfs",
"reiserfs",
"udf", # "ufs", not included in `man parted`
"xfs", # `man parted` allows this
"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()

View File

@ -47,3 +47,8 @@ def set_keyboard_language(locale):
return True
return False
def list_timezones():
for line in SysCommand("timedatectl --no-pager list-timezones", environment_vars={'SYSTEMD_COLORS': '0'}):
yield line.decode('UTF-8').strip()

91
archinstall/lib/menu.py Normal file
View File

@ -0,0 +1,91 @@
from .simple_menu import TerminalMenu
class Menu(TerminalMenu):
def __init__(self, title, options, skip=True, multi=False, default_option=None, sort=True):
"""
Creates a new menu
:param title: Text that will be displayed above the menu
:type title: str
:param 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
:param skip: Indicate if the selection is not mandatory and can be skipped
:type skip: bool
:param multi: Indicate if multiple options can be selected
:type multi: bool
:param default_option: The default option to be used in case the selection processes is skipped
:type default_option: str
:param sort: Indicate if the options should be sorted alphabetically before displaying
:type sort: bool
"""
if isinstance(options, dict):
options = list(options)
if sort:
options = sorted(options)
self.menu_options = options
self.skip = skip
self.default_option = default_option
self.multi = multi
menu_title = f'\n{title}\n\n'
if skip:
menu_title += "Use ESC to skip\n\n"
if default_option:
# if a default value was specified we move that one
# to the top of the list and mark it as default as well
default = f'{default_option} (default)'
self.menu_options = [default] + [o for o in self.menu_options if default_option != o]
cursor = "> "
main_menu_cursor_style = ("fg_cyan", "bold")
main_menu_style = ("bg_blue", "fg_gray")
super().__init__(
menu_entries=self.menu_options,
title=menu_title,
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
)
def _show(self):
idx = self.show()
if idx is not None:
if isinstance(idx, (list, tuple)):
return [self.menu_options[i] for i in idx]
else:
selected = self.menu_options[idx]
if ' (default)' in selected and self.default_option:
return self.default_option
return selected
else:
if self.default_option:
if self.multi:
return [self.default_option]
else:
return self.default_option
return None
def run(self):
ret = self._show()
if ret is None and not self.skip:
return self.run()
return ret

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,6 @@
import getpass
import ipaddress
import logging
import pathlib
import re
import select # Used for char by char polling of sys.stdin
import shutil
@ -9,17 +8,20 @@ import signal
import sys
import time
from .disk import BlockDevice, valid_fs_type, suggest_single_disk_layout, suggest_multi_disk_layout, valid_parted_position
from .disk import BlockDevice, suggest_single_disk_layout, suggest_multi_disk_layout, valid_parted_position, all_disks
from .exceptions import RequirementError, UserError, DiskError
from .hardware import AVAILABLE_GFX_DRIVERS, has_uefi, has_amd_graphics, has_intel_graphics, has_nvidia_graphics
from .locale_helpers import list_keyboard_languages, verify_keyboard_layout, search_keyboard_layout
from .locale_helpers import list_keyboard_languages, list_timezones
from .networking import list_interfaces
from .menu import Menu
from .output import log
from .profiles import Profile, list_profiles
from .storage import storage
from .mirrors import list_mirrors
# TODO: Some inconsistencies between the selection processes.
# Some return the keys from the options, some the values?
from .. import fs_types
def get_terminal_height():
@ -117,81 +119,6 @@ def print_large_list(options, padding=5, margin_bottom=0, separator=': '):
return column, row
def generic_multi_select(options, text="Select one or more of the options above (leave blank to continue): ", sort=True, default=None, allow_empty=False):
# Checking if the options are different from `list` or `dict` or if they are empty
if type(options) not in [list, dict, type({}.keys()), type({}.values())]:
log(f" * Generic multi-select doesn't support ({type(options)}) as type of options * ", fg='red')
log(" * If problem persists, please create an issue on https://github.com/archlinux/archinstall/issues * ", fg='yellow')
raise RequirementError("generic_multi_select() requires list or dictionary as options.")
if not options:
log(" * Generic multi-select didn't find any options to choose from * ", fg='red')
log(" * If problem persists, please create an issue on https://github.com/archlinux/archinstall/issues * ", fg='yellow')
raise RequirementError('generic_multi_select() requires at least one option to proceed.')
# After passing the checks, function continues to work
if type(options) == dict:
options = list(options.values())
elif type(options) in (type({}.keys()), type({}.values())):
options = list(options)
if sort:
options = sorted(options)
section = MiniCurses(get_terminal_width(), len(options))
selected_options = []
while True:
if not selected_options and default in options:
selected_options.append(default)
printed_options = []
for option in options:
if option in selected_options:
printed_options.append(f'>> {option}')
else:
printed_options.append(f'{option}')
section.clear(0, get_terminal_height() - section._cursor_y - 1)
print_large_list(printed_options, margin_bottom=2)
section._cursor_y = len(printed_options)
section._cursor_x = 0
section.write_line(text)
section.input_pos = section._cursor_x
selected_option = section.get_keyboard_input(end=None)
# This string check is necessary to correct work with it
# Without this, Python will raise AttributeError because of stripping `None`
# It also allows to remove empty spaces if the user accidentally entered them.
if isinstance(selected_option, str):
selected_option = selected_option.strip()
try:
if not selected_option:
if not selected_options and default:
selected_options = [default]
elif selected_options or allow_empty:
break
else:
raise RequirementError('Please select at least one option to continue')
elif selected_option.isnumeric():
if (selected_option := int(selected_option)) >= len(options):
raise RequirementError(f'Selected option "{selected_option}" is out of range')
selected_option = options[selected_option]
if selected_option in selected_options:
selected_options.remove(selected_option)
else:
selected_options.append(selected_option)
elif selected_option in options:
if selected_option in selected_options:
selected_options.remove(selected_option)
else:
selected_options.append(selected_option)
else:
raise RequirementError(f'Selected option "{selected_option}" does not exist in available options')
except RequirementError as e:
log(f" * {e} * ", fg='red')
sys.stdout.write('\n')
sys.stdout.flush()
return selected_options
def select_encrypted_partitions(block_devices :dict, password :str) -> dict:
for device in block_devices:
for partition in block_devices[device]['partitions']:
@ -208,6 +135,7 @@ def select_encrypted_partitions(block_devices :dict, password :str) -> dict:
# TODO: Next version perhaps we can support mixed multiple encrypted partitions
# Users might want to single out a partition for non-encryption to share between dualboot etc.
class MiniCurses:
def __init__(self, width, height):
self.width = width
@ -370,18 +298,17 @@ def ask_for_additional_users(prompt='Any additional users to install (leave blan
def ask_for_a_timezone():
while True:
timezone = input('Enter a valid timezone (examples: Europe/Stockholm, US/Eastern) or press enter to use UTC: ').strip().strip('*.')
if timezone == '':
timezone = 'UTC'
if (pathlib.Path("/usr") / "share" / "zoneinfo" / timezone).exists():
return timezone
else:
log(
f"Specified timezone {timezone} does not exist.",
level=logging.WARNING,
fg='red'
)
timezones = list_timezones()
default = 'UTC'
selected_tz = Menu(
f'Select a timezone or leave blank to use default "{default}"',
timezones,
skip=False,
default_option=default
).run()
return selected_tz
def ask_for_bootloader(advanced_options=False) -> str:
@ -394,7 +321,7 @@ def ask_for_bootloader(advanced_options=False) -> str:
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 = generic_select(choices, f'Choose a bootloader or leave blank to use systemd-boot: ', options_output=True)
selection = Menu('Choose a bootloader or leave blank to use systemd-boot', choices).run()
if selection != "":
if selection == 'systemd-boot':
bootloader = 'systemd-bootctl'
@ -409,11 +336,8 @@ def ask_for_bootloader(advanced_options=False) -> str:
def ask_for_audio_selection(desktop=True):
audio = 'pipewire' if desktop else 'none'
choices = ['pipewire', 'pulseaudio'] if desktop else ['pipewire', 'pulseaudio', 'none']
selection = generic_select(choices, f'Choose an audio server or leave blank to use {audio}: ', options_output=True)
if selection != "":
audio = selection
return audio
selected_audio = Menu(f'Choose an audio server or leave blank to use "{audio}"', choices, default_option=audio).run()
return selected_audio
def ask_to_configure_network():
@ -426,7 +350,8 @@ def ask_to_configure_network():
**list_interfaces()
}
nic = generic_select(interfaces, "Select one network interface to configure (leave blank to skip): ")
nic = Menu('Select one network interface to configure', interfaces.values()).run()
if nic and nic != 'Copy ISO network configuration to installation':
if nic == 'Use NetworkManager (necessary to configure internet graphically in GNOME and KDE)':
return {'nic': nic, 'NetworkManager': True}
@ -436,11 +361,15 @@ def ask_to_configure_network():
# printing out this part separate from options, passed in
# `generic_select`
modes = ['DHCP (auto detect)', 'IP (static)']
for index, mode in enumerate(modes):
print(f"{index}: {mode}")
default_mode = 'DHCP (auto detect)'
mode = generic_select(['DHCP', 'IP'], f"Select which mode to configure for {nic} or leave blank for DHCP: ", options_output=False)
if mode == 'IP':
mode = Menu(
f'Select which mode to configure for "{nic}" or leave blank for default "{default_mode}"',
modes,
default_option=default_mode
).run()
if mode == 'IP (static)':
while 1:
ip = input(f"Enter the IP and subnet for {nic} (example: 192.168.0.5/24): ").strip()
# Implemented new check for correct IP/subnet input
@ -483,15 +412,9 @@ def ask_to_configure_network():
return {}
def ask_for_disk_layout():
options = {
'keep-existing': 'Keep existing partition layout and select which ones to use where',
'format-all': 'Format entire drive and setup a basic partition scheme',
'abort': 'Abort the installation',
}
value = generic_select(options, "Found partitions on the selected drive, (select by number) what you want to do: ", allow_empty_input=False, sort=True)
return next((key for key, val in options.items() if val == value), None)
def partition_overlap(partitions :list, start :str, end :str) -> bool:
# TODO: Implement sanity check
return False
def ask_for_main_filesystem_format(advanced_options=False):
@ -509,77 +432,64 @@ def ask_for_main_filesystem_format(advanced_options=False):
if advanced_options:
options.update(advanced)
value = generic_select(options, "Select which filesystem your main partition should use (by number or name): ", allow_empty_input=False)
return next((key for key, val in options.items() if val == value), None)
return Menu('Select which filesystem your main partition should use', options, skip=False).run()
def generic_select(options, input_text="Select one of the above by index or absolute value: ", allow_empty_input=True, options_output=True, sort=False):
"""
A generic select function that does not output anything
other than the options and their indexes. As an example:
def current_partition_layout(partitions, with_idx=False):
def do_padding(name, max_len):
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 * " "}|'
generic_select(["first", "second", "third option"])
0: first
1: second
2: third option
column_names = {}
When the user has entered the option correctly,
this function returns an item from list, a string, or None
"""
# this will add an initial index to the table for each partition
if with_idx:
column_names['index'] = max([len(str(len(partitions))), len('index')])
# Checking if the options are different from `list` or `dict` or if they are empty
if type(options) not in [list, dict]:
log(f" * Generic select doesn't support ({type(options)}) as type of options * ", fg='red')
log(" * If problem persists, please create an issue on https://github.com/archlinux/archinstall/issues * ", fg='yellow')
raise RequirementError("generic_select() requires list or dictionary as options.")
if not options:
log(" * Generic select didn't find any options to choose from * ", fg='red')
log(" * If problem persists, please create an issue on https://github.com/archlinux/archinstall/issues * ", fg='yellow')
raise RequirementError('generic_select() requires at least one option to proceed.')
# After passing the checks, function continues to work
if type(options) == dict:
# To allow only `list` and `dict`, converting values of options here.
# Therefore, now we can only provide the dictionary itself
options = list(options.values())
if sort:
# As we pass only list and dict (converted to list), we can skip converting to list
options = sorted(options)
# Added ability to disable the output of options items,
# if another function displays something different from this
if options_output:
for index, option in enumerate(options):
print(f"{index}: {option}")
# The new changes introduce a single while loop for all inputs processed by this function
# Now the try...except block handles validation for invalid input from the user
while True:
try:
selected_option = input(input_text).strip()
if not selected_option:
# `allow_empty_input` parameter handles return of None on empty input, if necessary
# Otherwise raise `RequirementError`
if allow_empty_input:
return None
raise RequirementError('Please select an option to continue')
# Replaced `isdigit` with` isnumeric` to discard all negative numbers
elif selected_option.isnumeric():
if (selected_option := int(selected_option)) >= len(options):
raise RequirementError(f'Selected option "{selected_option}" is out of range')
selected_option = options[selected_option]
break
elif selected_option in options:
break # We gave a correct absolute value
# determine all attribute names and the max length
# of the value among all partitions to know the width
# of the table cells
for p in partitions:
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:
raise RequirementError(f'Selected option "{selected_option}" does not exist in available options')
except RequirementError as err:
log(f" * {err} * ", fg='red')
column_names[attribute] = max([len(str(value)), len(attribute)])
return selected_option
current_layout = ''
for name, max_len in column_names.items():
current_layout += do_padding(name, max_len)
def partition_overlap(partitions :list, start :str, end :str) -> bool:
# TODO: Implement sanity check
return False
current_layout = f'{current_layout[:-1]}\n{"-" * len(current_layout)}\n'
for idx, p in enumerate(partitions):
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'
return f'\n\nCurrent partition layout:\n\n{current_layout}'
def select_partition(title, partitions, multiple=False):
partition_indexes = list(map(str, range(len(partitions))))
partition = Menu(title, partition_indexes, multi=multiple).run()
if partition is not None:
if isinstance(partition, list):
return [int(p) for p in partition]
else:
return int(partition)
return None
def get_default_partition_layout(block_devices, advanced_options=False):
if len(block_devices) == 1:
@ -587,7 +497,6 @@ def get_default_partition_layout(block_devices, advanced_options=False):
else:
return suggest_multi_disk_layout(block_devices, advanced_options=advanced_options)
# TODO: Implement sane generic layout for 2+ drives
def manage_new_and_existing_partitions(block_device :BlockDevice) -> dict:
# if has_uefi():
@ -624,7 +533,7 @@ def manage_new_and_existing_partitions(block_device :BlockDevice) -> dict:
# return struct
block_device_struct = {
"partitions" : [partition.__dump__() for partition in block_device.partitions.values()]
"partitions": [partition.__dump__() for partition in block_device.partitions.values()]
}
# Test code: [part.__dump__() for part in block_device.partitions.values()]
# TODO: Squeeze in BTRFS subvolumes here
@ -632,25 +541,27 @@ def manage_new_and_existing_partitions(block_device :BlockDevice) -> dict:
while True:
modes = [
"Create a new partition",
f"Suggest partition layout for {block_device}",
"Delete a partition" if len(block_device_struct) else "",
"Clear/Delete all partitions" if len(block_device_struct) else "",
"Assign mount-point for a partition" if len(block_device_struct) else "",
"Mark/Unmark a partition to be formatted (wipes data)" if len(block_device_struct) else "",
"Mark/Unmark a partition as encrypted" if len(block_device_struct) else "",
"Mark/Unmark a partition as bootable (automatic for /boot)" if len(block_device_struct) else "",
"Set desired filesystem for a partition" if len(block_device_struct) else "",
f"Suggest partition layout for {block_device}"
]
# Print current partition layout:
if len(block_device_struct["partitions"]):
print('Current partition layout:')
for partition in block_device_struct["partitions"]:
print(partition)
print()
if len(block_device_struct['partitions']):
modes += [
"Delete a partition",
"Clear/Delete all partitions",
"Assign mount-point for a partition",
"Mark/Unmark a partition to be formatted (wipes data)",
"Mark/Unmark a partition as encrypted",
"Mark/Unmark a partition as bootable (automatic for /boot)",
"Set desired filesystem for a partition",
]
task = generic_select(modes,
input_text=f"Select what to do with {block_device} (leave blank when done): ")
title = f'Select what to do with \n{block_device}'
# show current partition layout:
if len(block_device_struct["partitions"]):
title += current_partition_layout(block_device_struct['partitions']) + '\n'
task = Menu(title, modes, sort=False).run()
if not task:
break
@ -661,7 +572,7 @@ def manage_new_and_existing_partitions(block_device :BlockDevice) -> dict:
# # https://www.gnu.org/software/parted/manual/html_node/mklabel.html
# name = input("Enter a desired name for the partition: ").strip()
fstype = input("Enter a desired filesystem type for the partition: ").strip()
fstype = Menu('Enter a desired filesystem type for the partition', fs_types(), skip=False).run()
start = input(f"Enter the start sector (percentage or block number, default: {block_device.first_free_sector}): ").strip()
if not start.strip():
@ -669,17 +580,19 @@ def manage_new_and_existing_partitions(block_device :BlockDevice) -> dict:
end_suggested = block_device.first_end_sector
else:
end_suggested = '100%'
end = input(f"Enter the end sector of the partition (percentage or block number, ex: {end_suggested}): ").strip()
if not end.strip():
end = end_suggested
if valid_parted_position(start) and valid_parted_position(end) and valid_fs_type(fstype):
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 MSDOS, but GPT accepts it so it's "safe" to inject
"type" : "primary", # Strictly only allowed under MSDOS, but GPT accepts it so it's "safe" to inject
"start" : start,
"size" : end,
"mountpoint" : None,
@ -689,7 +602,7 @@ def manage_new_and_existing_partitions(block_device :BlockDevice) -> dict:
}
})
else:
log(f"Invalid start ({valid_parted_position(start)}), end ({valid_parted_position(end)}) or fstype ({valid_fs_type(fstype)}) for this partition. Ignoring this partition creation.", fg="red")
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[:len("Suggest partition layout")] == "Suggest partition layout":
if len(block_device_struct["partitions"]):
@ -700,77 +613,83 @@ def manage_new_and_existing_partitions(block_device :BlockDevice) -> dict:
elif task is None:
return block_device_struct
else:
for index, partition in enumerate(block_device_struct["partitions"]):
print(f"{index}: Start: {partition['start']}, End: {partition['size']} ({partition['filesystem']['format']}{', mounting at: '+partition['mountpoint'] if partition['mountpoint'] else ''})")
current_layout = current_partition_layout(block_device_struct['partitions'], with_idx=True)
if task == "Delete a partition":
if (partition := generic_select(block_device_struct["partitions"], 'Select which partition to delete: ', options_output=False)):
del(block_device_struct["partitions"][block_device_struct["partitions"].index(partition)])
title = f'{current_layout}\n\nSelect by index which partitions to delete'
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 == "Clear/Delete all partitions":
block_device_struct["partitions"] = []
elif task == "Assign mount-point for a partition":
if (partition := generic_select(block_device_struct["partitions"], 'Select which partition to mount where: ', options_output=False)):
title = f'{current_layout}\n\nSelect by index which partition to mount where'
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"][block_device_struct["partitions"].index(partition)]['mountpoint'] = 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"][block_device_struct["partitions"].index(partition)]['boot'] = True
else:
del(block_device_struct["partitions"][block_device_struct["partitions"].index(partition)]['mountpoint'])
del(block_device_struct["partitions"][partition]['mountpoint'])
elif task == "Mark/Unmark a partition to be formatted (wipes data)":
if (partition := generic_select(block_device_struct["partitions"], 'Select which partition to mask for formatting: ', options_output=False)):
title = f'{current_layout}\n\nSelect which partition to mask for formatting'
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"][block_device_struct["partitions"].index(partition)].get('filesystem', {}).get('format', 'crypto_LUKS') == 'crypto_LUKS':
if not block_device_struct["partitions"][block_device_struct["partitions"].index(partition)].get('filesystem', None):
block_device_struct["partitions"][block_device_struct["partitions"].index(partition)]['filesystem'] = {}
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'] = {}
while True:
fstype = input("Enter a desired filesystem type for the partition: ").strip()
if not valid_fs_type(fstype):
log(f"Desired filesystem {fstype} is not a valid filesystem.", level=logging.ERROR, fg="red")
continue
break
fstype = Menu('Enter a desired filesystem type for the partition', fs_types(), skip=False).run()
block_device_struct["partitions"][block_device_struct["partitions"].index(partition)]['filesystem']['format'] = fstype
block_device_struct["partitions"][partition]['filesystem']['format'] = fstype
# Negate the current wipe marking
block_device_struct["partitions"][block_device_struct["partitions"].index(partition)]['format'] = not block_device_struct["partitions"][block_device_struct["partitions"].index(partition)].get('format', False)
block_device_struct["partitions"][partition]['format'] = not block_device_struct["partitions"][partition].get('format', False)
elif task == "Mark/Unmark a partition as encrypted":
if (partition := generic_select(block_device_struct["partitions"], 'Select which partition to mark as encrypted: ', options_output=False)):
title = f'{current_layout}\n\nSelect which partition to mark as encrypted'
partition = select_partition(title, block_device_struct["partitions"])
if partition is not None:
# Negate the current encryption marking
block_device_struct["partitions"][block_device_struct["partitions"].index(partition)]['encrypted'] = not block_device_struct["partitions"][block_device_struct["partitions"].index(partition)].get('encrypted', False)
block_device_struct["partitions"][partition]['encrypted'] = not block_device_struct["partitions"][partition].get('encrypted', False)
elif task == "Mark/Unmark a partition as bootable (automatic for /boot)":
if (partition := generic_select(block_device_struct["partitions"], 'Select which partition to mark as bootable: ', options_output=False)):
block_device_struct["partitions"][block_device_struct["partitions"].index(partition)]['boot'] = not block_device_struct["partitions"][block_device_struct["partitions"].index(partition)].get('boot', False)
title = f'{current_layout}\n\nSelect which partition to mark as bootable'
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 desired filesystem for a partition":
if not block_device_struct["partitions"]:
log("No partitions found. Create some partitions first", level=logging.WARNING, fg='yellow')
continue
elif (partition := generic_select(block_device_struct["partitions"], 'Select which partition to set a filesystem on: ', options_output=False)):
if not block_device_struct["partitions"][block_device_struct["partitions"].index(partition)].get('filesystem', None):
block_device_struct["partitions"][block_device_struct["partitions"].index(partition)]['filesystem'] = {}
title = f'{current_layout}\n\nSelect which partition to set a filesystem on'
partition = select_partition(title, block_device_struct["partitions"])
while True:
fstype = input("Enter a desired filesystem type for the partition: ").strip()
if not valid_fs_type(fstype):
log(f"Desired filesystem {fstype} is not a valid filesystem.", level=logging.ERROR, fg="red")
continue
break
if partition is not None:
if not block_device_struct["partitions"][partition].get('filesystem', None):
block_device_struct["partitions"][partition]['filesystem'] = {}
block_device_struct["partitions"][block_device_struct["partitions"].index(partition)]['filesystem']['format'] = fstype
fstype_title = 'Enter a desired filesystem type for the partition: '
fstype = Menu(fstype_title, fs_types(), skip=False).run()
block_device_struct["partitions"][partition]['filesystem']['format'] = fstype
return block_device_struct
def select_individual_blockdevice_usage(block_devices :list):
def select_individual_blockdevice_usage(block_devices: list):
result = {}
for device in block_devices:
@ -787,7 +706,7 @@ def select_disk_layout(block_devices :list, advanced_options=False):
"Select what to do with each individual drive (followed by partition usage)"
]
mode = generic_select(modes, input_text=f"Select what you wish to do with the selected block devices: ")
mode = Menu('Select what you wish to do with the selected block devices', modes, skip=False).run()
if mode == 'Wipe all selected drives and use a best-effort default partition layout':
return get_default_partition_layout(block_devices, advanced_options)
@ -812,7 +731,8 @@ def select_disk(dict_o_disks):
print(f"{index}: {drive} ({dict_o_disks[drive]['size'], dict_o_disks[drive].device, dict_o_disks[drive]['label']})")
log("You can skip selecting a drive and partitioning and use whatever drive-setup is mounted at /mnt (experimental)", fg="yellow")
drive = generic_select(drives, 'Select one of the above disks (by name or number) or leave blank to use /mnt: ', options_output=False)
drive = Menu('Select one of the disks or skip and use "/mnt" as default"', drives).run()
if not drive:
return drive
@ -824,128 +744,90 @@ def select_disk(dict_o_disks):
def select_profile():
"""
Asks the user to select a profile from the available profiles.
# 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 = {}
:return: The name/dictionary key of the selected profile
:rtype: str
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'
selection = Menu(title=title, options=options.keys()).run()
if selection is not None:
return options[selection]
return None
def select_language():
"""
shown_profiles = sorted(list(list_profiles(filter_top_level_profiles=True)))
actual_profiles_raw = shown_profiles + sorted([profile for profile in list_profiles() if profile not in shown_profiles])
if len(shown_profiles) >= 1:
for index, profile in enumerate(shown_profiles):
description = Profile(None, profile).get_profile_description()
print(f"{index}: {profile}: {description}")
print(' -- The above list is a set of pre-programmed profiles. --')
print(' -- They might make it easier to install things like desktop environments. --')
print(' -- (Leave blank and hit enter to skip this step and continue) --')
selected_profile = generic_select(actual_profiles_raw, 'Enter a pre-programmed profile name if you want to install one: ', options_output=False)
if selected_profile:
return Profile(None, selected_profile)
else:
raise RequirementError("Selecting profiles require a least one profile to be given as an option.")
def select_language(options, show_only_country_codes=True, input_text='Select one of the above keyboard languages (by number or full name): '):
"""
Asks the user to select a language from the `options` dictionary parameter.
Asks the user to select a language
Usually this is combined with :ref:`archinstall.list_keyboard_languages`.
:param options: A `generator` or `list` where keys are the language name, value should be a dict containing language information.
:type options: generator or list
:param show_only_country_codes: Filters out languages that are not len(lang) == 2. This to limit the number of results from stuff like dvorak and x-latin1 alternatives.
:type show_only_country_codes: bool
:return: The language/dictionary key of the selected language
:rtype: str
"""
default_keyboard_language = 'us'
kb_lang = list_keyboard_languages()
# sort alphabetically and then by length
# it's fine if the list is big because the Menu
# allows for searching anyways
sorted_kb_lang = sorted(sorted(list(kb_lang)), key=len)
if show_only_country_codes:
languages = sorted([language for language in list(options) if len(language) == 2])
else:
languages = sorted(list(options))
if len(languages) >= 1:
print_large_list(languages, margin_bottom=4)
print(" -- You can choose a layout that isn't in this list, but whose name you know --")
print(f" -- Also, you can enter '?' or 'help' to search for more languages, or skip to use {default_keyboard_language} layout --")
while True:
selected_language = input(input_text)
if not selected_language:
return default_keyboard_language
elif selected_language.lower() in ('?', 'help'):
while True:
filter_string = input("Search for layout containing (example: \"sv-\") or enter 'exit' to exit from search: ")
if filter_string.lower() == 'exit':
return select_language(list_keyboard_languages())
new_options = list(search_keyboard_layout(filter_string))
if len(new_options) <= 0:
log(f"Search string '{filter_string}' yielded no results, please try another search.", fg='yellow')
continue
return select_language(new_options, show_only_country_codes=False)
elif selected_language.isnumeric():
selected_language = int(selected_language)
if selected_language >= len(languages):
log(' * Selected option is out of range * ', fg='red')
continue
return languages[selected_language]
elif verify_keyboard_layout(selected_language):
return selected_language
else:
log(" * Given language wasn't found * ", fg='red')
raise RequirementError("Selecting languages require a least one language to be given as an option.")
selected_lang = Menu('Select Keyboard layout', sorted_kb_lang, default_option='us', sort=False).run()
return selected_lang
def select_mirror_regions(mirrors, show_top_mirrors=True):
def select_mirror_regions():
"""
Asks the user to select a mirror or region from the `mirrors` dictionary parameter.
Asks the user to select a mirror or region
Usually this is combined with :ref:`archinstall.list_mirrors`.
:param mirrors: A `dict` where keys are the mirror region name, value should be a dict containing mirror information.
:type mirrors: dict
:param show_top_mirrors: Will limit the list to the top 10 fastest mirrors based on rank-mirror *(Currently not implemented but will be)*.
:type show_top_mirrors: bool
:return: The dictionary information about a mirror/region.
:rtype: dict
"""
# TODO: Support multiple options and country codes, SE,UK for instance.
regions = sorted(list(mirrors.keys()))
selected_mirrors = {}
if len(regions) >= 1:
print_large_list(regions, margin_bottom=4)
mirrors = list_mirrors()
selected_mirror = Menu('Select one of the regions to download packages from', mirrors.keys()).run()
print(' -- You can skip this step by leaving the option blank --')
selected_mirror = generic_select(regions, 'Select one of the above regions to download packages from (by number or full name): ', options_output=False)
if not selected_mirror:
# Returning back empty options which can be both used to
# do "if x:" logic as well as do `x.get('mirror', {}).get('sub', None)` chaining
return {}
if selected_mirror is not None:
return {selected_mirror: mirrors[selected_mirror]}
# I'm leaving "mirrors" on purpose here.
# Since region possibly contains a known region of
# all possible regions, and we might want to write
# for instance Sweden (if we know that exists) without having to
# go through the search step.
return {}
selected_mirrors[selected_mirror] = mirrors[selected_mirror]
return selected_mirrors
raise RequirementError("Selecting mirror region require a least one region to be given as an option.")
def select_harddrives():
"""
Asks the user to select one or multiple hard drives
:return: List of selected hard drives
:rtype: list
"""
hard_drives = all_disks().values()
options = {f'{option}': option for option in hard_drives}
selected_harddrive = Menu(
'Select one or more hard drives to use and configure',
options.keys(),
multi=True
).run()
if selected_harddrive and len(selected_harddrive) > 0:
return [options[i] for i in selected_harddrive]
return None
def select_driver(options=AVAILABLE_GFX_DRIVERS):
@ -961,15 +843,18 @@ def select_driver(options=AVAILABLE_GFX_DRIVERS):
if drivers:
arguments = storage.get('arguments', {})
title = ''
if has_amd_graphics():
print('For the best compatibility with your AMD hardware, you may want to use either the all open-source or AMD / ATI options.')
title += '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():
print('For the best compatibility with your Intel hardware, you may want to use either the all open-source or Intel options.')
title += '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():
print('For the best compatibility with your Nvidia hardware, you may want to use the Nvidia proprietary driver.')
title += 'For the best compatibility with your Nvidia hardware, you may want to use the Nvidia proprietary driver.\n'
if not arguments.get('gfx_driver', None):
arguments['gfx_driver'] = generic_select(drivers, input_text="Select a graphics driver or leave blank to install all open-source drivers: ")
title += '\n\nSelect a graphics driver or leave blank to install all open-source drivers'
arguments['gfx_driver'] = Menu(title, drivers).run()
if arguments.get('gfx_driver', None) is None:
arguments['gfx_driver'] = "All open-source (default)"
@ -979,22 +864,23 @@ def select_driver(options=AVAILABLE_GFX_DRIVERS):
raise RequirementError("Selecting drivers require a least one profile to be given as an option.")
def select_kernel(options):
def select_kernel():
"""
Asks the user to select a kernel for system.
:param options: A `list` with kernel options
:type options: list
:return: The string as a selected kernel
:rtype: string
"""
kernels = ["linux", "linux-lts", "linux-zen", "linux-hardened"]
default_kernel = "linux"
kernels = sorted(list(options))
selected_kernels = Menu(
f'Choose which kernels to use or leave blank for default "{default_kernel}"',
kernels,
sort=True,
multi=True,
default_option=default_kernel
).run()
if kernels:
return generic_multi_select(kernels, f"Choose which kernels to use (leave blank for default: {default_kernel}): ", default=default_kernel, sort=False)
raise RequirementError("Selecting kernels require a least one kernel to be given as an option.")
return selected_kernels

View File

@ -63,6 +63,7 @@ def load_config():
except:
raise ValueError("--disk_layouts=<json> needs either a JSON file or a JSON string given with a valid disk layout.")
def ask_user_questions():
"""
First, we'll ask the user for a bunch of user input.
@ -70,12 +71,7 @@ def ask_user_questions():
will we continue with the actual installation steps.
"""
if not archinstall.arguments.get('keyboard-layout', None):
while True:
try:
archinstall.arguments['keyboard-layout'] = archinstall.select_language(archinstall.list_keyboard_languages()).strip()
break
except archinstall.RequirementError as err:
archinstall.log(err, fg="red")
archinstall.arguments['keyboard-layout'] = archinstall.select_language()
# Before continuing, set the preferred keyboard layout/language in the current terminal.
# This will just help the user with the next following questions.
@ -84,12 +80,7 @@ def ask_user_questions():
# Set which region to download packages from during the installation
if not archinstall.arguments.get('mirror-region', None):
while True:
try:
archinstall.arguments['mirror-region'] = archinstall.select_mirror_regions(archinstall.list_mirrors())
break
except archinstall.RequirementError as e:
archinstall.log(e, fg="red")
archinstall.arguments['mirror-region'] = archinstall.select_mirror_regions()
if not archinstall.arguments.get('sys-language', None) and archinstall.arguments.get('advanced', False):
archinstall.arguments['sys-language'] = input("Enter a valid locale (language) for your OS, (Default: en_US): ").strip()
@ -104,9 +95,7 @@ def ask_user_questions():
# Ask which harddrives/block-devices we will install to
# and convert them into archinstall.BlockDevice() objects.
if archinstall.arguments.get('harddrives', None) is None:
archinstall.arguments['harddrives'] = archinstall.generic_multi_select(archinstall.all_disks(),
text="Select one or more harddrives to use and configure (leave blank to skip this step): ",
allow_empty=True)
archinstall.arguments['harddrives'] = archinstall.select_harddrives()
if archinstall.arguments.get('harddrives', None) is not None and archinstall.storage.get('disk_layouts', None) is None:
archinstall.storage['disk_layouts'] = archinstall.select_disk_layout(archinstall.arguments['harddrives'], archinstall.arguments.get('advanced', False))
@ -150,7 +139,8 @@ def ask_user_questions():
# Check the potentially selected profiles preparations to get early checks if some additional questions are needed.
if archinstall.arguments['profile'] and archinstall.arguments['profile'].has_prep_function():
with archinstall.arguments['profile'].load_instructions(namespace=f"{archinstall.arguments['profile'].namespace}.py") as imported:
namespace = f"{archinstall.arguments['profile'].namespace}.py"
with archinstall.arguments['profile'].load_instructions(namespace=namespace) as imported:
if not imported._prep_function():
archinstall.log(' * Profile\'s preparation requirements was not fulfilled.', fg='red')
exit(1)
@ -162,8 +152,7 @@ def ask_user_questions():
# Ask for preferred kernel:
if not archinstall.arguments.get("kernels", None):
kernels = ["linux", "linux-lts", "linux-zen", "linux-hardened"]
archinstall.arguments['kernels'] = archinstall.select_kernel(kernels)
archinstall.arguments['kernels'] = archinstall.select_kernel()
# 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.")

View File

@ -1,5 +1,4 @@
# A desktop environment selector.
import archinstall
is_top_level_profile = True
@ -44,8 +43,7 @@ def _prep_function(*args, **kwargs):
other code in this stage. So it's a safe way to ask the user
for more input before any other installer steps start.
"""
desktop = archinstall.generic_select(__supported__, 'Select your desired desktop environment: ', allow_empty_input=False, sort=True)
desktop = archinstall.Menu('Select your desired desktop environment', __supported__, skip=False).run()
# Temporarily store the selected desktop profile
# in a session-safe location, since this module will get reloaded

View File

@ -26,7 +26,8 @@ def _prep_function(*args, **kwargs):
"""
supported_configurations = ['i3-wm', 'i3-gaps']
desktop = archinstall.generic_select(supported_configurations, 'Select your desired configuration: ', allow_empty_input=False, sort=True)
desktop = archinstall.Menu('Select your desired configuration', supported_configurations, skip=False).run()
# Temporarily store the selected desktop profile
# in a session-safe location, since this module will get reloaded

View File

@ -27,8 +27,12 @@ def _prep_function(*args, **kwargs):
before continuing any further.
"""
if not archinstall.storage.get('_selected_servers', None):
selected_servers = archinstall.generic_multi_select(available_servers, "Choose which servers to install and enable (leave blank for a minimal installation): ")
archinstall.storage['_selected_servers'] = selected_servers
servers = archinstall.Menu(
'Choose which servers to install, if none then a minimal installation wil be done', available_servers,
multi=True
).run()
archinstall.storage['_selected_servers'] = servers
return True

View File

@ -15,6 +15,7 @@ classifiers = [ "License :: OSI Approved :: GNU General Public License v3 or lat
]
description-file = "README.md"
requires-python=">=3.8"
[tool.flit.metadata.urls]
Source = "https://github.com/archlinux/archinstall"
Documentation = "https://archinstall.readthedocs.io/"

View File

@ -12,7 +12,7 @@ license_files =
project_urls =
Source = https://github.com/archlinux/archinstall
Documentation = https://archinstall.readthedocs.io/
classifers =
classifiers =
Programming Language :: Python :: 3
Programming Language :: Python :: 3.8
Programming Language :: Python :: 3.9