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

135 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-10-25 12:33 +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 Any, Union, ClassVar, Generator, Iterator, Optional as Nullable, Tuple 

51 

52from pyTooling.Common import getTypeName 

53from pyTooling.Decorators import export, readonly 

54from pyTooling.MetaClasses import ExtendedType, abstractmethod, mixin 

55from pyTooling.Exceptions import ConfigurationError 

56 

57 

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

59 

60 

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

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

63ValueT = Nullable[Union[NodeT, str, int, float]] #: Type variable for values; ``None`` for a null value. 

64 

65 

66@export 

67class KeyNotFoundError(ConfigurationError, KeyError): 

68 """ 

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

70 

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

72 range offered by the node. 

73 

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

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

76 """ 

77 

78 _key: Nullable[KeyT] #: Key or index that wasn't found. 

79 

80 def __init__(self, message: str, /, *, key: Nullable[KeyT] = None) -> None: 

81 """ 

82 Initializes the exception with the key or index that wasn't found. 

83 

84 :param message: The exception's message. 

85 :param key: Optional, the key or index that wasn't found. 

86 """ 

87 super().__init__(message) 

88 self._key = key 

89 

90 @readonly 

91 def Key(self) -> Nullable[KeyT]: 

92 """ 

93 Read-only property to access the key or index that wasn't found (:attr:`_key`). 

94 

95 :returns: The missing key or index, or ``None`` if it wasn't recorded. 

96 """ 

97 return self._key 

98 

99 def __str__(self) -> str: 

100 """ 

101 Return the exception's message. 

102 

103 :returns: The exception's message. 

104 """ 

105 return self.Message 

106 

107 

108@export 

109class UnsupportedValueTypeError(ConfigurationError, TypeError): 

110 """ 

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

112 

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

114 

115 It is a :exc:`TypeError` as well, because the value's type is what's wrong. Catching 

116 :exc:`~pyTooling.Exceptions.ConfigurationError` still catches it. 

117 """ 

118 

119 _value: Any #: Value of the unsupported type, as returned by the parser. 

120 

121 def __init__(self, message: str, /, *, value: Any = None) -> None: 

122 """ 

123 Initializes the exception with the value of the unsupported type. 

124 

125 :param message: The exception's message. 

126 :param value: Optional, the value of the unsupported type, as returned by the parser. 

127 """ 

128 super().__init__(message) 

129 self._value = value 

130 

131 @readonly 

132 def Value(self) -> Any: 

133 """ 

134 Read-only property to access the value of the unsupported type (:attr:`_value`). 

135 

136 :returns: The value as returned by the parser, or ``None`` if it wasn't recorded. 

137 """ 

138 return self._value 

139 

140 @readonly 

141 def ValueType(self) -> Nullable[type]: 

142 """ 

143 Read-only property to return the unsupported type, the type of :attr:`_value`. 

144 

145 :returns: The type of the value, or ``None`` if the value wasn't recorded. 

146 """ 

147 if self._value is None: 

148 return None 

149 

150 return type(self._value) 

151 

152 

153@export 

154class InterpolationError(ConfigurationError): 

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

156 

157 _value: Nullable[str] #: Configuration value containing the malformed variable reference. 

158 _position: Nullable[int] #: Position of the malformed ``$`` in the value. 

159 

160 def __init__(self, message: str, /, *, value: Nullable[str] = None, position: Nullable[int] = None) -> None: 

161 """ 

162 Initializes the exception with the value and the position of the malformed variable reference. 

163 

164 :param message: The exception's message. 

165 :param value: Optional, the configuration value containing the malformed variable reference. 

166 :param position: Optional, the position of the malformed ``$`` in the value. 

167 """ 

168 super().__init__(message) 

169 self._value = value 

170 self._position = position 

171 

172 @readonly 

173 def Value(self) -> Nullable[str]: 

174 """ 

175 Read-only property to access the configuration value containing the malformed variable reference (:attr:`_value`). 

176 

177 :returns: The value, or ``None`` if it wasn't recorded. 

178 """ 

179 return self._value 

180 

181 @readonly 

182 def Position(self) -> Nullable[int]: 

183 """ 

184 Read-only property to access the position of the malformed ``$`` in the value (:attr:`_position`). 

185 

186 :returns: The position, or ``None`` if it wasn't recorded. 

187 """ 

188 return self._position 

189 

190 

191@export 

192class PathExpressionError(ConfigurationError): 

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

194 

195 _pathExpression: Nullable[str] #: Path expression that doesn't describe a valid node or value. 

196 

197 def __init__(self, message: str, /, *, pathExpression: Nullable[str] = None) -> None: 

