Coverage for src/navdict/navdict.py: 75%

382 statements  

« prev     ^ index     » next       coverage.py v7.8.2, created at 2026-07-17 16:04 +0200

1""" 

2NavDict: A navigable dictionary with dot notation access and automatic file loading. 

3 

4NavDict extends Python's built-in dictionary to support convenient dot notation 

5access (data.user.name) alongside traditional key access (data["user"]["name"]). 

6It automatically loads data files and can instantiate classes dynamically based 

7on configuration. 

8 

9Features: 

10 - Dot notation access for nested data structures 

11 - Automatic file loading (CSV, YAML, JSON, etc.) 

12 - Dynamic class instantiation from configuration 

13 - Full backward compatibility with standard dictionaries 

14 

15Example: 

16 >>> from navdict import navdict 

17 >>> data = navdict({"user": {"name": "Alice", "config_file": "yaml//settings.yaml"}}) 

18 >>> data.user.name # "Alice" 

19 >>> data.user.config_file # Automatically loads and parses settings.yaml 

20 >>> data["user"]["name"] # Still works with traditional access 

21 

22Author: Rik Huygen 

23License: MIT 

24""" 

25 

26from __future__ import annotations 

27 

28__all__ = [ 

29 "navdict", # noqa: ignore typo 

30 "NavDict", 

31 "NavigableDict", 

32 "expand_env_vars", 

33 "get_resource_location", 

34 "load_class", 

35 "load_csv", 

36 "load_yaml", 

37] 

38 

39import csv 

40import datetime 

41import importlib 

42import itertools 

43import logging 

44import os 

45import re 

46import textwrap 

47import warnings 

48from enum import IntEnum 

49from pathlib import Path 

50from typing import Any 

51from typing import Callable 

52from typing import TypeVar 

53from typing import cast 

54 

55from rich.text import Text 

56from rich.tree import Tree 

57from ruamel.yaml import YAML 

58from ruamel.yaml.scanner import ScannerError 

59 

60from navdict.directive import is_directive 

61from navdict.directive import unravel_directive 

62from navdict.directive import get_directive_plugin 

63 

64logger = logging.getLogger("navdict") 

65 

66T = TypeVar("T") 

67 

68 

69def load_class(class_name: str): 

70 """ 

71 Find and returns a class based on the fully qualified name. 

72 

73 A class name can be preceded with the string `class//` or `factory//`. This is used in YAML 

74 files where the class is then instantiated on load. 

75 

76 Args: 

77 class_name (str): a fully qualified name for the class 

78 """ 

79 if class_name.startswith("class//"): 79 ↛ 80line 79 didn't jump to line 80 because the condition on line 79 was never true

80 class_name = class_name[7:] 

81 elif class_name.startswith("factory//"): 81 ↛ 82line 81 didn't jump to line 82 because the condition on line 81 was never true

82 class_name = class_name[9:] 

83 

84 module_name, class_name = class_name.rsplit(".", 1) 

85 module = importlib.import_module(module_name) 

86 return getattr(module, class_name) 

87 

88 

89def get_resource_location(parent_location: Path | None, in_dir: str | None) -> Path: 

90 """ 

91 Returns the resource location. 

92 

93 The resource location is the path to the file that is provided in a directive 

94 such as `yaml//` or `csv//`. The location of the file can be given as an absolute 

95 path or can be relative in which case there are two possibilities: 

96 

97 1. the parent location is not None. 

98 In this case the resource location will be relative to the parent's location. 

99 2. the parent location is None. 

100 In this case the resource location is taken to be relative to the current working directory '.' 

101 unless the environment variable NAVDICT_DEFAULT_RESOURCE_LOCATION is provided in which case 

102 it is taken from that variable. 

103 3. when both arguments are None, the resource location will be the current working directory '.' 

104 unless the environment variable NAVDICT_DEFAULT_RESOURCE_LOCATION is provided in which case 

105 it is taken from that variable. 

106 

107 In all cases, the returned path is expanded, i.e. `~` is expanded to the user's home directory, 

108 and resolved, but not checked for existence. 

109 

110 Args: 

111 parent_location: the location of the parent navdict, or None 

112 in_dir: a location extracted from the directive's value. 

113 

114 Returns: 

115 A Path object with the resource location. 

116 

117 """ 

118 

119 match (parent_location, in_dir): 

120 case (_, str()) if in_dir.startswith("~") or Path(in_dir).is_absolute(): 

121 location = Path(in_dir) 

122 case (None, str()): 

123 location = Path(os.getenv("NAVDICT_DEFAULT_RESOURCE_LOCATION", ".")) / in_dir 

124 case (Path(), str()): 

125 location = parent_location / in_dir 

126 case (Path(), None): 

127 location = parent_location 

128 case _: 

129 location = Path(os.getenv("NAVDICT_DEFAULT_RESOURCE_LOCATION", ".")) 

130 

131 # logger.debug(f"{location=}, {fn=}") 

132 

133 return location.expanduser() 

134 

135 

136ENV_VAR_PATTERN = re.compile(r"ENV\[\s*['\"]?(\w+)['\"]?\s*]") 

137 

138 

139def expand_env_vars(value: str) -> str: 

140 """ 

141 Expand `ENV[VARNAME]` references in `value` with the value of the corresponding environment 

142 variable. 

143 

144 The variable name may optionally be wrapped in single or double quotes, i.e. `ENV[VARNAME]`, 

145 `ENV['VARNAME']` and `ENV["VARNAME"]` are all equivalent. 

146 

147 Args: 

148 value: a string that may contain zero or more `ENV[VARNAME]` references. 

149 

150 Returns: 

151 The string with all `ENV[VARNAME]` references expanded. 

152 

153 Raises: 

154 ValueError: when a referenced environment variable is not set. 

155 """ 

156 

157 def _replace(match: re.Match) -> str: 

158 var_name = match[1] 

159 try: 

160 return os.environ[var_name] 

161 except KeyError: 

162 raise ValueError(f"Environment variable '{var_name}' referenced in '{value}' is not set.") from None 

163 

164 return ENV_VAR_PATTERN.sub(_replace, value) 

165 

166 

167def load_csv(resource_name: str, parent_location: Path | None, *args, **kwargs) -> list[list[str]]: 

168 """ 

169 Find and return the content of a CSV file. 

170 

171 If the `resource_name` argument starts with the directive (`csv//`), it will be split off automatically. 

172 

173 The `kwargs` dictionary can contain the following keys: 

174 

175 - `header_rows`: the number of header rows to skip when processing the file. 

176 - `delimiter`: the delimiter to use when parsing the CSV file. The default is a comma (`,`). 

177 - `expand_env`: when `True` (the default), `ENV[VARNAME]` references in `resource_name` are expanded 

178 with the value of the corresponding environment variable before the file is located. Set to `False` 

179 to disable this expansion. 

180 

181 Returns: 

182 A list of the split lines, i.e. a list of lists of strings. 

183 """ 

184 

185 # logger.debug(f"{resource_name=}, {parent_location=}") 

186 

187 if resource_name.startswith("csv//"): 187 ↛ 188line 187 didn't jump to line 188 because the condition on line 187 was never true

188 resource_name = resource_name[5:] 

189 

190 if not resource_name: 190 ↛ 191line 190 didn't jump to line 191 because the condition on line 190 was never true

191 raise ValueError("Resource name should not be empty, but contain a valid filename.") 

192 

193 if kwargs.get("expand_env", True): 

194 resource_name = expand_env_vars(resource_name) 

195 

196 parts = resource_name.rsplit("/", 1) 

197 in_dir, fn = parts if len(parts) > 1 else (None, parts[0]) # use a tuple here to make Mypy happy 

198 

199 try: 

200 n_header_rows = int(kwargs["header_rows"]) 

201 except KeyError: 

202 n_header_rows = 0 

203 

204 csv_location = get_resource_location(parent_location, in_dir) 

205 

206 def filter_lines(file_obj, n_skip): 

207 """ 

208 Generator that filters out comment lines and skips header lines. 

209 The standard library csv module cannot handle this functionality. 

210 """ 

211 

212 for line in itertools.islice(file_obj, n_skip, None): 

213 if not line.strip().startswith("#"): 

214 yield line 

215 

216 try: 

217 with open(csv_location / fn, "r", encoding="utf-8") as file: 

218 filtered_lines = filter_lines(file, n_header_rows) 

219 csv_reader = csv.reader(filtered_lines, delimiter=kwargs.get("delimiter", ",")) 

220 data = list(csv_reader) 

221 except FileNotFoundError: 

222 logger.error(f"Couldn't load resource '{resource_name}', file not found", exc_info=True) 

223 raise 

224 

225 return data 

226 

227 

228def _load_int_enum(enum_name: str, enum_content) -> IntEnum: 

229 """Dynamically build (and return) and IntEnum. 

230 

231 In the YAML file this will look like below. 

232 The IntEnum directive (where <name> is the class name): 

233 

234 enum: int_enum//<name> 

235 

236 The IntEnum content: 

237 

238 content: 

239 E: 

240 alias: ['E_SIDE', 'RIGHT_SIDE'] 

241 value: 1 

242 F: 

243 alias: ['F_SIDE', 'LEFT_SIDE'] 

244 value: 0 

245 

246 Args: 

247 - enum_name: Enumeration name (potentially prepended with "int_enum//"). 

248 - enum_content: Content of the enumeration, as read from the navdict field. 

249 

250 Returns: 

251 An IntEnum class with the given name and content. 

252 """ 

253 if enum_name.startswith("int_enum//"): 253 ↛ 254line 253 didn't jump to line 254 because the condition on line 253 was never true

254 enum_name = enum_name[10:] 

255 

256 definition = {} 

257 for side_name, side_definition in enum_content.items(): 

258 if "alias" in side_definition: 258 ↛ 261line 258 didn't jump to line 261 because the condition on line 258 was always true

259 aliases = side_definition["alias"] 

260 else: 

261 aliases = [] 

262 value = side_definition["value"] 

263 

264 definition[side_name] = value 

265 

266 for alias in aliases: 

267 definition[alias] = value 

268 

269 return IntEnum(enum_name, definition) 

270 

271 

272def load_yaml(resource_name: str, parent_location: Path | None, *args, **kwargs) -> NavigableDict: 

273 """Find and return the content of a YAML file. 

274 

275 The `kwargs` dictionary can contain the following keys: 

276 

277 - `expand_env`: when `True` (the default), `ENV[VARNAME]` references in `resource_name` are expanded 

278 with the value of the corresponding environment variable before the file is located. Set to `False` 

279 to disable this expansion. 

280 

281 Args: 

282 resource_name: the name of the YAML file to load, optionally prepended with `yaml//`. 

283 parent_location: the location of the parent navdict, or None. 

284 *args: additional positional arguments (currently unused). 

285 **kwargs: additional keyword arguments. 

286 

287 Returns: 

288 A NavigableDict containing the content of the YAML file. 

289 """ 

290 

291 # logger.debug(f"{resource_name=}, {parent_location=}") 

292 

293 if resource_name.startswith("yaml//"): 293 ↛ 294line 293 didn't jump to line 294 because the condition on line 293 was never true

294 resource_name = resource_name[6:] 

295 

296 if not resource_name: 296 ↛ 297line 296 didn't jump to line 297 because the condition on line 296 was never true

297 raise ValueError("Resource name should not be empty, but contain a valid filename.") 

298 

299 if kwargs.get("expand_env", True): 299 ↛ 302line 299 didn't jump to line 302 because the condition on line 299 was always true

300 resource_name = expand_env_vars(resource_name) 

301 

302 parts = resource_name.rsplit("/", 1) 

303 in_dir, fn = parts if len(parts) > 1 else (None, parts[0]) # use a tuple here to make Mypy happy 

304 

305 yaml_location = get_resource_location(parent_location, in_dir) 

306 

307 try: 

308 yaml = YAML(typ="safe") 

309 with open(yaml_location / fn, "r") as file: 

310 data = yaml.load(file) 

311 

312 except FileNotFoundError: 

313 logger.error(f"Couldn't load resource '{resource_name}', file not found", exc_info=True) 

314 raise 

315 except IsADirectoryError: 

316 logger.error( 

317 f"Couldn't load resource '{resource_name}', file seems to be a directory", 

318 exc_info=True, 

319 ) 

320 raise 

321 except ScannerError as exc: 

322 msg = f"A error occurred while scanning the YAML file: {yaml_location / fn}." 

323 logger.error(msg, exc_info=True) 

324 raise IOError(msg) from exc 

325 

326 data = NavigableDict(data, _filename=yaml_location / fn) 

327 

328 # logger.debug(f"{data.get_private_attribute('_filename')=}") 

329 

330 return data 

331 

332 

333def _get_attribute(self, name, default): 

334 """ 

335 Safely retrieve an attribute from the object, returning a default if not found. 

336 

337 This method uses object.__getattribute__() to bypass any custom __getattr__ 

338 or __getattribute__ implementations on the class, accessing attributes directly 

339 from the object's internal dictionary. 

340 

341 Args: 

342 name (str): The name of the attribute to retrieve. 

343 default: The value to return if the attribute does not exist. 

344 

345 Returns: 

346 The attribute value if it exists, otherwise the default value. 

347 

348 Note: 

349 This is typically used internally to avoid infinite recursion when 

350 implementing custom attribute access methods. 

351 """ 

352 try: 

353 attr = object.__getattribute__(self, name) 

354 except AttributeError: 

355 attr = default 

356 return attr 

357 

358 

359class NavigableDict(dict): 

