Skip to content

Commit e33851f

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 e33851f

File tree

3 files changed

+215
-30
lines changed

3 files changed

+215
-30
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 & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -68,20 +68,38 @@ 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)
8399

84-
return section in self.cp and key in self.cp[section]
100+
if section in self.cp and key in self.cp[section]:
101+
return True
102+
return False
85103

86104
def get(self, option: str):
87105
return self._get(option, self.cp.get)
@@ -95,37 +113,31 @@ def getint(self, option: str):
95113
def getfloat(self, option: str):
96114
return self._get(option, self.cp.getfloat)
97115

98-
def _get(self, option, getter):
116+
def _get(self, option, config_getter):
99117
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
118+
if section in self.cp and key in self.cp[section]:
119+
getter = config_getter
120+
else:
121+
raise KeyError(option)
122+
return getter(section, key)
105123

106124
def set(self, option: str, value: Any):
107125
section, key = _InternalCF.parse_key(option)
108-
109126
if section not in self.cp:
110127
self.cp[section] = {}
111-
112128
self.cp[section][key] = value
113-
114-
with open(self.path, 'w', encoding='utf-8') as f:
115-
self.cp.write(f)
129+
self._write()
116130

117131
def delete(self, option: str):
118132
section, key = _InternalCF.parse_key(option)
119-
120-
if section not in self.cp:
133+
if option not in self:
121134
raise KeyError(option)
122135

123136
del self.cp[section][key]
124137
if not self.cp[section].items():
125138
del self.cp[section]
126139

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

130142

131143
class ConfigFile(Enum):
@@ -181,6 +193,16 @@ def __init__(self, topdir: PathType | None = None):
181193
self._global = _InternalCF.from_path(self._global_path)
182194
self._local = _InternalCF.from_path(self._local_path)
183195

196+
def get_paths(self, configfile: ConfigFile = ConfigFile.ALL):
197+
ret = []
198+
if self._global and configfile in [ConfigFile.GLOBAL, ConfigFile.ALL]:
199+
ret += self._global.paths
200+
if self._system and configfile in [ConfigFile.SYSTEM, ConfigFile.ALL]:
201+
ret += self._system.paths
202+
if self._local and configfile in [ConfigFile.LOCAL, ConfigFile.ALL]:
203+
ret += self._local.paths
204+
return ret
205+
184206
def get(
185207
self, option: str, default: str | None = None, configfile: ConfigFile = ConfigFile.ALL
186208
) -> str | None:

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)