Coverage for pyTooling/Configuration/YAML.py: 95%

185 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-31 07:24 +0000

1# ==================================================================================================================== # 

2# _____ _ _ ____ __ _ _ _ # 

3# _ __ _ |_ _|__ ___ | (_)_ __ __ _ / ___|___ _ __ / _(_) __ _ _ _ _ __ __ _| |_(_) ___ _ __ # 

4# | '_ \| | | || |/ _ \ / _ \| | | '_ \ / _` || | / _ \| '_ \| |_| |/ _` | | | | '__/ _` | __| |/ _ \| '_ \ # 

5# | |_) | |_| || | (_) | (_) | | | | | | (_| || |__| (_) | | | | _| | (_| | |_| | | | (_| | |_| | (_) | | | | # 

6# | .__/ \__, ||_|\___/ \___/|_|_|_| |_|\__, (_)____\___/|_| |_|_| |_|\__, |\__,_|_| \__,_|\__|_|\___/|_| |_| # 

7# |_| |___/ |___/ |___/ # 

8# ==================================================================================================================== # 

9# Authors: # 

10# Patrick Lehmann # 

11# # 

12# License: # 

13# ==================================================================================================================== # 

14# Copyright 2021-2026 Patrick Lehmann - Bötzingen, Germany # 

15# # 

16# Licensed under the Apache License, Version 2.0 (the "License"); # 

17# you may not use this file except in compliance with the License. # 

18# You may obtain a copy of the License at # 

19# # 

20# http://www.apache.org/licenses/LICENSE-2.0 # 

21# # 

22# Unless required by applicable law or agreed to in writing, software # 

23# distributed under the License is distributed on an "AS IS" BASIS, # 

24# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # 

25# See the License for the specific language governing permissions and # 

26# limitations under the License. # 

27# # 

28# SPDX-License-Identifier: Apache-2.0 # 

29# ==================================================================================================================== # 

30# 

31""" 

32Configuration reader for YAML files. 

33 

34.. hint:: 

35 

36 See :ref:`high-level help <CONFIG/FileFormat/YAML>` for explanations and usage examples. 

37""" 

38from pathlib import Path 

39from typing import Any, Dict, List, Union, Iterator as typing_Iterator, Self 

40 

41try: 

42 from ruamel.yaml import YAML, CommentedMap, CommentedSeq 

43except ImportError as ex: # pragma: no cover 

44 raise Exception("Optional dependency 'ruamel.yaml' not installed. Either install pyTooling with extra dependencies 'pyTooling[yaml]' or install 'ruamel.yaml' directly.") from ex 

45 

46from pyTooling.Common import getFullyQualifiedName 

47from pyTooling.Decorators import export 

48from pyTooling.MetaClasses import ExtendedType 

49from pyTooling.Configuration import ConfigurationException, KeyT, NodeT, ValueT 

50from pyTooling.Configuration import InterpolationException, KeyNotFoundException, PathExpressionException 

51from pyTooling.Configuration import UnsupportedValueTypeException 

52from pyTooling.Configuration import Node as Abstract_Node 

53from pyTooling.Configuration import Dictionary as Abstract_Dict 

54from pyTooling.Configuration import Sequence as Abstract_Seq 

55from pyTooling.Configuration import Configuration as Abstract_Configuration 

56 

57 

58@export 

59class Node(Abstract_Node): 

60 """ 

61 Node in a YAML configuration data structure. 

62 """ 

63 

64 _yamlNode: Union[CommentedMap, CommentedSeq] #: Reference to the associated YAML node. 

65 _cache: Dict[str, ValueT] 

66 _key: KeyT #: Key of this node. 

67 _length: int #: Number of sub-elements. 

68 

69 def __init__( 

70 self, 

71 root: "Configuration", 

72 parent: NodeT, 

73 key: KeyT, 

74 yamlNode: Union[CommentedMap, CommentedSeq] 

75 ) -> None: 

76 """ 

77 Initializes a YAML node. 

78 

79 :param root: Reference to the root node. 

80 :param parent: Reference to the parent node. 

81 :param key: Key of the node within its parent. 

82 :param yamlNode: Reference to the YAML node. 

83 """ 

84 Abstract_Node.__init__(self, root, parent) 

85 

86 self._yamlNode = yamlNode 

87 self._cache = {} 

88 self._key = key 

89 self._length = len(yamlNode) 

90 

91 def __len__(self) -> int: 

