Coverage for pyTooling/Configuration/__init__.py: 99%

94 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-03 23:02 +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""" 

32Abstract configuration reader. 

33 

34.. hint:: 

35 

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

37 

38.. seealso:: 

39 

40 :mod:`pyTooling.Configuration.JSON` 

41 |rarr| A configuration read from a JSON file. 

42 :mod:`pyTooling.Configuration.YAML` 

43 |rarr| A configuration read from a YAML file. 

44 :mod:`pyTooling.GenericPath` 

45 |rarr| The path expressions a configuration is queried with. 

46""" 

47from __future__ import annotations 

48 

49from pathlib import Path 

50from typing import Union, ClassVar, Generator, Iterator, Optional as Nullable, Tuple 

51 

52from pyTooling.Decorators import export, readonly 

53from pyTooling.MetaClasses import ExtendedType, abstractmethod, mixin 

54from pyTooling.Exceptions import ConfigurationError 

55 

56 

57__all__ = ["KeyT", "NodeT", "ValueT"] 

58 

59 

60KeyT = Union[str, int] #: Type variable for keys. 

61NodeT = Union["Dictionary", "Sequence"] #: Type variable for nodes. 

62ValueT = Union[NodeT, str, int, float] #: Type variable for values. 

63 

64 

65@export 

66class KeyNotFoundError(ConfigurationError, KeyError): 

67 """ 

68 The requested key or index doesn't exist in the configuration node. 

69 

70 The key was neither found as a string, nor converted to an integer or float. A note lists the keys or the index 

71 range offered by the node. 

72 

73 It is a :exc:`KeyError` as well, because a dictionary node answers the mapping protocol and the code reading it 

74 writes ``except KeyError``. Catching :exc:`~pyTooling.Exceptions.ConfigurationError` still catches it. 

75 """ 

76 

77 

78@export 

79class UnsupportedValueTypeError(ConfigurationError): 

80 """ 

81 The configuration file parser returned a value of a type that isn't supported by :mod:`pyTooling.Configuration`. 

82 

83 Supported are scalars (:class:`str`, :class:`int`, :class:`float`) and the parser's dictionary and sequence types. 

84 """ 

85 

86 

87@export 

88class InterpolationError(ConfigurationError): 

89 """A variable reference (``${...}``) in a configuration value is malformed or can't be resolved.""" 

90 

91 

92@export 

93class PathExpressionError(ConfigurationError): 

94 """A path expression (``a:b:c``) doesn't describe a valid node or value in the configuration.""" 

95 

96 

97@export 

98class Node(metaclass=ExtendedType, slots=True): 

99 """Abstract node in a configuration data structure.""" 

100 

101 DICT_TYPE: ClassVar[type[Dictionary]] #: Type reference used when instantiating new dictionaries 

102 SEQ_TYPE: ClassVar[type[Sequence]] #: Type reference used when instantiating new sequences 

103 _root: Configuration #: Reference to the root node; the root node refers to itself. 

104 _parent: Nullable[Dictionary] #: Reference to a parent node; ``None`` for the root node. 

105 

106 def __init__(self, root: Nullable[Configuration] = None, parent: Nullable[NodeT] = None) -> None: 

107 """ 

108 Initializes a node. 

109 

110 :param root: Optional, reference to the root node. 

111 :param parent: Optional, reference to the parent node, or ``None`` for the root node. 

112 """ 

113 self._root = root 

114 self._parent = parent 

115 

116 @abstractmethod 

117 def __len__(self) -> int: # type: ignore[empty-body] 

118 """ 

119 Returns the number of sub-elements. 

120 

121 :returns: Number of sub-elements. 

122 """ 

123 

124 @abstractmethod 

125 def __getitem__(self, key: KeyT) -> ValueT: # type: ignore[empty-body] 

126 """ 

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

128 

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

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

131 """ 

132 

133 def __setitem__(self, key: KeyT, value: ValueT) -> None: 

134 """ 

135 Set an element in the node by index or key. 

136 

137 .. attention:: 

138 

139 A configuration is **read-only**: the file format doesn't implements writing. 

140 

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

142 :param value: The new value of that element. 

143 :raises NotImplementedError: Always - a configuration is read-only. 

144 """ 

