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

365 statements  

« prev     ^ index     » next       coverage.py v7.8.2, created at 2026-07-10 23:05 +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] 

34 

35import csv 

36import datetime 

37import importlib 

38import itertools 

39import logging 

40import os 

41import re 

42import textwrap 

43import warnings 

44from enum import IntEnum 

45from pathlib import Path 

46from typing import Any 

47from typing import Callable 

48 

49from rich.text import Text 

50from rich.tree import Tree 

51from ruamel.yaml import YAML 

52from ruamel.yaml.scanner import ScannerError 

53 

54from navdict.directive import is_directive 

55from navdict.directive import unravel_directive 

56from navdict.directive import get_directive_plugin 

57 

58logger = logging.getLogger("navdict") 

59 

60 

61def load_class(class_name: str): 

62 """ 

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

64 

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

66 files where the class is then instantiated on load. 

67 

68 Args: 

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

70 """ 

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

72 class_name = class_name[7:] 

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

74 class_name = class_name[9:] 

75 

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

77 module = importlib.import_module(module_name) 

78 return getattr(module, class_name) 

79 

80 

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

82 """ 

83 Returns the resource location. 

84 

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

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

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

88 

89 1. the parent location is not None. 

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

91 2. the parent location is None. 

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

93 unless the environment variable NAVDICT_DEFAULT_RESOURCE_LOCATION is provided in which case 

94 it is taken from that variable. 

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

96 unless the environment variable NAVDICT_DEFAULT_RESOURCE_LOCATION is provided in which case 

97 it is taken from that variable. 

98 

99 Args: 

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

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

102 

103 Returns: 

104 A Path object with the resource location. 

105 

106 """ 

107 

108 match (parent_location, in_dir): 

109 case (_, str()) if Path(in_dir).is_absolute(): 

110 location = Path(in_dir) 

111 case (None, str()): 

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

113 case (Path(), str()): 

114 location = parent_location / in_dir 

115 case (Path(), None): 

116 location = parent_location 

117 case _: 

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

119 

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

121 

122 return location 

123 

124 

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

126 

127 

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

129 """ 

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

131 variable, then apply `~` (user) expansion to the result. 

132 

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

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

135 

136 Args: 

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

138 

139 Returns: 

140 The string with all `ENV[VARNAME]` references expanded and `~` expanded to the user's 

141 home directory. 

142 

143 Raises: 

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

145 """ 

146 

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

148 var_name = match[1] 

149 try: 

150 return os.environ[var_name] 

151 except KeyError: 

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

153 

154 return str(Path(ENV_VAR_PATTERN.sub(_replace, value)).expanduser()) 

155 

156 

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

158 """ 

159 Find and return the content of a CSV file. 

160 

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

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

163 

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

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

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

167 to disable this expansion. 

168 

169 Returns: 

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

171 """ 

172 

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

174 

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

176 resource_name = resource_name[5:] 

177 

178 if not resource_name: 

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

180 

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

182 resource_name = expand_env_vars(resource_name) 

183 

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

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

186 

187 try: 

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

189 except KeyError: 

190 n_header_rows = 0 

191 

192 csv_location = get_resource_location(parent_location, in_dir) 

193 

194 def filter_lines(file_obj, n_skip): 

195 """ 

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

197 The standard library csv module cannot handle this functionality. 

198 """ 

199 

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

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

202 yield line 

203 

204 try: 

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

206 filtered_lines = filter_lines(file, n_header_rows) 

207 csv_reader = csv.reader(filtered_lines) 

208 data = list(csv_reader) 

209 except FileNotFoundError: 

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

211 raise 

212 

213 return data 

214 

215 

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

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

218 

219 In the YAML file this will look like below. 

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

221 

222 enum: int_enum//<name> 

223 

224 The IntEnum content: 

225 

226 content: 

227 E: 

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

229 value: 1 

230 F: 

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

232 value: 0 

233 

234 Args: 

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

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

237 """ 

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

239 enum_name = enum_name[10:] 

240 

241 definition = {} 

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

243 if "alias" in side_definition: 

244 aliases = side_definition["alias"] 

245 else: 

246 aliases = [] 

247 value = side_definition["value"] 

248 

249 definition[side_name] = value 

250 

251 for alias in aliases: 

252 definition[alias] = value 

253 

254 return IntEnum(enum_name, definition) 

255 

256 

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

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

259 

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

261 

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

263 resource_name = resource_name[6:] 

264 

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

266 

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

268 

269 yaml_location = get_resource_location(parent_location, in_dir) 

270 

271 try: 

272 yaml = YAML(typ="safe") 

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

274 data = yaml.load(file) 

275 

276 except FileNotFoundError: 

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

278 raise 

279 except IsADirectoryError: 

280 logger.error( 

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

282 exc_info=True, 

283 ) 

284 raise 

285 except ScannerError as exc: 

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

287 logger.error(msg, exc_info=True) 

288 raise IOError(msg) from exc 

289 

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

291 

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

293 

294 return data 

295 

296 

297def _get_attribute(self, name, default): 

298 """ 

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

