Coverage for src / thunderlab / configfile.py: 92%

194 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-10 21:08 +0000

1""" 

2class `ConfigFile`: handling of configuration parameter. 

3""" 

4 

5import sys 

6 

7from pathlib import Path 

8 

9 

10class ConfigFile(dict): 

11 """Handling of configuration parameter. 

12 

13 Configuration parameter have a name (key), a value, a unit, a 

14 description and a default value. ConfigFile is a dictionary with 

15 the parameter names as keys and the tuple (value, unit, 

16 description, default) as value. 

17  

18 New parameter can be added with the add() function. 

19 

20 Configuration parameter can be further structured in the 

21 configuration file by inserting section titles via add_section(). 

22 

23 The tuple (value, unit, description, default) can be retrieved via 

24 the name of a parameter using the [] operator. 

25 

26 The value of a configuration parameter is retrieved by value() 

27 and set by set(). 

28 

29 Values of several configuration parameter can be mapped to 

30 new names with the map() function. The resulting dictionary can be 

31 passed as key-word arguments to a function. 

32 

33 The configuration parameter can be written to a configuration file 

34 with write() and loaded from a file with load() and load_files(). 

35  

36 Methods 

37 ------- 

38 

39 - `add()`: add a new parameter to the configuration. 

40 - `add_section()`: add a new section to the configuration. 

41 - `value()`: returns the value of the configuration parameter defined by key. 

42 - `set(): set the value of the configuration parameter defined by key. 

43 - `set_values(): set values of configuration parameters from list of str. 

44 - `map()`: map the values of the configuration onto new names. 

45 - `write()`: pretty print configuration into file or stream. 

46 - `load()`: set values of configuration to values from key-value pairs read in from file. 

47 - `load_files()`: load configuration from current working directory as well as from several levels of a file path. 

48 

49 """ 

50 

51 def __init__(self, orig=None): 

52 super().__init__(dict()) 

53 self.sections = dict() 

54 self.new_section = None 

55 if not orig is None: 

56 for k, v in orig.items(): 

57 self[k] = list(v) 

58 for k, v in orig.sections.items(): 

59 self.sections[k] = v 

60 self.new_section = None 

61 

62 def add(self, key, value, unit, description): 

63 """Add a new parameter to the configuration. 

64 

65 The description of the parameter is a single string. Newline 

66 characters are intepreted as new paragraphs. 

67 

68 Parameters 

69 ---------- 

70 key: str 

71 Key (name) of the parameter. 

72 value: any type 

73 Value of the parameter. 

74 unit: str 

75 Unit of the parameter value. 

76 description: str 

77 Textual description of the parameter. 

78 """ 

79 # add a pending section: 

80 if self.new_section is not None: 

81 self.sections[key] = self.new_section 

82 self.new_section = None 

83 # add configuration parameter (4th element is default value): 

84 self[key] = [value, unit, description, value] 

85 

86 def add_section(self, description): 

87 """Add a new section to the configuration. 

88 

89 Parameters 

90 ---------- 

91 description: str 

92 Textual description of the section 

93 """ 

94 self.new_section = description 

95 

96 def value(self, key): 

97 """Returns the value of the configuration parameter defined by key. 

98 

99 Parameters 

100 ---------- 

101 key: str 

102 Key of the configuration parameter. 

103 

104 Returns 

105 ------- 

106 value: any type 

107 Value of the configuraion parameter. 

108 """ 

109 return self[key][0] 

110 

111 def set(self, key, value): 

112 """Set the value of the configuration parameter defined by key. 

113 

114 Parameters 

115 ---------- 

116 key: str 

117 Key of the configuration parameter. 

118 value: any type 

119 The new value. 

120 

121 Raises 

122 ------ 

123 IndexError: 

124 If key does not exist. 

125 """ 

126 if not key in self: 

127 raise IndexError(f'Key {key} does not exist') 

128 self[key][0] = value 

129 

130 def set_values(self, values): 

131 """Set values of configuration parameters from list of str. 

132  

133 Parameters 

134 ---------- 

135 values: list of str 

136 Each string can be one or several key-value pairs 

137 separated by commas. 

138 A key-value pair is separated by colons. 

139 

140 Returns 

141 ------- 

142 errors: list of str 

143 Error messages. 

144 

145 """ 

146 errors = [] 

147 for kvs in values: 

148 for kv in kvs.strip().split(','): 

149 if ':' in kv: 

150 ss = kv.split(':') 

151 key = ss[0].strip() 

152 val = ':'.join(ss[1:]).strip() 

153 if key in self: 

154 vt = type(self[key][0]) 

155 if vt is bool: 

156 if val.lower() in ['true', 'on', 'yes', 't', 'y']: 