360 """ 

361 A NavigableDict is a dictionary where all keys in the original dictionary are also accessible 

362 as attributes to the class instance. So, if the original dictionary (setup) has a key 

363 "site_id" which is accessible as `setup['site_id']`, it will also be accessible as 

364 `setup.site_id`. 

365 

366 Args: 

367 head (dict): the original dictionary 

368 label (str): a label or name that is used when printing the navdict 

369 

370 Examples: 

371 >>> setup = NavigableDict({'site_id': 'KU Leuven', 'version': "0.1.0"}) 

372 >>> assert setup['site_id'] == setup.site_id 

373 >>> assert setup['version'] == setup.version 

374 

375 Note: 

376 We always want **all** keys to be accessible as attributes, or none. That means all 

377 keys of the original dictionary shall be of type `str`. 

378 

379 """ 

380 

381 def __init__( 

382 self, 

383 head: dict | None = None, 

384 label: str | None = None, 

385 _filename: str | Path | None = None, 

386 ): 

387 head = head or {} 

388 super().__init__(head) 

389 self.__dict__["_memoized"] = {} 

390 self.__dict__["_label"] = label 

391 self.__dict__["_filename"] = Path(_filename) if _filename is not None else None 

392 

393 # TODO: 

394 # if _filename was not given as an argument, we might want to check if the `head` has a `_filename` and do 

395 # something like: 

396 # 

397 # if _filename is None and isinstance(head, navdict): 

398 # _filename = head.__dict__["_filename"] 

399 # self.__dict__["_filename"] = _filename 

400 

401 # By agreement, we only want the keys to be set as attributes if all keys are strings. 

402 # That way we enforce that always all keys are navigable, or none. 

403 

404 if any(True for k in head.keys() if not isinstance(k, str)): 

405 # invalid_keys = list(k for k in head.keys() if not isinstance(k, str)) 

406 # logger.warning(f"Dictionary will not be dot-navigable, not all keys are strings [{invalid_keys=}].") 

407 return 

408 

409 for key, value in head.items(): 

410 if isinstance(value, dict): 

411 value = NavigableDict(head.__getitem__(key), _filename=_filename, label=key) 

412 setattr(self, key, value) 

413 else: 

414 setattr(self, key, super().__getitem__(key)) 

415 

416 def get_label(self) -> str | None: 

417 return self.__dict__["_label"] 

418 

419 def set_label(self, value: str): 

420 self.__dict__["_label"] = value 

421 

422 def add(self, key: str, value: Any): 

423 """Set a value for the given key. 

424 

425 If the value is a dictionary, it will be converted into a NavigableDict and the keys 

426 will become available as attributes provided that all the keys are strings. 

427 

428 Args: 

429 key (str): the name of the key / attribute to access the value 

430 value (Any): the value to assign to the key 

431 """ 

432 if isinstance(value, dict) and not isinstance(value, NavigableDict): 

433 value = NavigableDict(value, label=key) 

434 setattr(self, key, value) 

435 

436 def clear(self) -> None: 

437 for key in list(self.keys()): 

438 self.__delitem__(key) 

439 

440 def __repr__(self): 

441 return f"{self.__class__.__name__}({super()!r}) [id={id(self)}]" 

442 

443 def __delitem__(self, key): 

444 dict.__delitem__(self, key) 

445 object.__delattr__(self, key) 

446 

447 def __setattr__(self, key, value): 

448 # logger.info(f"called __setattr__({self!r}, {key}, {value})") 

449 if isinstance(value, dict) and not isinstance(value, NavigableDict): 449 ↛ 450line 449 didn't jump to line 450 because the condition on line 449 was never true

450 value = NavigableDict(value, label=key) 

451 self.__dict__[key] = value 

452 super().__setitem__(key, value) 

453 try: 

454 del self.__dict__["_memoized"][key] 

455 except KeyError: 

456 pass 

457 

458 @staticmethod 

459 def _alias_hook(key: str) -> str: 

460 raise NotImplementedError 

461 

462 def set_alias_hook(self, hook: Callable[[str], str]): 

463 """ 

464 Sets an alias (hook) function that maps the given argument, an attribute 

465 or a dict key, to a valid attribute or key. 

466 

467 The `hook` function accepts a string argument and return a string for 

468 which the argument is an alias. The returned argument is expected to 

469 be a valid attribute or key for this navdict. 

470 """ 

471 # The setattr() function will add the attribute to the dictionary 

472 # which is not what we want. 

473 # setattr(self, "_alias_hook", hook) 

474 self.__dict__["_alias_hook"] = hook 

475 

476 def as_typed(self, schema_type: type[T]) -> T: 

477 """Return this instance typed as `schema_type` for static analysis. 

478 

479 This helper does not transform data at runtime. It returns `self` and 

480 exists to let type checkers and editors infer attribute completion when 

481 callers provide a Protocol, TypedDict wrapper, or class describing the 

482 expected structure. 

483 """ 

