From 4f23045ce8c69a0b0813522aa8d860fe569f5da4 Mon Sep 17 00:00:00 2001 From: Jan Kiszka Date: Sat, 10 Jul 2021 11:25:13 +0200 Subject: [PATCH] Add kconfiglib-based menu plugin This uses the kconfiglib python module to implement simple interactive configuration for complex layers with many kas options and even variable input. See the menu.py documentation header for details on the Kconfig format and its translation to kas. Rather than using the limited menuconfig implementation of kconfiglib, this comes with an own, python-newt based version. The permits smooth integration in the workflow, e.g. to offer an option that directly triggers a build after completing a configuration. And it comes with a nicer layout. Unfortunately, python-newt can't be fulfilled via pip. But it is available as proper package via common distros. So warn if the package is missing. Signed-off-by: Jan Kiszka --- docs/format-changelog.rst | 6 + docs/userguide.rst | 19 ++ kas/plugins/__init__.py | 2 + kas/plugins/menu.py | 494 ++++++++++++++++++++++++++++++++++++++ kas/schema-kas.json | 16 ++ setup.py | 1 + 6 files changed, 538 insertions(+) create mode 100644 kas/plugins/menu.py diff --git a/docs/format-changelog.rst b/docs/format-changelog.rst index e085024..934bc85 100644 --- a/docs/format-changelog.rst +++ b/docs/format-changelog.rst @@ -103,3 +103,9 @@ Changed behavior - String item ``includes`` are now using repo-relative paths. File-relative is still supported by issues a deprecation warning. + +Added +~~~~~ + +- ``menu_configuration`` key stores the selections done via ``kas menu`` in a + configuration file. It is only evaluated by that plugin. diff --git a/docs/userguide.rst b/docs/userguide.rst index fb5e263..621ad73 100644 --- a/docs/userguide.rst +++ b/docs/userguide.rst @@ -10,6 +10,8 @@ This project depends on - distro Python 3 package - jsonschema Python 3 package - PyYAML Python 3 package (optional, for yaml file support) +- kconfiglib Python 3 package (optional, for menu plugin) +- NEWT Python 3 distro package (optional, for menu plugin) To install kas into your python site-package repository, run:: @@ -64,6 +66,12 @@ Use Cases $ git pull $ kas build kas-project.yml +3. Interactive configuration:: + + $ cd $PROJECT_DIR/meta-project + $ kas menu + $ kas build # optional, if not triggered via kas menu + Plugins ------- @@ -86,6 +94,11 @@ typically provides a single command. .. automodule:: kas.plugins.for_all_repos +``menu`` plugin +~~~~~~~~~~~~~~~~ + +.. automodule:: kas.plugins.menu + ``shell`` plugin ~~~~~~~~~~~~~~~~ @@ -420,3 +433,9 @@ Configuration reference * ````: string [optional] A string that is added to the ``local.conf``. It operates in the same way as the ``bblayers_conf_header`` entry. + +* ``menu_configuration``:: dict [optional] + This contains user choices for a Kconfig menu of a project. Each variable + corresponds to a Kconfig configuration variable and can be of the types + string, boolean or integer. The content of this key is typically + maintained by the ``kas menu`` plugin in a ``.config.yaml`` file. diff --git a/kas/plugins/__init__.py b/kas/plugins/__init__.py index bbdb072..4346d05 100644 --- a/kas/plugins/__init__.py +++ b/kas/plugins/__init__.py @@ -42,11 +42,13 @@ def load(): from . import for_all_repos from . import checkout from . import shell + from . import menu register_plugins(build) register_plugins(checkout) register_plugins(for_all_repos) register_plugins(shell) + register_plugins(menu) def get(name): diff --git a/kas/plugins/menu.py b/kas/plugins/menu.py new file mode 100644 index 0000000..d3b123e --- /dev/null +++ b/kas/plugins/menu.py @@ -0,0 +1,494 @@ +# kas - setup tool for bitbake based projects +# +# Copyright (c) Siemens AG, 2021 +# +# 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. +# +# Parts of this were based on kconfiglib, examples/menuconfig_example.py +# +# Copyright (c) 2011-2019, Ulf Magnusson +# +# Permission to use, copy, modify, and/or distribute this software for any +# purpose with or without fee is hereby granted, provided that the above +# copyright notice and this permission notice appear in all copies. +# +""" + This plugin implements the ``kas menu`` command. + + When this command is executed, kas will open a configuration menu as + described by a Kconfig file. It processes any pre-existing configuration + file with saved settings, stores the final selections and invokes the build + plugin if requested by the user. + + To make use of this plugin, a ``Kconfig`` file has to be provided. The + menu can define these types of configuration variables that the plugin + will translate into a kas configuration: + + - kas configuration files that will be included when building the + generated configuration. Those are picked up from kconfig string + variables that have the name prefix ``KAS_INCLUDE_``. + + - bitbake targets that shall be built via the generated configuration. + Those are picked up from kconfig string variables that have the name + prefix ``KAS_TARGET_``. + + - The ``build_system`` that will used. The static kconfig string variable + ``KAS_BUILD_SYSTEM`` defines this value which must be ``openembedded``, + ``oe`` or ``isar`` is set. + + - bitbake configuration variables that will be added to the + ``local_conf_header`` section of the generated configuration. All other + active kconfig string, integer or hex variables are treated as such. + + See https://www.kernel.org/doc/html/latest/kbuild/kconfig-language.html + for a complete documentation of the Kconfig language. + + The menu plugin writes the selected configuration to a ``.config.yaml`` + file in the kas work directory and also reads previous selection from such + a file if it exists. The ``.config.yaml`` both contains the selected + configuration in the ``menu_configuration`` key and also the effective + settings that can be used to invoke ``kas build`` or other kas commands. +""" + +import logging +import os +import pprint +import sys +import yaml +from kconfiglib import Kconfig, Symbol, Choice, expr_value, TYPE_TO_STR, \ + MENU, COMMENT, STRING, BOOL, INT, HEX, UNKNOWN +from kas import __version__, __file_version__ +from kas.context import create_global_context +from kas.includehandler import load_config as load_config_yaml +from kas.plugins.build import Build + +try: + from snack import SnackScreen, EntryWindow, ButtonChoiceWindow, \ + ButtonBar, Listbox, GridFormHelp + newt_available = True +except ImportError: + newt_available = False # will be reported in run() + +__license__ = 'MIT' +__copyright__ = \ + 'Copyright (c) 2011-2019, Ulf Magnusson \n' \ + 'Copyright (c) Siemens AG, 2021' + +CONFIG_YAML_FILE = '.config.yaml' + + +def check_sym_is_string(sym): + if sym.type != STRING: + logging.error('Variable %s must be of string type', sym.name) + sys.exit(1) + + +def str_representer(dumper, data): + style = '|' if len(data.splitlines()) > 1 else None + return dumper.represent_scalar('tag:yaml.org,2002:str', data, style=style) + + +class Args: + pass + + +class Menu: + """ + This class implements the menu plugin for kas. + """ + + name = 'menu' + helpmsg = ( + 'Provides a configuration menu and triggers the build of the choices.' + ) + + @classmethod + def setup_parser(cls, parser): + parser.add_argument('kconfig', + help='Kconfig file', + nargs='?', default='Kconfig') + + def load_config(self, filename): + try: + self.orig_config = load_config_yaml(filename) + except FileNotFoundError: + self.orig_config = {} + return + + menu_configuration = self.orig_config.get('menu_configuration', {}) + for symname in menu_configuration: + sym = self.kconf.syms.get(symname) + if not sym: + logging.warning( + 'Ignoring unknown configuration variable %s in %s', + symname, filename) + continue + symvalue = menu_configuration[symname] + if sym.type == BOOL: + sym.set_value('y' if symvalue else 'n') + elif sym.type == INT: + sym.set_value(str(symvalue)) + elif sym.type == HEX: + sym.set_value(str(hex(symvalue))) + else: # string + sym.set_value(symvalue) + + def save_config(self, filename): + kas_includes = [] + kas_targets = [] + kas_build_system = None + kas_vars = {} + menu_configuration = {} + + for symname in self.kconf.syms: + if symname == 'MODULES': + continue + + sym = self.kconf.syms[symname] + symvalue = sym.str_value + + if expr_value(sym.direct_dep) < 2: + continue + + if sym.visibility == 2: + if sym.type == BOOL: + menu_configuration[symname] = symvalue == 'y' + elif sym.type == STRING: + menu_configuration[symname] = symvalue + elif sym.type == INT: + menu_configuration[symname] = int(symvalue) + elif sym.type == HEX: + menu_configuration[symname] = int(symvalue, 16) + else: + logging.error( + 'Configuration variable %s uses unsupported type', + symname) + sys.exit(1) + + if symname.startswith('KAS_INCLUDE_'): + check_sym_is_string(sym) + if symvalue != '': + kas_includes.append(symvalue) + elif symname.startswith('KAS_TARGET_'): + check_sym_is_string(sym) + if symvalue != '': + kas_targets.append(symvalue) + elif symname == 'KAS_BUILD_SYSTEM': + check_sym_is_string(sym) + if symvalue != '': + kas_build_system = symvalue + elif sym.type in (STRING, INT, HEX): + kas_vars[symname] = symvalue + + config = { + 'header': { + 'version': __file_version__, + 'includes': kas_includes + }, + 'menu_configuration': menu_configuration + } + if kas_build_system: + config['build_system'] = kas_build_system + if len(kas_targets) > 0: + config['target'] = kas_targets + if len(kas_vars) > 0: + config['local_conf_header'] = { + '__menu_config_vars': '\n'.join([ + '{} = "{}"'.format(key, value) + for key, value in kas_vars.items() + ]) + } + + logging.debug('Menu configuration:\n%s', pprint.pformat(config)) + + if config != self.orig_config: + logging.info('Saving configuration as %s', filename) + + # format multi-line strings more nicely + yaml.add_representer(str, str_representer) + + try: + os.rename(filename, filename + '.old') + except FileNotFoundError: + pass + + with open(filename, 'w') as config_file: + config_file.write( + '#\n' + '# Automatically generated by kas {}\n' + '#\n'.format(__version__)) + yaml.dump(config, config_file) + + def dump_kconf_warnings(self): + if len(self.kconf.warnings) > 0: + logging.warning("\n".join(self.kconf.warnings)) + self.kconf.warnings = [] + + def run(self, args): + if not newt_available: + logging.error( + 'Menu plugin requires \'python3-newt\' distribution package.') + sys.exit(1) + + ctx = create_global_context(args) + + self.kconf = Kconfig(args.kconfig, warn_to_stderr=False) + + config_filename = os.path.join(ctx.kas_work_dir, CONFIG_YAML_FILE) + + self.load_config(config_filename) + self.dump_kconf_warnings() + + menu = Menuconfig(self.kconf) + action = menu.show() + + if action == 'exit': + return + + self.save_config(config_filename) + self.dump_kconf_warnings() + + if action == 'build': + logging.debug('Starting build') + + build_args = Args() + build_args.config = None + build_args.target = None + build_args.task = None + build_args.extra_bitbake_args = [] + build_args.skip = None + + Build().run(build_args) + + +class Menuconfig(): + def __init__(self, kconf): + self.kconf = kconf + self.screen = None + + @staticmethod + def value_str(sym): + """ + Returns the value part ("[*]", "(foo)" etc.) of a menu entry. + """ + if sym.type in (STRING, INT, HEX): + return "({})".format(sym.str_value) + + # BOOL (TRISTATE not supported) + + # The choice mode is an upper bound on the visibility of choice + # symbols, so we can check the choice symbols' own visibility to see + # if the choice is in y mode + if sym.choice and sym.visibility == 2: + return "(*)" if sym.choice.selection is sym else "( )" + + tri_val_str = (" ", None, "*")[sym.tri_value] + + if len(sym.assignable) == 1: + # Pinned to a single value + return "-{}-".format(tri_val_str) + + if sym.type == BOOL: + return "[{}]".format(tri_val_str) + + raise RuntimeError() + + @staticmethod + def node_str(node, indent): + """ + Returns the complete menu entry text for a menu node, or "" for + invisible menu nodes. Invisible menu nodes are those that lack a prompt + or that do not have a satisfied prompt condition. + + Example return value: "[*] Bool symbol (BOOL)" + + The symbol name is printed in parentheses to the right of the prompt. + This is so that symbols can easily be referred to in the configuration + interface. + """ + if not node.prompt: + return "" + + # Even for menu nodes for symbols and choices, it's wrong to check + # Symbol.visibility / Choice.visibility here. The reason is that a + # symbol (and a choice, in theory) can be defined in multiple + # locations, giving it multiple menu nodes, which do not necessarily + # all have the same prompt visibility. Symbol.visibility / + # Choice.visibility is calculated as the OR of the visibility of all + # the prompts. + prompt, prompt_cond = node.prompt + if not expr_value(prompt_cond): + return "" + + if node.item == MENU: + return " " + indent * " " + prompt + " --->" + + if type(node.item) == Choice: + return " " + indent * " " + prompt + + if node.item == COMMENT: + return " " + indent * " " + "*** {} ***".format(prompt) + + # Symbol + sym = node.item + + if sym.type == UNKNOWN: + return "" + + # {:3} sets the field width to three. Gives nice alignment for empty + # string values. + res = "{:3} {}{}".format(Menuconfig.value_str(sym), indent * " ", + prompt) + + # Append a sub-menu arrow if menuconfig and enabled + if node.is_menuconfig: + res += " ---" + (">" if sym.tri_value > 0 else "-") + + return res + + @staticmethod + def menu_node_strings(node, indent): + items = [] + + while node: + string = Menuconfig.node_str(node, indent) + if string: + items.append((string, node)) + + if (node.list and node.item != MENU + and (type(node.item) == Choice or not node.is_menuconfig)): + items.extend(Menuconfig.menu_node_strings(node.list, + indent + 2)) + + node = node.next + + return items + + def show_menu(self, title, top_node): + selection = 0 + + while True: + items = Menuconfig.menu_node_strings(top_node, 0) + + height = len(items) + scroll = 0 + if height > self.screen.height - 13: + height = self.screen.height - 13 + scroll = 1 + + buttons = [ + ('Save & Build', 'build', 'B'), + ('Save & Exit', 'save', 'S'), + (' Help ', 'help', 'h'), + (' Exit ', 'exit', 'ESC') + ] + buttonbar = ButtonBar(self.screen, buttons) + listbox = Listbox(height, scroll=scroll, returnExit=1) + count = 0 + for string, _ in items: + listbox.append(string, count) + if (selection == count): + listbox.setCurrent(count) + count = count + 1 + + grid = GridFormHelp(self.screen, title, None, 1, 2) + grid.add(listbox, 0, 0, padding=(0, 0, 0, 1)) + grid.add(buttonbar, 0, 1, growx=1) + grid.addHotKey(' ') + + rc = grid.runOnce() + + action = buttonbar.buttonPressed(rc) + if action and action != 'help': + return action + + if count == 0: + continue + + selection = listbox.current() + _, selected_node = items[selection] + sym = selected_node.item + + if action == 'help': + prompt, _ = selected_node.prompt + if hasattr(selected_node, 'help') and selected_node.help: + help = selected_node.help + else: + help = 'No help available.' + ButtonChoiceWindow( + screen=self.screen, + title="Help on '{}'".format(prompt), + text=help, + width=60, + buttons=[' Ok ']) + continue + + show_submenu = False + + if type(sym) == Symbol: + if rc == ' ': + if sym.type == BOOL: + sym.set_value('n' if sym.tri_value > 0 else 'y') + else: + if selected_node.is_menuconfig: + show_submenu = True + elif sym.type in (STRING, INT, HEX): + action, values = EntryWindow( + screen=self.screen, + title=sym.name, + text='Enter a %s value:' % TYPE_TO_STR[sym.type], + prompts=[('', sym.str_value)], + buttons=[(' Ok ', 'Ok'), ('Cancel', '', 'ESC')]) + if action == 'Ok': + self.kconf.warnings = [] + val = values[0] + if sym.type == HEX and not val.startswith('0x'): + val = '0x' + val + sym.set_value(val) + # only fetching triggers range check - how ugly... + sym.str_value + if len(self.kconf.warnings) > 0: + ButtonChoiceWindow( + screen=self.screen, + title="Invalid entry", + text="\n".join(self.kconf.warnings), + width=60, + buttons=[' Ok ']) + self.kconf.warnings = [] + elif selected_node.is_menuconfig and type(sym) != Choice: + show_submenu = True + + if show_submenu: + submenu_title, _ = selected_node.prompt + action = self.show_menu(submenu_title, + selected_node.list) + if action != 'exit': + return action + + def show(self): + self.screen = SnackScreen() + + action = self.show_menu(self.kconf.mainmenu_text, + self.kconf.top_node.list) + + self.screen.finish() + return action + + +__KAS_PLUGINS__ = [Menu] diff --git a/kas/schema-kas.json b/kas/schema-kas.json index 2f3a685..60fa7dd 100644 --- a/kas/schema-kas.json +++ b/kas/schema-kas.json @@ -217,6 +217,22 @@ "type": "string" } } + }, + "menu_configuration": { + "type": "object", + "additionalProperties": { + "oneOf": [ + { + "type": "boolean" + }, + { + "type": "string" + }, + { + "type": "integer" + } + ] + } } } } diff --git a/setup.py b/setup.py index f0c4fc7..2d15229 100644 --- a/setup.py +++ b/setup.py @@ -91,6 +91,7 @@ setup( 'PyYAML>=3.0,<6', 'distro>=1.0.0,<2', 'jsonschema>=2.5.0,<4', + 'kconfiglib>=14.1.0,<15', ], # At least python 3.5 is needed by now for PyYAML: