Coverage for /Users/rik/github/navdict/src/navdict/navdict.py: 28%

350 statements  

« prev     ^ index     » next       coverage.py v7.8.2, created at 2025-10-16 22:47 +0200

1""" 

2NavDict: A navigable dictionary with dot notation access and automatic file loading. 

3 

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. 

8 

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 

14 

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 

21 

22Author: Rik Huygen 

23License: MIT 

24""" 

25 

26from __future__ import annotations 

27 

28__all__ = [ 

29 "navdict", # noqa: ignore typo 

30 "NavDict", 

31 "NavigableDict", 

32 "get_resource_location", 

33] 

34 

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 

47 

48from rich.text import Text 

49from rich.tree import Tree 

50from ruamel.yaml import YAML 

51from ruamel.yaml.scanner import ScannerError 

52 

53from navdict.directive import is_directive 

54from navdict.directive import unravel_directive 

55from navdict.directive import get_directive_plugin 

56 

57logger = logging.getLogger("navdict") 

58 

59 

60def load_class(class_name: str): 

61 """ 

62 Find and returns a class based on the fully qualified name. 

63 

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. 

66 

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:] 

74 

75 module_name, class_name = class_name.rsplit(".", 1) 

76 module = importlib.import_module(module_name) 

77 return getattr(module, class_name) 

78 

79 

80def get_resource_location(parent_location: Path | None, in_dir: str | None) -> Path: 

81 """ 

82 Returns the resource location. 

83 

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: 

87 

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. 

97 

98 Args: 

99 parent_location: the location of the parent navdict, or None 

100 in_dir: a location extracted from the directive's value. 

101 

102 Returns: 

103 A Path object with the resource location. 

104 

105 """ 

106 

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", ".")) 

118 

119 # logger.debug(f"{location=}, {fn=}") 

120 

121 return location 

122 

123 

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. 

127 

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. 

131 

132 Returns: 

133 A list of the split lines, i.e. a list of lists of strings. 

134 """ 

135 

136 # logger.debug(f"{resource_name=}, {parent_location=}") 

137 

138 if resource_name.startswith("csv//"): 

139 resource_name = resource_name[5:] 

140 

141 if not resource_name: 

142 raise ValueError(f"Resource name should not be empty, but contain a valid filename.") 

143 

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 

146 

147 try: 

148 n_header_rows = int(kwargs["header_rows"]) 

149 except KeyError: 

150 n_header_rows = 0 

151 

152 csv_location = get_resource_location(parent_location, in_dir) 

153 

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 """ 

159 

160 for line in itertools.islice(file_obj, n_skip, None): 

161 if not line.strip().startswith("#"): 

162 yield line 

163 

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 

172 

173 return data 

174 

175 

176def load_int_enum(enum_name: str, enum_content) -> IntEnum: 

177 """Dynamically build (and return) and IntEnum. 

178 

179 In the YAML file this will look like below. 

180 The IntEnum directive (where <name> is the class name): 

181 

182 enum: int_enum//<name> 

183 

184 The IntEnum content: 

185 

186 content: 

187 E: 

188 alias: ['E_SIDE', 'RIGHT_SIDE'] 

189 value: 1 

190 F: 

191 alias: ['F_SIDE', 'LEFT_SIDE'] 

192 value: 0 

193 

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:] 

200 

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"] 

208 

209 definition[side_name] = value 

210 

211 for alias in aliases: 

212 definition[alias] = value 

213 

214 return IntEnum(enum_name, definition) 

215 

216 

217def load_yaml(resource_name: str, parent_location: Path | None = None, *args, **kwargs) -> NavigableDict: 

218 """Find and return the content of a YAML file.""" 

219 

220 # logger.debug(f"{resource_name=}, {parent_location=}") 

221 

222 if resource_name.startswith("yaml//"): 

223 resource_name = resource_name[6:] 

224 

225 parts = resource_name.rsplit("/", 1) 

226 

227 in_dir, fn = parts if len(parts) > 1 else (None, parts[0]) # use a tuple here to make Mypy happy 

228 

229 yaml_location = get_resource_location(parent_location, in_dir) 

230 

231 try: 

232 yaml = YAML(typ="safe") 

233 with open(yaml_location / fn, "r") as file: 

234 data = yaml.load(file) 

235 

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 

249 

250 data = NavigableDict(data, _filename=yaml_location / fn) 

251 

252 # logger.debug(f"{data.get_private_attribute('_filename')=}") 

253 

254 return data 

255 

256 

257def _get_attribute(self, name, default): 