484 return cast(T, self) 

485 

486 def __dir__(self): 

487 """Include navigable keys in `dir()` for better interactive discovery.""" 

488 base = set(super().__dir__()) 

489 key_names = {key for key in self.keys() if isinstance(key, str) and key.isidentifier()} 

490 for key in self.keys(): 

491 if isinstance(key, str) and not key.isidentifier(): 

492 base.discard(key) 

493 return sorted(base | key_names) 

494 

495 # This method is called: 

496 # - for *every* single attribute access on an object using dot notation. 

497 # - when using the `getattr(obj, 'name') function 

498 # - accessing any kind of attributes, e.g. instance or class variables, 

499 # methods, properties, dunder methods, ... 

500 # 

501 # Note: `__getattr__` is only called when an attribute cannot be found 

502 # through normal means. 

503 def __getattribute__(self, key): 

504 # logger.info(f"called __getattribute__({key}) ...") 

505 try: 

506 value = object.__getattribute__(self, key) 

507 except AttributeError: 

508 try: 

509 alias = self._alias_hook(key) 

510 value = object.__getattribute__(self, alias) 

511 except (NotImplementedError, AttributeError): 

512 # Either the alias hook is not implemented or the alias hook just returned a key that still doesn't exist. 

513 # In both cases we want to raise an AttributeError to indicate that the attribute does not exist. 

514 raise AttributeError(f"{type(self).__name__!r} object has no attribute {key!r}") 

515 except Exception as exc: 

516 logger.error( 

517 f"Alias hook function {self._alias_hook.__name__!r}({key}) raised {type(exc).__name__!r}: {exc}" 

518 ) 

519 raise AttributeError(f"{type(self).__name__!r} object has no attribute {key!r}") 

520 

521 if key.startswith("__"): # small optimization 

522 return value 

523 # We can not directly call the `_handle_directive` function here due to infinite recursion 

524 if is_directive(value): 

525 m = object.__getattribute__(self, "_handle_directive") 

526 return m(key, value) 

527 else: 

528 return value 

529 

530 def __delattr__(self, item): 

531 # logger.info(f"called __delattr__({self!r}, {item})") 

532 object.__delattr__(self, item) 

533 dict.__delitem__(self, item) 

534 

535 def __setitem__(self, key, value): 

536 # logger.debug(f"called __setitem__({self!r}, {key}, {value})") 

537 if isinstance(value, dict) and not isinstance(value, NavigableDict): 

538 value = NavigableDict(value, label=key) 

539 super().__setitem__(key, value) 

540 self.__dict__[key] = value 

541 try: 

542 del self.__dict__["_memoized"][key] 

543 except KeyError: 

544 pass 

545 

546 # This method is called: 

547 # - whenever square brackets `[]` are used on an object, e.g. indexing or slicing. 

548 # - during iteration, if an object doesn't have __iter__ defined, Python will try 

549 # to iterate using __getitem__ with successive integer indices starting from 0. 

550 def __getitem__(self, key): 

551 # logger.info(f"called __getitem__({self!r}, {key})") 

552 try: 

553 value = super().__getitem__(key) 

554 except KeyError: 

555 try: 

556 alias = self._alias_hook(key) 

557 value = super().__getitem__(alias) 

558 except NotImplementedError: 

559 raise KeyError(f"{type(self).__name__!r} has no key {key!r}") 

560 except Exception as exc: 

561 logger.error( 

562 f"Alias hook function {self._alias_hook.__name__!r}({key}) raised {type(exc).__name__!r}: {exc}" 

563 ) 

564 raise KeyError(f"{type(self).__name__!r} has no key {key!r}") 

565 

566 if isinstance(key, str) and key.startswith("__"): 566 ↛ 567line 566 didn't jump to line 567 because the condition on line 566 was never true

567 return value 

568 # no danger for recursion here, so we can directly call the function 

569 if is_directive(value): 569 ↛ 570line 569 didn't jump to line 570 because the condition on line 569 was never true

570 return self._handle_directive(key, value) 

571 else: 

572 return value 

573 

574 def _handle_directive(self, key, value) -> Any: 

575 """ 

576 This method will handle the available directives. This may be builtin directives 

577 like `class/` or `factory//`, or it may be external directives that were provided 

578 as a plugin. Some builtin directives have also been provided as a plugin, e.g. 

579 'yaml//' and 'csv//'. 

580 

581 Args: 

582 key: the key of the field that might contain a directive 

583 value: the value which might be a directive 

584 

585 Returns: 

586 This function will return the value, either the original value or the result of 

587 evaluating and executing a directive. 

588 """ 

589 # logger.debug(f"called _handle_directive({key}, {value!r}) [id={id(self)}]") 

590 

591 directive_key, directive_value = unravel_directive(value) 

592 # logger.debug(f"{directive_key=}, {directive_value=}") 

593 

594 if directive := get_directive_plugin(directive_key): 

595 # logger.debug(f"{directive.name=}") 

596 

597 if key in self.__dict__["_memoized"]: 

598 return self.__dict__["_memoized"][key] 

599 

600 args, kwargs = self._get_args_and_kwargs(key) 

601 parent_location = self._get_location() 

602 result = directive.func(directive_value, parent_location, *args, **kwargs) 

603 

604 self.__dict__["_memoized"][key] = result 

605 return result 

606 

607 match directive_key: 

608 case "class": 

609 args, kwargs = self._get_args_and_kwargs(key) 

610 return load_class(directive_value)(*args, **kwargs) 

611 

612 case "factory": 612 ↛ 613line 612 didn't jump to line 613 because the pattern on line 612 never matched

613 factory_args = _get_attribute(self, f"{key}_args", {}) 

614 return load_class(directive_value)().create(**factory_args) 

615 

616 case "int_enum": 

617 content = object.__getattribute__(self, "content") 

618 return _load_int_enum(directive_value, content) 

619 

620 case _: 

621 return value 

622 

623 def _get_location(self): 

624 """Returns the location of the file from which this NavDict was loaded or None if no location exists.""" 

625 try: 

626 filename = self.__dict__["_filename"] 

627 return filename.parent if filename else None 

628 except KeyError: 

629 return None 

630 

631 def _get_args_and_kwargs(self, key): 

632 """ 

633 Read the args and kwargs that are associated with the key of a directive. 

634 

635 An example of such a directive: 

636 

637 hexapod: 

638 device: class//egse.hexapod.PunaProxy 

639 device_args: [PUNA_01] 

640 device_kwargs: 

641 sim: true 

642 

643 There might not be any positional nor keyword arguments provided in which 

644 case and empty tuple and/or dictionary is returned. 

645 

646 Returns: 

647 A tuple containing any positional arguments and a dictionary containing 

648 keyword arguments. 

649 """ 

650 try: 

651 args = object.__getattribute__(self, f"{key}_args") 

652 except AttributeError: 

653 args = () 

654 try: 

655 kwargs = object.__getattribute__(self, f"{key}_kwargs") 

656 except AttributeError: 

657 kwargs = {} 

658 

659 return args, kwargs 

660 

661 def set_private_attribute(self, key: str, value: Any) -> None: 

662 """Sets a private attribute for this object. 

663 

664 The name in key will be accessible as an attribute for this object, but the key will not 

665 be added to the dictionary and not be returned by methods like keys(). 

666 

667 The idea behind this private attribute is to have the possibility to add status information 

668 or identifiers to this classes object that can be used by save() or load() methods. 

669 

670 Args: 

671 key (str): the name of the private attribute (must start with an underscore character). 

672 value: the value for this private attribute 

673 

674 Examples: 

675 >>> setup = NavigableDict({'a': 1, 'b': 2, 'c': 3}) 

676 >>> setup.set_private_attribute("_loaded_from_dict", True) 

677 >>> assert "c" in setup 

678 >>> assert "_loaded_from_dict" not in setup 

679 >>> assert setup.get_private_attribute("_loaded_from_dict") == True 

680 

681 """ 

682 if key in self: 682 ↛ 683line 682 didn't jump to line 683 because the condition on line 682 was never true

683 raise ValueError(f"Invalid argument key='{key}', this key already exists in the dictionary.") 

684 if not key.startswith("_"): 684 ↛ 685line 684 didn't jump to line 685 because the condition on line 684 was never true

685 raise ValueError(f"Invalid argument key='{key}', must start with underscore character '_'.") 

686 self.__dict__[key] = value 

687 

688 def get_private_attribute(self, key: str) -> Any: 

689 """Returns the value of the given private attribute. 

690 

691 Args: 

692 key (str): the name of the private attribute (must start with an underscore character). 

693 

694 Returns: 

695 the value of the private attribute given in `key` or None if the attribute doesn't exist. 

696 

697 Note: 

698 Because of the implementation, this private attribute can also be accessed as a 'normal' 

699 attribute of the object. This use is however discouraged as it will make your code less 

700 understandable. Use the methods to access these 'private' attributes. 

701 """ 

702 if not key.startswith("_"): 702 ↛ 703line 702 didn't jump to line 703 because the condition on line 702 was never true

703 raise ValueError(f"Invalid argument key='{key}', must start with underscore character '_'.") 

704 try: 

705 return self.__dict__[key] 

706 except KeyError: 

707 return None 

