From fd78dfaac9d0d00f3348cbff4e427046b9f9c333 Mon Sep 17 00:00:00 2001 From: Ruslan Temirkhanov Date: Mon, 21 Sep 2026 13:15:28 -0400 Subject: [PATCH] feat(project): Add contract-test-exceptions.json support Scaffold contract-test-exceptions.json on init and include it in the submit package. Third-party publishers declare self-service contract-test exceptions in a contract-test-exceptions.json file at the project root. The test runner reads it from the submitted artifact and applies the exceptions for third-party namespaces only. cfn init scaffolds the file for resource projects with two example entries. Both are exceptions already granted by default to every third-party resource, so the generated file has no functional effect; it documents the expected shape and level of justification. The scaffold uses safewrite, so a publisher's edited file is never overwritten. Modules and hooks are unaffected. cfn submit includes the file in the artifact zip when present and skips it when absent, mirroring overrides.json, so older projects without the file continue to submit. --- src/rpdk/core/project.py | 64 ++++++++++++++++++++++++++++++++++++++++ tests/test_project.py | 53 +++++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+) diff --git a/src/rpdk/core/project.py b/src/rpdk/core/project.py index 5df758ea..78887781 100644 --- a/src/rpdk/core/project.py +++ b/src/rpdk/core/project.py @@ -46,6 +46,7 @@ SCHEMA_UPLOAD_FILENAME = "schema.json" CONFIGURATION_SCHEMA_UPLOAD_FILENAME = "configuration-schema.json" OVERRIDES_FILENAME = "overrides.json" +CONTRACT_TEST_EXCEPTIONS_FILENAME = "contract-test-exceptions.json" TARGET_INFO_FILENAME = "target-info.json" INPUTS_FOLDER = "inputs" EXAMPLE_INPUTS_FOLDER = "example_inputs" @@ -231,6 +232,10 @@ def schema_path(self): def overrides_path(self): return self.root / OVERRIDES_FILENAME + @property + def contract_test_exceptions_path(self): + return self.root / CONTRACT_TEST_EXCEPTIONS_FILENAME + @property def inputs_path(self): return self.root / INPUTS_FOLDER @@ -388,6 +393,49 @@ def _write(f): self.safewrite(self.schema_path, _write) + def _write_contract_test_exceptions(self): + """Scaffold contract-test-exceptions.json with two example entries. + + Both examples are exceptions already granted by default to every + third-party resource, so the file as generated has no effect. It shows + the expected shape and level of justification. Only read for + third-party types; first-party types ignore it. + """ + scaffold = { + "contractTestExceptions": { + "handlerTests": [], + "sgrLinterChecks": [ + { + "id": "TAG001", + "reason": ( + "Example entry. Granted by default for all" + " third-party resources. Vendor APIs often have" + " no tag support." + ), + } + ], + "sgrBackwardChecks": [], + "propertyCoverageTests": [ + { + "id": "no_missing_properties_for_create", + "reason": ( + "Example entry. Granted by default for all" + " third-party resources. Not every schema" + " property is settable on create." + ), + } + ], + "excludedCreateProperties": [], + "excludedPatchProperties": [], + } + } + + def _write(f): + json.dump(scaffold, f, indent=4) + f.write("\n") + + self.safewrite(self.contract_test_exceptions_path, _write) + def _write_example_inputs(self): shutil.rmtree(self.example_inputs_path, ignore_errors=True) self.example_inputs_path.mkdir(exist_ok=True) @@ -483,6 +531,7 @@ def init(self, type_name, language, settings=None): } self._write_example_schema() self._write_example_inputs() + self._write_contract_test_exceptions() self._plugin.init(self) self.write_settings() @@ -742,6 +791,7 @@ def submit( self._add_resources_content_to_zip(zip_file) self._add_overrides_file_to_zip(zip_file) + self._add_contract_test_exceptions_file_to_zip(zip_file) if dry_run: LOG.error("Dry run complete: %s", self._get_zip_file_path().resolve()) @@ -764,6 +814,20 @@ def _add_overrides_file_to_zip(self, zip_file): except FileNotFoundError: LOG.debug("%s not found. Not writing to package.", OVERRIDES_FILENAME) + def _add_contract_test_exceptions_file_to_zip(self, zip_file): + try: + zip_file.write( + self.contract_test_exceptions_path, CONTRACT_TEST_EXCEPTIONS_FILENAME + ) + LOG.debug( + "%s found. Writing to package.", CONTRACT_TEST_EXCEPTIONS_FILENAME + ) + except FileNotFoundError: + LOG.debug( + "%s not found. Not writing to package.", + CONTRACT_TEST_EXCEPTIONS_FILENAME, + ) + def _add_resources_content_to_zip(self, zip_file): zip_file.write(self.schema_path, SCHEMA_UPLOAD_FILENAME) if os.path.isdir(self.inputs_path): diff --git a/tests/test_project.py b/tests/test_project.py index 63016414..4f7dae6d 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -37,6 +37,7 @@ CFN_METADATA_FILENAME, CONFIGURATION_SCHEMA_UPLOAD_FILENAME, CONTRACT_TEST_DEPENDENCY_FILE_NAME, + CONTRACT_TEST_EXCEPTIONS_FILENAME, CONTRACT_TEST_FILE_NAMES, CONTRACT_TEST_FOLDER, OVERRIDES_FILENAME, @@ -1015,6 +1016,49 @@ def test_init_resource(project): f.seek(-1, os.SEEK_END) assert f.read() == b"\n" + # contract-test-exceptions.json is scaffolded with two example entries + with project.contract_test_exceptions_path.open("r", encoding="utf-8") as f: + exceptions = json.load(f)["contractTestExceptions"] + assert set(exceptions) == { + "handlerTests", + "sgrLinterChecks", + "sgrBackwardChecks", + "propertyCoverageTests", + "excludedCreateProperties", + "excludedPatchProperties", + } + assert [entry["id"] for entry in exceptions["sgrLinterChecks"]] == ["TAG001"] + assert [entry["id"] for entry in exceptions["propertyCoverageTests"]] == [ + "no_missing_properties_for_create" + ] + for entry in exceptions["sgrLinterChecks"] + exceptions["propertyCoverageTests"]: + assert entry["reason"] + for key in ( + "handlerTests", + "sgrBackwardChecks", + "excludedCreateProperties", + "excludedPatchProperties", + ): + assert exceptions[key] == [] + + # ends with newline + with project.contract_test_exceptions_path.open("rb") as f: + f.seek(-1, os.SEEK_END) + assert f.read() == b"\n" + + +def test_write_contract_test_exceptions_does_not_clobber_existing(project): + # A publisher's edited file must survive a re-run of the scaffold + # (safewrite semantics, matching _write_example_schema). + edited = '{"contractTestExceptions": {"handlerTests": [{"id": "x"}]}}\n' + with project.contract_test_exceptions_path.open("w", encoding="utf-8") as f: + f.write(edited) + + project._write_contract_test_exceptions() + + with project.contract_test_exceptions_path.open("r", encoding="utf-8") as f: + assert f.read() == edited + def test_generate_hook_handlers(project, tmpdir, session): project.type_name = "Test::Handler::Test" @@ -1142,6 +1186,8 @@ def test_init_hook(project): assert project.artifact_type == ARTIFACT_TYPE_HOOK assert project._plugin is mock_plugin assert project.settings == {} + # contract-test-exceptions.json is scaffolded for resources only + assert not project.contract_test_exceptions_path.exists() with project.settings_path.open("r", encoding="utf-8") as f: assert json.load(f) @@ -1180,6 +1226,8 @@ def test_init_module(project): assert project.artifact_type == ARTIFACT_TYPE_MODULE assert project._plugin is None assert project.settings == {} + # contract-test-exceptions.json is scaffolded for resources only + assert not project.contract_test_exceptions_path.exists() with project.settings_path.open("r", encoding="utf-8") as f: assert json.load(f) @@ -1417,6 +1465,10 @@ def test_submit_dry_run(project, is_type_configuration_available): with project.overrides_path.open("w", encoding="utf-8") as f: f.write(json.dumps(empty_override())) + exceptions = {"contractTestExceptions": {"handlerTests": []}} + with project.contract_test_exceptions_path.open("w", encoding="utf-8") as f: + f.write(json.dumps(exceptions)) + create_input_file(project.root) project.write_settings() @@ -1452,6 +1504,7 @@ def test_submit_dry_run(project, is_type_configuration_available): SCHEMA_UPLOAD_FILENAME, SETTINGS_FILENAME, OVERRIDES_FILENAME, + CONTRACT_TEST_EXCEPTIONS_FILENAME, CREATE_INPUTS_FILE, INVALID_INPUTS_FILE, UPDATE_INPUTS_FILE,