92 """ 

93 Returns the number of sub-elements. 

94 

95 :returns: Number of sub-elements. 

96 """ 

97 return self._length 

98 

99 def __getitem__(self, key: KeyT) -> ValueT: 

100 """ 

101 Access an element in the node by index or key. 

102 

103 :param key: Index or key of the element. 

104 :returns: A node (sequence or dictionary) or scalar value (int, float, str). 

105 """ 

106 return self._GetNodeOrValue(str(key)) 

107 

108 @property 

109 def Key(self) -> KeyT: 

110 """ 

111 Property to access the node's key. 

112 

113 :returns: Key of the node. 

114 """ 

115 return self._key 

116 

117 @Key.setter 

118 def Key(self, value: KeyT) -> None: 

119 raise NotImplementedError() 

120 

121 def QueryPath(self, query: str) -> ValueT: 

122 """ 

123 Return a node or value based on a path description to that node or value. 

124 

125 :param query: String describing the path to the node or value. 

126 :returns: A node (sequence or dictionary) or scalar value (int, float, str). 

127 """ 

128 path = self._ToPath(query) 

129 return self._GetNodeOrValueByPathExpression(path) 

130 

131 @staticmethod 

132 def _ToPath(query: str) -> List[Union[str, int]]: 

133 return query.split(":") 

134 

135 def _LookupKey(self, key: str) -> Any: 

136 """ 

137 Look up a key in the YAML node, trying it as string, integer and float. 

138 

139 :param key: Key or index to look up. 

140 :returns: The raw value as returned by the YAML parser. 

141 :raises KeyNotFoundException: If the key exists neither as string, nor as integer or float. 

142 """ 

143 try: 

144 return self._yamlNode[key] 

145 except (KeyError, TypeError): 

146 pass 

147 

148 for conversion in (int, float): 

149 try: 

150 convertedKey = conversion(key) 

151 except ValueError: 

152 continue 

153 

154 try: 

155 return self._yamlNode[convertedKey] 

156 except (KeyError, IndexError, TypeError): 

157 pass 

158 

159 ex = KeyNotFoundException(f"Key '{key}' not found in node '{self._key}'.") 

160 ex.add_note(self._DescribeKeys()) 

161 raise ex 

162 

163 def _DescribeKeys(self) -> str: 

164 """ 

165 Describe the keys or indices offered by this node, so it can be used as an exception note. 

166 

167 :returns: A one-line description of the node's keys or index range. 

168 """ 

169 if isinstance(self._yamlNode, CommentedMap): 

170 if self._length == 0: 

171 return f"Node '{self._key}' is an empty dictionary." 

172 

173 keys = "', '".join(str(key) for key in self._yamlNode) 

174 return f"Available keys: '{keys}'." 

175 else: 

176 if self._length == 0: 

177 return f"Node '{self._key}' is an empty sequence." 

178 

179 return f"Node '{self._key}' is a sequence with indices 0..{self._length - 1}." 

180 

181 def _GetNodeOrValue(self, key: str) -> ValueT: 

182 try: 

183 value = self._cache[key] 

184 except KeyError: 

185 value = self._LookupKey(key) 

186 

187 if isinstance(value, str): 

188 value = self._ResolveVariables(value) 

189 elif isinstance(value, (int, float)): 

190 value = str(value) 

191 elif isinstance(value, CommentedMap): 

192 value = self.DICT_TYPE(self, self, key, value) 

193 elif isinstance(value, CommentedSeq): 

194 value = self.SEQ_TYPE(self, self, key, value) 

195 else: 

196 typeName = getFullyQualifiedName(value) 

197 ex = UnsupportedValueTypeException(f"Unsupported type '{typeName}' for key '{key}' in node '{self._key}'.") 

198 ex.add_note(f"The YAML parser returned a value that is neither a scalar (str, int, float), nor a map or sequence.") 

199 raise ex 

200 

201 self._cache[key] = value 

202 

203 return value 

204 

205 def _ResolveVariables(self, value: str) -> str: 

206 if value == "": 206 ↛ 207line 206 didn't jump to line 207 because the condition on line 206 was never true

207 return "" 

208 elif "$" not in value: 

209 return value 

210 

211 rawValue = value 

212 result = "" 

213 

214 while (len(rawValue) > 0): 

215# print(f"_ResolveVariables: LOOP rawValue='{rawValue}'") 

216 beginPos = rawValue.find("$") 

217 if beginPos < 0: 