145 raise NotImplementedError("Currently, the configuration is read-only. Writing isn't implemented.") 

146 

147 @abstractmethod 

148 def __iter__(self) -> Iterator[ValueT]: # type: ignore[empty-body] 

149 """ 

150 Returns an iterator to iterate a node. 

151 

152 :returns: Node iterator. 

153 """ 

154 

155 @property 

156 def Key(self) -> KeyT: 

157 """ 

158 Property to access the node's key. 

159 

160 :returns: Key of the node. 

161 :raises NotImplementedError: If a deriving class doesn't implement this property. 

162 """ 

163 raise NotImplementedError(f"Property 'Key' is abstract and not implemented by '{self.__class__.__name__}'.") 

164 

165 @Key.setter 

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

167 raise NotImplementedError("Renaming a key isn't supported by a configuration.") 

168 

169 @abstractmethod 

170 def QueryPath(self, query: str) -> ValueT: # type: ignore[empty-body] 

171 """ 

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

173 

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

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

176 """ 

177 

178 

179@export 

180@mixin 

181class Dictionary(Node): 

182 """Abstract dictionary node in a configuration.""" 

183 

184 _keys: list[KeyT] #: Keys of this dictionary node, in the order the document states them. 

185 

186 def __init__(self, keys: list[KeyT]) -> None: 

187 """ 

188 Initializes the dictionary's keys. 

189 

190 A deriving class reads the keys from whatever it parsed and hands them over, so this node is never left 

191 without them. Being a mixin's constructor, it initializes only the field this mixin adds. 

192 

193 :param keys: Keys of this dictionary node, in the order the document states them. 

194 """ 

195 self._keys = keys 

196 

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

198 """ 

199 Check if a key exists in this dictionary node. 

200 

201 :param key: The key to check for. 

202 :returns: ``True``, if the key exists in this node. 

203 """ 

204 return key in self._keys 

205 

206 def IterateKeys(self) -> Generator[KeyT, None, None]: 

207 """ 

208 Iterate the keys of this dictionary node. 

209 

210 :returns: A generator of this node's keys, in the order the document states them. 

211 """ 

212 yield from self._keys 

213 

214 def IterateValues(self) -> Generator[ValueT, None, None]: 

215 """ 

216 Iterate the values of this dictionary node. 

217 

218 This is what :meth:`__iter__` yields, so ``for value in node`` and ``for value in node.IterateValues()`` are 

219 the same walk. It exists so that the three iterators can be named alike and a reader doesn't have to remember 

220 which of keys or values plain iteration gives. 

221 

222 :returns: A generator of this node's values, in the order the document states them. 

223 """ 

224 for key in self._keys: 

225 yield self[key] 

226 

227 def IterateItems(self) -> Generator[Tuple[KeyT, ValueT], None, None]: 

228 """ 

229 Iterate the key-value pairs of this dictionary node. 

230 

231 :returns: A generator of this node's ``(key, value)`` pairs, in the order the document states them. 

232 """ 

233 for key in self._keys: 

234 yield key, self[key] 

235 

236 def keys(self) -> Tuple[KeyT, ...]: 

237 """ 

238 Return this node's keys, so a dictionary node can be handed to code expecting a mapping. 

239 

240 This is :meth:`IterateKeys` materialized. The name is :class:`dict`'s, deliberately: :class:`dict` itself 

241 looks for a ``keys`` method to decide whether an object is a mapping, so ``dict(node)`` and ``{**node}`` 

242 work because this exists. 

243 

244 :returns: This node's keys, in the order the document states them. 

245 """ 

246 return tuple(self._keys) 

247 

248 def values(self) -> Tuple[ValueT, ...]: 

249 """ 

250 Return this node's values, so a dictionary node can be handed to code expecting a mapping. 

251 

252 This is :meth:`IterateValues` materialized. 

253 

254 :returns: This node's values, in the order the document states them. 

255 """ 

256 return tuple([self[key] for key in self._keys]) 

257 

