Skip to content

Commit 3c9c122

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 a3af732 commit 3c9c122

File tree

3 files changed

+215
-31
lines changed

3 files changed

+215
-31
lines changed

src/west/app/config.py

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import argparse
88

99
from west.commands import CommandError, WestCommand
10-
from west.configuration import ConfigFile
10+
from west.configuration import ConfigFile, Configuration
1111

1212
CONFIG_DESCRIPTION = '''\
1313
West configuration file handling.
@@ -35,13 +35,32 @@
3535
3636
- Linux, macOS, Windows: <workspace-root-directory>/.west/config
3737
38-
You can override these files' locations with the WEST_CONFIG_SYSTEM,
39-
WEST_CONFIG_GLOBAL, and WEST_CONFIG_LOCAL environment variables.
40-
4138
Configuration values from later configuration files override configuration
4239
from earlier ones. Local values have highest precedence, and system values
4340
lowest.
4441
42+
You can override the config file location for the according config level
43+
with the environment variables:
44+
- WEST_CONFIG_SYSTEM,
45+
- WEST_CONFIG_GLOBAL
46+
- WEST_CONFIG_LOCAL
47+
48+
Note: west will NOT fail if a specified config file does not exist, but it
49+
will ignore this file.
50+
51+
For each configuration level (local, global, and system) also multiple config
52+
file locations can be specified. To do so, set according environment variable
53+
to contain all paths (separated by 'os.pathsep', which is ';' on Windows or
54+
':' otherwise)
55+
56+
You can list all existing configuration files that are currently considered
57+
(existing files) via the following command, which prints them in the exact
58+
order as they are loaded (so later values override earlier ones):
59+
west config --list-paths
60+
west config --local --list-paths
61+
west config --global --list-paths
62+
west config --system --list-paths
63+
4564
To get a value for <name>, type:
4665
west config <name>
4766
@@ -99,6 +118,11 @@ def do_add_parser(self, parser_adder):
99118
"action to perform (give at most one)"
100119
).add_mutually_exclusive_group()
101120

121+
group.add_argument(
122+
'--list-paths',
123+
action='store_true',
124+
help='list all config files that are currently considered by west config',
125+
)
102126
group.add_argument(
103127
'-l', '--list', action='store_true', help='list all options and their values'
104128
)
@@ -153,13 +177,15 @@ def do_run(self, args, user_args):
153177
if args.list:
154178
if args.name:
155179
self.parser.error('-l cannot be combined with name argument')
156-
elif not args.name:
180+
elif not args.name and not args.list_paths:
157181
self.parser.error('missing argument name (to list all options and values, use -l)')
158182
elif args.append:
159183
if args.value is None:
160184
self.parser.error('-a requires both name and value')
161185

162-
if args.list:
186+
if args.list_paths:
187+
self.list_paths(args)
188+
elif args.list:
163189
self.list(args)
164190
elif delete:
165191
self.delete(args)
@@ -170,6 +196,11 @@ def do_run(self, args, user_args):
170196
else:
171197
self.write(args)
172198

199+
def list_paths(self, args):
200+
config_paths = Configuration().get_paths(args.configfile or ALL)
201+
for config_path in config_paths:
202+
print(config_path)
203+
173204
def list(self, args):
174205
what = args.configfile or ALL
175206
for option, value in self.config.items(configfile=what):

src/west/configuration.py

Lines changed: 46 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -68,19 +68,34 @@ def parse_key(dotted_name: str):
6868
return section_child
6969

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

8197
def __contains__(self, option: str) -> bool:
8298
section, key = _InternalCF.parse_key(option)
83-
8499
return section in self.cp and key in self.cp[section]
85100

86101
def get(self, option: str):
@@ -95,37 +110,31 @@ def getint(self, option: str):
95110
def getfloat(self, option: str):
96111
return self._get(option, self.cp.getfloat)
97112

98-
def _get(self, option, getter):
113+
def _get(self, option, config_getter):
99114
section, key = _InternalCF.parse_key(option)
100-
101-
try:
102-
return getter(section, key)
103-
except (configparser.NoOptionError, configparser.NoSectionError) as err:
104-
raise KeyError(option) from err
115+
if section in self.cp and key in self.cp[section]:
116+
getter = config_getter
117+
else:
118+
raise KeyError(option)
119+
return getter(section, key)
105120

106121
def set(self, option: str, value: Any):
107122
section, key = _InternalCF.parse_key(option)
108-
109123
if section not in self.cp:
110124
self.cp[section] = {}
111-
112125
self.cp[section][key] = value
113-
114-
with open(self.path, 'w', encoding='utf-8') as f:
115-
self.cp.write(f)
126+
self._write()
116127

117128
def delete(self, option: str):
118129
section, key = _InternalCF.parse_key(option)
119-
120-
if section not in self.cp:
130+
if option not in self:
121131
raise KeyError(option)
122132

123133
del self.cp[section][key]
124134
if not self.cp[section].items():
125135
del self.cp[section]
126136

127-
with open(self.path, 'w', encoding='utf-8') as f:
128-
self.cp.write(f)
137+
self._write()
129138

130139

131140
class ConfigFile(Enum):
@@ -181,6 +190,16 @@ def __init__(self, topdir: PathType | None = None):
181190
self._global = _InternalCF.from_path(self._global_path)
182191
self._local = _InternalCF.from_path(self._local_path)
183192

193+
def get_paths(self, configfile: ConfigFile = ConfigFile.ALL):
194+
ret = []
195+
if self._global and configfile in [ConfigFile.GLOBAL, ConfigFile.ALL]:
196+
ret += self._global.paths
197+
if self._system and configfile in [ConfigFile.SYSTEM, ConfigFile.ALL]:
198+
ret += self._system.paths
199+
if self._local and configfile in [ConfigFile.LOCAL, ConfigFile.ALL]:
200+
ret += self._local.paths
201+
return ret
202+
184203
def get(
185204
self, option: str, default: str | None = None, configfile: ConfigFile = ConfigFile.ALL
186205
) -> str | None:
@@ -269,7 +288,9 @@ def set(self, option: str, value: Any, configfile: ConfigFile = ConfigFile.LOCAL
269288
:param value: value to set option to
270289
:param configfile: type of config file to set the value in
271290
'''
272-
291+
paths = self.get_paths(configfile)
292+
if len(paths) > 1:
293+
raise WestNotFound('Cannot write if multiple configs in use.')
273294
if configfile == ConfigFile.ALL:
274295
# We need a real configuration file; ALL doesn't make sense here.
275296
raise ValueError(configfile)

