Coverage for src/navdict/navdict.py: 16%

382 statements  

« prev     ^ index     » next       coverage.py v7.8.2, created at 2026-07-17 13:13 +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 "expand_env_vars", 

34] 

35 

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 

51 

52from rich.text import Text 

53from rich.tree import Tree 

54from ruamel.yaml import YAML 

55from ruamel.yaml.scanner import ScannerError 

56 

57from navdict.directive import is_directive 

58from navdict.directive import unravel_directive 

59from navdict.directive import get_directive_plugin 

60 

61logger = logging.getLogger("navdict") 

62 

63T = TypeVar("T") 

64 

65 

66def load_class(class_name: str): 

67 """ 

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

69 

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. 

72 

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

80 

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

82 module = importlib.import_module(module_name) 

83 return getattr(module, class_name) 

84 

85 

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

87 """ 

88 Returns the resource location. 

89 

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: 

93 

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. 

103 

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. 

106 

107 Args: 

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

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

110 

111 Returns: 

112 A Path object with the resource location. 

113 

114 """ 

115 

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

127 

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

129 

130 return location.expanduser() 

131 

132 

133ENV_VAR_PATTERN = re.compile(r"ENV\[\s*['\"]?(\w+)['\"]?\s*]") 

134 

135 

136def expand_env_vars(value: str) -> str: 

137 """ 

138 Expand `ENV[VARNAME]` references in `value` with the value of the corresponding environment 

139 variable. 

140 

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. 

143 

144 Args: 

145 value: a string that may contain zero or more `ENV[VARNAME]` references. 

146 

147 Returns: 

148 The string with all `ENV[VARNAME]` references expanded. 

149 

150 Raises: 

151 ValueError: when a referenced environment variable is not set. 

152 """ 

153 

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 

160 

161 return ENV_VAR_PATTERN.sub(_replace, value) 

162 

163 

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. 

167 

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: 

170 

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. 

175 

176 Returns: 

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

178 """ 

179 

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

181 

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

183 resource_name = resource_name[5:] 

184 

185 if not resource_name: 

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

187 

188 if kwargs.get("expand_env", True): 

189 resource_name = expand_env_vars(resource_name) 

190 

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 

193 

194 try: 

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

196 except KeyError: 

197 n_header_rows = 0 

198 

199 csv_location = get_resource_location(parent_location, in_dir) 

200 

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

206 

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

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

209 yield line 

210 

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 

219 

220 return data 

221 

222 

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

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

225 

226 In the YAML file this will look like below. 

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

228 

229 enum: int_enum//<name> 

230 

231 The IntEnum content: 

232 

233 content: 

234 E: 

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

236 value: 1 

237 F: 

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

239 value: 0 

240 

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

247 

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

255 

256 definition[side_name] = value 

257 

258 for alias in aliases: 

259 definition[alias] = value 

260 

261 return IntEnum(enum_name, definition) 

262 

263 

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

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

266 

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

268 

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

270 resource_name = resource_name[6:] 

271 

272 if not resource_name: 

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

274 

275 if kwargs.get("expand_env", True): 

276 resource_name = expand_env_vars(resource_name) 

277 

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 

280 

281 yaml_location = get_resource_location(parent_location, in_dir) 

282 

283 try: 

284 yaml = YAML(typ="safe") 

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

286 data = yaml.load(file) 

287 

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 

301 

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

303 

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

305 

306 return data 

307 

308 

309def _get_attribute(self, name, default): 

310 """ 

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

312 

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. 

316 

317 Args: 

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

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

320 

321 Returns: 

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

323 

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 

333 

334 

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

341 

342 Args: 

343 head (dict): the original dictionary 

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

345 

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 

350 

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

354 

355 """ 

356 

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 

368 

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 

376 

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. 

379 

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 

384 

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

391 

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

393 return self.__dict__["_label"] 

394 

395 def set_label(self, value: str): 

396 self.__dict__["_label"] = value 

397 

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

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

400 

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. 

403 

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) 

411 

412 def clear(self) -> None: 

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

414 self.__delitem__(key) 

415 

416 def __repr__(self): 

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

418 

419 def __delitem__(self, key): 

420 dict.__delitem__(self, key) 

421 object.__delattr__(self, key) 

422 

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 

433 

434 @staticmethod 

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

436 raise NotImplementedError 

437 

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. 

442 

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 

451 

452 def as_typed(self, schema_type: type[T]) -> T: 

453 """Return this instance typed as `schema_type` for static analysis. 

454 

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) 

461 

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) 

470 

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, AttributeError): 

488 # Either the alias hook is not implemented or the alias hook just returned a key that still doesn't exist. 

489 # In both cases we want to raise an AttributeError to indicate that the attribute does not exist. 

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

491 except Exception as exc: 

492 logger.error( 

493 f"Alias hook function {self._alias_hook.__name__!r}({key}) raised {type(exc).__name__!r}: {exc}" 

494 ) 

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

496 

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

498 return value 

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

500 if is_directive(value): 

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

502 return m(key, value) 

503 else: 

504 return value 

505 

506 def __delattr__(self, item): 

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

508 object.__delattr__(self, item) 

509 dict.__delitem__(self, item) 

510 

511 def __setitem__(self, key, value): 

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

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

514 value = NavigableDict(value, label=key) 

515 super().__setitem__(key, value) 

516 self.__dict__[key] = value 

517 try: 

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

519 except KeyError: 

520 pass 

521 

522 # This method is called: 

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

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

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

526 def __getitem__(self, key): 

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

528 try: 

529 value = super().__getitem__(key) 

530 except KeyError: 

531 try: 

532 alias = self._alias_hook(key) 

533 value = super().__getitem__(alias) 

534 except NotImplementedError: 

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

536 except Exception as exc: 

537 logger.error( 

538 f"Alias hook function {self._alias_hook.__name__!r}({key}) raised {type(exc).__name__!r}: {exc}" 

539 ) 

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

541 

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

543 return value 

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

545 if is_directive(value): 

546 return self._handle_directive(key, value) 

547 else: 

548 return value 

549 

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

551 """ 

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

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

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

555 'yaml//' and 'csv//'. 

556 

557 Args: 

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

559 value: the value which might be a directive 

560 

561 Returns: 

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

563 evaluating and executing a directive. 

564 """ 

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

566 

567 directive_key, directive_value = unravel_directive(value) 

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

569 

570 if directive := get_directive_plugin(directive_key): 

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

572 

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

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

575 

576 args, kwargs = self._get_args_and_kwargs(key) 

577 parent_location = self._get_location() 

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

579 

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

581 return result 

582 

583 match directive_key: 

584 case "class": 

585 args, kwargs = self._get_args_and_kwargs(key) 

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

587 

588 case "factory": 

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

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

591 

592 case "int_enum": 

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

594 return load_int_enum(directive_value, content) 

595 

596 case _: 

597 return value 

598 

599 def _get_location(self): 

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

601 try: 

602 filename = self.__dict__["_filename"] 

603 return filename.parent if filename else None 

604 except KeyError: 

605 return None 

606 

607 def _get_args_and_kwargs(self, key): 

608 """ 

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

610 

611 An example of such a directive: 

612 

613 hexapod: 

614 device: class//egse.hexapod.PunaProxy 

615 device_args: [PUNA_01] 

616 device_kwargs: 

617 sim: true 

618 

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

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

621 

622 Returns: 

623 A tuple containing any positional arguments and a dictionary containing 

624 keyword arguments. 

625 """ 

626 try: 

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

628 except AttributeError: 

629 args = () 

630 try: 

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

632 except AttributeError: 

633 kwargs = {} 

634 

635 return args, kwargs 

636 

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

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

639 

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

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

642 

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

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

645 

646 Args: 

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

648 value: the value for this private attribute 

649 

650 Examples: 

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

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

653 >>> assert "c" in setup 

654 >>> assert "_loaded_from_dict" not in setup 

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

656 

657 """ 

658 if key in self: 

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

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

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

662 self.__dict__[key] = value 

663 

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

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

666 

667 Args: 

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

669 

670 Returns: 

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

672 

673 Note: 

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

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

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

