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,