Skip to content

Commit e3e5efc

Browse files
thorsten-kleinpdgendt
authored andcommitted
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. 'config --list-paths' prints all considered config files which are currently in use and exist. existing tests are adopted to work with the internal change of the return type of _location from 'str' to 'list[str]'
1 parent d1a3a77 commit e3e5efc

File tree

3 files changed

+155
-99
lines changed

3 files changed

+155
-99
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) 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_existing_paths(args.configfile or ALL)
193198
for config_path in config_paths:
194199
self.inf(config_path)
195200

src/west/configuration.py

Lines changed: 128 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -70,15 +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+
if not paths:
75+
return None
76+
paths = [p for p in paths if p.exists()]
77+
return _InternalCF(paths) if paths else None
78+
79+
def __init__(self, paths: list[Path]):
80+
# Latter elements in the list have precedence as documented by configparser.
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+
readable = [str(p) for p in paths]
86+
raise MalformedConfig(f"Error while reading one of '{readable}'")
87+
88+
def _check_single_config(self):
89+
assert self.paths
90+
if len(self.paths) > 1:
91+
raise ValueError(f'Cannot write if multiple configs in use: {self.paths}')
92+
93+
def _write(self):
94+
assert len(self.paths) == 1 # callers are expected to _check_single_config()
95+
with open(self.paths[0], 'w', encoding='utf-8') as f:
96+
self.cp.write(f)
8297

8398
def __contains__(self, option: str) -> bool:
8499
section, key = _InternalCF.parse_key(option)
@@ -106,17 +121,18 @@ def _get(self, option, getter):
106121
raise KeyError(option) from err
107122

108123
def set(self, option: str, value: Any):
124+
self._check_single_config()
109125
section, key = _InternalCF.parse_key(option)
110126

111127
if section not in self.cp:
112128
self.cp[section] = {}
113129

114130
self.cp[section][key] = value
115131

116-
with open(self.path, 'w', encoding='utf-8') as f:
117-
self.cp.write(f)
132+
self._write()
118133

119134
def delete(self, option: str):
135+
self._check_single_config()
120136
section, key = _InternalCF.parse_key(option)
121137

122138
if section not in self.cp:
@@ -126,8 +142,7 @@ def delete(self, option: str):
126142
if not self.cp[section].items():
127143
del self.cp[section]
128144

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

132147