258 """ 

259 Safely retrieve an attribute from the object, returning a default if not found. 

260 

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. 

264 

265 Args: 

266 name (str): The name of the attribute to retrieve. 

267 default: The value to return if the attribute does not exist. 

268 

269 Returns: 

270 The attribute value if it exists, otherwise the default value. 

271 

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 

281 

282 

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`. 

289 

290 Args: 

291 head (dict): the original dictionary 

292 label (str): a label or name that is used when printing the navdict 

293 

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 

298 

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`. 

302 

303 """ 

304 

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 

316 

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 

324 

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. 

327 

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 

332 

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)) 

339 

340 def get_label(self) -> str | None: 

341 return self.__dict__["_label"] 

342 

343 def set_label(self, value: str): 

344 self.__dict__["_label"] = value 

345 

346 def add(self, key: str, value: Any): 

347 """Set a value for the given key. 

348 

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. 

351 

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) 

359 

360 def clear(self) -> None: 

361 for key in list(self.keys()): 

362 self.__delitem__(key) 

363 

364 def __repr__(self): 

365 return f"{self.__class__.__name__}({super()!r}) [id={id(self)}]" 

366 

367 def __delitem__(self, key): 

368 dict.__delitem__(self, key) 

369 object.__delattr__(self, key) 

370 

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 

381 

382 def __getattr__(self, key): 

383 cls = type(self) 

384 # logger.info(f"Called __getattr__({key}) ...") 

385 

386 try: 

387 alias = self._alias_hook(key) 

388 return self.__dict__[alias] 

389 except NotImplementedError: 

390 raise AttributeError(f"{cls.__name__!r} object has no attribute {key!r}") 

391 

392 @staticmethod 

393 def _alias_hook(key): 

394 raise NotImplementedError 

395 

396 def set_alias_hook(self, hook: Callable[[str], Any]): 

397 setattr(self, "_alias_hook", hook) 

398 

399 # This method is called: 

400 # - for *every* single attribute access on an object using dot notation. 

401 # - when using the `getattr(obj, 'name') function 

402 # - accessing any kind of attributes, e.g. instance or class variables, 

403 # methods, properties, dunder methods, ... 

404 # 

405 # Note: `__getattr__` is only called when an attribute cannot be found 

406 # through normal means. 

407 def __getattribute__(self, key): 

408 # logger.info(f"called __getattribute__({key}) ...") 

409 try: 

410 value = object.__getattribute__(self, key) 

411 except AttributeError: 

412 raise # this will call self.__getattr__(key) 

413 

414 if key.startswith("__"): # small optimization 

415 return value 

416 # We can not directly call the `_handle_directive` function here due to infinite recursion 

417 if is_directive(value): 417 ↛ 418line 417 didn't jump to line 418 because the condition on line 417 was never true

418 m = object.__getattribute__(self, "_handle_directive") 

419 return m(key, value) 

420 else: 

421 return value 

422 

423 def __delattr__(self, item): 

424 # logger.info(f"called __delattr__({self!r}, {item})") 

425 object.__delattr__(self, item) 

426 dict.__delitem__(self, item) 

427 

428 def __setitem__(self, key, value): 

429 # logger.debug(f"called __setitem__({self!r}, {key}, {value})") 

430 if isinstance(value, dict) and not isinstance(value, NavigableDict): 

431 value = NavigableDict(value) 

432 super().__setitem__(key, value) 

433 self.__dict__[key] = value 

434 try: 

435 del self.__dict__["_memoized"][key] 

436 except KeyError: 

437 pass 

438 

439 # This method is called: 

440 # - whenever square brackets `[]` are used on an object, e.g. indexing or slicing. 

441 # - during iteration, if an object doesn't have __iter__ defined, Python will try 

442 # to iterate using __getitem__ with successive integer indices starting from 0. 

443 def __getitem__(self, key): 

444 # logger.info(f"called __getitem__({self!r}, {key})") 

445 try: 

446 value = super().__getitem__(key) 

447 except KeyError: 

448 try: 

449 alias = self._alias_hook(key) 

450 value = super().__getitem__(alias) 

451 except NotImplementedError: 

452 raise KeyError(f"{type(self).__name__!r} has no key {key!r}") 

453 

454 if isinstance(key, str) and key.startswith("__"): 454 ↛ 455line 454 didn't jump to line 455 because the condition on line 454 was never true

455 return value 

456 # no danger for recursion here, so we can directly call the function 

457 if is_directive(value): 457 ↛ 458line 457 didn't jump to line 458 because the condition on line 457 was never true

458 return self._handle_directive(key, value) 

459 else: 

460 return value 

461 

462 def _handle_directive(self, key, value) -> Any: 

463 """ 