198 """ 

199 Initializes the exception with the path expression that doesn't describe a valid node or value. 

200 

201 :param message: The exception's message. 

202 :param pathExpression: Optional, the path expression, with its elements separated by ``:``. 

203 """ 

204 super().__init__(message) 

205 self._pathExpression = pathExpression 

206 

207 @readonly 

208 def PathExpression(self) -> Nullable[str]: 

209 """ 

210 Read-only property to access the path expression (:attr:`_pathExpression`). 

211 

212 :returns: The path expression, or ``None`` if it wasn't recorded. 

213 """ 

214 return self._pathExpression 

215 

216 

217@export 

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

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

220 

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

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

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

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

225 

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

227 """ 

228 Initializes a node. 

229 

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

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

232 """ 

233 self._root = root 

234 self._parent = parent 

235 

236 @abstractmethod 

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

238 """ 

239 Returns the number of sub-elements. 

240 

241 :returns: Number of sub-elements. 

242 """ 

243 

244 @abstractmethod 

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

246 """ 

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

248 

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

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

251 """ 

252 

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

254 """ 

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

256 

257 .. attention:: 

258 

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

260 

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

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

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

264 """ 

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

266 

267 @abstractmethod 

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

269 """ 

270 Returns an iterator to iterate a node. 

271 

272 :returns: Node iterator. 

273 """ 

274 

275 @property 

276 def Key(self) -> KeyT: 

277 """ 

278 Property to access the node's key. 

279 

280 :returns: Key of the node. 

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

282 """ 

283 raise NotImplementedError(f"Property 'Key' is abstract and not implemented by '{getTypeName(self)}'.") 

284 

285 @Key.setter 

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

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

288 

289 @abstractmethod 

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

291 """ 

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

293 

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

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

296 """ 

297 

298 

299@export 

300@mixin 

301class Dictionary(Node): 

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

303 

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

305 

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

307 """ 

308 Initializes the dictionary's keys. 

309 

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

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

312 

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

314 """ 

315 self._keys = keys 

316 

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

318 """ 

319 Check if a key exists in this dictionary node. 

320 

321 :param key: The key to check for. 

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

323 """ 

324 return key in self._keys 

325 

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

327 """ 

328 Iterate the keys of this dictionary node. 

329 

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

331 """ 

332 yield from self._keys 

333 

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

335 """ 

336 Iterate the values of this dictionary node. 

337 

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

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

340 which of keys or values plain iteration gives. 

341 

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

343 """ 

344 for key in self._keys: 

345 yield self[key] 

346 

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

348 """ 

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

350 

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

352 """ 

353 for key in self._keys: 

354 yield key, self[key] 

355 

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

357 """ 

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

359 

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

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

362 work because this exists. 

363 

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

365 """ 

366 return tuple(self._keys) 

367 

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

369 """ 

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

371 

372 This is :meth:`IterateValues` materialized. 

373 

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

375 """ 

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

377 

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

379 """ 

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

381 

382 This is :meth:`IterateItems` materialized. 

383 

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

385 """ 

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

387 

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

389 """ 

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

391 

392 :param key: The key to read. 

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

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

395 """ 

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

397 

398 

399@export 

400@mixin 

401class Sequence(Node): 

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

403 

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

405 """ 

406 Initializes a sequence. 

407 

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

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

410 """ 

411 Node.__init__(self, root, parent) 

412 

413 @abstractmethod 

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

415 """ 

416 Read an element of this sequence node by index. 

417 

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

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

420 """ 

421 

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

423 """ 

424 Write an element of this sequence node by index. 

425 

426 .. attention:: 

427 

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

429 

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

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

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

433 """ 

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

435 

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

437 """ 

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

439 

440 :param value: The value to search for. 

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

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

443 :returns: Index of the first matching element. 

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

445 """ 

446 length = len(self) 

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

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

449 

450 for index in range(start, stop): 

451 if self[index] == value: 

452 return index 

453 

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

455 

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

457 """ 

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

459 

460 :param value: The value to count. 

461 :returns: Number of matching elements. 

462 """ 

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

464 

465 

466setattr(Node, "DICT_TYPE", Dictionary) 

467setattr(Node, "SEQ_TYPE", Sequence) 

468 

469 

470@export 

471@mixin 

472class Configuration(Node): 

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

474 

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

476 

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

478 """ 

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

480 

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

482 

483 :param configFile: Configuration file. 

484 """ 

485 Node.__init__(self, self, None) 

486 

487 self._configFile = configFile 

488 

489 @readonly 

490 def ConfigFile(self) -> Path: 

491 """ 

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

493 

494 :returns: Path to the configuration file. 

495 """ 

496 return self._configFile