218 result += rawValue 

219 rawValue = "" 

220 else: 

221 result += rawValue[:beginPos] 

222 if beginPos + 1 >= len(rawValue): 

223 ex = InterpolationException(f"Dangling '$' at the end of value '{value}'.") 

224 ex.add_note(f"Use '$$' to escape a literal dollar sign.") 

225 raise ex 

226 elif rawValue[beginPos + 1] == "$": 226 ↛ 227line 226 didn't jump to line 227 because the condition on line 226 was never true

227 result += "$" 

228 rawValue = rawValue[1:] 

229 elif rawValue[beginPos + 1] == "{": 229 ↛ 214line 229 didn't jump to line 214 because the condition on line 229 was always true

230 endPos = rawValue.find("}", beginPos) 

231 nextPos = rawValue.rfind("$", beginPos, endPos) 

232 if endPos < 0: 

233 ex = InterpolationException(f"Unclosed variable reference in value '{value}'.") 

234 ex.add_note(f"Missing closing '}}' for the '${{' at position {beginPos}.") 

235 raise ex 

236 if (nextPos > 0) and (nextPos < endPos): # an embedded $-sign 

237 path = rawValue[nextPos+2:endPos] 

238# print(f"_ResolveVariables: path='{path}'") 

239 innervalue = self._GetValueByPathExpression(self._ToPath(path)) 

240# print(f"_ResolveVariables: innervalue='{innervalue}'") 

241 rawValue = rawValue[beginPos:nextPos] + str(innervalue) + rawValue[endPos + 1:] 

242# print(f"_ResolveVariables: new rawValue='{rawValue}'") 

243 else: 

244 path = rawValue[beginPos+2:endPos] 

245 rawValue = rawValue[endPos+1:] 

246 result += str(self._GetValueByPathExpression(self._ToPath(path))) 

247 

248 return result 

249 

250 def _GetValueByPathExpression(self, path: List[KeyT]) -> ValueT: 

251 node = self 

252 for p in path: 

253 if p == "..": 

254 node = node._parent 

255 else: 

256 node = node._GetNodeOrValue(p) 

257 

258 if isinstance(node, Dictionary): 

259 pathExpression = ":".join(str(element) for element in path) 

260 ex = PathExpressionException(f"Path expression '{pathExpression}' resolves to a dictionary, not to a value.") 

261 ex.add_note(f"Element '{p}' is a dictionary. Extend the path expression to address a scalar value.") 

262 raise ex 

263 

264 return node 

265 

266 def _GetNodeOrValueByPathExpression(self, path: List[KeyT]) -> ValueT: 

267 node = self 

268 for p in path: 

269 if p == "..": 269 ↛ 270line 269 didn't jump to line 270 because the condition on line 269 was never true

270 node = node._parent 

271 else: 

272 node = node._GetNodeOrValue(p) 

273 

274 return node 

275 

276 

277@export 

278class Dictionary(Node, Abstract_Dict): 

279 """A dictionary node in a YAML data file.""" 

280 

281 _keys: List[KeyT] #: List of keys in this dictionary. 

282 

283 def __init__( 

284 self, 

285 root: "Configuration", 

286 parent: NodeT, 

287 key: KeyT, 

288 yamlNode: CommentedMap 

289 ) -> None: 

290 """ 

291 Initializes a YAML dictionary. 

292 

293 :param root: Reference to the root node. 

294 :param parent: Reference to the parent node. 

295 :param key: Key of the node within its parent. 

296 :param yamlNode: Reference to the YAML node. 

297 """ 

298 Node.__init__(self, root, parent, key, yamlNode) 

299 

300 self._keys = [str(k) for k in yamlNode.keys()] 

301 

302 def __contains__(self, key: KeyT) -> bool: 

303 """ 

304 Checks if the key is in this dictionary. 

305 

306 :param key: The key to check. 

307 :returns: ``True``, if the key is in the dictionary. 

308 """ 

309 return key in self._keys 

310 

311 def __iter__(self) -> typing_Iterator[ValueT]: 

312 """ 

313 Returns an iterator to iterate dictionary keys. 

314 

315 :returns: Dictionary key iterator. 

316 """ 

317 

318 class Iterator(metaclass=ExtendedType, slots=True): 

319 """Iterator to iterate dictionary items.""" 

320 

321 _iter: typing_Iterator[ValueT] 

322 _obj: Dictionary 

323 

