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

369 statements  

« prev     ^ index     » next       coverage.py v7.8.2, created at 2026-07-13 14:06 +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 "get_resource_location", 

33 "expand_env_vars", 

34] 

35 

36import csv 

37import datetime 

38import importlib 

39import itertools 

40import logging 

41import os 

42import re 

43import textwrap 

44import warnings 

45from enum import IntEnum 

46from pathlib import Path 

47from typing import Any 

48from typing import Callable 

49 

50from rich.text import Text 

51from rich.tree import Tree 

52from ruamel.yaml import YAML 

53from ruamel.yaml.scanner import ScannerError 

54 

55from navdict.directive import is_directive 

56from navdict.directive import unravel_directive 

57from navdict.directive import get_directive_plugin 

58 

59logger = logging.getLogger("navdict") 

60 

61 

62def load_class(class_name: str): 

63 """ 

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

65 

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

67 files where the class is then instantiated on load. 

68 

69 Args: 

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

71 """ 

72 if class_name.startswith("class//"): 

73 class_name = class_name[7:] 

74 elif class_name.startswith("factory//"): 

75 class_name = class_name[9:] 

76 

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

78 module = importlib.import_module(module_name) 

79 return getattr(module, class_name) 

80 

81 

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

83 """ 

84 Returns the resource location. 

85 

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

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

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

89 

90 1. the parent location is not None. 

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

92 2. the parent location is None. 

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

94 unless the environment variable NAVDICT_DEFAULT_RESOURCE_LOCATION is provided in which case 

95 it is taken from that variable. 

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

97 unless the environment variable NAVDICT_DEFAULT_RESOURCE_LOCATION is provided in which case 

98 it is taken from that variable. 

99 

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

101 and resolved, but not checked for existence. 

102 

103 Args: 

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

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

106 

107 Returns: 

108 A Path object with the resource location. 

109 

110 """ 

111 

112 match (parent_location, in_dir): 

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

114 location = Path(in_dir) 

115 case (None, str()): 

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

117 case (Path(), str()): 

118 location = parent_location / in_dir 

119 case (Path(), None): 

120 location = parent_location 

121 case _: 

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

123 

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

125 

126 return location.expanduser() 

127 

128 

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

130 

131 

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

133 """ 

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

135 variable. 

136 

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

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

139 

140 Args: 

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

142 

143 Returns: 

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

145 

146 Raises: 

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

148 """ 

149 

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

151 var_name = match[1] 

152 try: 

153 return os.environ[var_name] 

154 except KeyError: 

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

156 

157 return ENV_VAR_PATTERN.sub(_replace, value) 

158 

159 

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

161 """ 

162 Find and return the content of a CSV file. 

163 

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

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

166 

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

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

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

170 to disable this expansion. 

171 

172 Returns: 

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

174 """ 

175 

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

177 

178 if resource_name.startswith("csv//"): 

179 resource_name = resource_name[5:] 

180 

181 if not resource_name: 

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

183 

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

185 resource_name = expand_env_vars(resource_name) 

186 

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

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

189 

190 try: 

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

192 except KeyError: 

193 n_header_rows = 0 

194 

195 csv_location = get_resource_location(parent_location, in_dir) 

196 

197 def filter_lines(file_obj, n_skip): 

198 """ 

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

200 The standard library csv module cannot handle this functionality. 

201 """ 

202 

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

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

205 yield line 

206 

207 try: 

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

209 filtered_lines = filter_lines(file, n_header_rows) 

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

211 data = list(csv_reader) 

212 except FileNotFoundError: 

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

214 raise 

215 

216 return data 

217 

218 

219def load_int_enum(enum_name: str, enum_content) -> IntEnum: 

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

221 

222 In the YAML file this will look like below. 

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

224 

225 enum: int_enum//<name> 

226 

227 The IntEnum content: 

228 

229 content: 

230 E: 

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

232 value: 1 

233 F: 

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

235 value: 0 

236 

237 Args: 

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

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

240 """ 

241 if enum_name.startswith("int_enum//"): 

242 enum_name = enum_name[10:] 

243 

244 definition = {} 

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

246 if "alias" in side_definition: 

247 aliases = side_definition["alias"] 

248 else: 

249 aliases = [] 

250 value = side_definition["value"] 

251 

252 definition[side_name] = value 

253 

254 for alias in aliases: 

255 definition[alias] = value 

256 

257 return IntEnum(enum_name, definition) 

258 

259 

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

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

262 

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

264 

265 if resource_name.startswith("yaml//"): 

266 resource_name = resource_name[6:] 

267 

268 if not resource_name: 

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

270 

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

272 resource_name = expand_env_vars(resource_name) 

273 

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

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

276 

277 yaml_location = get_resource_location(parent_location, in_dir) 

278 

279 try: 

280 yaml = YAML(typ="safe") 

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

282 data = yaml.load(file) 

283 

284 except FileNotFoundError: 

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

286 raise 

287 except IsADirectoryError: 

288 logger.error( 

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

290 exc_info=True, 

291 ) 

292 raise 

293 except ScannerError as exc: 

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

295 logger.error(msg, exc_info=True) 

296 raise IOError(msg) from exc 

297 

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

299 

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

301 

302 return data 

303 

304 

305def _get_attribute(self, name, default): 

306 """ 

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

