Coverage for tests/test_navdict.py: 98%

349 statements  

« prev     ^ index     » next       coverage.py v7.8.2, created at 2026-07-17 16:04 +0200

1import enum 

2import os 

3from pathlib import Path 

4from typing import Protocol 

5 

6import pytest 

7 

8from helpers import create_test_csv_file 

9from helpers import create_test_csv_file_semicolon 

10from helpers import create_text_file 

11from navdict import navdict 

12from navdict.directive import Directive 

13from navdict.directive import get_directive_plugin 

14from navdict.directive import is_directive 

15from navdict.directive import register_directive 

16from navdict.navdict import expand_env_vars 

17from navdict.navdict import get_resource_location 

18 

19HERE = Path(__file__).parent 

20 

21 

22class TakeTwoOptionalArguments: 

23 """Test class for YAML load and save methods.""" 

24 

25 def __init__(self, a=23, b=24): 

26 super().__init__() 

27 self._a = a 

28 self._b = b 

29 

30 def __str__(self): 

31 return f"a={self._a}, b={self._b}" 

32 

33 

34class TakeOneKeywordArgument: 

35 def __init__(self, *, sim: bool): 

36 self._sim = sim 

37 

38 def __str__(self): 

39 return f"sim = {self._sim}" 

40 

41 

42class _HexapodSchema(Protocol): 

43 id: str 

44 

45 

46class _GSESchema(Protocol): 

47 hexapod: _HexapodSchema 

48 

49 

50class _SetupSchema(Protocol): 

51 site_id: str 

52 gse: _GSESchema 

53 

54 

55class _RootSchema(Protocol): 

56 Setup: _SetupSchema 

57 

58 

59YAML_STRING_SIMPLE = """ 

60Setup: 

61 site_id: KUL 

62 

63 gse: 

64 hexapod: 

65 id: PUNA_01 

66 

67""" 

68 

69YAML_STRING_WITH_RELATIVE_YAML = """ 

70Setup: 

71 camera: 

72 fm01: yaml//cameras/fm01.yaml 

73""" 

74 

75YAML_STRING_WITH_CLASS = """ 

76root: 

77 defaults: 

78 dev: class//test_navdict.TakeTwoOptionalArguments 

79 with_args: 

80 dev: class//test_navdict.TakeTwoOptionalArguments 

81 dev_args: [42, 73] 

82 with_kwarg: 

83 dev: class//test_navdict.TakeOneKeywordArgument 

84 dev_kwargs: 

85 sim: true 

86""" 

87 

88YAML_STRING_WITH_INT_ENUM = """ 

89F_FEE: 

90 ccd_sides: 

91 enum: int_enum//FEE_SIDES 

92 content: 

93 E: 

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

95 value: 1 

96 F: 

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

98 value: 0 

99""" 

100 

101YAML_STRING_WITH_USER_DEFINED_FUNCTION = """ 

102telemetry: 

103 dictionary: pandas//data/telemetry.csv 

104 dictionary_kwargs: 

105 separator: ; 

106""" 

107 

108YAML_STRING_WITH_UNKNOWN_CLASS = """ 

109root: 

110 part_one: 

111 cls: class//navdict.navdict 

112 part_two: 

113 cls: class//unknown.navdict 

114""" 

115 

116YAML_STRING_INVALID_INDENTATION = """ 

117name: test 

118 age: 30 

119description: invalid indentation 

120""" 

121 

122YAML_STRING_MISSING_COLON = """ 

123name test 

124age: 30 

125""" 

126 

127YAML_STRING_EMPTY = """""" 

128 

129 

130def test_is_directive(): 

131 assert is_directive("yaml//sample.yaml") 

132 assert is_directive("class//navdict.navdict") 

133 assert is_directive("my_directive//value") 

134 

135 assert not is_directive("just a string") 

136 assert not is_directive("relative/path") 

137 assert not is_directive("my-directive//value") 

138 

139 assert not is_directive(42) 

140 assert not is_directive(23.7) 

141 

142 assert not is_directive("my-setup-001") 

143 

144 

145def test_get_directive_plugin(): 

