Skip to content

Commit 42bc614

Browse files
support config dropin directories
config files in acccording dropin directories are considered automatically. The dropin directory is named as the config itself but with a '.d' suffix. For example for local config the dropin directory is './west/config.d' and for global config it is '~/.westconfig.d'
1 parent cb5f4de commit 42bc614

File tree

2 files changed

+159
-37
lines changed

2 files changed

+159
-37
lines changed

src/west/configuration.py

Lines changed: 57 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -68,63 +68,81 @@ def parse_key(dotted_name: str):
6868

6969
@staticmethod
7070
def from_path(path: Path | None) -> '_InternalCF | None':
71-
return _InternalCF(path) if path and path.exists() else None
71+
return _InternalCF(path) if path else None
7272

7373
def __init__(self, path: Path):
74-
self.path = path
7574
self.cp = _configparser()
76-
read_files = self.cp.read(path, encoding='utf-8')
77-
if len(read_files) != 1:
78-
raise FileNotFoundError(path)
75+
self.dropin_cp = _configparser()
76+
self.path = path if path.exists() else None
77+
dropin_dir = Path(f'{path}.d')
78+
self.dropin_dir = dropin_dir if dropin_dir.exists() else None
79+
self.dropin_paths = []
80+
if self.dropin_dir:
81+
# dropin configs are applied in alphabetical order
82+
for conf in sorted(self.dropin_dir.iterdir()):
83+
# only consider .conf files
84+
if conf.suffix.lower() == '.conf':
85+
self.dropin_paths.append(self.dropin_dir / conf)
86+
self._read()
87+
88+
def _read(self):
89+
if self.path:
90+
self.cp.read(self.path, encoding='utf-8')
91+
if self.dropin_paths:
92+
self.dropin_cp.read(self.dropin_paths, encoding='utf-8')
93+
94+
def _write(self):
95+
if not self.path:
96+
raise WestNotFound('No config file exists that can be written')
97+
with open(self.path, 'w', encoding='utf-8') as f:
98+
self.cp.write(f)
7999

80100
def __contains__(self, option: str) -> bool:
81101
section, key = _InternalCF.parse_key(option)
82102

83-
return section in self.cp and key in self.cp[section]
103+
if section in self.cp and key in self.cp[section]:
104+
return True
105+
return section in self.dropin_cp and key in self.dropin_cp[section]
84106

85107
def get(self, option: str):
86-
return self._get(option, self.cp.get)
108+
return self._get(option, self.cp.get, self.dropin_cp.get)
87109

88110
def getboolean(self, option: str):
89-
return self._get(option, self.cp.getboolean)
111+
return self._get(option, self.cp.getboolean, self.dropin_cp.getboolean)
90112

91113
def getint(self, option: str):
92-
return self._get(option, self.cp.getint)
114+
return self._get(option, self.cp.getint, self.dropin_cp.getint)
93115

94116
def getfloat(self, option: str):
95-
return self._get(option, self.cp.getfloat)
117+
return self._get(option, self.cp.getfloat, self.dropin_cp.getfloat)
96118

97-
def _get(self, option, getter):
119+
def _get(self, option, config_getter, dropin_getter):
98120
section, key = _InternalCF.parse_key(option)
99-
100-
try:
101-
return getter(section, key)
102-
except (configparser.NoOptionError, configparser.NoSectionError) as err:
103-
raise KeyError(option) from err
121+
if section in self.cp and key in self.cp[section]:
122+
getter = config_getter
123+
elif section in self.dropin_cp and key in self.dropin_cp[section]:
124+
getter = dropin_getter
125+
else:
126+
raise KeyError(option)
127+
return getter(section, key)
104128

105129
def set(self, option: str, value: Any):
106130
section, key = _InternalCF.parse_key(option)
107-
108131
if section not in self.cp:
109132
self.cp[section] = {}
110-
111133
self.cp[section][key] = value
112-
113-
with open(self.path, 'w', encoding='utf-8') as f:
114-
self.cp.write(f)
134+
self._write()
115135

116136
def delete(self, option: str):
117137
section, key = _InternalCF.parse_key(option)
118-
119-
if section not in self.cp:
138+
if (section not in self.cp) or (key not in self.cp[section]):
120139
raise KeyError(option)
121140

122141
del self.cp[section][key]
123142
if not self.cp[section].items():
124143
del self.cp[section]
125144

126-
with open(self.path, 'w', encoding='utf-8') as f:
127-
self.cp.write(f)
145+
self._write()
128146