308 

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

310 or __getattribute__ implementations on the class, accessing attributes directly 

311 from the object's internal dictionary. 

312 

313 Args: 

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

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

316 

317 Returns: 

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

319 

320 Note: 

321 This is typically used internally to avoid infinite recursion when 

322 implementing custom attribute access methods. 

323 """ 

324 try: 

325 attr = object.__getattribute__(self, name) 

326 except AttributeError: 

327 attr = default 

328 return attr 

329 

330 

331class NavigableDict(dict): 

332 """ 

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

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

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

336 `setup.site_id`. 

337 

338 Args: 

339 head (dict): the original dictionary 

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

341 

342 Examples: 

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

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

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

346 

347 Note: 

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

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

350 

351 """ 

352 

353 def __init__( 

354 self, 

355 head: dict | None = None, 

356 label: str | None = None, 

357 _filename: str | Path | None = None, 

358 ): 

359 head = head or {} 

360 super().__init__(head) 

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

362 self.__dict__["_label"] = label 

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

364 

365 # TODO: 

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

367 # something like: 

368 # 

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

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

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

372 

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

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

375 

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

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

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

379 return 

380 

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

382 if isinstance(value, dict): 

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

384 setattr(self, key, value) 

385 else: 

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

387 

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

389 return self.__dict__["_label"] 

390 

391 def set_label(self, value: str): 

392 self.__dict__["_label"] = value 

393 

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

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

396 

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

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

399 

400 Args: 

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

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

403 """ 

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

405 value = NavigableDict(value, label=key) 

406 setattr(self, key, value) 

407 

408 def clear(self) -> None: 

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

410 self.__delitem__(key) 

411 

412 def __repr__(self): 

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

414 

415 def __delitem__(self, key): 

416 dict.__delitem__(self, key) 

417 object.__delattr__(self, key) 

418 

419 def __setattr__(self, key, value): 

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

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

422 value = NavigableDict(value, label=key) 

423 self.__dict__[key] = value 

424 super().__setitem__(key, value) 

425 try: 

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

427 except KeyError: 

428 pass 

429 

430 @staticmethod 

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

432 raise NotImplementedError 

433 

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

435 """ 

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

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

438 

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

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

441 be a valid attribute or key for this navdict. 

442 """ 

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

444 # which is not what we want. 

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

446 self.__dict__["_alias_hook"] = hook 

447 

448 # This method is called: 

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

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

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

452 # methods, properties, dunder methods, ... 

453 # 

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

455 # through normal means. 

456 def __getattribute__(self, key): 

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

458 try: 

459 value = object.__getattribute__(self, key) 

460 except AttributeError: 

461 try: 

462 alias = self._alias_hook(key) 

463 value = object.__getattribute__(self, alias) 

464 except NotImplementedError: 

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

466 except Exception as exc: 

467 logger.error(f"Alias hook function raised {type(exc).__name__!r}: {exc}") 

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

469 

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

471 return value 

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

473 if is_directive(value): 

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

475 return m(key, value) 

476 else: 

477 return value 

478 

479 def __delattr__(self, item): 

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

481 object.__delattr__(self, item) 

482 dict.__delitem__(self, item) 

483 

484 def __setitem__(self, key, value): 

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

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

487 value = NavigableDict(value, label=key) 

488 super().__setitem__(key, value) 

489 self.__dict__[key] = value 

490 try: 

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

492 except KeyError: 

493 pass 

494 

495 # This method is called: 

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

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

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

499 def __getitem__(self, key): 

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

501 try: 

502 value = super().__getitem__(key) 

503 except KeyError: 

504 try: 

505 alias = self._alias_hook(key) 

506 value = super().__getitem__(alias) 

507 except NotImplementedError: 

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

509 except Exception as exc: 

510 logger.error(f"Alias hook function raised {type(exc).__name__!r}: {exc}") 

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

512 

513 if isinstance(key, str) and key.startswith("__"): 

514 return value 

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

516 if is_directive(value): 

517 return self._handle_directive(key, value) 

518 else: 

519 return value 

520 

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

522 """ 

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

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

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

526 'yaml//' and 'csv//'. 

527 

528 Args: 

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

530 value: the value which might be a directive 

531 

532 Returns: 

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

534 evaluating and executing a directive. 

535 """ 

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

537 

538 directive_key, directive_value = unravel_directive(value) 

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

540 

541 if directive := get_directive_plugin(directive_key): 

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

543 

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

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

546 

547 args, kwargs = self._get_args_and_kwargs(key) 

548 parent_location = self._get_location() 

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

550 

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

552 return result 

553 

554 match directive_key: 

555 case "class": 

556 args, kwargs = self._get_args_and_kwargs(key) 

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

558 

559 case "factory": 

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

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

562 

563 case "int_enum": 

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

565 return load_int_enum(directive_value, content) 

566 

567 case _: 

568 return value 

569 

570 def _get_location(self): 

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

572 try: 

573 filename = self.__dict__["_filename"] 