146 assert isinstance(get_directive_plugin("yaml"), Directive) 

147 

148 assert not isinstance(get_directive_plugin("not-a-plugin"), Directive) 

149 

150 

151def test_use_a_directive_plugin(): 

152 yaml_string = """ 

153 Setup: 

154 info: my_yaml//../use/this/file.yaml 

155 info_args: [1, 2] 

156 info_kwargs: 

157 x: X 

158 y: Y 

159 """ 

160 data = navdict.from_yaml_string(yaml_string) 

161 # print(f"{data.Setup.info=}") 

162 assert data.Setup.info.startswith("my_yaml//") 

163 assert data.Setup.info.endswith("use/this/file.yaml") 

164 

165 

166def test_get_resource_location(): 

167 assert get_resource_location(None, None) == Path(".") 

168 assert get_resource_location(None, "../data") == Path(".") / "../data" 

169 assert get_resource_location(Path("~/project/"), "data") == Path.home() / "project/data" 

170 assert get_resource_location(Path("~"), None) == Path.home() 

171 

172 

173def test_construction(): 

174 setup = navdict() 

175 

176 assert setup == {} 

177 assert setup.get_label() is None 

178 

179 setup = navdict(label="Setup") 

180 assert setup.get_label() == "Setup" 

181 

182 

183def test_label(): 

184 setup = navdict() 

185 

186 assert setup == {} 

187 assert setup.get_label() is None 

188 

189 setup.set_label("Setup") 

190 

191 assert setup == {} 

192 assert setup.get_label() == "Setup" 

193 

194 

195def test_navigation(): 

196 data = navdict.from_yaml_string(YAML_STRING_SIMPLE) 

197 

198 assert isinstance(data, navdict) 

199 assert isinstance(data.Setup, navdict) 

200 

201 assert data.Setup.site_id == "KUL" 

202 assert data.Setup.gse.hexapod.id == "PUNA_01" 

203 

204 

205def test_as_typed_helper_for_static_typing(): 

206 data = navdict.from_yaml_string(YAML_STRING_SIMPLE) 

207 

208 typed_data = data.as_typed(_RootSchema) 

209 

210 assert typed_data.Setup.site_id == "KUL" 

211 assert typed_data.Setup.gse.hexapod.id == "PUNA_01" 

212 assert typed_data is data 

213 

214 

215def test_dir_includes_identifier_keys(): 

216 data = navdict({"valid_key": 1, "not-valid": 2, "also.not.valid": 3}) 

217 names = dir(data) 

218 

219 assert "valid_key" in names 

220 assert "not-valid" not in names 

221 assert "also.not.valid" not in names 

222 

223 

224def test_from_yaml_string(): 

225 setup = navdict.from_yaml_string(YAML_STRING_SIMPLE) 

226 

227 assert "Setup" in setup 

228 assert "site_id" in setup.Setup 

229 assert "gse" in setup.Setup 

230 assert setup.Setup.gse.hexapod.id == "PUNA_01" 

231 

232 with pytest.raises( 

233 ValueError, 

234 match="Invalid YAML string: mapping values are not allowed in this context", 

235 ): 

236 setup = navdict.from_yaml_string(YAML_STRING_INVALID_INDENTATION) 

237 

238 with pytest.raises( 

239 ValueError, 

240 match="Invalid YAML string: mapping values are not allowed in this context", 

241 ): 

242 setup = navdict.from_yaml_string(YAML_STRING_MISSING_COLON) 

243 

244 with pytest.raises(ValueError, match="Invalid argument to function: No input string or None given"): 

245 setup = navdict.from_yaml_string(YAML_STRING_EMPTY) 

246 

247 

248def test_from_yaml_file(): 

249 with pytest.raises( 

250 ValueError, 

251 match=r"Invalid argument to function, filename does not exist: " 

252 r".*/simple.yaml", 

253 ): 

254 navdict.from_yaml_file("~/simple.yaml") 

255 

256 with create_text_file("simple.yaml", YAML_STRING_SIMPLE) as fn: 

257 setup = navdict.from_yaml_file(fn) 

258 assert "Setup" in setup 

259 assert "site_id" in setup.Setup 

260 assert "gse" in setup.Setup 

261 assert setup.Setup.gse.hexapod.id == "PUNA_01" 

262 

263 with create_text_file("with_unknown_class.yaml", YAML_STRING_WITH_UNKNOWN_CLASS) as fn: 

264 # The following line shall not generate an exception, meaning the `class//` 

265 # shall not be evaluated on load! 

266 data = navdict.from_yaml_file(fn) 

267 

268 assert "root" in data 

269 assert isinstance(data.root.part_one.cls, navdict) 

270 

271 # Only when accessed, it will generate an exception. 

272 with pytest.raises(ModuleNotFoundError, match="No module named 'unknown'"): 

273 _ = data.root.part_two.cls 

274 

275 

276def test_to_yaml_file(): 

277 """ 

278 This test loads the standard Setup and saves it without change to a new file. 

279 Loading back the saved Setup should show no differences. 

280 """ 

281 

282 setup = navdict.from_yaml_string(YAML_STRING_SIMPLE) 

283 

284 with pytest.raises(ValueError, match="No filename given or known, can not save navdict."): 

285 setup.to_yaml_file() 

286 

287 setup.to_yaml_file("simple.yaml") 

288 

289 setup = navdict.from_yaml_string(YAML_STRING_WITH_CLASS) 

290 setup.to_yaml_file("with_class.yaml") 

291 

292 Path("simple.yaml").unlink() 

293 Path("with_class.yaml").unlink() 

294 

295 

296def test_class_directive(): 

297 setup = navdict.from_yaml_string(YAML_STRING_WITH_CLASS) 

298 

299 obj = setup.root.defaults.dev 

300 assert isinstance(obj, TakeTwoOptionalArguments) 

301 assert str(obj) == "a=23, b=24" 

302 

303 obj = setup.root.with_args.dev 

304 assert isinstance(obj, TakeTwoOptionalArguments) 

305 assert str(obj) == "a=42, b=73" 

306 

307 obj = setup.root.with_kwarg.dev 

308 assert isinstance(obj, TakeOneKeywordArgument) 

309 assert str(obj) == "sim = True" 

310 

311 

312def test_user_defined_function_directive(): 

313 setup = navdict.from_yaml_string(YAML_STRING_WITH_USER_DEFINED_FUNCTION) 

314 

315 # By default, the 'pandas' directive is not registered, so, the 'dictionary' value will be returned as is. 

316 result = setup.telemetry.dictionary 

317 assert result == "pandas//data/telemetry.csv" 

318 

319 # Now we register a simple 'pandas' directive for testing purposes. 

320 def mock_pandas_directive(value: str, parent_location: Path | None, *args, **kwargs): 

321 return f"Mocked pandas loading of '{value}' with args={args} and kwargs={kwargs}" 

322 

323 register_directive("pandas", mock_pandas_directive) 

324 

325 result = setup.telemetry.dictionary 

326 print(f"{result=}") 

327 assert result == "Mocked pandas loading of 'data/telemetry.csv' with args=() and kwargs={'separator': ';'}" 

328 

329 

330def test_from_dict(): 

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

332 assert setup["ID"] == setup.ID == "my-setup-001" 

333 

334 assert setup._label == "Setup" 

335 

336 # If not all keys are of type 'str', the navdict will not be navigable. 

337 setup = navdict.from_dict({"ID": 1234, 42: "forty two"}, label="Setup") 

338 assert setup["ID"] == 1234 

339 

340 with pytest.raises(AttributeError): 

341 _ = setup.ID 

342 

343 # Only the (sub-)dictionary that contains non-str keys will not be navigable. 

344 setup = navdict.from_dict({"ID": 1234, "answer": {"book": "H2G2", 42: "forty two"}}, label="Setup") 

345 assert setup["ID"] == setup.ID == 1234 

346 assert setup.answer["book"] == "H2G2" 

347 

348 with pytest.raises(AttributeError): 

349 _ = setup.answer.book 

350 

351 

352def get_enum_metaclass(): 

353 """Get the enum metaclass in a version-compatible way.""" 