133148
class ConfigFile(Enum):
@@ -173,26 +188,34 @@ def __init__(self, topdir: PathType | None = None):
173188
:param topdir: workspace location; may be None
174189
'''
175190

176-
local_path = _location(ConfigFile.LOCAL, topdir=topdir, find_local=False) or None
177-
178-
self._system_path = Path(_location(ConfigFile.SYSTEM, topdir=topdir))
179-
self._global_path = Path(_location(ConfigFile.GLOBAL, topdir=topdir))
180-
self._local_path = Path(local_path) if local_path is not None else None
191+
self._local_locs = [Path(p) for p in _location(ConfigFile.LOCAL, topdir, find_local=False)]
192+
self._global_locs = [Path(p) for p in _location(ConfigFile.GLOBAL, topdir)]
193+
self._system_locs = [Path(p) for p in _location(ConfigFile.SYSTEM, topdir)]
181194

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)
195+
self._system = _InternalCF.from_paths(self._system_locs)
196+
self._global = _InternalCF.from_paths(self._global_locs)
197+
self._local = _InternalCF.from_paths(self._local_locs)
185198

186-
def get_paths(self, location: ConfigFile = ConfigFile.ALL) -> list[Path]:
199+
def get_search_paths(self, location: ConfigFile = ConfigFile.ALL) -> list[Path]:
187200
ret = []
188-
if self._system and location in [ConfigFile.SYSTEM, ConfigFile.ALL]:
189-
ret.append(self._system.path)
190-
if self._global and location in [ConfigFile.GLOBAL, ConfigFile.ALL]:
191-
ret.append(self._global.path)
192-
if self._local and location in [ConfigFile.LOCAL, ConfigFile.ALL]:
193-
ret.append(self._local.path)
201+
if location in [ConfigFile.SYSTEM, ConfigFile.ALL]:
202+
ret.extend(self._system_locs)
203+
if location in [ConfigFile.GLOBAL, ConfigFile.ALL]:
204+
ret.extend(self._global_locs)
205+
if location in [ConfigFile.LOCAL, ConfigFile.ALL]:
206+
ret.extend(self._local_locs)
194207
return ret
195208

209+
def get_existing_paths(self, location: ConfigFile = ConfigFile.ALL) -> list[Path]:
210+
paths = []
211+
if location in [ConfigFile.SYSTEM, ConfigFile.ALL]:
212+
paths.extend(self._system.paths if self._system else [])
213+
if location in [ConfigFile.GLOBAL, ConfigFile.ALL]:
214+
paths.extend(self._global.paths if self._global else [])
215+
if location in [ConfigFile.LOCAL, ConfigFile.ALL]:
216+
paths.extend(self._local.paths if self._local else [])
217+
return paths
218+
196219
def get(
197220
self, option: str, default: str | None = None, configfile: ConfigFile = ConfigFile.ALL
198221
) -> str | None:
@@ -287,36 +310,40 @@ def set(self, option: str, value: Any, configfile: ConfigFile = ConfigFile.LOCAL
287310
# We need a real configuration file; ALL doesn't make sense here.
288311
raise ValueError(configfile)
289312
elif configfile == ConfigFile.LOCAL:
290-
if self._local_path is None:
313+
if not self._local_locs:
291314
raise ValueError(
292315
f'{configfile}: file not found; retry in a workspace or set WEST_CONFIG_LOCAL'
293316
)
294-
if not self._local_path.exists():
295-
self._local = self._create(self._local_path)
296-
if TYPE_CHECKING:
297-
assert self._local
298-
self._local.set(option, value)
317+
318+
# ensure that only one config is in use
319+
configs = self.get_search_paths(configfile)
320+
if len(configs) > 1:
321+
raise ValueError(f'Cannot set value if multiple configs in use: {configs}')
322+
assert len(configs) == 1, f'{configfile}: no config file in use'
323+
config_path = configs[0]
324+
325+
# get internal attribute name (_local/_global/_system)
326+
assert configfile in [ConfigFile.SYSTEM, ConfigFile.GLOBAL, ConfigFile.LOCAL]
327+
if configfile == ConfigFile.SYSTEM:
328+
attr = '_system'
299329
elif configfile == ConfigFile.GLOBAL:
300-
if not self._global_path.exists():
301-
self._global = self._create(self._global_path)
302-
if TYPE_CHECKING:
303-
assert self._global
304-
self._global.set(option, value)
305-
elif configfile == ConfigFile.SYSTEM:
306-
if not self._system_path.exists():
307-
self._system = self._create(self._system_path)
308-
if TYPE_CHECKING:
309-
assert self._system
310-
self._system.set(option, value)
311-
else:
312-
# Shouldn't happen.
313-
raise AssertionError(configfile)
330+
attr = '_global'
331+
elif configfile == ConfigFile.LOCAL:
332+
attr = '_local'
333+
334+
# create config file if it does not already exist and use it
335+
if not getattr(self, attr, None):
336+
config = self._create(config_path)
337+
setattr(self, attr, config)
338+
339+
# set config value
340+
getattr(self, attr).set(option, value)
314341

315342
@staticmethod
316343
def _create(path: Path) -> _InternalCF:
317344
path.parent.mkdir(parents=True, exist_ok=True)
318345
path.touch(exist_ok=True)
319-
ret = _InternalCF.from_path(path)
346+
ret = _InternalCF.from_paths([path])
320347
if TYPE_CHECKING:
321348
assert ret
322349
return ret
@@ -441,6 +468,13 @@ def _deprecated(old_function):
441468
)
442469

443470

471+
def _ensure_one_file_max_configs(topdir):
472+
for loc in [ConfigFile.SYSTEM, ConfigFile.GLOBAL, ConfigFile.LOCAL]:
473+
config = _location(loc, topdir=topdir, find_local=False)
474+
if len(config) > 1:
475+
raise MalformedConfig(f"{loc}: Switch to newer API to use multiple configs: '{config}'")
476+
477+
444478
def read_config(
445479
configfile: ConfigFile | None = None,
446480
config: configparser.ConfigParser = config,
@@ -471,6 +505,7 @@ def read_config(
471505
:param topdir: west workspace root to read local options from
472506
'''
473507
_deprecated('read_config')
508+
_ensure_one_file_max_configs(topdir)
474509

