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