354 if hasattr(enum, "EnumMeta"): 354 ↛ 356line 354 didn't jump to line 356 because the condition on line 354 was always true

355 return enum.EnumMeta 

356 elif hasattr(enum, "EnumType"): # Python 3.11+ 

357 return enum.EnumType 

358 else: 

359 # Fallback: get it from a known enum 

360 return type(enum.IntEnum) 

361 

362 

363def test_int_enum(): 

364 setup = navdict.from_yaml_string(YAML_STRING_WITH_INT_ENUM) 

365 

366 assert "enum" in setup.F_FEE.ccd_sides 

367 assert "content" in setup.F_FEE.ccd_sides 

368 assert "E" in setup.F_FEE.ccd_sides.content 

369 assert "F" in setup.F_FEE.ccd_sides.content 

370 

371 assert setup.F_FEE.ccd_sides.enum.E.value == 1 

372 assert setup.F_FEE.ccd_sides.enum.E_SIDE.value == 1 

373 assert setup.F_FEE.ccd_sides.enum.RIGHT_SIDE.value == 1 

374 assert setup.F_FEE.ccd_sides.enum.RIGHT_SIDE.name == "E" 

375 

376 assert setup.F_FEE.ccd_sides.enum.F.value == 0 

377 assert setup.F_FEE.ccd_sides.enum.F_SIDE.value == 0 

378 assert setup.F_FEE.ccd_sides.enum.LEFT_SIDE.value == 0 

379 assert setup.F_FEE.ccd_sides.enum.LEFT_SIDE.name == "F" 

380 

381 assert issubclass(setup.F_FEE.ccd_sides.enum, enum.IntEnum) 

382 assert isinstance(setup.F_FEE.ccd_sides.enum, get_enum_metaclass()) 

383 assert isinstance(setup.F_FEE.ccd_sides.enum, type) 

384 assert isinstance(setup.F_FEE.ccd_sides.enum.E, enum.IntEnum) # noqa 

385 

386 

387YAML_STRING_LOADS_YAML_FILE = """ 

388root: 

389 simple: yaml//enum.yaml 

390""" 

391 

392 

393def test_recursive_load(): 

394 with ( 

395 create_text_file("load_yaml.yaml", YAML_STRING_LOADS_YAML_FILE) as fn, 

396 create_text_file("enum.yaml", YAML_STRING_WITH_INT_ENUM), 

397 ): 

398 data = navdict.from_yaml_file(fn) 

399 assert data.root.simple.F_FEE.ccd_sides.enum.E.value == 1 

400 

401 

402def test_relative_load(): 

403 with ( 

404 create_text_file(HERE / "data/conf/load_relative_yaml.yaml", YAML_STRING_WITH_RELATIVE_YAML) as fn, 

405 ): 

406 data = navdict.from_yaml_file(fn) 

407 assert data.Setup.camera.fm01.calibration.temperature.T1.name == "TRP99" 

408 

409 

410def test_relative_load_from_string(): 

411 """ 

412 The YAML string contains a directive to load another YAML file, but since 

413 we will load this navdict from the string instead of the file, it doesn't 

414 have a location and therefore the directive will be loaded relative to the 

415 working directory. 

416 

417 When we change the current working directory to the expected location, things 

418 work just fine. 

419 

420 """ 

421 data = navdict.from_yaml_string(YAML_STRING_WITH_RELATIVE_YAML) 

422 

423 assert "fm01" in data.Setup.camera 

424 

425 with pytest.raises(FileNotFoundError, match="No such file or directory: 'cameras/fm01.yaml'"): 

426 assert data.Setup.camera.fm01 

427 

428 cwd = os.getcwd() 

429 

430 os.chdir(HERE / "data/conf") 

431 

432 assert data.Setup.camera.fm01.name == "FM01" 

433 assert "T1" in data.Setup.camera.fm01.calibration.temperature 

434 

435 os.chdir(cwd) 

436 

437 

438YAML_STRING_LOADS_CSV_FILE = """ 

439root: 

440 sample: csv//data/sample.csv 

441 sample_kwargs: 

442 header_rows: 1 

443""" 

