Repository: great-expectations/great_expectations

Issue:
creating config variables using save_config_variables() method is adding additional "\" character to password variables in config_variables.yml file
**Describe the bug**
Im trying to create great expectations project from code instead of cli.  When creating config variables using save_config_variables() method, code is adding additional "\\" character to password variables in config_variables.yml file if password is environment variable.

**To Reproduce**
Steps to reproduce the behavior:
1. Create a great expectations data context object using ge.DataContext.create()
2. Create kwargs with all details about a connection where password is an environmental variable
2. pass connection name and connection kwargs to save_config_variables() method of data context object

**Expected behavior**
An entry should be created in config_variables.yml file inside uncommitted folder with all details present in kwargs  variable.
password value should not contain additional "\\" character. This works fine in version 0.12.4
Also, This works fine if password is not an environmental variable


**Environment (please complete the following information):**
 - OS:  Windows 10
 - GE Version: 0.12.8 and 0.13.3
**Additional context**
Code i used:
```
import great_expectations

kwargs = {
            "drivername": "snowflake",
            "host": "hostname",
            "warehouse": "compute_wh",
            "schema": "public",
            "database": "employee",
            "username": "username",
            "password": "${SF_PWD}"
            }
data_ctxt = ge.DataContext.create('./', False)
data_ctxt.save_config_variable('SF', kwargs)

```


Discussion:
The core team will take on reproducing and fixing this. Thank you for the report, @varundunga !

Resolving patch:
diff --git a/great_expectations/data_context/data_context.py b/great_expectations/data_context/data_context.py
--- a/great_expectations/data_context/data_context.py
+++ b/great_expectations/data_context/data_context.py
@@ -61,6 +61,7 @@
     file_relative_path,
     instantiate_class_from_config,
     load_class,
+    parse_substitution_variable,
     substitute_all_config_variables,
     substitute_config_variable,
 )
@@ -805,6 +806,7 @@ def escape_all_config_variables(
         self,
         value: Union[str, dict, list],
         dollar_sign_escape_string: str = DOLLAR_SIGN_ESCAPE_STRING,
+        skip_if_substitution_variable: bool = True,
     ) -> Union[str, dict, list]:
         """
         Replace all `$` characters with the DOLLAR_SIGN_ESCAPE_STRING
@@ -812,6 +814,7 @@ def escape_all_config_variables(
         Args:
             value: config variable value
             dollar_sign_escape_string: replaces instances of `$`
+            skip_if_substitution_variable: skip if the value is of the form ${MYVAR} or $MYVAR
 
         Returns:
             input value with all `$` characters replaced with the escape string
@@ -819,29 +822,47 @@ def escape_all_config_variables(
 
         if isinstance(value, dict) or isinstance(value, OrderedDict):
             return {
-                k: self.escape_all_config_variables(v, dollar_sign_escape_string)
+                k: self.escape_all_config_variables(
+                    v, dollar_sign_escape_string, skip_if_substitution_variable
+                )
                 for k, v in value.items()
             }
 
         elif isinstance(value, list):
             return [
-                self.escape_all_config_variables(v, dollar_sign_escape_string)
+                self.escape_all_config_variables(
+                    v, dollar_sign_escape_string, skip_if_substitution_variable
+                )
                 for v in value
             ]
-        return value.replace("$", dollar_sign_escape_string)
+        if skip_if_substitution_variable:
+            if parse_substitution_variable(value) is None:
+                return value.replace("$", dollar_sign_escape_string)
+            else:
+                return value
+        else:
+            return value.replace("$", dollar_sign_escape_string)
 
-    def save_config_variable(self, config_variable_name, value):
-        """Save config variable value
+    def save_config_variable(
+        self, config_variable_name, value, skip_if_substitution_variable: bool = True
+    ):
+        r"""Save config variable value
+        Escapes $ unless they are used in substitution variables e.g. the $ characters in ${SOME_VAR} or $SOME_VAR are not escaped
 
         Args:
             config_variable_name: name of the property
             value: the value to save for the property
+            skip_if_substitution_variable: set to False to escape $ in values in substitution variable form e.g. ${SOME_VAR} -> r"\${SOME_VAR}" or $SOME_VAR -> r"\$SOME_VAR"
 
         Returns:
             None
         """
         config_variables = self._load_config_variables_file()
-        value = self.escape_all_config_variables(value, self.DOLLAR_SIGN_ESCAPE_STRING)
+        value = self.escape_all_config_variables(
+            value,
+            self.DOLLAR_SIGN_ESCAPE_STRING,
+            skip_if_substitution_variable=skip_if_substitution_variable,
+        )
         config_variables[config_variable_name] = value
         config_variables_filepath = self.get_config().config_variables_file_path
         if not config_variables_filepath:
