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

164 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-02-13 22:36 +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 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.Decorators import export 

47from pyTooling.MetaClasses import ExtendedType 

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

49from pyTooling.Configuration import Node as Abstract_Node 

50from pyTooling.Configuration import Dictionary as Abstract_Dict 

51from pyTooling.Configuration import Sequence as Abstract_Seq 

52from pyTooling.Configuration import Configuration as Abstract_Configuration 

53 

54 

55@export 

56class Node(Abstract_Node): 

57 """ 

58 Node in a YAML configuration data structure. 

59 """ 

60 

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

62 _cache: Dict[str, ValueT] 

63 _key: KeyT #: Key of this node. 

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

65 

66 def __init__( 

67 self, 

68 root: "Configuration", 

69 parent: NodeT, 

70 key: KeyT, 

71 yamlNode: Union[CommentedMap, CommentedSeq] 

72 ) -> None: 

73 """ 

74 Initializes a YAML node. 

75 

76 :param root: Reference to the root node. 

77 :param parent: Reference to the parent node. 

78 :param key: 

79 :param yamlNode: Reference to the YAML node. 

80 """ 

81 Abstract_Node.__init__(self, root, parent) 

82 

83 self._yamlNode = yamlNode 

84 self._cache = {} 

85 self._key = key 

86 self._length = len(yamlNode) 

87 

88 def __len__(self) -> int: 

89 """ 

90 Returns the number of sub-elements. 

91 

92 :returns: Number of sub-elements. 

93 """ 

94 return self._length 

95 

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

97 """ 

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

99 

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

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

102 """ 

103 return self._GetNodeOrValue(str(key)) 

104 

105 @property 

106 def Key(self) -> KeyT: 

107 """ 

108 Property to access the node's key. 

109 

110 :returns: Key of the node. 

111 """ 

112 return self._key 

113 

114 @Key.setter 

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

116 raise NotImplementedError() 

117 

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

119 """ 

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

121 

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

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

124 """ 

125 path = self._ToPath(query) 

126 return self._GetNodeOrValueByPathExpression(path) 

127 

128 @staticmethod 

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

130 return query.split(":") 

131 

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

133 try: 

134 value = self._cache[key] 

135 except KeyError: 

136 try: 

137 value = self._yamlNode[key] 

138 except (KeyError, TypeError): 

139 try: 

140 value = self._yamlNode[int(key)] 

141 except KeyError: 

142 try: 

143 value = self._yamlNode[float(key)] 

144 except KeyError as ex: 

145 raise Exception(f"") from ex # XXX: needs error message 

146 

147 if isinstance(value, str): 

148 value = self._ResolveVariables(value) 

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

150 value = str(value) 

151 elif isinstance(value, CommentedMap): 

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

153 elif isinstance(value, CommentedSeq): 153 ↛ 156line 153 didn't jump to line 156 because the condition on line 153 was always true

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

155 else: 

156 raise Exception(f"") from TypeError(f"Unknown type '{value.__class__.__name__}' returned from ruamel.yaml.") # XXX: error message 

157 

158 self._cache[key] = value 

159 

160 return value 

161 

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

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

164 return "" 

165 elif "$" not in value: 

166 return value 

167 

168 rawValue = value 

169 result = "" 

170 

171 while (len(rawValue) > 0): 

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

173 beginPos = rawValue.find("$") 

174 if beginPos < 0: 

175 result += rawValue 

176 rawValue = "" 

177 else: 

178 result += rawValue[:beginPos] 

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

180 result += "$" 

181 rawValue = rawValue[1:] 

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

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

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

185 if endPos < 0: 185 ↛ 186line 185 didn't jump to line 186 because the condition on line 185 was never true

186 raise Exception(f"") # XXX: InterpolationSyntaxError(option, section, f"Bad interpolation variable reference {rest!r}") 

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

188 path = rawValue[nextPos+2:endPos] 

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

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

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

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

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

194 else: 

195 path = rawValue[beginPos+2:endPos] 

196 rawValue = rawValue[endPos+1:] 

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

198 

199 return result 

200 

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

202 node = self 

203 for p in path: 

204 if p == "..": 

205 node = node._parent 

206 else: 

207 node = node._GetNodeOrValue(p) 

208 

209 if isinstance(node, Dictionary): 209 ↛ 210line 209 didn't jump to line 210 because the condition on line 209 was never true

