Coverage for test_navdict.py: 28%
242 statements
« prev ^ index » next coverage.py v7.8.2, created at 2025-10-16 22:41 +0200
« prev ^ index » next coverage.py v7.8.2, created at 2025-10-16 22:41 +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 get_resource_location
16HERE = Path(__file__).parent
19class TakeTwoOptionalArguments:
20 """Test class for YAML load and save methods."""
22 def __init__(self, a=23, b=24):
23 super().__init__()
24 self._a = a
25 self._b = b
27 def __str__(self):
28 return f"a={self._a}, b={self._b}"
31class TakeOneKeywordArgument:
32 def __init__(self, *, sim: bool):
33 self._sim = sim
35 def __str__(self):
36 return f"sim = {self._sim}"
39YAML_STRING_SIMPLE = """
40Setup:
41 site_id: KUL
43 gse:
44 hexapod:
45 id: PUNA_01
47"""
49YAML_STRING_WITH_RELATIVE_YAML = """
50Setup:
51 camera:
52 fm01: yaml//cameras/fm01.yaml
53"""
55YAML_STRING_WITH_CLASS = """
56root:
57 defaults:
58 dev: class//test_navdict.TakeTwoOptionalArguments
59 with_args:
60 dev: class//test_navdict.TakeTwoOptionalArguments
61 dev_args: [42, 73]
62 with_kwarg:
63 dev: class//test_navdict.TakeOneKeywordArgument
64 dev_kwargs:
65 sim: true
66"""
68YAML_STRING_WITH_INT_ENUM = """
69F_FEE:
70 ccd_sides:
71 enum: int_enum//FEE_SIDES
72 content:
73 E:
74 alias: ['E_SIDE', 'RIGHT_SIDE']
75 value: 1
76 F:
77 alias: ['F_SIDE', 'LEFT_SIDE']
78 value: 0
79"""
81YAML_STRING_WITH_UNKNOWN_CLASS = """
82root:
83 part_one:
84 cls: class//navdict.navdict
85 part_two:
86 cls: class//unknown.navdict
87"""
89YAML_STRING_INVALID_INDENTATION = """
90name: test
91 age: 30
92description: invalid indentation
93"""
95YAML_STRING_MISSING_COLON = """
96name test
97age: 30
98"""
100YAML_STRING_EMPTY = """"""
103def test_is_directive():
104 assert is_directive("yaml//sample.yaml")
105 assert is_directive("class//navdict.navdict")
106 assert is_directive("my_directive//value")
108 assert not is_directive("just a string")
109 assert not is_directive("relative/path")
110 assert not is_directive("my-directive//value")
112 assert not is_directive(42)
113 assert not is_directive(23.7)
115 assert not is_directive("my-setup-001")
118def test_get_directive_plugin():
119 assert isinstance(get_directive_plugin("yaml"), Directive)
121 assert not isinstance(get_directive_plugin("not-a-plugin"), Directive)
124def test_use_a_directive_plugin():
125 yaml_string = """
126 Setup:
127 info: my_yaml//../use/this/file.yaml
128 info_args: [1, 2]
129 info_kwargs:
130 x: X
131 y: Y
132 """
133 data = navdict.from_yaml_string(yaml_string)
134 # print(f"{data.Setup.info=}")
135 assert data.Setup.info.startswith("my_yaml//")
136 assert data.Setup.info.endswith("use/this/file.yaml")
139def test_get_resource_location():
140 assert get_resource_location(None, None) == Path(".")
141 assert get_resource_location(None, "../data") == Path(".") / "../data"
142 assert get_resource_location(Path("~"), "data") == Path("~") / "data"
143 assert get_resource_location(Path("~"), None) == Path("~")
146def test_construction():
147 setup = navdict()
149 assert setup == {}
150 assert setup.get_label() is None
152 setup = navdict(label="Setup")
153 assert setup.get_label() == "Setup"
156def test_label():
157 setup = navdict()
159 assert setup == {}
160 assert setup.get_label() is None
162 setup.set_label("Setup")
164 assert setup == {}
165 assert setup.get_label() == "Setup"
168def test_navigation():
169 data = navdict.from_yaml_string(YAML_STRING_SIMPLE)
171 assert isinstance(data, navdict)
172 assert isinstance(data.Setup, navdict)
174 assert data.Setup.site_id == "KUL"
175 assert data.Setup.gse.hexapod.id == "PUNA_01"
178def test_from_yaml_string():
179 setup = navdict.from_yaml_string(YAML_STRING_SIMPLE)
181 assert "Setup" in setup
182 assert "site_id" in setup.Setup
183 assert "gse" in setup.Setup
184 assert setup.Setup.gse.hexapod.id == "PUNA_01"
186 with pytest.raises(
187 ValueError,
188 match="Invalid YAML string: mapping values are not allowed in this context",
189 ):
190 setup = navdict.from_yaml_string(YAML_STRING_INVALID_INDENTATION)
192 with pytest.raises(
193 ValueError,
194 match="Invalid YAML string: mapping values are not allowed in this context",
195 ):
196 setup = navdict.from_yaml_string(YAML_STRING_MISSING_COLON)
198 with pytest.raises(ValueError, match="Invalid argument to function: No input string or None given"):
199 setup = navdict.from_yaml_string(YAML_STRING_EMPTY)
202def test_from_yaml_file():
203 with pytest.raises(
204 ValueError,
205 match=r"Invalid argument to function, filename does not exist: "
206 r".*/simple.yaml",
207 ):
208 navdict.from_yaml_file("~/simple.yaml")
210 with create_text_file("simple.yaml", YAML_STRING_SIMPLE) as fn:
211 setup = navdict.from_yaml_file(fn)
212 assert "Setup" in setup
213 assert "site_id" in setup.Setup
214 assert "gse" in setup.Setup
215 assert setup.Setup.gse.hexapod.id == "PUNA_01"
217 with create_text_file("with_unknown_class.yaml", YAML_STRING_WITH_UNKNOWN_CLASS) as fn:
218 # The following line shall not generate an exception, meaning the `class//`
219 # shall not be evaluated on load!
220 data = navdict.from_yaml_file(fn)
222 assert "root" in data
223 assert isinstance(data.root.part_one.cls, navdict)
225 # Only when accessed, it will generate an exception.
226 with pytest.raises(ModuleNotFoundError, match="No module named 'unknown'"):
227 _ = data.root.part_two.cls
230def test_to_yaml_file():
231 """
232 This test loads the standard Setup and saves it without change to a new file.
233 Loading back the saved Setup should show no differences.
234 """
236 setup = navdict.from_yaml_string(YAML_STRING_SIMPLE)
238 with pytest.raises(ValueError, match="No filename given or known, can not save navdict."):
239 setup.to_yaml_file()
241 setup.to_yaml_file("simple.yaml")
243 setup = navdict.from_yaml_string(YAML_STRING_WITH_CLASS)
244 setup.to_yaml_file("with_class.yaml")
246 Path("simple.yaml").unlink()
247 Path("with_class.yaml").unlink()
250def test_class_directive():
251 setup = navdict.from_yaml_string(YAML_STRING_WITH_CLASS)
253 obj = setup.root.defaults.dev
254 assert isinstance(obj, TakeTwoOptionalArguments)
255 assert str(obj) == "a=23, b=24"
257 obj = setup.root.with_args.dev
258 assert isinstance(obj, TakeTwoOptionalArguments)
259 assert str(obj) == "a=42, b=73"
261 obj = setup.root.with_kwarg.dev
262 assert isinstance(obj, TakeOneKeywordArgument)
263 assert str(obj) == "sim = True"
266def test_from_dict():
267 setup = navdict.from_dict({"ID": "my-setup-001", "version": "0.1.0"}, label="Setup")
268 assert setup["ID"] == setup.ID == "my-setup-001"
270 assert setup._label == "Setup"
272 # If not all keys are of type 'str', the navdict will not be navigable.
273 setup = navdict.from_dict({"ID": 1234, 42: "forty two"}, label="Setup")
274 assert setup["ID"] == 1234
276 with pytest.raises(AttributeError):
277 _ = setup.ID
279 # Only the (sub-)dictionary that contains non-str keys will not be navigable.
280 setup = navdict.from_dict({"ID": 1234, "answer": {"book": "H2G2", 42: "forty two"}}, label="Setup")
281 assert setup["ID"] == setup.ID == 1234
282 assert setup.answer["book"] == "H2G2"
284 with pytest.raises(AttributeError):
285 _ = setup.answer.book
288def get_enum_metaclass():
289 """Get the enum metaclass in a version-compatible way."""
290 if hasattr(enum, "EnumMeta"):
291 return enum.EnumMeta
292 elif hasattr(enum, "EnumType"): # Python 3.11+
293 return enum.EnumType
294 else:
295 # Fallback: get it from a known enum
296 return type(enum.IntEnum)
299def test_int_enum():
300 setup = navdict.from_yaml_string(YAML_STRING_WITH_INT_ENUM)
302 assert "enum" in setup.F_FEE.ccd_sides
303 assert "content" in setup.F_FEE.ccd_sides
304 assert "E" in setup.F_FEE.ccd_sides.content
305 assert "F" in setup.F_FEE.ccd_sides.content
307 assert setup.F_FEE.ccd_sides.enum.E.value == 1
308 assert setup.F_FEE.ccd_sides.enum.E_SIDE.value == 1
309 assert setup.F_FEE.ccd_sides.enum.RIGHT_SIDE.value == 1
310 assert setup.F_FEE.ccd_sides.enum.RIGHT_SIDE.name == "E"
312 assert setup.F_FEE.ccd_sides.enum.F.value == 0
313 assert setup.F_FEE.ccd_sides.enum.F_SIDE.value == 0
314 assert setup.F_FEE.ccd_sides.enum.LEFT_SIDE.value == 0
315 assert setup.F_FEE.ccd_sides.enum.LEFT_SIDE.name == "F"
317 assert issubclass(setup.F_FEE.ccd_sides.enum, enum.IntEnum)
318 assert isinstance(setup.F_FEE.ccd_sides.enum, get_enum_metaclass())
319 assert isinstance(setup.F_FEE.ccd_sides.enum, type)
320 assert isinstance(setup.F_FEE.ccd_sides.enum.E, enum.IntEnum) # noqa
323YAML_STRING_LOADS_YAML_FILE = """
324root:
325 simple: yaml//enum.yaml
326"""
329def test_recursive_load():
330 with (
331 create_text_file("load_yaml.yaml", YAML_STRING_LOADS_YAML_FILE) as fn,
332 create_text_file("enum.yaml", YAML_STRING_WITH_INT_ENUM),
333 ):
334 data = navdict.from_yaml_file(fn)
335 assert data.root.simple.F_FEE.ccd_sides.enum.E.value == 1
338def test_relative_load():
339 with (
340 create_text_file(HERE / "data/conf/load_relative_yaml.yaml", YAML_STRING_WITH_RELATIVE_YAML) as fn,
341 ):
342 data = navdict.from_yaml_file(fn)
343 assert data.Setup.camera.fm01.calibration.temperature.T1.name == "TRP99"
346def test_relative_load_from_string():
347 """
348 The YAML string contains a directive to load another YAML file, but since
349 we will load this navdict from the string instead of the file, it doesn't
350 have a location and therefore the directive will be loaded relative to the
351 working directory.
353 When we change the current working directory to the expected location, things
354 work just fine.
356 """
357 data = navdict.from_yaml_string(YAML_STRING_WITH_RELATIVE_YAML)
359 assert "fm01" in data.Setup.camera
361 with pytest.raises(FileNotFoundError, match="No such file or directory: 'cameras/fm01.yaml'"):
362 assert data.Setup.camera.fm01
364 cwd = os.getcwd()
366 os.chdir(HERE / "data/conf")
368 assert data.Setup.camera.fm01.name == "FM01"
369 assert "T1" in data.Setup.camera.fm01.calibration.temperature
371 os.chdir(cwd)
374YAML_STRING_LOADS_CSV_FILE = """
375root:
376 sample: csv//data/sample.csv
377 sample_kwargs:
378 header_rows: 1
379"""
382def test_load_csv():
383 """
384 The sample.csv file will be read using the standard load_csv directive.
386 - one header row will be skipped (header_rows=1)
387 - the comment line will be filtered
388 - a list of list[str] will be returned
390 """
391 with (
392 create_text_file(HERE / "load_csv.yaml", YAML_STRING_LOADS_CSV_FILE) as fn,
393 create_test_csv_file(HERE / "data/sample.csv"),
394 ):
395 data = navdict.from_yaml_file(fn)
397 csv_data = data.root.sample
399 assert len(csv_data[0]) == 9
400 assert isinstance(csv_data, list)
401 assert isinstance(csv_data[0], list)
403 assert csv_data[0][0] == "1001"
404 assert csv_data[0][8] == "john.smith@company.com"
407def test_directive_registration():
408 def inspect_directive(value, parent_location, *args, **kwargs) -> dict:
409 return dict(value=value, parent_location=parent_location, args=args, kwargs=kwargs)
411 # The following should overwrite the default `csv//` directive
412 register_directive("csv", inspect_directive)
414 with (
415 create_text_file(HERE / "load_csv.yaml", YAML_STRING_LOADS_CSV_FILE) as fn,
416 create_test_csv_file(HERE / "data/sample.csv"),
417 ):
418 data = navdict.from_yaml_file(fn)
420 csv_data = data.root.sample
422 assert isinstance(csv_data, dict)
423 assert "value" in csv_data
424 assert csv_data["value"] == "data/sample.csv"
425 assert csv_data["parent_location"] == HERE
427 assert "kwargs" in csv_data
428 assert "header_rows" in csv_data["kwargs"]
429 assert csv_data["kwargs"]["header_rows"] == 1
432YAML_STRING_WITH_ENV_VAR = """
433config:
434 token: env//AUTH_TOKEN
435"""
438def test_env_var():
439 data = navdict.from_yaml_string(YAML_STRING_WITH_ENV_VAR)
441 assert data.config.token is None
443 os.environ["AUTH_TOKEN"] = "this-is-my-token"
445 data.config.del_memoized_key("token")
447 assert data.config.token == "this-is-my-token"
449 del os.environ["AUTH_TOKEN"]
452def test_memoized_keys():
453 # NOTE:
454 # * memoized keys are only for directives.
455 # * a key is memoized only after it was accessed.
457 data = navdict.from_yaml_string(YAML_STRING_WITH_ENV_VAR)
459 assert data.config.get_memoized_keys() == []
461 os.environ["AUTH_TOKEN"] = "this-is-my-token-too"
463 assert data.config.token == "this-is-my-token-too"
465 assert data.config.get_memoized_keys() == ["token"]
467 assert data.config.del_memoized_key("token")
469 assert "token" not in data.config.get_memoized_keys()
471 # returns False when a key is not memoized and could not be deleted
472 assert not data.config.del_memoized_key("unknown")
475def test_non_string_keys():
476 x = navdict({"A": {1: "one", 2: "two", (3,): "three-tuple"}})
478 assert x.A[1] == "one"
479 assert x.A[2] == "two"
480 assert x.A[(3,)] == "three-tuple"
483def test_invalid_yaml():
484 # This would normally raise a ScannerError from the ruamel.yaml package
485 # - ruamel.yaml.scanner.ScannerError: mapping values are not allowed in this context
487 with pytest.raises(IOError):
488 _ = navdict.from_yaml_file(__file__)
491def test_alias_hook():
493 x = navdict(
494 {
495 'letters': {'a': 'A', 'b': 'B', 'c': 'C'},
496 'numbers': [1, 2, 3, 4, 5]
497 }
498 )
500 def greek(letter: str):
501 greek_to_latin = {'alpha': 'a', 'beta': 'b', 'gamma': 'c'}
502 return greek_to_latin[letter]
504 assert x.letters.a == 'A'
505 assert x.numbers[2] == 3
507 with pytest.raises(AttributeError):
508 _ = x.letters.alpha
510 with pytest.raises(KeyError):
511 _ = x.letters["alpha"]
513 x.letters.set_alias_hook(greek)
515 assert x.letters.alpha == 'A'
516 assert x.letters['alpha'] == 'A'
518 assert x.numbers[2] == 3