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

349 statements  

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

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//"): 70 ↛ 71line 70 didn't jump to line 71 because the condition on line 70 was never true

71 class_name = class_name[7:] 

72 elif class_name.startswith("factory//"): 72 ↛ 73line 72 didn't jump to line 73 because the condition on line 72 was never true

73 class_name = class_name[9:] 

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//"): 138 ↛ 139line 138 didn't jump to line 139 because the condition on line 138 was never true

139 resource_name = resource_name[5:] 

140 

141 if not resource_name: 141 ↛ 142line 141 didn't jump to line 142 because the condition on line 141 was never true

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

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//"): 198 ↛ 199line 198 didn't jump to line 199 because the condition on line 198 was never true

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: 203 ↛ 206line 203 didn't jump to line 206 because the condition on line 203 was always true

204 aliases = side_definition["alias"] 

205 else: 

206 aliases = [] 

207 value = side_definition["value"] 

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//"): 222 ↛ 223line 222 didn't jump to line 223 because the condition on line 222 was never true

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

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 # This function is called when the attribute is not found in the hierarchy. 

383 # So, what do we do? We check if the key that was provided is an alias for 

384 # an existing key. The alias mapping is a function that is provided as a 

385 # hook, see `set_alias_hook()`. 

386 def __getattr__(self, key): 

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

388 

389 try: 

390 alias = self._alias_hook(key) 

391 return self.__dict__[alias] 

392 except NotImplementedError: 

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

394 

395 @staticmethod 

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

397 raise NotImplementedError 

398 

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

400 """ 

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

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

403 

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

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

406 be a valid attribute or key for this navdict. 

407 """ 

408 setattr(self, "_alias_hook", hook) 

409 

410 # This method is called: 

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

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

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

414 # methods, properties, dunder methods, ... 

415 # 

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

417 # through normal means. 

418 def __getattribute__(self, key): 

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

420 try: 

421 value = object.__getattribute__(self, key) 

422 except AttributeError: 

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

424 

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

426 return value 

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

428 if is_directive(value): 

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

430 return m(key, value) 

431 else: 

432 return value 

433 

434 def __delattr__(self, item): 

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

436 object.__delattr__(self, item) 

437 dict.__delitem__(self, item) 

438 

439 def __setitem__(self, key, value): 

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

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

442 value = NavigableDict(value) 

443 super().__setitem__(key, value) 

444 self.__dict__[key] = value 

445 try: 

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

447 except KeyError: 

448 pass 

449 

450 # This method is called: 

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

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

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

454 def __getitem__(self, key): 

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

456 try: 

457 value = super().__getitem__(key) 

458 except KeyError: 

459 try: 

460 alias = self._alias_hook(key) 

461 value = super().__getitem__(alias) 

462 except NotImplementedError: 

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

464 

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

466 return value 

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

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

469 return self._handle_directive(key, value) 

470 else: 

471 return value 

472 

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

474 """ 

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

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

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

478 'yaml//' and 'csv//'. 

479 

480 Args: 

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

482 value: the value which might be a directive 

483 

484 Returns: 

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

486 evaluating and executing a directive. 

487 """ 

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

489 

490 directive_key, directive_value = unravel_directive(value) 

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

492 

493 if directive := get_directive_plugin(directive_key): 

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

495 

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

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

498 

499 args, kwargs = self._get_args_and_kwargs(key) 

500 parent_location = self._get_location() 

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

502 

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

504 return result 

505 

506 match directive_key: 

507 case "class": 

508 args, kwargs = self._get_args_and_kwargs(key) 

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

510 

511 case "factory": 511 ↛ 512line 511 didn't jump to line 512 because the pattern on line 511 never matched

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

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

514 

515 case "int_enum": 

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

517 return load_int_enum(directive_value, content) 

518 

519 case _: 

520 return value 

521 

522 def _get_location(self): 

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

524 try: 

525 filename = self.__dict__["_filename"] 

526 return filename.parent if filename else None 

527 except KeyError: 

528 return None 

529 

530 def _get_args_and_kwargs(self, key): 

531 """ 

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

533 

534 An example of such a directive: 

535 

536 hexapod: 

537 device: class//egse.hexapod.PunaProxy 

538 device_args: [PUNA_01] 

539 device_kwargs: 

540 sim: true 

541 

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

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

544 

545 Returns: 

546 A tuple containing any positional arguments and a dictionary containing 

547 keyword arguments. 

548 """ 

549 try: 

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

551 except AttributeError: 

552 args = () 

553 try: 

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

555 except AttributeError: 

556 kwargs = {} 

557 

558 return args, kwargs 

559 

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

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

562 

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

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

565 

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

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

568 

569 Args: 

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

571 value: the value for this private attribute 

572 

573 Examples: 

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

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

576 >>> assert "c" in setup 

577 >>> assert "_loaded_from_dict" not in setup 

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

579 

580 """ 

581 if key in self: 581 ↛ 582line 581 didn't jump to line 582 because the condition on line 581 was never true

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

583 if not key.startswith("_"): 583 ↛ 584line 583 didn't jump to line 584 because the condition on line 583 was never true

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

585 self.__dict__[key] = value 

586 

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

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

589 

590 Args: 

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

592 

593 Returns: 

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

595 

596 Note: 

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

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

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

600 """ 

601 if not key.startswith("_"): 601 ↛ 602line 601 didn't jump to line 602 because the condition on line 601 was never true

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

603 try: 

604 return self.__dict__[key] 

605 except KeyError: 

606 return None 

607 

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

609 """ 

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

611 

612 Args: 

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

614 Returns: 

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

616 Raises: 

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

618 """ 

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

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

621 

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

623 

624 try: 

625 _ = self.__dict__[key] 

626 return True 

627 except KeyError: 

628 return False 

629 

630 def get_raw_value(self, key): 

631 """ 

632 Returns the raw value of the given key. 

633 

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

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

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

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

638 """ 

639 try: 

640 return object.__getattribute__(self, key) 

641 except AttributeError: 

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

643 

644 def __str__(self): 

645 return self._pretty_str() 

646 

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

648 msg = "" 

649 

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

651 if isinstance(v, NavigableDict): 

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

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

654 else: 

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

656 

657 return msg 

658 

659 def __rich__(self) -> Tree: 

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

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

662 return tree 

663 

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

665 """ 

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

667 

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

669 

670 Args: 

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

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

673 

674 """ 

675 

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

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

678 # _processed_ value. 

679 

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

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

682 

683 if k == "history": 683 ↛ 684line 683 didn't jump to line 684 because the condition on line 683 was never true

684 continue 

685 

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

687 

688 if isinstance(k, str) and ":" in k: 688 ↛ 689line 688 didn't jump to line 689 because the condition on line 688 was never true

689 k = '"' + k + '"' 

690 

691 if isinstance(v, NavigableDict): 

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

693 v._save(fd, indent + 1) 

694 fd.flush() 

695 continue 

696 

697 if isinstance(v, float): 697 ↛ 698line 697 didn't jump to line 698 because the condition on line 697 was never true

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

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

700 fd.flush() 

701 

702 # now save the history as the last item 

703 

704 if "history" in self: 704 ↛ 705line 704 didn't jump to line 705 because the condition on line 704 was never true

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

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

707 

708 def get_memoized_keys(self): 

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

710 

711 def del_memoized_key(self, key: str): 

712 try: 

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

714 return True 

715 except KeyError: 

716 return False 

717 

718 @staticmethod 

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

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

721 

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

723 accessible as attributes. 

724 

725 Args: 

726 my_dict: a Python dictionary 

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

728 

729 Examples: 

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

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

732 

733 """ 

734 return NavigableDict(my_dict, label=label) 

735 

736 @staticmethod 

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

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

739 

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

741 

742 Args: 

743 yaml_content: a string containing YAML 

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

745 

746 Returns: 

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

748 """ 

749 

750 if not yaml_content: 

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

752 

753 yaml = YAML(typ="safe") 

754 try: 

755 data = yaml.load(yaml_content) 

756 except ScannerError as exc: 

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

758 

759 return NavigableDict(data, label=label) 

760 

761 @staticmethod 

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

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

764 

765 Args: 

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

767 

768 Returns: 

769 a navdict that was loaded from the given location. 

770 

771 Raises: 

772 ValueError: when no filename is given. 

773 """ 

774 

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

776 

777 if not filename: 777 ↛ 778line 777 didn't jump to line 778 because the condition on line 777 was never true

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

779 

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

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

782 if not filename.is_file(): 

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

784 

785 data = load_yaml(str(filename)) 

786 

787 if data == {}: 787 ↛ 788line 787 didn't jump to line 788 because the condition on line 787 was never true

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

789 

790 return data 

791 

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

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

794 

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

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

797 

798 Args: 

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

800 header (str): Custom header for this navdict 

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

802 

803 Note: 

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

805 lose proper formatting and/or comments. 

806 

807 """ 

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

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

810 

811 if header is None: 811 ↛ 823line 811 didn't jump to line 823 because the condition on line 811 was always true

812 header = textwrap.dedent( 

813 f""" 

814 # This YAML file is generated by: 

815 # 

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

817 # 

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

819 

820 """ 

821 ) 

822 

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

824 fd.write(header) 

825 indent = 0 

826 if top_level_group: 826 ↛ 827line 826 didn't jump to line 827 because the condition on line 826 was never true

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

828 indent = 1 

829 

830 self._save(fd, indent=indent) 

831 

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

833 

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

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

836 return self.get_private_attribute("_filename") 

837 

838 

839navdict = NavigableDict 

840NavDict = NavigableDict 

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

842 

843 

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

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

846 if isinstance(v, dict): 

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

848 _walk_dict_tree(v, branch, text_style=text_style) 

849 else: 

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

851 tree.add(text)