475510
if configfile is None:
476511
configfile = ConfigFile.ALL
@@ -503,6 +538,7 @@ def update_config(
503538
one is not found).
504539
'''
505540
_deprecated('update_config')
541+
_ensure_one_file_max_configs(topdir)
506542

507543
if configfile == ConfigFile.ALL:
508544
# Not possible to update ConfigFile.ALL, needs specific conf file here.
@@ -552,20 +588,23 @@ def delete_config(
552588
one is not found).
553589
'''
554590
_deprecated('delete_config')
591+
_ensure_one_file_max_configs(topdir)
555592

556593
stop = False
594+
considered_locations = []
557595
if configfile is None:
558-
to_check = [_location(x, topdir=topdir) for x in [ConfigFile.LOCAL, ConfigFile.GLOBAL]]
596+
considered_locations = [ConfigFile.LOCAL, ConfigFile.GLOBAL]
559597
stop = True
560598
elif configfile == ConfigFile.ALL:
561-
to_check = [
562-
_location(x, topdir=topdir)
563-
for x in [ConfigFile.SYSTEM, ConfigFile.GLOBAL, ConfigFile.LOCAL]
564-
]
599+
considered_locations = [ConfigFile.SYSTEM, ConfigFile.GLOBAL, ConfigFile.LOCAL]
565600
elif isinstance(configfile, ConfigFile):
566-
to_check = [_location(configfile, topdir=topdir)]
601+
considered_locations = [configfile]
567602
else:
568-
to_check = [_location(x, topdir=topdir) for x in configfile]
603+
considered_locations = configfile
604+
assert isinstance(considered_locations, list)
605+
606+
# thanks to _ensure_one_file_max_configs() above, we don't actually have 2 levels here.
607+
to_check = [f for cfg in considered_locations for f in _location(cfg, topdir=topdir)]
569608

570609
found = False
571610
for path in to_check:
@@ -597,7 +636,9 @@ def _rel_topdir_to_abs(p: PathType, topdir: PathType | None) -> str:
597636
return str(os.path.join(topdir, p))
598637

599638

