Coverage for tests/test_bobbie.py: 100%
211 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-10-30 23:03 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-10-30 23:03 +0000
1"""Tests Settings and related classes and functions.
3Contents:
4 FOLDER: folder with the example settings files.
5 example_settings: settings that match every file in the tests folder.
6 settings: fixture with a fresh `Settings` copy of `example_settings`.
7 verify_settings: checks that a `Settings` matches `example_settings`.
8 test_version: tests that the package version is set.
9 test_create_from_dict: tests creating `Settings` from a `dict`.
10 test_create_with_parameters: tests passing `parameters` to `create`.
11 test_create_with_unsupported_source: tests `create` with an invalid source.
12 test_load_file: tests loading every supported file type.
13 test_toml_keyword_arguments: tests passing kwargs to the `toml` parser.
14 test_create_from_str_path: tests loading a file from a `str` path.
15 test_unsupported_extension: tests loading a file type with no loader.
16 test_missing_file: tests that every loader raises on a missing file.
17 test_ini_percent_sign: tests `ini` values that contain "%".
18 test_ini_lists_and_dicts: tests `ini` values that are lists and dicts.
19 test_keys_items_values: tests the `dict` views.
20 test_item_access: tests getting, setting, deleting, length, and iteration.
21 test_add: tests adding new and existing sections.
22 test_update_and_plus: tests `update` and the "+" and "+=" operators.
23 test_delete: tests deleting present and missing keys.
24 test_get: tests `get` with defaults and `setdefault`.
25 test_subset: tests `subset` with `include` and `exclude`.
26 test_fromkeys: tests `fromkeys`.
27 Target: object to inject settings into.
28 test_inject: tests injecting settings into an object.
29 test_inject_skips_missing_section: tests `inject` with a missing section.
30 test_typify_scalars: tests converting strings to scalars.
31 test_typify_lists_and_dicts: tests converting strings to lists and dicts.
32 test_split_top_level: tests splitting strings outside brackets and quotes.
33 test_iterify: tests wrapping items in iterables.
34 test_pathlibify: tests converting paths to `pathlib.Path`.
36"""
38from __future__ import annotations
40import copy
41import decimal
42import pathlib
43from typing import Any
45import pytest
47import bobbie
48from bobbie import utilities
50FOLDER = pathlib.Path('tests')
52example_settings = {
53 'general': {
54 'verbose': True,
55 'seed': 43,
56 'conserve_memory': True,
57 'parallelize': False,
58 'gpu': False},
59 'files': {
60 'source_format': 'csv',
61 'interim_format': 'csv',
62 'final_format': 'csv',
63 'analysis_format': 'csv',
64 'file_encoding': 'windows-1252',
65 'test_data': True,
66 'test_chunk': 500,
67 'random_test_chunk': True,
68 'boolean_out': True,
69 'export_results': True},
70 'tasks': {
71 'things_to_do': ['stop', 'drop', 'roll']}}
73""" Fixtures and Helpers """
76@pytest.fixture
77def settings() -> bobbie.Settings:
78 """Returns a `Settings` with a copy of `example_settings`.
80 The copy keeps tests that change their `Settings` from changing
81 `example_settings`.
83 Returns:
84 A `Settings` instance with a deep copy of `example_settings`.
86 """
87 return bobbie.Settings.create(copy.deepcopy(example_settings))
90def verify_settings(loaded: bobbie.Settings) -> None:
91 """Checks that `loaded` matches `example_settings`.
93 Args:
94 loaded: `Settings` instance to check.
96 """
97 assert loaded['general']['verbose'] is True
98 assert isinstance(loaded['tasks']['things_to_do'], list)
99 assert loaded['files']['test_chunk'] == 500
100 assert dict(loaded.contents) == example_settings
103""" Creation Tests """
106def test_version() -> None:
107 """Tests that the package version is set."""
108 assert bobbie.__version__ == '0.3.0'
111def test_create_from_dict() -> None:
112 """Tests creating `Settings` from a `dict` in every supported way."""
113 verify_settings(bobbie.Settings.create(example_settings))
114 verify_settings(bobbie.Settings.from_dict(example_settings))
115 verify_settings(bobbie.Settings(example_settings))
118def test_create_with_parameters() -> None:
119 """Tests that `parameters` are passed to the `Settings` constructor."""
120 loaded = bobbie.Settings.create(
121 example_settings, parameters = {'name': 'Project Settings'})
122 assert loaded.name == 'Project Settings'
123 verify_settings(loaded)
126def test_create_with_unsupported_source() -> None:
127 """Tests that `create` rejects sources that are not a path or mapping."""
128 with pytest.raises(TypeError):
129 bobbie.Settings.create(42)
130 with pytest.raises(TypeError):
131 bobbie.Settings.create([example_settings])
134""" File Loading Tests """
137@pytest.mark.parametrize(
138 ('file_name', 'loader'),
139 [
140 ('project_settings.ini', 'from_ini'),
141 ('project_settings.json', 'from_json'),
142 ('project_settings.py', 'from_module'),
143 ('project_settings.toml', 'from_toml'),
144 ('project_settings.yaml', 'from_yaml')])
145def test_load_file(file_name: str, loader: str) -> None:
146 """Tests loading each supported file type in every supported way.
148 Args:
149 file_name: name of the settings file in the tests folder.
150 loader: name of the `Settings` class method for the file type.
152 """
153 file_path = FOLDER / file_name
154 verify_settings(bobbie.Settings.create(file_path))
155 verify_settings(getattr(bobbie.Settings, loader)(file_path))
156 verify_settings(bobbie.Settings.from_file(file_path))
159def test_toml_keyword_arguments() -> None:
160 """Tests that keyword arguments are passed to the `toml` parser."""
161 file_path = FOLDER / 'project_settings.toml'
162 loaded = bobbie.Settings.create(file_path, parse_float = decimal.Decimal)
163 verify_settings(loaded)
166def test_create_from_str_path() -> None:
167 """Tests that a `str` path is loaded like a `pathlib.Path`."""
168 verify_settings(bobbie.Settings.create('tests/project_settings.yaml'))
171def test_unsupported_extension(tmp_path: pathlib.Path) -> None:
172 """Tests that a file type with no loader raises a `TypeError`.
174 Args:
175 tmp_path: temporary folder from pytest.
177 """
178 file_path = tmp_path / 'settings.csv'
179 file_path.write_text('a,b\n1,2\n')
180 with pytest.raises(TypeError):
181 bobbie.Settings.create(file_path)
184@pytest.mark.parametrize(
185 ('file_name', 'loader'),
186 [
187 ('missing.ini', 'from_ini'),
188 ('missing.json', 'from_json'),
189 ('missing.py', 'from_module'),
190 ('missing.toml', 'from_toml'),
191 ('missing.yaml', 'from_yaml')])
192def test_missing_file(
193 tmp_path: pathlib.Path,
194 file_name: str,
195 loader: str) -> None:
196 """Tests that every loader raises a `FileNotFoundError` on a missing file.
198 Args:
199 tmp_path: temporary folder from pytest.
200 file_name: name of a file that does not exist.
201 loader: name of the `Settings` class method for the file type.
203 """
204 file_path = tmp_path / file_name
205 with pytest.raises(FileNotFoundError):
206 getattr(bobbie.Settings, loader)(file_path)
207 with pytest.raises(FileNotFoundError):
208 bobbie.Settings.create(file_path)
211def test_ini_percent_sign(tmp_path: pathlib.Path) -> None:
212 """Tests that `ini` values with "%" are not treated as interpolation.
214 Args:
215 tmp_path: temporary folder from pytest.
217 """
218 file_path = tmp_path / 'percent.ini'
219 file_path.write_text('[files]\nfloat_format = %.4f\nsteps = a, b\n')
220 loaded = bobbie.Settings.create(file_path)
221 assert loaded['files']['float_format'] == '%.4f'
222 assert loaded['files']['steps'] == ['a', 'b']
223 assert list(loaded) == ['files']
226def test_ini_lists_and_dicts(tmp_path: pathlib.Path) -> None:
227 """Tests that `ini` values in brackets become lists and dicts.
229 Args:
230 tmp_path: temporary folder from pytest.
232 """
233 file_path = tmp_path / 'containers.ini'
234 file_path.write_text(
235 '[tasks]\nsteps = [stop, drop, roll]\nsizes = {small: 1, big: 2}\n')
236 loaded = bobbie.Settings.create(file_path)
237 assert loaded['tasks']['steps'] == ['stop', 'drop', 'roll']
238 assert loaded['tasks']['sizes'] == {'small': 1, 'big': 2}
241""" Mapping Tests """
244def test_keys_items_values(settings: bobbie.Settings) -> None:
245 """Tests that the `dict` views match those of the stored `dict`.
247 Args:
248 settings: `Settings` fixture.
250 """
251 assert list(settings.keys()) == ['general', 'files', 'tasks']
252 assert list(settings) == list(settings.keys())
253 assert dict(settings.items()) == example_settings
254 assert list(settings.values()) == list(example_settings.values())
257def test_item_access(settings: bobbie.Settings) -> None:
258 """Tests getting, setting, and deleting items, length, and iteration.
260 Args:
261 settings: `Settings` fixture.
263 """
264 assert len(settings) == 3
265 assert 'general' in settings
266 settings['extra'] = {'a': 1}
267 assert settings['extra'] == {'a': 1}
268 assert len(settings) == 4
269 del settings['extra']
270 assert 'extra' not in settings
271 with pytest.raises(KeyError):
272 settings['extra']
275def test_add(settings: bobbie.Settings) -> None:
276 """Tests that `add` merges existing sections and creates new ones.
278 Args:
279 settings: `Settings` fixture.
281 """
282 settings.add('general', {'seed': 7, 'workers': 4})
283 assert settings['general']['seed'] == 7
284 assert settings['general']['workers'] == 4
285 assert settings['general']['verbose'] is True
286 settings.add('new', {'a': 1})
287 assert settings['new'] == {'a': 1}
290def test_update_and_plus(settings: bobbie.Settings) -> None:
291 """Tests `update` and the "+" and "+=" operators, which all use `add`.
293 Args:
294 settings: `Settings` fixture.
296 """
297 result = settings.update({'general': {'seed': 7}, 'new': {'a': 1}})
298 assert result is settings
299 assert settings['general']['seed'] == 7
300 assert settings['general']['gpu'] is False
301 combined = settings + {'general': {'seed': 8}, 'other': {'b': 2}}
302 assert isinstance(combined, bobbie.Settings)
303 assert combined is not settings
304 assert combined['general']['seed'] == 8
305 assert combined['general']['gpu'] is False
306 assert combined['other'] == {'b': 2}
307 assert settings['general']['seed'] == 7
308 assert 'other' not in settings
309 original = settings
310 settings += {'other': {'b': 2}}
311 assert settings is original
312 assert settings['other'] == {'b': 2}
315def test_delete(settings: bobbie.Settings) -> None:
316 """Tests deleting present and missing keys.
318 Args:
319 settings: `Settings` fixture.
321 """
322 settings.delete('tasks')
323 assert 'tasks' not in settings
324 settings.delete('missing')
325 with pytest.raises(KeyError):
326 settings.delete('missing', return_error = True)
329def test_get(settings: bobbie.Settings) -> None:
330 """Tests `get` with a `default` and with `setdefault`.
332 Args:
333 settings: `Settings` fixture.
335 """
336 assert settings.get('general') == example_settings['general']
337 assert settings.get('missing', 'fallback') == 'fallback'
338 with pytest.raises(KeyError):
339 settings.get('missing')
340 settings.setdefault(list)
341 assert settings.get('missing') == []
342 settings.setdefault('constant')
343 assert settings.get('missing') == 'constant'
346def test_subset(settings: bobbie.Settings) -> None:
347 """Tests `subset` with `include`, `exclude`, neither, and `returns`.
349 Args:
350 settings: `Settings` fixture.
352 """
353 included = settings.subset(include = 'general')
354 assert isinstance(included, bobbie.Settings)
355 assert list(included) == ['general']
356 excluded = settings.subset(exclude = ['files', 'tasks'])
357 assert list(excluded) == ['general']
358 both = settings.subset(include = ['general', 'files'], exclude = 'files')
359 assert list(both) == ['general']
360 excluded['general']['seed'] = 0
361 included['general']['seed'] = 0
362 assert settings['general']['seed'] == 43
363 simple = settings.subset(include = 'tasks', returns = 'simple')
364 assert simple == {'tasks': {'things_to_do': ['stop', 'drop', 'roll']}}
365 assert not isinstance(simple, bobbie.Settings)
366 with pytest.raises(ValueError, match = 'include or exclude'):
367 settings.subset()
370def test_fromkeys() -> None:
371 """Tests that `fromkeys` builds a `Settings` like `dict.fromkeys`."""
372 loaded = bobbie.Settings.fromkeys(['a', 'b'], 0, name = 'keys')
373 assert isinstance(loaded, bobbie.Settings)
374 assert dict(loaded.contents) == {'a': 0, 'b': 0}
375 assert loaded.name == 'keys'
376 assert dict(bobbie.Settings.fromkeys(('a',)).contents) == {'a': None}
379""" Injection Tests """
382class Target:
383 """Object to inject settings into.
385 Attributes:
386 seed: attribute with an existing value.
387 gpu: attribute with no existing value.
389 """
391 seed: Any = 1
392 gpu: Any = None
395def test_inject(settings: bobbie.Settings) -> None:
396 """Tests injecting chosen sections, with and without overwriting.
398 Args:
399 settings: `Settings` fixture.
401 """
402 target = settings.inject(Target(), sections = 'general')
403 assert target.seed == 43
404 assert target.gpu is False
405 assert not hasattr(target, 'source_format')
406 target = settings.inject(Target(), sections = 'general', overwrite = False)
407 assert target.seed == 1
408 assert target.gpu is False
409 target = settings.inject(Target())
410 assert target.source_format == 'csv'
411 assert target.things_to_do == ['stop', 'drop', 'roll']
414def test_inject_skips_missing_section(settings: bobbie.Settings) -> None:
415 """Tests that `inject` ignores sections that are not in `contents`.
417 Args:
418 settings: `Settings` fixture.
420 """
421 target = settings.inject(Target(), sections = ['missing', 'files'])
422 assert target.source_format == 'csv'
423 assert target.seed == 1
426""" Utility Tests """
429@pytest.mark.parametrize(
430 ('item', 'expected'),
431 [
432 ('42', 42),
433 ('-3', -3),
434 ('2.5', 2.5),
435 ('true', True),
436 ('Yes', True),
437 ('FALSE', False),
438 ('no', False),
439 ('csv', 'csv'),
440 ('', ''),
441 (7, 7),
442 (None, None)])
443def test_typify_scalars(item: Any, expected: Any) -> None:
444 """Tests converting strings to scalars and passing other types through.
446 Args:
447 item: value to convert.
448 expected: converted value.
450 """
451 result = utilities._typify(item)
452 assert result == expected
453 assert type(result) is type(expected)
456def test_typify_lists_and_dicts() -> None:
457 """Tests converting strings to lists and dicts, including nested ones."""
458 typify = utilities._typify
459 assert typify('[x, y, z]') == ['x', 'y', 'z']
460 assert typify('[1, 2.5, true]') == [1, 2.5, True]
461 assert typify('["a, b", \'c\']') == ['a, b', 'c']
462 assert typify("['1']") == ['1']
463 assert typify('[]') == []
464 assert typify('[x, y,]') == ['x', 'y']
465 assert typify('[[1, 2], [3]]') == [[1, 2], [3]]
466 assert typify('{a: 1, b: [x, y]}') == {'a': 1, 'b': ['x', 'y']}
467 assert typify('{"a": {b: no}}') == {'a': {'b': False}}
468 assert typify('{url: "http://x.com"}') == {'url': 'http://x.com'}
469 assert typify('{}') == {}
470 assert typify('{a, b}') == '{a, b}'
471 assert typify('[x, y], z') == [['x', 'y'], 'z']
472 assert typify("don't, stop") == ["don't", 'stop']
473 assert typify('a,b') == 'a,b'
476def test_split_top_level() -> None:
477 """Tests splitting strings outside of brackets and quotes."""
478 split = utilities._split_top_level
479 assert split('a, [b, c], {d: e}', ',') == ['a', '[b, c]', '{d: e}']
480 assert split('a: b: c', ':', maxsplit = 1) == ['a', 'b: c']
481 assert split('"a, b", c', ',') == ['"a, b"', 'c']
482 assert split('a), b', ',') == ['a)', 'b']
483 assert split('', ',') == []
486def test_iterify() -> None:
487 """Tests wrapping items in iterables without iterating strings."""
488 assert list(utilities._iterify(None)) == []
489 assert list(utilities._iterify('abc')) == ['abc']
490 assert list(utilities._iterify(b'abc')) == [b'abc']
491 assert list(utilities._iterify([1, 2])) == [1, 2]
492 assert list(utilities._iterify(5)) == [5]
495def test_pathlibify() -> None:
496 """Tests converting `str` paths and rejecting other types."""
497 path = pathlib.Path('tests')
498 assert utilities._pathlibify('tests') == path
499 assert utilities._pathlibify(path) is path
500 with pytest.raises(TypeError):
501 utilities._pathlibify(5) # type: ignore
504if __name__ == '__main__':
505 pytest.main([__file__])