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: