Skip to content

Commit 3572a90

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 b473747 commit 3572a90

File tree

3 files changed

+172
-37
lines changed

3 files changed

+172
-37
lines changed

src/west/app/config.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,14 @@
7373
7474
To delete <name> everywhere it's set, including the system file:
7575
west config -D <name>
76+
77+
Additionally to the config file a dropin config directory is considered.
78+
The directory is named as the according config file, but with a '.d' suffix.
79+
As a result there are three levels for dropin config directories (local, global
80+
and system), whereby all '.conf' files from each dropin directory are loaded in
81+
alphabetical order.
82+
For example:
83+
.west/config.d/basics.conf
7684
'''
7785

7886
CONFIG_EPILOG = '''\

src/west/configuration.py

Lines changed: 62 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -68,63 +68,86 @@ 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+
cf = _InternalCF(path) if path else None
72+
if not cf:
73+
return None
74+
if not cf.path and not cf.dropin_paths:
75+
return None
76+
return cf
7277

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

80105
def __contains__(self, option: str) -> bool:
81106
section, key = _InternalCF.parse_key(option)
82107

83-
return section in self.cp and key in self.cp[section]
108+
if section in self.cp and key in self.cp[section]:
109+
return True
110+
return section in self.dropin_cp and key in self.dropin_cp[section]
84111

85112
def get(self, option: str):
86-
return self._get(option, self.cp.get)
113+
return self._get(option, self.cp.get, self.dropin_cp.get)
87114

88115
def getboolean(self, option: str):
89-
return self._get(option, self.cp.getboolean)
116+
return self._get(option, self.cp.getboolean, self.dropin_cp.getboolean)
90117

91118
def getint(self, option: str):
92-
return self._get(option, self.cp.getint)
119+
return self._get(option, self.cp.getint, self.dropin_cp.getint)
93120

94121
def getfloat(self, option: str):
95-
return self._get(option, self.cp.getfloat)
122+
return self._get(option, self.cp.getfloat, self.dropin_cp.getfloat)
96123

97-
def _get(self, option, getter):
124+
def _get(self, option, config_getter, dropin_getter):
98125
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
126+
if section in self.cp and key in self.cp[section]:
127+
getter = config_getter
128+
elif section in self.dropin_cp and key in self.dropin_cp[section]:
129+
getter = dropin_getter
130+
else:
131+
raise KeyError(option)
132+
return getter(section, key)
104133

105134
def set(self, option: str, value: Any):
106135
section, key = _InternalCF.parse_key(option)
107-
108136
if section not in self.cp:
109137
self.cp[section] = {}
110-
111138
self.cp[section][key] = value
112-
113-
with open(self.path, 'w', encoding='utf-8') as f:
114-
self.cp.write(f)
139+
self._write()
115140

116141
def delete(self, option: str):
117142
section, key = _InternalCF.parse_key(option)
118-
119-
if section not in self.cp:
143+
if (section not in self.cp) or (key not in self.cp[section]):
120144
raise KeyError(option)
121145

122146
del self.cp[section][key]
123147
if not self.cp[section].items():
124148
del self.cp[section]
125149

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

129152
class ConfigFile(Enum):
130153
'''Types of west configuration file.
@@ -362,13 +385,14 @@ def _copy_to_configparser(self, cp: configparser.ConfigParser) -> None:
362385
# function-and-global-state APIs.
363386

364387
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
388+
for cp in [cf.dropin_cp, cf.cp]:
389+
for section, contents in cp.items():
390+
if section == 'DEFAULT':
391+
continue
392+
if section not in cp:
393+
cp.add_section(section)
394+
for key, value in contents.items():
395+
cp[section][key] = value
372396

373397
if self._system:
374398
load(self._system)
@@ -415,11 +439,12 @@ def _cf_to_dict(cf: _InternalCF | None) -> dict[str, Any]:
415439
ret: dict[str, Any] = {}
416440
if cf is None:
417441
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
442+
for cp in [cf.dropin_cp, cf.cp]:
443+
for section, contents in cp.items():
444+
if section == 'DEFAULT':
445+
continue
446+
for key, value in contents.items():
447+
ret[f'{section}.{key}'] = value
423448
return ret
424449

425450

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)