444 

445 

446def test_load_csv(): 

447 """ 

448 The sample.csv file will be read using the standard load_csv directive. 

449 

450 - one header row will be skipped (header_rows=1) 

451 - the comment line will be filtered 

452 - a list of list[str] will be returned 

453 

454 """ 

455 with ( 

456 create_text_file(HERE / "load_csv.yaml", YAML_STRING_LOADS_CSV_FILE) as fn, 

457 create_test_csv_file(HERE / "data/sample.csv"), 

458 ): 

459 data = navdict.from_yaml_file(fn) 

460 

461 csv_data = data.root.sample 

462 

463 assert len(csv_data[0]) == 9 

464 assert isinstance(csv_data, list) 

465 assert isinstance(csv_data[0], list) 

466 

467 assert csv_data[0][0] == "1001" 

468 assert csv_data[0][8] == "john.smith@company.com" 

469 

470 

471def test_expand_env_vars(): 

472 os.environ["NAVDICT_TEST_VAR"] = "some/value" 

473 

474 assert expand_env_vars("prefix/ENV[NAVDICT_TEST_VAR]/suffix") == "prefix/some/value/suffix" 

475 assert expand_env_vars("prefix/ENV['NAVDICT_TEST_VAR']/suffix") == "prefix/some/value/suffix" 

476 assert expand_env_vars('prefix/ENV["NAVDICT_TEST_VAR"]/suffix') == "prefix/some/value/suffix" 

477 

478 os.environ["NAVDICT_TEST_HOME_VAR"] = "~" 

479 

480 assert ( 

481 expand_env_vars("ENV[NAVDICT_TEST_HOME_VAR]/data") == "~/data" 

482 ) # environment variable is expanded, but not the '~' character 

483 

484 del os.environ["NAVDICT_TEST_VAR"] 

485 del os.environ["NAVDICT_TEST_HOME_VAR"] 

486 

487 with pytest.raises(ValueError): 

488 expand_env_vars("ENV[NAVDICT_TEST_VAR_NOT_SET]/data") 

489 

490 

491YAML_STRING_LOADS_CSV_FILE_WITH_ENV_VAR = """ 

492root: 

493 sample: csv//ENV[NAVDICT_TEST_DATA_DIR]/sample.csv 

494 sample_kwargs: 

495 header_rows: 1 

496""" 

497 

498YAML_STRING_LOADS_CSV_FILE_WITH_ENV_VAR_DISABLED = """ 

499root: 

500 sample: csv//ENV[NAVDICT_TEST_DATA_DIR]/sample.csv 

501 sample_kwargs: 

502 header_rows: 1 

503 expand_env: false 

504""" 

505 

506YAML_STRING_LOADS_CSV_FILE_WITH_MISSING_ENV_VAR = """ 

507root: 

508 sample: csv//ENV[NAVDICT_TEST_VAR_NOT_SET]/sample.csv 

509""" 

510 

511 

512def test_load_csv_with_env_var(): 

513 os.environ["NAVDICT_TEST_DATA_DIR"] = str(HERE / "data") 

514 

515 try: 

516 with ( 

517 create_text_file(HERE / "load_csv_env.yaml", YAML_STRING_LOADS_CSV_FILE_WITH_ENV_VAR) as fn, 

518 create_test_csv_file(HERE / "data/sample.csv"), 

519 ): 

520 data = navdict.from_yaml_file(fn) 

521 

522 csv_data = data.root.sample 

523 

524 assert isinstance(csv_data, list) 

525 assert csv_data[0][0] == "1001" 

526 assert csv_data[0][8] == "john.smith@company.com" 

527 finally: 

528 del os.environ["NAVDICT_TEST_DATA_DIR"] 

529 

530 

531def test_load_csv_env_var_expansion_disabled(): 

532 os.environ["NAVDICT_TEST_DATA_DIR"] = str(HERE / "data") 

533 

534 try: 

535 with ( 

536 create_text_file( 

537 HERE / "load_csv_env_disabled.yaml", YAML_STRING_LOADS_CSV_FILE_WITH_ENV_VAR_DISABLED 

538 ) as fn, 

539 create_test_csv_file(HERE / "data/sample.csv"), 

540 ): 