210 raise Exception(f"Error when resolving path expression '{':'.join(path)}' at '{p}'.") from TypeError(f"") # XXX: needs error messages 

211 

212 return node 

213 

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

215 node = self 

216 for p in path: 

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

218 node = node._parent 

219 else: 

220 node = node._GetNodeOrValue(p) 

221 

222 return node 

223 

224 

225@export 

226class Dictionary(Node, Abstract_Dict): 

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

228 

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

230 

231 def __init__( 

232 self, 

233 root: "Configuration", 

234 parent: NodeT, 

235 key: KeyT, 

236 yamlNode: CommentedMap 

237 ) -> None: 

238 """ 

239 Initializes a YAML dictionary. 

240 

241 :param root: Reference to the root node. 

242 :param parent: Reference to the parent node. 

243 :param key: 

244 :param yamlNode: Reference to the YAML node. 

245 """ 

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

247 

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

249 

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

251 """ 

252 Checks if the key is in this dictionary. 

253 

254 :param key: The key to check. 

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

256 """ 

257 return key in self._keys 

258 

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

260 """ 

261 Returns an iterator to iterate dictionary keys. 

262 

263 :returns: Dictionary key iterator. 

264 """ 

265 

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

267 """Iterator to iterate dictionary items.""" 

268 

269 _iter: typing_Iterator[ValueT] 

270 _obj: Dictionary 

271 

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

273 """ 

274 Initializes an iterator for a YAML dictionary node. 

275 

276 :param obj: YAML dictionary to iterate. 

277 """ 

278 self._iter = iter(obj._keys) 

279 self._obj = obj 

280 

281 def __iter__(self) -> Self: 

282 """ 

283 Return itself to fulfil the iterator protocol. 

284 

285 :returns: Itself. 

286 """ 

287 return self # pragma: no cover 

288 

289 def __next__(self) -> ValueT: 

290 """ 

291 Returns the next item in the dictionary. 

292 

293 :returns: Next item. 

294 """ 

295 key = next(self._iter) 

296 return self._obj[key] 

297 

298 return Iterator(self) 

299 

300 

301@export 

302class Sequence(Node, Abstract_Seq): 

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

304 

305 def __init__( 

306 self, 

307 root: "Configuration", 

308 parent: NodeT, 

309 key: KeyT, 

310 yamlNode: CommentedSeq 

311 ) -> None: 

312 """ 

313 Initializes a YAML sequence (list). 

314 

315 :param root: Reference to the root node. 

316 :param parent: Reference to the parent node. 

317 :param key: 

318 :param yamlNode: Reference to the YAML node. 

319 """ 

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

321 

322 self._length = len(yamlNode) 

323 

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

325 """ 

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

327 

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

329 """ 

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

331 """Iterator to iterate sequence items.""" 

332 

333 _i: int #: internal iterator position 

334 _obj: Sequence #: Sequence object to iterate 

335 

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

337 """ 

338 Initializes an iterator for a YAML sequence node. 

339 

340 :param obj: YAML sequence to iterate. 

341 """ 

342 self._i = 0 

343 self._obj = obj 

344 

345 def __iter__(self) -> Self: 

346 """ 

347 Return itself to fulfil the iterator protocol. 

348 

349 :returns: Itself. 

350 """ 

351 return self # pragma: no cover 

352 

353 def __next__(self) -> ValueT: 

354 """ 

355 Returns the next item in the sequence. 

356 

357 :returns: Next item. 

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

359 """ 

360 try: 

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

362 self._i += 1 

363 return result 

364 except IndexError: 

365 raise StopIteration 

366 

367 return Iterator(self) 

368 

369 

370setattr(Node, "DICT_TYPE", Dictionary) 

371setattr(Node, "SEQ_TYPE", Sequence) 

372 

373 

374@export 

375class Configuration(Dictionary, Abstract_Configuration): 

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

377 

378 _yamlConfig: YAML 

379 

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

381 """ 

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

383 

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

385 

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

387 """ 

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

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

390 

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

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

393 

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

395 Abstract_Configuration.__init__(self, configFile) 

396 

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

398 """ 

399 Access a configuration node by key. 

400 

401 :param key: The key to look for. 

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

403 """ 

404 return self._GetNodeOrValue(str(key)) 

405 

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

407 raise NotImplementedError()