Skip to content

Commit c6064fd

Browse files
support multiple west config files per config level
multiple config files can be specified in the according environment variable for each config level (separated by 'os.pathsep', which is ';' on Windows or ':' otherwise). The config files are applied in the same order as they are specified, whereby values from later files override earlier ones.
1 parent 50cbc34 commit c6064fd

File tree

4 files changed

+134
-30
lines changed

4 files changed

+134
-30
lines changed

src/west/app/config.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,11 @@
4949
providing one of the following arguments:
5050
--local | --system | --global
5151
52+
For each configuration level (local, global, and system) also multiple config
53+
file locations can be specified. To do so, set according environment variable
54+
to contain all paths (separated by 'os.pathsep', which is ';' on Windows or
55+
':' otherwise): Latter configuration files have precedence in such lists.
56+
5257
The following command prints a list of all configuration files currently
5358
considered and existing (listed in the order as they are loaded):
5459
west config --list-paths

src/west/configuration.py

Lines changed: 39 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@
4444
from pathlib import Path, PureWindowsPath
4545
from typing import TYPE_CHECKING, Any
4646

47-
from west.util import WEST_DIR, PathType, WestNotFound, west_dir
47+
from west.util import WEST_DIR, PathType, WestNotFound, paths_to_str, west_dir
4848

4949

5050
class MalformedConfig(Exception):
@@ -70,19 +70,36 @@ def parse_key(dotted_name: str):
7070
return section_child
7171

7272
@staticmethod
73-
def from_path(path: Path | None) -> '_InternalCF | None':
74-
return _InternalCF(path) if path and path.exists() else None
75-
76-
def __init__(self, path: Path):
77-
self.path = path
73+
def from_path(path: Path | list[Path] | None) -> '_InternalCF | None':
74+
if not path:
75+
return None
76+
77+
# Normalize to a list of existing paths
78+
paths = path
79+
if isinstance(paths, Path):
80+
paths = [Path(p) for p in str(paths).split(os.pathsep)]
81+
paths = [p for p in paths if p.exists()]
82+
return _InternalCF(paths) if paths else None
83+
84+
def __init__(self, paths: list[Path]):
7885
self.cp = _configparser()
79-
read_files = self.cp.read(path, encoding='utf-8')
80-
if len(read_files) != 1:
81-
raise FileNotFoundError(path)
86+
self.paths = paths
87+
read_files = self.cp.read(self.paths, encoding='utf-8')
88+
if len(read_files) != len(self.paths):
89+
raise WestNotFound(f"Error while reading one of '{paths_to_str(paths)}'")
90+
91+
def _write(self):
92+
if not self.paths:
93+
raise WestNotFound('No config file exists that can be written')
94+
if len(self.paths) > 1:
95+
raise WestNotFound(
96+
f'Cannot write if multiple configs in use: {paths_to_str(self.paths)}'
97+
)
98+
with open(self.paths[0], 'w', encoding='utf-8') as f:
99+
self.cp.write(f)
82100

83101
def __contains__(self, option: str) -> bool:
84102
section, key = _InternalCF.parse_key(option)
85-
86103
return section in self.cp and key in self.cp[section]
87104

88105
def get(self, option: str):
@@ -97,37 +114,31 @@ def getint(self, option: str):
97114
def getfloat(self, option: str):
98115
return self._get(option, self.cp.getfloat)
99116

100-
def _get(self, option, getter):
117+
def _get(self, option, config_getter):
101118
section, key = _InternalCF.parse_key(option)
102-
103-
try:
104-
return getter(section, key)
105-
except (configparser.NoOptionError, configparser.NoSectionError) as err:
106-
raise KeyError(option) from err
119+
if section in self.cp and key in self.cp[section]:
120+
getter = config_getter
121+
else:
122+
raise KeyError(option)
123+
return getter(section, key)
107124

108125
def set(self, option: str, value: Any):
109126
section, key = _InternalCF.parse_key(option)
110-
111127
if section not in self.cp:
112128
self.cp[section] = {}
113-
114129
self.cp[section][key] = value
115-
116-
with open(self.path, 'w', encoding='utf-8') as f:
117-
self.cp.write(f)
130+
self._write()
118131

119132
def delete(self, option: str):
120133
section, key = _InternalCF.parse_key(option)
121-
122-
if section not in self.cp:
134+
if option not in self:
123135
raise KeyError(option)
124136

125137
del self.cp[section][key]
126138
if not self.cp[section].items():
127139
del self.cp[section]
128140

129-
with open(self.path, 'w', encoding='utf-8') as f:
130-
self.cp.write(f)
141+
self._write()
131142

132143

133144
class ConfigFile(Enum):
@@ -186,11 +197,11 @@ def __init__(self, topdir: PathType | None = None):
186197
def get_paths(self, location: ConfigFile = ConfigFile.ALL):
187198
ret = []
188199
if self._global and location in [ConfigFile.GLOBAL, ConfigFile.ALL]:
189-
ret.append(self._global.path)
200+
ret.extend(self._global.paths)
190201
if self._system and location in [ConfigFile.SYSTEM, ConfigFile.ALL]:
191-
ret.append(self._system.path)
202+
ret.extend(self._system.paths)
192203
if self._local and location in [ConfigFile.LOCAL, ConfigFile.ALL]:
193-
ret.append(self._local.path)
204+
ret.extend(self._local.paths)
194205
return ret
195206

