Coverage for src/navdict/navdict.py: 72%
349 statements
« prev ^ index » next coverage.py v7.8.2, created at 2025-10-16 23:00 +0200
« prev ^ index » next coverage.py v7.8.2, created at 2025-10-16 23:00 +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//"): 70 ↛ 71line 70 didn't jump to line 71 because the condition on line 70 was never true
71 class_name = class_name[7:]
72 elif class_name.startswith("factory//"): 72 ↛ 73line 72 didn't jump to line 73 because the condition on line 72 was never true
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//"): 138 ↛ 139line 138 didn't jump to line 139 because the condition on line 138 was never true
139 resource_name = resource_name[5:]
141 if not resource_name: 141 ↛ 142line 141 didn't jump to line 142 because the condition on line 141 was never true
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//"): 198 ↛ 199line 198 didn't jump to line 199 because the condition on line 198 was never true
199 enum_name = enum_name[10:]
201 definition = {}
202 for side_name, side_definition in enum_content.items():
203 if "alias" in side_definition: 203 ↛ 206line 203 didn't jump to line 206 because the condition on line 203 was always true
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//"): 222 ↛ 223line 222 didn't jump to line 223 because the condition on line 222 was never true
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)):
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 # This function is called when the attribute is not found in the hierarchy.
383 # So, what do we do? We check if the key that was provided is an alias for
384 # an existing key. The alias mapping is a function that is provided as a
385 # hook, see `set_alias_hook()`.
386 def __getattr__(self, key):
387 # logger.info(f"Called __getattr__({key}) ...")
389 try:
390 alias = self._alias_hook(key)
391 return self.__dict__[alias]
392 except NotImplementedError:
393 raise AttributeError(f"{type(self).__name__!r} object has no attribute {key!r}")
395 @staticmethod
396 def _alias_hook(key: str) -> str:
397 raise NotImplementedError
399 def set_alias_hook(self, hook: Callable[[str], str]):
400 """
401 Sets an alias (hook) function that maps the given argument, an attribute
402 or a dict key, to a valid attribute or key.
404 The `hook` function accepts a string argument and return a string for
405 which the argument is an alias. The returned argument is expected to
406 be a valid attribute or key for this navdict.
407 """
408 setattr(self, "_alias_hook", hook)
410 # This method is called:
411 # - for *every* single attribute access on an object using dot notation.
412 # - when using the `getattr(obj, 'name') function
413 # - accessing any kind of attributes, e.g. instance or class variables,
414 # methods, properties, dunder methods, ...
415 #
416 # Note: `__getattr__` is only called when an attribute cannot be found
417 # through normal means.
418 def __getattribute__(self, key):
419 # logger.info(f"called __getattribute__({key}) ...")
420 try:
421 value = object.__getattribute__(self, key)
422 except AttributeError:
423 raise # this will call self.__getattr__(key)
425 if key.startswith("__"): # small optimization
426 return value
427 # We can not directly call the `_handle_directive` function here due to infinite recursion
428 if is_directive(value):
429 m = object.__getattribute__(self, "_handle_directive")
430 return m(key, value)
431 else:
432 return value
434 def __delattr__(self, item):
435 # logger.info(f"called __delattr__({self!r}, {item})")
436 object.__delattr__(self, item)
437 dict.__delitem__(self, item)
439 def __setitem__(self, key, value):
440 # logger.debug(f"called __setitem__({self!r}, {key}, {value})")
441 if isinstance(value, dict) and not isinstance(value, NavigableDict):
442 value = NavigableDict(value)
443 super().__setitem__(key, value)
444 self.__dict__[key] = value
445 try:
446 del self.__dict__["_memoized"][key]
447 except KeyError:
448 pass
450 # This method is called:
451 # - whenever square brackets `[]` are used on an object, e.g. indexing or slicing.
452 # - during iteration, if an object doesn't have __iter__ defined, Python will try
453 # to iterate using __getitem__ with successive integer indices starting from 0.
454 def __getitem__(self, key):
455 # logger.info(f"called __getitem__({self!r}, {key})")
456 try:
457 value = super().__getitem__(key)
458 except KeyError:
459 try:
460 alias = self._alias_hook(key)
461 value = super().__getitem__(alias)
462 except NotImplementedError:
463 raise KeyError(f"{type(self).__name__!r} has no key {key!r}")
465 if isinstance(key, str) and key.startswith("__"): 465 ↛ 466line 465 didn't jump to line 466 because the condition on line 465 was never true
466 return value
467 # no danger for recursion here, so we can directly call the function
468 if is_directive(value): 468 ↛ 469line 468 didn't jump to line 469 because the condition on line 468 was never true
469 return self._handle_directive(key, value)
470 else:
471 return value
473 def _handle_directive(self, key, value) -> Any:
474 """
475 This method will handle the available directives. This may be builtin directives
476 like `class/` or `factory//`, or it may be external directives that were provided
477 as a plugin. Some builtin directives have also been provided as a plugin, e.g.
478 'yaml//' and 'csv//'.
480 Args:
481 key: the key of the field that might contain a directive
482 value: the value which might be a directive
484 Returns:
485 This function will return the value, either the original value or the result of
486 evaluating and executing a directive.
487 """
488 # logger.debug(f"called _handle_directive({key}, {value!r}) [id={id(self)}]")
490 directive_key, directive_value = unravel_directive(value)
491 # logger.debug(f"{directive_key=}, {directive_value=}")
493 if directive := get_directive_plugin(directive_key):
494 # logger.debug(f"{directive.name=}")
496 if key in self.__dict__["_memoized"]:
497 return self.__dict__["_memoized"][key]
499 args, kwargs = self._get_args_and_kwargs(key)
500 parent_location = self._get_location()
501 result = directive.func(directive_value, parent_location, *args, **kwargs)
503 self.__dict__["_memoized"][key] = result
504 return result
506 match directive_key:
507 case "class":
508 args, kwargs = self._get_args_and_kwargs(key)
509 return load_class(directive_value)(*args, **kwargs)
511 case "factory": 511 ↛ 512line 511 didn't jump to line 512 because the pattern on line 511 never matched
512 factory_args = _get_attribute(self, f"{key}_args", {})
513 return load_class(directive_value)().create(**factory_args)
515 case "int_enum":
516 content = object.__getattribute__(self, "content")
517 return load_int_enum(directive_value, content)
519 case _:
520 return value
522 def _get_location(self):
523 """Returns the location of the file from which this NavDict was loaded or None if no location exists."""
524 try:
525 filename = self.__dict__["_filename"]
526 return filename.parent if filename else None
527 except KeyError:
528 return None
530 def _get_args_and_kwargs(self, key):
531 """
532 Read the args and kwargs that are associated with the key of a directive.
534 An example of such a directive:
536 hexapod:
537 device: class//egse.hexapod.PunaProxy
538 device_args: [PUNA_01]
539 device_kwargs:
540 sim: true
542 There might not be any positional nor keyword arguments provided in which
543 case and empty tuple and/or dictionary is returned.
545 Returns:
546 A tuple containing any positional arguments and a dictionary containing
547 keyword arguments.
548 """
549 try:
550 args = object.__getattribute__(self, f"{key}_args")
551 except AttributeError:
552 args = ()
553 try:
554 kwargs = object.__getattribute__(self, f"{key}_kwargs")
555 except AttributeError:
556 kwargs = {}
558 return args, kwargs
560 def set_private_attribute(self, key: str, value: Any) -> None:
561 """Sets a private attribute for this object.
563 The name in key will be accessible as an attribute for this object, but the key will not
564 be added to the dictionary and not be returned by methods like keys().
566 The idea behind this private attribute is to have the possibility to add status information
567 or identifiers to this classes object that can be used by save() or load() methods.
569 Args:
570 key (str): the name of the private attribute (must start with an underscore character).
571 value: the value for this private attribute
573 Examples:
574 >>> setup = NavigableDict({'a': 1, 'b': 2, 'c': 3})
575 >>> setup.set_private_attribute("_loaded_from_dict", True)
576 >>> assert "c" in setup
577 >>> assert "_loaded_from_dict" not in setup
578 >>> assert setup.get_private_attribute("_loaded_from_dict") == True
580 """
581 if key in self: 581 ↛ 582line 581 didn't jump to line 582 because the condition on line 581 was never true
582 raise ValueError(f"Invalid argument key='{key}', this key already exists in the dictionary.")
583 if not key.startswith("_"): 583 ↛ 584line 583 didn't jump to line 584 because the condition on line 583 was never true
584 raise ValueError(f"Invalid argument key='{key}', must start with underscore character '_'.")
585 self.__dict__[key] = value
587 def get_private_attribute(self, key: str) -> Any:
588 """Returns the value of the given private attribute.
590 Args:
591 key (str): the name of the private attribute (must start with an underscore character).
593 Returns:
594 the value of the private attribute given in `key` or None if the attribute doesn't exist.
596 Note:
597 Because of the implementation, this private attribute can also be accessed as a 'normal'
598 attribute of the object. This use is however discouraged as it will make your code less
599 understandable. Use the methods to access these 'private' attributes.
600 """
601 if not key.startswith("_"): 601 ↛ 602line 601 didn't jump to line 602 because the condition on line 601 was never true
602 raise ValueError(f"Invalid argument key='{key}', must start with underscore character '_'.")
603 try:
604 return self.__dict__[key]
605 except KeyError:
606 return None
608 def has_private_attribute(self, key) -> bool:
609 """
610 Check if the given key is defined as a private attribute.
612 Args:
613 key (str): the name of a private attribute (must start with an underscore)
614 Returns:
615 True if the given key is a known private attribute.
616 Raises:
617 ValueError: when the key doesn't start with an underscore.
618 """
619 if not key.startswith("_"):
620 raise ValueError(f"Invalid argument key='{key}', must start with underscore character '_'.")
622 # logger.debug(f"{self.__dict__.keys()} for [id={id(self)}]")
624 try:
625 _ = self.__dict__[key]
626 return True
627 except KeyError:
628 return False
630 def get_raw_value(self, key):
631 """
632 Returns the raw value of the given key.
634 Some keys have special values that are interpreted by the NavigableDict class. An example is
635 a value that starts with 'class//'. When you access these values, they are first converted
636 from their raw value into their expected value, e.g. the instantiated object in the above
637 example. This method allows you to access the raw value before conversion.
638 """
639 try:
640 return object.__getattribute__(self, key)
641 except AttributeError:
642 raise KeyError(f"The key '{key}' is not defined.")
644 def __str__(self):
645 return self._pretty_str()
647 def _pretty_str(self, indent: int = 0):
648 msg = ""
650 for k, v in self.items():
651 if isinstance(v, NavigableDict):
652 msg += f"{' ' * indent}{k}:\n"
653 msg += v._pretty_str(indent + 1)
654 else:
655 msg += f"{' ' * indent}{k}: {v}\n"
657 return msg
659 def __rich__(self) -> Tree:
660 tree = Tree(self.__dict__["_label"] or "NavigableDict", guide_style="dim")
661 _walk_dict_tree(self, tree, text_style="dark grey")
662 return tree
664 def _save(self, fd, indent: int = 0):
665 """
666 Recursive method to write the dictionary to the file descriptor.
668 Indentation is done in steps of four spaces, i.e. `' '*indent`.
670 Args:
671 fd: a file descriptor as returned by the open() function
672 indent (int): indentation level of each line [default = 0]
674 """
676 # Note that the .items() method returns the actual values of the keys and doesn't use the
677 # __getattribute__ or __getitem__ methods. So the raw value is returned and not the
678 # _processed_ value.
680 for k, v in self.items():
681 # history shall be saved last, skip it for now
683 if k == "history": 683 ↛ 684line 683 didn't jump to line 684 because the condition on line 683 was never true
684 continue
686 # make sure to escape a colon in the key name
688 if isinstance(k, str) and ":" in k: 688 ↛ 689line 688 didn't jump to line 689 because the condition on line 688 was never true
689 k = '"' + k + '"'
691 if isinstance(v, NavigableDict):
692 fd.write(f"{' ' * indent}{k}:\n")
693 v._save(fd, indent + 1)
694 fd.flush()
695 continue
697 if isinstance(v, float): 697 ↛ 698line 697 didn't jump to line 698 because the condition on line 697 was never true
698 v = f"{v:.6E}"
699 fd.write(f"{' ' * indent}{k}: {v}\n")
700 fd.flush()
702 # now save the history as the last item
704 if "history" in self: 704 ↛ 705line 704 didn't jump to line 705 because the condition on line 704 was never true
705 fd.write(f"{' ' * indent}history:\n")
706 self.history._save(fd, indent + 1) # noqa
708 def get_memoized_keys(self):
709 return list(self.__dict__["_memoized"].keys())
711 def del_memoized_key(self, key: str):
712 try:
713 del self.__dict__["_memoized"][key]
714 return True
715 except KeyError:
716 return False
718 @staticmethod
719 def from_dict(my_dict: dict, label: str | None = None) -> NavigableDict:
720 """Create a NavigableDict from a given dictionary.
722 Remember that all keys in the given dictionary shall be of type 'str' in order to be
723 accessible as attributes.
725 Args:
726 my_dict: a Python dictionary
727 label: a label that will be attached to this navdict
729 Examples:
730 >>> setup = navdict.from_dict({"ID": "my-setup-001", "version": "0.1.0"}, label="Setup")
731 >>> assert setup["ID"] == setup.ID == "my-setup-001"
733 """
734 return NavigableDict(my_dict, label=label)
736 @staticmethod
737 def from_yaml_string(yaml_content: str | None = None, label: str | None = None) -> NavigableDict:
738 """Creates a NavigableDict from the given YAML string.
740 This method is mainly used for easy creation of a navdict from strings during unit tests.
742 Args:
743 yaml_content: a string containing YAML
744 label: a label that will be attached to this navdict
746 Returns:
747 a navdict that was loaded from the content of the given string.
748 """
750 if not yaml_content:
751 raise ValueError("Invalid argument to function: No input string or None given.")
753 yaml = YAML(typ="safe")
754 try:
755 data = yaml.load(yaml_content)
756 except ScannerError as exc:
757 raise ValueError(f"Invalid YAML string: {exc}")
759 return NavigableDict(data, label=label)
761 @staticmethod
762 def from_yaml_file(filename: str | Path | None = None) -> NavigableDict:
763 """Creates a navigable dictionary from the given YAML file.
765 Args:
766 filename (str): the path of the YAML file to be loaded
768 Returns:
769 a navdict that was loaded from the given location.
771 Raises:
772 ValueError: when no filename is given.
773 """
775 # logger.debug(f"{filename=}")
777 if not filename: 777 ↛ 778line 777 didn't jump to line 778 because the condition on line 777 was never true
778 raise ValueError("Invalid argument to function: No filename or None given.")
780 # Make sure the filename exists and is a regular file
781 filename = Path(filename).expanduser().resolve()
782 if not filename.is_file():
783 raise ValueError(f"Invalid argument to function, filename does not exist: {filename!s}")
785 data = load_yaml(str(filename))
787 if data == {}: 787 ↛ 788line 787 didn't jump to line 788 because the condition on line 787 was never true
788 warnings.warn(f"Empty YAML file: {filename!s}")
790 return data
792 def to_yaml_file(self, filename: str | Path | None = None, header: str = None, top_level_group: str = None) -> None:
793 """Saves a NavigableDict to a YAML file.
795 When no filename is provided, this method will look for a 'private' attribute
796 `_filename` and use that to save the data.
798 Args:
799 filename (str|Path): the path of the YAML file where to save the data
800 header (str): Custom header for this navdict
801 top_level_group (str): name of the optional top-level group
803 Note:
804 This method will **overwrite** the original or given YAML file and therefore you might
805 lose proper formatting and/or comments.
807 """
808 if filename is None and self.get_private_attribute("_filename") is None:
809 raise ValueError("No filename given or known, can not save navdict.")
811 if header is None: 811 ↛ 823line 811 didn't jump to line 823 because the condition on line 811 was always true
812 header = textwrap.dedent(
813 f"""
814 # This YAML file is generated by:
815 #
816 # navdict.to_yaml_file(setup, filename="{filename}')
817 #
818 # Created on {datetime.datetime.now(tz=datetime.timezone.utc).isoformat()}
820 """
821 )
823 with Path(filename).open("w") as fd:
824 fd.write(header)
825 indent = 0
826 if top_level_group: 826 ↛ 827line 826 didn't jump to line 827 because the condition on line 826 was never true
827 fd.write(f"{top_level_group}:\n")
828 indent = 1
830 self._save(fd, indent=indent)
832 self.set_private_attribute("_filename", Path(filename))
834 def get_filename(self) -> str | None:
835 """Returns the filename for this navdict or None when no filename could be determined."""
836 return self.get_private_attribute("_filename")
839navdict = NavigableDict
840NavDict = NavigableDict
841"""Shortcuts for NavigableDict and more Pythonic."""
844def _walk_dict_tree(dictionary: dict, tree: Tree, text_style: str = "green"):
845 for k, v in dictionary.items():
846 if isinstance(v, dict):
847 branch = tree.add(f"[purple]{k}", style="", guide_style="dim")
848 _walk_dict_tree(v, branch, text_style=text_style)
849 else:
850 text = Text.assemble((str(k), "medium_purple1"), ": ", (str(v), text_style))
851 tree.add(text)