300 

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

302 or __getattribute__ implementations on the class, accessing attributes directly 

303 from the object's internal dictionary. 

304 

305 Args: 

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

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

308 

309 Returns: 

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

311 

312 Note: 

313 This is typically used internally to avoid infinite recursion when 

314 implementing custom attribute access methods. 

315 """ 

316 try: 

317 attr = object.__getattribute__(self, name) 

318 except AttributeError: 

319 attr = default 

320 return attr 

321 

322 

323class NavigableDict(dict): 

324 """ 

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

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

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

328 `setup.site_id`. 

329 

330 Args: 

331 head (dict): the original dictionary 

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

333 

334 Examples: 

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

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

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

338 

339 Note: 

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

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

342 

343 """ 

344 

345 def __init__( 

346 self, 

347 head: dict | None = None, 

348 label: str | None = None, 

349 _filename: str | Path | None = None, 

350 ): 

351 head = head or {} 

352 super().__init__(head) 

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

354 self.__dict__["_label"] = label 

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

356 

357 # TODO: 

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

359 # something like: 

360 # 

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

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

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

364 

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

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

367 

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

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

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

371 return 

372 

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

374 if isinstance(value, dict): 

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

376 setattr(self, key, value) 

377 else: 

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

379 

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

381 return self.__dict__["_label"] 

382 

383 def set_label(self, value: str): 

384 self.__dict__["_label"] = value 

385 

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

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

388 

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

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

391 

392 Args: 

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

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

395 """ 

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

397 value = NavigableDict(value, label=key) 

398 setattr(self, key, value) 

399 

400 def clear(self) -> None: 

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

402 self.__delitem__(key) 

403 

404 def __repr__(self): 

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

406 

407 def __delitem__(self, key): 

408 dict.__delitem__(self, key) 

409 object.__delattr__(self, key) 

410 

411 def __setattr__(self, key, value): 

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

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

414 value = NavigableDict(value, label=key) 

415 self.__dict__[key] = value 

416 super().__setitem__(key, value) 

417 try: 

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

419 except KeyError: 

420 pass 

421 

422 @staticmethod 

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

424 raise NotImplementedError 

425 

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

427 """ 

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

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

430 

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

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

433 be a valid attribute or key for this navdict. 

434 """ 

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

436 # which is not what we want. 

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

438 self.__dict__["_alias_hook"] = hook 

439 

440 # This method is called: 

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

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

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

444 # methods, properties, dunder methods, ... 

445 # 

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

447 # through normal means. 

448 def __getattribute__(self, key): 

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

450 try: 

451 value = object.__getattribute__(self, key) 

452 except AttributeError: 

453 try: 

454 alias = self._alias_hook(key) 

455 value = object.__getattribute__(self, alias) 

456 except NotImplementedError: 

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

458 except Exception as exc: 

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

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

461 

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

463 return value 

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

465 if is_directive(value): 

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

467 return m(key, value) 

468 else: 

469 return value 

470 

471 def __delattr__(self, item): 

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

473 object.__delattr__(self, item) 

474 dict.__delitem__(self, item) 

475 

476 def __setitem__(self, key, value): 

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

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

479 value = NavigableDict(value, label=key) 

480 super().__setitem__(key, value) 

481 self.__dict__[key] = value 

482 try: 

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

484 except KeyError: 

485 pass 

486 

487 # This method is called: 

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

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

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

491 def __getitem__(self, key): 

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

493 try: 

494 value = super().__getitem__(key) 

495 except KeyError: 

496 try: 

497 alias = self._alias_hook(key) 

498 value = super().__getitem__(alias) 

499 except NotImplementedError: 

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

501 except Exception as exc: 

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

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

504 

505 

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

507 return value 

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

509 if is_directive(value): 

510 return self._handle_directive(key, value) 

511 else: 

512 return value 

513 

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

515 """ 

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

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

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

519 'yaml//' and 'csv//'. 

520 

521 Args: 

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

523 value: the value which might be a directive 

524 

525 Returns: 

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

527 evaluating and executing a directive. 

528 """ 

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

530 

531 directive_key, directive_value = unravel_directive(value) 

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

533 

534 if directive := get_directive_plugin(directive_key): 

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

536 

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

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

539 

540 args, kwargs = self._get_args_and_kwargs(key) 

541 parent_location = self._get_location() 

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

543 

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

545 return result 

546 

547 match directive_key: 

548 case "class": 

549 args, kwargs = self._get_args_and_kwargs(key) 

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

551 

552 case "factory": 

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

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

555 

556 case "int_enum": 

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

558 return load_int_enum(directive_value, content) 

559 

560 case _: 

561 return value 

562 

563 def _get_location(self): 

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

565 try: 

566 filename = self.__dict__["_filename"] 