464 This method will handle the available directives. This may be builtin directives 

465 like `class/` or `factory//`, or it may be external directives that were provided 

466 as a plugin. Some builtin directives have also been provided as a plugin, e.g. 

467 'yaml//' and 'csv//'. 

468 

469 Args: 

470 key: the key of the field that might contain a directive 

471 value: the value which might be a directive 

472 

473 Returns: 

474 This function will return the value, either the original value or the result of 

475 evaluating and executing a directive. 

476 """ 

477 # logger.debug(f"called _handle_directive({key}, {value!r}) [id={id(self)}]") 

478 

479 directive_key, directive_value = unravel_directive(value) 

480 # logger.debug(f"{directive_key=}, {directive_value=}") 

481 

482 if directive := get_directive_plugin(directive_key): 

483 # logger.debug(f"{directive.name=}") 

484 

485 if key in self.__dict__["_memoized"]: 

486 return self.__dict__["_memoized"][key] 

487 

488 args, kwargs = self._get_args_and_kwargs(key) 

489 parent_location = self._get_location() 

490 result = directive.func(directive_value, parent_location, *args, **kwargs) 

491 

492 self.__dict__["_memoized"][key] = result 

493 return result 

494 

495 match directive_key: 

496 case "class": 

497 args, kwargs = self._get_args_and_kwargs(key) 

498 return load_class(directive_value)(*args, **kwargs) 

499 

500 case "factory": 

501 factory_args = _get_attribute(self, f"{key}_args", {}) 

502 return load_class(directive_value)().create(**factory_args) 

503 

504 case "int_enum": 

505 content = object.__getattribute__(self, "content") 

506 return load_int_enum(directive_value, content) 

507 

508 case _: 

509 return value 

510 

511 def _get_location(self): 

512 """Returns the location of the file from which this NavDict was loaded or None if no location exists.""" 

513 try: 

514 filename = self.__dict__["_filename"] 

515 return filename.parent if filename else None 

516 except KeyError: 

517 return None 

518 

519 def _get_args_and_kwargs(self, key): 

520 """ 

521 Read the args and kwargs that are associated with the key of a directive. 

522 

523 An example of such a directive: 

524 

525 hexapod: 

526 device: class//egse.hexapod.PunaProxy 

527 device_args: [PUNA_01] 

528 device_kwargs: 

529 sim: true 

530 

531 There might not be any positional nor keyword arguments provided in which 

532 case and empty tuple and/or dictionary is returned. 

533 

534 Returns: 

535 A tuple containing any positional arguments and a dictionary containing 

536 keyword arguments. 

537 """ 

538 try: 

539 args = object.__getattribute__(self, f"{key}_args") 

540 except AttributeError: 

541 args = () 

542 try: 

543 kwargs = object.__getattribute__(self, f"{key}_kwargs") 

544 except AttributeError: 

545 kwargs = {} 

546 

547 return args, kwargs 

548 

549 def set_private_attribute(self, key: str, value: Any) -> None: 

550 """Sets a private attribute for this object. 

551 

552 The name in key will be accessible as an attribute for this object, but the key will not 

553 be added to the dictionary and not be returned by methods like keys(). 

554 

555 The idea behind this private attribute is to have the possibility to add status information 

556 or identifiers to this classes object that can be used by save() or load() methods. 

557 

558 Args: 

559 key (str): the name of the private attribute (must start with an underscore character). 

560 value: the value for this private attribute 

561 

562 Examples: 

563 >>> setup = NavigableDict({'a': 1, 'b': 2, 'c': 3}) 

564 >>> setup.set_private_attribute("_loaded_from_dict", True) 

565 >>> assert "c" in setup 

566 >>> assert "_loaded_from_dict" not in setup 

567 >>> assert setup.get_private_attribute("_loaded_from_dict") == True 

568 

569 """ 

570 if key in self: 

571 raise ValueError(f"Invalid argument key='{key}', this key already exists in the dictionary.") 

572 if not key.startswith("_"): 

573 raise ValueError(f"Invalid argument key='{key}', must start with underscore character '_'.") 

574 self.__dict__[key] = value 

575 

576 def get_private_attribute(self, key: str) -> Any: 

577 """Returns the value of the given private attribute. 

578 

579 Args: 

580 key (str): the name of the private attribute (must start with an underscore character). 

581 

582 Returns: 

583 the value of the private attribute given in `key` or None if the attribute doesn't exist. 

584 

585 Note: 

586 Because of the implementation, this private attribute can also be accessed as a 'normal' 

587 attribute of the object. This use is however discouraged as it will make your code less 

588 understandable. Use the methods to access these 'private' attributes. 

589 """ 

590 if not key.startswith("_"): 

591 raise ValueError(f"Invalid argument key='{key}', must start with underscore character '_'.") 

592 try: 

593 return self.__dict__[key] 

594 except KeyError: 

595 return None 

596 

597 def has_private_attribute(self, key) -> bool: 

598 """ 

599 Check if the given key is defined as a private attribute. 

600 

601 Args: 

602 key (str): the name of a private attribute (must start with an underscore) 

603 Returns: 

604 True if the given key is a known private attribute. 

605 Raises: 

606 ValueError: when the key doesn't start with an underscore. 

607 """ 

608 if not key.startswith("_"): 

609 raise ValueError(f"Invalid argument key='{key}', must start with underscore character '_'.") 

610 

611 # logger.debug(f"{self.__dict__.keys()} for [id={id(self)}]") 

612 

613 try: 

614 _ = self.__dict__[key] 

615 return True 

616 except KeyError: 

617 return False 

618 

619 def get_raw_value(self, key): 

620 """ 

621 Returns the raw value of the given key. 

622 

623 Some keys have special values that are interpreted by the NavigableDict class. An example is 

624 a value that starts with 'class//'. When you access these values, they are first converted 

625 from their raw value into their expected value, e.g. the instantiated object in the above 

626 example. This method allows you to access the raw value before conversion. 

627 """ 

628 try: 

629 return object.__getattribute__(self, key) 

630 except AttributeError: 

631 raise KeyError(f"The key '{key}' is not defined.") 

632 

633 def __str__(self): 

634 return self._pretty_str() 

635 

636 def _pretty_str(self, indent: int = 0): 

637 msg = "" 

638 

639 for k, v in self.items(): 

640 if isinstance(v, NavigableDict): 

641 msg += f"{' ' * indent}{k}:\n" 

642 msg += v._pretty_str(indent + 1) 

643 else: 

644 msg += f"{' ' * indent}{k}: {v}\n" 

645 

646 return msg 

647 

648 def __rich__(self) -> Tree: 

649 tree = Tree(self.__dict__["_label"] or "NavigableDict", guide_style="dim") 

650 _walk_dict_tree(self, tree, text_style="dark grey") 

651 return tree 

652 

653 def _save(self, fd, indent: int = 0): 

654 """ 

655 Recursive method to write the dictionary to the file descriptor. 

656 

657 Indentation is done in steps of four spaces, i.e. `' '*indent`. 

658 

659 Args: 

660 fd: a file descriptor as returned by the open() function 

661 indent (int): indentation level of each line [default = 0] 

662 

663 """ 

664 

665 # Note that the .items() method returns the actual values of the keys and doesn't use the 

666 # __getattribute__ or __getitem__ methods. So the raw value is returned and not the 

667 # _processed_ value. 

668 

669 for k, v in self.items(): 

670 # history shall be saved last, skip it for now 

671 

672 if k == "history": 

673 continue 

674 

675 # make sure to escape a colon in the key name 

676 

677 if isinstance(k, str) and ":" in k: 

678 k = '"' + k + '"' 

679 

680 if isinstance(v, NavigableDict): 

681 fd.write(f"{' ' * indent}{k}:\n") 

682 v._save(fd, indent + 1) 

683 fd.flush() 

684 continue 

685 

686 if isinstance(v, float): 

687 v = f"{v:.6E}" 

688 fd.write(f"{' ' * indent}{k}: {v}\n") 

689 fd.flush() 

690 

691 # now save the history as the last item 

692 

693 if "history" in self: 

694 fd.write(f"{' ' * indent}history:\n") 

695 self.history._save(fd, indent + 1) # noqa 

696 

697 def get_memoized_keys(self): 

698 return list(self.__dict__["_memoized"].keys()) 

699 

700 def del_memoized_key(self, key: str): 

701 try: 

702 del self.__dict__["_memoized"][key] 

703 return True 

704 except KeyError: 

705 return False 

706 

707 @staticmethod 

708 def from_dict(my_dict: dict, label: str | None = None) -> NavigableDict: 

709 """Create a NavigableDict from a given dictionary. 

710 

711 Remember that all keys in the given dictionary shall be of type 'str' in order to be 

712 accessible as attributes. 

713 

714 Args: 

715 my_dict: a Python dictionary 

716 label: a label that will be attached to this navdict 

717 

718 Examples: 

719 >>> setup = navdict.from_dict({"ID": "my-setup-001", "version": "0.1.0"}, label="Setup") 

720 >>> assert setup["ID"] == setup.ID == "my-setup-001" 

721 

722 """ 

723 return NavigableDict(my_dict, label=label) 

724 

725 @staticmethod 

726 def from_yaml_string(yaml_content: str | None = None, label: str | None = None) -> NavigableDict: 

727 """Creates a NavigableDict from the given YAML string. 

728 

729 This method is mainly used for easy creation of a navdict from strings during unit tests. 

730 

731 Args: 

732 yaml_content: a string containing YAML 

733 label: a label that will be attached to this navdict 

734 

735 Returns: 

736 a navdict that was loaded from the content of the given string. 

737 """ 

738 

739 if not yaml_content: 

740 raise ValueError("Invalid argument to function: No input string or None given.") 

741 

742 yaml = YAML(typ="safe") 

743 try: 

744 data = yaml.load(yaml_content) 

745 except ScannerError as exc: 

746 raise ValueError(f"Invalid YAML string: {exc}") 

747 

748 return NavigableDict(data, label=label) 

749 

750 @staticmethod 

751 def from_yaml_file(filename: str | Path | None = None) -> NavigableDict: 

752 """Creates a navigable dictionary from the given YAML file. 

753 

754 Args: 

755 filename (str): the path of the YAML file to be loaded 

756 

757 Returns: 

758 a navdict that was loaded from the given location. 

759 

760 Raises: 

761 ValueError: when no filename is given. 

762 """ 

763 

764 # logger.debug(f"{filename=}") 

765 

766 if not filename: 

767 raise ValueError("Invalid argument to function: No filename or None given.") 

768 

769 # Make sure the filename exists and is a regular file 

770 filename = Path(filename).expanduser().resolve() 

771 if not filename.is_file(): 

772 raise ValueError(f"Invalid argument to function, filename does not exist: {filename!s}") 

773 

774 data = load_yaml(str(filename)) 

775 

776 if data == {}: 

777 warnings.warn(f"Empty YAML file: {filename!s}") 

778 

779 return data 

780 

781 def to_yaml_file(self, filename: str | Path | None = None, header: str = None, top_level_group: str = None) -> None: 

782 """Saves a NavigableDict to a YAML file. 

783 

784 When no filename is provided, this method will look for a 'private' attribute 

785 `_filename` and use that to save the data. 

786 

787 Args: 

788 filename (str|Path): the path of the YAML file where to save the data 

789 header (str): Custom header for this navdict 

790 top_level_group (str): name of the optional top-level group 

791 

792 Note: 

793 This method will **overwrite** the original or given YAML file and therefore you might 

794 lose proper formatting and/or comments. 

795 

796 """ 

797 if filename is None and self.get_private_attribute("_filename") is None: 

798 raise ValueError("No filename given or known, can not save navdict.") 

799 

800 if header is None: 

801 header = textwrap.dedent( 

802 f""" 

803 # This YAML file is generated by: 

804 # 

805 # navdict.to_yaml_file(setup, filename="{filename}') 

806 # 

807 # Created on {datetime.datetime.now(tz=datetime.timezone.utc).isoformat()} 

808 

809 """ 

810 ) 

811 

812 with Path(filename).open("w") as fd: 

813 fd.write(header) 

814 indent = 0 

815 if top_level_group: 

816 fd.write(f"{top_level_group}:\n") 

817 indent = 1 

818 

819 self._save(fd, indent=indent) 

820 

821 self.set_private_attribute("_filename", Path(filename)) 

822 

823 def get_filename(self) -> str | None: 

824 """Returns the filename for this navdict or None when no filename could be determined.""" 

825 return self.get_private_attribute("_filename") 

826 

827 

828navdict = NavigableDict 

829NavDict = NavigableDict 

830"""Shortcuts for NavigableDict and more Pythonic.""" 

831 

832 

833def _walk_dict_tree(dictionary: dict, tree: Tree, text_style: str = "green"): 

834 for k, v in dictionary.items(): 

835 if isinstance(v, dict): 

836 branch = tree.add(f"[purple]{k}", style="", guide_style="dim") 

837 _walk_dict_tree(v, branch, text_style=text_style) 

838 else: 

839 text = Text.assemble((str(k), "medium_purple1"), ": ", (str(v), text_style)) 

840 tree.add(text)