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
« 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.
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 re
42import textwrap
43import warnings
44from enum import IntEnum
45from pathlib import Path
46from typing import Any
47from typing import Callable
49from rich.text import Text
50from rich.tree import Tree
51from ruamel.yaml import YAML
52from ruamel.yaml.scanner import ScannerError
54from navdict.directive import is_directive
55from navdict.directive import unravel_directive
56from navdict.directive import get_directive_plugin
58logger = logging.getLogger("navdict")
61def load_class(class_name: str):
62 """
63 Find and returns a class based on the fully qualified name.
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.
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:]
76 module_name, class_name = class_name.rsplit(".", 1)
77 module = importlib.import_module(module_name)
78 return getattr(module, class_name)
81def get_resource_location(parent_location: Path | None, in_dir: str | None) -> Path:
82 """
83 Returns the resource location.
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:
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.
99 Args:
100 parent_location: the location of the parent navdict, or None
101 in_dir: a location extracted from the directive's value.
103 Returns:
104 A Path object with the resource location.
106 """
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", "."))
120 # logger.debug(f"{location=}, {fn=}")
122 return location
125ENV_VAR_PATTERN = re.compile(r"ENV\[\s*['\"]?(\w+)['\"]?\s*]")
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.
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.
136 Args:
137 value: a string that may contain zero or more `ENV[VARNAME]` references.
139 Returns:
140 The string with all `ENV[VARNAME]` references expanded and `~` expanded to the user's
141 home directory.
143 Raises:
144 ValueError: when a referenced environment variable is not set.
145 """
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
154 return str(Path(ENV_VAR_PATTERN.sub(_replace, value)).expanduser())
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.
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:
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.
169 Returns:
170 A list of the split lines, i.e. a list of lists of strings.
171 """
173 # logger.debug(f"{resource_name=}, {parent_location=}")
175 if resource_name.startswith("csv//"):
176 resource_name = resource_name[5:]
178 if not resource_name:
179 raise ValueError(f"Resource name should not be empty, but contain a valid filename.")
181 if kwargs.get("expand_env", True):
182 resource_name = expand_env_vars(resource_name)
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
187 try:
188 n_header_rows = int(kwargs["header_rows"])
189 except KeyError:
190 n_header_rows = 0
192 csv_location = get_resource_location(parent_location, in_dir)
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 """
200 for line in itertools.islice(file_obj, n_skip, None):
201 if not line.strip().startswith("#"):
202 yield line
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
213 return data
216def load_int_enum(enum_name: str, enum_content) -> IntEnum:
217 """Dynamically build (and return) and IntEnum.
219 In the YAML file this will look like below.
220 The IntEnum directive (where <name> is the class name):
222 enum: int_enum//<name>
224 The IntEnum content:
226 content:
227 E:
228 alias: ['E_SIDE', 'RIGHT_SIDE']
229 value: 1
230 F:
231 alias: ['F_SIDE', 'LEFT_SIDE']
232 value: 0
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:]
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"]
249 definition[side_name] = value
251 for alias in aliases:
252 definition[alias] = value
254 return IntEnum(enum_name, definition)
257def load_yaml(resource_name: str, parent_location: Path | None = None, *args, **kwargs) -> NavigableDict:
258 """Find and return the content of a YAML file."""
260 # logger.debug(f"{resource_name=}, {parent_location=}")
262 if resource_name.startswith("yaml//"):
263 resource_name = resource_name[6:]
265 parts = resource_name.rsplit("/", 1)
267 in_dir, fn = parts if len(parts) > 1 else (None, parts[0]) # use a tuple here to make Mypy happy
269 yaml_location = get_resource_location(parent_location, in_dir)
271 try:
272 yaml = YAML(typ="safe")
273 with open(yaml_location / fn, "r") as file:
274 data = yaml.load(file)
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
290 data = NavigableDict(data, _filename=yaml_location / fn)
292 # logger.debug(f"{data.get_private_attribute('_filename')=}")
294 return data
297def _get_attribute(self, name, default):
298 """
299 Safely retrieve an attribute from the object, returning a default if not found.
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.
305 Args:
306 name (str): The name of the attribute to retrieve.
307 default: The value to return if the attribute does not exist.
309 Returns:
310 The attribute value if it exists, otherwise the default value.
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
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`.
330 Args:
331 head (dict): the original dictionary
332 label (str): a label or name that is used when printing the navdict
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
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`.
343 """
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
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
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.
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
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))
380 def get_label(self) -> str | None:
381 return self.__dict__["_label"]
383 def set_label(self, value: str):
384 self.__dict__["_label"] = value
386 def add(self, key: str, value: Any):
387 """Set a value for the given key.
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.
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)
400 def clear(self) -> None:
401 for key in list(self.keys()):
402 self.__delitem__(key)
404 def __repr__(self):
405 return f"{self.__class__.__name__}({super()!r}) [id={id(self)}]"
407 def __delitem__(self, key):
408 dict.__delitem__(self, key)
409 object.__delattr__(self, key)
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
422 @staticmethod
423 def _alias_hook(key: str) -> str:
424 raise NotImplementedError
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.
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
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}")
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
471 def __delattr__(self, item):
472 # logger.info(f"called __delattr__({self!r}, {item})")
473 object.__delattr__(self, item)
474 dict.__delitem__(self, item)
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
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}")
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
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//'.
521 Args:
522 key: the key of the field that might contain a directive
523 value: the value which might be a directive
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)}]")
531 directive_key, directive_value = unravel_directive(value)
532 # logger.debug(f"{directive_key=}, {directive_value=}")
534 if directive := get_directive_plugin(directive_key):
535 # logger.debug(f"{directive.name=}")
537 if key in self.__dict__["_memoized"]:
538 return self.__dict__["_memoized"][key]
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)
544 self.__dict__["_memoized"][key] = result
545 return result
547 match directive_key:
548 case "class":
549 args, kwargs = self._get_args_and_kwargs(key)
550 return load_class(directive_value)(*args, **kwargs)
552 case "factory":
553 factory_args = _get_attribute(self, f"{key}_args", {})
554 return load_class(directive_value)().create(**factory_args)
556 case "int_enum":
557 content = object.__getattribute__(self, "content")
558 return load_int_enum(directive_value, content)
560 case _:
561 return value
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
571 def _get_args_and_kwargs(self, key):
572 """
573 Read the args and kwargs that are associated with the key of a directive.
575 An example of such a directive:
577 hexapod:
578 device: class//egse.hexapod.PunaProxy
579 device_args: [PUNA_01]
580 device_kwargs:
581 sim: true
583 There might not be any positional nor keyword arguments provided in which
584 case and empty tuple and/or dictionary is returned.
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 = {}
599 return args, kwargs
601 def set_private_attribute(self, key: str, value: Any) -> None:
602 """Sets a private attribute for this object.
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().
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.
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
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
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
628 def get_private_attribute(self, key: str) -> Any:
629 """Returns the value of the given private attribute.
631 Args:
632 key (str): the name of the private attribute (must start with an underscore character).
634 Returns:
635 the value of the private attribute given in `key` or None if the attribute doesn't exist.
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
649 def has_private_attribute(self, key) -> bool:
650 """
651 Check if the given key is defined as a private attribute.
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 '_'.")
663 # logger.debug(f"{self.__dict__.keys()} for [id={id(self)}]")
665 try:
666 _ = self.__dict__[key]
667 return True
668 except KeyError:
669 return False
671 def get_raw_value(self, key):
672 """
673 Returns the raw value of the given key.
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.")
685 def __str__(self):
686 return self._pretty_str()
688 def _pretty_str(self, indent: int = 0):
689 msg = ""
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"
698 return msg
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
705 def _save(self, fd, indent: int = 0):
706 """
707 Recursive method to write the dictionary to the file descriptor.
709 Indentation is done in steps of four spaces, i.e. `' '*indent`.
711 Args:
712 fd: a file descriptor as returned by the open() function
713 indent (int): indentation level of each line [default = 0]
715 """
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.
721 for k, v in self.items():
722 # history shall be saved last, skip it for now
724 if k == "history":
725 continue
727 # make sure to escape a colon in the key name
729 if isinstance(k, str) and ":" in k:
730 k = '"' + k + '"'
732 if isinstance(v, NavigableDict):
733 fd.write(f"{' ' * indent}{k}:\n")
734 v._save(fd, indent + 1)
735 fd.flush()
736 continue
738 if isinstance(v, float):
739 v = f"{v:.6E}"
740 fd.write(f"{' ' * indent}{k}: {v}\n")
741 fd.flush()
743 # now save the history as the last item
745 if "history" in self:
746 fd.write(f"{' ' * indent}history:\n")
747 self.history._save(fd, indent + 1) # noqa
749 def get_memoized_keys(self):
750 return list(self.__dict__["_memoized"].keys())
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
759 @staticmethod
760 def from_dict(my_dict: dict, label: str | None = None) -> NavigableDict:
761 """Create a NavigableDict from a given dictionary.
763 Remember that all keys in the given dictionary shall be of type 'str' in order to be
764 accessible as attributes.
766 Args:
767 my_dict: a Python dictionary
768 label: a label that will be attached to this navdict
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"
774 """
775 return NavigableDict(my_dict, label=label)
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.
781 This method is mainly used for easy creation of a navdict from strings during unit tests.
783 Args:
784 yaml_content: a string containing YAML
785 label: a label that will be attached to this navdict
787 Returns:
788 a navdict that was loaded from the content of the given string.
789 """
791 if not yaml_content:
792 raise ValueError("Invalid argument to function: No input string or None given.")
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}")
800 return NavigableDict(data, label=label)
802 @staticmethod
803 def from_yaml_file(filename: str | Path | None = None) -> NavigableDict:
804 """Creates a navigable dictionary from the given YAML file.
806 Args:
807 filename (str): the path of the YAML file to be loaded
809 Returns:
810 a navdict that was loaded from the given location.
812 Raises:
813 ValueError: when no filename is given.
814 """
816 # logger.debug(f"{filename=}")
818 if not filename:
819 raise ValueError("Invalid argument to function: No filename or None given.")
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}")
826 data = load_yaml(str(filename))
828 if data == {}:
829 warnings.warn(f"Empty YAML file: {filename!s}")
831 return data
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.
836 When no filename is provided, this method will look for a 'private' attribute
837 `_filename` and use that to save the data.
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
844 Note:
845 This method will **overwrite** the original or given YAML file and therefore you might
846 lose proper formatting and/or comments.
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.")
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()}
861 """
862 )
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
871 self._save(fd, indent=indent)
873 self.set_private_attribute("_filename", Path(filename))
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")
880navdict = NavigableDict
881NavDict = NavigableDict
882"""Shortcuts for NavigableDict and more Pythonic."""
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)