574 return filename.parent if filename else None 

575 except KeyError: 

576 return None 

577 

578 def _get_args_and_kwargs(self, key): 

579 """ 

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

581 

582 An example of such a directive: 

583 

584 hexapod: 

585 device: class//egse.hexapod.PunaProxy 

586 device_args: [PUNA_01] 

587 device_kwargs: 

588 sim: true 

589 

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

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

592 

593 Returns: 

594 A tuple containing any positional arguments and a dictionary containing 

595 keyword arguments. 

596 """ 

597 try: 

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

599 except AttributeError: 

600 args = () 

601 try: 

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

603 except AttributeError: 

604 kwargs = {} 

605 

606 return args, kwargs 

607 

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

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

610 

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

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

613 

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

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

616 

617 Args: 

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

619 value: the value for this private attribute 

620 

621 Examples: 

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

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

624 >>> assert "c" in setup 

625 >>> assert "_loaded_from_dict" not in setup 

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

627 

628 """ 

629 if key in self: 

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

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

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

633 self.__dict__[key] = value 

634 

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

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

637 

638 Args: 

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

640 

641 Returns: 

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

643 

644 Note: 

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

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

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

648 """ 

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

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

651 try: 

652 return self.__dict__[key] 

653 except KeyError: 

654 return None 

655 

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

657 """ 

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

659 

660 Args: 

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

662 Returns: 

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

664 Raises: 

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

666 """ 

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

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

669 

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

671 

672 try: 

673 _ = self.__dict__[key] 

674 return True 

675 except KeyError: 

676 return False 

677 

678 def get_raw_value(self, key): 

679 """ 

680 Returns the raw value of the given key. 

681 

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

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

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

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

686 """ 

687 try: 

688 return object.__getattribute__(self, key) 

689 except AttributeError: 

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

691 

692 def __str__(self): 

693 return self._pretty_str() 

694 

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

696 msg = "" 

697 

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

699 if isinstance(v, NavigableDict): 

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

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

702 else: 

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

704 

705 return msg 

706 

707 def __rich__(self) -> Tree: 

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

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

710 return tree 

711 

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

713 """ 

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

715 

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

717 

718 Args: 

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

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

721 

722 """ 

723 

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

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

726 # _processed_ value. 

727 

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

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

730 

731 if k == "history": 

732 continue 

733 

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

735 

736 if isinstance(k, str) and ":" in k: 

737 k = '"' + k + '"' 

738 

739 if isinstance(v, NavigableDict): 

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

741 v._save(fd, indent + 1) 

742 fd.flush() 

743 continue 

744 

745 if isinstance(v, float): 

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

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

748 fd.flush() 

749 

750 # now save the history as the last item 

751 

752 if "history" in self: 

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

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

755 

756 def get_memoized_keys(self): 

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

758 

759 def del_memoized_key(self, key: str): 

760 try: 

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

762 return True 

763 except KeyError: 

764 return False 

765 

766 @staticmethod 

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

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

769 

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

771 accessible as attributes. 

772 

773 Args: 

774 my_dict: a Python dictionary 

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

776 

777 Examples: 

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

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

780 

781 """ 

782 return NavigableDict(my_dict, label=label) 

783 

784 @staticmethod 

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

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

787 

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

789 

790 Args: 

791 yaml_content: a string containing YAML 

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

793 

794 Returns: 

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

796 """ 

797 

798 if not yaml_content: 

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

800 

801 yaml = YAML(typ="safe") 

802 try: 

803 data = yaml.load(yaml_content) 

804 except ScannerError as exc: 

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

806 

807 return NavigableDict(data, label=label) 

808 

809 @staticmethod 

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

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

812 

813 Args: 

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

815 

816 Returns: 

817 a navdict that was loaded from the given location. 

818 

819 Raises: 

820 ValueError: when no filename is given. 

821 """ 

822 

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

824 

825 if not filename: 

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

827 

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

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

830 if not filename.is_file(): 

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

832 

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

834 

835 if data == {}: 

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

837 

838 return data 

839 

840 def to_yaml_file(self, filename: str | Path | None = None, header: str = None, top_level_group: str = None) -> None: 

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

842 

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

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

845 

846 Args: 

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

848 header (str): Custom header for this navdict 

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

850 

851 Note: 

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

853 lose proper formatting and/or comments. 

854 

855 """ 

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

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

858 

859 if header is None: 

860 header = textwrap.dedent( 

861 f""" 

862 # This YAML file is generated by: 

863 # 

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

865 # 

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

867 

868 """ 

869 ) 

870 

871 with Path(filename).open("w") as fd: 

872 fd.write(header) 

873 indent = 0 

874 if top_level_group: 

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

876 indent = 1 

877 

878 self._save(fd, indent=indent) 

879 

880 self.set_private_attribute("_filename", Path(filename)) 

881 

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

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

884 return self.get_private_attribute("_filename") 

885 

886 

887navdict = NavigableDict 

888NavDict = NavigableDict 

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

890 

891 

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

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

894 if isinstance(v, dict): 

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

896 _walk_dict_tree(v, branch, text_style=text_style) 

897 else: 

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

899 tree.add(text)