258 def items(self) -> Tuple[Tuple[KeyT, ValueT], ...]: 

259 """ 

260 Return this node's key-value pairs, so a dictionary node can be handed to code expecting a mapping. 

261 

262 This is :meth:`IterateItems` materialized. 

263 

264 :returns: This node's ``(key, value)`` pairs, in the order the document states them. 

265 """ 

266 return tuple([(key, self[key]) for key in self._keys]) 

267 

268 def get(self, key: KeyT, default: Nullable[ValueT] = None) -> Nullable[ValueT]: 

269 """ 

270 Return the value a key names, or a default when this node doesn't state that key. 

271 

272 :param key: The key to read. 

273 :param default: Optional, what to return when the key isn't stated. Defaults to ``None``. 

274 :returns: The value the key names, or ``default``. 

275 """ 

276 return self[key] if key in self else default 

277 

278 

279@export 

280@mixin 

281class Sequence(Node): 

282 """Abstract sequence node in a configuration.""" 

283 

284 def __init__(self, root: Nullable[Configuration] = None, parent: Nullable[NodeT] = None) -> None: 

285 """ 

286 Initializes a sequence. 

287 

288 :param root: Optional, reference to the root node. 

289 :param parent: Optional, reference to the parent node. 

290 """ 

291 Node.__init__(self, root, parent) 

292 

293 @abstractmethod 

294 def __getitem__(self, index: int) -> ValueT: # type: ignore[empty-body] 

295 """ 

296 Read an element of this sequence node by index. 

297 

298 :param index: Index of the element to read. 

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

300 """ 

301 

302 def __setitem__(self, index: int, value: ValueT) -> None: 

303 """ 

304 Write an element of this sequence node by index. 

305 

306 .. attention:: 

307 

308 A configuration is **read-only** - see :meth:`Node.__setitem__`. 

309 

310 :param index: Index of the element to write. 

311 :param value: The new value of that element. 

312 :raises NotImplementedError: Always - a configuration is read-only. 

313 """ 

314 raise NotImplementedError("Currently, the configuration is read-only. Writing isn't implemented.") 

315 

316 def index(self, value: ValueT, start: int = 0, stop: Nullable[int] = None) -> int: 

317 """ 

318 Return the index of the first element equal to a value, so a sequence node reads like a :class:`list`. 

319 

320 :param value: The value to search for. 

321 :param start: Optional, index to start searching at. Defaults to ``0``. 

322 :param stop: Optional, index to stop searching before. Defaults to the end of this node. 

323 :returns: Index of the first matching element. 

324 :raises ValueError: If no element in the searched range equals the value. 

325 """ 

326 length = len(self) 

327 start = max(0, length + start if start < 0 else start) 

328 stop = length if stop is None else min(length, length + stop if stop < 0 else stop) 

329 

330 for index in range(start, stop): 

331 if self[index] == value: 

332 return index 

333 

334 raise ValueError(f"'{value}' is not in this sequence node.") 

335 

336 def count(self, value: ValueT) -> int: 

337 """ 

338 Return how many elements of this node equal a value, so a sequence node reads like a :class:`list`. 

339 

340 :param value: The value to count. 

341 :returns: Number of matching elements. 

342 """ 

343 return sum(1 for element in self if element == value) 

344 

345 

346setattr(Node, "DICT_TYPE", Dictionary) 

347setattr(Node, "SEQ_TYPE", Sequence) 

348 

349 

350@export 

351@mixin 

352class Configuration(Node): 

353 """Abstract root node in a configuration.""" 

354 

355 _configFile: Path #: Path to the configuration file. 

356 

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

358 """ 

359 Initializes a configuration, the root node of the tree. 

360 

361 The root node refers to itself as the root and has no parent. 

362 

363 :param configFile: Configuration file. 

364 """ 

365 Node.__init__(self, self, None) 

366 

367 self._configFile = configFile 

368 

369 @readonly 

370 def ConfigFile(self) -> Path: 

371 """ 

372 Read-only property to access the configuration file's path. 

373 

374 :returns: Path to the configuration file. 

375 """ 

376 return self._configFile