Coverage for tests/test_navdict.py: 21%
327 statements
« prev ^ index » next coverage.py v7.8.2, created at 2026-07-13 14:01 +0200
« prev ^ index » next coverage.py v7.8.2, created at 2026-07-13 14:01 +0200
1import enum
2import os
3from pathlib import Path
5import pytest
7from helpers import create_test_csv_file
8from helpers import create_test_csv_file_semicolon
9from helpers import create_text_file
10from navdict import navdict
11from navdict.directive import Directive
12from navdict.directive import get_directive_plugin
13from navdict.directive import is_directive
14from navdict.directive import register_directive
15from navdict.navdict import expand_env_vars
16from navdict.navdict import get_resource_location
18HERE = Path(__file__).parent
21class TakeTwoOptionalArguments:
22 """Test class for YAML load and save methods."""
24 def __init__(self, a=23, b=24):
25 super().__init__()
26 self._a = a
27 self._b = b
29 def __str__(self):
30 return f"a={self._a}, b={self._b}"
33class TakeOneKeywordArgument:
34 def __init__(self, *, sim: bool):
35 self._sim = sim
37 def __str__(self):
38 return f"sim = {self._sim}"
41YAML_STRING_SIMPLE = """
42Setup:
43 site_id: KUL
45 gse:
46 hexapod:
47 id: PUNA_01
49"""
51YAML_STRING_WITH_RELATIVE_YAML = """
52Setup:
53 camera:
54 fm01: yaml//cameras/fm01.yaml
55"""
57YAML_STRING_WITH_CLASS = """
58root:
59 defaults:
60 dev: class//test_navdict.TakeTwoOptionalArguments
61 with_args:
62 dev: class//test_navdict.TakeTwoOptionalArguments
63 dev_args: [42, 73]
64 with_kwarg:
65 dev: class//test_navdict.TakeOneKeywordArgument
66 dev_kwargs:
67 sim: true
68"""
70YAML_STRING_WITH_INT_ENUM = """
71F_FEE:
72 ccd_sides:
73 enum: int_enum//FEE_SIDES
74 content:
75 E:
76 alias: ['E_SIDE', 'RIGHT_SIDE']
77 value: 1
78 F:
79 alias: ['F_SIDE', 'LEFT_SIDE']
80 value: 0
81"""
83YAML_STRING_WITH_USER_DEFINED_FUNCTION = """
84telemetry:
85 dictionary: pandas//data/telemetry.csv
86 dictionary_kwargs:
87 separator: ;
88"""
90YAML_STRING_WITH_UNKNOWN_CLASS = """
91root:
92 part_one:
93 cls: class//navdict.navdict
94 part_two:
95 cls: class//unknown.navdict
96"""
98YAML_STRING_INVALID_INDENTATION = """
99name: test
100 age: 30
101description: invalid indentation
102"""
104YAML_STRING_MISSING_COLON = """
105name test
106age: 30
107"""
109YAML_STRING_EMPTY = """"""
112def test_is_directive():
113 assert is_directive("yaml//sample.yaml")
114 assert is_directive("class//navdict.navdict")
115 assert is_directive("my_directive//value")
117 assert not is_directive("just a string")
118 assert not is_directive("relative/path")
119 assert not is_directive("my-directive//value")
121 assert not is_directive(42)
122 assert not is_directive(23.7)
124 assert not is_directive("my-setup-001")
127def test_get_directive_plugin():
128 assert isinstance(get_directive_plugin("yaml"), Directive)
130 assert not isinstance(get_directive_plugin("not-a-plugin"), Directive)
133def test_use_a_directive_plugin():
134 yaml_string = """
135 Setup:
136 info: my_yaml//../use/this/file.yaml
137 info_args: [1, 2]
138 info_kwargs:
139 x: X
140 y: Y
141 """
142 data = navdict.from_yaml_string(yaml_string)
143 # print(f"{data.Setup.info=}")
144 assert data.Setup.info.startswith("my_yaml//")
145 assert data.Setup.info.endswith("use/this/file.yaml")
148def test_get_resource_location():
149 assert get_resource_location(None, None) == Path(".")
150 assert get_resource_location(None, "../data") == Path(".") / "../data"
151 assert get_resource_location(Path("~/project/"), "data") == Path.home() / "project/data"
152 assert get_resource_location(Path("~"), None) == Path.home()
155def test_construction():
156 setup = navdict()
158 assert setup == {}
159 assert setup.get_label() is None
161 setup = navdict(label="Setup")
162 assert setup.get_label() == "Setup"
165def test_label():
166 setup = navdict()
168 assert setup == {}
169 assert setup.get_label() is None
171 setup.set_label("Setup")
173 assert setup == {}
174 assert setup.get_label() == "Setup"
177def test_navigation():
178 data = navdict.from_yaml_string(YAML_STRING_SIMPLE)
180 assert isinstance(data, navdict)
181 assert isinstance(data.Setup, navdict)
183 assert data.Setup.site_id == "KUL"
184 assert data.Setup.gse.hexapod.id == "PUNA_01"
187def test_from_yaml_string():
188 setup = navdict.from_yaml_string(YAML_STRING_SIMPLE)
190 assert "Setup" in setup
191 assert "site_id" in setup.Setup
192 assert "gse" in setup.Setup
193 assert setup.Setup.gse.hexapod.id == "PUNA_01"
195 with pytest.raises(
196 ValueError,
197 match="Invalid YAML string: mapping values are not allowed in this context",
198 ):
199 setup = navdict.from_yaml_string(YAML_STRING_INVALID_INDENTATION)
201 with pytest.raises(
202 ValueError,
203 match="Invalid YAML string: mapping values are not allowed in this context",
204 ):
205 setup = navdict.from_yaml_string(YAML_STRING_MISSING_COLON)
207 with pytest.raises(ValueError, match="Invalid argument to function: No input string or None given"):
208 setup = navdict.from_yaml_string(YAML_STRING_EMPTY)
211def test_from_yaml_file():
212 with pytest.raises(
213 ValueError,
214 match=r"Invalid argument to function, filename does not exist: "
215 r".*/simple.yaml",
216 ):
217 navdict.from_yaml_file("~/simple.yaml")
219 with create_text_file("simple.yaml", YAML_STRING_SIMPLE) as fn:
220 setup = navdict.from_yaml_file(fn)
221 assert "Setup" in setup
222 assert "site_id" in setup.Setup
223 assert "gse" in setup.Setup
224 assert setup.Setup.gse.hexapod.id == "PUNA_01"
226 with create_text_file("with_unknown_class.yaml", YAML_STRING_WITH_UNKNOWN_CLASS) as fn:
227 # The following line shall not generate an exception, meaning the `class//`
228 # shall not be evaluated on load!
229 data = navdict.from_yaml_file(fn)
231 assert "root" in data
232 assert isinstance(data.root.part_one.cls, navdict)
234 # Only when accessed, it will generate an exception.
235 with pytest.raises(ModuleNotFoundError, match="No module named 'unknown'"):
236 _ = data.root.part_two.cls
239def test_to_yaml_file():
240 """
241 This test loads the standard Setup and saves it without change to a new file.
242 Loading back the saved Setup should show no differences.
243 """
245 setup = navdict.from_yaml_string(YAML_STRING_SIMPLE)
247 with pytest.raises(ValueError, match="No filename given or known, can not save navdict."):
248 setup.to_yaml_file()
250 setup.to_yaml_file("simple.yaml")
252 setup = navdict.from_yaml_string(YAML_STRING_WITH_CLASS)
253 setup.to_yaml_file("with_class.yaml")
255 Path("simple.yaml").unlink()
256 Path("with_class.yaml").unlink()
259def test_class_directive():
260 setup = navdict.from_yaml_string(YAML_STRING_WITH_CLASS)
262 obj = setup.root.defaults.dev
263 assert isinstance(obj, TakeTwoOptionalArguments)
264 assert str(obj) == "a=23, b=24"
266 obj = setup.root.with_args.dev
267 assert isinstance(obj, TakeTwoOptionalArguments)
268 assert str(obj) == "a=42, b=73"
270 obj = setup.root.with_kwarg.dev
271 assert isinstance(obj, TakeOneKeywordArgument)
272 assert str(obj) == "sim = True"
275def test_user_defined_function_directive():
276 setup = navdict.from_yaml_string(YAML_STRING_WITH_USER_DEFINED_FUNCTION)
278 # By default, the 'pandas' directive is not registered, so, the 'dictionary' value will be returned as is.
279 result = setup.telemetry.dictionary
280 assert result == "pandas//data/telemetry.csv"
282 # Now we register a simple 'pandas' directive for testing purposes.
283 def mock_pandas_directive(value: str, parent_location: Path | None, *args, **kwargs):
284 return f"Mocked pandas loading of '{value}' with args={args} and kwargs={kwargs}"
286 register_directive("pandas", mock_pandas_directive)
288 result = setup.telemetry.dictionary
289 print(f"{result=}")
290 assert result == "Mocked pandas loading of 'data/telemetry.csv' with args=() and kwargs={'separator': ';'}"
293def test_from_dict():
294 setup = navdict.from_dict({"ID": "my-setup-001", "version": "0.1.0"}, label="Setup")
295 assert setup["ID"] == setup.ID == "my-setup-001"
297 assert setup._label == "Setup"
299 # If not all keys are of type 'str', the navdict will not be navigable.
300 setup = navdict.from_dict({"ID": 1234, 42: "forty two"}, label="Setup")
301 assert setup["ID"] == 1234
303 with pytest.raises(AttributeError):
304 _ = setup.ID
306 # Only the (sub-)dictionary that contains non-str keys will not be navigable.
307 setup = navdict.from_dict({"ID": 1234, "answer": {"book": "H2G2", 42: "forty two"}}, label="Setup")
308 assert setup["ID"] == setup.ID == 1234
309 assert setup.answer["book"] == "H2G2"
311 with pytest.raises(AttributeError):
312 _ = setup.answer.book
315def get_enum_metaclass():
316 """Get the enum metaclass in a version-compatible way."""
317 if hasattr(enum, "EnumMeta"):
318 return enum.EnumMeta
319 elif hasattr(enum, "EnumType"): # Python 3.11+
320 return enum.EnumType
321 else:
322 # Fallback: get it from a known enum
323 return type(enum.IntEnum)
326def test_int_enum():
327 setup = navdict.from_yaml_string(YAML_STRING_WITH_INT_ENUM)
329 assert "enum" in setup.F_FEE.ccd_sides
330 assert "content" in setup.F_FEE.ccd_sides
331 assert "E" in setup.F_FEE.ccd_sides.content
332 assert "F" in setup.F_FEE.ccd_sides.content
334 assert setup.F_FEE.ccd_sides.enum.E.value == 1
335 assert setup.F_FEE.ccd_sides.enum.E_SIDE.value == 1
336 assert setup.F_FEE.ccd_sides.enum.RIGHT_SIDE.value == 1
337 assert setup.F_FEE.ccd_sides.enum.RIGHT_SIDE.name == "E"
339 assert setup.F_FEE.ccd_sides.enum.F.value == 0
340 assert setup.F_FEE.ccd_sides.enum.F_SIDE.value == 0
341 assert setup.F_FEE.ccd_sides.enum.LEFT_SIDE.value == 0
342 assert setup.F_FEE.ccd_sides.enum.LEFT_SIDE.name == "F"
344 assert issubclass(setup.F_FEE.ccd_sides.enum, enum.IntEnum)
345 assert isinstance(setup.F_FEE.ccd_sides.enum, get_enum_metaclass())
346 assert isinstance(setup.F_FEE.ccd_sides.enum, type)
347 assert isinstance(setup.F_FEE.ccd_sides.enum.E, enum.IntEnum) # noqa
350YAML_STRING_LOADS_YAML_FILE = """
351root:
352 simple: yaml//enum.yaml
353"""
356def test_recursive_load():
357 with (
358 create_text_file("load_yaml.yaml", YAML_STRING_LOADS_YAML_FILE) as fn,
359 create_text_file("enum.yaml", YAML_STRING_WITH_INT_ENUM),
360 ):
361 data = navdict.from_yaml_file(fn)
362 assert data.root.simple.F_FEE.ccd_sides.enum.E.value == 1
365def test_relative_load():
366 with (
367 create_text_file(HERE / "data/conf/load_relative_yaml.yaml", YAML_STRING_WITH_RELATIVE_YAML) as fn,
368 ):
369 data = navdict.from_yaml_file(fn)
370 assert data.Setup.camera.fm01.calibration.temperature.T1.name == "TRP99"
373def test_relative_load_from_string():
374 """
375 The YAML string contains a directive to load another YAML file, but since
376 we will load this navdict from the string instead of the file, it doesn't
377 have a location and therefore the directive will be loaded relative to the
378 working directory.
380 When we change the current working directory to the expected location, things
381 work just fine.
383 """
384 data = navdict.from_yaml_string(YAML_STRING_WITH_RELATIVE_YAML)
386 assert "fm01" in data.Setup.camera
388 with pytest.raises(FileNotFoundError, match="No such file or directory: 'cameras/fm01.yaml'"):
389 assert data.Setup.camera.fm01
391 cwd = os.getcwd()
393 os.chdir(HERE / "data/conf")
395 assert data.Setup.camera.fm01.name == "FM01"
396 assert "T1" in data.Setup.camera.fm01.calibration.temperature
398 os.chdir(cwd)
401YAML_STRING_LOADS_CSV_FILE = """
402root:
403 sample: csv//data/sample.csv
404 sample_kwargs:
405 header_rows: 1
406"""
409def test_load_csv():
410 """
411 The sample.csv file will be read using the standard load_csv directive.
413 - one header row will be skipped (header_rows=1)
414 - the comment line will be filtered
415 - a list of list[str] will be returned
417 """
418 with (
419 create_text_file(HERE / "load_csv.yaml", YAML_STRING_LOADS_CSV_FILE) as fn,
420 create_test_csv_file(HERE / "data/sample.csv"),
421 ):
422 data = navdict.from_yaml_file(fn)
424 csv_data = data.root.sample
426 assert len(csv_data[0]) == 9
427 assert isinstance(csv_data, list)
428 assert isinstance(csv_data[0], list)
430 assert csv_data[0][0] == "1001"
431 assert csv_data[0][8] == "john.smith@company.com"
434def test_expand_env_vars():
435 os.environ["NAVDICT_TEST_VAR"] = "some/value"
437 assert expand_env_vars("prefix/ENV[NAVDICT_TEST_VAR]/suffix") == "prefix/some/value/suffix"
438 assert expand_env_vars("prefix/ENV['NAVDICT_TEST_VAR']/suffix") == "prefix/some/value/suffix"
439 assert expand_env_vars('prefix/ENV["NAVDICT_TEST_VAR"]/suffix') == "prefix/some/value/suffix"
441 os.environ["NAVDICT_TEST_HOME_VAR"] = "~"
443 assert (
444 expand_env_vars("ENV[NAVDICT_TEST_HOME_VAR]/data") == "~/data"
445 ) # environment variable is expanded, but not the '~' character
447 del os.environ["NAVDICT_TEST_VAR"]
448 del os.environ["NAVDICT_TEST_HOME_VAR"]
450 with pytest.raises(ValueError):
451 expand_env_vars("ENV[NAVDICT_TEST_VAR_NOT_SET]/data")
454YAML_STRING_LOADS_CSV_FILE_WITH_ENV_VAR = """
455root:
456 sample: csv//ENV[NAVDICT_TEST_DATA_DIR]/sample.csv
457 sample_kwargs:
458 header_rows: 1
459"""
461YAML_STRING_LOADS_CSV_FILE_WITH_ENV_VAR_DISABLED = """
462root:
463 sample: csv//ENV[NAVDICT_TEST_DATA_DIR]/sample.csv
464 sample_kwargs:
465 header_rows: 1
466 expand_env: false
467"""
469YAML_STRING_LOADS_CSV_FILE_WITH_MISSING_ENV_VAR = """
470root:
471 sample: csv//ENV[NAVDICT_TEST_VAR_NOT_SET]/sample.csv
472"""
475def test_load_csv_with_env_var():
476 os.environ["NAVDICT_TEST_DATA_DIR"] = str(HERE / "data")
478 try:
479 with (
480 create_text_file(HERE / "load_csv_env.yaml", YAML_STRING_LOADS_CSV_FILE_WITH_ENV_VAR) as fn,
481 create_test_csv_file(HERE / "data/sample.csv"),
482 ):
483 data = navdict.from_yaml_file(fn)
485 csv_data = data.root.sample
487 assert isinstance(csv_data, list)
488 assert csv_data[0][0] == "1001"
489 assert csv_data[0][8] == "john.smith@company.com"
490 finally:
491 del os.environ["NAVDICT_TEST_DATA_DIR"]
494def test_load_csv_env_var_expansion_disabled():
495 os.environ["NAVDICT_TEST_DATA_DIR"] = str(HERE / "data")
497 try:
498 with (
499 create_text_file(
500 HERE / "load_csv_env_disabled.yaml", YAML_STRING_LOADS_CSV_FILE_WITH_ENV_VAR_DISABLED
501 ) as fn,
502 create_test_csv_file(HERE / "data/sample.csv"),
503 ):
504 data = navdict.from_yaml_file(fn)
506 with pytest.raises(FileNotFoundError):
507 _ = data.root.sample
508 finally:
509 del os.environ["NAVDICT_TEST_DATA_DIR"]
512def test_load_csv_missing_env_var():
513 with create_text_file(HERE / "load_csv_missing_env.yaml", YAML_STRING_LOADS_CSV_FILE_WITH_MISSING_ENV_VAR) as fn:
514 data = navdict.from_yaml_file(fn)
516 with pytest.raises(ValueError):
517 _ = data.root.sample
520YAML_STRING_LOADS_CSV_FILE_WITH_DELIMITER = """
521root:
522 products: csv//data/sample_semicolon.csv
523 products_kwargs:
524 header_rows: 1
525 delimiter: ';'
526"""
529def test_load_csv_with_delimiter():
530 """
531 Test that the CSV directive correctly handles custom delimiters.
532 The sample CSV file uses semicolons as delimiters instead of the default comma.
533 """
534 with (
535 create_text_file(HERE / "load_csv_delimiter.yaml", YAML_STRING_LOADS_CSV_FILE_WITH_DELIMITER) as fn,
536 create_test_csv_file_semicolon(HERE / "data/sample_semicolon.csv"),
537 ):
538 data = navdict.from_yaml_file(fn)
540 csv_data = data.root.products
542 assert isinstance(csv_data, list)
543 assert isinstance(csv_data[0], list)
545 assert len(csv_data[0]) == 5
546 assert csv_data[0][0] == "101"
547 assert csv_data[0][1] == "Laptop"
548 assert csv_data[0][4] == "15"
551def test_directive_registration():
552 def inspect_directive(value, parent_location, *args, **kwargs) -> dict:
553 return dict(value=value, parent_location=parent_location, args=args, kwargs=kwargs)
555 # The following should overwrite the default `csv//` directive
556 register_directive("csv", inspect_directive)
558 with (
559 create_text_file(HERE / "load_csv.yaml", YAML_STRING_LOADS_CSV_FILE) as fn,
560 create_test_csv_file(HERE / "data/sample.csv"),
561 ):
562 data = navdict.from_yaml_file(fn)
564 csv_data = data.root.sample
566 assert isinstance(csv_data, dict)
567 assert "value" in csv_data
568 assert csv_data["value"] == "data/sample.csv"
569 assert csv_data["parent_location"] == HERE
571 assert "kwargs" in csv_data
572 assert "header_rows" in csv_data["kwargs"]
573 assert csv_data["kwargs"]["header_rows"] == 1
576YAML_STRING_WITH_ENV_VAR = """
577config:
578 token: env//AUTH_TOKEN
579"""
582def test_env_var():
583 data = navdict.from_yaml_string(YAML_STRING_WITH_ENV_VAR)
585 assert data.config.token is None
587 os.environ["AUTH_TOKEN"] = "this-is-my-token"
589 data.config.del_memoized_key("token")
591 assert data.config.token == "this-is-my-token"
593 del os.environ["AUTH_TOKEN"]
596def test_memoized_keys():
597 # NOTE:
598 # * memoized keys are only for directives.
599 # * a key is memoized only after it was accessed.
601 data = navdict.from_yaml_string(YAML_STRING_WITH_ENV_VAR)
603 assert data.config.get_memoized_keys() == []
605 os.environ["AUTH_TOKEN"] = "this-is-my-token-too"
607 assert data.config.token == "this-is-my-token-too"
609 assert data.config.get_memoized_keys() == ["token"]
611 assert data.config.del_memoized_key("token")
613 assert "token" not in data.config.get_memoized_keys()
615 # returns False when a key is not memoized and could not be deleted
616 assert not data.config.del_memoized_key("unknown")
619def test_non_string_keys():
620 x = navdict({"A": {1: "one", 2: "two", (3,): "three-tuple"}})
622 assert x.A[1] == "one"
623 assert x.A[2] == "two"
624 assert x.A[(3,)] == "three-tuple"
627def test_invalid_yaml():
628 # This would normally raise a ScannerError from the ruamel.yaml package
629 # - ruamel.yaml.scanner.ScannerError: mapping values are not allowed in this context
631 with pytest.raises(IOError):
632 _ = navdict.from_yaml_file(__file__)
635def test_alias_hook():
636 x = navdict({"letters": {"a": "A", "b": "B", "c": "C"}, "numbers": [1, 2, 3, 4, 5]})
638 def greek(letter: str):
639 greek_to_latin = {"alpha": "a", "beta": "b", "gamma": "c"}
640 return greek_to_latin[letter]
642 assert x.letters.a == "A"
643 assert x.numbers[2] == 3
645 with pytest.raises(AttributeError):
646 _ = x.letters.alpha
648 with pytest.raises(KeyError):
649 _ = x.letters["alpha"]
651 x.letters.set_alias_hook(greek)
653 assert x.letters.alpha == "A"
654 assert x.letters["alpha"] == "A"
656 assert x.numbers[2] == 3
659YAML_STRING_HOUSE = """
660House:
661 Cameras:
662 cam_1:
663 location: front door
664 type: XYZ-A123
665 cam_2:
666 location: front garage
667 type: XYZ-B123
668"""
671def test_alias_hook_from_yaml_string():
672 iot = navdict.from_yaml_string(YAML_STRING_HOUSE)
674 assert iot.House.Cameras.cam_1.type == "XYZ-A123"
675 assert iot.House.Cameras.cam_2.type == "XYZ-B123"
677 def abbrev(name: str) -> str:
678 aliases = {"front_door": "cam_1", "front_garage": "cam_2"}
679 return aliases[name]
681 iot.House.Cameras.set_alias_hook(abbrev)
683 assert iot.House.Cameras.cam_1.type == "XYZ-A123"
684 assert iot.House.Cameras.cam_2.type == "XYZ-B123"
686 assert iot.House.Cameras.front_door.type == "XYZ-A123"
687 assert iot.House.Cameras.front_garage.type == "XYZ-B123"
689 assert "_alias_hook" not in iot.House.Cameras
692def test_rich_print_with_alias_hook():
693 iot = navdict.from_yaml_string(YAML_STRING_HOUSE)
695 def abbrev(name: str) -> str:
696 aliases = {"front_door": "cam_1", "front_garage": "cam_2"}
697 return aliases[name]
699 iot.House.Cameras.set_alias_hook(abbrev)
701 with pytest.raises(AttributeError, match="'NavigableDict' object has no attribute 'no_such_camera'"):
702 # The following line will cause a KeyError in the abbrev()` hook
703 # Any Exception from the alias hook should not propagate to the top
704 # but should raise an AttributeError as expected.
705 _ = iot.House.Cameras.no_such_camera
707 with pytest.raises(KeyError, match="'NavigableDict' has no key 'no_such_camera'"):
708 # The following line will cause a KeyError in the abbrev()` hook
709 # Any Exception from the alias hook should not propagate to the top
710 # but should raise a KeyError as expected.
711 _ = iot.House.Cameras["no_such_camera"]