708 

709 def has_private_attribute(self, key) -> bool: 

710 """ 

711 Check if the given key is defined as a private attribute. 

712 

713 Args: 

714 key (str): the name of a private attribute (must start with an underscore) 

715 Returns: 

716 True if the given key is a known private attribute. 

717 Raises: 

718 ValueError: when the key doesn't start with an underscore. 

719 """ 

720 if not key.startswith("_"): 

721 raise ValueError(f"Invalid argument key='{key}', must start with underscore character '_'.") 

722 

723 # logger.debug(f"{self.__dict__.keys()} for [id={id(self)}]") 

724 

725 try: 

726 _ = self.__dict__[key] 

727 return True 

728 except KeyError: 

729 return False 

730 

731 def get_raw_value(self, key): 

732 """ 

733 Returns the raw value of the given key. 

734 

735 Some keys have special values that are interpreted by the NavigableDict class. An example is 

736 a value that starts with 'class//'. When you access these values, they are first converted 

737 from their raw value into their expected value, e.g. the instantiated object in the above 

738 example. This method allows you to access the raw value before conversion. 

739 """ 

740 try: 

741 return object.__getattribute__(self, key) 

742 except AttributeError: 

743 raise KeyError(f"The key '{key}' is not defined.") 

744 

745 def __str__(self): 

746 return self._pretty_str() 

747 

748 def _pretty_str(self, indent: int = 0): 

749 msg = "" 

750 

751 for k, v in self.items(): 

752 if isinstance(v, NavigableDict): 

753 msg += f"{' ' * indent}{k}:\n" 

754 msg += v._pretty_str(indent + 1) 

755 else: 

756 msg += f"{' ' * indent}{k}: {v}\n" 

757 

758 return msg 

759 

760 def __rich__(self) -> Tree: 

761 tree = Tree(self.__dict__["_label"] or "NavigableDict", guide_style="dim") 

762 _walk_dict_tree(self, tree, text_style="dark grey") 

763 return tree 

764 

765 def _save(self, fd, indent: int = 0): 

766 """ 

767 Recursive method to write the dictionary to the file descriptor. 

768 

769 Indentation is done in steps of four spaces, i.e. `' '*indent`. 

770 

771 Args: 

772 fd: a file descriptor as returned by the open() function 

773 indent (int): indentation level of each line [default = 0] 

774 

775 """ 

776 

777 # Note that the .items() method returns the actual values of the keys and doesn't use the 

778 # __getattribute__ or __getitem__ methods. So the raw value is returned and not the 

779 # _processed_ value. 

780 

781 for k, v in self.items(): 

782 # history shall be saved last, skip it for now 

783 

784 if k == "history": 784 ↛ 785line 784 didn't jump to line 785 because the condition on line 784 was never true

785 continue 

786 

787 # make sure to escape a colon in the key name 

788 

789 if isinstance(k, str) and ":" in k: 789 ↛ 790line 789 didn't jump to line 790 because the condition on line 789 was never true

790 k = '"' + k + '"' 

791 

792 if isinstance(v, NavigableDict): 

793 fd.write(f"{' ' * indent}{k}:\n") 

794 v._save(fd, indent + 1) 

795 fd.flush() 

796 continue 

797 

798 if isinstance(v, float): 798 ↛ 799line 798 didn't jump to line 799 because the condition on line 798 was never true

799 v = f"{v:.6E}" 

800 fd.write(f"{' ' * indent}{k}: {v}\n") 

801 fd.flush() 

802 

803 # now save the history as the last item 

804 

805 if "history" in self: 805 ↛ 806line 805 didn't jump to line 806 because the condition on line 805 was never true

806 fd.write(f"{' ' * indent}history:\n") 

807 self.history._save(fd, indent + 1) # noqa 

808 

809 def get_memoized_keys(self): 

810 return list(self.__dict__["_memoized"].keys()) 

811 

812 def del_memoized_key(self, key: str): 

813 try: 

814 del self.__dict__["_memoized"][key] 

815 return True 

816 except KeyError: 

817 return False 

818 

819 @staticmethod 

820 def from_dict(my_dict: dict, label: str | None = None) -> NavigableDict: 

821 """Create a NavigableDict from a given dictionary. 

822 

823 Remember that all keys in the given dictionary shall be of type 'str' in order to be 

824 accessible as attributes. 

825 

826 Args: 

827 my_dict: a Python dictionary 

828 label: a label that will be attached to this navdict 

829 

830 Examples: 

831 >>> setup = navdict.from_dict({"ID": "my-setup-001", "version": "0.1.0"}, label="Setup") 

832 >>> assert setup["ID"] == setup.ID == "my-setup-001" 

833 

834 """ 