diff --git a/great_expectations/data_context/util.py b/great_expectations/data_context/util.py
--- a/great_expectations/data_context/util.py
+++ b/great_expectations/data_context/util.py
@@ -5,8 +5,11 @@
 import re
 import warnings
 from collections import OrderedDict
+from typing import Optional
 from urllib.parse import urlparse
 
+import pyparsing as pp
+
 from great_expectations.data_context.types.base import (
     DataContextConfig,
     DataContextConfigSchema,
@@ -214,6 +217,28 @@ def file_relative_path(dunderfile, relative_path):
     return os.path.join(os.path.dirname(dunderfile), relative_path)
 
 
+def parse_substitution_variable(substitution_variable: str) -> Optional[str]:
+    """
+    Parse and check whether the string contains a substitution variable of the case insensitive form ${SOME_VAR} or $SOME_VAR
+    Args:
+        substitution_variable: string to be parsed
+
+    Returns:
+        string of variable name e.g. SOME_VAR or None if not parsable. If there are multiple substitution variables this currently returns the first e.g. $SOME_$TRING -> $SOME_
+    """
+    substitution_variable_name = pp.Word(pp.alphanums + "_").setResultsName(
+        "substitution_variable_name"
+    )
+    curly_brace_parser = "${" + substitution_variable_name + "}"
+    non_curly_brace_parser = "$" + substitution_variable_name
+    both_parser = curly_brace_parser | non_curly_brace_parser
+    try:
+        parsed_substitution_variable = both_parser.parseString(substitution_variable)
+        return parsed_substitution_variable.substitution_variable_name
+    except pp.ParseException:
+        return None
+
+
 class PasswordMasker:
     """
     Used to mask passwords in Datasources. Does not mask sqlite urls.


Test patch:
diff --git a/tests/data_context/test_data_context_config_variables.py b/tests/data_context/test_data_context_config_variables.py
--- a/tests/data_context/test_data_context_config_variables.py
+++ b/tests/data_context/test_data_context_config_variables.py
@@ -8,6 +8,8 @@
 from great_expectations.data_context.types.base import (
     DataContextConfig,
     DataContextConfigSchema,
+    DatasourceConfig,
+    DatasourceConfigSchema,
 )
 from great_expectations.data_context.util import (
     file_relative_path,
@@ -111,7 +113,7 @@ def test_setting_config_variables_is_visible_immediately(
         "host": "localhost",
         "port": "5432",
         "username": "postgres",
-        "password": "$pas$wor$d1$",
+        "password": "pas$wor$d1$",
         "database": "postgres",
     }
     context.save_config_variable(
@@ -150,7 +152,7 @@ def test_setting_config_variables_is_visible_immediately(
         "host": "localhost",
         "port": "5432",
         "username": "postgres",
-        "password": r"\$pas\$wor\$d1\$",
+        "password": r"pas\$wor\$d1\$",
         "database": "postgres",
     }
 
@@ -389,8 +391,8 @@ def test_escape_all_config_variables(empty_data_context_with_config_variables):
     escaped_value_str = r"pas\$word1"
     assert context.escape_all_config_variables(value=value_str) == escaped_value_str
 
-    value_str2 = "$pas$wor$d1$"
-    escaped_value_str2 = r"\$pas\$wor\$d1\$"
+    value_str2 = "pas$wor$d1$"
+    escaped_value_str2 = r"pas\$wor\$d1\$"
     assert context.escape_all_config_variables(value=value_str2) == escaped_value_str2
 
     # dict
@@ -416,7 +418,7 @@ def test_escape_all_config_variables(empty_data_context_with_config_variables):
     value_ordered_dict = OrderedDict(
         [
             ("UNCOMMITTED", "uncommitted"),
-            ("docs_test_folder", "$test$folder"),
+            ("docs_test_folder", "test$folder"),
             (
                 "test_db",
                 {
@@ -433,7 +435,7 @@ def test_escape_all_config_variables(empty_data_context_with_config_variables):
     escaped_value_ordered_dict = OrderedDict(
         [
             ("UNCOMMITTED", "uncommitted"),
-            ("docs_test_folder", r"\$test\$folder"),
+            ("docs_test_folder", r"test\$folder"),
             (
                 "test_db",
                 {
@@ -481,11 +483,356 @@ def test_escape_all_config_variables(empty_data_context_with_config_variables):
         == escaped_value_str_custom_escape_string
     )
 
-    value_str_custom_escape_string2 = "$pas$wor$d1$"
-    escaped_value_str_custom_escape_string2 = "@*&$pas@*&$wor@*&$d1@*&$"
+    value_str_custom_escape_string2 = "pas$wor$d1$"
+    escaped_value_str_custom_escape_string2 = "pas@*&$wor@*&$d1@*&$"
     assert (
         context.escape_all_config_variables(
             value=value_str_custom_escape_string2, dollar_sign_escape_string="@*&$"
         )
         == escaped_value_str_custom_escape_string2
     )
+
+
+def test_escape_all_config_variables_skip_substitution_vars(
+    empty_data_context_with_config_variables,
+):
+    """
+    What does this test and why?
+    escape_all_config_variables(skip_if_substitution_variable=True/False) should function as documented.
+    """
+    context = empty_data_context_with_config_variables
+
+    # str
+    value_str = "$VALUE_STR"
+    escaped_value_str = r"\$VALUE_STR"
+    assert (
+        context.escape_all_config_variables(
+            value=value_str, skip_if_substitution_variable=True
+        )
+        == value_str
+    )
+    assert (
+        context.escape_all_config_variables(
+            value=value_str, skip_if_substitution_variable=False
+        )
+        == escaped_value_str
+    )
+
+    value_str2 = "VALUE_$TR"
+    escaped_value_str2 = r"VALUE_\$TR"
+    assert (
+        context.escape_all_config_variables(
+            value=value_str2, skip_if_substitution_variable=True
+        )
+        == escaped_value_str2
+    )
+    assert (
+        context.escape_all_config_variables(
+            value=value_str2, skip_if_substitution_variable=False
+        )
+        == escaped_value_str2
+    )
+
+    multi_value_str = "${USER}:pas$word@${HOST}:${PORT}/${DATABASE}"
+    escaped_multi_value_str = r"\${USER}:pas\$word@\${HOST}:\${PORT}/\${DATABASE}"
+    assert (
+        context.escape_all_config_variables(
+            value=multi_value_str, skip_if_substitution_variable=True
+        )
+        == multi_value_str
+    )
+    assert (
+        context.escape_all_config_variables(
+            value=multi_value_str, skip_if_substitution_variable=False
+        )
+        == escaped_multi_value_str
+    )
+
+    multi_value_str2 = "$USER:pas$word@$HOST:${PORT}/${DATABASE}"
+    escaped_multi_value_str2 = r"\$USER:pas\$word@\$HOST:\${PORT}/\${DATABASE}"
+    assert (
+        context.escape_all_config_variables(
+            value=multi_value_str2, skip_if_substitution_variable=True
+        )
+        == multi_value_str2
+    )
+    assert (
+        context.escape_all_config_variables(
+            value=multi_value_str2, skip_if_substitution_variable=False
+        )
+        == escaped_multi_value_str2
+    )
+
+    multi_value_str3 = "USER:pas$word@$HOST:${PORT}/${DATABASE}"
+    escaped_multi_value_str3 = r"USER:pas\$word@\$HOST:\${PORT}/\${DATABASE}"
+    assert (
+        context.escape_all_config_variables(
+            value=multi_value_str3, skip_if_substitution_variable=True
+        )
+        == escaped_multi_value_str3
+    )
+    assert (
+        context.escape_all_config_variables(
+            value=multi_value_str3, skip_if_substitution_variable=False
+        )
+        == escaped_multi_value_str3
+    )
+
+    # dict
+    value_dict = {
+        "drivername": "postgresql",
+        "host": "${HOST}",
+        "port": "5432",
+        "username": "postgres",
+        "password": "pass$word1",
+        "database": "$postgres",
+        "sub_dict": {
+            "test_val_no_escaping": "test_val",
+            "test_val_escaping": "te$t_val",
+            "test_val_substitution": "$test_val",
+            "test_val_substitution_braces": "${test_val}",
+        },
+    }
+    escaped_value_dict = {
+        "drivername": "postgresql",
+        "host": r"\${HOST}",
+        "port": "5432",
+        "username": "postgres",
+        "password": r"pass\$word1",
+        "database": r"\$postgres",
+        "sub_dict": {
+            "test_val_no_escaping": "test_val",
+            "test_val_escaping": r"te\$t_val",
+            "test_val_substitution": r"\$test_val",
+            "test_val_substitution_braces": r"\${test_val}",
+        },
+    }
+    escaped_value_dict_skip_substitution_variables = {
+        "drivername": "postgresql",
+        "host": "${HOST}",
+        "port": "5432",
+        "username": "postgres",
+        "password": r"pass\$word1",
+        "database": "$postgres",
+        "sub_dict": {
+            "test_val_no_escaping": "test_val",
+            "test_val_escaping": r"te\$t_val",
+            "test_val_substitution": "$test_val",
+            "test_val_substitution_braces": "${test_val}",
+        },
+    }
+    assert (
+        context.escape_all_config_variables(
+            value=value_dict, skip_if_substitution_variable=False
+        )
+        == escaped_value_dict
+    )
+    assert (
+        context.escape_all_config_variables(
+            value=value_dict, skip_if_substitution_variable=True
+        )
+        == escaped_value_dict_skip_substitution_variables
+    )
+
+    # OrderedDict
+    value_ordered_dict = OrderedDict(
+        [
+            ("UNCOMMITTED", "uncommitted"),
+            ("docs_test_folder", "test$folder"),
+            (
+                "test_db",
+                {
+                    "drivername": "$postgresql",
+                    "host": "some_host",
+                    "port": "5432",
+                    "username": "${USERNAME}",
+                    "password": "pa$sword1",
+                    "database": "postgres",
+                },
+            ),
+        ]
+    )
+    escaped_value_ordered_dict = OrderedDict(
+        [
+            ("UNCOMMITTED", "uncommitted"),
+            ("docs_test_folder", r"test\$folder"),
+            (
+                "test_db",
+                {
+                    "drivername": r"\$postgresql",
+                    "host": "some_host",
+                    "port": "5432",
+                    "username": r"\${USERNAME}",
+                    "password": r"pa\$sword1",
+                    "database": "postgres",
+                },
+            ),
+        ]
+    )
+    escaped_value_ordered_dict_skip_substitution_variables = OrderedDict(
+        [
+            ("UNCOMMITTED", "uncommitted"),
+            ("docs_test_folder", r"test\$folder"),
+            (
+                "test_db",
+                {
+                    "drivername": "$postgresql",
+                    "host": "some_host",
+                    "port": "5432",
+                    "username": "${USERNAME}",
+                    "password": r"pa\$sword1",
+                    "database": "postgres",
+                },
+            ),
+        ]
+    )
+    assert (
+        context.escape_all_config_variables(
+            value=value_ordered_dict, skip_if_substitution_variable=False
+        )
+        == escaped_value_ordered_dict
+    )
+    assert (
+        context.escape_all_config_variables(
+            value=value_ordered_dict, skip_if_substitution_variable=True
+        )
+        == escaped_value_ordered_dict_skip_substitution_variables
+    )
+
+    # list
+    value_list = [
+        "postgresql",
+        "localhost",
+        "5432",
+        "$postgres",
+        "pass$word1",
+        "${POSTGRES}",
+    ]
+    escaped_value_list = [
+        "postgresql",
+        "localhost",
+        "5432",
+        r"\$postgres",
+        r"pass\$word1",
+        r"\${POSTGRES}",
+    ]
+    escaped_value_list_skip_substitution_variables = [
+        "postgresql",
+        "localhost",
+        "5432",
+        "$postgres",
+        r"pass\$word1",
+        "${POSTGRES}",
+    ]
+    assert (
+        context.escape_all_config_variables(
+            value=value_list, skip_if_substitution_variable=False
+        )
+        == escaped_value_list
+    )
+    assert (
+        context.escape_all_config_variables(
+            value=value_list, skip_if_substitution_variable=True
+        )
+        == escaped_value_list_skip_substitution_variables
+    )
+
+
+def test_create_data_context_and_config_vars_in_code(tmp_path_factory, monkeypatch):
+    """
+    What does this test and why?
+    Creating a DataContext via .create(), then using .save_config_variable() to save a variable that will eventually be substituted (e.g. ${SOME_VAR}) should result in the proper escaping of $.
+    This is in response to issue #2196
+    """
+
+    project_path = str(tmp_path_factory.mktemp("data_context"))
+    context = ge.DataContext.create(
+        project_root_dir=project_path,
+        usage_statistics_enabled=False,
+    )
+
+    CONFIG_VARS = {
+        "DB_HOST": "${DB_HOST_FROM_ENV_VAR}",
+        "DB_NAME": "DB_NAME",
+        "DB_USER": "DB_USER",
+        "DB_PWD": "pas$word",
+    }
+    for k, v in CONFIG_VARS.items():
+        context.save_config_variable(k, v)
+
+    config_vars_file_contents = context._load_config_variables_file()
+
+    # Add escaping for DB_PWD since it is not of the form ${SOMEVAR} or $SOMEVAR
+    CONFIG_VARS_WITH_ESCAPING = CONFIG_VARS.copy()
+    CONFIG_VARS_WITH_ESCAPING["DB_PWD"] = r"pas\$word"
+
+    # Ensure all config vars saved are in the config_variables.yml file
+    # and that escaping was added for "pas$word" -> "pas\$word"
+    assert all(
+        item in config_vars_file_contents.items()
+        for item in CONFIG_VARS_WITH_ESCAPING.items()
+    )
+    assert not all(
+        item in config_vars_file_contents.items() for item in CONFIG_VARS.items()
+    )
+
+    # Add env var for substitution
+    monkeypatch.setenv("DB_HOST_FROM_ENV_VAR", "DB_HOST_FROM_ENV_VAR_VALUE")
+
+    datasource_config = DatasourceConfig(
+        class_name="SqlAlchemyDatasource",
+        credentials={
+            "drivername": "postgresql",
+            "host": "$DB_HOST",
+            "port": "65432",
+            "database": "${DB_NAME}",
+            "username": "${DB_USER}",
+            "password": "${DB_PWD}",
+        },
+    )
+    datasource_config_schema = DatasourceConfigSchema()
+
+    # use context.add_datasource to test this by adding a datasource with values to substitute.
+    context.add_datasource(
+        initialize=False,
+        name="test_datasource",
+        **datasource_config_schema.dump(datasource_config)
+    )
+
+    assert context.list_datasources()[0]["credentials"] == {
+        "drivername": "postgresql",
+        "host": "DB_HOST_FROM_ENV_VAR_VALUE",
+        "port": "65432",
+        "database": "DB_NAME",
+        "username": "DB_USER",
+        # Note masking of "password" field
+        "password": "***",
+    }
+
+    # Check context substitutes escaped variables appropriately
+    data_context_config_schema = DataContextConfigSchema()
+    context_with_variables_substituted_dict = data_context_config_schema.dump(
+        context.get_config_with_variables_substituted()
+    )
+
+    test_datasource_credentials = context_with_variables_substituted_dict[
+        "datasources"
+    ]["test_datasource"]["credentials"]
+
+    assert test_datasource_credentials["host"] == "DB_HOST_FROM_ENV_VAR_VALUE"
+    assert test_datasource_credentials["username"] == "DB_USER"
+    assert test_datasource_credentials["password"] == "pas$word"
+    assert test_datasource_credentials["database"] == "DB_NAME"
+
+    # Ensure skip_if_substitution_variable=False works as documented
+    context.save_config_variable(
+        "escaped", "$SOME_VAR", skip_if_substitution_variable=False
+    )
+    context.save_config_variable(
+        "escaped_curly", "${SOME_VAR}", skip_if_substitution_variable=False
+    )
+
+    config_vars_file_contents = context._load_config_variables_file()
+
+    assert config_vars_file_contents["escaped"] == r"\$SOME_VAR"
+    assert config_vars_file_contents["escaped_curly"] == r"\${SOME_VAR}"
diff --git a/tests/data_context/test_data_context_utils.py b/tests/data_context/test_data_context_utils.py
--- a/tests/data_context/test_data_context_utils.py
+++ b/tests/data_context/test_data_context_utils.py
@@ -1,7 +1,10 @@
 import pytest
 
 import great_expectations.exceptions as gee
-from great_expectations.data_context.util import PasswordMasker
+from great_expectations.data_context.util import (
+    PasswordMasker,
+    parse_substitution_variable,
+)
 from great_expectations.util import load_class
 
 
@@ -227,3 +230,22 @@ def test_password_masker_mask_db_url():
     # in-memory
     assert PasswordMasker.mask_db_url("sqlite://") == "sqlite://"
     assert PasswordMasker.mask_db_url("sqlite://", use_urlparse=True) == "sqlite://"
+
+
+def test_parse_substitution_variable():
+    """
+    What does this test and why?
+    Ensure parse_substitution_variable works as expected.
+    Returns:
+
+    """
+    assert parse_substitution_variable("${SOME_VAR}") == "SOME_VAR"
+    assert parse_substitution_variable("$SOME_VAR") == "SOME_VAR"
+    assert parse_substitution_variable("SOME_STRING") is None
+    assert parse_substitution_variable("SOME_$TRING") is None
+    assert parse_substitution_variable("${some_var}") == "some_var"
+    assert parse_substitution_variable("$some_var") == "some_var"
+    assert parse_substitution_variable("some_string") is None
+    assert parse_substitution_variable("some_$tring") is None
+    assert parse_substitution_variable("${SOME_$TRING}") is None
+    assert parse_substitution_variable("$SOME_$TRING") == "SOME_"