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 <jan.kiszka@siemens.com>
This commit is contained in:
Jan Kiszka 2021-07-10 11:25:13 +02:00
parent 4c975a04f9
commit 4f23045ce8
6 changed files with 538 additions and 0 deletions

View File

@ -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.

View File

@ -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
* ``<local-conf-id>``: 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.

View File

@ -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):

494
kas/plugins/menu.py Normal file
View File

@ -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 <ulfalizer@gmail.com>
#
# 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 <ulfalizer@gmail.com>\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]

View File

@ -217,6 +217,22 @@
"type": "string"
}
}
},
"menu_configuration": {
"type": "object",
"additionalProperties": {
"oneOf": [
{
"type": "boolean"
},
{
"type": "string"
},
{
"type": "integer"
}
]
}
}
}
}

View File

@ -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: