Coverage for src/navdict/navdict.py: 16%
382 statements
« prev ^ index » next coverage.py v7.8.2, created at 2026-07-14 16:43 +0200
« prev ^ index » next coverage.py v7.8.2, created at 2026-07-14 16:43 +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 "expand_env_vars",
34]
36import csv
37import datetime
38import importlib
39import itertools
40import logging
41import os
42import re
43import textwrap
44import warnings
45from enum import IntEnum
46from pathlib import Path
47from typing import Any
48from typing import Callable
49from typing import TypeVar
50from typing import cast
52from rich.text import Text
53from rich.tree import Tree
54from ruamel.yaml import YAML
55from ruamel.yaml.scanner import ScannerError
57from navdict.directive import is_directive
58from navdict.directive import unravel_directive
59from navdict.directive import get_directive_plugin
61logger = logging.getLogger("navdict")
63T = TypeVar("T")
66def load_class(class_name: str):
67 """
68 Find and returns a class based on the fully qualified name.
70 A class name can be preceded with the string `class//` or `factory//`. This is used in YAML
71 files where the class is then instantiated on load.
73 Args:
74 class_name (str): a fully qualified name for the class
75 """
76 if class_name.startswith("class//"):
77 class_name = class_name[7:]
78 elif class_name.startswith("factory//"):
79 class_name = class_name[9:]
81 module_name, class_name = class_name.rsplit(".", 1)
82 module = importlib.import_module(module_name)
83 return getattr(module, class_name)
86def get_resource_location(parent_location: Path | None, in_dir: str | None) -> Path:
87 """
88 Returns the resource location.
90 The resource location is the path to the file that is provided in a directive
91 such as `yaml//` or `csv//`. The location of the file can be given as an absolute
92 path or can be relative in which case there are two possibilities:
94 1. the parent location is not None.
95 In this case the resource location will be relative to the parent's location.
96 2. the parent location is None.
97 In this case the resource location is taken to be relative to the current working directory '.'
98 unless the environment variable NAVDICT_DEFAULT_RESOURCE_LOCATION is provided in which case
99 it is taken from that variable.
100 3. when both arguments are None, the resource location will be the current working directory '.'
101 unless the environment variable NAVDICT_DEFAULT_RESOURCE_LOCATION is provided in which case
102 it is taken from that variable.
104 In all cases, the returned path is expanded, i.e. `~` is expanded to the user's home directory,
105 and resolved, but not checked for existence.
107 Args:
108 parent_location: the location of the parent navdict, or None
109 in_dir: a location extracted from the directive's value.
111 Returns:
112 A Path object with the resource location.
114 """
116 match (parent_location, in_dir):
117 case (_, str()) if in_dir.startswith("~") or Path(in_dir).is_absolute():
118 location = Path(in_dir)
119 case (None, str()):
120 location = Path(os.getenv("NAVDICT_DEFAULT_RESOURCE_LOCATION", ".")) / in_dir
121 case (Path(), str()):
122 location = parent_location / in_dir
123 case (Path(), None):
124 location = parent_location
125 case _:
126 location = Path(os.getenv("NAVDICT_DEFAULT_RESOURCE_LOCATION", "."))
128 # logger.debug(f"{location=}, {fn=}")
130 return location.expanduser()
133ENV_VAR_PATTERN = re.compile(r"ENV\[\s*['\"]?(\w+)['\"]?\s*]")
136def expand_env_vars(value: str) -> str:
137 """
138 Expand `ENV[VARNAME]` references in `value` with the value of the corresponding environment
139 variable.
141 The variable name may optionally be wrapped in single or double quotes, i.e. `ENV[VARNAME]`,
142 `ENV['VARNAME']` and `ENV["VARNAME"]` are all equivalent.
144 Args:
145 value: a string that may contain zero or more `ENV[VARNAME]` references.
147 Returns:
148 The string with all `ENV[VARNAME]` references expanded.
150 Raises:
151 ValueError: when a referenced environment variable is not set.
152 """
154 def _replace(match: re.Match) -> str:
155 var_name = match[1]
156 try:
157 return os.environ[var_name]
158 except KeyError:
159 raise ValueError(f"Environment variable '{var_name}' referenced in '{value}' is not set.") from None
161 return ENV_VAR_PATTERN.sub(_replace, value)
164def load_csv(resource_name: str, parent_location: Path | None, *args, **kwargs) -> list[list[str]]:
165 """
166 Find and return the content of a CSV file.
168 If the `resource_name` argument starts with the directive (`csv//`), it will be split off automatically.
169 The `kwargs` dictionary can contain the following keys:
171 - `header_rows`: the number of header rows to skip when processing the file.
172 - `expand_env`: when `True` (the default), `ENV[VARNAME]` references in `resource_name` are expanded
173 with the value of the corresponding environment variable before the file is located. Set to `False`
174 to disable this expansion.
176 Returns:
177 A list of the split lines, i.e. a list of lists of strings.
178 """
180 # logger.debug(f"{resource_name=}, {parent_location=}")
182 if resource_name.startswith("csv//"):
183 resource_name = resource_name[5:]
185 if not resource_name:
186 raise ValueError("Resource name should not be empty, but contain a valid filename.")
188 if kwargs.get("expand_env", True):
189 resource_name = expand_env_vars(resource_name)
191 parts = resource_name.rsplit("/", 1)
192 in_dir, fn = parts if len(parts) > 1 else (None, parts[0]) # use a tuple here to make Mypy happy
194 try:
195 n_header_rows = int(kwargs["header_rows"])
196 except KeyError:
197 n_header_rows = 0
199 csv_location = get_resource_location(parent_location, in_dir)
201 def filter_lines(file_obj, n_skip):
202 """
203 Generator that filters out comment lines and skips header lines.
204 The standard library csv module cannot handle this functionality.
205 """
207 for line in itertools.islice(file_obj, n_skip, None):
208 if not line.strip().startswith("#"):
209 yield line
211 try:
212 with open(csv_location / fn, "r", encoding="utf-8") as file:
213 filtered_lines = filter_lines(file, n_header_rows)
214 csv_reader = csv.reader(filtered_lines, delimiter=kwargs.get("delimiter", ","))
215 data = list(csv_reader)
216 except FileNotFoundError:
217 logger.error(f"Couldn't load resource '{resource_name}', file not found", exc_info=True)
218 raise
220 return data
223def load_int_enum(enum_name: str, enum_content) -> IntEnum:
224 """Dynamically build (and return) and IntEnum.
226 In the YAML file this will look like below.
227 The IntEnum directive (where <name> is the class name):
229 enum: int_enum//<name>
231 The IntEnum content:
233 content:
234 E:
235 alias: ['E_SIDE', 'RIGHT_SIDE']
236 value: 1
237 F:
238 alias: ['F_SIDE', 'LEFT_SIDE']
239 value: 0
241 Args:
242 - enum_name: Enumeration name (potentially prepended with "int_enum//").
243 - enum_content: Content of the enumeration, as read from the navdict field.
244 """
245 if enum_name.startswith("int_enum//"):
246 enum_name = enum_name[10:]
248 definition = {}
249 for side_name, side_definition in enum_content.items():
250 if "alias" in side_definition:
251 aliases = side_definition["alias"]
252 else:
253 aliases = []
254 value = side_definition["value"]
256 definition[side_name] = value
258 for alias in aliases:
259 definition[alias] = value
261 return IntEnum(enum_name, definition)
264def load_yaml(resource_name: str, parent_location: Path | None, *args, **kwargs) -> NavigableDict:
265 """Find and return the content of a YAML file."""
267 # logger.debug(f"{resource_name=}, {parent_location=}")
269 if resource_name.startswith("yaml//"):
270 resource_name = resource_name[6:]
272 if not resource_name:
273 raise ValueError("Resource name should not be empty, but contain a valid filename.")
275 if kwargs.get("expand_env", True):
276 resource_name = expand_env_vars(resource_name)
278 parts = resource_name.rsplit("/", 1)
279 in_dir, fn = parts if len(parts) > 1 else (None, parts[0]) # use a tuple here to make Mypy happy
281 yaml_location = get_resource_location(parent_location, in_dir)
283 try:
284 yaml = YAML(typ="safe")
285 with open(yaml_location / fn, "r") as file:
286 data = yaml.load(file)
288 except FileNotFoundError:
289 logger.error(f"Couldn't load resource '{resource_name}', file not found", exc_info=True)
290 raise
291 except IsADirectoryError:
292 logger.error(
293 f"Couldn't load resource '{resource_name}', file seems to be a directory",
294 exc_info=True,
295 )
296 raise
297 except ScannerError as exc:
298 msg = f"A error occurred while scanning the YAML file: {yaml_location / fn}."
299 logger.error(msg, exc_info=True)
300 raise IOError(msg) from exc
302 data = NavigableDict(data, _filename=yaml_location / fn)
304 # logger.debug(f"{data.get_private_attribute('_filename')=}")
306 return data
309def _get_attribute(self, name, default):
310 """
311 Safely retrieve an attribute from the object, returning a default if not found.
313 This method uses object.__getattribute__() to bypass any custom __getattr__
314 or __getattribute__ implementations on the class, accessing attributes directly
315 from the object's internal dictionary.
317 Args:
318 name (str): The name of the attribute to retrieve.
319 default: The value to return if the attribute does not exist.
321 Returns:
322 The attribute value if it exists, otherwise the default value.
324 Note:
325 This is typically used internally to avoid infinite recursion when
326 implementing custom attribute access methods.
327 """
328 try:
329 attr = object.__getattribute__(self, name)
330 except AttributeError:
331 attr = default
332 return attr
335class NavigableDict(dict):
336 """
337 A NavigableDict is a dictionary where all keys in the original dictionary are also accessible
338 as attributes to the class instance. So, if the original dictionary (setup) has a key
339 "site_id" which is accessible as `setup['site_id']`, it will also be accessible as
340 `setup.site_id`.
342 Args:
343 head (dict): the original dictionary
344 label (str): a label or name that is used when printing the navdict
346 Examples:
347 >>> setup = NavigableDict({'site_id': 'KU Leuven', 'version': "0.1.0"})
348 >>> assert setup['site_id'] == setup.site_id
349 >>> assert setup['version'] == setup.version
351 Note:
352 We always want **all** keys to be accessible as attributes, or none. That means all
353 keys of the original dictionary shall be of type `str`.
355 """
357 def __init__(
358 self,
359 head: dict | None = None,
360 label: str | None = None,
361 _filename: str | Path | None = None,
362 ):
363 head = head or {}
364 super().__init__(head)
365 self.__dict__["_memoized"] = {}
366 self.__dict__["_label"] = label
367 self.__dict__["_filename"] = Path(_filename) if _filename is not None else None
369 # TODO:
370 # if _filename was not given as an argument, we might want to check if the `head` has a `_filename` and do
371 # something like:
372 #
373 # if _filename is None and isinstance(head, navdict):
374 # _filename = head.__dict__["_filename"]
375 # self.__dict__["_filename"] = _filename
377 # By agreement, we only want the keys to be set as attributes if all keys are strings.
378 # That way we enforce that always all keys are navigable, or none.
380 if any(True for k in head.keys() if not isinstance(k, str)):
381 # invalid_keys = list(k for k in head.keys() if not isinstance(k, str))
382 # logger.warning(f"Dictionary will not be dot-navigable, not all keys are strings [{invalid_keys=}].")
383 return
385 for key, value in head.items():
386 if isinstance(value, dict):
387 value = NavigableDict(head.__getitem__(key), _filename=_filename, label=key)
388 setattr(self, key, value)
389 else:
390 setattr(self, key, super().__getitem__(key))
392 def get_label(self) -> str | None:
393 return self.__dict__["_label"]
395 def set_label(self, value: str):
396 self.__dict__["_label"] = value
398 def add(self, key: str, value: Any):
399 """Set a value for the given key.
401 If the value is a dictionary, it will be converted into a NavigableDict and the keys
402 will become available as attributes provided that all the keys are strings.
404 Args:
405 key (str): the name of the key / attribute to access the value
406 value (Any): the value to assign to the key
407 """
408 if isinstance(value, dict) and not isinstance(value, NavigableDict):
409 value = NavigableDict(value, label=key)
410 setattr(self, key, value)
412 def clear(self) -> None:
413 for key in list(self.keys()):
414 self.__delitem__(key)
416 def __repr__(self):
417 return f"{self.__class__.__name__}({super()!r}) [id={id(self)}]"
419 def __delitem__(self, key):
420 dict.__delitem__(self, key)
421 object.__delattr__(self, key)
423 def __setattr__(self, key, value):
424 # logger.info(f"called __setattr__({self!r}, {key}, {value})")
425 if isinstance(value, dict) and not isinstance(value, NavigableDict):
426 value = NavigableDict(value, label=key)
427 self.__dict__[key] = value
428 super().__setitem__(key, value)
429 try:
430 del self.__dict__["_memoized"][key]
431 except KeyError:
432 pass
434 @staticmethod
435 def _alias_hook(key: str) -> str:
436 raise NotImplementedError
438 def set_alias_hook(self, hook: Callable[[str], str]):
439 """
440 Sets an alias (hook) function that maps the given argument, an attribute
441 or a dict key, to a valid attribute or key.
443 The `hook` function accepts a string argument and return a string for
444 which the argument is an alias. The returned argument is expected to
445 be a valid attribute or key for this navdict.
446 """
447 # The setattr() function will add the attribute to the dictionary
448 # which is not what we want.
449 # setattr(self, "_alias_hook", hook)
450 self.__dict__["_alias_hook"] = hook
452 def as_typed(self, schema_type: type[T]) -> T:
453 """Return this instance typed as `schema_type` for static analysis.
455 This helper does not transform data at runtime. It returns `self` and
456 exists to let type checkers and editors infer attribute completion when
457 callers provide a Protocol, TypedDict wrapper, or class describing the
458 expected structure.
459 """
460 return cast(T, self)
462 def __dir__(self):
463 """Include navigable keys in `dir()` for better interactive discovery."""
464 base = set(super().__dir__())
465 key_names = {key for key in self.keys() if isinstance(key, str) and key.isidentifier()}
466 for key in self.keys():
467 if isinstance(key, str) and not key.isidentifier():
468 base.discard(key)
469 return sorted(base | key_names)
471 # This method is called:
472 # - for *every* single attribute access on an object using dot notation.
473 # - when using the `getattr(obj, 'name') function
474 # - accessing any kind of attributes, e.g. instance or class variables,
475 # methods, properties, dunder methods, ...
476 #
477 # Note: `__getattr__` is only called when an attribute cannot be found
478 # through normal means.
479 def __getattribute__(self, key):
480 # logger.info(f"called __getattribute__({key}) ...")
481 try:
482 value = object.__getattribute__(self, key)
483 except AttributeError:
484 try:
485 alias = self._alias_hook(key)
486 value = object.__getattribute__(self, alias)
487 except NotImplementedError:
488 raise AttributeError(f"{type(self).__name__!r} object has no attribute {key!r}")
489 except Exception as exc:
490 logger.error(f"Alias hook function raised {type(exc).__name__!r}: {exc}")
491 raise AttributeError(f"{type(self).__name__!r} object has no attribute {key!r}")
493 if key.startswith("__"): # small optimization
494 return value
495 # We can not directly call the `_handle_directive` function here due to infinite recursion
496 if is_directive(value):
497 m = object.__getattribute__(self, "_handle_directive")
498 return m(key, value)
499 else:
500 return value
502 def __delattr__(self, item):
503 # logger.info(f"called __delattr__({self!r}, {item})")
504 object.__delattr__(self, item)
505 dict.__delitem__(self, item)
507 def __setitem__(self, key, value):
508 # logger.debug(f"called __setitem__({self!r}, {key}, {value})")
509 if isinstance(value, dict) and not isinstance(value, NavigableDict):
510 value = NavigableDict(value, label=key)
511 super().__setitem__(key, value)
512 self.__dict__[key] = value
513 try:
514 del self.__dict__["_memoized"][key]
515 except KeyError:
516 pass
518 # This method is called:
519 # - whenever square brackets `[]` are used on an object, e.g. indexing or slicing.
520 # - during iteration, if an object doesn't have __iter__ defined, Python will try
521 # to iterate using __getitem__ with successive integer indices starting from 0.
522 def __getitem__(self, key):
523 # logger.info(f"called __getitem__({self!r}, {key})")
524 try:
525 value = super().__getitem__(key)
526 except KeyError:
527 try:
528 alias = self._alias_hook(key)
529 value = super().__getitem__(alias)
530 except NotImplementedError:
531 raise KeyError(f"{type(self).__name__!r} has no key {key!r}")
532 except Exception as exc:
533 logger.error(f"Alias hook function raised {type(exc).__name__!r}: {exc}")
534 raise KeyError(f"{type(self).__name__!r} has no key {key!r}")
536 if isinstance(key, str) and key.startswith("__"):
537 return value
538 # no danger for recursion here, so we can directly call the function
539 if is_directive(value):
540 return self._handle_directive(key, value)
541 else:
542 return value
544 def _handle_directive(self, key, value) -> Any:
545 """
546 This method will handle the available directives. This may be builtin directives
547 like `class/` or `factory//`, or it may be external directives that were provided
548 as a plugin. Some builtin directives have also been provided as a plugin, e.g.
549 'yaml//' and 'csv//'.
551 Args:
552 key: the key of the field that might contain a directive
553 value: the value which might be a directive
555 Returns:
556 This function will return the value, either the original value or the result of
557 evaluating and executing a directive.
558 """
559 # logger.debug(f"called _handle_directive({key}, {value!r}) [id={id(self)}]")
561 directive_key, directive_value = unravel_directive(value)
562 # logger.debug(f"{directive_key=}, {directive_value=}")
564 if directive := get_directive_plugin(directive_key):
565 # logger.debug(f"{directive.name=}")
567 if key in self.__dict__["_memoized"]:
568 return self.__dict__["_memoized"][key]
570 args, kwargs = self._get_args_and_kwargs(key)
571 parent_location = self._get_location()
572 result = directive.func(directive_value, parent_location, *args, **kwargs)
574 self.__dict__["_memoized"][key] = result
575 return result
577 match directive_key:
578 case "class":
579 args, kwargs = self._get_args_and_kwargs(key)
580 return load_class(directive_value)(*args, **kwargs)
582 case "factory":
583 factory_args = _get_attribute(self, f"{key}_args", {})
584 return load_class(directive_value)().create(**factory_args)
586 case "int_enum":
587 content = object.__getattribute__(self, "content")
588 return load_int_enum(directive_value, content)
590 case _:
591 return value
593 def _get_location(self):
594 """Returns the location of the file from which this NavDict was loaded or None if no location exists."""
595 try:
596 filename = self.__dict__["_filename"]
597 return filename.parent if filename else None
598 except KeyError:
599 return None
601 def _get_args_and_kwargs(self, key):
602 """
603 Read the args and kwargs that are associated with the key of a directive.
605 An example of such a directive:
607 hexapod:
608 device: class//egse.hexapod.PunaProxy
609 device_args: [PUNA_01]
610 device_kwargs:
611 sim: true
613 There might not be any positional nor keyword arguments provided in which
614 case and empty tuple and/or dictionary is returned.
616 Returns:
617 A tuple containing any positional arguments and a dictionary containing
618 keyword arguments.
619 """
620 try:
621 args = object.__getattribute__(self, f"{key}_args")
622 except AttributeError:
623 args = ()
624 try:
625 kwargs = object.__getattribute__(self, f"{key}_kwargs")
626 except AttributeError:
627 kwargs = {}
629 return args, kwargs
631 def set_private_attribute(self, key: str, value: Any) -> None:
632 """Sets a private attribute for this object.
634 The name in key will be accessible as an attribute for this object, but the key will not
635 be added to the dictionary and not be returned by methods like keys().
637 The idea behind this private attribute is to have the possibility to add status information
638 or identifiers to this classes object that can be used by save() or load() methods.
640 Args:
641 key (str): the name of the private attribute (must start with an underscore character).
642 value: the value for this private attribute
644 Examples:
645 >>> setup = NavigableDict({'a': 1, 'b': 2, 'c': 3})
646 >>> setup.set_private_attribute("_loaded_from_dict", True)
647 >>> assert "c" in setup
648 >>> assert "_loaded_from_dict" not in setup
649 >>> assert setup.get_private_attribute("_loaded_from_dict") == True
651 """
652 if key in self:
653 raise ValueError(f"Invalid argument key='{key}', this key already exists in the dictionary.")
654 if not key.startswith("_"):
655 raise ValueError(f"Invalid argument key='{key}', must start with underscore character '_'.")
656 self.__dict__[key] = value
658 def get_private_attribute(self, key: str) -> Any:
659 """Returns the value of the given private attribute.
661 Args:
662 key (str): the name of the private attribute (must start with an underscore character).
664 Returns:
665 the value of the private attribute given in `key` or None if the attribute doesn't exist.
667 Note:
668 Because of the implementation, this private attribute can also be accessed as a 'normal'
669 attribute of the object. This use is however discouraged as it will make your code less
670 understandable. Use the methods to access these 'private' attributes.
671 """
672 if not key.startswith("_"):
673 raise ValueError(f"Invalid argument key='{key}', must start with underscore character '_'.")
674 try:
675 return self.__dict__[key]
676 except KeyError:
677 return None
679 def has_private_attribute(self, key) -> bool:
680 """
681 Check if the given key is defined as a private attribute.
683 Args:
684 key (str): the name of a private attribute (must start with an underscore)
685 Returns:
686 True if the given key is a known private attribute.
687 Raises:
688 ValueError: when the key doesn't start with an underscore.
689 """
690 if not key.startswith("_"):
691 raise ValueError(f"Invalid argument key='{key}', must start with underscore character '_'.")
693 # logger.debug(f"{self.__dict__.keys()} for [id={id(self)}]")
695 try:
696 _ = self.__dict__[key]
697 return True
698 except KeyError:
699 return False
701 def get_raw_value(self, key):
702 """
703 Returns the raw value of the given key.
705 Some keys have special values that are interpreted by the NavigableDict class. An example is
706 a value that starts with 'class//'. When you access these values, they are first converted
707 from their raw value into their expected value, e.g. the instantiated object in the above
708 example. This method allows you to access the raw value before conversion.
709 """
710 try:
711 return object.__getattribute__(self, key)
712 except AttributeError:
713 raise KeyError(f"The key '{key}' is not defined.")
715 def __str__(self):
716 return self._pretty_str()
718 def _pretty_str(self, indent: int = 0):
719 msg = ""
721 for k, v in self.items():
722 if isinstance(v, NavigableDict):
723 msg += f"{' ' * indent}{k}:\n"
724 msg += v._pretty_str(indent + 1)
725 else:
726 msg += f"{' ' * indent}{k}: {v}\n"
728 return msg
730 def __rich__(self) -> Tree:
731 tree = Tree(self.__dict__["_label"] or "NavigableDict", guide_style="dim")
732 _walk_dict_tree(self, tree, text_style="dark grey")
733 return tree
735 def _save(self, fd, indent: int = 0):
736 """
737 Recursive method to write the dictionary to the file descriptor.
739 Indentation is done in steps of four spaces, i.e. `' '*indent`.
741 Args:
742 fd: a file descriptor as returned by the open() function
743 indent (int): indentation level of each line [default = 0]
745 """
747 # Note that the .items() method returns the actual values of the keys and doesn't use the
748 # __getattribute__ or __getitem__ methods. So the raw value is returned and not the
749 # _processed_ value.
751 for k, v in self.items():
752 # history shall be saved last, skip it for now
754 if k == "history":
755 continue
757 # make sure to escape a colon in the key name
759 if isinstance(k, str) and ":" in k:
760 k = '"' + k + '"'
762 if isinstance(v, NavigableDict):
763 fd.write(f"{' ' * indent}{k}:\n")
764 v._save(fd, indent + 1)
765 fd.flush()
766 continue
768 if isinstance(v, float):
769 v = f"{v:.6E}"
770 fd.write(f"{' ' * indent}{k}: {v}\n")
771 fd.flush()
773 # now save the history as the last item
775 if "history" in self:
776 fd.write(f"{' ' * indent}history:\n")
777 self.history._save(fd, indent + 1) # noqa
779 def get_memoized_keys(self):
780 return list(self.__dict__["_memoized"].keys())
782 def del_memoized_key(self, key: str):
783 try:
784 del self.__dict__["_memoized"][key]
785 return True
786 except KeyError:
787 return False
789 @staticmethod
790 def from_dict(my_dict: dict, label: str | None = None) -> NavigableDict:
791 """Create a NavigableDict from a given dictionary.
793 Remember that all keys in the given dictionary shall be of type 'str' in order to be
794 accessible as attributes.
796 Args:
797 my_dict: a Python dictionary
798 label: a label that will be attached to this navdict
800 Examples:
801 >>> setup = navdict.from_dict({"ID": "my-setup-001", "version": "0.1.0"}, label="Setup")
802 >>> assert setup["ID"] == setup.ID == "my-setup-001"
804 """
805 return NavigableDict(my_dict, label=label)
807 @staticmethod
808 def from_yaml_string(yaml_content: str | None = None, label: str | None = None) -> NavigableDict:
809 """Creates a NavigableDict from the given YAML string.
811 This method is mainly used for easy creation of a navdict from strings during unit tests.
813 Args:
814 yaml_content: a string containing YAML
815 label: a label that will be attached to this navdict
817 Returns:
818 a navdict that was loaded from the content of the given string.
819 """
821 if not yaml_content:
822 raise ValueError("Invalid argument to function: No input string or None given.")
824 yaml = YAML(typ="safe")
825 try:
826 data = yaml.load(yaml_content)
827 except ScannerError as exc:
828 raise ValueError(f"Invalid YAML string: {exc}")
830 return NavigableDict(data, label=label)
832 @staticmethod
833 def from_yaml_file(filename: str | Path | None = None) -> NavigableDict:
834 """Creates a navigable dictionary from the given YAML file.
836 Args:
837 filename (str): the path of the YAML file to be loaded
839 Returns:
840 a navdict that was loaded from the given location.
842 Raises:
843 ValueError: when no filename is given.
844 """
846 # logger.debug(f"{filename=}")
848 if not filename:
849 raise ValueError("Invalid argument to function: No filename or None given.")
851 # Make sure the filename exists and is a regular file
852 filename = Path(filename).expanduser().resolve()
853 if not filename.is_file():
854 raise ValueError(f"Invalid argument to function, filename does not exist: {filename!s}")
856 data = load_yaml(str(filename), parent_location=None)
858 if data == {}:
859 warnings.warn(f"Empty YAML file: {filename!s}")
861 return data
863 def to_yaml_file(
864 self,
865 filename: str | Path | None = None,
866 header: str | None = None,
867 top_level_group: str | None = None,
868 ) -> None:
869 """Saves a NavigableDict to a YAML file.
871 When no filename is provided, this method will look for a 'private' attribute
872 `_filename` and use that to save the data.
874 Args:
875 filename (str|Path): the path of the YAML file where to save the data
876 header (str): Custom header for this navdict
877 top_level_group (str): name of the optional top-level group
879 Note:
880 This method will **overwrite** the original or given YAML file and therefore you might
881 lose proper formatting and/or comments.
883 """
884 if filename is None and self.get_private_attribute("_filename") is None:
885 raise ValueError("No filename given or known, can not save navdict.")
887 if header is None:
888 header = textwrap.dedent(
889 f"""
890 # This YAML file is generated by:
891 #
892 # navdict.to_yaml_file(setup, filename="{filename}')
893 #
894 # Created on {datetime.datetime.now(tz=datetime.timezone.utc).isoformat()}
896 """
897 )
899 target = Path(filename) if filename is not None else Path(self.get_private_attribute("_filename"))
901 with target.open("w") as fd:
902 fd.write(header)
903 indent = 0
904 if top_level_group:
905 fd.write(f"{top_level_group}:\n")
906 indent = 1
908 self._save(fd, indent=indent)
910 self.set_private_attribute("_filename", target)
912 def get_filename(self) -> str | None:
913 """Returns the filename for this navdict or None when no filename could be determined."""
914 return self.get_private_attribute("_filename")
917navdict = NavigableDict
918NavDict = NavigableDict
919"""Shortcuts for NavigableDict and more Pythonic."""
922def _walk_dict_tree(dictionary: dict, tree: Tree, text_style: str = "green"):
923 for k, v in dictionary.items():
924 if isinstance(v, dict):
925 branch = tree.add(f"[purple]{k}", style="", guide_style="dim")
926 _walk_dict_tree(v, branch, text_style=text_style)
927 else:
928 text = Text.assemble((str(k), "medium_purple1"), ": ", (str(v), text_style))
929 tree.add(text)