541 data = navdict.from_yaml_file(fn) 

542 

543 with pytest.raises(FileNotFoundError): 

544 _ = data.root.sample 

545 finally: 

546 del os.environ["NAVDICT_TEST_DATA_DIR"] 

547 

548 

549def test_load_csv_missing_env_var(): 

550 with create_text_file(HERE / "load_csv_missing_env.yaml", YAML_STRING_LOADS_CSV_FILE_WITH_MISSING_ENV_VAR) as fn: 

551 data = navdict.from_yaml_file(fn) 

552 

553 with pytest.raises(ValueError): 

554 _ = data.root.sample 

555 

556 

557YAML_STRING_LOADS_CSV_FILE_WITH_DELIMITER = """ 

558root: 

559 products: csv//data/sample_semicolon.csv 

560 products_kwargs: 

561 header_rows: 1 

562 delimiter: ';' 

563""" 

564 

565 

566def test_load_csv_with_delimiter(): 

567 """ 

568 Test that the CSV directive correctly handles custom delimiters. 

569 The sample CSV file uses semicolons as delimiters instead of the default comma. 

570 """ 

571 with ( 

572 create_text_file(HERE / "load_csv_delimiter.yaml", YAML_STRING_LOADS_CSV_FILE_WITH_DELIMITER) as fn, 

573 create_test_csv_file_semicolon(HERE / "data/sample_semicolon.csv"), 

574 ): 

575 data = navdict.from_yaml_file(fn) 

576 

577 csv_data = data.root.products 

578 

579 assert isinstance(csv_data, list) 

580 assert isinstance(csv_data[0], list) 

581 

582 assert len(csv_data[0]) == 5 

583 assert csv_data[0][0] == "101" 

584 assert csv_data[0][1] == "Laptop" 

585 assert csv_data[0][4] == "15" 

586 

587 

588def test_directive_registration(): 

589 def inspect_directive(value, parent_location, *args, **kwargs) -> dict: 

590 return dict(value=value, parent_location=parent_location, args=args, kwargs=kwargs) 

591 

592 # The following should overwrite the default `csv//` directive 

593 register_directive("csv", inspect_directive) 

594 

595 with ( 

596 create_text_file(HERE / "load_csv.yaml", YAML_STRING_LOADS_CSV_FILE) as fn, 

597 create_test_csv_file(HERE / "data/sample.csv"), 

598 ): 

599 data = navdict.from_yaml_file(fn) 

600 

601 csv_data = data.root.sample 

602 

603 assert isinstance(csv_data, dict) 

604 assert "value" in csv_data 

605 assert csv_data["value"] == "data/sample.csv" 

606 assert csv_data["parent_location"] == HERE 

607 

608 assert "kwargs" in csv_data 

609 assert "header_rows" in csv_data["kwargs"] 

610 assert csv_data["kwargs"]["header_rows"] == 1 

611 

612 

613YAML_STRING_WITH_ENV_VAR = """ 

614config: 

615 token: env//AUTH_TOKEN 

616""" 

617 

618 

619def test_env_var(): 

620 data = navdict.from_yaml_string(YAML_STRING_WITH_ENV_VAR) 

621 

622 assert data.config.token is None 

623 

624 os.environ["AUTH_TOKEN"] = "this-is-my-token" 

625 

626 data.config.del_memoized_key("token") 

627 

628 assert data.config.token == "this-is-my-token" 

629 

630 del os.environ["AUTH_TOKEN"] 

631 

632 

633def test_memoized_keys(): 

634 # NOTE: 

635 # * memoized keys are only for directives. 

636 # * a key is memoized only after it was accessed. 

637 

638 data = navdict.from_yaml_string(YAML_STRING_WITH_ENV_VAR) 

639 

640 assert data.config.get_memoized_keys() == [] 

641 

642 os.environ["AUTH_TOKEN"] = "this-is-my-token-too" 

643 

644 assert data.config.token == "this-is-my-token-too" 