196207
def get(
@@ -282,7 +293,6 @@ def set(self, option: str, value: Any, configfile: ConfigFile = ConfigFile.LOCAL
282293
:param value: value to set option to
283294
:param configfile: type of config file to set the value in
284295
'''
285-
286296
if configfile == ConfigFile.ALL:
287297
# We need a real configuration file; ALL doesn't make sense here.
288298
raise ValueError(configfile)

src/west/util.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,10 @@ class WestNotFound(RuntimeError):
5353
'''Neither the current directory nor any parent has a west workspace.'''
5454

5555

56+
def paths_to_str(paths: list[pathlib.Path], sep: str = os.pathsep) -> str:
57+
return sep.join([str(p) for p in paths])
58+
59+
5660
def west_dir(start: PathType | None = None) -> str:
5761
'''Returns the absolute path of the workspace's .west directory.
5862

tests/test_config.py

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from typing import Any
1111

1212
import pytest
13-
from conftest import cmd, cmd_raises, update_env
13+
from conftest import WINDOWS, cmd, cmd_raises, update_env
1414

1515
from west import configuration as config
1616
from west.util import PathType
@@ -590,6 +590,91 @@ def test_config_precedence():
590590
assert cfg(f=LOCAL)['pytest']['precedence'] == 'local'
591591

592592

593+
def test_config_multiple(config_tmpdir):
594+
# Verify that local settings take precedence over global ones,
595+
# but that both values are still available, and that setting
596+
# either doesn't affect system settings.
597+
def write_config(config_file, section, key1, value1, key2, value2):
598+
config_file.parent.mkdir(exist_ok=True)
599+
600+
content = textwrap.dedent(f'''
601+
[{section}]
602+
{key1} = {value1}
603+
{key2} = {value2}
604+
''')
605+
606+
with open(config_file, 'w') as conf:
607+
conf.write(content)
608+
609+
# config file paths
610+
config_dir = pathlib.Path(config_tmpdir) / 'configs'
611+
config_s1 = config_dir / 'system 1'
612+
config_s2 = config_dir / 'system 2'
613+
config_g1 = config_dir / 'global 1'
614+
config_g2 = config_dir / 'global 2'
615+
config_l1 = config_dir / 'local 1'
616+
config_l2 = config_dir / 'local 2'
617+
618+
# create some configs with
619+
# - some individual option per config file
620+
# - the same option defined in multiple configs
621+
write_config(config_s1, 'sec', 's', '1 !"$&/()=?\\', 's1', '1 !"$&/()=?\\')
622+
write_config(config_s2, 'sec', 's', '2', 's2', '2')
623+
write_config(config_g1, 'sec', 'g', '1', 'g1', '1')
624+
write_config(config_g2, 'sec', 'g', '2', 'g2', '2')
625+
write_config(config_l1, 'sec', 'l', '1', 'l1', '1')
626+
write_config(config_l2, 'sec', 'l', '2', 'l2', '2')
627+
628+
# use a non-readable config file (does not work on Windows)
629+
if not WINDOWS:
630+
config_non_readable = config_dir / 'non-readable'
631+
config_non_readable.touch()
632+
config_non_readable.chmod(0o000)
633+
os.environ["WEST_CONFIG_GLOBAL"] = f'{config_g1}{os.pathsep}{config_non_readable}'
634+
stderr = cmd_raises('config --global some.section', subprocess.CalledProcessError)
635+
expected = f"Error while reading one of '{config_g1}{os.pathsep}{config_non_readable}'"
636+
assert expected in stderr
637+
638+
# specify multiple configs for each config level (separated by os.pathsep)
639+
os.environ["WEST_CONFIG_GLOBAL"] = f'{config_g1}{os.pathsep}{config_g2}'
640+
os.environ["WEST_CONFIG_SYSTEM"] = f'{config_s1}{os.pathsep}{config_s2}'
641+
os.environ["WEST_CONFIG_LOCAL"] = f'{config_l1}{os.pathsep}{config_l2}'
642+
643+
# check that all individual options are applied
644+
stdout = cmd('config --system sec.s1').rstrip()
645+
assert stdout == '1 !"$&/()=?\\'
646+
stdout = cmd('config --system sec.s2').rstrip()
647+
assert stdout == '2'
648+
stdout = cmd('config --global sec.g1').rstrip()
649+
assert stdout == '1'
650+
stdout = cmd('config --global sec.g2').rstrip()
651+
assert stdout == '2'
652+
stdout = cmd('config --local sec.l1').rstrip()
653+
assert stdout == '1'
654+
stdout = cmd('config --local sec.l2').rstrip()
655+
assert stdout == '2'
656+
657+
# check that options from latest config overrides
658+
stdout = cmd('config --system sec.s').rstrip()
659+
assert stdout == '2'
660+
stdout = cmd('config --global sec.g').rstrip()
661+
assert stdout == '2'
662+
stdout = cmd('config --local sec.l').rstrip()
663+
assert stdout == '2'
664+
665+
# check that list-paths gives correct output
666+
stdout = cmd('config --global --list-paths')
667+
assert [str(config_g1), str(config_g2)] == stdout.rstrip().splitlines()
668+
stdout = cmd('config --system --list-paths')
669+
assert [str(config_s1), str(config_s2)] == stdout.rstrip().splitlines()
670+
stdout = cmd('config --local --list-paths')
671+
assert [str(config_l1), str(config_l2)] == stdout.rstrip().splitlines()
672+
673+
# writing not possible if multiple configs are used
674+
err_msg = cmd_raises('config --local sec.l3 3', subprocess.CalledProcessError)
675+
assert f'Cannot write if multiple configs in use: {config_l1}{os.pathsep}{config_l2}' in err_msg
676+
677+
593678
def test_config_missing_key():
594679
err_msg = cmd_raises('config pytest', subprocess.CalledProcessError)
595680
assert 'invalid configuration option "pytest"; expected "section.key" format' in err_msg

0 commit comments

Comments
 (0)