Repository: aws-cloudformation/cloudformation-cli

Issue:
Add option to specify the typeconfiguration.json file to use
Testing / submitting hooks/resources requires copying over specific typeconfiguration.json files to ~/.cfn-cli/typeConfiguration.json

It would be useful to either default to the local resource/hook directory where the cfn commands are being run and/or be able to specify which typeConfiguration.json file to use.

Discussion:

Patch:
diff --git a/.pylintrc b/.pylintrc
index 75a3f8c..2b4fb64 100644
--- a/.pylintrc
+++ b/.pylintrc
@@ -22,3 +22,6 @@ good-names=e,ex,f,fp,i,j,k,n,_
 
 indent-string='    '
 max-line-length=160
+
+[DESIGN]
+max-locals=16
diff --git a/README.md b/README.md
index 703c35f..64d4fa0 100644
--- a/README.md
+++ b/README.md
@@ -60,7 +60,10 @@ cfn test --log-group-name cw_log_group --log-role-arn log_delivery_role_arn # Ha
 ```
 
 Note:
-* To use your type configuration in contract tests, you will need to save your type configuration json file in `~/.cfn-cli/typeConfiguration.json`.
+* To use your type configuration in contract tests, you will need to save your type configuration json file in `~/.cfn-cli/typeConfiguration.json` or specify the file you would like to use
+    * `--typeconfig ./myResourceTypeConfig.json`
+    * `--typeconfig /test/myresource/config1.json`
+    * `--typeconfig C:\MyResource\typeconf.json`
 
 * To use `propertyTransform` in schema, you will need to install [PYJQ](https://pypi.org/project/pyjq/). This feature will not be available to use with contract tests on Windows OS
 
diff --git a/src/rpdk/core/contract/hook_client.py b/src/rpdk/core/contract/hook_client.py
index 74b8ad6..782ec51 100644
--- a/src/rpdk/core/contract/hook_client.py
+++ b/src/rpdk/core/contract/hook_client.py
@@ -57,6 +57,7 @@ class HookClient:  # pylint: disable=too-many-instance-attributes
         log_group_name=None,
         log_role_arn=None,
         docker_image=None,
+        typeconfig=None,
         executable_entrypoint=None,
         target_info=None,
         profile=None,
@@ -101,6 +102,7 @@ class HookClient:  # pylint: disable=too-many-instance-attributes
         self._executable_entrypoint = executable_entrypoint
         self._target_info = self._setup_target_info(target_info)
         self._resolved_targets = {}
+        self._typeconfig = typeconfig
 
     @staticmethod
     def _properties_to_paths(schema, key):
@@ -491,7 +493,7 @@ class HookClient:  # pylint: disable=too-many-instance-attributes
             invocation_point,
             target,
             target_model,
-            TypeConfiguration.get_hook_configuration(),
+            TypeConfiguration.get_hook_configuration(self._typeconfig),
             **kwargs,
         )
         start_time = time.time()
diff --git a/src/rpdk/core/contract/resource_client.py b/src/rpdk/core/contract/resource_client.py
index 40e3937..0e0fd37 100644
--- a/src/rpdk/core/contract/resource_client.py
+++ b/src/rpdk/core/contract/resource_client.py
@@ -171,6 +171,7 @@ class ResourceClient:  # pylint: disable=too-many-instance-attributes
         log_group_name=None,
         log_role_arn=None,
         docker_image=None,
+        typeconfig=None,
         executable_entrypoint=None,
         profile=None,
     ):  # pylint: disable=too-many-arguments
@@ -213,6 +214,7 @@ class ResourceClient:  # pylint: disable=too-many-instance-attributes
         self._docker_image = docker_image
         self._docker_client = docker.from_env() if self._docker_image else None
         self._executable_entrypoint = executable_entrypoint
+        self._typeconfig = typeconfig
 
     def _properties_to_paths(self, key):
         return {fragment_decode(prop, prefix="") for prop in self._schema.get(key, [])}
@@ -736,7 +738,7 @@ class ResourceClient:  # pylint: disable=too-many-instance-attributes
             action,
             current_model,
             previous_model,
-            TypeConfiguration.get_type_configuration(),
+            TypeConfiguration.get_type_configuration(self._typeconfig),
             **kwargs,
         )
         start_time = time.time()
diff --git a/src/rpdk/core/contract/type_configuration.py b/src/rpdk/core/contract/type_configuration.py
index 1500318..209b257 100644
--- a/src/rpdk/core/contract/type_configuration.py
+++ b/src/rpdk/core/contract/type_configuration.py
@@ -6,48 +6,50 @@ from rpdk.core.exceptions import InvalidProjectError
 
 LOG = logging.getLogger(__name__)
 
-TYPE_CONFIGURATION_FILE_PATH = "~/.cfn-cli/typeConfiguration.json"
-
 
 class TypeConfiguration:
     TYPE_CONFIGURATION = None
 
     @staticmethod
-    def get_type_configuration():
+    def get_type_configuration(typeconfigloc):
+        if typeconfigloc:
+            type_config_file_path = typeconfigloc
+        else:
+            type_config_file_path = "~/.cfn-cli/typeConfiguration.json"
+
         LOG.debug(
-            "Loading type configuration setting file at '~/.cfn-cli/typeConfiguration.json'"
+            "Loading type configuration setting file at %s",
+            type_config_file_path,
         )
         if TypeConfiguration.TYPE_CONFIGURATION is None:
             try:
                 with open(
-                    os.path.expanduser(TYPE_CONFIGURATION_FILE_PATH), encoding="utf-8"
+                    os.path.expanduser(type_config_file_path), encoding="utf-8"
                 ) as f:
                     TypeConfiguration.TYPE_CONFIGURATION = json.load(f)
             except json.JSONDecodeError as json_decode_error:
                 LOG.debug(
                     "Type configuration file '%s' is invalid",
-                    TYPE_CONFIGURATION_FILE_PATH,
+                    type_config_file_path,
                 )
                 raise InvalidProjectError(
-                    "Type configuration file '%s' is invalid"
-                    % TYPE_CONFIGURATION_FILE_PATH
+                    "Type configuration file '%s' is invalid" % type_config_file_path
                 ) from json_decode_error
             except FileNotFoundError:
                 LOG.debug(
                     "Type configuration file '%s' not Found, do nothing",
-                    TYPE_CONFIGURATION_FILE_PATH,
+                    type_config_file_path,
                 )
         return TypeConfiguration.TYPE_CONFIGURATION
 
     @staticmethod
-    def get_hook_configuration():
-        # pylint: disable=unsubscriptable-object
-        type_configuration = TypeConfiguration.get_type_configuration()
+    def get_hook_configuration(typeconfigloc):
+        type_configuration = TypeConfiguration.get_type_configuration(typeconfigloc)
         if type_configuration:
             try:
-                return type_configuration["CloudFormationConfiguration"][
+                return type_configuration.get("CloudFormationConfiguration", {})[
                     "HookConfiguration"
-                ].get("Properties")
+                ]["Properties"]
             except KeyError as e:
                 LOG.warning("Hook configuration is invalid")
                 raise InvalidProjectError("Hook configuration is invalid") from e
diff --git a/src/rpdk/core/hook/init_hook.py b/src/rpdk/core/hook/init_hook.py
index 308719b..279bf1c 100644
--- a/src/rpdk/core/hook/init_hook.py
+++ b/src/rpdk/core/hook/init_hook.py
@@ -32,6 +32,9 @@ def init_hook(args, project):
 
     project.init_hook(type_name, language, settings)
     project.generate(args.endpoint_url, args.region, args.target_schemas, args.profile)
+    # Reload the generated example schema
+    project.load_configuration_schema()
+    # generate the docs based on the example schema loaded
     project.generate_docs()
 
 


Test patch:
diff --git a/src/rpdk/core/test.py b/src/rpdk/core/test.py
index 19a5143..5d64e75 100644
--- a/src/rpdk/core/test.py
+++ b/src/rpdk/core/test.py
@@ -342,6 +342,7 @@ def get_contract_plugin_client(args, project, overrides, inputs):
             args.log_role_arn,
             executable_entrypoint=project.executable_entrypoint,
             docker_image=args.docker_image,
+            typeconfig=args.typeconfig,
             target_info=project._load_target_info(  # pylint: disable=protected-access
                 args.cloudformation_endpoint_url, args.region
             ),
@@ -362,6 +363,7 @@ def get_contract_plugin_client(args, project, overrides, inputs):
         project.type_name,
         args.log_group_name,
         args.log_role_arn,
+        typeconfig=args.typeconfig,
         executable_entrypoint=project.executable_entrypoint,
         docker_image=args.docker_image,
         profile=args.profile,
@@ -441,7 +443,7 @@ def setup_subparser(subparsers, parents):
 
     _sam_arguments(parser)
     # this parameter can be used to pass additional arguments to pytest after `--`
-    # for example,
+    # for example, cfn test -- -k contract_delete_update # to have pytest run a single test
 
     parser.add_argument(
         "--role-arn", help="Role used when performing handler operations."
@@ -477,6 +479,11 @@ def setup_subparser(subparsers, parents):
         "of SAM",
     )
 
+    parser.add_argument(
+        "--typeconfig",
+        help="typeConfiguration file to use. Default: '~/.cfn-cli/typeConfiguration.json.'",
+    )
+
 
 def _sam_arguments(parser):
     parser.add_argument(
diff --git a/tests/contract/test_type_configuration.py b/tests/contract/test_type_configuration.py
index d026099..6c4b452 100644
--- a/tests/contract/test_type_configuration.py
+++ b/tests/contract/test_type_configuration.py
@@ -1,3 +1,4 @@
+import os
 from unittest.mock import mock_open, patch
 
 import pytest
@@ -29,17 +30,35 @@ def teardown_function():
 def test_get_type_configuration_with_not_exist_file():
     with patch("builtins.open", mock_open()) as f:
         f.side_effect = FileNotFoundError()
-        assert TypeConfiguration.get_type_configuration() is None
+        assert TypeConfiguration.get_type_configuration(None) is None
+
+
+def test_get_type_configuration_with_default_typeconfig_location():
+    with patch(
+        "builtins.open", mock_open(read_data=TYPE_CONFIGURATION_TEST_SETTING)
+    ) as f:
+        TypeConfiguration.get_type_configuration(None)
+    f.assert_called_with(
+        os.path.expanduser("~/.cfn-cli/typeConfiguration.json"), encoding="utf-8"
+    )
+
+
+def test_get_type_configuration_with_set_typeconfig_location():
+    with patch(
+        "builtins.open", mock_open(read_data=TYPE_CONFIGURATION_TEST_SETTING)
+    ) as f:
+        TypeConfiguration.get_type_configuration("./test.json")
+    f.assert_called_with("./test.json", encoding="utf-8")
 
 
 @patch("builtins.open", mock_open(read_data=TYPE_CONFIGURATION_TEST_SETTING))
 def test_get_type_configuration():
-    type_configuration = TypeConfiguration.get_type_configuration()
+    type_configuration = TypeConfiguration.get_type_configuration(None)
     assert type_configuration["Credentials"]["ApiKey"] == "123"
     assert type_configuration["Credentials"]["ApplicationKey"] == "123"
 
     # get type config again, should be the same config
-    type_configuration = TypeConfiguration.get_type_configuration()
+    type_configuration = TypeConfiguration.get_type_configuration(None)
     assert type_configuration["Credentials"]["ApiKey"] == "123"
     assert type_configuration["Credentials"]["ApplicationKey"] == "123"
 
@@ -47,19 +66,19 @@ def test_get_type_configuration():
 @patch("builtins.open", mock_open(read_data=TYPE_CONFIGURATION_INVALID))
 def test_get_type_configuration_with_invalid_json():
     try:
-        TypeConfiguration.get_type_configuration()
+        TypeConfiguration.get_type_configuration(None)
     except InvalidProjectError:
         pass
 
 
 @patch("builtins.open", mock_open(read_data=HOOK_CONFIGURATION_TEST_SETTING))
 def test_get_hook_configuration():
-    hook_configuration = TypeConfiguration.get_hook_configuration()
+    hook_configuration = TypeConfiguration.get_hook_configuration(None)
     assert hook_configuration["Credentials"]["ApiKey"] == "123"
     assert hook_configuration["Credentials"]["ApplicationKey"] == "123"
 
     # get type config again, should be the same config
-    hook_configuration = TypeConfiguration.get_hook_configuration()
+    hook_configuration = TypeConfiguration.get_hook_configuration(None)
     assert hook_configuration["Credentials"]["ApiKey"] == "123"
     assert hook_configuration["Credentials"]["ApplicationKey"] == "123"
 
@@ -67,7 +86,7 @@ def test_get_hook_configuration():
 @patch("builtins.open", mock_open(read_data=HOOK_CONFIGURATION_INVALID))
 def test_get_hook_configuration_with_invalid_json():
     with pytest.raises(InvalidProjectError) as execinfo:
-        TypeConfiguration.get_hook_configuration()
+        TypeConfiguration.get_hook_configuration(None)
 
     assert "Hook configuration is invalid" in str(execinfo.value)
 
@@ -75,4 +94,4 @@ def test_get_hook_configuration_with_invalid_json():
 def test_get_hook_configuration_with_not_exist_file():
     with patch("builtins.open", mock_open()) as f:
         f.side_effect = FileNotFoundError()
-        assert TypeConfiguration.get_hook_configuration() is None
+        assert TypeConfiguration.get_hook_configuration(None) is None
diff --git a/tests/test_test.py b/tests/test_test.py
index 35dfe07..6f17496 100644
--- a/tests/test_test.py
+++ b/tests/test_test.py
@@ -227,6 +227,7 @@ def test_test_command_happy_path_resource(
         mock_project.type_name,
         None,
         None,
+        typeconfig=None,
         executable_entrypoint=None,
         docker_image=None,
         profile=profile,
@@ -334,6 +335,7 @@ def test_test_command_happy_path_hook(
         mock_project.type_name,
         None,
         None,
+        typeconfig=None,
         executable_entrypoint=None,
         docker_image=None,
         target_info=HOOK_TARGET_INFO,