1import pickle 2import os 3from os.path import join, expanduser 4 5from invoke.util import six 6from mock import patch, call, Mock 7import pytest 8from pytest_relaxed import raises 9 10from invoke.runners import Local 11from invoke.config import Config 12from invoke.exceptions import ( 13 AmbiguousEnvVar, 14 UncastableEnvVar, 15 UnknownFileType, 16 UnpicklableConfigMember, 17) 18 19from _util import skip_if_windows, support 20 21 22pytestmark = pytest.mark.usefixtures("integration") 23 24 25CONFIGS_PATH = "configs" 26TYPES = ("yaml", "yml", "json", "python") 27 28 29def _load(kwarg, type_, **kwargs): 30 path = join(CONFIGS_PATH, type_ + "/") 31 kwargs[kwarg] = path 32 return Config(**kwargs) 33 34 35class Config_: 36 class class_attrs: 37 # TODO: move all other non-data-bearing kwargs to this mode 38 class prefix: 39 def defaults_to_invoke(self): 40 assert Config().prefix == "invoke" 41 42 @patch.object(Config, "_load_yaml") 43 def informs_config_filenames(self, load_yaml): 44 class MyConf(Config): 45 prefix = "other" 46 47 MyConf(system_prefix="dir/") 48 load_yaml.assert_any_call("dir/other.yaml") 49 50 def informs_env_var_prefix(self): 51 os.environ["OTHER_FOO"] = "bar" 52 53 class MyConf(Config): 54 prefix = "other" 55 56 c = MyConf(defaults={"foo": "notbar"}) 57 c.load_shell_env() 58 assert c.foo == "bar" 59 60 class file_prefix: 61 def defaults_to_None(self): 62 assert Config().file_prefix is None 63 64 @patch.object(Config, "_load_yaml") 65 def informs_config_filenames(self, load_yaml): 66 class MyConf(Config): 67 file_prefix = "other" 68 69 MyConf(system_prefix="dir/") 70 load_yaml.assert_any_call("dir/other.yaml") 71 72 class env_prefix: 73 def defaults_to_None(self): 74 assert Config().env_prefix is None 75 76 def informs_env_vars_loaded(self): 77 os.environ["OTHER_FOO"] = "bar" 78 79 class MyConf(Config): 80 env_prefix = "other" 81 82 c = MyConf(defaults={"foo": "notbar"}) 83 c.load_shell_env() 84 assert c.foo == "bar" 85 86 class global_defaults: 87 @skip_if_windows 88 def basic_settings(self): 89 # Just a catchall for what the baseline config settings should 90 # be...for some reason we're not actually capturing all of these 91 # reliably (even if their defaults are often implied by the tests 92 # which override them, e.g. runner tests around warn=True, etc). 93 expected = { 94 "run": { 95 "asynchronous": False, 96 "disown": False, 97 "dry": False, 98 "echo": False, 99 "echo_format": "\033[1;37m{command}\033[0m", 100 "echo_stdin": None, 101 "encoding": None, 102 "env": {}, 103 "err_stream": None, 104 "fallback": True, 105 "hide": None, 106 "in_stream": None, 107 "out_stream": None, 108 "pty": False, 109 "replace_env": False, 110 "shell": "/bin/bash", 111 "warn": False, 112 "watchers": [], 113 }, 114 "runners": {"local": Local}, 115 "sudo": { 116 "password": None, 117 "prompt": "[sudo] password: ", 118 "user": None, 119 }, 120 "tasks": { 121 "auto_dash_names": True, 122 "collection_name": "tasks", 123 "dedupe": True, 124 "executor_class": None, 125 "search_root": None, 126 }, 127 "timeouts": {"command": None}, 128 } 129 assert Config.global_defaults() == expected 130 131 class init: 132 "__init__" 133 134 def can_be_empty(self): 135 assert Config().__class__ == Config # derp 136 137 @patch.object(Config, "_load_yaml") 138 def configure_global_location_prefix(self, load_yaml): 139 # This is a bit funky but more useful than just replicating the 140 # same test farther down? 141 Config(system_prefix="meh/") 142 load_yaml.assert_any_call("meh/invoke.yaml") 143 144 @skip_if_windows 145 @patch.object(Config, "_load_yaml") 146 def default_system_prefix_is_etc(self, load_yaml): 147 # TODO: make this work on Windows somehow without being a total 148 # tautology? heh. 149 Config() 150 load_yaml.assert_any_call("/etc/invoke.yaml") 151 152 @patch.object(Config, "_load_yaml") 153 def configure_user_location_prefix(self, load_yaml): 154 Config(user_prefix="whatever/") 155 load_yaml.assert_any_call("whatever/invoke.yaml") 156 157 @patch.object(Config, "_load_yaml") 158 def default_user_prefix_is_homedir_plus_dot(self, load_yaml): 159 Config() 160 load_yaml.assert_any_call(expanduser("~/.invoke.yaml")) 161 162 @patch.object(Config, "_load_yaml") 163 def configure_project_location(self, load_yaml): 164 Config(project_location="someproject").load_project() 165 load_yaml.assert_any_call(join("someproject", "invoke.yaml")) 166 167 @patch.object(Config, "_load_yaml") 168 def configure_runtime_path(self, load_yaml): 169 Config(runtime_path="some/path.yaml").load_runtime() 170 load_yaml.assert_any_call("some/path.yaml") 171 172 def accepts_defaults_dict_kwarg(self): 173 c = Config(defaults={"super": "low level"}) 174 assert c.super == "low level" 175 176 def overrides_dict_is_first_posarg(self): 177 c = Config({"new": "data", "run": {"hide": True}}) 178 assert c.run.hide is True # default is False 179 assert c.run.warn is False # in global defaults, untouched 180 assert c.new == "data" # data only present at overrides layer 181 182 def overrides_dict_is_also_a_kwarg(self): 183 c = Config(overrides={"run": {"hide": True}}) 184 assert c.run.hide is True 185 186 @patch.object(Config, "load_system") 187 @patch.object(Config, "load_user") 188 @patch.object(Config, "merge") 189 def system_and_user_files_loaded_automatically( 190 self, merge, load_u, load_s 191 ): 192 Config() 193 load_s.assert_called_once_with(merge=False) 194 load_u.assert_called_once_with(merge=False) 195 merge.assert_called_once_with() 196 197 @patch.object(Config, "load_system") 198 @patch.object(Config, "load_user") 199 def can_defer_loading_system_and_user_files(self, load_u, load_s): 200 config = Config(lazy=True) 201 assert not load_s.called 202 assert not load_u.called 203 # Make sure default levels are still in place! (When bug present, 204 # i.e. merge() never called, config appears effectively empty.) 205 assert config.run.echo is False 206 207 class basic_API: 208 "Basic API components" 209 210 def can_be_used_directly_after_init(self): 211 # No load() here... 212 c = Config({"lots of these": "tests look similar"}) 213 assert c["lots of these"] == "tests look similar" 214 215 def allows_dict_and_attr_access(self): 216 # TODO: combine with tests for Context probably 217 c = Config({"foo": "bar"}) 218 assert c.foo == "bar" 219 assert c["foo"] == "bar" 220 221 def nested_dict_values_also_allow_dual_access(self): 222 # TODO: ditto 223 c = Config({"foo": "bar", "biz": {"baz": "boz"}}) 224 # Sanity check - nested doesn't somehow kill simple top level 225 assert c.foo == "bar" 226 assert c["foo"] == "bar" 227 # Actual check 228 assert c.biz.baz == "boz" 229 assert c["biz"]["baz"] == "boz" 230 assert c.biz["baz"] == "boz" 231 assert c["biz"].baz == "boz" 232 233 def attr_access_has_useful_error_msg(self): 234 c = Config() 235 try: 236 c.nope 237 except AttributeError as e: 238 expected = """ 239No attribute or config key found for 'nope' 240 241Valid keys: ['run', 'runners', 'sudo', 'tasks', 'timeouts'] 242 243Valid real attributes: ['clear', 'clone', 'env_prefix', 'file_prefix', 'from_data', 'global_defaults', 'load_base_conf_files', 'load_collection', 'load_defaults', 'load_overrides', 'load_project', 'load_runtime', 'load_shell_env', 'load_system', 'load_user', 'merge', 'pop', 'popitem', 'prefix', 'set_project_location', 'set_runtime_path', 'setdefault', 'update'] 244""".strip() # noqa 245 assert str(e) == expected 246 else: 247 assert False, "Didn't get an AttributeError on bad key!" 248 249 def subkeys_get_merged_not_overwritten(self): 250 # Ensures nested keys merge deeply instead of shallowly. 251 defaults = {"foo": {"bar": "baz"}} 252 overrides = {"foo": {"notbar": "notbaz"}} 253 c = Config(defaults=defaults, overrides=overrides) 254 assert c.foo.notbar == "notbaz" 255 assert c.foo.bar == "baz" 256 257 def is_iterable_like_dict(self): 258 c = Config(defaults={"a": 1, "b": 2}) 259 assert set(c.keys()) == {"a", "b"} 260 assert set(list(c)) == {"a", "b"} 261 262 def supports_readonly_dict_protocols(self): 263 # Use single-keypair dict to avoid sorting problems in tests. 264 c = Config(defaults={"foo": "bar"}) 265 c2 = Config(defaults={"foo": "bar"}) 266 assert "foo" in c 267 assert "foo" in c2 # mostly just to trigger loading :x 268 assert c == c2 269 assert len(c) == 1 270 assert c.get("foo") == "bar" 271 if six.PY2: 272 assert c.has_key("foo") is True # noqa 273 assert list(c.iterkeys()) == ["foo"] 274 assert list(c.itervalues()) == ["bar"] 275 assert list(c.items()) == [("foo", "bar")] 276 assert list(six.iteritems(c)) == [("foo", "bar")] 277 assert list(c.keys()) == ["foo"] 278 assert list(c.values()) == ["bar"] 279 280 class runtime_loading_of_defaults_and_overrides: 281 def defaults_can_be_given_via_method(self): 282 c = Config() 283 assert "foo" not in c 284 c.load_defaults({"foo": "bar"}) 285 assert c.foo == "bar" 286 287 def defaults_can_skip_merging(self): 288 c = Config() 289 c.load_defaults({"foo": "bar"}, merge=False) 290 assert "foo" not in c 291 c.merge() 292 assert c.foo == "bar" 293 294 def overrides_can_be_given_via_method(self): 295 c = Config(defaults={"foo": "bar"}) 296 assert c.foo == "bar" # defaults level 297 c.load_overrides({"foo": "notbar"}) 298 assert c.foo == "notbar" # overrides level 299 300 def overrides_can_skip_merging(self): 301 c = Config() 302 c.load_overrides({"foo": "bar"}, merge=False) 303 assert "foo" not in c 304 c.merge() 305 assert c.foo == "bar" 306 307 class deletion_methods: 308 def pop(self): 309 # Root 310 c = Config(defaults={"foo": "bar"}) 311 assert c.pop("foo") == "bar" 312 assert c == {} 313 # With the default arg 314 assert c.pop("wut", "fine then") == "fine then" 315 # Leaf (different key to avoid AmbiguousMergeError) 316 c.nested = {"leafkey": "leafval"} 317 assert c.nested.pop("leafkey") == "leafval" 318 assert c == {"nested": {}} 319 320 def delitem(self): 321 "__delitem__" 322 c = Config(defaults={"foo": "bar"}) 323 del c["foo"] 324 assert c == {} 325 c.nested = {"leafkey": "leafval"} 326 del c.nested["leafkey"] 327 assert c == {"nested": {}} 328 329 def delattr(self): 330 "__delattr__" 331 c = Config(defaults={"foo": "bar"}) 332 del c.foo 333 assert c == {} 334 c.nested = {"leafkey": "leafval"} 335 del c.nested.leafkey 336 assert c == {"nested": {}} 337 338 def clear(self): 339 c = Config(defaults={"foo": "bar"}) 340 c.clear() 341 assert c == {} 342 c.nested = {"leafkey": "leafval"} 343 c.nested.clear() 344 assert c == {"nested": {}} 345 346 def popitem(self): 347 c = Config(defaults={"foo": "bar"}) 348 assert c.popitem() == ("foo", "bar") 349 assert c == {} 350 c.nested = {"leafkey": "leafval"} 351 assert c.nested.popitem() == ("leafkey", "leafval") 352 assert c == {"nested": {}} 353 354 class modification_methods: 355 def setitem(self): 356 c = Config(defaults={"foo": "bar"}) 357 c["foo"] = "notbar" 358 assert c.foo == "notbar" 359 del c["foo"] 360 c["nested"] = {"leafkey": "leafval"} 361 assert c == {"nested": {"leafkey": "leafval"}} 362 363 def setdefault(self): 364 c = Config({"foo": "bar", "nested": {"leafkey": "leafval"}}) 365 assert c.setdefault("foo") == "bar" 366 assert c.nested.setdefault("leafkey") == "leafval" 367 assert c.setdefault("notfoo", "notbar") == "notbar" 368 assert c.notfoo == "notbar" 369 nested = c.nested.setdefault("otherleaf", "otherval") 370 assert nested == "otherval" 371 assert c.nested.otherleaf == "otherval" 372 373 def update(self): 374 c = Config( 375 defaults={"foo": "bar", "nested": {"leafkey": "leafval"}} 376 ) 377 # Regular update(dict) 378 c.update({"foo": "notbar"}) 379 assert c.foo == "notbar" 380 c.nested.update({"leafkey": "otherval"}) 381 assert c.nested.leafkey == "otherval" 382 # Apparently allowed but wholly useless 383 c.update() 384 expected = {"foo": "notbar", "nested": {"leafkey": "otherval"}} 385 assert c == expected 386 # Kwarg edition 387 c.update(foo="otherbar") 388 assert c.foo == "otherbar" 389 # Iterator of 2-tuples edition 390 c.nested.update( 391 [("leafkey", "yetanotherval"), ("newleaf", "turnt")] 392 ) 393 assert c.nested.leafkey == "yetanotherval" 394 assert c.nested.newleaf == "turnt" 395 396 def reinstatement_of_deleted_values_works_ok(self): 397 # Sounds like a stupid thing to test, but when we have to track 398 # deletions and mutations manually...it's an easy thing to overlook 399 c = Config(defaults={"foo": "bar"}) 400 assert c.foo == "bar" 401 del c["foo"] 402 # Sanity checks 403 assert "foo" not in c 404 assert len(c) == 0 405 # Put it back again...as a different value, for funsies 406 c.foo = "formerly bar" 407 # And make sure it stuck 408 assert c.foo == "formerly bar" 409 410 def deleting_parent_keys_of_deleted_keys_subsumes_them(self): 411 c = Config({"foo": {"bar": "biz"}}) 412 del c.foo["bar"] 413 del c.foo 414 # Make sure we didn't somehow still end up with {'foo': {'bar': 415 # None}} 416 assert c._deletions == {"foo": None} 417 418 def supports_mutation_via_attribute_access(self): 419 c = Config({"foo": "bar"}) 420 assert c.foo == "bar" 421 c.foo = "notbar" 422 assert c.foo == "notbar" 423 assert c["foo"] == "notbar" 424 425 def supports_nested_mutation_via_attribute_access(self): 426 c = Config({"foo": {"bar": "biz"}}) 427 assert c.foo.bar == "biz" 428 c.foo.bar = "notbiz" 429 assert c.foo.bar == "notbiz" 430 assert c["foo"]["bar"] == "notbiz" 431 432 def real_attrs_and_methods_win_over_attr_proxying(self): 433 # Setup 434 class MyConfig(Config): 435 myattr = None 436 437 def mymethod(self): 438 return 7 439 440 c = MyConfig({"myattr": "foo", "mymethod": "bar"}) 441 # By default, attr and config value separate 442 assert c.myattr is None 443 assert c["myattr"] == "foo" 444 # After a setattr, same holds true 445 c.myattr = "notfoo" 446 assert c.myattr == "notfoo" 447 assert c["myattr"] == "foo" 448 # Method and config value separate 449 assert callable(c.mymethod) 450 assert c.mymethod() == 7 451 assert c["mymethod"] == "bar" 452 # And same after setattr 453 def monkeys(): 454 return 13 455 456 c.mymethod = monkeys 457 assert c.mymethod() == 13 458 assert c["mymethod"] == "bar" 459 460 def config_itself_stored_as_private_name(self): 461 # I.e. one can refer to a key called 'config', which is relatively 462 # commonplace (e.g. <Config>.myservice.config -> a config file 463 # contents or path or etc) 464 c = Config() 465 c["foo"] = {"bar": "baz"} 466 c["whatever"] = {"config": "myconfig"} 467 assert c.foo.bar == "baz" 468 assert c.whatever.config == "myconfig" 469 470 def inherited_real_attrs_also_win_over_config_keys(self): 471 class MyConfigParent(Config): 472 parent_attr = 17 473 474 class MyConfig(MyConfigParent): 475 pass 476 477 c = MyConfig() 478 assert c.parent_attr == 17 479 c.parent_attr = 33 480 oops = "Oops! Looks like config won over real attr!" 481 assert "parent_attr" not in c, oops 482 assert c.parent_attr == 33 483 c["parent_attr"] = "fifteen" 484 assert c.parent_attr == 33 485 assert c["parent_attr"] == "fifteen" 486 487 def nonexistent_attrs_can_be_set_to_create_new_top_level_configs(self): 488 # I.e. some_config.foo = 'bar' is like some_config['foo'] = 'bar'. 489 # When this test breaks it usually means some_config.foo = 'bar' 490 # sets a regular attribute - and the configuration itself is never 491 # touched! 492 c = Config() 493 c.some_setting = "some_value" 494 assert c["some_setting"] == "some_value" 495 496 def nonexistent_attr_setting_works_nested_too(self): 497 c = Config() 498 c.a_nest = {} 499 assert c["a_nest"] == {} 500 c.a_nest.an_egg = True 501 assert c["a_nest"]["an_egg"] 502 503 def string_display(self): 504 "__str__ and friends" 505 config = Config(defaults={"foo": "bar"}) 506 assert repr(config) == "<Config: {'foo': 'bar'}>" 507 508 def merging_does_not_wipe_user_modifications_or_deletions(self): 509 c = Config({"foo": {"bar": "biz"}, "error": True}) 510 c.foo.bar = "notbiz" 511 del c["error"] 512 assert c["foo"]["bar"] == "notbiz" 513 assert "error" not in c 514 c.merge() 515 # Will be back to 'biz' if user changes don't get saved on their 516 # own (previously they are just mutations on the cached central 517 # config) 518 assert c["foo"]["bar"] == "notbiz" 519 # And this would still be here, too 520 assert "error" not in c 521 522 class config_file_loading: 523 "Configuration file loading" 524 525 def system_global(self): 526 "Systemwide conf files" 527 # NOTE: using lazy=True to avoid autoloading so we can prove 528 # load_system() works. 529 for type_ in TYPES: 530 config = _load("system_prefix", type_, lazy=True) 531 assert "outer" not in config 532 config.load_system() 533 assert config.outer.inner.hooray == type_ 534 535 def system_can_skip_merging(self): 536 config = _load("system_prefix", "yml", lazy=True) 537 assert "outer" not in config._system 538 assert "outer" not in config 539 config.load_system(merge=False) 540 # Test that we loaded into the per-level dict, but not the 541 # central/merged config. 542 assert "outer" in config._system 543 assert "outer" not in config 544 545 def user_specific(self): 546 "User-specific conf files" 547 # NOTE: using lazy=True to avoid autoloading so we can prove 548 # load_user() works. 549 for type_ in TYPES: 550 config = _load("user_prefix", type_, lazy=True) 551 assert "outer" not in config 552 config.load_user() 553 assert config.outer.inner.hooray == type_ 554 555 def user_can_skip_merging(self): 556 config = _load("user_prefix", "yml", lazy=True) 557 assert "outer" not in config._user 558 assert "outer" not in config 559 config.load_user(merge=False) 560 # Test that we loaded into the per-level dict, but not the 561 # central/merged config. 562 assert "outer" in config._user 563 assert "outer" not in config 564 565 def project_specific(self): 566 "Local-to-project conf files" 567 for type_ in TYPES: 568 c = Config(project_location=join(CONFIGS_PATH, type_)) 569 assert "outer" not in c 570 c.load_project() 571 assert c.outer.inner.hooray == type_ 572 573 def project_can_skip_merging(self): 574 config = Config( 575 project_location=join(CONFIGS_PATH, "yml"), lazy=True 576 ) 577 assert "outer" not in config._project 578 assert "outer" not in config 579 config.load_project(merge=False) 580 # Test that we loaded into the per-level dict, but not the 581 # central/merged config. 582 assert "outer" in config._project 583 assert "outer" not in config 584 585 def loads_no_project_specific_file_if_no_project_location_given(self): 586 c = Config() 587 assert c._project_path is None 588 c.load_project() 589 assert list(c._project.keys()) == [] 590 defaults = ["tasks", "run", "runners", "sudo", "timeouts"] 591 assert set(c.keys()) == set(defaults) 592 593 def project_location_can_be_set_after_init(self): 594 c = Config() 595 assert "outer" not in c 596 c.set_project_location(join(CONFIGS_PATH, "yml")) 597 c.load_project() 598 assert c.outer.inner.hooray == "yml" 599 600 def runtime_conf_via_cli_flag(self): 601 c = Config(runtime_path=join(CONFIGS_PATH, "yaml", "invoke.yaml")) 602 c.load_runtime() 603 assert c.outer.inner.hooray == "yaml" 604 605 def runtime_can_skip_merging(self): 606 path = join(CONFIGS_PATH, "yaml", "invoke.yaml") 607 config = Config(runtime_path=path, lazy=True) 608 assert "outer" not in config._runtime 609 assert "outer" not in config 610 config.load_runtime(merge=False) 611 # Test that we loaded into the per-level dict, but not the 612 # central/merged config. 613 assert "outer" in config._runtime 614 assert "outer" not in config 615 616 @raises(UnknownFileType) 617 def unknown_suffix_in_runtime_path_raises_useful_error(self): 618 c = Config(runtime_path=join(CONFIGS_PATH, "screw.ini")) 619 c.load_runtime() 620 621 def python_modules_dont_load_special_vars(self): 622 "Python modules don't load special vars" 623 # Borrow another test's Python module. 624 c = _load("system_prefix", "python") 625 # Sanity test that lowercase works 626 assert c.outer.inner.hooray == "python" 627 # Real test that builtins, etc are stripped out 628 for special in ("builtins", "file", "package", "name", "doc"): 629 assert "__{}__".format(special) not in c 630 631 def python_modules_except_usefully_on_unpicklable_modules(self): 632 # Re: #556; when bug present, a TypeError pops up instead (granted, 633 # at merge time, but we want it to raise ASAP, so we're testing the 634 # intended new behavior: raising at config load time. 635 c = Config() 636 c.set_runtime_path(join(support, "has_modules.py")) 637 expected = r"'os' is a module.*giving a tasks file.*mistake" 638 with pytest.raises(UnpicklableConfigMember, match=expected): 639 c.load_runtime(merge=False) 640 641 @patch("invoke.config.debug") 642 def nonexistent_files_are_skipped_and_logged(self, mock_debug): 643 c = Config() 644 c._load_yml = Mock(side_effect=IOError(2, "aw nuts")) 645 c.set_runtime_path("is-a.yml") # Triggers use of _load_yml 646 c.load_runtime() 647 mock_debug.assert_any_call("Didn't see any is-a.yml, skipping.") 648 649 @raises(IOError) 650 def non_missing_file_IOErrors_are_raised(self): 651 c = Config() 652 c._load_yml = Mock(side_effect=IOError(17, "uh, what?")) 653 c.set_runtime_path("is-a.yml") # Triggers use of _load_yml 654 c.load_runtime() 655 656 class collection_level_config_loading: 657 def performed_explicitly_and_directly(self): 658 # TODO: do we want to update the other levels to allow 'direct' 659 # loading like this, now that they all have explicit methods? 660 c = Config() 661 assert "foo" not in c 662 c.load_collection({"foo": "bar"}) 663 assert c.foo == "bar" 664 665 def merging_can_be_deferred(self): 666 c = Config() 667 assert "foo" not in c._collection 668 assert "foo" not in c 669 c.load_collection({"foo": "bar"}, merge=False) 670 assert "foo" in c._collection 671 assert "foo" not in c 672 673 class comparison_and_hashing: 674 def comparison_looks_at_merged_config(self): 675 c1 = Config(defaults={"foo": {"bar": "biz"}}) 676 # Empty defaults to suppress global_defaults 677 c2 = Config(defaults={}, overrides={"foo": {"bar": "biz"}}) 678 assert c1 is not c2 679 assert c1._defaults != c2._defaults 680 assert c1 == c2 681 682 def allows_comparison_with_real_dicts(self): 683 c = Config({"foo": {"bar": "biz"}}) 684 assert c["foo"] == {"bar": "biz"} 685 686 @raises(TypeError) 687 def is_explicitly_not_hashable(self): 688 hash(Config()) 689 690 class env_vars: 691 "Environment variables" 692 693 def base_case_defaults_to_INVOKE_prefix(self): 694 os.environ["INVOKE_FOO"] = "bar" 695 c = Config(defaults={"foo": "notbar"}) 696 c.load_shell_env() 697 assert c.foo == "bar" 698 699 def non_predeclared_settings_do_not_get_consumed(self): 700 os.environ["INVOKE_HELLO"] = "is it me you're looking for?" 701 c = Config() 702 c.load_shell_env() 703 assert "HELLO" not in c 704 assert "hello" not in c 705 706 def underscores_top_level(self): 707 os.environ["INVOKE_FOO_BAR"] = "biz" 708 c = Config(defaults={"foo_bar": "notbiz"}) 709 c.load_shell_env() 710 assert c.foo_bar == "biz" 711 712 def underscores_nested(self): 713 os.environ["INVOKE_FOO_BAR"] = "biz" 714 c = Config(defaults={"foo": {"bar": "notbiz"}}) 715 c.load_shell_env() 716 assert c.foo.bar == "biz" 717 718 def both_types_of_underscores_mixed(self): 719 os.environ["INVOKE_FOO_BAR_BIZ"] = "baz" 720 c = Config(defaults={"foo_bar": {"biz": "notbaz"}}) 721 c.load_shell_env() 722 assert c.foo_bar.biz == "baz" 723 724 @raises(AmbiguousEnvVar) 725 def ambiguous_underscores_dont_guess(self): 726 os.environ["INVOKE_FOO_BAR"] = "biz" 727 c = Config(defaults={"foo_bar": "wat", "foo": {"bar": "huh"}}) 728 c.load_shell_env() 729 730 class type_casting: 731 def strings_replaced_with_env_value(self): 732 os.environ["INVOKE_FOO"] = u"myvalue" 733 c = Config(defaults={"foo": "myoldvalue"}) 734 c.load_shell_env() 735 assert c.foo == u"myvalue" 736 assert isinstance(c.foo, six.text_type) 737 738 def unicode_replaced_with_env_value(self): 739 # Python 3 doesn't allow you to put 'bytes' objects into 740 # os.environ, so the test makes no sense there. 741 if six.PY3: 742 return 743 os.environ["INVOKE_FOO"] = "myunicode" 744 c = Config(defaults={"foo": u"myoldvalue"}) 745 c.load_shell_env() 746 assert c.foo == "myunicode" 747 assert isinstance(c.foo, str) 748 749 def None_replaced(self): 750 os.environ["INVOKE_FOO"] = "something" 751 c = Config(defaults={"foo": None}) 752 c.load_shell_env() 753 assert c.foo == "something" 754 755 def booleans(self): 756 for input_, result in ( 757 ("0", False), 758 ("1", True), 759 ("", False), 760 ("meh", True), 761 ("false", True), 762 ): 763 os.environ["INVOKE_FOO"] = input_ 764 c = Config(defaults={"foo": bool()}) 765 c.load_shell_env() 766 assert c.foo == result 767 768 def boolean_type_inputs_with_non_boolean_defaults(self): 769 for input_ in ("0", "1", "", "meh", "false"): 770 os.environ["INVOKE_FOO"] = input_ 771 c = Config(defaults={"foo": "bar"}) 772 c.load_shell_env() 773 assert c.foo == input_ 774 775 def numeric_types_become_casted(self): 776 tests = [ 777 (int, "5", 5), 778 (float, "5.5", 5.5), 779 # TODO: more? 780 ] 781 # Can't use '5L' in Python 3, even having it in a branch makes 782 # it upset. 783 if not six.PY3: 784 tests.append((long, "5", long(5))) # noqa 785 for old, new_, result in tests: 786 os.environ["INVOKE_FOO"] = new_ 787 c = Config(defaults={"foo": old()}) 788 c.load_shell_env() 789 assert c.foo == result 790 791 def arbitrary_types_work_too(self): 792 os.environ["INVOKE_FOO"] = "whatever" 793 794 class Meh(object): 795 def __init__(self, thing=None): 796 pass 797 798 old_obj = Meh() 799 c = Config(defaults={"foo": old_obj}) 800 c.load_shell_env() 801 assert isinstance(c.foo, Meh) 802 assert c.foo is not old_obj 803 804 class uncastable_types: 805 @raises(UncastableEnvVar) 806 def _uncastable_type(self, default): 807 os.environ["INVOKE_FOO"] = "stuff" 808 c = Config(defaults={"foo": default}) 809 c.load_shell_env() 810 811 def lists(self): 812 self._uncastable_type(["a", "list"]) 813 814 def tuples(self): 815 self._uncastable_type(("a", "tuple")) 816 817 class hierarchy: 818 "Config hierarchy in effect" 819 820 # 821 # NOTE: most of these just leverage existing test fixtures (which live 822 # in their own directories & have differing values for the 'hooray' 823 # key), since we normally don't need more than 2-3 different file 824 # locations for any one test. 825 # 826 827 def collection_overrides_defaults(self): 828 c = Config(defaults={"nested": {"setting": "default"}}) 829 c.load_collection({"nested": {"setting": "collection"}}) 830 assert c.nested.setting == "collection" 831 832 def systemwide_overrides_collection(self): 833 c = Config(system_prefix=join(CONFIGS_PATH, "yaml/")) 834 c.load_collection({"outer": {"inner": {"hooray": "defaults"}}}) 835 assert c.outer.inner.hooray == "yaml" 836 837 def user_overrides_systemwide(self): 838 c = Config( 839 system_prefix=join(CONFIGS_PATH, "yaml/"), 840 user_prefix=join(CONFIGS_PATH, "json/"), 841 ) 842 assert c.outer.inner.hooray == "json" 843 844 def user_overrides_collection(self): 845 c = Config(user_prefix=join(CONFIGS_PATH, "json/")) 846 c.load_collection({"outer": {"inner": {"hooray": "defaults"}}}) 847 assert c.outer.inner.hooray == "json" 848 849 def project_overrides_user(self): 850 c = Config( 851 user_prefix=join(CONFIGS_PATH, "json/"), 852 project_location=join(CONFIGS_PATH, "yaml"), 853 ) 854 c.load_project() 855 assert c.outer.inner.hooray == "yaml" 856 857 def project_overrides_systemwide(self): 858 c = Config( 859 system_prefix=join(CONFIGS_PATH, "json/"), 860 project_location=join(CONFIGS_PATH, "yaml"), 861 ) 862 c.load_project() 863 assert c.outer.inner.hooray == "yaml" 864 865 def project_overrides_collection(self): 866 c = Config(project_location=join(CONFIGS_PATH, "yaml")) 867 c.load_project() 868 c.load_collection({"outer": {"inner": {"hooray": "defaults"}}}) 869 assert c.outer.inner.hooray == "yaml" 870 871 def env_vars_override_project(self): 872 os.environ["INVOKE_OUTER_INNER_HOORAY"] = "env" 873 c = Config(project_location=join(CONFIGS_PATH, "yaml")) 874 c.load_project() 875 c.load_shell_env() 876 assert c.outer.inner.hooray == "env" 877 878 def env_vars_override_user(self): 879 os.environ["INVOKE_OUTER_INNER_HOORAY"] = "env" 880 c = Config(user_prefix=join(CONFIGS_PATH, "yaml/")) 881 c.load_shell_env() 882 assert c.outer.inner.hooray == "env" 883 884 def env_vars_override_systemwide(self): 885 os.environ["INVOKE_OUTER_INNER_HOORAY"] = "env" 886 c = Config(system_prefix=join(CONFIGS_PATH, "yaml/")) 887 c.load_shell_env() 888 assert c.outer.inner.hooray == "env" 889 890 def env_vars_override_collection(self): 891 os.environ["INVOKE_OUTER_INNER_HOORAY"] = "env" 892 c = Config() 893 c.load_collection({"outer": {"inner": {"hooray": "defaults"}}}) 894 c.load_shell_env() 895 assert c.outer.inner.hooray == "env" 896 897 def runtime_overrides_env_vars(self): 898 os.environ["INVOKE_OUTER_INNER_HOORAY"] = "env" 899 c = Config(runtime_path=join(CONFIGS_PATH, "json", "invoke.json")) 900 c.load_runtime() 901 c.load_shell_env() 902 assert c.outer.inner.hooray == "json" 903 904 def runtime_overrides_project(self): 905 c = Config( 906 runtime_path=join(CONFIGS_PATH, "json", "invoke.json"), 907 project_location=join(CONFIGS_PATH, "yaml"), 908 ) 909 c.load_runtime() 910 c.load_project() 911 assert c.outer.inner.hooray == "json" 912 913 def runtime_overrides_user(self): 914 c = Config( 915 runtime_path=join(CONFIGS_PATH, "json", "invoke.json"), 916 user_prefix=join(CONFIGS_PATH, "yaml/"), 917 ) 918 c.load_runtime() 919 assert c.outer.inner.hooray == "json" 920 921 def runtime_overrides_systemwide(self): 922 c = Config( 923 runtime_path=join(CONFIGS_PATH, "json", "invoke.json"), 924 system_prefix=join(CONFIGS_PATH, "yaml/"), 925 ) 926 c.load_runtime() 927 assert c.outer.inner.hooray == "json" 928 929 def runtime_overrides_collection(self): 930 c = Config(runtime_path=join(CONFIGS_PATH, "json", "invoke.json")) 931 c.load_collection({"outer": {"inner": {"hooray": "defaults"}}}) 932 c.load_runtime() 933 assert c.outer.inner.hooray == "json" 934 935 def cli_overrides_override_all(self): 936 "CLI-driven overrides win vs all other layers" 937 # TODO: expand into more explicit tests like the above? meh 938 c = Config( 939 overrides={"outer": {"inner": {"hooray": "overrides"}}}, 940 runtime_path=join(CONFIGS_PATH, "json", "invoke.json"), 941 ) 942 c.load_runtime() 943 assert c.outer.inner.hooray == "overrides" 944 945 def yaml_prevents_yml_json_or_python(self): 946 c = Config(system_prefix=join(CONFIGS_PATH, "all-four/")) 947 assert "json-only" not in c 948 assert "python_only" not in c 949 assert "yml-only" not in c 950 assert "yaml-only" in c 951 assert c.shared == "yaml-value" 952 953 def yml_prevents_json_or_python(self): 954 c = Config(system_prefix=join(CONFIGS_PATH, "three-of-em/")) 955 assert "json-only" not in c 956 assert "python_only" not in c 957 assert "yml-only" in c 958 assert c.shared == "yml-value" 959 960 def json_prevents_python(self): 961 c = Config(system_prefix=join(CONFIGS_PATH, "json-and-python/")) 962 assert "python_only" not in c 963 assert "json-only" in c 964 assert c.shared == "json-value" 965 966 class clone: 967 def preserves_basic_members(self): 968 c1 = Config( 969 defaults={"key": "default"}, 970 overrides={"key": "override"}, 971 system_prefix="global", 972 user_prefix="user", 973 project_location="project", 974 runtime_path="runtime.yaml", 975 ) 976 c2 = c1.clone() 977 # NOTE: expecting identical defaults also implicitly tests that 978 # clone() passes in defaults= instead of doing an empty init + 979 # copy. (When that is not the case, we end up with 980 # global_defaults() being rerun and re-added to _defaults...) 981 assert c2._defaults == c1._defaults 982 assert c2._defaults is not c1._defaults 983 assert c2._overrides == c1._overrides 984 assert c2._overrides is not c1._overrides 985 assert c2._system_prefix == c1._system_prefix 986 assert c2._user_prefix == c1._user_prefix 987 assert c2._project_prefix == c1._project_prefix 988 assert c2.prefix == c1.prefix 989 assert c2.file_prefix == c1.file_prefix 990 assert c2.env_prefix == c1.env_prefix 991 assert c2._runtime_path == c1._runtime_path 992 993 def preserves_merged_config(self): 994 c = Config( 995 defaults={"key": "default"}, overrides={"key": "override"} 996 ) 997 assert c.key == "override" 998 assert c._defaults["key"] == "default" 999 c2 = c.clone() 1000 assert c2.key == "override" 1001 assert c2._defaults["key"] == "default" 1002 assert c2._overrides["key"] == "override" 1003 1004 def preserves_file_data(self): 1005 c = Config(system_prefix=join(CONFIGS_PATH, "yaml/")) 1006 assert c.outer.inner.hooray == "yaml" 1007 c2 = c.clone() 1008 assert c2.outer.inner.hooray == "yaml" 1009 assert c2._system == {"outer": {"inner": {"hooray": "yaml"}}} 1010 1011 @patch.object( 1012 Config, 1013 "_load_yaml", 1014 return_value={"outer": {"inner": {"hooray": "yaml"}}}, 1015 ) 1016 def does_not_reload_file_data(self, load_yaml): 1017 path = join(CONFIGS_PATH, "yaml/") 1018 c = Config(system_prefix=path) 1019 c2 = c.clone() 1020 assert c2.outer.inner.hooray == "yaml" 1021 # Crummy way to say "only got called with this specific invocation 1022 # one time" (since assert_calls_with gets mad about other 1023 # invocations w/ different args) 1024 calls = load_yaml.call_args_list 1025 my_call = call("{}invoke.yaml".format(path)) 1026 try: 1027 calls.remove(my_call) 1028 assert my_call not in calls 1029 except ValueError: 1030 err = "{} not found in {} even once!" 1031 assert False, err.format(my_call, calls) 1032 1033 def preserves_env_data(self): 1034 os.environ["INVOKE_FOO"] = "bar" 1035 c = Config(defaults={"foo": "notbar"}) 1036 c.load_shell_env() 1037 c2 = c.clone() 1038 assert c2.foo == "bar" 1039 1040 def works_correctly_when_subclassed(self): 1041 # Because sometimes, implementation #1 is really naive! 1042 class MyConfig(Config): 1043 pass 1044 1045 c = MyConfig() 1046 assert isinstance(c, MyConfig) # sanity 1047 c2 = c.clone() 1048 assert isinstance(c2, MyConfig) # actual test 1049 1050 class into_kwarg: 1051 "'into' kwarg" 1052 1053 def is_not_required(self): 1054 c = Config(defaults={"meh": "okay"}) 1055 c2 = c.clone() 1056 assert c2.meh == "okay" 1057 1058 def raises_TypeError_if_value_is_not_Config_subclass(self): 1059 try: 1060 Config().clone(into=17) 1061 except TypeError: 1062 pass 1063 else: 1064 assert False, "Non-class obj did not raise TypeError!" 1065 1066 class Foo(object): 1067 pass 1068 1069 try: 1070 Config().clone(into=Foo) 1071 except TypeError: 1072 pass 1073 else: 1074 assert False, "Non-subclass did not raise TypeError!" 1075 1076 def resulting_clones_are_typed_as_new_class(self): 1077 class MyConfig(Config): 1078 pass 1079 1080 c = Config() 1081 c2 = c.clone(into=MyConfig) 1082 assert type(c2) is MyConfig 1083 1084 def non_conflicting_values_are_merged(self): 1085 # NOTE: this is really just basic clone behavior. 1086 class MyConfig(Config): 1087 @staticmethod 1088 def global_defaults(): 1089 orig = Config.global_defaults() 1090 orig["new"] = {"data": "ohai"} 1091 return orig 1092 1093 c = Config(defaults={"other": {"data": "hello"}}) 1094 c["runtime"] = {"modification": "sup"} 1095 c2 = c.clone(into=MyConfig) 1096 # New default data from MyConfig present 1097 assert c2.new.data == "ohai" 1098 # As well as old default data from the cloned instance 1099 assert c2.other.data == "hello" 1100 # And runtime user mods from the cloned instance 1101 assert c2.runtime.modification == "sup" 1102 1103 def does_not_deepcopy(self): 1104 c = Config( 1105 defaults={ 1106 # Will merge_dicts happily 1107 "oh": {"dear": {"god": object()}}, 1108 # And shallow-copy compound values 1109 "shallow": {"objects": ["copy", "okay"]}, 1110 # Will preserve refrences to the innermost dict, sadly. Not 1111 # much we can do without incurring deepcopy problems (or 1112 # reimplementing it entirely) 1113 "welp": {"cannot": ["have", {"everything": "we want"}]}, 1114 } 1115 ) 1116 c2 = c.clone() 1117 # Basic identity 1118 assert c is not c2, "Clone had same identity as original!" 1119 # Dicts get recreated 1120 assert c.oh is not c2.oh, "Top level key had same identity!" 1121 assert ( 1122 c.oh.dear is not c2.oh.dear 1123 ), "Midlevel key had same identity!" # noqa 1124 # Basic values get copied 1125 err = "Leaf object() had same identity!" 1126 assert c.oh.dear.god is not c2.oh.dear.god, err 1127 assert c.shallow.objects == c2.shallow.objects 1128 err = "Shallow list had same identity!" 1129 assert c.shallow.objects is not c2.shallow.objects, err 1130 # Deeply nested non-dict objects are stil problematic, oh well 1131 err = "Huh, a deeply nested dict-in-a-list had different identity?" 1132 assert c.welp.cannot[1] is c2.welp.cannot[1], err 1133 err = "Huh, a deeply nested dict-in-a-list value had different identity?" # noqa 1134 assert ( 1135 c.welp.cannot[1]["everything"] 1136 is c2.welp.cannot[1]["everything"] 1137 ), err # noqa 1138 1139 def can_be_pickled(self): 1140 c = Config(overrides={"foo": {"bar": {"biz": ["baz", "buzz"]}}}) 1141 c2 = pickle.loads(pickle.dumps(c)) 1142 assert c == c2 1143 assert c is not c2 1144 assert c.foo.bar.biz is not c2.foo.bar.biz 1145 1146 1147# NOTE: merge_dicts has its own very low level unit tests in its own file 1148