835 return NavigableDict(my_dict, label=label) 

836 

837 @staticmethod 

838 def from_yaml_string(yaml_content: str | None = None, label: str | None = None) -> NavigableDict: 

839 """Creates a NavigableDict from the given YAML string. 

840 

841 This method is mainly used for easy creation of a navdict from strings during unit tests. 

842 

843 Args: 

844 yaml_content: a string containing YAML 

845 label: a label that will be attached to this navdict 

846 

847 Returns: 

848 a navdict that was loaded from the content of the given string. 

849 """ 

850 

851 if not yaml_content: 

852 raise ValueError("Invalid argument to function: No input string or None given.") 

853 

854 yaml = YAML(typ="safe") 

855 try: 

856 data = yaml.load(yaml_content) 

857 except ScannerError as exc: 

858 raise ValueError(f"Invalid YAML string: {exc}") 

859 

860 return NavigableDict(data, label=label) 

861 

862 @staticmethod 

863 def from_yaml_file(filename: str | Path | None = None) -> NavigableDict: 

864 """Creates a navigable dictionary from the given YAML file. 

865 

866 Args: 

867 filename (str): the path of the YAML file to be loaded 

868 

869 Returns: 

870 a navdict that was loaded from the given location. 

871 

872 Raises: 

873 ValueError: when no filename is given. 

874 """ 

875 

876 # logger.debug(f"{filename=}") 

877 

878 if not filename: 878 ↛ 879line 878 didn't jump to line 879 because the condition on line 878 was never true

879 raise ValueError("Invalid argument to function: No filename or None given.") 

880 

881 # Make sure the filename exists and is a regular file 

882 filename = Path(filename).expanduser().resolve() 

883 if not filename.is_file(): 

884 raise ValueError(f"Invalid argument to function, filename does not exist: {filename!s}") 

885 

886 data = load_yaml(str(filename), parent_location=None) 

887 

888 if data == {}: 888 ↛ 889line 888 didn't jump to line 889 because the condition on line 888 was never true

889 warnings.warn(f"Empty YAML file: {filename!s}") 

890 

891 return data 

892 

893 def to_yaml_file( 

894 self, 

895 filename: str | Path | None = None, 

896 header: str | None = None, 

897 top_level_group: str | None = None, 

898 ) -> None: 

899 """Saves a NavigableDict to a YAML file. 

900 

901 When no filename is provided, this method will look for a 'private' attribute 

902 `_filename` and use that to save the data. 

903 

904 Args: 

905 filename (str|Path): the path of the YAML file where to save the data 

906 header (str): Custom header for this navdict 

907 top_level_group (str): name of the optional top-level group 

908 

909 Note: 

910 This method will **overwrite** the original or given YAML file and therefore you might 

911 lose proper formatting and/or comments. 

912 

913 """ 

914 if filename is None and self.get_private_attribute("_filename") is None: 

915 raise ValueError("No filename given or known, can not save navdict.") 

916 

917 if header is None: 917 ↛ 929line 917 didn't jump to line 929 because the condition on line 917 was always true

918 header = textwrap.dedent( 

919 f""" 

920 # This YAML file is generated by: 

921 # 

922 # navdict.to_yaml_file(setup, filename="{filename}') 

923 # 

924 # Created on {datetime.datetime.now(tz=datetime.timezone.utc).isoformat()} 

925 

926 """ 

927 ) 

928 

929 target = Path(filename) if filename is not None else Path(self.get_private_attribute("_filename")) 

930 

931 with target.open("w") as fd: 

932 fd.write(header) 

933 indent = 0 

934 if top_level_group: 934 ↛ 935line 934 didn't jump to line 935 because the condition on line 934 was never true

935 fd.write(f"{top_level_group}:\n") 

936 indent = 1 

937 

938 self._save(fd, indent=indent) 

939 

940 self.set_private_attribute("_filename", target) 

941 

942 def get_filename(self) -> str | None: 

943 """Returns the filename for this navdict or None when no filename could be determined.""" 

944 return self.get_private_attribute("_filename") 

945 

946 

947navdict = NavigableDict 

948NavDict = NavigableDict 

949"""Shortcuts for NavigableDict and more Pythonic.""" 

950 

951 

952def _walk_dict_tree(dictionary: dict, tree: Tree, text_style: str = "green"): 

953 for k, v in dictionary.items(): 

954 if isinstance(v, dict): 

955 branch = tree.add(f"[purple]{k}", style="", guide_style="dim") 

956 _walk_dict_tree(v, branch, text_style=text_style) 

957 else: 

958 text = Text.assemble((str(k), "medium_purple1"), ": ", (str(v), text_style)) 

959 tree.add(text)