From c032bc31dd2ca7fb8ae970b78d0073c4627182c0 Mon Sep 17 00:00:00 2001 From: Thorsten Klein Date: Tue, 14 Oct 2025 22:29:06 +0200 Subject: [PATCH 1/4] support 'config --print-path' Added new flag `config --print-path` (`-p`) for easy determination of the currently active configuration. The path is printed on console. --- src/west/app/config.py | 22 ++++++++++++++++++++-- src/west/configuration.py | 10 ++++++++++ tests/test_config.py | 15 +++++++++++++++ 3 files changed, 45 insertions(+), 2 deletions(-) diff --git a/src/west/app/config.py b/src/west/app/config.py index 4f50951a..29a200a6 100644 --- a/src/west/app/config.py +++ b/src/west/app/config.py @@ -42,6 +42,11 @@ from earlier ones. Local values have highest precedence, and system values lowest. +To get the according config file path: + west config --local --print-path + west config --global --print-path + west config --system --print-path + To get a value for , type: west config @@ -99,6 +104,12 @@ def do_add_parser(self, parser_adder): "action to perform (give at most one)" ).add_mutually_exclusive_group() + group.add_argument( + '-p', + '--print-path', + action='store_true', + help='print file path from according west config(--system, --global, --local)', + ) group.add_argument( '-l', '--list', action='store_true', help='list all options and their values' ) @@ -153,13 +164,15 @@ def do_run(self, args, user_args): if args.list: if args.name: self.parser.error('-l cannot be combined with name argument') - elif not args.name: + elif not args.name and not args.print_path: self.parser.error('missing argument name (to list all options and values, use -l)') elif args.append: if args.value is None: self.parser.error('-a requires both name and value') - if args.list: + if args.print_path: + self.print_path(args) + elif args.list: self.list(args) elif delete: self.delete(args) @@ -170,6 +183,11 @@ def do_run(self, args, user_args): else: self.write(args) + def print_path(self, args): + config_path = self.config.get_path(args.configfile or LOCAL) + if config_path: + print(config_path) + def list(self, args): what = args.configfile or ALL for option, value in self.config.items(configfile=what): diff --git a/src/west/configuration.py b/src/west/configuration.py index 4ecb42ce..ccf67a6c 100644 --- a/src/west/configuration.py +++ b/src/west/configuration.py @@ -181,6 +181,16 @@ def __init__(self, topdir: PathType | None = None): self._global = _InternalCF.from_path(self._global_path) self._local = _InternalCF.from_path(self._local_path) + def get_path(self, configfile: ConfigFile = ConfigFile.LOCAL): + if configfile == ConfigFile.ALL: + raise RuntimeError(f'{configfile} not allowed for get_path') + elif configfile == ConfigFile.LOCAL: + return self._local_path + elif configfile == ConfigFile.SYSTEM: + return self._system_path + elif configfile == ConfigFile.GLOBAL: + return self._global_path + def get( self, option: str, default: str | None = None, configfile: ConfigFile = ConfigFile.ALL ) -> str | None: diff --git a/tests/test_config.py b/tests/test_config.py index 3905bea8..d7f9d709 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -93,6 +93,21 @@ def test_config_global(): assert 'pytest' not in lcl +def test_config_print_path(): + stdout = cmd('config --local --print-path') + assert os.environ["WEST_CONFIG_LOCAL"] == stdout.rstrip() + + stdout = cmd('config --global --print-path') + assert os.environ["WEST_CONFIG_GLOBAL"] == stdout.rstrip() + + stdout = cmd('config --system --print-path') + assert os.environ["WEST_CONFIG_SYSTEM"] == stdout.rstrip() + + del os.environ['WEST_CONFIG_LOCAL'] + stdout = cmd('config --local --print-path') + assert "" == stdout.rstrip() + + def test_config_local(): # test_config_system for local variables. cmd('config --local pytest.local foo') From 510f59b9fcb5966cb29799178180f57ef3b31c90 Mon Sep 17 00:00:00 2001 From: "Klein, Thorsten" Date: Thu, 16 Oct 2025 08:28:47 +0200 Subject: [PATCH 2/4] support 'config --print-path' Added new flag `config --print-path` (`-p`) for easy determination of the currently active configuration. The path is printed on console. '--print-path' may display default configuration paths for system and global configurations. The local configuration must either exist or be specified via environment variable, since it cannot be determined otherwise. --- src/west/app/config.py | 10 +++++++++- src/west/configuration.py | 2 ++ tests/test_config.py | 34 +++++++++++++++++++++++++++++++--- 3 files changed, 42 insertions(+), 4 deletions(-) diff --git a/src/west/app/config.py b/src/west/app/config.py index 29a200a6..d98d78b3 100644 --- a/src/west/app/config.py +++ b/src/west/app/config.py @@ -42,11 +42,19 @@ from earlier ones. Local values have highest precedence, and system values lowest. -To get the according config file path: +The path of each configuration file currently being considered can be printed: west config --local --print-path west config --global --print-path west config --system --print-path +Note that '--print-path' may display default configuration paths for system and +global configurations. The local configuration must either exist or be specified +via environment variable, since it cannot be determined otherwise. + +may print a considered default config paths in case +of system and global configurations, whereby the local configuration must +either exist or explicitly specified via environment variable. + To get a value for , type: west config diff --git a/src/west/configuration.py b/src/west/configuration.py index ccf67a6c..33f1023c 100644 --- a/src/west/configuration.py +++ b/src/west/configuration.py @@ -185,6 +185,8 @@ def get_path(self, configfile: ConfigFile = ConfigFile.LOCAL): if configfile == ConfigFile.ALL: raise RuntimeError(f'{configfile} not allowed for get_path') elif configfile == ConfigFile.LOCAL: + if not self._local_path: + raise MalformedConfig('local configuration cannot be determined') return self._local_path elif configfile == ConfigFile.SYSTEM: return self._system_path diff --git a/tests/test_config.py b/tests/test_config.py index d7f9d709..bc4c2c8b 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -94,18 +94,46 @@ def test_config_global(): def test_config_print_path(): + # create the configs + cmd('config --local pytest.key val') + cmd('config --global pytest.key val') + cmd('config --system pytest.key val') + + # print the path while config files exist + stdout = cmd('config --print-path') + assert os.environ["WEST_CONFIG_LOCAL"] == stdout.rstrip() stdout = cmd('config --local --print-path') assert os.environ["WEST_CONFIG_LOCAL"] == stdout.rstrip() - stdout = cmd('config --global --print-path') assert os.environ["WEST_CONFIG_GLOBAL"] == stdout.rstrip() + stdout = cmd('config --system --print-path') + assert os.environ["WEST_CONFIG_SYSTEM"] == stdout.rstrip() + + # print the path while config files do NOT exist + pathlib.Path(os.environ["WEST_CONFIG_LOCAL"]).unlink() + pathlib.Path(os.environ["WEST_CONFIG_GLOBAL"]).unlink() + pathlib.Path(os.environ["WEST_CONFIG_SYSTEM"]).unlink() + # error if local config does not exist as there does no default exist + stdout = cmd('config --print-path') + assert os.environ["WEST_CONFIG_LOCAL"] == stdout.rstrip() + stdout = cmd('config --local --print-path') + assert os.environ["WEST_CONFIG_LOCAL"] == stdout.rstrip() + stdout = cmd('config --global --print-path') + assert os.environ["WEST_CONFIG_GLOBAL"] == stdout.rstrip() stdout = cmd('config --system --print-path') assert os.environ["WEST_CONFIG_SYSTEM"] == stdout.rstrip() + # Local config cannot be determined if not specified via env del os.environ['WEST_CONFIG_LOCAL'] - stdout = cmd('config --local --print-path') - assert "" == stdout.rstrip() + stderr = cmd_raises('config --local --print-path', subprocess.CalledProcessError) + assert "local configuration cannot be determined" in stderr.rstrip() + + # for global/system configuration it works as a default path is printed + del os.environ['WEST_CONFIG_GLOBAL'] + del os.environ['WEST_CONFIG_SYSTEM'] + cmd('config --global --print-path') + cmd('config --system --print-path') def test_config_local(): From eac7ccc17043494d2270a137601043c4dafad3e5 Mon Sep 17 00:00:00 2001 From: "Klein, Thorsten" Date: Thu, 16 Oct 2025 08:30:42 +0200 Subject: [PATCH 3/4] add support for 'config --list-paths' added config command to list all config files and dropin files which are currently considered by west. --- src/west/app/config.py | 29 ++++++++++++-- src/west/configuration.py | 10 +++++ tests/test_config.py | 80 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 115 insertions(+), 4 deletions(-) diff --git a/src/west/app/config.py b/src/west/app/config.py index d98d78b3..c49abae7 100644 --- a/src/west/app/config.py +++ b/src/west/app/config.py @@ -7,7 +7,7 @@ import argparse from west.commands import CommandError, WestCommand -from west.configuration import ConfigFile +from west.configuration import ConfigFile, Configuration CONFIG_DESCRIPTION = '''\ West configuration file handling. @@ -81,6 +81,14 @@ To delete everywhere it's set, including the system file: west config -D + +To list the configuration files that are loaded (both the main config file +and all drop-ins) in the exact order they were applied (where later values +override earlier ones): + west config --list-paths + west config --local --list-paths + west config --global --list-paths + west config --system --list-paths ''' CONFIG_EPILOG = '''\ @@ -113,10 +121,16 @@ def do_add_parser(self, parser_adder): ).add_mutually_exclusive_group() group.add_argument( - '-p', '--print-path', action='store_true', - help='print file path from according west config(--system, --global, --local)', + help='print the file path from according west ' + 'config (--local [default], --global, --system)', + ) + group.add_argument( + '--list-paths', + action='store_true', + help='list all config files and dropin files that ' + 'are currently considered by west config', ) group.add_argument( '-l', '--list', action='store_true', help='list all options and their values' @@ -172,7 +186,7 @@ def do_run(self, args, user_args): if args.list: if args.name: self.parser.error('-l cannot be combined with name argument') - elif not args.name and not args.print_path: + elif not any([args.name, args.print_path, args.list_paths]): self.parser.error('missing argument name (to list all options and values, use -l)') elif args.append: if args.value is None: @@ -180,6 +194,8 @@ def do_run(self, args, user_args): if args.print_path: self.print_path(args) + elif args.list_paths: + self.list_paths(args) elif args.list: self.list(args) elif delete: @@ -196,6 +212,11 @@ def print_path(self, args): if config_path: print(config_path) + def list_paths(self, args): + config_paths = Configuration().get_paths(args.configfile or ALL) + for config_path in config_paths: + print(config_path) + def list(self, args): what = args.configfile or ALL for option, value in self.config.items(configfile=what): diff --git a/src/west/configuration.py b/src/west/configuration.py index 33f1023c..8f56ea16 100644 --- a/src/west/configuration.py +++ b/src/west/configuration.py @@ -193,6 +193,16 @@ def get_path(self, configfile: ConfigFile = ConfigFile.LOCAL): elif configfile == ConfigFile.GLOBAL: return self._global_path + def get_paths(self, configfile: ConfigFile = ConfigFile.ALL): + ret = [] + if self._global and configfile in [ConfigFile.GLOBAL, ConfigFile.ALL]: + ret += self._global._paths() + if self._system and configfile in [ConfigFile.SYSTEM, ConfigFile.ALL]: + ret += self._system._paths() + if self._local and configfile in [ConfigFile.LOCAL, ConfigFile.ALL]: + ret += self._local._paths() + return ret + def get( self, option: str, default: str | None = None, configfile: ConfigFile = ConfigFile.ALL ) -> str | None: diff --git a/tests/test_config.py b/tests/test_config.py index bc4c2c8b..3e23b278 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -136,6 +136,86 @@ def test_config_print_path(): cmd('config --system --print-path') +TEST_CASES_CONFIG_LIST_PATHS = [ + # (flag, env_var) + ('--local', 'WEST_CONFIG_LOCAL'), + ('--system', 'WEST_CONFIG_SYSTEM'), + ('--global', 'WEST_CONFIG_GLOBAL'), +] + + +@pytest.mark.parametrize("test_case", TEST_CASES_CONFIG_LIST_PATHS) +def test_config_list_paths(test_case): + flag, env_var = test_case + + # no config is listed (since it does not exist) + stdout = cmd(f'config {flag} --list-paths') + assert '' == stdout.rstrip() + + # create the config + cmd(f'config {flag} pytest.key val') + + # check that the config is listed now + stdout = cmd(f'config {flag} --list-paths') + config_path = pathlib.Path(os.environ[env_var]) + assert f'{config_path}' == stdout.rstrip() + + +def test_config_list_paths_extended(): + WEST_CONFIG_LOCAL = os.environ['WEST_CONFIG_LOCAL'] + WEST_CONFIG_GLOBAL = os.environ['WEST_CONFIG_GLOBAL'] + WEST_CONFIG_SYSTEM = os.environ['WEST_CONFIG_SYSTEM'] + + # create the configs + cmd('config --local pytest.key val') + cmd('config --global pytest.key val') + cmd('config --system pytest.key val') + + # list the configs + stdout = cmd('config --list-paths') + assert ( + stdout.splitlines() + == textwrap.dedent(f'''\ + {WEST_CONFIG_GLOBAL} + {WEST_CONFIG_SYSTEM} + {WEST_CONFIG_LOCAL} + ''').splitlines() + ) + + # create some dropins files + dropin_files = [ + pathlib.Path(WEST_CONFIG_GLOBAL + '.d') / 'a.conf', + pathlib.Path(WEST_CONFIG_GLOBAL + '.d') / 'z.conf', + pathlib.Path(WEST_CONFIG_SYSTEM + '.d') / 'a.conf', + pathlib.Path(WEST_CONFIG_SYSTEM + '.d') / 'z.conf', + pathlib.Path(WEST_CONFIG_LOCAL + '.d') / 'a.conf', + pathlib.Path(WEST_CONFIG_LOCAL + '.d') / 'z.conf', + ] + for dropin_file in dropin_files: + dropin_file.parent.mkdir(exist_ok=True) + dropin_file.touch() + + # list the configs + stdout = cmd('config --list-paths') + assert ( + stdout.splitlines() + == textwrap.dedent(f'''\ + {dropin_files[0]} + {dropin_files[1]} + {WEST_CONFIG_GLOBAL} + {dropin_files[2]} + {dropin_files[3]} + {WEST_CONFIG_SYSTEM} + {dropin_files[4]} + {dropin_files[5]} + {WEST_CONFIG_LOCAL} + ''').splitlines() + ) + + # print nothing if local config does not exist (exit code 0) + del os.environ['WEST_CONFIG_LOCAL'] + + def test_config_local(): # test_config_system for local variables. cmd('config --local pytest.local foo') From b9ee7fc73957cf16cc435f1cd27896355c775b07 Mon Sep 17 00:00:00 2001 From: "Klein, Thorsten" Date: Thu, 16 Oct 2025 08:34:02 +0200 Subject: [PATCH 4/4] support config dropin directories config files in acccording dropin directories are considered automatically. The dropin directory is named as the config itself but with a '.d' suffix. For example for local config the dropin directory is './west/config.d' and for global config it is '~/.westconfig.d' support config dropin directories config files in acccording dropin directories are considered automatically. The dropin directory is named as the config itself but with a '.d' suffix. For example for local config the dropin directory is './west/config.d' and for global config it is '~/.westconfig.d' Boolean option `config.dropins` must be set to `true` so that dropin configs for the according config type are considered. So if the option config.dropins=true in enabled in local config, only local dropin configs are used (but no system or global ones). --- src/west/app/config.py | 14 +++ src/west/configuration.py | 123 ++++++++++++-------- tests/test_config.py | 228 +++++++++++++++++++++++++++++++++++--- 3 files changed, 307 insertions(+), 58 deletions(-) diff --git a/src/west/app/config.py b/src/west/app/config.py index c49abae7..c2a001e0 100644 --- a/src/west/app/config.py +++ b/src/west/app/config.py @@ -82,6 +82,20 @@ To delete everywhere it's set, including the system file: west config -D +For each configuration type (local, global, and system), an additional +drop-in config directory is supported. This directory is named as the +according config file, but with a '.d' suffix, whereby all '.conf' and +'.ini' files are loaded in alphabetical order. + +All files inside a drop-in directory must use `.conf` extension and are +loaded in **alphabetical order**. +For example: + .west/config.d/basics.conf + +Note: It is not possible to modify dropin configs.via 'west config' commands. +When config option values are set/appended/deleted via 'west config' commands, +always the config file is modified (never the dropin config files). + To list the configuration files that are loaded (both the main config file and all drop-ins) in the exact order they were applied (where later values override earlier ones): diff --git a/src/west/configuration.py b/src/west/configuration.py index 8f56ea16..fbe19d8b 100644 --- a/src/west/configuration.py +++ b/src/west/configuration.py @@ -59,6 +59,11 @@ class _InternalCF: # For internal use only; convenience interface for reading and # writing INI-style [section] key = value configuration files, # but presenting a west-style section.key = value style API. + # The config file and the drop-in configs need separate + # configparsers, since west needs to determine options that are + # set in the config. E.g. if config values are updated, only those + # values shall be written to the config, which are already present + # (and not all values that may also come from dropin configs) @staticmethod def parse_key(dotted_name: str): @@ -69,63 +74,91 @@ def parse_key(dotted_name: str): @staticmethod def from_path(path: Path | None) -> '_InternalCF | None': - return _InternalCF(path) if path and path.exists() else None + if not path: + return None + cf = _InternalCF(path) + if not cf.path and not cf.dropin_paths: + return None + return cf def __init__(self, path: Path): - self.path = path self.cp = _configparser() - read_files = self.cp.read(path, encoding='utf-8') - if len(read_files) != 1: - raise FileNotFoundError(path) + self.path = path if path.exists() else None + if self.path: + self.cp.read(self.path, encoding='utf-8') + + # consider dropin configs + self.dropin_cp = _configparser() + self.dropin_dir = None + self.dropin_paths = [] + # dropin configs must be enabled in config + if self.cp.getboolean('config', 'dropins', fallback=False): + # dropin dir is the config path with .d suffix + dropin_dir = Path(f'{path}.d') + self.dropin_dir = dropin_dir if dropin_dir.exists() else None + if self.dropin_dir: + # dropin configs are applied in alphabetical order + for conf in sorted(self.dropin_dir.iterdir()): + # only consider .conf files + if conf.suffix in ['.conf', '.ini']: + self.dropin_paths.append(self.dropin_dir / conf) + if self.dropin_paths: + self.dropin_cp.read(self.dropin_paths, encoding='utf-8') + + def _paths(self) -> list[Path]: + ret = [p for p in self.dropin_paths] + if self.path: + ret.append(self.path) + return ret + + def _write(self): + with open(self.path, 'w', encoding='utf-8') as f: + self.cp.write(f) def __contains__(self, option: str) -> bool: section, key = _InternalCF.parse_key(option) - return section in self.cp and key in self.cp[section] + if section in self.cp and key in self.cp[section]: + return True + return section in self.dropin_cp and key in self.dropin_cp[section] def get(self, option: str): - return self._get(option, self.cp.get) + return self._get(option, self.cp.get, self.dropin_cp.get) def getboolean(self, option: str): - return self._get(option, self.cp.getboolean) + return self._get(option, self.cp.getboolean, self.dropin_cp.getboolean) def getint(self, option: str): - return self._get(option, self.cp.getint) + return self._get(option, self.cp.getint, self.dropin_cp.getint) def getfloat(self, option: str): - return self._get(option, self.cp.getfloat) + return self._get(option, self.cp.getfloat, self.dropin_cp.getfloat) - def _get(self, option, getter): + def _get(self, option, config_getter, dropin_getter): section, key = _InternalCF.parse_key(option) - - try: - return getter(section, key) - except (configparser.NoOptionError, configparser.NoSectionError) as err: - raise KeyError(option) from err + if section in self.cp and key in self.cp[section]: + getter = config_getter + elif section in self.dropin_cp and key in self.dropin_cp[section]: + getter = dropin_getter + else: + raise KeyError(option) + return getter(section, key) def set(self, option: str, value: Any): section, key = _InternalCF.parse_key(option) - if section not in self.cp: self.cp[section] = {} - self.cp[section][key] = value - - with open(self.path, 'w', encoding='utf-8') as f: - self.cp.write(f) + self._write() def delete(self, option: str): section, key = _InternalCF.parse_key(option) - - if section not in self.cp: + if option not in self: raise KeyError(option) - del self.cp[section][key] if not self.cp[section].items(): del self.cp[section] - - with open(self.path, 'w', encoding='utf-8') as f: - self.cp.write(f) + self._write() class ConfigFile(Enum): @@ -183,22 +216,22 @@ def __init__(self, topdir: PathType | None = None): def get_path(self, configfile: ConfigFile = ConfigFile.LOCAL): if configfile == ConfigFile.ALL: - raise RuntimeError(f'{configfile} not allowed for get_path') + raise RuntimeError(f'{configfile} not allowed for this operation') elif configfile == ConfigFile.LOCAL: if not self._local_path: raise MalformedConfig('local configuration cannot be determined') return self._local_path - elif configfile == ConfigFile.SYSTEM: - return self._system_path elif configfile == ConfigFile.GLOBAL: return self._global_path + elif configfile == ConfigFile.SYSTEM: + return self._system_path def get_paths(self, configfile: ConfigFile = ConfigFile.ALL): ret = [] - if self._global and configfile in [ConfigFile.GLOBAL, ConfigFile.ALL]: - ret += self._global._paths() if self._system and configfile in [ConfigFile.SYSTEM, ConfigFile.ALL]: ret += self._system._paths() + if self._global and configfile in [ConfigFile.GLOBAL, ConfigFile.ALL]: + ret += self._global._paths() if self._local and configfile in [ConfigFile.LOCAL, ConfigFile.ALL]: ret += self._local._paths() return ret @@ -376,13 +409,14 @@ def _copy_to_configparser(self, cp: configparser.ConfigParser) -> None: # function-and-global-state APIs. def load(cf: _InternalCF): - for section, contents in cf.cp.items(): - if section == 'DEFAULT': - continue - if section not in cp: - cp.add_section(section) - for key, value in contents.items(): - cp[section][key] = value + for cp in [cf.dropin_cp, cf.cp]: + for section, contents in cp.items(): + if section == 'DEFAULT': + continue + if section not in cp: + cp.add_section(section) + for key, value in contents.items(): + cp[section][key] = value if self._system: load(self._system) @@ -428,11 +462,12 @@ def _cf_to_dict(cf: _InternalCF | None) -> dict[str, Any]: ret: dict[str, Any] = {} if cf is None: return ret - for section, contents in cf.cp.items(): - if section == 'DEFAULT': - continue - for key, value in contents.items(): - ret[f'{section}.{key}'] = value + for cp in [cf.dropin_cp, cf.cp]: + for section, contents in cp.items(): + if section == 'DEFAULT': + continue + for key, value in contents.items(): + ret[f'{section}.{key}'] = value return ret diff --git a/tests/test_config.py b/tests/test_config.py index 3e23b278..d7c66ee3 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -6,6 +6,7 @@ import os import pathlib import subprocess +import textwrap from typing import Any import pytest @@ -162,32 +163,32 @@ def test_config_list_paths(test_case): def test_config_list_paths_extended(): - WEST_CONFIG_LOCAL = os.environ['WEST_CONFIG_LOCAL'] - WEST_CONFIG_GLOBAL = os.environ['WEST_CONFIG_GLOBAL'] WEST_CONFIG_SYSTEM = os.environ['WEST_CONFIG_SYSTEM'] + WEST_CONFIG_GLOBAL = os.environ['WEST_CONFIG_GLOBAL'] + WEST_CONFIG_LOCAL = os.environ['WEST_CONFIG_LOCAL'] # create the configs - cmd('config --local pytest.key val') - cmd('config --global pytest.key val') - cmd('config --system pytest.key val') + cmd('config --system pytest.key val1') + cmd('config --global pytest.key val2') + cmd('config --local pytest.key val3') # list the configs stdout = cmd('config --list-paths') assert ( stdout.splitlines() == textwrap.dedent(f'''\ - {WEST_CONFIG_GLOBAL} {WEST_CONFIG_SYSTEM} + {WEST_CONFIG_GLOBAL} {WEST_CONFIG_LOCAL} ''').splitlines() ) - # create some dropins files + # create some dropin config files dropin_files = [ - pathlib.Path(WEST_CONFIG_GLOBAL + '.d') / 'a.conf', - pathlib.Path(WEST_CONFIG_GLOBAL + '.d') / 'z.conf', pathlib.Path(WEST_CONFIG_SYSTEM + '.d') / 'a.conf', pathlib.Path(WEST_CONFIG_SYSTEM + '.d') / 'z.conf', + pathlib.Path(WEST_CONFIG_GLOBAL + '.d') / 'a.conf', + pathlib.Path(WEST_CONFIG_GLOBAL + '.d') / 'z.conf', pathlib.Path(WEST_CONFIG_LOCAL + '.d') / 'a.conf', pathlib.Path(WEST_CONFIG_LOCAL + '.d') / 'z.conf', ] @@ -195,25 +196,89 @@ def test_config_list_paths_extended(): dropin_file.parent.mkdir(exist_ok=True) dropin_file.touch() - # list the configs + # list the configs (dropin configs are not enabled by default) + stdout = cmd('config --list-paths') + assert ( + stdout.splitlines() + == textwrap.dedent(f'''\ + {WEST_CONFIG_SYSTEM} + {WEST_CONFIG_GLOBAL} + {WEST_CONFIG_LOCAL} + ''').splitlines() + ) + + # enable config.dropins for each config type + cmd('config --system config.dropins true') + cmd('config --global config.dropins true') + cmd('config --local config.dropins true') + + # list the configs (consider dropin configs) stdout = cmd('config --list-paths') assert ( stdout.splitlines() == textwrap.dedent(f'''\ {dropin_files[0]} {dropin_files[1]} - {WEST_CONFIG_GLOBAL} + {WEST_CONFIG_SYSTEM} {dropin_files[2]} {dropin_files[3]} - {WEST_CONFIG_SYSTEM} + {WEST_CONFIG_GLOBAL} {dropin_files[4]} {dropin_files[5]} {WEST_CONFIG_LOCAL} ''').splitlines() ) - # print nothing if local config does not exist (exit code 0) - del os.environ['WEST_CONFIG_LOCAL'] + # assert local config value is overriding + stdout = cmd('config --system pytest.key') + assert stdout.rstrip() == 'val1' + stdout = cmd('config --global pytest.key') + assert stdout.rstrip() == 'val2' + stdout = cmd('config --local pytest.key') + assert stdout.rstrip() == 'val3' + stdout = cmd('config pytest.key') + assert stdout.rstrip() == 'val3' + + # append config values and write them to the config + cmd('config --system -a pytest.key +append') + stdout = cmd('config --system pytest.key') + assert stdout.rstrip() == 'val1+append' + cmd('config --global -a pytest.key +append') + stdout = cmd('config --global pytest.key') + assert stdout.rstrip() == 'val2+append' + cmd('config --local -a pytest.key +append') + stdout = cmd('config --local pytest.key') + assert stdout.rstrip() == 'val3+append' + + # list the configs only when dropin configs are disabled + cmd('config --system config.dropins false') + cmd('config --global config.dropins false') + cmd('config --local config.dropins false') + stdout = cmd('config --list-paths') + assert ( + stdout.splitlines() + == textwrap.dedent(f'''\ + {WEST_CONFIG_SYSTEM} + {WEST_CONFIG_GLOBAL} + {WEST_CONFIG_LOCAL} + ''').splitlines() + ) + + # values from the config are still set + stdout = cmd('config --system pytest.key') + assert stdout.rstrip() == 'val1+append' + stdout = cmd('config --global pytest.key') + assert stdout.rstrip() == 'val2+append' + stdout = cmd('config --local pytest.key') + assert stdout.rstrip() == 'val3+append' + + # do not list any configs if no config files exist + # (Note: even no local config exists, same as outside any west workspace) + pathlib.Path(WEST_CONFIG_SYSTEM).unlink() + pathlib.Path(WEST_CONFIG_GLOBAL).unlink() + pathlib.Path(WEST_CONFIG_LOCAL).unlink() + stdout = cmd('config --list-paths') + assert stdout.splitlines() == [] def test_config_local(): @@ -334,6 +399,141 @@ def test_local_creation(): assert cfg(f=LOCAL)['pytest']['key'] == 'val' +TEST_CASES_DROPIN_CONFIGS = [ + # (flag, env_var) + ('', 'WEST_CONFIG_LOCAL'), + ('--local', 'WEST_CONFIG_LOCAL'), + ('--system', 'WEST_CONFIG_SYSTEM'), + ('--global', 'WEST_CONFIG_GLOBAL'), +] + + +@pytest.mark.parametrize("test_case", TEST_CASES_DROPIN_CONFIGS) +def test_config_dropins(test_case): + flag, env_var = test_case + config_path = pathlib.Path(os.environ[env_var]) + dropin_configs_dir = pathlib.Path(f'{config_path}.d') + dropin_configs_dir.mkdir() + + # write value in actual config file + cmd(f'config {flag} pytest.key val') + cmd(f'config {flag} pytest.config-only val') + cmd(f'config {flag} config.dropins true') + + # read config value via command line + stdout = cmd(f'config {flag} pytest.key') + assert 'val' == stdout.rstrip() + stdout = cmd(f'config {flag} pytest.config-only') + assert 'val' == stdout.rstrip() + + # read the config file + with open(config_path) as config_file: + config_file_initial = config_file.read() + assert config_file_initial == textwrap.dedent('''\ + [pytest] + key = val + config-only = val + + [config] + dropins = true + + ''') + + # create some dropin configs under .d + with open(dropin_configs_dir / 'a.conf', 'w') as conf: + conf.write( + textwrap.dedent(''' + [pytest] + key = from dropin a + dropin-only = from dropin a + dropin-only-a = from dropin a + ''') + ) + with open(dropin_configs_dir / 'z.conf', 'w') as conf: + conf.write( + textwrap.dedent(''' + [pytest] + dropin-only = from dropin z + dropin-only-z = from dropin z + ''') + ) + + # value from config is prefered over dropin config + stdout = cmd(f'config {flag} pytest.key') + assert 'val' == stdout.rstrip() + stdout = cmd(f'config {flag} pytest.dropin-only-a') + assert 'from dropin a' == stdout.rstrip() + stdout = cmd(f'config {flag} pytest.dropin-only-z') + assert 'from dropin z' == stdout.rstrip() + # alphabetical order (z.conf is overwriting a.conf) + stdout = cmd(f'config {flag} pytest.dropin-only') + assert 'from dropin z' == stdout.rstrip() + + # deletion of a value that is only set in a dropin config should fail + cmd_raises(f'config {flag} -d pytest.dropin-only', subprocess.CalledProcessError) + + # deletion of a value that is set in config + stdout = cmd(f'config {flag} -d pytest.config-only') + assert '' == stdout + with open(config_path) as config_file: + config_file_edited = config_file.read() + assert config_file_edited == textwrap.dedent('''\ + [pytest] + key = val + + [config] + dropins = true + + ''') + + # appending of a value that is only set in a dropin config: + # - value is calculated on base of the current value and written to config + # - dropin config is not modified. + cmd(f'config {flag} -a pytest.dropin-only +appended') + stdout = cmd(f'config {flag} pytest.dropin-only') + assert stdout.rstrip() == 'from dropin z+appended' + with open(dropin_configs_dir / 'z.conf') as conf: + config_file_edited = conf.read() + assert config_file_edited == textwrap.dedent(''' + [pytest] + dropin-only = from dropin z + dropin-only-z = from dropin z + ''') + + # remove config file and only set config.dropins true again + config_path.unlink() + cmd(f'config {flag} config.dropins true') + + # values from config are unset now + stderr = cmd_raises(f'config {flag} pytest.config-only', subprocess.CalledProcessError) + assert 'ERROR: pytest.config-only is unset' == stderr.rstrip() + + # dropin config values are used now, since they are not set in config + stdout = cmd(f'config {flag} pytest.key') + assert 'from dropin a' == stdout.rstrip() + # alphabetical order (z.conf is overwriting a.conf) + stdout = cmd(f'config {flag} pytest.dropin-only') + assert 'from dropin z' == stdout.rstrip() + # other values remain same + stdout = cmd(f'config {flag} pytest.dropin-only-a') + assert 'from dropin a' == stdout.rstrip() + # values specified in only one dropin config remain same + stdout = cmd(f'config {flag} pytest.dropin-only-z') + assert 'from dropin z' == stdout.rstrip() + + # deletion of a value that is not existing anymore should fail + cmd_raises(f'config {flag} -d pytest.config-only', subprocess.CalledProcessError) + + # remove config (config.dropins is false by default) + config_path.unlink() + + # values from dropin configs are not considered now + cmd_raises(f'config {flag} pytest.key', subprocess.CalledProcessError) + cmd_raises(f'config {flag} pytest.dropin-only', subprocess.CalledProcessError) + cmd_raises(f'config {flag} pytest.dropin-only-a', subprocess.CalledProcessError) + cmd_raises(f'config {flag} pytest.dropin-only-z', subprocess.CalledProcessError) + + def test_local_creation_with_topdir(): # Like test_local_creation, with a specified topdir.