diff --git a/src/west/app/config.py b/src/west/app/config.py index 07d96a4f..5f8dcfaf 100644 --- a/src/west/app/config.py +++ b/src/west/app/config.py @@ -49,8 +49,16 @@ providing one of the following arguments: --local | --system | --global -The following command prints a list of all configuration files currently -considered and existing (listed in the order as they are loaded): +For each configuration level (local, global, and system) also multiple config +file locations can be specified. To do so, set according environment variable +to contain all paths (separated by 'os.pathsep', which is ';' on Windows or +':' otherwise): Latter configuration files have precedence in such lists. + +To list all configuration files searched by west config, in the order as they +are looked up: + west config --list-search-paths + +To list only existing configs (listed in the order as they are loaded): west config --list-paths To get the value for config option , type: @@ -113,7 +121,12 @@ def do_add_parser(self, parser_adder): group.add_argument( '--list-paths', action='store_true', - help='list all config files that are currently considered by west config', + help='list paths of existing config files currently used by west config', + ) + group.add_argument( + '--list-search-paths', + action='store_true', + help='list search paths for west config files', ) group.add_argument( '-l', '--list', action='store_true', help='list all options and their values' @@ -169,7 +182,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.list_paths: + elif not any([args.name, args.list_paths, args.list_search_paths]): self.parser.error('missing argument name (to list all options and values, use -l)') elif args.append: if args.value is None: @@ -177,6 +190,8 @@ def do_run(self, args, user_args): if args.list_paths: self.list_paths(args) + elif args.list_search_paths: + self.list_search_paths(args) elif args.list: self.list(args) elif delete: @@ -189,10 +204,15 @@ def do_run(self, args, user_args): self.write(args) def list_paths(self, args): - config_paths = self.config.get_paths(args.configfile or ALL) + config_paths = self.config.get_existing_paths(args.configfile or ALL) for config_path in config_paths: self.inf(config_path) + def list_search_paths(self, args): + search_paths = self.config.get_search_paths(args.configfile or ALL) + for search_path in search_paths: + self.inf(search_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 6a1a247a..8f418203 100644 --- a/src/west/configuration.py +++ b/src/west/configuration.py @@ -70,19 +70,31 @@ def parse_key(dotted_name: str): return section_child @staticmethod - def from_path(path: Path | None) -> '_InternalCF | None': - return _InternalCF(path) if path and path.exists() else None + def from_paths(paths: list[Path] | None) -> '_InternalCF | None': + if not paths: + return None + paths = [p for p in paths if p.exists()] + return _InternalCF(paths) if paths else None - def __init__(self, path: Path): - self.path = path + def __init__(self, paths: list[Path]): self.cp = _configparser() - read_files = self.cp.read(path, encoding='utf-8') - if len(read_files) != 1: - raise FileNotFoundError(path) + self.paths = paths + read_files = self.cp.read(self.paths, encoding='utf-8') + if len(read_files) != len(self.paths): + raise MalformedConfig(f"Error while reading one of '{paths}'") + + def _check_single_config(self): + assert self.paths + if len(self.paths) > 1: + raise ValueError(f'Cannot write if multiple configs in use: {self.paths}') + + def _write(self): + assert len(self.paths) == 1 # (in case some function forgot to check) + with open(self.paths[0], '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] def get(self, option: str): @@ -106,6 +118,7 @@ def _get(self, option, getter): raise KeyError(option) from err def set(self, option: str, value: Any): + self._check_single_config() section, key = _InternalCF.parse_key(option) if section not in self.cp: @@ -113,10 +126,10 @@ def set(self, option: str, value: Any): 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): + self._check_single_config() section, key = _InternalCF.parse_key(option) if section not in self.cp: @@ -126,8 +139,20 @@ def delete(self, option: str): 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 _Converter: + @staticmethod + def parse_paths(paths: str | None, sep: str = os.pathsep) -> list[Path]: + """Split a string into a list of Path objects using the given separator.""" + paths = paths or "" + return [Path(p) for p in paths.split(sep) if p] + + @staticmethod + def str_list_to_paths(paths: list[str]) -> list[Path]: + """Convert a list of path strings into a list of Path objects.""" + return [Path(p) for p in paths] class ConfigFile(Enum): @@ -173,26 +198,34 @@ def __init__(self, topdir: PathType | None = None): :param topdir: workspace location; may be None ''' - local_path = _location(ConfigFile.LOCAL, topdir=topdir, find_local=False) or None + local_paths = _location(ConfigFile.LOCAL, topdir=topdir, find_local=False) or [] - self._system_path = Path(_location(ConfigFile.SYSTEM, topdir=topdir)) - self._global_path = Path(_location(ConfigFile.GLOBAL, topdir=topdir)) - self._local_path = Path(local_path) if local_path is not None else None + self._system_paths = _Converter.str_list_to_paths( + _location(ConfigFile.SYSTEM, topdir=topdir) + ) + self._global_paths = _Converter.str_list_to_paths( + _location(ConfigFile.GLOBAL, topdir=topdir) + ) + self._local_paths = _Converter.str_list_to_paths(local_paths) - self._system = _InternalCF.from_path(self._system_path) - self._global = _InternalCF.from_path(self._global_path) - self._local = _InternalCF.from_path(self._local_path) + self._system = _InternalCF.from_paths(self._system_paths) + self._global = _InternalCF.from_paths(self._global_paths) + self._local = _InternalCF.from_paths(self._local_paths) - def get_paths(self, location: ConfigFile = ConfigFile.ALL) -> list[Path]: + def get_search_paths(self, location: ConfigFile = ConfigFile.ALL) -> list[Path]: ret = [] - if self._global and location in [ConfigFile.GLOBAL, ConfigFile.ALL]: - ret.append(self._global.path) - if self._system and location in [ConfigFile.SYSTEM, ConfigFile.ALL]: - ret.append(self._system.path) - if self._local and location in [ConfigFile.LOCAL, ConfigFile.ALL]: - ret.append(self._local.path) + if location in [ConfigFile.SYSTEM, ConfigFile.ALL]: + ret.extend(self._system_paths) + if location in [ConfigFile.GLOBAL, ConfigFile.ALL]: + ret.extend(self._global_paths) + if location in [ConfigFile.LOCAL, ConfigFile.ALL]: + ret.extend(self._local_paths) return ret + def get_existing_paths(self, location: ConfigFile = ConfigFile.ALL) -> list[Path]: + paths = self.get_search_paths(location) + return [p for p in paths if p.exists()] + def get( self, option: str, default: str | None = None, configfile: ConfigFile = ConfigFile.ALL ) -> str | None: @@ -283,28 +316,42 @@ def set(self, option: str, value: Any, configfile: ConfigFile = ConfigFile.LOCAL :param configfile: type of config file to set the value in ''' + def get_single_configfile(location: ConfigFile) -> Path: + ''' + check that exactly one configfile is in use (even if it not exists yet) + and return its path. + ''' + configs = self.get_search_paths(location) + if len(configs) > 1: + raise ValueError(f'Cannot set value if multiple configs in use: {configs}') + assert len(configs) == 1 + return configs[0] + if configfile == ConfigFile.ALL: # We need a real configuration file; ALL doesn't make sense here. raise ValueError(configfile) elif configfile == ConfigFile.LOCAL: - if self._local_path is None: + if not self._local_paths: raise ValueError( f'{configfile}: file not found; retry in a workspace or set WEST_CONFIG_LOCAL' ) - if not self._local_path.exists(): - self._local = self._create(self._local_path) + config_file = get_single_configfile(configfile) + if not config_file.exists(): + self._local = self._create(config_file) if TYPE_CHECKING: assert self._local self._local.set(option, value) elif configfile == ConfigFile.GLOBAL: - if not self._global_path.exists(): - self._global = self._create(self._global_path) + config_file = get_single_configfile(configfile) + if not config_file.exists(): + self._global = self._create(config_file) if TYPE_CHECKING: assert self._global self._global.set(option, value) elif configfile == ConfigFile.SYSTEM: - if not self._system_path.exists(): - self._system = self._create(self._system_path) + config_file = get_single_configfile(configfile) + if not config_file.exists(): + self._system = self._create(config_file) if TYPE_CHECKING: assert self._system self._system.set(option, value) @@ -316,7 +363,7 @@ def set(self, option: str, value: Any, configfile: ConfigFile = ConfigFile.LOCAL def _create(path: Path) -> _InternalCF: path.parent.mkdir(parents=True, exist_ok=True) path.touch(exist_ok=True) - ret = _InternalCF.from_path(path) + ret = _InternalCF.from_paths([path]) if TYPE_CHECKING: assert ret return ret @@ -554,18 +601,20 @@ def delete_config( _deprecated('delete_config') stop = False + considered_locations = [] if configfile is None: - to_check = [_location(x, topdir=topdir) for x in [ConfigFile.LOCAL, ConfigFile.GLOBAL]] + considered_locations = [ConfigFile.LOCAL, ConfigFile.GLOBAL] stop = True elif configfile == ConfigFile.ALL: - to_check = [ - _location(x, topdir=topdir) - for x in [ConfigFile.SYSTEM, ConfigFile.GLOBAL, ConfigFile.LOCAL] - ] + considered_locations = [ConfigFile.SYSTEM, ConfigFile.GLOBAL, ConfigFile.LOCAL] elif isinstance(configfile, ConfigFile): - to_check = [_location(configfile, topdir=topdir)] + considered_locations = [configfile] else: - to_check = [_location(x, topdir=topdir) for x in configfile] + considered_locations = configfile + assert isinstance(considered_locations, list) + + # ensure that only one config file is in use for the considered_locations + to_check = [f for cfg in considered_locations for f in _location(cfg, topdir=topdir)] found = False for path in to_check: @@ -597,7 +646,9 @@ def _rel_topdir_to_abs(p: PathType, topdir: PathType | None) -> str: return str(os.path.join(topdir, p)) -def _location(cfg: ConfigFile, topdir: PathType | None = None, find_local: bool = True) -> str: +def _location( + cfg: ConfigFile, topdir: PathType | None = None, find_local: bool = True +) -> list[str]: # Return the WEST_CONFIG_x environment variable if defined, or the # OS-specific default value. Anchors relative paths to # "topdir". Does _not_ check whether the file exists or if it is @@ -622,21 +673,22 @@ def _location(cfg: ConfigFile, topdir: PathType | None = None, find_local: bool raise ValueError('ConfigFile.ALL has no location') elif cfg == ConfigFile.SYSTEM: if 'WEST_CONFIG_SYSTEM' in env: - return _rel_topdir_to_abs(env['WEST_CONFIG_SYSTEM'], topdir) + paths = _Converter.parse_paths(env['WEST_CONFIG_SYSTEM']) + return [_rel_topdir_to_abs(p, topdir) for p in paths] plat = platform.system() if plat == 'Linux': - return '/etc/westconfig' + return ['/etc/westconfig'] if plat == 'Darwin': - return '/usr/local/etc/westconfig' + return ['/usr/local/etc/westconfig'] if plat == 'Windows': - return os.path.expandvars('%PROGRAMDATA%\\west\\config') + return [os.path.expandvars('%PROGRAMDATA%\\west\\config')] if 'BSD' in plat: - return '/etc/westconfig' + return ['/etc/westconfig'] if 'CYGWIN' in plat or 'MSYS_NT' in plat: # Cygwin can handle windows style paths, so make sure we @@ -647,29 +699,31 @@ def _location(cfg: ConfigFile, topdir: PathType | None = None, find_local: bool # See https://github.com/zephyrproject-rtos/west/issues/300 # for details. pd = PureWindowsPath(os.environ['ProgramData']) - return os.fspath(pd / 'west' / 'config') + return [os.fspath(pd / 'west' / 'config')] raise ValueError('unsupported platform ' + plat) elif cfg == ConfigFile.GLOBAL: if 'WEST_CONFIG_GLOBAL' in env: - return _rel_topdir_to_abs(env['WEST_CONFIG_GLOBAL'], topdir) + paths = _Converter.parse_paths(env['WEST_CONFIG_GLOBAL']) + return [_rel_topdir_to_abs(p, topdir) for p in paths] if platform.system() == 'Linux' and 'XDG_CONFIG_HOME' in env: - return os.path.join(env['XDG_CONFIG_HOME'], 'west', 'config') + return [os.path.join(env['XDG_CONFIG_HOME'], 'west', 'config')] - return os.fspath(Path.home() / '.westconfig') + return [os.fspath(Path.home() / '.westconfig')] elif cfg == ConfigFile.LOCAL: if 'WEST_CONFIG_LOCAL' in env: - return _rel_topdir_to_abs(env['WEST_CONFIG_LOCAL'], topdir) + paths = _Converter.parse_paths(env['WEST_CONFIG_LOCAL']) + return [_rel_topdir_to_abs(p, topdir) for p in paths] if topdir: - return os.fspath(Path(topdir) / WEST_DIR / 'config') + return [os.fspath(Path(topdir) / WEST_DIR / 'config')] if find_local: # Might raise WestNotFound! - return os.fspath(Path(west_dir()) / 'config') + return [os.fspath(Path(west_dir()) / 'config')] else: - return '' + return [] else: raise ValueError(f'invalid configuration file {cfg}') @@ -677,15 +731,15 @@ def _location(cfg: ConfigFile, topdir: PathType | None = None, find_local: bool def _gather_configs(cfg: ConfigFile, topdir: PathType | None) -> list[str]: # Find the paths to the given configuration files, in increasing # precedence order. - ret = [] + ret: list[str] = [] if cfg == ConfigFile.ALL or cfg == ConfigFile.SYSTEM: - ret.append(_location(ConfigFile.SYSTEM, topdir=topdir)) + ret.extend(_location(ConfigFile.SYSTEM, topdir=topdir)) if cfg == ConfigFile.ALL or cfg == ConfigFile.GLOBAL: - ret.append(_location(ConfigFile.GLOBAL, topdir=topdir)) + ret.extend(_location(ConfigFile.GLOBAL, topdir=topdir)) if cfg == ConfigFile.ALL or cfg == ConfigFile.LOCAL: try: - ret.append(_location(ConfigFile.LOCAL, topdir=topdir)) + ret.extend(_location(ConfigFile.LOCAL, topdir=topdir)) except WestNotFound: pass @@ -695,11 +749,12 @@ def _gather_configs(cfg: ConfigFile, topdir: PathType | None) -> list[str]: def _ensure_config(configfile: ConfigFile, topdir: PathType | None) -> str: # Ensure the given configfile exists, returning its path. May # raise permissions errors, WestNotFound, etc. - loc = _location(configfile, topdir=topdir) - path = Path(loc) - + configs: list[str] = _location(configfile, topdir=topdir) + assert configs, 'No configfile found' + assert len(configs) == 1, f'Multiple config files in use: {configs}' + path = Path(configs[0]) if path.is_file(): - return loc + return os.fspath(path) path.parent.mkdir(parents=True, exist_ok=True) path.touch(exist_ok=True) diff --git a/tests/conftest.py b/tests/conftest.py index 60411688..aaa074be 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -68,6 +68,22 @@ # +@contextlib.contextmanager +def tmp_west_topdir(path: str | Path): + """ + Temporarily create a west topdir for the duration of the `with` block by + creating a .west directory at given path. The directory is removed again + when the `with` block exits. + """ + west_dir = Path(path) / '.west' + west_dir.mkdir(parents=True) + try: + yield + finally: + # remove the directory (must be empty) + west_dir.rmdir() + + @contextlib.contextmanager def update_env(env: dict[str, str | None]): """ diff --git a/tests/test_config.py b/tests/test_config.py index c65f6377..4a7903cf 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -9,9 +9,10 @@ from typing import Any import pytest -from conftest import chdir, cmd, cmd_raises, update_env +from conftest import WINDOWS, chdir, cmd, cmd_raises, tmp_west_topdir, update_env from west import configuration as config +from west.configuration import MalformedConfig from west.util import PathType, WestNotFound SYSTEM = config.ConfigFile.SYSTEM @@ -19,6 +20,18 @@ LOCAL = config.ConfigFile.LOCAL ALL = config.ConfigFile.ALL +west_env = { + SYSTEM: 'WEST_CONFIG_SYSTEM', + GLOBAL: 'WEST_CONFIG_GLOBAL', + LOCAL: 'WEST_CONFIG_LOCAL', +} + +west_flag = { + SYSTEM: '--system', + GLOBAL: '--global', + LOCAL: '--local', +} + @pytest.fixture(autouse=True) def autouse_config_tmpdir(config_tmpdir): @@ -93,18 +106,11 @@ def test_config_global(): assert 'pytest' not in lcl -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_env(test_case): +@pytest.mark.parametrize("location", [LOCAL, GLOBAL, SYSTEM]) +def test_config_list_paths_env(location): '''Test that --list-paths considers the env variables''' - flag, env_var = test_case + flag = west_flag[location] + env_var = west_env[location] # create the config cmd(f'config {flag} pytest.key val') @@ -139,16 +145,16 @@ def test_config_list_paths(): assert ( stdout.splitlines() == textwrap.dedent(f'''\ - {WEST_CONFIG_GLOBAL} {WEST_CONFIG_SYSTEM} + {WEST_CONFIG_GLOBAL} {WEST_CONFIG_LOCAL} ''').splitlines() ) # do not list any configs if no config files currently exist # (Note: even no local config exists, same as outside any west workspace) - pathlib.Path(WEST_CONFIG_GLOBAL).unlink() 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() == [] @@ -189,6 +195,83 @@ def test_config_list_paths(): assert 'topdir' in str(WNE) +def test_config_list_search_paths_all(): + WEST_CONFIG_SYSTEM = os.getenv('WEST_CONFIG_SYSTEM') + WEST_CONFIG_GLOBAL = os.getenv('WEST_CONFIG_GLOBAL') + WEST_CONFIG_LOCAL = os.getenv('WEST_CONFIG_LOCAL') + + # one config file set via env variables + stdout = cmd('config --list-search-paths') + assert stdout.splitlines() == [WEST_CONFIG_SYSTEM, WEST_CONFIG_GLOBAL, WEST_CONFIG_LOCAL] + + # multiple config files are set via env variables + conf_dir = (pathlib.Path('some') / 'conf').resolve() + config_s1 = conf_dir / "s1" + config_s2 = conf_dir / "s2" + config_g1 = conf_dir / "g1" + config_g2 = conf_dir / "g2" + config_l1 = conf_dir / "l1" + config_l2 = conf_dir / "l2" + env = { + 'WEST_CONFIG_SYSTEM': f'{config_s1}{os.pathsep}{config_s2}', + 'WEST_CONFIG_GLOBAL': f'{config_g1}{os.pathsep}{config_g2}', + 'WEST_CONFIG_LOCAL': f'{config_l1}{os.pathsep}{config_l2}', + } + with update_env(env): + stdout = cmd('config --list-search-paths') + search_paths = stdout.splitlines() + assert search_paths == [ + str(config_s1), + str(config_s2), + str(config_g1), + str(config_g2), + str(config_l1), + str(config_l2), + ] + + # unset all west config env variables + env = { + 'WEST_CONFIG_SYSTEM': None, + 'WEST_CONFIG_GLOBAL': None, + 'WEST_CONFIG_LOCAL': None, + } + with update_env(env): + # outside west topdir: show system and global config search paths + stdout = cmd('config --list-search-paths') + search_paths = stdout.splitlines() + assert len(search_paths) == 2 + + # inside west topdir: show system, global and local config search paths + west_topdir = pathlib.Path('.') + with tmp_west_topdir(west_topdir): + stdout = cmd('config --list-search-paths') + search_paths = stdout.splitlines() + assert len(search_paths) == 3 + local_path = (west_topdir / '.west' / 'config').resolve() + assert search_paths[2] == str(local_path) + + +@pytest.mark.parametrize("location", [LOCAL, GLOBAL, SYSTEM]) +def test_config_list_search_paths(location): + flag = '' if location == ALL else west_flag[location] + env_var = west_env[location] if flag else None + + west_topdir = pathlib.Path('.') + config1 = (west_topdir / 'some' / 'config 1').resolve() + config2 = pathlib.Path('relative') / 'c 2' + config2_abs = config2.resolve() + with tmp_west_topdir(west_topdir): + env = {env_var: f'{config1}{os.pathsep}{config2}'} + # env variable contains two config files + with update_env(env): + stdout = cmd(f'config {flag} --list-search-paths') + assert stdout.splitlines() == [str(config1), str(config2_abs)] + # if no env var is set it should list one default search path + with update_env({env_var: None}): + stdout = cmd(f'config {flag} --list-search-paths') + assert len(stdout.splitlines()) == 1 + + def test_config_local(): # test_config_system for local variables. cmd('config --local pytest.local foo') @@ -256,15 +339,15 @@ def test_system_creation(): # Test that the system file -- and just that file -- is created on # demand. - assert not os.path.isfile(config._location(SYSTEM)) - assert not os.path.isfile(config._location(GLOBAL)) - assert not os.path.isfile(config._location(LOCAL)) + assert not os.path.isfile(config._location(SYSTEM)[0]) + assert not os.path.isfile(config._location(GLOBAL)[0]) + assert not os.path.isfile(config._location(LOCAL)[0]) update_testcfg('pytest', 'key', 'val', configfile=SYSTEM) - assert os.path.isfile(config._location(SYSTEM)) - assert not os.path.isfile(config._location(GLOBAL)) - assert not os.path.isfile(config._location(LOCAL)) + assert os.path.isfile(config._location(SYSTEM)[0]) + assert not os.path.isfile(config._location(GLOBAL)[0]) + assert not os.path.isfile(config._location(LOCAL)[0]) assert cfg(f=ALL)['pytest']['key'] == 'val' assert cfg(f=SYSTEM)['pytest']['key'] == 'val' assert 'pytest' not in cfg(f=GLOBAL) @@ -274,15 +357,15 @@ def test_system_creation(): def test_global_creation(): # Like test_system_creation, for global config options. - assert not os.path.isfile(config._location(SYSTEM)) - assert not os.path.isfile(config._location(GLOBAL)) - assert not os.path.isfile(config._location(LOCAL)) + assert not os.path.isfile(config._location(SYSTEM)[0]) + assert not os.path.isfile(config._location(GLOBAL)[0]) + assert not os.path.isfile(config._location(LOCAL)[0]) update_testcfg('pytest', 'key', 'val', configfile=GLOBAL) - assert not os.path.isfile(config._location(SYSTEM)) - assert os.path.isfile(config._location(GLOBAL)) - assert not os.path.isfile(config._location(LOCAL)) + assert not os.path.isfile(config._location(SYSTEM)[0]) + assert os.path.isfile(config._location(GLOBAL)[0]) + assert not os.path.isfile(config._location(LOCAL)[0]) assert cfg(f=ALL)['pytest']['key'] == 'val' assert 'pytest' not in cfg(f=SYSTEM) assert cfg(f=GLOBAL)['pytest']['key'] == 'val' @@ -292,15 +375,15 @@ def test_global_creation(): def test_local_creation(): # Like test_system_creation, for local config options. - assert not os.path.isfile(config._location(SYSTEM)) - assert not os.path.isfile(config._location(GLOBAL)) - assert not os.path.isfile(config._location(LOCAL)) + assert not os.path.isfile(config._location(SYSTEM)[0]) + assert not os.path.isfile(config._location(GLOBAL)[0]) + assert not os.path.isfile(config._location(LOCAL)[0]) update_testcfg('pytest', 'key', 'val', configfile=LOCAL) - assert not os.path.isfile(config._location(SYSTEM)) - assert not os.path.isfile(config._location(GLOBAL)) - assert os.path.isfile(config._location(LOCAL)) + assert not os.path.isfile(config._location(SYSTEM)[0]) + assert not os.path.isfile(config._location(GLOBAL)[0]) + assert os.path.isfile(config._location(LOCAL)[0]) assert cfg(f=ALL)['pytest']['key'] == 'val' assert 'pytest' not in cfg(f=SYSTEM) assert 'pytest' not in cfg(f=GLOBAL) @@ -310,9 +393,9 @@ def test_local_creation(): def test_local_creation_with_topdir(): # Like test_local_creation, with a specified topdir. - system = pathlib.Path(config._location(SYSTEM)) - glbl = pathlib.Path(config._location(GLOBAL)) - local = pathlib.Path(config._location(LOCAL)) + system = pathlib.Path(config._location(SYSTEM)[0]) + glbl = pathlib.Path(config._location(GLOBAL)[0]) + local = pathlib.Path(config._location(LOCAL)[0]) topdir = pathlib.Path(os.getcwd()) / 'test-topdir' topdir_west = topdir / '.west' @@ -614,6 +697,154 @@ def test_config_precedence(): assert cfg(f=LOCAL)['pytest']['precedence'] == 'local' +def test_config_multiple(config_tmpdir): + # Verify that local settings take precedence over global ones, + # but that both values are still available, and that setting + # either doesn't affect system settings. + def write_config(config_file, section, key1, value1, key2, value2): + config_file.parent.mkdir(exist_ok=True) + + content = textwrap.dedent(f''' + [{section}] + {key1} = {value1} + {key2} = {value2} + ''') + + with open(config_file, 'w') as conf: + conf.write(content) + + # helper function to assert multiple config values + def run_and_assert(expected_values: dict[str, dict[str, str]]): + for scope, meta in expected_values.items(): + for flags, expected in meta.items(): + stdout = cmd(f'config --{scope} {flags}').rstrip() + if type(expected) is list: + stdout = stdout.splitlines() + assert stdout == expected, f"{scope} {flags}: {expected} =! {stdout}" + + # config file paths + config_dir = pathlib.Path(config_tmpdir) / 'configs' + config_s1 = config_dir / 'system 1' + config_s2 = config_dir / 'system 2' + config_g1 = config_dir / 'global 1' + config_g2 = config_dir / 'global 2' + config_l1 = config_dir / 'local 1' + config_l2 = config_dir / 'local 2' + + # create some configs with + # - some individual option per config file (s1/s2/g1/g2/l1/l2)) + # - the same option (s/g/l) defined in multiple configs + write_config(config_s1, 'sec', 's', '1 !"$&/()=?', 's1', '1 !"$&/()=?') + write_config(config_s2, 'sec', 's', '2', 's2', '2') + write_config(config_g1, 'sec', 'g', '1', 'g1', '1') + write_config(config_g2, 'sec', 'g', '2', 'g2', '2') + write_config(config_l1, 'sec', 'l', '1', 'l1', '1') + write_config(config_l2, 'sec', 'l', '2', 'l2', '2') + + # config file without read permission (does not work on Windows) + if not WINDOWS: + config_non_readable = config_dir / 'non-readable' + config_non_readable.touch() + config_non_readable.chmod(0o000) + with update_env({'WEST_CONFIG_GLOBAL': f'{config_g1}{os.pathsep}{config_non_readable}'}): + _, stderr = cmd_raises('config --global some.section', MalformedConfig) + expected = f"Error while reading one of '{[config_g1, config_non_readable]}'" + assert expected in stderr + + # specify multiple configs for each config level (separated by os.pathsep) + os.environ["WEST_CONFIG_GLOBAL"] = f'{config_g1}{os.pathsep}{config_g2}' + os.environ["WEST_CONFIG_SYSTEM"] = f'{config_s1}{os.pathsep}{config_s2}' + os.environ["WEST_CONFIG_LOCAL"] = f'{config_l1}{os.pathsep}{config_l2}' + + # check options from individual files and that options from latter configs override + expected = { + 'system': {'sec.s1': '1 !"$&/()=?', 'sec.s2': '2', 'sec.s': '2'}, + 'global': {'sec.g1': '1', 'sec.g2': '2', 'sec.g': '2'}, + 'local': {'sec.l1': '1', 'sec.l2': '2', 'sec.l': '2'}, + } + run_and_assert(expected) + + # check that list-paths gives correct output + expected = { + 'global': {'--list-paths': [str(config_g1), str(config_g2)]}, + 'system': {'--list-paths': [str(config_s1), str(config_s2)]}, + 'local': {'--list-paths': [str(config_l1), str(config_l2)]}, + } + run_and_assert(expected) + + # writing not possible if multiple configs are used + _, stderr = cmd_raises('config --local sec.l3 3', ValueError) + assert f'Cannot set value if multiple configs in use: {[config_l1, config_l2]}' in stderr + + +@pytest.mark.parametrize("location", [LOCAL, GLOBAL, SYSTEM]) +def test_config_multiple_write(location): + # write to a config with a single config file must work, even if other + # locations have multiple configs in use + flag = west_flag[location] + env_var = west_env[location] + + configs_dir = pathlib.Path("configs") + config1 = (configs_dir / 'config 1').resolve() + config2 = (configs_dir / 'config 2').resolve() + config3 = (configs_dir / 'config 3').resolve() + + env = {west_env[location]: f'{config1}'} + other_locations = [c for c in [LOCAL, GLOBAL, SYSTEM] if c != location] + for loc in other_locations: + env[west_env[loc]] = f'{config2}{os.pathsep}{config3}' + + with update_env(env): + cmd(f'config {flag} key.value {env_var}') + stdout = cmd(f'config {flag} key.value') + assert [env_var] == stdout.rstrip().splitlines() + + +@pytest.mark.parametrize("location", [LOCAL, GLOBAL, SYSTEM]) +def test_config_multiple_relative(location): + # specify multiple configs for each config level (separated by os.pathsep). + # The paths may be relative relative paths, which are always anchored to + # west topdir. For the test, the cwd is changed to another cwd to ensure + # that relative paths are anchored correctly. + flag = west_flag[location] + env_var = west_env[location] + + msg = "'{file}' is relative but 'west topdir' is not defined" + + # create some configs + configs_dir = pathlib.Path('config') + configs_dir.mkdir() + config1 = (configs_dir / 'config 1').resolve() + config2 = (configs_dir / 'config 2').resolve() + config1.touch() + config2.touch() + + west_topdir = pathlib.Path.cwd() + cwd = west_topdir / 'any' / 'other cwd' + cwd.mkdir(parents=True) + with chdir(cwd): + config2_rel = config2.relative_to(west_topdir) + command = f'config {flag} --list-paths' + env_value = f'{config1}{os.pathsep}{config2_rel}' + with update_env({env_var: env_value}): + # cannot anchor relative path if no west topdir exists + exc, _ = cmd_raises(command, WestNotFound) + assert msg.format(file=config2_rel) in str(exc.value) + + # relative paths are anchored to west topdir + with tmp_west_topdir(west_topdir): + stdout = cmd(command) + assert [str(config1), str(config2)] == stdout.rstrip().splitlines() + + # if a wrong separator is used, no config file must be found + wrong_sep = ':' if WINDOWS else ';' + env_value = f'{config1}{wrong_sep}{config2_rel}' + with update_env({env_var: env_value}): + # no path is listed + stdout = cmd(command) + assert not stdout + + def test_config_missing_key(): _, err_msg = cmd_raises('config pytest', SystemExit) assert 'invalid configuration option "pytest"; expected "section.key" format' in err_msg