129147
class ConfigFile(Enum):
130148
'''Types of west configuration file.
@@ -362,13 +380,14 @@ def _copy_to_configparser(self, cp: configparser.ConfigParser) -> None:
362380
# function-and-global-state APIs.
363381

364382
def load(cf: _InternalCF):
365-
for section, contents in cf.cp.items():
366-
if section == 'DEFAULT':
367-
continue
368-
if section not in cp:
369-
cp.add_section(section)
370-
for key, value in contents.items():
371-
cp[section][key] = value
383+
for cp in [cf.dropin_cp, cf.cp]:
384+
for section, contents in cp.items():
385+
if section == 'DEFAULT':
386+
continue
387+
if section not in cp:
388+
cp.add_section(section)
389+
for key, value in contents.items():
390+
cp[section][key] = value
372391

373392
if self._system:
374393
load(self._system)
@@ -415,11 +434,12 @@ def _cf_to_dict(cf: _InternalCF | None) -> dict[str, Any]:
415434
ret: dict[str, Any] = {}
416435
if cf is None:
417436
return ret
418-
for section, contents in cf.cp.items():
419-
if section == 'DEFAULT':
420-
continue
421-
for key, value in contents.items():
422-
ret[f'{section}.{key}'] = value
437+
for cp in [cf.dropin_cp, cf.cp]:
438+
for section, contents in cp.items():
439+
if section == 'DEFAULT':
440+
continue
441+
for key, value in contents.items():
442+
ret[f'{section}.{key}'] = value
423443
return ret
424444

425445

tests/test_config.py

Lines changed: 102 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
@@ -208,6 +209,107 @@ def test_local_creation():
208209
assert 'pytest' not in cfg(f=GLOBAL)
209210
assert cfg(f=LOCAL)['pytest']['key'] == 'val'
210211

212+
TEST_CASES_CONFIG_D = [
213+
# (flag, env_var)
214+
('', 'WEST_CONFIG_LOCAL'),
215+
('--local', 'WEST_CONFIG_LOCAL'),
216+
('--system', 'WEST_CONFIG_SYSTEM'),
217+
('--global', 'WEST_CONFIG_GLOBAL'),
218+
]
219+
220+
@pytest.mark.parametrize("test_case", TEST_CASES_CONFIG_D)
221+
def test_config_d_local(test_case):
222+
flag, env_var = test_case
223+
config_path = pathlib.Path(os.environ[env_var])
224+
config_d_dir = pathlib.Path(f'{config_path}.d')
225+
config_d_dir.mkdir()
226+
227+
# write value in actual config file
228+
cmd(f'config {flag} pytest.key val')
229+
cmd(f'config {flag} pytest.config-only val')
230+
231+
# read config value via command line
232+
stdout = cmd(f'config {flag} pytest.key')
233+
assert 'val' == stdout.rstrip()
234+
stdout = cmd(f'config {flag} pytest.config-only')
235+
assert 'val' == stdout.rstrip()
236+
237+
# read the config file
238+
with open(config_path) as config_file:
239+
config_file_initial = config_file.read()
240+
assert config_file_initial == textwrap.dedent('''\
241+
[pytest]
242+
key = val
243+
config-only = val
244+
245+
''')
246+
247+
# create a dropin config under .d
248+
with open(config_d_dir / 'a.conf', 'w') as conf:
249+
conf.write(textwrap.dedent('''
250+
[pytest]
251+
key = from dropin a
252+
dropin-only = from dropin a
253+
dropin-only-a = from dropin a
254+
'''))
255+
256+
# create a dropin config under .d
257+
with open(config_d_dir / 'z.conf', 'w') as conf:
258+
conf.write(textwrap.dedent('''
259+
[pytest]
260+
dropin-only = from dropin z
261+
dropin-only-z = from dropin z
262+
'''))
263+
264+
# value from config is prefered over dropin config
265+
stdout = cmd(f'config {flag} pytest.key')
266+
assert 'val' == stdout.rstrip()
267+
stdout = cmd(f'config {flag} pytest.dropin-only-a')
268+
assert 'from dropin a' == stdout.rstrip()
269+
stdout = cmd(f'config {flag} pytest.dropin-only-z')
270+
assert 'from dropin z' == stdout.rstrip()
271+
# alphabetical order (z.conf is overwriting a.conf)
272+
stdout = cmd(f'config {flag} pytest.dropin-only')
273+
assert 'from dropin z' == stdout.rstrip()
274+
275+
# deletion of a value that is only set in a dropin config should fail
276+
cmd_raises(f'config {flag} -d pytest.dropin-only', subprocess.CalledProcessError)
277+
278+
# deletion of a value that is set in config
279+
stdout = cmd(f'config {flag} -d pytest.config-only')
280+
assert '' == stdout
281+
with open(config_path) as config_file:
282+
config_file_edited = config_file.read()
283+
assert config_file_edited == textwrap.dedent('''\
284+
[pytest]
285+
key = val
286+
287+
''')
288+
289+
# remove config file
290+
config_path.unlink()
291+
292+
# values from config are unset now
293+
stderr = cmd_raises(f'config {flag} pytest.config-only', subprocess.CalledProcessError)
294+
assert 'ERROR: pytest.config-only is unset' == stderr.rstrip()
295+
296+
# dropin applies now, since config does not exist
297+
stdout = cmd(f'config {flag} pytest.key')
298+
assert 'from dropin a' == stdout.rstrip()
299+
# alphabetical order (z.conf is overwriting a.conf)
300+
stdout = cmd(f'config {flag} pytest.dropin-only')
301+
assert 'from dropin z' == stdout.rstrip()
302+
# other values remain same
303+
stdout = cmd(f'config {flag} pytest.dropin-only-a')
304+
assert 'from dropin a' == stdout.rstrip()
305+
# values specified in only one dropin config remain same
306+
stdout = cmd(f'config {flag} pytest.dropin-only-z')
307+
assert 'from dropin z' == stdout.rstrip()
308+
309+
# deletion of a value that is not existing anymore should fail
310+
cmd_raises(f'config {flag} -d pytest.config-only', subprocess.CalledProcessError)
311+
312+
211313
def test_local_creation_with_topdir():
212314
# Like test_local_creation, with a specified topdir.
213315

0 commit comments

Comments
 (0)