Skip to content

Commit 8178ece

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 a4ce10a commit 8178ece

File tree

4 files changed

+200
-62
lines changed

4 files changed

+200
-62
lines changed

src/west/app/config.py

Lines changed: 6 additions & 1 deletion
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
@@ -189,7 +194,7 @@ def do_run(self, args, user_args):
189194
self.write(args)
190195

191196
def list_paths(self, args):
192-
config_paths = self.config.get_paths(args.configfile or ALL)
197+
config_paths = self.config.get_paths(args.configfile or ALL, existing_only=True)
193198
for config_path in config_paths:
194199
self.inf(config_path)
195200

src/west/configuration.py

Lines changed: 96 additions & 59 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, str_to_paths, west_dir
4848

4949

5050
class MalformedConfig(Exception):
@@ -70,19 +70,30 @@ 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_paths(paths: list[Path] | None) -> '_InternalCF | None':
74+
paths = paths or []
75+
if not paths:
76+
return None
77+
paths = [p for p in paths if p.exists()]
78+
return _InternalCF(paths) if paths else None
79+
80+
def __init__(self, paths: list[Path]):
7881
self.cp = _configparser()
79-
read_files = self.cp.read(path, encoding='utf-8')
80-
if len(read_files) != 1:
81-
raise FileNotFoundError(path)
82+
self.paths = paths
83+
read_files = self.cp.read(self.paths, encoding='utf-8')
84+
if len(read_files) != len(self.paths):
85+
raise WestNotFound(f"Error while reading one of '{paths_to_str(paths)}'")
86+
87+
def _write(self):
88+
if not self.paths:
89+
raise WestNotFound('No config file exists that can be written')
90+
if len(self.paths) > 1:
91+
raise ValueError(f'Cannot write if multiple configs in use: {paths_to_str(self.paths)}')
92+
with open(self.paths[0], 'w', encoding='utf-8') as f:
93+
self.cp.write(f)
8294

8395
def __contains__(self, option: str) -> bool:
8496
section, key = _InternalCF.parse_key(option)
85-
8697
return section in self.cp and key in self.cp[section]
8798

8899
def get(self, option: str):
@@ -99,35 +110,28 @@ def getfloat(self, option: str):
99110

100111
def _get(self, option, getter):
101112
section, key = _InternalCF.parse_key(option)
102-
103113
try:
104114
return getter(section, key)
105115
except (configparser.NoOptionError, configparser.NoSectionError) as err:
106116
raise KeyError(option) from err
107117

108118
def set(self, option: str, value: Any):
109119
section, key = _InternalCF.parse_key(option)
110-
111120
if section not in self.cp:
112121
self.cp[section] = {}
113-
114122
self.cp[section][key] = value
115-
116-
with open(self.path, 'w', encoding='utf-8') as f:
117-
self.cp.write(f)
123+
self._write()
118124

119125
def delete(self, option: str):
120126
section, key = _InternalCF.parse_key(option)
121-
122-
if section not in self.cp:
127+
if option not in self:
123128
raise KeyError(option)
124129

125130
del self.cp[section][key]
126131
if not self.cp[section].items():
127132
del self.cp[section]
128133

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

132136

