Coverage for /Users/rik/github/navdict/src/navdict/navdict.py: 28%
350 statements
« prev ^ index » next coverage.py v7.8.2, created at 2025-10-16 22:47 +0200
« prev ^ index » next coverage.py v7.8.2, created at 2025-10-16 22:47 +0200
1"""
2NavDict: A navigable dictionary with dot notation access and automatic file loading.
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.
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
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
22Author: Rik Huygen
23License: MIT
24"""
26from __future__ import annotations
28__all__ = [
29 "navdict", # noqa: ignore typo
30 "NavDict",
31 "NavigableDict",
32 "get_resource_location",
33]
35import csv
36import datetime
37import importlib
38import itertools
39import logging
40import os
41import textwrap
42import warnings
43from enum import IntEnum
44from pathlib import Path
45from typing import Any
46from typing import Callable
48from rich.text import Text
49from rich.tree import Tree
50from ruamel.yaml import YAML
51from ruamel.yaml.scanner import ScannerError
53from navdict.directive import is_directive
54from navdict.directive import unravel_directive
55from navdict.directive import get_directive_plugin
57logger = logging.getLogger("navdict")
60def load_class(class_name: str):
61 """
62 Find and returns a class based on the fully qualified name.
64 A class name can be preceded with the string `class//` or `factory//`. This is used in YAML
65 files where the class is then instantiated on load.
67 Args:
68 class_name (str): a fully qualified name for the class
69 """
70 if class_name.startswith("class//"):
71 class_name = class_name[7:]
72 elif class_name.startswith("factory//"):
73 class_name = class_name[9:]
75 module_name, class_name = class_name.rsplit(".", 1)
76 module = importlib.import_module(module_name)
77 return getattr(module, class_name)
80def get_resource_location(parent_location: Path | None, in_dir: str | None) -> Path:
81 """
82 Returns the resource location.
84 The resource location is the path to the file that is provided in a directive
85 such as `yaml//` or `csv//`. The location of the file can be given as an absolute
86 path or can be relative in which case there are two possibilities:
88 1. the parent location is not None.
89 In this case the resource location will be relative to the parent's location.
90 2. the parent location is None.
91 In this case the resource location is taken to be relative to the current working directory '.'
92 unless the environment variable NAVDICT_DEFAULT_RESOURCE_LOCATION is provided in which case
93 it is taken from that variable.
94 3. when both arguments are None, the resource location will be the current working directory '.'
95 unless the environment variable NAVDICT_DEFAULT_RESOURCE_LOCATION is provided in which case
96 it is taken from that variable.
98 Args:
99 parent_location: the location of the parent navdict, or None
100 in_dir: a location extracted from the directive's value.
102 Returns:
103 A Path object with the resource location.
105 """
107 match (parent_location, in_dir):
108 case (_, str()) if Path(in_dir).is_absolute():
109 location = Path(in_dir)
110 case (None, str()):
111 location = Path(os.getenv("NAVDICT_DEFAULT_RESOURCE_LOCATION", ".")) / in_dir
112 case (Path(), str()):
113 location = parent_location / in_dir
114 case (Path(), None):
115 location = parent_location
116 case _:
117 location = Path(os.getenv("NAVDICT_DEFAULT_RESOURCE_LOCATION", "."))
119 # logger.debug(f"{location=}, {fn=}")
121 return location
124def load_csv(resource_name: str, parent_location: Path | None, *args, **kwargs) -> list[list[str]]:
125 """
126 Find and return the content of a CSV file.
128 If the `resource_name` argument starts with the directive (`csv//`), it will be split off automatically.
129 The `kwargs` dictionary can contain the key `header_rows` which indicates the number of header rows to
130 be skipped when processing the file.
132 Returns:
133 A list of the split lines, i.e. a list of lists of strings.
134 """
136 # logger.debug(f"{resource_name=}, {parent_location=}")
138 if resource_name.startswith("csv//"):
139 resource_name = resource_name[5:]
141 if not resource_name:
142 raise ValueError(f"Resource name should not be empty, but contain a valid filename.")
144 parts = resource_name.rsplit("/", 1)
145 in_dir, fn = parts if len(parts) > 1 else (None, parts[0]) # use a tuple here to make Mypy happy
147 try:
148 n_header_rows = int(kwargs["header_rows"])
149 except KeyError:
150 n_header_rows = 0
152 csv_location = get_resource_location(parent_location, in_dir)
154 def filter_lines(file_obj, n_skip):
155 """
156 Generator that filters out comment lines and skips header lines.
157 The standard library csv module cannot handle this functionality.
158 """
160 for line in itertools.islice(file_obj, n_skip, None):
161 if not line.strip().startswith("#"):
162 yield line
164 try:
165 with open(csv_location / fn, "r", encoding="utf-8") as file:
166 filtered_lines = filter_lines(file, n_header_rows)
167 csv_reader = csv.reader(filtered_lines)
168 data = list(csv_reader)
169 except FileNotFoundError:
170 logger.error(f"Couldn't load resource '{resource_name}', file not found", exc_info=True)
171 raise
173 return data
176def load_int_enum(enum_name: str, enum_content) -> IntEnum:
177 """Dynamically build (and return) and IntEnum.
179 In the YAML file this will look like below.
180 The IntEnum directive (where <name> is the class name):
182 enum: int_enum//<name>
184 The IntEnum content:
186 content:
187 E:
188 alias: ['E_SIDE', 'RIGHT_SIDE']
189 value: 1
190 F:
191 alias: ['F_SIDE', 'LEFT_SIDE']
192 value: 0
194 Args:
195 - enum_name: Enumeration name (potentially prepended with "int_enum//").
196 - enum_content: Content of the enumeration, as read from the navdict field.
197 """
198 if enum_name.startswith("int_enum//"):
199 enum_name = enum_name[10:]
201 definition = {}
202 for side_name, side_definition in enum_content.items():
203 if "alias" in side_definition:
204 aliases = side_definition["alias"]
205 else:
206 aliases = []
207 value = side_definition["value"]
209 definition[side_name] = value
211 for alias in aliases:
212 definition[alias] = value
214 return IntEnum(enum_name, definition)
217def load_yaml(resource_name: str, parent_location: Path | None = None, *args, **kwargs) -> NavigableDict:
218 """Find and return the content of a YAML file."""
220 # logger.debug(f"{resource_name=}, {parent_location=}")
222 if resource_name.startswith("yaml//"):
223 resource_name = resource_name[6:]
225 parts = resource_name.rsplit("/", 1)
227 in_dir, fn = parts if len(parts) > 1 else (None, parts[0]) # use a tuple here to make Mypy happy
229 yaml_location = get_resource_location(parent_location, in_dir)
231 try:
232 yaml = YAML(typ="safe")
233 with open(yaml_location / fn, "r") as file:
234 data = yaml.load(file)
236 except FileNotFoundError:
237 logger.error(f"Couldn't load resource '{resource_name}', file not found", exc_info=True)
238 raise
239 except IsADirectoryError:
240 logger.error(
241 f"Couldn't load resource '{resource_name}', file seems to be a directory",
242 exc_info=True,
243 )
244 raise
245 except ScannerError as exc:
246 msg = f"A error occurred while scanning the YAML file: {yaml_location / fn}."
247 logger.error(msg, exc_info=True)
248 raise IOError(msg) from exc
250 data = NavigableDict(data, _filename=yaml_location / fn)
252 # logger.debug(f"{data.get_private_attribute('_filename')=}")
254 return data
257def _get_attribute(self, name, default):
258 """
259 Safely retrieve an attribute from the object, returning a default if not found.
261 This method uses object.__getattribute__() to bypass any custom __getattr__
262 or __getattribute__ implementations on the class, accessing attributes directly
263 from the object's internal dictionary.
265 Args:
266 name (str): The name of the attribute to retrieve.
267 default: The value to return if the attribute does not exist.
269 Returns:
270 The attribute value if it exists, otherwise the default value.
272 Note:
273 This is typically used internally to avoid infinite recursion when
274 implementing custom attribute access methods.
275 """
276 try:
277 attr = object.__getattribute__(self, name)
278 except AttributeError:
279 attr = default
280 return attr
283class NavigableDict(dict):
284 """
285 A NavigableDict is a dictionary where all keys in the original dictionary are also accessible
286 as attributes to the class instance. So, if the original dictionary (setup) has a key
287 "site_id" which is accessible as `setup['site_id']`, it will also be accessible as
288 `setup.site_id`.
290 Args:
291 head (dict): the original dictionary
292 label (str): a label or name that is used when printing the navdict
294 Examples:
295 >>> setup = NavigableDict({'site_id': 'KU Leuven', 'version': "0.1.0"})
296 >>> assert setup['site_id'] == setup.site_id
297 >>> assert setup['version'] == setup.version
299 Note:
300 We always want **all** keys to be accessible as attributes, or none. That means all
301 keys of the original dictionary shall be of type `str`.
303 """
305 def __init__(
306 self,
307 head: dict | None = None,
308 label: str | None = None,
309 _filename: str | Path | None = None,
310 ):
311 head = head or {}
312 super().__init__(head)
313 self.__dict__["_memoized"] = {}
314 self.__dict__["_label"] = label
315 self.__dict__["_filename"] = Path(_filename) if _filename is not None else None
317 # TODO:
318 # if _filename was not given as an argument, we might want to check if the `head` has a `_filename` and do
319 # something like:
320 #
321 # if _filename is None and isinstance(head, navdict):
322 # _filename = head.__dict__["_filename"]
323 # self.__dict__["_filename"] = _filename
325 # By agreement, we only want the keys to be set as attributes if all keys are strings.
326 # That way we enforce that always all keys are navigable, or none.
328 if any(True for k in head.keys() if not isinstance(k, str)): 328 ↛ 331line 328 didn't jump to line 331 because the condition on line 328 was never true
329 # invalid_keys = list(k for k in head.keys() if not isinstance(k, str))
330 # logger.warning(f"Dictionary will not be dot-navigable, not all keys are strings [{invalid_keys=}].")
331 return
333 for key, value in head.items():
334 if isinstance(value, dict):
335 value = NavigableDict(head.__getitem__(key), _filename=_filename)
336 setattr(self, key, value)
337 else:
338 setattr(self, key, super().__getitem__(key))
340 def get_label(self) -> str | None:
341 return self.__dict__["_label"]
343 def set_label(self, value: str):
344 self.__dict__["_label"] = value
346 def add(self, key: str, value: Any):
347 """Set a value for the given key.
349 If the value is a dictionary, it will be converted into a NavigableDict and the keys
350 will become available as attributes provided that all the keys are strings.
352 Args:
353 key (str): the name of the key / attribute to access the value
354 value (Any): the value to assign to the key
355 """
356 if isinstance(value, dict) and not isinstance(value, NavigableDict):
357 value = NavigableDict(value)
358 setattr(self, key, value)
360 def clear(self) -> None:
361 for key in list(self.keys()):
362 self.__delitem__(key)
364 def __repr__(self):
365 return f"{self.__class__.__name__}({super()!r}) [id={id(self)}]"
367 def __delitem__(self, key):
368 dict.__delitem__(self, key)
369 object.__delattr__(self, key)
371 def __setattr__(self, key, value):
372 # logger.info(f"called __setattr__({self!r}, {key}, {value})")
373 if isinstance(value, dict) and not isinstance(value, NavigableDict): 373 ↛ 374line 373 didn't jump to line 374 because the condition on line 373 was never true
374 value = NavigableDict(value)
375 self.__dict__[key] = value
376 super().__setitem__(key, value)
377 try:
378 del self.__dict__["_memoized"][key]
379 except KeyError:
380 pass
382 def __getattr__(self, key):
383 cls = type(self)
384 # logger.info(f"Called __getattr__({key}) ...")
386 try:
387 alias = self._alias_hook(key)
388 return self.__dict__[alias]
389 except NotImplementedError:
390 raise AttributeError(f"{cls.__name__!r} object has no attribute {key!r}")
392 @staticmethod
393 def _alias_hook(key):
394 raise NotImplementedError
396 def set_alias_hook(self, hook: Callable[[str], Any]):
397 setattr(self, "_alias_hook", hook)
399 # This method is called:
400 # - for *every* single attribute access on an object using dot notation.
401 # - when using the `getattr(obj, 'name') function
402 # - accessing any kind of attributes, e.g. instance or class variables,
403 # methods, properties, dunder methods, ...
404 #
405 # Note: `__getattr__` is only called when an attribute cannot be found
406 # through normal means.
407 def __getattribute__(self, key):
408 # logger.info(f"called __getattribute__({key}) ...")
409 try:
410 value = object.__getattribute__(self, key)
411 except AttributeError:
412 raise # this will call self.__getattr__(key)
414 if key.startswith("__"): # small optimization
415 return value
416 # We can not directly call the `_handle_directive` function here due to infinite recursion
417 if is_directive(value): 417 ↛ 418line 417 didn't jump to line 418 because the condition on line 417 was never true
418 m = object.__getattribute__(self, "_handle_directive")
419 return m(key, value)
420 else:
421 return value
423 def __delattr__(self, item):
424 # logger.info(f"called __delattr__({self!r}, {item})")
425 object.__delattr__(self, item)
426 dict.__delitem__(self, item)
428 def __setitem__(self, key, value):
429 # logger.debug(f"called __setitem__({self!r}, {key}, {value})")
430 if isinstance(value, dict) and not isinstance(value, NavigableDict):
431 value = NavigableDict(value)
432 super().__setitem__(key, value)
433 self.__dict__[key] = value
434 try:
435 del self.__dict__["_memoized"][key]
436 except KeyError:
437 pass
439 # This method is called:
440 # - whenever square brackets `[]` are used on an object, e.g. indexing or slicing.
441 # - during iteration, if an object doesn't have __iter__ defined, Python will try
442 # to iterate using __getitem__ with successive integer indices starting from 0.
443 def __getitem__(self, key):
444 # logger.info(f"called __getitem__({self!r}, {key})")
445 try:
446 value = super().__getitem__(key)
447 except KeyError:
448 try:
449 alias = self._alias_hook(key)
450 value = super().__getitem__(alias)
451 except NotImplementedError:
452 raise KeyError(f"{type(self).__name__!r} has no key {key!r}")
454 if isinstance(key, str) and key.startswith("__"): 454 ↛ 455line 454 didn't jump to line 455 because the condition on line 454 was never true
455 return value
456 # no danger for recursion here, so we can directly call the function
457 if is_directive(value): 457 ↛ 458line 457 didn't jump to line 458 because the condition on line 457 was never true
458 return self._handle_directive(key, value)
459 else:
460 return value
462 def _handle_directive(self, key, value) -> Any:
463 """
464 This method will handle the available directives. This may be builtin directives
465 like `class/` or `factory//`, or it may be external directives that were provided
466 as a plugin. Some builtin directives have also been provided as a plugin, e.g.
467 'yaml//' and 'csv//'.
469 Args:
470 key: the key of the field that might contain a directive
471 value: the value which might be a directive
473 Returns:
474 This function will return the value, either the original value or the result of
475 evaluating and executing a directive.
476 """
477 # logger.debug(f"called _handle_directive({key}, {value!r}) [id={id(self)}]")
479 directive_key, directive_value = unravel_directive(value)
480 # logger.debug(f"{directive_key=}, {directive_value=}")
482 if directive := get_directive_plugin(directive_key):
483 # logger.debug(f"{directive.name=}")
485 if key in self.__dict__["_memoized"]:
486 return self.__dict__["_memoized"][key]
488 args, kwargs = self._get_args_and_kwargs(key)
489 parent_location = self._get_location()
490 result = directive.func(directive_value, parent_location, *args, **kwargs)
492 self.__dict__["_memoized"][key] = result
493 return result
495 match directive_key:
496 case "class":
497 args, kwargs = self._get_args_and_kwargs(key)
498 return load_class(directive_value)(*args, **kwargs)
500 case "factory":
501 factory_args = _get_attribute(self, f"{key}_args", {})
502 return load_class(directive_value)().create(**factory_args)
504 case "int_enum":
505 content = object.__getattribute__(self, "content")
506 return load_int_enum(directive_value, content)
508 case _:
509 return value
511 def _get_location(self):
512 """Returns the location of the file from which this NavDict was loaded or None if no location exists."""
513 try:
514 filename = self.__dict__["_filename"]
515 return filename.parent if filename else None
516 except KeyError:
517 return None
519 def _get_args_and_kwargs(self, key):
520 """
521 Read the args and kwargs that are associated with the key of a directive.
523 An example of such a directive:
525 hexapod:
526 device: class//egse.hexapod.PunaProxy
527 device_args: [PUNA_01]
528 device_kwargs:
529 sim: true
531 There might not be any positional nor keyword arguments provided in which
532 case and empty tuple and/or dictionary is returned.
534 Returns:
535 A tuple containing any positional arguments and a dictionary containing
536 keyword arguments.
537 """
538 try:
539 args = object.__getattribute__(self, f"{key}_args")
540 except AttributeError:
541 args = ()
542 try:
543 kwargs = object.__getattribute__(self, f"{key}_kwargs")
544 except AttributeError:
545 kwargs = {}
547 return args, kwargs
549 def set_private_attribute(self, key: str, value: Any) -> None:
550 """Sets a private attribute for this object.
552 The name in key will be accessible as an attribute for this object, but the key will not
553 be added to the dictionary and not be returned by methods like keys().
555 The idea behind this private attribute is to have the possibility to add status information
556 or identifiers to this classes object that can be used by save() or load() methods.
558 Args:
559 key (str): the name of the private attribute (must start with an underscore character).
560 value: the value for this private attribute
562 Examples:
563 >>> setup = NavigableDict({'a': 1, 'b': 2, 'c': 3})
564 >>> setup.set_private_attribute("_loaded_from_dict", True)
565 >>> assert "c" in setup
566 >>> assert "_loaded_from_dict" not in setup
567 >>> assert setup.get_private_attribute("_loaded_from_dict") == True
569 """
570 if key in self:
571 raise ValueError(f"Invalid argument key='{key}', this key already exists in the dictionary.")
572 if not key.startswith("_"):
573 raise ValueError(f"Invalid argument key='{key}', must start with underscore character '_'.")
574 self.__dict__[key] = value
576 def get_private_attribute(self, key: str) -> Any:
577 """Returns the value of the given private attribute.
579 Args:
580 key (str): the name of the private attribute (must start with an underscore character).
582 Returns:
583 the value of the private attribute given in `key` or None if the attribute doesn't exist.
585 Note:
586 Because of the implementation, this private attribute can also be accessed as a 'normal'
587 attribute of the object. This use is however discouraged as it will make your code less
588 understandable. Use the methods to access these 'private' attributes.
589 """
590 if not key.startswith("_"):
591 raise ValueError(f"Invalid argument key='{key}', must start with underscore character '_'.")
592 try:
593 return self.__dict__[key]
594 except KeyError:
595 return None
597 def has_private_attribute(self, key) -> bool:
598 """
599 Check if the given key is defined as a private attribute.
601 Args:
602 key (str): the name of a private attribute (must start with an underscore)
603 Returns:
604 True if the given key is a known private attribute.
605 Raises:
606 ValueError: when the key doesn't start with an underscore.
607 """
608 if not key.startswith("_"):
609 raise ValueError(f"Invalid argument key='{key}', must start with underscore character '_'.")
611 # logger.debug(f"{self.__dict__.keys()} for [id={id(self)}]")
613 try:
614 _ = self.__dict__[key]
615 return True
616 except KeyError:
617 return False
619 def get_raw_value(self, key):
620 """
621 Returns the raw value of the given key.
623 Some keys have special values that are interpreted by the NavigableDict class. An example is
624 a value that starts with 'class//'. When you access these values, they are first converted
625 from their raw value into their expected value, e.g. the instantiated object in the above
626 example. This method allows you to access the raw value before conversion.
627 """
628 try:
629 return object.__getattribute__(self, key)
630 except AttributeError:
631 raise KeyError(f"The key '{key}' is not defined.")
633 def __str__(self):
634 return self._pretty_str()
636 def _pretty_str(self, indent: int = 0):
637 msg = ""
639 for k, v in self.items():
640 if isinstance(v, NavigableDict):
641 msg += f"{' ' * indent}{k}:\n"
642 msg += v._pretty_str(indent + 1)
643 else:
644 msg += f"{' ' * indent}{k}: {v}\n"
646 return msg
648 def __rich__(self) -> Tree:
649 tree = Tree(self.__dict__["_label"] or "NavigableDict", guide_style="dim")
650 _walk_dict_tree(self, tree, text_style="dark grey")
651 return tree
653 def _save(self, fd, indent: int = 0):
654 """
655 Recursive method to write the dictionary to the file descriptor.
657 Indentation is done in steps of four spaces, i.e. `' '*indent`.
659 Args:
660 fd: a file descriptor as returned by the open() function
661 indent (int): indentation level of each line [default = 0]
663 """
665 # Note that the .items() method returns the actual values of the keys and doesn't use the
666 # __getattribute__ or __getitem__ methods. So the raw value is returned and not the
667 # _processed_ value.
669 for k, v in self.items():
670 # history shall be saved last, skip it for now
672 if k == "history":
673 continue
675 # make sure to escape a colon in the key name
677 if isinstance(k, str) and ":" in k:
678 k = '"' + k + '"'
680 if isinstance(v, NavigableDict):
681 fd.write(f"{' ' * indent}{k}:\n")
682 v._save(fd, indent + 1)
683 fd.flush()
684 continue
686 if isinstance(v, float):
687 v = f"{v:.6E}"
688 fd.write(f"{' ' * indent}{k}: {v}\n")
689 fd.flush()
691 # now save the history as the last item
693 if "history" in self:
694 fd.write(f"{' ' * indent}history:\n")
695 self.history._save(fd, indent + 1) # noqa
697 def get_memoized_keys(self):
698 return list(self.__dict__["_memoized"].keys())
700 def del_memoized_key(self, key: str):
701 try:
702 del self.__dict__["_memoized"][key]
703 return True
704 except KeyError:
705 return False
707 @staticmethod
708 def from_dict(my_dict: dict, label: str | None = None) -> NavigableDict:
709 """Create a NavigableDict from a given dictionary.
711 Remember that all keys in the given dictionary shall be of type 'str' in order to be
712 accessible as attributes.
714 Args:
715 my_dict: a Python dictionary
716 label: a label that will be attached to this navdict
718 Examples:
719 >>> setup = navdict.from_dict({"ID": "my-setup-001", "version": "0.1.0"}, label="Setup")
720 >>> assert setup["ID"] == setup.ID == "my-setup-001"
722 """
723 return NavigableDict(my_dict, label=label)
725 @staticmethod
726 def from_yaml_string(yaml_content: str | None = None, label: str | None = None) -> NavigableDict:
727 """Creates a NavigableDict from the given YAML string.
729 This method is mainly used for easy creation of a navdict from strings during unit tests.
731 Args:
732 yaml_content: a string containing YAML
733 label: a label that will be attached to this navdict
735 Returns:
736 a navdict that was loaded from the content of the given string.
737 """
739 if not yaml_content:
740 raise ValueError("Invalid argument to function: No input string or None given.")
742 yaml = YAML(typ="safe")
743 try:
744 data = yaml.load(yaml_content)
745 except ScannerError as exc:
746 raise ValueError(f"Invalid YAML string: {exc}")
748 return NavigableDict(data, label=label)
750 @staticmethod
751 def from_yaml_file(filename: str | Path | None = None) -> NavigableDict:
752 """Creates a navigable dictionary from the given YAML file.
754 Args:
755 filename (str): the path of the YAML file to be loaded
757 Returns:
758 a navdict that was loaded from the given location.
760 Raises:
761 ValueError: when no filename is given.
762 """
764 # logger.debug(f"{filename=}")
766 if not filename:
767 raise ValueError("Invalid argument to function: No filename or None given.")
769 # Make sure the filename exists and is a regular file
770 filename = Path(filename).expanduser().resolve()
771 if not filename.is_file():
772 raise ValueError(f"Invalid argument to function, filename does not exist: {filename!s}")
774 data = load_yaml(str(filename))
776 if data == {}:
777 warnings.warn(f"Empty YAML file: {filename!s}")
779 return data
781 def to_yaml_file(self, filename: str | Path | None = None, header: str = None, top_level_group: str = None) -> None:
782 """Saves a NavigableDict to a YAML file.
784 When no filename is provided, this method will look for a 'private' attribute
785 `_filename` and use that to save the data.
787 Args:
788 filename (str|Path): the path of the YAML file where to save the data
789 header (str): Custom header for this navdict
790 top_level_group (str): name of the optional top-level group
792 Note:
793 This method will **overwrite** the original or given YAML file and therefore you might
794 lose proper formatting and/or comments.
796 """
797 if filename is None and self.get_private_attribute("_filename") is None:
798 raise ValueError("No filename given or known, can not save navdict.")
800 if header is None:
801 header = textwrap.dedent(
802 f"""
803 # This YAML file is generated by:
804 #
805 # navdict.to_yaml_file(setup, filename="{filename}')
806 #
807 # Created on {datetime.datetime.now(tz=datetime.timezone.utc).isoformat()}
809 """
810 )
812 with Path(filename).open("w") as fd:
813 fd.write(header)
814 indent = 0
815 if top_level_group:
816 fd.write(f"{top_level_group}:\n")
817 indent = 1
819 self._save(fd, indent=indent)
821 self.set_private_attribute("_filename", Path(filename))
823 def get_filename(self) -> str | None:
824 """Returns the filename for this navdict or None when no filename could be determined."""
825 return self.get_private_attribute("_filename")
828navdict = NavigableDict
829NavDict = NavigableDict
830"""Shortcuts for NavigableDict and more Pythonic."""
833def _walk_dict_tree(dictionary: dict, tree: Tree, text_style: str = "green"):
834 for k, v in dictionary.items():
835 if isinstance(v, dict):
836 branch = tree.add(f"[purple]{k}", style="", guide_style="dim")
837 _walk_dict_tree(v, branch, text_style=text_style)
838 else:
839 text = Text.assemble((str(k), "medium_purple1"), ": ", (str(v), text_style))
840 tree.add(text)