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

353 statements  

« prev     ^ index     » next       coverage.py v7.8.2, created at 2025-10-17 09:34 +0200

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 @staticmethod 

383 def _alias_hook(key: str) -> str: 

384 raise NotImplementedError 

385 

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

387 """ 

388 Sets an alias (hook) function that maps the given argument, an attribute 

389 or a dict key, to a valid attribute or key. 

390 

391 The `hook` function accepts a string argument and return a string for 

392 which the argument is an alias. The returned argument is expected to 

393 be a valid attribute or key for this navdict. 

394 """ 

395 # The setattr() function will add the attribute to the dictionary 

396 # which is not what we want. 

397 # setattr(self, "_alias_hook", hook) 

398 self.__dict__["_alias_hook"] = hook 

399 

400 # This method is called: 

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

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

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

404 # methods, properties, dunder methods, ... 

405 # 

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

407 # through normal means. 

408 def __getattribute__(self, key): 

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

410 try: 

411 value = object.__getattribute__(self, key) 

412 except AttributeError: 

413 try: 

414 alias = self._alias_hook(key) 

415 value = object.__getattribute__(self, alias) 

416 except NotImplementedError: 

417 raise AttributeError(f"{type(self).__name__!r} object has no attribute {key!r}") 

418 except Exception as exc: 

419 logger.error(f"Alias hook function raised {type(exc).__name__!r}: {exc}") 

420 raise AttributeError(f"{type(self).__name__!r} object has no attribute {key!r}") 

421 

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

423 return value 

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

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

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

427 return m(key, value) 

428 else: 

429 return value 

430 

431 def __delattr__(self, item): 

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

433 object.__delattr__(self, item) 

434 dict.__delitem__(self, item) 

435 

436 def __setitem__(self, key, value): 

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

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

439 value = NavigableDict(value) 

440 super().__setitem__(key, value) 

441 self.__dict__[key] = value 

442 try: 

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

444 except KeyError: 

445 pass 

446 

447 # This method is called: 

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

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

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

451 def __getitem__(self, key): 

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

453 try: 

454 value = super().__getitem__(key) 

455 except KeyError: 

456 try: 

457 alias = self._alias_hook(key) 

458 value = super().__getitem__(alias) 

459 except NotImplementedError: 

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

461 except Exception as exc: 

462 logger.error(f"Alias hook function raised {type(exc).__name__!r}: {exc}") 

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

464 

465 

466 if isinstance(key, str) and key.startswith("__"): 

467 return value 

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

469 if is_directive(value): 

470 return self._handle_directive(key, value) 

471 else: 

472 return value 

473 

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

475 """ 

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

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

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

479 'yaml//' and 'csv//'. 

480 

481 Args: 

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

483 value: the value which might be a directive 

484 

485 Returns: 

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

487 evaluating and executing a directive. 

488 """ 

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

490 

491 directive_key, directive_value = unravel_directive(value) 

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

493 

494 if directive := get_directive_plugin(directive_key): 

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

496 

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

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

499 

500 args, kwargs = self._get_args_and_kwargs(key) 

501 parent_location = self._get_location() 

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

503 

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

505 return result 

506 

507 match directive_key: 

508 case "class": 

509 args, kwargs = self._get_args_and_kwargs(key) 

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

511 

512 case "factory": 

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

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

515 

516 case "int_enum": 

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

518 return load_int_enum(directive_value, content) 

519 

520 case _: 

521 return value 

522 

523 def _get_location(self): 

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

525 try: 

526 filename = self.__dict__["_filename"] 

527 return filename.parent if filename else None 

528 except KeyError: 

529 return None 

530 

531 def _get_args_and_kwargs(self, key): 

532 """ 

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

534 

535 An example of such a directive: 

536 

537 hexapod: 

538 device: class//egse.hexapod.PunaProxy 

539 device_args: [PUNA_01] 

540 device_kwargs: 

541 sim: true 

542 

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

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

545 

546 Returns: 

547 A tuple containing any positional arguments and a dictionary containing 

548 keyword arguments. 

549 """ 

550 try: 

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

552 except AttributeError: 

553 args = () 

554 try: 

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

556 except AttributeError: 

557 kwargs = {} 

558 

559 return args, kwargs 

560 

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

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

563 

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

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

566 

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

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

569 

570 Args: 

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

572 value: the value for this private attribute 

573 

574 Examples: 

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

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

577 >>> assert "c" in setup 

578 >>> assert "_loaded_from_dict" not in setup 

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

580 

581 """ 

582 if key in self: 

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

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

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

586 self.__dict__[key] = value 

587 

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

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

590 

591 Args: 

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

593 

594 Returns: 

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

596 

597 Note: 

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

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

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

601 """ 

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

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

604 try: 

605 return self.__dict__[key] 

606 except KeyError: 

607 return None 

608 

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

610 """ 

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

612 

613 Args: 

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

615 Returns: 

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

617 Raises: 

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

619 """ 

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

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

622 

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

624 

625 try: 

626 _ = self.__dict__[key] 

627 return True 

628 except KeyError: 

629 return False 

630 

631 def get_raw_value(self, key): 

632 """ 

633 Returns the raw value of the given key. 

634 

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

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

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

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

639 """ 

640 try: 

641 return object.__getattribute__(self, key) 

642 except AttributeError: 

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

644 

645 def __str__(self): 

646 return self._pretty_str() 

647 

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

649 msg = "" 

650 

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

652 if isinstance(v, NavigableDict): 

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

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

655 else: 

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

657 

658 return msg 

659 

660 def __rich__(self) -> Tree: 

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

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

663 return tree 

664 

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

666 """ 

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

668 

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

670 

671 Args: 

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

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

674 

675 """ 

676 

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

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

679 # _processed_ value. 

680 

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

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

683 

684 if k == "history": 

685 continue 

686 

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

688 

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

690 k = '"' + k + '"' 

691 

692 if isinstance(v, NavigableDict): 

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

694 v._save(fd, indent + 1) 

695 fd.flush() 

696 continue 

697 

698 if isinstance(v, float): 

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

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

701 fd.flush() 

702 

703 # now save the history as the last item 

704 

705 if "history" in self: 

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

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

708 

709 def get_memoized_keys(self): 

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

711 

712 def del_memoized_key(self, key: str): 

713 try: 

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

715 return True 

716 except KeyError: 

717 return False 

718 

719 @staticmethod 

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

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

722 

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

724 accessible as attributes. 

725 

726 Args: 

727 my_dict: a Python dictionary 

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

729 

730 Examples: 

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

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

733 

734 """ 

735 return NavigableDict(my_dict, label=label) 

736 

737 @staticmethod 

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

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

740 

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

742 

743 Args: 

744 yaml_content: a string containing YAML 

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

746 

747 Returns: 

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

749 """ 

750 

751 if not yaml_content: 751 ↛ 752line 751 didn't jump to line 752 because the condition on line 751 was never true

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

753 

754 yaml = YAML(typ="safe") 

755 try: 

756 data = yaml.load(yaml_content) 

757 except ScannerError as exc: 

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

759 

760 return NavigableDict(data, label=label) 

761 

762 @staticmethod 

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

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

765 

766 Args: 

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

768 

769 Returns: 

770 a navdict that was loaded from the given location. 

771 

772 Raises: 

773 ValueError: when no filename is given. 

774 """ 

775 

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

777 

778 if not filename: 

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

780 

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

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

783 if not filename.is_file(): 

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

785 

786 data = load_yaml(str(filename)) 

787 

788 if data == {}: 

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

790 

791 return data 

792 

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

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

795 

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

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

798 

799 Args: 

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

801 header (str): Custom header for this navdict 

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

803 

804 Note: 

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

806 lose proper formatting and/or comments. 

807 

808 """ 

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

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

811 

812 if header is None: 

813 header = textwrap.dedent( 

814 f""" 

815 # This YAML file is generated by: 

816 # 

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

818 # 

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

820 

821 """ 

822 ) 

823 

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

825 fd.write(header) 

826 indent = 0 

827 if top_level_group: 

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

829 indent = 1 

830 

831 self._save(fd, indent=indent) 

832 

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

834 

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

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

837 return self.get_private_attribute("_filename") 

838 

839 

840navdict = NavigableDict 

841NavDict = NavigableDict 

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

843 

844 

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

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

847 if isinstance(v, dict): 

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

849 _walk_dict_tree(v, branch, text_style=text_style) 

850 else: 

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

852 tree.add(text)