677 """ 

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

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

680 try: 

681 return self.__dict__[key] 

682 except KeyError: 

683 return None 

684 

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

686 """ 

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

688 

689 Args: 

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

691 Returns: 

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

693 Raises: 

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

695 """ 

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

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

698 

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

700 

701 try: 

702 _ = self.__dict__[key] 

703 return True 

704 except KeyError: 

705 return False 

706 

707 def get_raw_value(self, key): 

708 """ 

709 Returns the raw value of the given key. 

710 

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

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

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

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

715 """ 

716 try: 

717 return object.__getattribute__(self, key) 

718 except AttributeError: 

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

720 

721 def __str__(self): 

722 return self._pretty_str() 

723 

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

725 msg = "" 

726 

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

728 if isinstance(v, NavigableDict): 

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

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

731 else: 

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

733 

734 return msg 

735 

736 def __rich__(self) -> Tree: 

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

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

739 return tree 

740 

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

742 """ 

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

744 

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

746 

747 Args: 

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

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

750 

751 """ 

752 

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

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

755 # _processed_ value. 

756 

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

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

759 

760 if k == "history": 

761 continue 

762 

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

764 

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

766 k = '"' + k + '"' 

767 

768 if isinstance(v, NavigableDict): 

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

770 v._save(fd, indent + 1) 

771 fd.flush() 

772 continue 

773 

774 if isinstance(v, float): 

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

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

777 fd.flush() 

778 

779 # now save the history as the last item 

780 

781 if "history" in self: 

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

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

784 

785 def get_memoized_keys(self): 

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

787 

788 def del_memoized_key(self, key: str): 

789 try: 

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

791 return True 

792 except KeyError: 

793 return False 

794 

795 @staticmethod 

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

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

798 

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

800 accessible as attributes. 

801 

802 Args: 

803 my_dict: a Python dictionary 

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

805 

806 Examples: 

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

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

809 

810 """ 

811 return NavigableDict(my_dict, label=label) 

812 

813 @staticmethod 

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

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

816 

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

818 

819 Args: 

820 yaml_content: a string containing YAML 

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

822 

823 Returns: 

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

825 """ 

826 

827 if not yaml_content: 

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

829 

830 yaml = YAML(typ="safe") 

831 try: 

832 data = yaml.load(yaml_content) 

833 except ScannerError as exc: 

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

835 

836 return NavigableDict(data, label=label) 

837 

838 @staticmethod 

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

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

841 

842 Args: 

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

844 

845 Returns: 

846 a navdict that was loaded from the given location. 

847 

848 Raises: 

849 ValueError: when no filename is given. 

850 """ 

851 

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

853 

854 if not filename: 

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

856 

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

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

859 if not filename.is_file(): 

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

861 

862 data = load_yaml(str(filename), parent_location=None) 

863 

864 if data == {}: 

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

866 

867 return data 

868 

869 def to_yaml_file( 

870 self, 

871 filename: str | Path | None = None, 

872 header: str | None = None, 

873 top_level_group: str | None = None, 

874 ) -> None: 

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

876 

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

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

879 

880 Args: 

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

882 header (str): Custom header for this navdict 

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

884 

885 Note: 

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

887 lose proper formatting and/or comments. 

888 

889 """ 

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

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

892 

893 if header is None: 

894 header = textwrap.dedent( 

895 f""" 

896 # This YAML file is generated by: 

897 # 

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

899 # 

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

901 

902 """ 

903 ) 

904 

905 target = Path(filename) if filename is not None else Path(self.get_private_attribute("_filename")) 

906 

907 with target.open("w") as fd: 

908 fd.write(header) 

909 indent = 0 

910 if top_level_group: 

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

912 indent = 1 

913 

914 self._save(fd, indent=indent) 

915 

916 self.set_private_attribute("_filename", target) 

917 

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

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

920 return self.get_private_attribute("_filename") 

921 

922 

923navdict = NavigableDict 

924NavDict = NavigableDict 

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

926 

927 

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

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

930 if isinstance(v, dict): 

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

932 _walk_dict_tree(v, branch, text_style=text_style) 

933 else: 

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

935 tree.add(text)