Send patches - preferably formatted by git format-patch - to patches at archlinux32 dot org.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDaniel <blackrabbit256@gmail.com>2022-03-01 03:03:37 +1100
committerGitHub <noreply@github.com>2022-02-28 17:03:37 +0100
commit35a19a616aee602a849097411cc0254a27c3f9b7 (patch)
tree5b1db96f151fae55f7b76e06066fc747daf6b070
parent04e3880d8f41a87889690609e20ab1220700c163 (diff)
Menu option save config (#1009)
* Add new save config menu option * Update * Fixed issue with merging * Fixed merge issue (I think) Co-authored-by: Daniel Girtler <girtler.daniel@gmail.com> Co-authored-by: Anton Hvornum <anton.feeds@gmail.com>
-rw-r--r--archinstall/lib/configuration.py168
-rw-r--r--archinstall/lib/menu/menu.py16
-rw-r--r--archinstall/lib/menu/selection_menu.py123
-rw-r--r--archinstall/lib/user_interaction.py66
-rw-r--r--examples/guided.py6
-rw-r--r--examples/only_hd.py10
-rw-r--r--examples/swiss.py13
7 files changed, 301 insertions, 101 deletions
diff --git a/archinstall/lib/configuration.py b/archinstall/lib/configuration.py
index bb8c7aab..c971768f 100644
--- a/archinstall/lib/configuration.py
+++ b/archinstall/lib/configuration.py
@@ -1,64 +1,122 @@
import json
-import pathlib
import logging
+from pathlib import Path
+from typing import Optional, Dict
+
from .storage import storage
from .general import JSON, UNSAFE_JSON
from .output import log
-def output_configs(area :dict, show :bool = True, save :bool = True):
- """ Show on the screen the configuration data (except credentials) and/or save them on a json file
- :param area: a dictionary to be shown/save (basically archinstall.arguments, but needed to be passed explictly to avoid circular references
- :type area: dict
- :param show:Determines if the config data will be displayed on screen in Json format
- :type show: bool
- :param save:Determines if the config data will we written as a Json file
- :type save:bool
- """
- user_credentials = {}
- disk_layout = {}
- user_config = {}
- for key in area:
- if key in ['!users','!superusers','!encryption-password']:
- user_credentials[key] = area[key]
- elif key == 'disk_layouts':
- disk_layout = area[key]
- elif key in ['abort','install','config','creds','dry_run']:
- pass
- else:
- user_config[key] = area[key]
-
- user_configuration_json = json.dumps({
- 'config_version': storage['__version__'], # Tells us what version was used to generate the config
- **user_config, # __version__ will be overwritten by old version definition found in config
- 'version': storage['__version__']
- } , indent=4, sort_keys=True, cls=JSON)
- if disk_layout:
- disk_layout_json = json.dumps(disk_layout, indent=4, sort_keys=True, cls=JSON)
- if user_credentials:
- user_credentials_json = json.dumps(user_credentials, indent=4, sort_keys=True, cls=UNSAFE_JSON)
-
- if save:
- dest_path = pathlib.Path(storage.get('LOG_PATH','.'))
- if (not dest_path.exists()) or not (dest_path.is_dir()):
- log(f"Destination directory {dest_path.resolve()} does not exist or is not a directory,\n Configuration files can't be saved",fg="yellow",)
- input("Press enter to continue")
- else:
- with (dest_path / "user_configuration.json").open('w') as config_file:
- config_file.write(user_configuration_json)
- if user_credentials:
- target = dest_path / "user_credentials.json"
- with target.open('w') as config_file:
- config_file.write(user_credentials_json)
- if disk_layout:
- target = dest_path / "user_disk_layout.json"
- with target.open('w') as config_file:
- config_file.write(disk_layout_json)
- if show:
- print()
- print('This is your chosen configuration:')
- log("-- Guided template chosen (with below config) --", level=logging.DEBUG)
- log(user_configuration_json, level=logging.INFO)
+class ConfigurationOutput:
+ def __init__(self, config: Dict):
+ """
+ Configuration output handler to parse the existing configuration data structure and prepare for output on the
+ console and for saving it to configuration files
+
+ :param config: A dictionary containing configurations (basically archinstall.arguments)
+ :type config: Dict
+ """
+ self._config = config
+ self._user_credentials = {}
+ self._disk_layout = None
+ self._user_config = {}
+ 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', '!superusers', '!encryption-password']
+ self._ignore = ['abort', 'install', 'config', 'creds', 'dry_run']
+
+ self._process_config()
+
+ @property
+ def user_credentials_file(self):
+ return self._user_creds_file
+
+ @property
+ 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]
+
+ def user_config_to_json(self) -> str:
+ return json.dumps({
+ 'config_version': storage['__version__'], # Tells us what version was used to generate the config
+ **self._user_config, # __version__ will be overwritten by old version definition found in config
+ '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)
+ return None
+
+ def show(self):
+ print(_('\nThis is your chosen configuration:'))
+ log(" -- Chosen configuration --", level=logging.DEBUG)
+
+ user_conig = self.user_config_to_json()
+ disk_layout = self.disk_layout_to_json()
+ log(user_conig, level=logging.INFO)
+
if disk_layout:
- log(disk_layout_json, level=logging.INFO)
+ log(disk_layout, level=logging.INFO)
+
print()
+
+ 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()),
+ fg="yellow"
+ )
+ return False
+ return True
+
+ def save_user_config(self, dest_path :Path = None):
+ if self._is_valid_path(dest_path):
+ with open(dest_path / self._user_config_file, 'w') as config_file:
+ config_file.write(self.user_config_to_json())
+
+ def save_user_creds(self, dest_path :Path = None):
+ if self._is_valid_path(dest_path):
+ if user_creds := self.user_credentials_to_json():
+ target = dest_path / self._user_creds_file
+ with open(target, 'w') as config_file:
+ config_file.write(user_creds)
+
+ def save_disk_layout(self, dest_path :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)
+
+ def save(self, dest_path :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/menu/menu.py b/archinstall/lib/menu/menu.py
index d7b1605d..c52ed8f3 100644
--- a/archinstall/lib/menu/menu.py
+++ b/archinstall/lib/menu/menu.py
@@ -12,7 +12,6 @@ import logging
if TYPE_CHECKING:
_: Any
-
class Menu(TerminalMenu):
def __init__(
self,
@@ -24,6 +23,9 @@ class Menu(TerminalMenu):
sort :bool = True,
preset_values :Union[str, List[str]] = None,
cursor_index :int = None,
+ preview_command=None,
+ preview_size=0.75,
+ preview_title='Info',
**kwargs
):
"""
@@ -54,6 +56,15 @@ class Menu(TerminalMenu):
:param cursor_index: The position where the cursor will be located. If it is not in range (number of elements of the menu) it goes to the first position
:type cursor_index: int
+ :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
+
+ :param preview_size: Size of the preview window in ratio to the full window
+ :type preview_size: float
+
+ :param preview_title: Title of the preview window
+ :type preview_title: str
+
:param kwargs : any SimpleTerminal parameter
"""
# we guarantee the inmutability of the options outside the class.
@@ -123,6 +134,9 @@ class Menu(TerminalMenu):
# show_search_hint=True,
preselected_entries=self.preset_values,
cursor_index=self.cursor_index,
+ preview_command=preview_command,
+ preview_size=preview_size,
+ preview_title=preview_title,
**kwargs,
)
diff --git a/archinstall/lib/menu/selection_menu.py b/archinstall/lib/menu/selection_menu.py
index 14278999..bffd8e40 100644
--- a/archinstall/lib/menu/selection_menu.py
+++ b/archinstall/lib/menu/selection_menu.py
@@ -2,7 +2,7 @@ from __future__ import annotations
import sys
import logging
-from typing import Callable, Any, List, Iterator
+from typing import Callable, Any, List, Iterator, Tuple, Optional
from .menu import Menu
from ..general import SysCommand, secret
@@ -12,7 +12,7 @@ from ..output import log
from ..profiles import is_desktop_profile
from ..disk import encrypted_partitions
from ..locale_helpers import set_keyboard_language
-from ..user_interaction import get_password, ask_for_a_timezone
+from ..user_interaction import get_password, ask_for_a_timezone, save_config
from ..user_interaction import ask_ntp
from ..user_interaction import ask_for_swap
from ..user_interaction import ask_for_bootloader
@@ -47,7 +47,8 @@ class Selector:
dependencies_not :List = [],
exec_func :Callable = None,
preview_func :Callable = None,
- mandatory :bool = False
+ mandatory :bool = False,
+ no_store :bool = False
):
"""
Create a new menu selection entry
@@ -83,13 +84,15 @@ class Selector:
menu returns to the selection screen. If not specified it is assumed the return is False
:type exec_func: Callable
- :param preview_func: A callable which invokws a preview screen (not implemented)
+ :param preview_func: A callable which invokws a preview screen
:type preview_func: Callable
:param mandatory: A boolean which determines that the field is mandatory, i.e. menu can not be exited if it is not set
:type mandatory: bool
- """
+ :param no_store: A boolean which determines that the field should or shouldn't be stored in the data storage
+ :type no_store: bool
+ """
self._description = description
self.func = func
self._display_func = display_func
@@ -98,21 +101,29 @@ class Selector:
self._dependencies = dependencies
self._dependencies_not = dependencies_not
self.exec_func = exec_func
- self.preview_func = preview_func
+ self._preview_func = preview_func
self.mandatory = mandatory
+ self._no_store = no_store
@property
- def dependencies(self) -> dict:
+ def dependencies(self) -> List:
return self._dependencies
@property
- def dependencies_not(self) -> dict:
+ def dependencies_not(self) -> List:
return self._dependencies_not
@property
def current_selection(self):
return self._current_selection
+ @property
+ def preview_func(self):
+ return self._preview_func
+
+ def do_store(self) -> bool:
+ return self._no_store is False
+
def set_enabled(self, status :bool = True):
self.enabled = status
@@ -247,6 +258,20 @@ class GeneralMenu:
print(f'No selector found: {selector_name}')
sys.exit(1)
+ def _preview_display(self, selection_name: str) -> Optional[str]:
+ config_name, selector = self._find_selection(selection_name)
+ if preview := selector.preview_func:
+ return preview()
+ return None
+
+ def _find_selection(self, selection_name: str) -> Tuple[str, Selector]:
+ option = [[k, v] for k, v in self._menu_options.items() if v.text.strip() == selection_name.strip()]
+ if len(option) != 1:
+ raise ValueError(f'Selection not found: {selection_name}')
+ config_name = option[0][0]
+ selector = option[0][1]
+ return config_name, selector
+
def run(self):
""" Calls the Menu framework"""
# we synch all the options just in case
@@ -260,7 +285,16 @@ class GeneralMenu:
self._set_kb_language()
enabled_menus = self._menus_to_enable()
menu_text = [m.text for m in enabled_menus.values()]
- selection = Menu('Set/Modify the below options', menu_text, sort=False, cursor_index=cursor_pos).run()
+
+ selection = Menu(
+ _('Set/Modify the below options'),
+ menu_text,
+ sort=False,
+ cursor_index=cursor_pos,
+ preview_command=self._preview_display,
+ preview_size=0.5
+ ).run()
+
if selection and self.auto_cursor:
cursor_pos = menu_text.index(selection) + 1 # before the strip otherwise fails
if cursor_pos >= len(menu_text):
@@ -273,21 +307,16 @@ class GeneralMenu:
if not self.is_context_mgr:
self.__exit__()
- def _process_selection(self, selection :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
"""
# find the selected option in our option list
- option = [[k, v] for k, v in self._menu_options.items() if v.text.strip() == selection.strip()]
- if len(option) != 1:
- raise ValueError(f'Selection not found: {selection}')
- selector_name = option[0][0]
- selector = option[0][1]
-
- return self.exec_option(selector_name,selector)
+ config_name, selector = self._find_selection(selection_name)
+ return self.exec_option(config_name, selector)
- def exec_option(self,selector_name :str, p_selector :Selector = None) -> bool:
+ def exec_option(self, config_name :str, p_selector :Selector = None) -> bool:
""" processes the exection of a given menu entry
- pre process callback
- selection function
@@ -296,20 +325,22 @@ class GeneralMenu:
returns True if the loop has to continue, false if the loop can be closed
"""
if not p_selector:
- selector = self.option(selector_name)
+ selector = self.option(config_name)
else:
selector = p_selector
- self.pre_callback(selector_name)
+ self.pre_callback(config_name)
result = None
if selector.func:
- presel_val = self.option(selector_name).get_selection()
+ presel_val = self.option(config_name).get_selection()
result = selector.func(presel_val)
- self._menu_options[selector_name].set_current_selection(result)
- self._data_store[selector_name] = result
- exec_ret_val = selector.exec_func(selector_name,result) if selector.exec_func else False
- self.post_callback(selector_name,result)
+ 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 else False
+ self.post_callback(config_name,result)
+
if exec_ret_val and self._check_mandatory_status():
return False
return True
@@ -515,20 +546,28 @@ class GlobalMenu(GeneralMenu):
_('Set automatic time sync (NTP)'),
lambda preset: self._select_ntp(preset),
default=True)
+ self._menu_options['save_config'] = \
+ Selector(
+ _('Save configuration'),
+ lambda: save_config(self._data_store),
+ enabled=True,
+ no_store=True)
self._menu_options['install'] = \
Selector(
self._install_text(),
- exec_func=lambda n,v: True if self._missing_configs() == 0 else False,
- enabled=True)
+ exec_func=lambda n,v: True if len(self._missing_configs()) == 0 else False,
+ preview_func=self._prev_install_missing_config,
+ enabled=True,
+ no_store=True)
self._menu_options['abort'] = Selector(_('Abort'), exec_func=lambda n,v:exit(1), enabled=True)
- def _update_install(self,name :str = None ,result :Any = None):
+ def _update_install_text(self, name :str = None, result :Any = None):
text = self._install_text()
self._menu_options.get('install').update_description(text)
def post_callback(self,name :str = None ,result :Any = None):
- self._update_install(name,result)
+ self._update_install_text(name, result)
def exit_callback(self):
if self._data_store.get('harddrives', None) and self._data_store.get('!encryption-password', None):
@@ -539,29 +578,37 @@ class GlobalMenu(GeneralMenu):
storage['arguments']['disk_layouts'], storage['arguments']['!encryption-password'])
def _install_text(self):
- missing = self._missing_configs()
+ missing = len(self._missing_configs())
if missing > 0:
return _('Install ({} config(s) missing)').format(missing)
return 'Install'
- def _missing_configs(self):
+ 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 _missing_configs(self) -> List[str]:
def check(s):
return self._menu_options.get(s).has_selection()
- missing = 0
+ missing = []
if not check('bootloader'):
- missing += 1
+ missing += ['Bootloader']
if not check('hostname'):
- missing += 1
+ missing += ['Hostname']
if not check('audio'):
- missing += 1
+ missing += ['Audio']
if not check('!root-password') and not check('!superusers'):
- missing += 1
+ missing += [str(_('Either root-password or at least 1 superuser must be specified'))]
if not check('harddrives'):
- missing += 1
+ missing += ['Hard drives']
if check('harddrives'):
if not self._menu_options.get('harddrives').is_empty() and not check('disk_layouts'):
- missing += 1
+ missing += ['Disk layout']
return missing
diff --git a/archinstall/lib/user_interaction.py b/archinstall/lib/user_interaction.py
index 1f62b7fd..f87f83be 100644
--- a/archinstall/lib/user_interaction.py
+++ b/archinstall/lib/user_interaction.py
@@ -9,11 +9,13 @@ import signal
import sys
import time
from collections.abc import Iterable
+from pathlib import Path
from copy import copy
from typing import List, Any, Optional, Dict, Union, TYPE_CHECKING
# https://stackoverflow.com/a/39757388/929999
from .menu.text_input import TextInput
+from .configuration import ConfigurationOutput
from .models.network_configuration import NetworkConfiguration, NicType
if TYPE_CHECKING:
@@ -1165,3 +1167,67 @@ def generic_multi_select(p_options :Union[list,dict],
sort=sort,
multi=True,
default=default)
+
+
+def save_config(config: Dict):
+ def preview(selection: str):
+ if options['user_config'] == selection:
+ json_config = config_output.user_config_to_json()
+ return f'{config_output.user_configuration_file}\n{json_config}'
+ elif options['user_creds'] == selection:
+ if json_config := config_output.user_credentials_to_json():
+ return f'{config_output.user_credentials_file}\n{json_config}'
+ else:
+ return str(_('No configuration'))
+ elif options['disk_layout'] == selection:
+ if json_config := config_output.disk_layout_to_json():
+ return f'{config_output.disk_layout_file}\n{json_config}'
+ else:
+ return str(_('No configuration'))
+ elif options['all'] == selection:
+ output = f'{config_output.user_configuration_file}\n'
+ if json_config := config_output.user_credentials_to_json():
+ output += f'{config_output.user_credentials_file}\n'
+ if json_config := config_output.disk_layout_to_json():
+ output += f'{config_output.disk_layout_file}\n'
+ return output[:-1]
+ return None
+
+ config_output = ConfigurationOutput(config)
+
+ options = {
+ 'user_config': str(_('Save user configuration')),
+ 'user_creds': str(_('Save user credentials')),
+ 'disk_layout': str(_('Save disk layout')),
+ 'all': str(_('Save all'))
+ }
+
+ selection = Menu(
+ _('Choose which configuration to save'),
+ list(options.values()),
+ sort=False,
+ skip=True,
+ preview_size=0.75,
+ preview_command=preview
+ ).run()
+
+ if not selection:
+ return
+
+ while True:
+ path = input(_('Enter a directory for the configuration(s) to be saved: ')).strip(' ')
+ dest_path = Path(path)
+ if dest_path.exists() and dest_path.is_dir():
+ break
+ log(_('Not a valid directory: {}').format(dest_path), fg='red')
+
+ if options['user_config'] == selection:
+ config_output.save_user_config(dest_path)
+ elif options['user_creds'] == selection:
+ config_output.save_user_creds(dest_path)
+ elif options['disk_layout'] == selection:
+ config_output.save_disk_layout(dest_path)
+ elif options['all'] == selection:
+ config_output.save_user_config(dest_path)
+ config_output.save_user_creds(dest_path)
+ config_output.save_disk_layout(dest_path)
diff --git a/examples/guided.py b/examples/guided.py
index d2447341..37f4cf72 100644
--- a/examples/guided.py
+++ b/examples/guided.py
@@ -3,6 +3,7 @@ import os
import time
import archinstall
+from archinstall import ConfigurationOutput
if archinstall.arguments.get('help'):
print("See `man archinstall` for help.")
@@ -257,7 +258,10 @@ if not archinstall.arguments.get('offline', False):
if not archinstall.arguments.get('silent'):
ask_user_questions()
-archinstall.output_configs(archinstall.arguments,show=False if archinstall.arguments.get('silent') else True)
+config_output = ConfigurationOutput(archinstall.arguments)
+if not archinstall.arguments.get('silent'):
+ config_output.show()
+config_output.save()
if archinstall.arguments.get('dry_run'):
exit(0)
diff --git a/examples/only_hd.py b/examples/only_hd.py
index 5d081b14..76a616db 100644
--- a/examples/only_hd.py
+++ b/examples/only_hd.py
@@ -4,6 +4,8 @@ import os
import pathlib
import archinstall
+from archinstall import ConfigurationOutput
+
class OnlyHDMenu(archinstall.GlobalMenu):
def _setup_selection_menu_options(self):
@@ -23,7 +25,7 @@ class OnlyHDMenu(archinstall.GlobalMenu):
self.enable(entry)
else:
self.option(entry).set_enabled(False)
- self._update_install()
+ self._update_install_text()
def _missing_configs(self):
""" overloaded method """
@@ -122,7 +124,11 @@ if not archinstall.check_mirror_reachable():
if not archinstall.arguments.get('silent'):
ask_user_questions()
-archinstall.output_configs(archinstall.arguments,show=False if archinstall.arguments.get('silent') else True)
+
+config_output = ConfigurationOutput(archinstall.arguments)
+if not archinstall.arguments.get('silent'):
+ config_output.show()
+config_output.save()
if archinstall.arguments.get('dry_run'):
exit(0)
diff --git a/examples/swiss.py b/examples/swiss.py
index 4eb51a05..cf864ed5 100644
--- a/examples/swiss.py
+++ b/examples/swiss.py
@@ -19,6 +19,7 @@ import time
import pathlib
import archinstall
+from archinstall import ConfigurationOutput
if archinstall.arguments.get('help'):
print("See `man archinstall` for help.")
@@ -248,10 +249,10 @@ class MyMenu(archinstall.GlobalMenu):
self.enable(entry)
else:
self.option(entry).set_enabled(False)
- self._update_install()
+ self._update_install_text()
def post_callback(self,option,value=None):
- self._update_install(self._execution_mode)
+ self._update_install_text(self._execution_mode)
def _missing_configs(self,mode='full'):
def check(s):
@@ -271,7 +272,7 @@ class MyMenu(archinstall.GlobalMenu):
return f'Instalation ({missing} config(s) missing)'
return 'Install'
- def _update_install(self,mode='full'):
+ def _update_install_text(self, mode='full'):
text = self._install_text(mode)
self.option('install').update_description(text)
@@ -492,7 +493,11 @@ mode = archinstall.arguments.get('mode', 'full').lower()
if not archinstall.arguments.get('silent'):
ask_user_questions(mode)
-archinstall.output_configs(archinstall.arguments,show=False if archinstall.arguments.get('silent') else True)
+config_output = ConfigurationOutput(archinstall.arguments)
+if not archinstall.arguments.get('silent'):
+ config_output.show()
+config_output.save()
+
if archinstall.arguments.get('dry_run'):
exit(0)
if not archinstall.arguments.get('silent'):