133137
class ConfigFile(Enum):
@@ -173,24 +177,26 @@ def __init__(self, topdir: PathType | None = None):
173177
:param topdir: workspace location; may be None
174178
'''
175179

176-
local_path = _location(ConfigFile.LOCAL, topdir=topdir, find_local=False) or None
180+
local_paths = _location(ConfigFile.LOCAL, topdir=topdir, find_local=False) or None
177181

178-
self._system_path = Path(_location(ConfigFile.SYSTEM))
179-
self._global_path = Path(_location(ConfigFile.GLOBAL))
180-
self._local_path = Path(local_path) if local_path is not None else None
182+
self._system_paths = str_to_paths(_location(ConfigFile.SYSTEM))
183+
self._global_paths = str_to_paths(_location(ConfigFile.GLOBAL))
184+
self._local_paths = str_to_paths(local_paths)
181185

182-
self._system = _InternalCF.from_path(self._system_path)
183-
self._global = _InternalCF.from_path(self._global_path)
184-
self._local = _InternalCF.from_path(self._local_path)
186+
self._system = _InternalCF.from_paths(self._system_paths)
187+
self._global = _InternalCF.from_paths(self._global_paths)
188+
self._local = _InternalCF.from_paths(self._local_paths)
185189

186-
def get_paths(self, location: ConfigFile = ConfigFile.ALL):
190+
def get_paths(self, location: ConfigFile = ConfigFile.ALL, existing_only=False) -> list[Path]:
187191
ret = []
188-
if self._global and location in [ConfigFile.GLOBAL, ConfigFile.ALL]:
189-
ret.append(self._global.path)
190-
if self._system and location in [ConfigFile.SYSTEM, ConfigFile.ALL]:
191-
ret.append(self._system.path)
192-
if self._local and location in [ConfigFile.LOCAL, ConfigFile.ALL]:
193-
ret.append(self._local.path)
192+
if location in [ConfigFile.GLOBAL, ConfigFile.ALL]:
193+
ret.extend(self._global_paths)
194+
if location in [ConfigFile.SYSTEM, ConfigFile.ALL]:
195+
ret.extend(self._system_paths)
196+
if location in [ConfigFile.LOCAL, ConfigFile.ALL]:
197+
ret.extend(self._local_paths)
198+
if existing_only:
199+
ret = [p for p in ret if p.exists()]
194200
return ret
195201

196202
def get(
@@ -283,28 +289,45 @@ def set(self, option: str, value: Any, configfile: ConfigFile = ConfigFile.LOCAL
283289
:param configfile: type of config file to set the value in
284290
'''
285291