645 

646 assert data.config.get_memoized_keys() == ["token"] 

647 

648 assert data.config.del_memoized_key("token") 

649 

650 assert "token" not in data.config.get_memoized_keys() 

651 

652 # returns False when a key is not memoized and could not be deleted 

653 assert not data.config.del_memoized_key("unknown") 

654 

655 

656def test_non_string_keys(): 

657 x = navdict({"A": {1: "one", 2: "two", (3,): "three-tuple"}}) 

658 

659 assert x.A[1] == "one" 

660 assert x.A[2] == "two" 

661 assert x.A[(3,)] == "three-tuple" 

662 

663 

664def test_invalid_yaml(): 

665 # This would normally raise a ScannerError from the ruamel.yaml package 

666 # - ruamel.yaml.scanner.ScannerError: mapping values are not allowed in this context 

667 

668 with pytest.raises(IOError): 

669 _ = navdict.from_yaml_file(__file__) 

670 

671 

672def test_alias_hook(): 

673 x = navdict({"letters": {"a": "A", "b": "B", "c": "C"}, "numbers": [1, 2, 3, 4, 5]}) 

674 

675 def greek(letter: str): 

676 greek_to_latin = {"alpha": "a", "beta": "b", "gamma": "c"} 

677 return greek_to_latin[letter] 

678 

679 assert x.letters.a == "A" 

680 assert x.numbers[2] == 3 

681 

682 with pytest.raises(AttributeError): 

683 _ = x.letters.alpha 

684 

685 with pytest.raises(KeyError): 

686 _ = x.letters["alpha"] 

687 

688 x.letters.set_alias_hook(greek) 

689 

690 assert x.letters.alpha == "A" 

691 assert x.letters["alpha"] == "A" 

692 

693 assert x.numbers[2] == 3 

694 

695 

696YAML_STRING_HOUSE = """ 

697House: 

698 Cameras: 

699 cam_1: 

700 location: front door 

701 type: XYZ-A123 

702 cam_2: 

703 location: front garage 

704 type: XYZ-B123 

705""" 

706 

707 

708def test_alias_hook_from_yaml_string(): 

709 iot = navdict.from_yaml_string(YAML_STRING_HOUSE) 

710 

711 assert iot.House.Cameras.cam_1.type == "XYZ-A123" 

712 assert iot.House.Cameras.cam_2.type == "XYZ-B123" 

713 

714 def abbrev(name: str) -> str: 

715 aliases = {"front_door": "cam_1", "front_garage": "cam_2"} 

716 return aliases[name] 

717 

718 iot.House.Cameras.set_alias_hook(abbrev) 

719 

720 assert iot.House.Cameras.cam_1.type == "XYZ-A123" 

721 assert iot.House.Cameras.cam_2.type == "XYZ-B123" 

722 

723 assert iot.House.Cameras.front_door.type == "XYZ-A123" 

724 assert iot.House.Cameras.front_garage.type == "XYZ-B123" 

725 

726 assert "_alias_hook" not in iot.House.Cameras 

727 

728 

729def test_rich_print_with_alias_hook(): 

730 iot = navdict.from_yaml_string(YAML_STRING_HOUSE) 

731 

732 def abbrev(name: str) -> str: 

733 aliases = {"front_door": "cam_1", "front_garage": "cam_2"} 

734 return aliases[name] 

735 

736 iot.House.Cameras.set_alias_hook(abbrev) 

737 

738 with pytest.raises(AttributeError, match="'NavigableDict' object has no attribute 'no_such_camera'"): 

739 # The following line will cause a KeyError in the abbrev()` hook 

740 # Any Exception from the alias hook should not propagate to the top 

741 # but should raise an AttributeError as expected. 

742 _ = iot.House.Cameras.no_such_camera 

743 

744 with pytest.raises(KeyError, match="'NavigableDict' has no key 'no_such_camera'"): 

745 # The following line will cause a KeyError in the abbrev()` hook 

746 # Any Exception from the alias hook should not propagate to the top 

747 # but should raise a KeyError as expected. 

748 _ = iot.House.Cameras["no_such_camera"]