tests/test_config.py

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import os
77
import pathlib
88
import subprocess
9+
import textwrap
910
from typing import Any
1011

1112
import pytest
@@ -93,6 +94,62 @@ def test_config_global():
9394
assert 'pytest' not in lcl
9495

9596

97+
TEST_CASES_CONFIG_LIST_PATHS = [
98+
# (flag, env_var)
99+
('--local', 'WEST_CONFIG_LOCAL'),
100+
('--system', 'WEST_CONFIG_SYSTEM'),
101+
('--global', 'WEST_CONFIG_GLOBAL'),
102+
]
103+
104+
105+
@pytest.mark.parametrize("test_case", TEST_CASES_CONFIG_LIST_PATHS)
106+
def test_config_list_paths(test_case):
107+
flag, env_var = test_case
108+
109+
# create the config
110+
cmd(f'config {flag} pytest.key val')
111+
112+
# check that the config is listed now
113+
stdout = cmd(f'config {flag} --list-paths')
114+
config_path = pathlib.Path(os.environ[env_var])
115+
assert f'{config_path}' == stdout.rstrip()
116+
117+
# no config is listed (since it does not exist)
118+
config_path.unlink()
119+
stdout = cmd(f'config {flag} --list-paths')
120+
assert '' == stdout.rstrip()
121+
122+
123+
def test_config_list_paths_extended():
124+
WEST_CONFIG_LOCAL = os.environ['WEST_CONFIG_LOCAL']
125+
WEST_CONFIG_GLOBAL = os.environ['WEST_CONFIG_GLOBAL']
126+
WEST_CONFIG_SYSTEM = os.environ['WEST_CONFIG_SYSTEM']
127+
128+
# create the configs
129+
cmd('config --local pytest.key val')
130+
cmd('config --global pytest.key val')
131+
cmd('config --system pytest.key val')
132+
133+
# list the configs
134+
stdout = cmd('config --list-paths')
135+
assert (
136+
stdout.splitlines()
137+
== textwrap.dedent(f'''\
138+
{WEST_CONFIG_GLOBAL}
139+
{WEST_CONFIG_SYSTEM}
140+
{WEST_CONFIG_LOCAL}
141+
''').splitlines()
142+
)
143+
144+
# do not list any configs if no config files exist
145+
# (Note: even no local config exists, same as outside any west workspace)
146+
pathlib.Path(WEST_CONFIG_GLOBAL).unlink()
147+
pathlib.Path(WEST_CONFIG_SYSTEM).unlink()
148+
pathlib.Path(WEST_CONFIG_LOCAL).unlink()
149+
stdout = cmd('config --list-paths')
150+
assert stdout.splitlines() == []
151+
152+
96153
def test_config_local():
97154
# test_config_system for local variables.
98155
cmd('config --local pytest.local foo')
@@ -519,6 +576,81 @@ def test_config_precedence():
519576
assert cfg(f=LOCAL)['pytest']['precedence'] == 'local'
520577

