Repository: chaostoolkit/chaostoolkit-lib

Issue:
Enable global controls from the settings
It would be valuable to allow controls to be declared and defined in the settings and applied globally no matter the experiment.

Discussion:
codecov-io: # [Codecov](https://codecov.io/gh/chaostoolkit/chaostoolkit-lib/pull/109?src=pr&el=h1) Report
> Merging [#109](https://codecov.io/gh/chaostoolkit/chaostoolkit-lib/pull/109?src=pr&el=desc) into [master](https://codecov.io/gh/chaostoolkit/chaostoolkit-lib/commit/fa7c5a5900481c7d52f337a3841a22b3c12e7e98?src=pr&el=desc) will **increase** coverage by `0.41%`.
> The diff coverage is `96.07%`.

[![Impacted file tree graph](https://codecov.io/gh/chaostoolkit/chaostoolkit-lib/pull/109/graphs/tree.svg?width=650&token=XAcvtqktVg&height=150&src=pr)](https://codecov.io/gh/chaostoolkit/chaostoolkit-lib/pull/109?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##           master     #109      +/-   ##
==========================================
+ Coverage   82.06%   82.48%   +0.41%     
==========================================
  Files          23       23              
  Lines        1489     1536      +47     
==========================================
+ Hits         1222     1267      +45     
- Misses        267      269       +2
```


| [Impacted Files](https://codecov.io/gh/chaostoolkit/chaostoolkit-lib/pull/109?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [chaoslib/settings.py](https://codecov.io/gh/chaostoolkit/chaostoolkit-lib/pull/109/diff?src=pr&el=tree#diff-Y2hhb3NsaWIvc2V0dGluZ3MucHk=) | `100% <100%> (ø)` | :arrow_up: |
| [chaoslib/control/\_\_init\_\_.py](https://codecov.io/gh/chaostoolkit/chaostoolkit-lib/pull/109/diff?src=pr&el=tree#diff-Y2hhb3NsaWIvY29udHJvbC9fX2luaXRfXy5weQ==) | `87.41% <100%> (+2.86%)` | :arrow_up: |
| [chaoslib/control/python.py](https://codecov.io/gh/chaostoolkit/chaostoolkit-lib/pull/109/diff?src=pr&el=tree#diff-Y2hhb3NsaWIvY29udHJvbC9weXRob24ucHk=) | `85% <84.61%> (-0.3%)` | :arrow_down: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/chaostoolkit/chaostoolkit-lib/pull/109?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/chaostoolkit/chaostoolkit-lib/pull/109?src=pr&el=footer). Last update [fa7c5a5...3954531](https://codecov.io/gh/chaostoolkit/chaostoolkit-lib/pull/109?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3c26329..5e4ccfe 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,9 +10,12 @@
 - Pass configuration to tolerance checks so that probe tolerance can access it
   [#98][98]
 - Fix using a probe as a tolerance validator [#98][98]
+- Make settings globally available during the run [#99][99]
+- Load controls from settings too now [#99][99]
 
 [97]: https://github.com/chaostoolkit/chaostoolkit-lib/issues/97
 [98]: https://github.com/chaostoolkit/chaostoolkit-lib/issues/98
+[99]: https://github.com/chaostoolkit/chaostoolkit-lib/issues/99
 
 ## [1.1.2][] - 2019-03-22
 
diff --git a/chaoslib/control/__init__.py b/chaoslib/control/__init__.py
index 5439bac..a8057eb 100644
--- a/chaoslib/control/__init__.py
+++ b/chaoslib/control/__init__.py
@@ -3,16 +3,23 @@ from contextlib import contextmanager
 from copy import deepcopy
 from typing import List, Union
 
+import contextvars
 from logzero import logger
 
 from chaoslib.control.python import apply_python_control, cleanup_control, \
     initialize_control, validate_python_control
 from chaoslib.exceptions import InterruptExecution, InvalidControl
-from chaoslib.types import Activity, Configuration, \
+from chaoslib.settings import get_loaded_settings
+from chaoslib.types import Settings
+from chaoslib.types import Activity, Configuration, Control as ControlType, \
     Experiment, Hypothesis, Journal, Run, Secrets
 
+
 __all__ = ["controls", "initialize_controls", "cleanup_controls",
-           "validate_controls", "Control"]
+           "validate_controls", "Control", "initialize_global_controls",
+           "cleanup_global_controls"]
+
+global_controls = contextvars.ContextVar('global_controls', default=[])
 
 
 def initialize_controls(experiment: Experiment,
@@ -105,6 +112,47 @@ def validate_controls(experiment: Experiment):
             validate_python_control(c)
 
 
+def initialize_global_controls(settings: Settings):
+    """
+    Load and initialize controls declared in the settings
+    """
+    controls = []
+    for name, control in settings.get("controls", {}).items():
+        control['name'] = name
+        logger.debug("Initializing global control '{}'".format(name))
+
+        provider = control.get("provider")
+        if provider and provider["type"] == "python":
+            initialize_control(
+                control, configuration=None, secrets=None,
+                settings=settings)
+        controls.append(control)
+    global_controls.set(controls)
+
+
+def cleanup_global_controls():
+    """
+    Unload and cleanup global controls
+    """
+    controls = global_controls.get()
+    global_controls.set([])
+
+    for control in controls:
+        name = control['name']
+        logger.debug("Cleaning up global control '{}'".format(name))
+
+        provider = control.get("provider")
+        if provider and provider["type"] == "python":
+            cleanup_control(control)
+
+
+def get_global_controls() -> List[ControlType]:
+    """
+    All the controls loaded from the settings.
+    """
+    return global_controls.get()
+
+
 class Control:
     def begin(self, level: str, experiment: Experiment,
               context: Union[Activity, Hypothesis, Experiment],
@@ -178,8 +226,10 @@ def get_context_controls(level: str, experiment: Experiment,
     If a control is declared at the current level, do override it with an
     top-level ine.
     """
+    glbl_controls = global_controls.get()[:]
     top_level_controls = experiment.get("controls", [])
     controls = context.get("controls", [])
+    controls.extend(glbl_controls)
 
     # do we even have something at the top level to be merged?
     if not top_level_controls:
@@ -229,6 +279,7 @@ def apply_controls(level: str, experiment: Experiment,
     the `"activity"` when it must be an activity. The `scope` is one of
     `"before", "after"` and the `state` is only set on `"after"` scope.
     """
+    settings = get_loaded_settings() or None
     controls = get_context_controls(level, experiment, context)
     if not controls:
         return
@@ -251,7 +302,8 @@ def apply_controls(level: str, experiment: Experiment,
                 apply_python_control(
                     level="{}-{}".format(level, scope), control=control,
                     context=context, state=state, experiment=experiment,
-                    configuration=configuration, secrets=secrets)
+                    configuration=configuration, secrets=secrets,
+                    settings=settings)
         except InterruptExecution:
             logger.debug(
                 "{}-control '{}' interrupted the execution".format(
diff --git a/chaoslib/control/python.py b/chaoslib/control/python.py
index 26e4a4c..0236fc4 100644
--- a/chaoslib/control/python.py
+++ b/chaoslib/control/python.py
@@ -9,7 +9,7 @@ from logzero import logger
 from chaoslib import substitute
 from chaoslib.exceptions import InvalidActivity
 from chaoslib.types import Activity, Configuration, Control, Experiment, \
-    Journal, Run, Secrets
+    Journal, Run, Secrets, Settings
 
 
 __all__ = ["apply_python_control", "cleanup_control", "initialize_control",
@@ -29,14 +29,27 @@ _level_mapping = {
 
 
 def initialize_control(control: Control, configuration: Configuration,
-                       secrets: Secrets):
+                       secrets: Secrets, settings: Settings = None):
     """
     Initialize a control by calling its `configure_control` function.
     """
     func = load_func(control, "configure_control")
     if not func:
         return
-    func(configuration, secrets)
+
+    arguments = {}
+    sig = inspect.signature(func)
+
+    if "secrets" in sig.parameters:
+        arguments["secrets"] = secrets
+
+    if "config" in sig.parameters:
+        arguments["config"] = configuration
+
+    if "settings" in sig.parameters:
+        arguments["settings"] = settings
+
+    func(**arguments)
 
 
 def cleanup_control(control: Control):
@@ -73,7 +86,7 @@ def apply_python_control(level: str, control: Control, experiment: Experiment,
                          context: Union[Activity, Experiment],
                          state: Union[Journal, Run, List[Run]] = None,
                          configuration: Configuration = None,
-                         secrets: Secrets = None):
+                         secrets: Secrets = None, settings: Settings = None):
     """
     Apply a control by calling a function matching the given level.
     """
@@ -103,6 +116,12 @@ def apply_python_control(level: str, control: Control, experiment: Experiment,
     if "experiment" in sig.parameters:
         arguments["experiment"] = experiment
 
+    if "extensions" in sig.parameters:
+        arguments["extensions"] = experiment.get("extensions")
+
+    if "settings" in sig.parameters:
+        arguments["settings"] = settings
+
     func(context=context, **arguments)
 
 
diff --git a/chaoslib/settings.py b/chaoslib/settings.py
index f40e10a..acddcda 100644
--- a/chaoslib/settings.py
+++ b/chaoslib/settings.py
@@ -2,14 +2,16 @@
 import os
 import os.path
 
+import contextvars
 from logzero import logger
 import yaml
 
 from chaoslib.types import Settings
 
-__all__ = ["load_settings", "save_settings"]
+__all__ = ["get_loaded_settings", "load_settings", "save_settings"]
 CHAOSTOOLKIT_CONFIG_PATH = os.path.abspath(
     os.path.expanduser("~/.chaostoolkit/settings.yaml"))
+loaded_settings = contextvars.ContextVar('loaded_settings', default={})
 
 
 def load_settings(settings_path: str = CHAOSTOOLKIT_CONFIG_PATH) -> Settings:
@@ -24,7 +26,9 @@ def load_settings(settings_path: str = CHAOSTOOLKIT_CONFIG_PATH) -> Settings:
 
     with open(settings_path) as f:
         try:
-            return yaml.safe_load(f.read())
+            settings = yaml.safe_load(f.read())
+            loaded_settings.set(settings)
+            return settings
         except yaml.YAMLError as ye:
             logger.error("Failed parsing YAML settings: {}".format(str(ye)))
 
@@ -35,9 +39,17 @@ def save_settings(settings: Settings,
     Save chaostoolkit settings as a mapping of key/values, overwriting any file
     that may already be present.
     """
+    loaded_settings.set(settings)
     settings_dir = os.path.dirname(settings_path)
     if not os.path.isdir(settings_dir):
         os.mkdir(settings_dir)
 
     with open(settings_path, 'w') as outfile:
         yaml.dump(settings, outfile, default_flow_style=False)
+
+
+def get_loaded_settings() -> Settings:
+    """
+    Settings that have been loaded in the current context.
+    """
+    return loaded_settings.get()
diff --git a/setup.py b/setup.py
index 405330d..d92fb18 100644
--- a/setup.py
+++ b/setup.py
@@ -52,6 +52,10 @@ install_require = []
 with io.open('requirements.txt') as f:
     install_require = [l.strip() for l in f if not l.startswith('#')]
 
+install_require.append(
+    'contextvars;python_version<"3.7"'
+)
+
 extras_require = {
     "vault": [
         "hvac>=0.7.2"


Test patch:
diff --git a/tests/fixtures/controls/dummy.py b/tests/fixtures/controls/dummy.py
index f2b39bc..9bb718e 100644
--- a/tests/fixtures/controls/dummy.py
+++ b/tests/fixtures/controls/dummy.py
@@ -7,10 +7,13 @@ from chaoslib.types import Activity, Configuration, \
 value_from_config = None
 
 
-def configure_control(config: Configuration, secrets = Secrets):
+def configure_control(config: Configuration, secrets: Secrets,
+                      settings: Settings):
     global value_from_config
-    print(config)
-    value_from_config = config.get("dummy-key", "default")
+    if config:
+        value_from_config = config.get("dummy-key", "default")
+    elif settings:
+        value_from_config = settings.get("dummy-key", "default")
 
 
 def cleanup_control():
@@ -32,7 +35,7 @@ def before_hypothesis_control(context: Hypothesis, **kwargs):
 
 
 def after_hypothesis_control(context: Hypothesis,
-                            state: Dict[str, Any], **kwargs):
+                             state: Dict[str, Any], **kwargs):
     context["after_hypothesis_control"] = True
     state["after_hypothesis_control"] = True
 
diff --git a/tests/fixtures/controls/dummy_with_experiment.py b/tests/fixtures/controls/dummy_with_experiment.py
index 17a6d61..67594f0 100644
--- a/tests/fixtures/controls/dummy_with_experiment.py
+++ b/tests/fixtures/controls/dummy_with_experiment.py
@@ -7,9 +7,8 @@ from chaoslib.types import Activity, Configuration, \
 value_from_config = None
 
 
-def configure_control(config: Configuration, secrets = Secrets):
+def configure_control(config: Configuration, secrets: Secrets):
     global value_from_config
-    print(config)
     value_from_config = config.get("dummy-key", "default")
 
 
diff --git a/tests/fixtures/experiments.py b/tests/fixtures/experiments.py
index 7667167..4a031ec 100644
--- a/tests/fixtures/experiments.py
+++ b/tests/fixtures/experiments.py
@@ -229,7 +229,7 @@ ExperimentWithControls = {
     "steady-state-hypothesis": {
         "title": "hello",
         "probes": [
-        deepcopy(PythonModuleProbeWithBoolTolerance)
+            deepcopy(PythonModuleProbeWithBoolTolerance)
         ]
     },
     "method": [
diff --git a/tests/test_control.py b/tests/test_control.py
index cb2ca95..62503f7 100644
--- a/tests/test_control.py
+++ b/tests/test_control.py
@@ -12,7 +12,8 @@ import pytest
 
 from chaoslib.activity import execute_activity
 from chaoslib.control import initialize_controls, cleanup_controls, \
-    validate_controls, controls, get_all_activities, get_context_controls
+    validate_controls, controls, get_all_activities, get_context_controls, \
+    initialize_global_controls, cleanup_global_controls, get_global_controls
 from chaoslib.control.python import validate_python_control
 from chaoslib.exceptions import InterruptExecution, InvalidActivity
 from chaoslib.experiment import run_experiment
@@ -258,7 +259,6 @@ def test_controls_are_applied_at_various_levels():
     run_experiment(exp)
     activities = get_all_activities(exp)
     for activity in activities:
-        print(activity)
         if "controls" in activity:
             assert activity["before_activity_control"] is True
             assert activity["after_activity_control"] is True
@@ -274,3 +274,64 @@ def test_controls_are_applied_when_they_are_not_top_level():
         if "controls" in activity:
             assert activity["before_activity_control"] is True
             assert activity["after_activity_control"] is True
+
+
+def test_load_global_controls_from_settings():
+    exp = deepcopy(experiments.ExperimentWithControls)
+
+    try:
+        run_experiment(exp)
+        activities = get_all_activities(exp)
+        for activity in activities:
+            if "controls" in activity:
+                assert "before_activity_control" not in activity
+                assert "after_activity_control" not in activity
+    finally:
+        cleanup_global_controls()
+
+    initialize_global_controls({
+        "dummy-key": "blah",
+        "controls": {
+            "dummy": {
+                "provider": {
+                    "type": "python",
+                    "module": "fixtures.controls.dummy"
+                }
+            }
+        }
+    })
+
+    try:
+        run_experiment(exp)
+        activities = get_all_activities(exp)
+        for activity in activities:
+            if "controls" in activity:
+                assert activity["before_activity_control"] is True
+                assert activity["after_activity_control"] is True
+    finally:
+        cleanup_global_controls()
+
+
+def test_get_globally_loaded_controls_from_settings():
+    assert get_global_controls() == []
+
+    initialize_global_controls({
+        "controls": {
+            "dummy": {
+                "provider": {
+                    "type": "python",
+                    "module": "fixtures.controls.dummy"
+                }
+            }
+        }
+    })
+
+    try:
+        ctrls = get_global_controls()
+        assert len(ctrls) == 1
+        assert ctrls[0]["name"] == "dummy"
+        assert ctrls[0]["provider"]["type"] == "python"
+        assert ctrls[0]["provider"]["module"] == "fixtures.controls.dummy"
+    finally:
+        cleanup_global_controls()
+        assert get_global_controls() == []
diff --git a/tests/test_settings.py b/tests/test_settings.py
index c125a1c..ed5e6c0 100644
--- a/tests/test_settings.py
+++ b/tests/test_settings.py
@@ -1,6 +1,7 @@
 # -*- coding: utf-8 -*-
 import os.path
-from chaoslib.settings import load_settings, save_settings
+from chaoslib.settings import get_loaded_settings, load_settings, save_settings
+
 
 settings_dir = os.path.join(os.path.dirname(__file__), "fixtures")
 
@@ -45,3 +46,8 @@ def test_create_settings_file_on_save():
             os.remove(ghost)
         except OSError:
             pass
+
+
+def test_get_loaded_settings():
+    settings = load_settings(os.path.join(settings_dir, "settings.yaml"))
+    assert get_loaded_settings() is settings