567 return filename.parent if filename else None 

568 except KeyError: 

569 return None 

570 

571 def _get_args_and_kwargs(self, key): 

572 """ 

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

574 

575 An example of such a directive: 

576 

577 hexapod: 

578 device: class//egse.hexapod.PunaProxy 

579 device_args: [PUNA_01] 

580 device_kwargs: 

581 sim: true 

582 

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

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

585 

586 Returns: 

587 A tuple containing any positional arguments and a dictionary containing 

588 keyword arguments. 

589 """ 

590 try: 

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

592 except AttributeError: 

593 args = () 

594 try: 

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

596 except AttributeError: 

597 kwargs = {} 

598 

599 return args, kwargs 

600 

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

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

603 

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

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

606 

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

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

609 

610 Args: 

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

612 value: the value for this private attribute 

613 

614 Examples: 

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

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

617 >>> assert "c" in setup 

618 >>> assert "_loaded_from_dict" not in setup 

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

620 

621 """ 

622 if key in self: 

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

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

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

626 self.__dict__[key] = value 

627 

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

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

630 

631 Args: 

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

633 

634 Returns: 

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

636 

637 Note: 

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

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

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

641 """ 

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

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

644 try: 

645 return self.__dict__[key] 

646 except KeyError: 

647 return None 

648 

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

650 """ 

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

652 

653 Args: 

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

655 Returns: 

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

657 Raises: 

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

659 """ 

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

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

662 

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

664 

665 try: 

666 _ = self.__dict__[key] 

667 return True 

668 except KeyError: 

669 return False 

670 

671 def get_raw_value(self, key): 

672 """ 

673 Returns the raw value of the given key. 

674 

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

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

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

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

679 """ 

680 try: 

681 return object.__getattribute__(self, key) 

682 except AttributeError: 

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

684 

685 def __str__(self): 

686 return self._pretty_str() 

687 

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

689 msg = "" 

690 

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

692 if isinstance(v, NavigableDict): 

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

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

695 else: 

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

697 

698 return msg 

699 

700 def __rich__(self) -> Tree: 

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

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

703 return tree 

704 

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

706 """ 

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

708 

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

710 

711 Args: 

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

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

714 

715 """ 

716 

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

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

719 # _processed_ value. 

720 

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

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

723 

724 if k == "history": 

725 continue 

726 

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

728 

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

730 k = '"' + k + '"' 

731 

732 if isinstance(v, NavigableDict): 

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

734 v._save(fd, indent + 1) 

735 fd.flush() 

736 continue 

737 

738 if isinstance(v, float): 

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

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

741 fd.flush() 

742 

743 # now save the history as the last item 

744 

745 if "history" in self: 

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

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

748 

749 def get_memoized_keys(self): 

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

751 

752 def del_memoized_key(self, key: str): 

753 try: 

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

755 return True 

756 except KeyError: 

757 return False 

758 

759 @staticmethod 

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

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

762 

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

764 accessible as attributes. 

765 

766 Args: 

767 my_dict: a Python dictionary 

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

769 

770 Examples: 

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

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

773 

774 """ 

775 return NavigableDict(my_dict, label=label) 

776 

777 @staticmethod 

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

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

780 

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

782 

783 Args: 

784 yaml_content: a string containing YAML 

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

786 

787 Returns: 

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

789 """ 

790 

791 if not yaml_content: 

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

793 

794 yaml = YAML(typ="safe") 

795 try: 

796 data = yaml.load(yaml_content) 

797 except ScannerError as exc: 

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

799 

800 return NavigableDict(data, label=label) 

801 

802 @staticmethod 

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

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

805 

806 Args: 

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

808 

809 Returns: 

810 a navdict that was loaded from the given location. 

811 

812 Raises: 

813 ValueError: when no filename is given. 

814 """ 

815 

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

817 

818 if not filename: 

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

820 

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

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

823 if not filename.is_file(): 

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

825 

826 data = load_yaml(str(filename)) 

827 

828 if data == {}: 

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

830 

831 return data 

832 

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

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

835 

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

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

838 

839 Args: 

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

841 header (str): Custom header for this navdict 

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

843 

844 Note: 

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

846 lose proper formatting and/or comments. 

847 

848 """ 

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

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

851 

852 if header is None: 

853 header = textwrap.dedent( 

854 f""" 

855 # This YAML file is generated by: 

856 # 

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

858 # 

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

860 

861 """ 

862 ) 

863 

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

865 fd.write(header) 

866 indent = 0 

867 if top_level_group: 

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

869 indent = 1 

870 

871 self._save(fd, indent=indent) 

872 

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

874 

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

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

877 return self.get_private_attribute("_filename") 

878 

879 

880navdict = NavigableDict 

881NavDict = NavigableDict 

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

883 

884 

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

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

887 if isinstance(v, dict): 

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

889 _walk_dict_tree(v, branch, text_style=text_style) 

890 else: 

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

892 tree.add(text)