521578

579+
def test_config_multiple(config_tmpdir):
580+
# Verify that local settings take precedence over global ones,
581+
# but that both values are still available, and that setting
582+
# either doesn't affect system settings.
583+
def write_config(config_file, section, key1, value1, key2, value2):
584+
config_file.parent.mkdir(exist_ok=True)
585+
586+
content = textwrap.dedent(f'''
587+
[{section}]
588+
{key1} = {value1}
589+
{key2} = {value2}
590+
''')
591+
592+
with open(config_file, 'w') as conf:
593+
conf.write(content)
594+
595+
# config file paths
596+
config_dir = pathlib.Path(config_tmpdir) / 'configs'
597+
config_s1 = config_dir / 'system1'
598+
config_s2 = config_dir / 'system2'
599+
config_g1 = config_dir / 'global1'
600+
config_g2 = config_dir / 'global2'
601+
config_l1 = config_dir / 'local1'
602+
config_l2 = config_dir / 'local2'
603+
604+
# create some configs with
605+
# - some individual option per config file
606+
# - the same option defined in multiple configs
607+
write_config(config_s1, 'sec', 's', 1, 's1', 1)
608+
write_config(config_s2, 'sec', 's', 2, 's2', 2)
609+
write_config(config_g1, 'sec', 'g', 1, 'g1', 1)
610+
write_config(config_g2, 'sec', 'g', 2, 'g2', 2)
611+
write_config(config_l1, 'sec', 'l', 1, 'l1', 1)
612+
write_config(config_l2, 'sec', 'l', 2, 'l2', 2)
613+
614+
# specify multiple configs for each config level (separated by os.pathsep)
615+
os.environ["WEST_CONFIG_GLOBAL"] = f'{config_g1}{os.pathsep}{config_g2}'
616+
os.environ["WEST_CONFIG_SYSTEM"] = f'{config_s1}{os.pathsep}{config_s2}'
617+
os.environ["WEST_CONFIG_LOCAL"] = f'{config_l1}{os.pathsep}{config_l2}'
618+
619+
# check that all individual options are applied
620+
stdout = cmd('config --system sec.s1').rstrip()
621+
assert stdout == '1'
622+
stdout = cmd('config --system sec.s2').rstrip()
623+
assert stdout == '2'
624+
stdout = cmd('config --global sec.g1').rstrip()
625+
assert stdout == '1'
626+
stdout = cmd('config --global sec.g2').rstrip()
627+
assert stdout == '2'
628+
stdout = cmd('config --local sec.l1').rstrip()
629+
assert stdout == '1'
630+
stdout = cmd('config --local sec.l2').rstrip()
631+
assert stdout == '2'
632+
633+
# check that options from latest config overrides
634+
stdout = cmd('config --system sec.s').rstrip()
635+
assert stdout == '2'
636+
stdout = cmd('config --global sec.g').rstrip()
637+
assert stdout == '2'
638+
stdout = cmd('config --local sec.l').rstrip()
639+
assert stdout == '2'
640+
641+
# check that list-paths gives correct output
642+
stdout = cmd('config --global --list-paths')
643+
assert [str(config_g1), str(config_g2)] == stdout.rstrip().splitlines()
644+
stdout = cmd('config --system --list-paths')
645+
assert [str(config_s1), str(config_s2)] == stdout.rstrip().splitlines()
646+
stdout = cmd('config --local --list-paths')
647+
assert [str(config_l1), str(config_l2)] == stdout.rstrip().splitlines()
648+
649+
# writing not possible if multiple configs are used
650+
err_msg = cmd_raises('config --local sec.l3 3', subprocess.CalledProcessError)
651+
assert 'Cannot write if multiple configs in use' in err_msg
652+
653+
522654
def test_config_missing_key():
523655
err_msg = cmd_raises('config pytest', subprocess.CalledProcessError)
524656
assert 'invalid configuration option "pytest"; expected "section.key" format' in err_msg

0 commit comments

Comments
 (0)