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

1"""Tests Settings and related classes and functions. 

2 

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`. 

35 

36""" 

37 

38from __future__ import annotations 

39 

40import copy 

41import decimal 

42import pathlib 

43from typing import Any 

44 

45import pytest 

46 

47import bobbie 

48from bobbie import utilities 

49 

50FOLDER = pathlib.Path('tests') 

51 

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']}} 

72 

73""" Fixtures and Helpers """ 

74 

75 

76@pytest.fixture 

77def settings() -> bobbie.Settings: 

78 """Returns a `Settings` with a copy of `example_settings`. 

79 

80 The copy keeps tests that change their `Settings` from changing 

81 `example_settings`. 

82 

83 Returns: 

84 A `Settings` instance with a deep copy of `example_settings`. 

85 

86 """ 

87 return bobbie.Settings.create(copy.deepcopy(example_settings)) 

88 

89 

90def verify_settings(loaded: bobbie.Settings) -> None: 

91 """Checks that `loaded` matches `example_settings`. 

92 

93 Args: 

94 loaded: `Settings` instance to check. 

95 

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 

101 

102 

103""" Creation Tests """ 

104 

105 

106def test_version() -> None: 

107 """Tests that the package version is set.""" 

108 assert bobbie.__version__ == '0.3.0' 

109 

110 

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)) 

116 

117 

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) 

124 

125 

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]) 

132 

133 

134""" File Loading Tests """ 

135 

136 

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. 

147 

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. 

151 

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)) 

157 

158 

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) 

164 

165 

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')) 

169 

170 

171def test_unsupported_extension(tmp_path: pathlib.Path) -> None: 

172 """Tests that a file type with no loader raises a `TypeError`. 

173 

174 Args: 

175 tmp_path: temporary folder from pytest. 

176 

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) 

182 

183 

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. 

197 

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. 

202 

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) 

209 

210 

211def test_ini_percent_sign(tmp_path: pathlib.Path) -> None: 

212 """Tests that `ini` values with "%" are not treated as interpolation. 

213 

214 Args: 

215 tmp_path: temporary folder from pytest. 

216 

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'] 

224 

225 

226def test_ini_lists_and_dicts(tmp_path: pathlib.Path) -> None: 

227 """Tests that `ini` values in brackets become lists and dicts. 

228 

229 Args: 

230 tmp_path: temporary folder from pytest. 

231 

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} 

239 

240 

241""" Mapping Tests """ 

242 

243 

244def test_keys_items_values(settings: bobbie.Settings) -> None: 

245 """Tests that the `dict` views match those of the stored `dict`. 

246 

247 Args: 

248 settings: `Settings` fixture. 

249 

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()) 

255 

256 

257def test_item_access(settings: bobbie.Settings) -> None: 

258 """Tests getting, setting, and deleting items, length, and iteration. 

259 

260 Args: 

261 settings: `Settings` fixture. 

262 

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'] 

273 

274 

275def test_add(settings: bobbie.Settings) -> None: 

276 """Tests that `add` merges existing sections and creates new ones. 

277 

278 Args: 

279 settings: `Settings` fixture. 

280 

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} 

288 

289 

290def test_update_and_plus(settings: bobbie.Settings) -> None: 

291 """Tests `update` and the "+" and "+=" operators, which all use `add`. 

292 

293 Args: 

294 settings: `Settings` fixture. 

295 

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} 

313 

314 

315def test_delete(settings: bobbie.Settings) -> None: 

316 """Tests deleting present and missing keys. 

317 

318 Args: 

319 settings: `Settings` fixture. 

320 

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) 

327 

328 

329def test_get(settings: bobbie.Settings) -> None: 

330 """Tests `get` with a `default` and with `setdefault`. 

331 

332 Args: 

333 settings: `Settings` fixture. 

334 

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' 

344 

345 

346def test_subset(settings: bobbie.Settings) -> None: 

347 """Tests `subset` with `include`, `exclude`, neither, and `returns`. 

348 

349 Args: 

350 settings: `Settings` fixture. 

351 

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() 

368 

369 

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} 

377 

378 

379""" Injection Tests """ 

380 

381 

382class Target: 

383 """Object to inject settings into. 

384 

385 Attributes: 

386 seed: attribute with an existing value. 

387 gpu: attribute with no existing value. 

388 

389 """ 

390 

391 seed: Any = 1 

392 gpu: Any = None 

393 

394 

395def test_inject(settings: bobbie.Settings) -> None: 

396 """Tests injecting chosen sections, with and without overwriting. 

397 

398 Args: 

399 settings: `Settings` fixture. 

400 

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'] 

412 

413 

414def test_inject_skips_missing_section(settings: bobbie.Settings) -> None: 

415 """Tests that `inject` ignores sections that are not in `contents`. 

416 

417 Args: 

418 settings: `Settings` fixture. 

419 

420 """ 

421 target = settings.inject(Target(), sections = ['missing', 'files']) 

422 assert target.source_format == 'csv' 

423 assert target.seed == 1 

424 

425 

426""" Utility Tests """ 

427 

428 

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. 

445 

446 Args: 

447 item: value to convert. 

448 expected: converted value. 

449 

450 """ 

451 result = utilities._typify(item) 

452 assert result == expected 

453 assert type(result) is type(expected) 

454 

455 

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' 

474 

475 

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('', ',') == [] 

484 

485 

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] 

493 

494 

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 

502 

503 

504if __name__ == '__main__': 

505 pytest.main([__file__])