324 def __init__(self, obj: Dictionary) -> None: 

325 """ 

326 Initializes an iterator for a YAML dictionary node. 

327 

328 :param obj: YAML dictionary to iterate. 

329 """ 

330 self._iter = iter(obj._keys) 

331 self._obj = obj 

332 

333 def __iter__(self) -> Self: 

334 """ 

335 Return itself to fulfil the iterator protocol. 

336 

337 :returns: Itself. 

338 """ 

339 return self # pragma: no cover 

340 

341 def __next__(self) -> ValueT: 

342 """ 

343 Returns the next item in the dictionary. 

344 

345 :returns: Next item. 

346 """ 

347 key = next(self._iter) 

348 return self._obj[key] 

349 

350 return Iterator(self) 

351 

352 

353@export 

354class Sequence(Node, Abstract_Seq): 

355 """A sequence node (ordered list) in a YAML data file.""" 

356 

357 def __init__( 

358 self, 

359 root: "Configuration", 

360 parent: NodeT, 

361 key: KeyT, 

362 yamlNode: CommentedSeq 

363 ) -> None: 

364 """ 

365 Initializes a YAML sequence (list). 

366 

367 :param root: Reference to the root node. 

368 :param parent: Reference to the parent node. 

369 :param key: Key of the node within its parent. 

370 :param yamlNode: Reference to the YAML node. 

371 """ 

372 Node.__init__(self, root, parent, key, yamlNode) 

373 

374 self._length = len(yamlNode) 

375 

376 def __iter__(self) -> typing_Iterator[ValueT]: 

377 """ 

378 Returns an iterator to iterate items in the sequence of sub-nodes. 

379 

380 :returns: Iterator to iterate items in a sequence. 

381 """ 

382 class Iterator(metaclass=ExtendedType, slots=True): 

383 """Iterator to iterate sequence items.""" 

384 

385 _i: int #: internal iterator position 

386 _obj: Sequence #: Sequence object to iterate 

387 

388 def __init__(self, obj: Sequence) -> None: 

389 """ 

390 Initializes an iterator for a YAML sequence node. 

391 

392 :param obj: YAML sequence to iterate. 

393 """ 

394 self._i = 0 

395 self._obj = obj 

396 

397 def __iter__(self) -> Self: 

398 """ 

399 Return itself to fulfil the iterator protocol. 

400 

401 :returns: Itself. 

402 """ 

403 return self # pragma: no cover 

404 

405 def __next__(self) -> ValueT: 

406 """ 

407 Returns the next item in the sequence. 

408 

409 :returns: Next item. 

410 :raises StopIteration: If end of sequence is reached. 

411 """ 

412 if self._i >= len(self._obj): 

413 raise StopIteration 

414 

415 result = self._obj[str(self._i)] 

416 self._i += 1 

417 return result 

418 

419 return Iterator(self) 

420 

421 

422setattr(Node, "DICT_TYPE", Dictionary) 

423setattr(Node, "SEQ_TYPE", Sequence) 

424 

425 

426@export 

427class Configuration(Dictionary, Abstract_Configuration): 

428 """A configuration read from a YAML file.""" 

429 

430 _yamlConfig: YAML 

431 

432 def __init__(self, configFile: Path) -> None: 

433 """ 

434 Initializes a configuration instance that reads a YAML file as input. 

435 

436 All sequence items or dictionaries key-value-pairs in the YAML file are accessible via Python's dictionary syntax. 

437 

438 :param configFile: Configuration file to read and parse. 

439 """ 

440 if not configFile.exists(): 440 ↛ 441line 440 didn't jump to line 441 because the condition on line 440 was never true

441 raise ConfigurationException(f"JSON configuration file '{configFile}' not found.") from FileNotFoundError(configFile) 

442 

443 with configFile.open("r", encoding="utf-8") as file: 

444 self._yamlConfig = YAML().load(file) 

445 

446 Dictionary.__init__(self, self, self, None, self._yamlConfig) 

447 Abstract_Configuration.__init__(self, configFile) 

448 

449 def __getitem__(self, key: str) -> ValueT: 

450 """ 

451 Access a configuration node by key. 

452 

453 :param key: The key to look for. 

454 :returns: A node (sequence or dictionary) or scalar value (int, float, str). 

455 """ 

456 return self._GetNodeOrValue(str(key)) 

457 

458 def __setitem__(self, key: str, value: ValueT) -> None: 

459 raise NotImplementedError()