Coverage for tests/test_navdict.py: 21%
315 statements
« prev ^ index » next coverage.py v7.8.2, created at 2026-07-10 23:05 +0200
« prev ^ index » next coverage.py v7.8.2, created at 2026-07-10 23:05 +0200
1import enum
2import os
3from pathlib import Path
5import pytest
7from helpers import create_test_csv_file
8from helpers import create_text_file
9from navdict import navdict
10from navdict.directive import Directive
11from navdict.directive import get_directive_plugin
12from navdict.directive import is_directive
13from navdict.directive import register_directive
14from navdict.navdict import expand_env_vars
15from navdict.navdict import get_resource_location
17HERE = Path(__file__).parent
20class TakeTwoOptionalArguments:
21 """Test class for YAML load and save methods."""
23 def __init__(self, a=23, b=24):
24 super().__init__()
25 self._a = a
26 self._b = b
28 def __str__(self):
29 return f"a={self._a}, b={self._b}"
32class TakeOneKeywordArgument:
33 def __init__(self, *, sim: bool):
34 self._sim = sim
36 def __str__(self):
37 return f"sim = {self._sim}"
40YAML_STRING_SIMPLE = """
41Setup:
42 site_id: KUL
44 gse:
45 hexapod:
46 id: PUNA_01
48"""
50YAML_STRING_WITH_RELATIVE_YAML = """
51Setup:
52 camera:
53 fm01: yaml//cameras/fm01.yaml
54"""
56YAML_STRING_WITH_CLASS = """
57root:
58 defaults:
59 dev: class//test_navdict.TakeTwoOptionalArguments
60 with_args:
61 dev: class//test_navdict.TakeTwoOptionalArguments
62 dev_args: [42, 73]
63 with_kwarg:
64 dev: class//test_navdict.TakeOneKeywordArgument
65 dev_kwargs:
66 sim: true
67"""
69YAML_STRING_WITH_INT_ENUM = """
70F_FEE:
71 ccd_sides:
72 enum: int_enum//FEE_SIDES
73 content:
74 E:
75 alias: ['E_SIDE', 'RIGHT_SIDE']
76 value: 1
77 F:
78 alias: ['F_SIDE', 'LEFT_SIDE']
79 value: 0
80"""
82YAML_STRING_WITH_USER_DEFINED_FUNCTION = """
83telemetry:
84 dictionary: pandas//data/telemetry.csv
85 dictionary_kwargs:
86 separator: ;
87"""
89YAML_STRING_WITH_UNKNOWN_CLASS = """
90root:
91 part_one:
92 cls: class//navdict.navdict
93 part_two:
94 cls: class//unknown.navdict
95"""
97YAML_STRING_INVALID_INDENTATION = """
98name: test
99 age: 30
100description: invalid indentation
101"""
103YAML_STRING_MISSING_COLON = """
104name test
105age: 30
106"""
108YAML_STRING_EMPTY = """"""
111def test_is_directive():
112 assert is_directive("yaml//sample.yaml")
113 assert is_directive("class//navdict.navdict")
114 assert is_directive("my_directive//value")
116 assert not is_directive("just a string")
117 assert not is_directive("relative/path")
118 assert not is_directive("my-directive//value")
120 assert not is_directive(42)
121 assert not is_directive(23.7)
123 assert not is_directive("my-setup-001")
126def test_get_directive_plugin():
127 assert isinstance(get_directive_plugin("yaml"), Directive)
129 assert not isinstance(get_directive_plugin("not-a-plugin"), Directive)
132def test_use_a_directive_plugin():
133 yaml_string = """
134 Setup:
135 info: my_yaml//../use/this/file.yaml
136 info_args: [1, 2]
137 info_kwargs:
138 x: X
139 y: Y
140 """
141 data = navdict.from_yaml_string(yaml_string)
142 # print(f"{data.Setup.info=}")
143 assert data.Setup.info.startswith("my_yaml//")
144 assert data.Setup.info.endswith("use/this/file.yaml")
147def test_get_resource_location():
148 assert get_resource_location(None, None) == Path(".")
149 assert get_resource_location(None, "../data") == Path(".") / "../data"
150 assert get_resource_location(Path("~"), "data") == Path("~") / "data"
151 assert get_resource_location(Path("~"), None) == Path("~")
154def test_construction():
155 setup = navdict()
157 assert setup == {}
158 assert setup.get_label() is None
160 setup = navdict(label="Setup")
161 assert setup.get_label() == "Setup"
164def test_label():
165 setup = navdict()
167 assert setup == {}
168 assert setup.get_label() is None
170 setup.set_label("Setup")
172 assert setup == {}
173 assert setup.get_label() == "Setup"
176def test_navigation():
177 data = navdict.from_yaml_string(YAML_STRING_SIMPLE)
179 assert isinstance(data, navdict)
180 assert isinstance(data.Setup, navdict)
182 assert data.Setup.site_id == "KUL"
183 assert data.Setup.gse.hexapod.id == "PUNA_01"
186def test_from_yaml_string():
187 setup = navdict.from_yaml_string(YAML_STRING_SIMPLE)
189 assert "Setup" in setup
190 assert "site_id" in setup.Setup
191 assert "gse" in setup.Setup
192 assert setup.Setup.gse.hexapod.id == "PUNA_01"
194 with pytest.raises(
195 ValueError,
196 match="Invalid YAML string: mapping values are not allowed in this context",
197 ):
198 setup = navdict.from_yaml_string(YAML_STRING_INVALID_INDENTATION)
200 with pytest.raises(
201 ValueError,
202 match="Invalid YAML string: mapping values are not allowed in this context",
203 ):
204 setup = navdict.from_yaml_string(YAML_STRING_MISSING_COLON)
206 with pytest.raises(ValueError, match="Invalid argument to function: No input string or None given"):
207 setup = navdict.from_yaml_string(YAML_STRING_EMPTY)
210def test_from_yaml_file():
211 with pytest.raises(
212 ValueError,
213 match=r"Invalid argument to function, filename does not exist: "
214 r".*/simple.yaml",
215 ):
216 navdict.from_yaml_file("~/simple.yaml")
218 with create_text_file("simple.yaml", YAML_STRING_SIMPLE) as fn:
219 setup = navdict.from_yaml_file(fn)
220 assert "Setup" in setup
221 assert "site_id" in setup.Setup
222 assert "gse" in setup.Setup
223 assert setup.Setup.gse.hexapod.id == "PUNA_01"
225 with create_text_file("with_unknown_class.yaml", YAML_STRING_WITH_UNKNOWN_CLASS) as fn:
226 # The following line shall not generate an exception, meaning the `class//`
227 # shall not be evaluated on load!
228 data = navdict.from_yaml_file(fn)
230 assert "root" in data
231 assert isinstance(data.root.part_one.cls, navdict)
233 # Only when accessed, it will generate an exception.
234 with pytest.raises(ModuleNotFoundError, match="No module named 'unknown'"):
235 _ = data.root.part_two.cls
238def test_to_yaml_file():
239 """
240 This test loads the standard Setup and saves it without change to a new file.
241 Loading back the saved Setup should show no differences.
242 """
244 setup = navdict.from_yaml_string(YAML_STRING_SIMPLE)
246 with pytest.raises(ValueError, match="No filename given or known, can not save navdict."):
247 setup.to_yaml_file()
249 setup.to_yaml_file("simple.yaml")
251 setup = navdict.from_yaml_string(YAML_STRING_WITH_CLASS)
252 setup.to_yaml_file("with_class.yaml")
254 Path("simple.yaml").unlink()
255 Path("with_class.yaml").unlink()
258def test_class_directive():
259 setup = navdict.from_yaml_string(YAML_STRING_WITH_CLASS)
261 obj = setup.root.defaults.dev
262 assert isinstance(obj, TakeTwoOptionalArguments)
263 assert str(obj) == "a=23, b=24"
265 obj = setup.root.with_args.dev
266 assert isinstance(obj, TakeTwoOptionalArguments)
267 assert str(obj) == "a=42, b=73"
269 obj = setup.root.with_kwarg.dev
270 assert isinstance(obj, TakeOneKeywordArgument)
271 assert str(obj) == "sim = True"
274def test_user_defined_function_directive():
275 setup = navdict.from_yaml_string(YAML_STRING_WITH_USER_DEFINED_FUNCTION)
277 # By default, the 'pandas' directive is not registered, so, the 'dictionary' value will be returned as is.
278 result = setup.telemetry.dictionary
279 assert result == "pandas//data/telemetry.csv"
281 # Now we register a simple 'pandas' directive for testing purposes.
282 def mock_pandas_directive(value: str, parent_location: Path | None, *args, **kwargs):
283 return f"Mocked pandas loading of '{value}' with args={args} and kwargs={kwargs}"
285 register_directive("pandas", mock_pandas_directive)
287 result = setup.telemetry.dictionary
288 print(f"{result=}")
289 assert result == "Mocked pandas loading of 'data/telemetry.csv' with args=() and kwargs={'separator': ';'}"
292def test_from_dict():
293 setup = navdict.from_dict({"ID": "my-setup-001", "version": "0.1.0"}, label="Setup")
294 assert setup["ID"] == setup.ID == "my-setup-001"
296 assert setup._label == "Setup"
298 # If not all keys are of type 'str', the navdict will not be navigable.
299 setup = navdict.from_dict({"ID": 1234, 42: "forty two"}, label="Setup")
300 assert setup["ID"] == 1234
302 with pytest.raises(AttributeError):
303 _ = setup.ID
305 # Only the (sub-)dictionary that contains non-str keys will not be navigable.
306 setup = navdict.from_dict({"ID": 1234, "answer": {"book": "H2G2", 42: "forty two"}}, label="Setup")
307 assert setup["ID"] == setup.ID == 1234
308 assert setup.answer["book"] == "H2G2"
310 with pytest.raises(AttributeError):
311 _ = setup.answer.book
314def get_enum_metaclass():
315 """Get the enum metaclass in a version-compatible way."""
316 if hasattr(enum, "EnumMeta"):
317 return enum.EnumMeta
318 elif hasattr(enum, "EnumType"): # Python 3.11+
319 return enum.EnumType
320 else:
321 # Fallback: get it from a known enum
322 return type(enum.IntEnum)
325def test_int_enum():
326 setup = navdict.from_yaml_string(YAML_STRING_WITH_INT_ENUM)
328 assert "enum" in setup.F_FEE.ccd_sides
329 assert "content" in setup.F_FEE.ccd_sides
330 assert "E" in setup.F_FEE.ccd_sides.content
331 assert "F" in setup.F_FEE.ccd_sides.content
333 assert setup.F_FEE.ccd_sides.enum.E.value == 1
334 assert setup.F_FEE.ccd_sides.enum.E_SIDE.value == 1
335 assert setup.F_FEE.ccd_sides.enum.RIGHT_SIDE.value == 1
336 assert setup.F_FEE.ccd_sides.enum.RIGHT_SIDE.name == "E"
338 assert setup.F_FEE.ccd_sides.enum.F.value == 0
339 assert setup.F_FEE.ccd_sides.enum.F_SIDE.value == 0
340 assert setup.F_FEE.ccd_sides.enum.LEFT_SIDE.value == 0
341 assert setup.F_FEE.ccd_sides.enum.LEFT_SIDE.name == "F"
343 assert issubclass(setup.F_FEE.ccd_sides.enum, enum.IntEnum)
344 assert isinstance(setup.F_FEE.ccd_sides.enum, get_enum_metaclass())
345 assert isinstance(setup.F_FEE.ccd_sides.enum, type)
346 assert isinstance(setup.F_FEE.ccd_sides.enum.E, enum.IntEnum) # noqa
349YAML_STRING_LOADS_YAML_FILE = """
350root:
351 simple: yaml//enum.yaml
352"""
355def test_recursive_load():
356 with (
357 create_text_file("load_yaml.yaml", YAML_STRING_LOADS_YAML_FILE) as fn,
358 create_text_file("enum.yaml", YAML_STRING_WITH_INT_ENUM),
359 ):
360 data = navdict.from_yaml_file(fn)
361 assert data.root.simple.F_FEE.ccd_sides.enum.E.value == 1
364def test_relative_load():
365 with (
366 create_text_file(HERE / "data/conf/load_relative_yaml.yaml", YAML_STRING_WITH_RELATIVE_YAML) as fn,
367 ):
368 data = navdict.from_yaml_file(fn)
369 assert data.Setup.camera.fm01.calibration.temperature.T1.name == "TRP99"
372def test_relative_load_from_string():
373 """
374 The YAML string contains a directive to load another YAML file, but since
375 we will load this navdict from the string instead of the file, it doesn't
376 have a location and therefore the directive will be loaded relative to the
377 working directory.
379 When we change the current working directory to the expected location, things
380 work just fine.
382 """
383 data = navdict.from_yaml_string(YAML_STRING_WITH_RELATIVE_YAML)
385 assert "fm01" in data.Setup.camera
387 with pytest.raises(FileNotFoundError, match="No such file or directory: 'cameras/fm01.yaml'"):
388 assert data.Setup.camera.fm01
390 cwd = os.getcwd()
392 os.chdir(HERE / "data/conf")
394 assert data.Setup.camera.fm01.name == "FM01"
395 assert "T1" in data.Setup.camera.fm01.calibration.temperature
397 os.chdir(cwd)
400YAML_STRING_LOADS_CSV_FILE = """
401root:
402 sample: csv//data/sample.csv
403 sample_kwargs:
404 header_rows: 1
405"""
408def test_load_csv():
409 """
410 The sample.csv file will be read using the standard load_csv directive.
412 - one header row will be skipped (header_rows=1)
413 - the comment line will be filtered
414 - a list of list[str] will be returned
416 """
417 with (
418 create_text_file(HERE / "load_csv.yaml", YAML_STRING_LOADS_CSV_FILE) as fn,
419 create_test_csv_file(HERE / "data/sample.csv"),
420 ):
421 data = navdict.from_yaml_file(fn)
423 csv_data = data.root.sample
425 assert len(csv_data[0]) == 9
426 assert isinstance(csv_data, list)
427 assert isinstance(csv_data[0], list)
429 assert csv_data[0][0] == "1001"
430 assert csv_data[0][8] == "john.smith@company.com"
433def test_expand_env_vars():
434 os.environ["NAVDICT_TEST_VAR"] = "some/value"
436 assert expand_env_vars("prefix/ENV[NAVDICT_TEST_VAR]/suffix") == "prefix/some/value/suffix"
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"
440 os.environ["NAVDICT_TEST_HOME_VAR"] = "~"
442 assert expand_env_vars("ENV[NAVDICT_TEST_HOME_VAR]/data") == str(Path.home() / "data")
444 del os.environ["NAVDICT_TEST_VAR"]
445 del os.environ["NAVDICT_TEST_HOME_VAR"]
447 with pytest.raises(ValueError):
448 expand_env_vars("ENV[NAVDICT_TEST_VAR_NOT_SET]/data")
451YAML_STRING_LOADS_CSV_FILE_WITH_ENV_VAR = """
452root:
453 sample: csv//ENV[NAVDICT_TEST_DATA_DIR]/sample.csv
454 sample_kwargs:
455 header_rows: 1
456"""
458YAML_STRING_LOADS_CSV_FILE_WITH_ENV_VAR_DISABLED = """
459root:
460 sample: csv//ENV[NAVDICT_TEST_DATA_DIR]/sample.csv
461 sample_kwargs:
462 header_rows: 1
463 expand_env: false
464"""
466YAML_STRING_LOADS_CSV_FILE_WITH_MISSING_ENV_VAR = """
467root:
468 sample: csv//ENV[NAVDICT_TEST_VAR_NOT_SET]/sample.csv
469"""
472def test_load_csv_with_env_var():
473 os.environ["NAVDICT_TEST_DATA_DIR"] = str(HERE / "data")
475 try:
476 with (
477 create_text_file(HERE / "load_csv_env.yaml", YAML_STRING_LOADS_CSV_FILE_WITH_ENV_VAR) as fn,
478 create_test_csv_file(HERE / "data/sample.csv"),
479 ):
480 data = navdict.from_yaml_file(fn)
482 csv_data = data.root.sample
484 assert isinstance(csv_data, list)
485 assert csv_data[0][0] == "1001"
486 assert csv_data[0][8] == "john.smith@company.com"
487 finally:
488 del os.environ["NAVDICT_TEST_DATA_DIR"]
491def test_load_csv_env_var_expansion_disabled():
492 os.environ["NAVDICT_TEST_DATA_DIR"] = str(HERE / "data")
494 try:
495 with (
496 create_text_file(HERE / "load_csv_env_disabled.yaml", YAML_STRING_LOADS_CSV_FILE_WITH_ENV_VAR_DISABLED) as fn,
497 create_test_csv_file(HERE / "data/sample.csv"),
498 ):
499 data = navdict.from_yaml_file(fn)
501 with pytest.raises(FileNotFoundError):
502 _ = data.root.sample
503 finally:
504 del os.environ["NAVDICT_TEST_DATA_DIR"]
507def test_load_csv_missing_env_var():
508 with create_text_file(HERE / "load_csv_missing_env.yaml", YAML_STRING_LOADS_CSV_FILE_WITH_MISSING_ENV_VAR) as fn:
509 data = navdict.from_yaml_file(fn)
511 with pytest.raises(ValueError):
512 _ = data.root.sample
515def test_directive_registration():
516 def inspect_directive(value, parent_location, *args, **kwargs) -> dict:
517 return dict(value=value, parent_location=parent_location, args=args, kwargs=kwargs)
519 # The following should overwrite the default `csv//` directive
520 register_directive("csv", inspect_directive)
522 with (
523 create_text_file(HERE / "load_csv.yaml", YAML_STRING_LOADS_CSV_FILE) as fn,
524 create_test_csv_file(HERE / "data/sample.csv"),
525 ):
526 data = navdict.from_yaml_file(fn)
528 csv_data = data.root.sample
530 assert isinstance(csv_data, dict)
531 assert "value" in csv_data
532 assert csv_data["value"] == "data/sample.csv"
533 assert csv_data["parent_location"] == HERE
535 assert "kwargs" in csv_data
536 assert "header_rows" in csv_data["kwargs"]
537 assert csv_data["kwargs"]["header_rows"] == 1
540YAML_STRING_WITH_ENV_VAR = """
541config:
542 token: env//AUTH_TOKEN
543"""
546def test_env_var():
547 data = navdict.from_yaml_string(YAML_STRING_WITH_ENV_VAR)
549 assert data.config.token is None
551 os.environ["AUTH_TOKEN"] = "this-is-my-token"
553 data.config.del_memoized_key("token")
555 assert data.config.token == "this-is-my-token"
557 del os.environ["AUTH_TOKEN"]
560def test_memoized_keys():
561 # NOTE:
562 # * memoized keys are only for directives.
563 # * a key is memoized only after it was accessed.
565 data = navdict.from_yaml_string(YAML_STRING_WITH_ENV_VAR)
567 assert data.config.get_memoized_keys() == []
569 os.environ["AUTH_TOKEN"] = "this-is-my-token-too"
571 assert data.config.token == "this-is-my-token-too"
573 assert data.config.get_memoized_keys() == ["token"]
575 assert data.config.del_memoized_key("token")
577 assert "token" not in data.config.get_memoized_keys()
579 # returns False when a key is not memoized and could not be deleted
580 assert not data.config.del_memoized_key("unknown")
583def test_non_string_keys():
584 x = navdict({"A": {1: "one", 2: "two", (3,): "three-tuple"}})
586 assert x.A[1] == "one"
587 assert x.A[2] == "two"
588 assert x.A[(3,)] == "three-tuple"
591def test_invalid_yaml():
592 # This would normally raise a ScannerError from the ruamel.yaml package
593 # - ruamel.yaml.scanner.ScannerError: mapping values are not allowed in this context
595 with pytest.raises(IOError):
596 _ = navdict.from_yaml_file(__file__)
599def test_alias_hook():
600 x = navdict({"letters": {"a": "A", "b": "B", "c": "C"}, "numbers": [1, 2, 3, 4, 5]})
602 def greek(letter: str):
603 greek_to_latin = {"alpha": "a", "beta": "b", "gamma": "c"}
604 return greek_to_latin[letter]
606 assert x.letters.a == "A"
607 assert x.numbers[2] == 3
609 with pytest.raises(AttributeError):
610 _ = x.letters.alpha
612 with pytest.raises(KeyError):
613 _ = x.letters["alpha"]
615 x.letters.set_alias_hook(greek)
617 assert x.letters.alpha == "A"
618 assert x.letters["alpha"] == "A"
620 assert x.numbers[2] == 3
623YAML_STRING_HOUSE = """
624House:
625 Cameras:
626 cam_1:
627 location: front door
628 type: XYZ-A123
629 cam_2:
630 location: front garage
631 type: XYZ-B123
632"""
635def test_alias_hook_from_yaml_string():
636 iot = navdict.from_yaml_string(YAML_STRING_HOUSE)
638 assert iot.House.Cameras.cam_1.type == "XYZ-A123"
639 assert iot.House.Cameras.cam_2.type == "XYZ-B123"
641 def abbrev(name: str) -> str:
642 aliases = {"front_door": "cam_1", "front_garage": "cam_2"}
643 return aliases[name]
645 iot.House.Cameras.set_alias_hook(abbrev)
647 assert iot.House.Cameras.cam_1.type == "XYZ-A123"
648 assert iot.House.Cameras.cam_2.type == "XYZ-B123"
650 assert iot.House.Cameras.front_door.type == "XYZ-A123"
651 assert iot.House.Cameras.front_garage.type == "XYZ-B123"
653 assert "_alias_hook" not in iot.House.Cameras
656def test_rich_print_with_alias_hook():
657 iot = navdict.from_yaml_string(YAML_STRING_HOUSE)
659 def abbrev(name: str) -> str:
660 aliases = {"front_door": "cam_1", "front_garage": "cam_2"}
661 return aliases[name]
663 iot.House.Cameras.set_alias_hook(abbrev)
665 with pytest.raises(AttributeError, match="'NavigableDict' object has no attribute 'no_such_camera'"):
666 # The following line will cause a KeyError in the abbrev()` hook
667 # Any Exception from the alias hook should not propagate to the top
668 # but should raise an AttributeError as expected.
669 _ = iot.House.Cameras.no_such_camera
671 with pytest.raises(KeyError, match="'NavigableDict' has no key 'no_such_camera'"):
672 # The following line will cause a KeyError in the abbrev()` hook
673 # Any Exception from the alias hook should not propagate to the top
674 # but should raise a KeyError as expected.
675 _ = iot.House.Cameras["no_such_camera"]