600-
def _location(cfg: ConfigFile, topdir: PathType | None = None, find_local: bool = True) -> str:
639+
def _location(
640+
cfg: ConfigFile, topdir: PathType | None = None, find_local: bool = True
641+
) -> list[str]:
601642
# Return the WEST_CONFIG_x environment variable if defined, or the
602643
# OS-specific default value. Anchors relative paths to
603644
# "topdir". Does _not_ check whether the file exists or if it is
@@ -616,27 +657,34 @@ def _location(cfg: ConfigFile, topdir: PathType | None = None, find_local: bool
616657
# any cases which might run on Windows, then call fspath() on the
617658
# final result. This lets the standard library do the work of
618659
# producing a canonical string name.
660+
661+
def parse_paths(paths: str | None, sep: str = os.pathsep) -> list[Path]:
662+
"""Split a string into a list of Path objects using the given separator."""
663+
paths = paths or ""
664+
return [Path(p) for p in paths.split(sep) if p]
665+
619666
env = os.environ
620667

621668
if cfg == ConfigFile.ALL:
622669
raise ValueError('ConfigFile.ALL has no location')
623670
elif cfg == ConfigFile.SYSTEM:
624671
if 'WEST_CONFIG_SYSTEM' in env:
625-
return _rel_topdir_to_abs(env['WEST_CONFIG_SYSTEM'], topdir)
672+
paths = parse_paths(env['WEST_CONFIG_SYSTEM'])
673+
return [_rel_topdir_to_abs(p, topdir) for p in paths]
626674

627675
plat = platform.system()
628676

629677
if plat == 'Linux':
630-
return '/etc/westconfig'
678+
return ['/etc/westconfig']
631679

632680
if plat == 'Darwin':
633-
return '/usr/local/etc/westconfig'
681+
return ['/usr/local/etc/westconfig']
634682

635683
if plat == 'Windows':
636-
return os.path.expandvars('%PROGRAMDATA%\\west\\config')
684+
return [os.path.expandvars('%PROGRAMDATA%\\west\\config')]
637685

638686
if 'BSD' in plat:
639-
return '/etc/westconfig'
687+
return ['/etc/westconfig']
640688

641689
if 'CYGWIN' in plat or 'MSYS_NT' in plat:
642690
# Cygwin can handle windows style paths, so make sure we
@@ -647,45 +695,47 @@ def _location(cfg: ConfigFile, topdir: PathType | None = None, find_local: bool
647695
# See https://github.com/zephyrproject-rtos/west/issues/300
648696
# for details.
649697
pd = PureWindowsPath(os.environ['ProgramData'])
650-
return os.fspath(pd / 'west' / 'config')
698+
return [os.fspath(pd / 'west' / 'config')]
651699

652700
raise ValueError('unsupported platform ' + plat)
653701
elif cfg == ConfigFile.GLOBAL:
654702
if 'WEST_CONFIG_GLOBAL' in env:
655-
return _rel_topdir_to_abs(env['WEST_CONFIG_GLOBAL'], topdir)
703+
paths = parse_paths(env['WEST_CONFIG_GLOBAL'])
704+
return [_rel_topdir_to_abs(p, topdir) for p in paths]
656705

657706
if platform.system() == 'Linux' and 'XDG_CONFIG_HOME' in env:
658-
return os.path.join(env['XDG_CONFIG_HOME'], 'west', 'config')
707+
return [os.path.join(env['XDG_CONFIG_HOME'], 'west', 'config')]
659708

660-
return os.fspath(Path.home() / '.westconfig')
709+
return [os.fspath(Path.home() / '.westconfig')]
661710
elif cfg == ConfigFile.LOCAL:
662711
if 'WEST_CONFIG_LOCAL' in env:
663-
return _rel_topdir_to_abs(env['WEST_CONFIG_LOCAL'], topdir)
712+
paths = parse_paths(env['WEST_CONFIG_LOCAL'])
713+
return [_rel_topdir_to_abs(p, topdir) for p in paths]
664714

665715
if topdir:
666-
return os.fspath(Path(topdir) / WEST_DIR / 'config')
716+
return [os.fspath(Path(topdir) / WEST_DIR / 'config')]
667717

668718
if find_local:
669719
# Might raise WestNotFound!
670-
return os.fspath(Path(west_dir()) / 'config')
720+
return [os.fspath(Path(west_dir()) / 'config')]
671721
else:
672-
return ''
722+
return []
673723
else:
674724
raise ValueError(f'invalid configuration file {cfg}')
675725

676726

677727
def _gather_configs(cfg: ConfigFile, topdir: PathType | None) -> list[str]:
678728
# Find the paths to the given configuration files, in increasing
679729
# precedence order.
680-
ret = []
730+
ret: list[str] = []
681731

682732
if cfg == ConfigFile.ALL or cfg == ConfigFile.SYSTEM:
683-
ret.append(_location(ConfigFile.SYSTEM, topdir=topdir))
733+
ret.extend(_location(ConfigFile.SYSTEM, topdir=topdir))
684734
if cfg == ConfigFile.ALL or cfg == ConfigFile.GLOBAL:
685-
ret.append(_location(ConfigFile.GLOBAL, topdir=topdir))
735+
ret.extend(_location(ConfigFile.GLOBAL, topdir=topdir))
686736
if cfg == ConfigFile.ALL or cfg == ConfigFile.LOCAL:
687737
try:
688-
ret.append(_location(ConfigFile.LOCAL, topdir=topdir))
738+
ret.extend(_location(ConfigFile.LOCAL, topdir=topdir))
689739
except WestNotFound:
690740
pass
691741

@@ -695,11 +745,12 @@ def _gather_configs(cfg: ConfigFile, topdir: PathType | None) -> list[str]:
695745
def _ensure_config(configfile: ConfigFile, topdir: PathType | None) -> str:
696746
# Ensure the given configfile exists, returning its path. May
697747
# raise permissions errors, WestNotFound, etc.
698-
loc = _location(configfile, topdir=topdir)
699-
path = Path(loc)
700-
748+
configs: list[str] = _location(configfile, topdir=topdir)
749+
assert configs, 'No configfile found'
750+
assert len(configs) == 1, f'Multiple config files in use: {configs}'
751+
path = Path(configs[0])
701752
if path.is_file():
702-
return loc
753+
return os.fspath(path)
703754

704755
path.parent.mkdir(parents=True, exist_ok=True)
705756
path.touch(exist_ok=True)

0 commit comments

Comments
 (0)