292+
def check_configfile(location: ConfigFile) -> Path:
293+
'''
294+
check that exactly one configfile is in use (even if it not exists yet).
295+
Return its path.
296+
'''
297+
configs = self.get_paths(location)
298+
if not configs:
299+
raise WestNotFound(f'{configfile}: Cannot determine any config file')
300+
if len(configs) > 1:
301+
raise ValueError(
302+
f'Cannot set value if multiple configs in use: {paths_to_str(configs)}'
303+
)
304+
return configs[0]
305+
286306
if configfile == ConfigFile.ALL:
287307
# We need a real configuration file; ALL doesn't make sense here.
288308
raise ValueError(configfile)
289309
elif configfile == ConfigFile.LOCAL:
290-
if self._local_path is None:
310+
if not self._local_paths:
291311
raise ValueError(
292312
f'{configfile}: file not found; retry in a workspace or set WEST_CONFIG_LOCAL'
293313
)
294-
if not self._local_path.exists():
295-
self._local = self._create(self._local_path)
314+
config_file = check_configfile(configfile)
315+
if not config_file.exists():
316+
self._local = self._create(config_file)
296317
if TYPE_CHECKING:
297318
assert self._local
298319
self._local.set(option, value)
299320
elif configfile == ConfigFile.GLOBAL:
300-
if not self._global_path.exists():
301-
self._global = self._create(self._global_path)
321+
config_file = check_configfile(configfile)
322+
if not config_file.exists():
323+
self._global = self._create(config_file)
302324
if TYPE_CHECKING:
303325
assert self._global
304326
self._global.set(option, value)
305327
elif configfile == ConfigFile.SYSTEM:
306-
if not self._system_path.exists():
307-
self._system = self._create(self._system_path)
328+
config_file = check_configfile(configfile)
329+
if not config_file.exists():
330+
self._system = self._create(config_file)
308331
if TYPE_CHECKING:
309332
assert self._system
310333
self._system.set(option, value)
@@ -316,7 +339,7 @@ def set(self, option: str, value: Any, configfile: ConfigFile = ConfigFile.LOCAL
316339
def _create(path: Path) -> _InternalCF:
317340
path.parent.mkdir(parents=True, exist_ok=True)
318341
path.touch(exist_ok=True)
319-
ret = _InternalCF.from_path(path)
342+
ret = _InternalCF.from_paths([path])
320343
if TYPE_CHECKING:
321344
assert ret
322345
return ret
@@ -554,18 +577,27 @@ def delete_config(
554577
_deprecated('delete_config')
555578

556579
stop = False
580+
considered_locations = []
557581
if configfile is None:
558-
to_check = [_location(x, topdir=topdir) for x in [ConfigFile.LOCAL, ConfigFile.GLOBAL]]
582+
considered_locations = [ConfigFile.LOCAL, ConfigFile.GLOBAL]
559583
stop = True
560584
elif configfile == ConfigFile.ALL:
561-
to_check = [
562-
_location(x, topdir=topdir)
563-
for x in [ConfigFile.SYSTEM, ConfigFile.GLOBAL, ConfigFile.LOCAL]
564-
]
585+
considered_locations = [ConfigFile.SYSTEM, ConfigFile.GLOBAL, ConfigFile.LOCAL]
565586
elif isinstance(configfile, ConfigFile):
566-
to_check = [_location(configfile, topdir=topdir)]
587+
considered_locations = [configfile]
567588
else:
568-
to_check = [_location(x, topdir=topdir) for x in configfile]
589+
considered_locations = configfile
590+
591+
# ensure that only one config file is in use
592+
config_files = [str_to_paths(_location(cfg, topdir=topdir)) for cfg in considered_locations]
593+
for config_file_list in config_files:
594+
if len(config_file_list) > 1:
595+
raise ValueError(
596+
f'Error: Multiple config paths in use: {paths_to_str(config_file_list)}'
597+
)
598+
599+
# determine config file paths
600+
to_check = [c for config_file_list in config_files for c in config_file_list]
569601

570602
found = False
571603
for path in to_check:
@@ -659,32 +691,37 @@ def _location(cfg: ConfigFile, topdir: PathType | None = None, find_local: bool
659691
raise ValueError(f'invalid configuration file {cfg}')
660692

661693

662-
def _gather_configs(cfg: ConfigFile, topdir: PathType | None) -> list[str]:
694+
def _gather_configs(cfg: ConfigFile, topdir: PathType | None) -> list[Path]:
663695
# Find the paths to the given configuration files, in increasing
664696
# precedence order.
665-
ret = []
697+
ret: list[Path] = []
666698

667699
if cfg == ConfigFile.ALL or cfg == ConfigFile.SYSTEM:
668-
ret.append(_location(ConfigFile.SYSTEM, topdir=topdir))
700+
paths = str_to_paths(_location(ConfigFile.SYSTEM, topdir=topdir))
701+
ret.extend(paths)
669702
if cfg == ConfigFile.ALL or cfg == ConfigFile.GLOBAL:
670-
ret.append(_location(ConfigFile.GLOBAL, topdir=topdir))
703+
paths = str_to_paths(_location(ConfigFile.GLOBAL, topdir=topdir))
704+
ret.extend(paths)
671705
if cfg == ConfigFile.ALL or cfg == ConfigFile.LOCAL:
672706
try:
673-
ret.append(_location(ConfigFile.LOCAL, topdir=topdir))
707+
paths = str_to_paths(_location(ConfigFile.LOCAL, topdir=topdir))
708+
ret.extend(paths)
674709
except WestNotFound:
675710
pass
676-
677711
return ret
678712

679713

680714
def _ensure_config(configfile: ConfigFile, topdir: PathType | None) -> str:
681715
# Ensure the given configfile exists, returning its path. May
682716
# raise permissions errors, WestNotFound, etc.
683-
loc = _location(configfile, topdir=topdir)
684-
path = Path(loc)
685-
717+
config_file = str_to_paths(_location(configfile, topdir=topdir))
718+
if not config_file:
719+
raise ValueError(f"no config found for configfile '{configfile}'")
720+
if len(config_file) > 1:
721+
raise ValueError(f'Cannot write if multiple configs in use: {paths_to_str(config_file)}')
722+
path: Path = config_file[0]
686723
if path.is_file():
687-
return loc
724+
return os.fspath(path)
688725

689726
path.parent.mkdir(parents=True, exist_ok=True)
690727
path.touch(exist_ok=True)

src/west/util.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,15 @@ 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+
60+
def str_to_paths(paths: str | None, sep: str = os.pathsep) -> list[pathlib.Path]:
61+
paths = paths or ""
62+
return [pathlib.Path(p) for p in paths.split(sep) if p]
63+
64+
5665
def west_dir(start: PathType | None = None) -> str:
5766
'''Returns the absolute path of the workspace's .west directory.
5867

0 commit comments

Comments
 (0)