157 self[key][0] = True 

158 elif val.lower() in ['false', 'off', 'no', 'f', 'n']: 

159 self[key][0] = False 

160 else: 

161 errors.append(f'configuration parameter "{key}": cannot convert "{val}" to bool') 

162 else: 

163 try: 

164 self[key][0] = vt(val) 

165 except ValueError: 

166 errors.append(f'configuration parameter "{key}": cannot convert "{val}" to {vt}') 

167 else: 

168 errors.append(f'configuration parameter "{key}:{val}" not found in configuration parameters') 

169 return errors if len(errors) > 0 else None 

170 

171 def __delitem__(self, key): 

172 """Remove an entry from the configuration. 

173 

174 Parameters 

175 ---------- 

176 key: str 

177 Key of the configuration parameter to be removed. 

178 """ 

179 if key in self.sections: 

180 sec = self.sections.pop(key) 

181 keys = list(self.keys()) 

182 inx = keys.index(key)+1 

183 if inx < len(keys): 

184 next_key = keys[inx] 

185 if not next_key in self.sections: 

186 self.sections[next_key] = sec 

187 super().__delitem__(key) 

188 

189 def map(self, *args, **kwargs): 

190 """Map the values of the configuration onto new names. 

191 

192 Use this function to generate key-word arguments 

193 that can be passed on to functions. 

194 

195 Parameters 

196 ---------- 

197 mapping: dict 

198 If provided as the first argument, then 

199 dictionary with keys being the new names 

200 and corresponding values being the parameter names 

201 of the configuration. 

202 kwargs: dict 

203 Further key-word arguments with keys being the new names 

204 and corresponding values being the parameter names 

205 of the configuration 

206 

207 Returns 

208 ------- 

209 a: dict 

210 A dictionary with the keys of mapping 

211 and the corresponding values retrieved from the configuration 

212 using the values from mapping. 

213 """ 

214 mapping = {} 

215 for a in args: 

216 mapping.update(**a) 

217 mapping.update(**kwargs) 

218 a = {} 

219 for dest, src in mapping.items(): 

220 if src in self: 

221 a[dest] = self.value(src) 

222 return a 

223 

224 def write(self, fh=sys.stdout, header=None, 

225 diff_only=False, maxline=60, comments=True): 

226 """Pretty print configuration into file or stream. 

227 

228 The description of a configuration parameter is printed out 

229 right before its key-value pair with an initial comment 

230 character ('#').  

231 

232 Section titles get two comment characters prependend ('##'). 

233 

234 Lines are folded if the character count of parameter 

235 descriptions or section title exceeds maxline. 

236  

237 A header can be printed initially. This is a simple string that is 

238 formatted like the section titles. 

239 

240 Parameters 

241 ---------- 

242 fh: str or Path or file object 

243 Stream or file name for writing the configuration. 

244 header: str 

245 A string that is written as an introductory comment into the file. 

246 diff_only: bool 

247 If true write out only those parameters whose value differs from their default. 

248 maxline: int 

249 Maximum number of characters that fit into a line. 

250 comments: boolean 

251 Print out descriptions as comments if True. 

252 """ 

253 

254 def write_comment(fh, comment, maxline, cs): 

255 # format comment: 

256 if len(comment) > 0: 

257 for line in comment.split('\n'): 

258 fh.write(cs + ' ') 

259 cc = len(cs) + 1 # character count 

260 for w in line.strip().split(' '): 

261 # line too long? 

262 if cc + len(w) > maxline: 

263 fh.write('\n' + cs + ' ') 

264 cc = len(cs) + 1 

265 fh.write(w + ' ') 

266 cc += len(w) + 1 

267 fh.write('\n') 

268 

269 # open file: 

270 own_file = False 

271 if not hasattr(fh, 'write'): 

272 fh = open(fh, 'w') 

273 own_file = True 

274 # write header: 

275 First = True 

276 if comments and not header is None: 

277 write_comment(fh, header, maxline, '##') 

278 First = False 

279 # get length of longest key: 

280 maxkey = 0 

281 for key in self.keys(): 

282 if maxkey < len(key): 

283 maxkey = len(key) 

284 # write out parameter: 

285 section = '' 

286 for key, v in self.items(): 

287 # possible section entry: 

288 if comments and key in self.sections: 

289 section = self.sections[key] 

290 # get value, unit, and comment from v: 

291 val = None 

292 unit = '' 

293 comment = '' 

294 differs = False 

295 if hasattr(v, '__len__') and (not isinstance(v, str)): 

296 val = v[0] 

297 if len(v) > 1 and len(v[1]) > 0: 

298 unit = ' ' + v[1] 

299 if len(v) > 2: 

300 comment = v[2] 

