Coverage for /Users/rik/github/navdict/src/navdict/navdict.py: 27%
353 statements
« prev ^ index » next coverage.py v7.8.2, created at 2025-10-17 09:34 +0200
« prev ^ index » next coverage.py v7.8.2, created at 2025-10-17 09:34 +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 @staticmethod
383 def _alias_hook(key: str) -> str:
384 raise NotImplementedError
386 def set_alias_hook(self, hook: Callable[[str], str]):
387 """
388 Sets an alias (hook) function that maps the given argument, an attribute
389 or a dict key, to a valid attribute or key.
391 The `hook` function accepts a string argument and return a string for
392 which the argument is an alias. The returned argument is expected to
393 be a valid attribute or key for this navdict.
394 """
395 # The setattr() function will add the attribute to the dictionary
396 # which is not what we want.
397 # setattr(self, "_alias_hook", hook)
398 self.__dict__["_alias_hook"] = hook
400 # This method is called:
401 # - for *every* single attribute access on an object using dot notation.
402 # - when using the `getattr(obj, 'name') function
403 # - accessing any kind of attributes, e.g. instance or class variables,
404 # methods, properties, dunder methods, ...
405 #
406 # Note: `__getattr__` is only called when an attribute cannot be found
407 # through normal means.
408 def __getattribute__(self, key):
409 # logger.info(f"called __getattribute__({key}) ...")
410 try:
411 value = object.__getattribute__(self, key)
412 except AttributeError:
413 try:
414 alias = self._alias_hook(key)
415 value = object.__getattribute__(self, alias)
416 except NotImplementedError:
417 raise AttributeError(f"{type(self).__name__!r} object has no attribute {key!r}")
418 except Exception as exc:
419 logger.error(f"Alias hook function raised {type(exc).__name__!r}: {exc}")
420 raise AttributeError(f"{type(self).__name__!r} object has no attribute {key!r}")
422 if key.startswith("__"): # small optimization
423 return value
424 # We can not directly call the `_handle_directive` function here due to infinite recursion
425 if is_directive(value): 425 ↛ 426line 425 didn't jump to line 426 because the condition on line 425 was never true
426 m = object.__getattribute__(self, "_handle_directive")
427 return m(key, value)
428 else:
429 return value
431 def __delattr__(self, item):
432 # logger.info(f"called __delattr__({self!r}, {item})")
433 object.__delattr__(self, item)
434 dict.__delitem__(self, item)
436 def __setitem__(self, key, value):
437 # logger.debug(f"called __setitem__({self!r}, {key}, {value})")
438 if isinstance(value, dict) and not isinstance(value, NavigableDict):
439 value = NavigableDict(value)
440 super().__setitem__(key, value)
441 self.__dict__[key] = value
442 try:
443 del self.__dict__["_memoized"][key]
444 except KeyError:
445 pass
447 # This method is called:
448 # - whenever square brackets `[]` are used on an object, e.g. indexing or slicing.
449 # - during iteration, if an object doesn't have __iter__ defined, Python will try
450 # to iterate using __getitem__ with successive integer indices starting from 0.
451 def __getitem__(self, key):
452 # logger.info(f"called __getitem__({self!r}, {key})")
453 try:
454 value = super().__getitem__(key)
455 except KeyError:
456 try:
457 alias = self._alias_hook(key)
458 value = super().__getitem__(alias)
459 except NotImplementedError:
460 raise KeyError(f"{type(self).__name__!r} has no key {key!r}")
461 except Exception as exc:
462 logger.error(f"Alias hook function raised {type(exc).__name__!r}: {exc}")
463 raise KeyError(f"{type(self).__name__!r} has no key {key!r}")
466 if isinstance(key, str) and key.startswith("__"):
467 return value
468 # no danger for recursion here, so we can directly call the function
469 if is_directive(value):
470 return self._handle_directive(key, value)
471 else:
472 return value
474 def _handle_directive(self, key, value) -> Any:
475 """
476 This method will handle the available directives. This may be builtin directives
477 like `class/` or `factory//`, or it may be external directives that were provided
478 as a plugin. Some builtin directives have also been provided as a plugin, e.g.
479 'yaml//' and 'csv//'.
481 Args:
482 key: the key of the field that might contain a directive
483 value: the value which might be a directive
485 Returns:
486 This function will return the value, either the original value or the result of
487 evaluating and executing a directive.
488 """
489 # logger.debug(f"called _handle_directive({key}, {value!r}) [id={id(self)}]")
491 directive_key, directive_value = unravel_directive(value)
492 # logger.debug(f"{directive_key=}, {directive_value=}")
494 if directive := get_directive_plugin(directive_key):
495 # logger.debug(f"{directive.name=}")
497 if key in self.__dict__["_memoized"]:
498 return self.__dict__["_memoized"][key]
500 args, kwargs = self._get_args_and_kwargs(key)
501 parent_location = self._get_location()
502 result = directive.func(directive_value, parent_location, *args, **kwargs)
504 self.__dict__["_memoized"][key] = result
505 return result
507 match directive_key:
508 case "class":
509 args, kwargs = self._get_args_and_kwargs(key)
510 return load_class(directive_value)(*args, **kwargs)
512 case "factory":
513 factory_args = _get_attribute(self, f"{key}_args", {})
514 return load_class(directive_value)().create(**factory_args)
516 case "int_enum":
517 content = object.__getattribute__(self, "content")
518 return load_int_enum(directive_value, content)
520 case _:
521 return value
523 def _get_location(self):
524 """Returns the location of the file from which this NavDict was loaded or None if no location exists."""
525 try:
526 filename = self.__dict__["_filename"]
527 return filename.parent if filename else None
528 except KeyError:
529 return None
531 def _get_args_and_kwargs(self, key):
532 """
533 Read the args and kwargs that are associated with the key of a directive.
535 An example of such a directive:
537 hexapod:
538 device: class//egse.hexapod.PunaProxy
539 device_args: [PUNA_01]
540 device_kwargs:
541 sim: true
543 There might not be any positional nor keyword arguments provided in which
544 case and empty tuple and/or dictionary is returned.
546 Returns:
547 A tuple containing any positional arguments and a dictionary containing
548 keyword arguments.
549 """
550 try:
551 args = object.__getattribute__(self, f"{key}_args")
552 except AttributeError:
553 args = ()
554 try:
555 kwargs = object.__getattribute__(self, f"{key}_kwargs")
556 except AttributeError:
557 kwargs = {}
559 return args, kwargs
561 def set_private_attribute(self, key: str, value: Any) -> None:
562 """Sets a private attribute for this object.
564 The name in key will be accessible as an attribute for this object, but the key will not
565 be added to the dictionary and not be returned by methods like keys().
567 The idea behind this private attribute is to have the possibility to add status information
568 or identifiers to this classes object that can be used by save() or load() methods.
570 Args:
571 key (str): the name of the private attribute (must start with an underscore character).
572 value: the value for this private attribute
574 Examples:
575 >>> setup = NavigableDict({'a': 1, 'b': 2, 'c': 3})
576 >>> setup.set_private_attribute("_loaded_from_dict", True)
577 >>> assert "c" in setup
578 >>> assert "_loaded_from_dict" not in setup
579 >>> assert setup.get_private_attribute("_loaded_from_dict") == True
581 """
582 if key in self:
583 raise ValueError(f"Invalid argument key='{key}', this key already exists in the dictionary.")
584 if not key.startswith("_"):
585 raise ValueError(f"Invalid argument key='{key}', must start with underscore character '_'.")
586 self.__dict__[key] = value
588 def get_private_attribute(self, key: str) -> Any:
589 """Returns the value of the given private attribute.
591 Args:
592 key (str): the name of the private attribute (must start with an underscore character).
594 Returns:
595 the value of the private attribute given in `key` or None if the attribute doesn't exist.
597 Note:
598 Because of the implementation, this private attribute can also be accessed as a 'normal'
599 attribute of the object. This use is however discouraged as it will make your code less
600 understandable. Use the methods to access these 'private' attributes.
601 """
602 if not key.startswith("_"):
603 raise ValueError(f"Invalid argument key='{key}', must start with underscore character '_'.")
604 try:
605 return self.__dict__[key]
606 except KeyError:
607 return None
609 def has_private_attribute(self, key) -> bool:
610 """
611 Check if the given key is defined as a private attribute.
613 Args:
614 key (str): the name of a private attribute (must start with an underscore)
615 Returns:
616 True if the given key is a known private attribute.
617 Raises:
618 ValueError: when the key doesn't start with an underscore.
619 """
620 if not key.startswith("_"):
621 raise ValueError(f"Invalid argument key='{key}', must start with underscore character '_'.")
623 # logger.debug(f"{self.__dict__.keys()} for [id={id(self)}]")
625 try:
626 _ = self.__dict__[key]
627 return True
628 except KeyError:
629 return False
631 def get_raw_value(self, key):
632 """
633 Returns the raw value of the given key.
635 Some keys have special values that are interpreted by the NavigableDict class. An example is
636 a value that starts with 'class//'. When you access these values, they are first converted
637 from their raw value into their expected value, e.g. the instantiated object in the above
638 example. This method allows you to access the raw value before conversion.
639 """
640 try:
641 return object.__getattribute__(self, key)
642 except AttributeError:
643 raise KeyError(f"The key '{key}' is not defined.")
645 def __str__(self):
646 return self._pretty_str()
648 def _pretty_str(self, indent: int = 0):
649 msg = ""
651 for k, v in self.items():
652 if isinstance(v, NavigableDict):
653 msg += f"{' ' * indent}{k}:\n"
654 msg += v._pretty_str(indent + 1)
655 else:
656 msg += f"{' ' * indent}{k}: {v}\n"
658 return msg
660 def __rich__(self) -> Tree:
661 tree = Tree(self.__dict__["_label"] or "NavigableDict", guide_style="dim")
662 _walk_dict_tree(self, tree, text_style="dark grey")
663 return tree
665 def _save(self, fd, indent: int = 0):
666 """
667 Recursive method to write the dictionary to the file descriptor.
669 Indentation is done in steps of four spaces, i.e. `' '*indent`.
671 Args:
672 fd: a file descriptor as returned by the open() function
673 indent (int): indentation level of each line [default = 0]
675 """
677 # Note that the .items() method returns the actual values of the keys and doesn't use the
678 # __getattribute__ or __getitem__ methods. So the raw value is returned and not the
679 # _processed_ value.
681 for k, v in self.items():
682 # history shall be saved last, skip it for now
684 if k == "history":
685 continue
687 # make sure to escape a colon in the key name
689 if isinstance(k, str) and ":" in k:
690 k = '"' + k + '"'
692 if isinstance(v, NavigableDict):
693 fd.write(f"{' ' * indent}{k}:\n")
694 v._save(fd, indent + 1)
695 fd.flush()
696 continue
698 if isinstance(v, float):
699 v = f"{v:.6E}"
700 fd.write(f"{' ' * indent}{k}: {v}\n")
701 fd.flush()
703 # now save the history as the last item
705 if "history" in self:
706 fd.write(f"{' ' * indent}history:\n")
707 self.history._save(fd, indent + 1) # noqa
709 def get_memoized_keys(self):
710 return list(self.__dict__["_memoized"].keys())
712 def del_memoized_key(self, key: str):
713 try:
714 del self.__dict__["_memoized"][key]
715 return True
716 except KeyError:
717 return False
719 @staticmethod
720 def from_dict(my_dict: dict, label: str | None = None) -> NavigableDict:
721 """Create a NavigableDict from a given dictionary.
723 Remember that all keys in the given dictionary shall be of type 'str' in order to be
724 accessible as attributes.
726 Args:
727 my_dict: a Python dictionary
728 label: a label that will be attached to this navdict
730 Examples:
731 >>> setup = navdict.from_dict({"ID": "my-setup-001", "version": "0.1.0"}, label="Setup")
732 >>> assert setup["ID"] == setup.ID == "my-setup-001"
734 """
735 return NavigableDict(my_dict, label=label)
737 @staticmethod
738 def from_yaml_string(yaml_content: str | None = None, label: str | None = None) -> NavigableDict:
739 """Creates a NavigableDict from the given YAML string.
741 This method is mainly used for easy creation of a navdict from strings during unit tests.
743 Args:
744 yaml_content: a string containing YAML
745 label: a label that will be attached to this navdict
747 Returns:
748 a navdict that was loaded from the content of the given string.
749 """
751 if not yaml_content: 751 ↛ 752line 751 didn't jump to line 752 because the condition on line 751 was never true
752 raise ValueError("Invalid argument to function: No input string or None given.")
754 yaml = YAML(typ="safe")
755 try:
756 data = yaml.load(yaml_content)
757 except ScannerError as exc:
758 raise ValueError(f"Invalid YAML string: {exc}")
760 return NavigableDict(data, label=label)
762 @staticmethod
763 def from_yaml_file(filename: str | Path | None = None) -> NavigableDict:
764 """Creates a navigable dictionary from the given YAML file.
766 Args:
767 filename (str): the path of the YAML file to be loaded
769 Returns:
770 a navdict that was loaded from the given location.
772 Raises:
773 ValueError: when no filename is given.
774 """
776 # logger.debug(f"{filename=}")
778 if not filename:
779 raise ValueError("Invalid argument to function: No filename or None given.")
781 # Make sure the filename exists and is a regular file
782 filename = Path(filename).expanduser().resolve()
783 if not filename.is_file():
784 raise ValueError(f"Invalid argument to function, filename does not exist: {filename!s}")
786 data = load_yaml(str(filename))
788 if data == {}:
789 warnings.warn(f"Empty YAML file: {filename!s}")
791 return data
793 def to_yaml_file(self, filename: str | Path | None = None, header: str = None, top_level_group: str = None) -> None:
794 """Saves a NavigableDict to a YAML file.
796 When no filename is provided, this method will look for a 'private' attribute
797 `_filename` and use that to save the data.
799 Args:
800 filename (str|Path): the path of the YAML file where to save the data
801 header (str): Custom header for this navdict
802 top_level_group (str): name of the optional top-level group
804 Note:
805 This method will **overwrite** the original or given YAML file and therefore you might
806 lose proper formatting and/or comments.
808 """
809 if filename is None and self.get_private_attribute("_filename") is None:
810 raise ValueError("No filename given or known, can not save navdict.")
812 if header is None:
813 header = textwrap.dedent(
814 f"""
815 # This YAML file is generated by:
816 #
817 # navdict.to_yaml_file(setup, filename="{filename}')
818 #
819 # Created on {datetime.datetime.now(tz=datetime.timezone.utc).isoformat()}
821 """
822 )
824 with Path(filename).open("w") as fd:
825 fd.write(header)
826 indent = 0
827 if top_level_group:
828 fd.write(f"{top_level_group}:\n")
829 indent = 1
831 self._save(fd, indent=indent)
833 self.set_private_attribute("_filename", Path(filename))
835 def get_filename(self) -> str | None:
836 """Returns the filename for this navdict or None when no filename could be determined."""
837 return self.get_private_attribute("_filename")
840navdict = NavigableDict
841NavDict = NavigableDict
842"""Shortcuts for NavigableDict and more Pythonic."""
845def _walk_dict_tree(dictionary: dict, tree: Tree, text_style: str = "green"):
846 for k, v in dictionary.items():
847 if isinstance(v, dict):
848 branch = tree.add(f"[purple]{k}", style="", guide_style="dim")
849 _walk_dict_tree(v, branch, text_style=text_style)
850 else:
851 text = Text.assemble((str(k), "medium_purple1"), ": ", (str(v), text_style))
852 tree.add(text)