301 if len(v) > 3: 

302 differs = (val != v[3]) 

303 else: 

304 val = v 

305 # only write parameter whose value differs: 

306 if diff_only and not differs: 

307 continue 

308 # write out section 

309 if len(section) > 0: 

310 if not First: 

311 fh.write('\n\n') 

312 write_comment(fh, section, maxline, '##') 

313 section = '' 

314 First = False 

315 # write key-value pair: 

316 if comments : 

317 fh.write('\n') 

318 write_comment(fh, comment, maxline, '#') 

319 fh.write('{key:<{width}s}: {val}{unit:s}\n'.format( 

320 key=key, width=maxkey, val=val, unit=unit)) 

321 First = False 

322 # close file: 

323 if own_file: 

324 fh.close() 

325 

326 def load(self, filename): 

327 """Set values of configuration to values from key-value pairs read in from file. 

328 

329 Parameters 

330 ---------- 

331 filename: str or Path 

332 Name of the file from which to read the configuration. 

333 

334 """ 

335 with open(filename, 'r') as f: 

336 for line in f: 

337 # do not process empty lines and comments: 

338 if len(line.strip()) == 0 or line[0] == '#' or not ':' in line: 

339 continue 

340 # parse key value pair: 

341 key, val = line.split(':', 1) 

342 key = key.strip() 

343 # only read values of existing keys: 

344 if not key in self: 

345 continue 

346 cv = self[key] 

347 vals = val.strip().split(' ') 

348 if hasattr(cv, '__len__') and (not isinstance(cv, str)): 

349 unit = '' 

350 if len(vals) > 1: 

351 unit = vals[1] 

352 if unit != cv[1]: 

353 print(f'unit for {key} is {unit} but should be {cv[1]}') 

354 if type(cv[0]) == bool: 

355 cv[0] = (vals[0].lower() == 'true' 

356 or vals[0].lower() == 'yes') 

357 else: 

358 try: 

359 cv[0] = type(cv[0])(vals[0]) 

360 except ValueError: 

361 cv[0] = vals[0] 

362 else: 

363 if type(cv[0]) == bool: 

364 self[key] = (vals[0].lower() == 'true' 

365 or vals[0].lower() == 'yes') 

366 else: 

367 try: 

368 self[key] = type(cv)(vals[0]) 

369 except ValueError: 

370 self[key] = vals[0] 

371 

372 def load_files(self, cfgfile, filepath, maxlevel=3, verbose=0): 

373 """Load configuration from current working directory as well as from several levels of a file path. 

374 

375 Parameters 

376 ---------- 

377 cfgfile: str or Path 

378 Name of the configuration file (without any path). 

379 filepath: str or Path 

380 Path of a file. Configuration files are read in from different levels 

381 of the expanded path. 

382 maxlevel: int 

383 Read configuration files from up to maxlevel parent directories. 

384 verbose: int 

385 If greater than zero, print out from which files configuration has been loaded. 

386 """ 

387 # load configuration from the current directory: 

388 cfgfile = Path(cfgfile) 

389 if cfgfile.is_file(): 

390 if verbose > 0: 

391 print(f'load configuration {cfgfile}') 

392 self.load(cfgfile) 

393 

394 # load configuration files from higher directories: 

395 filepath = Path(filepath) 

396 parents = filepath.resolve().parents 

397 for k in reversed(range(min(maxlevel, len(parents)))): 

398 path = parents[k] / cfgfile 

399 if path.is_file(): 

400 if verbose > 0: 

401 print(f'load configuration {path}') 

402 self.load(path) 

403 

404 

405def main(): 

406 cfg = ConfigFile() 

407 cfg.add_section('Power spectrum:') 

408 cfg.add('nfft', 256, '', 'Number of data poinst for fourier transform.') 

409 cfg.add('windows', 4, '', 'Number of windows on which power spectra are computed.') 

410 cfg.add_section('Peaks:') 

411 cfg.add('threshold', 20.0, 'dB', 'Threshold for peak detection.') 

412 cfg.add('deltaf', 10.0, 'Hz', 'Minimum distance between peaks.') 

413 cfg.add('flip', True, '', 'Flip peaks.') 

414 cfg.write() 

415 print() 

416 print('set values:') 

417 s = cfg.set_values(['nfft: 1024, windows: 2', 'threshold: donkey', 'flip: false', 'something: blue']) 

418 if s is not None: 

419 print('errors:') 

420 for es in s: 

421 print(' ', es) 

422 cfg.write() 

423 print() 

424 print() 

425 

426 print('delete nfft and windows:') 

427 del cfg['nfft'] 

428 del cfg['windows'] 

429 cfg.write() 

430 

431 

432if __name__ == "__main__": 

433 main()