Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add property transform for contract tests #843

Merged
merged 21 commits into from
Jan 6, 2023
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/pr-ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,16 @@ jobs:
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python }}
- name: Install PYJQ dependencies on Ubuntu
run: |
pip install pyjq
if: matrix.os == 'ubuntu-latest'
- name: Install PYJQ dependencies on Macos
run: |
brew install autoconf automake libtool
brew install jq
pip install pyjq
if: matrix.os == 'macos-latest'
- name: Install dependencies
run: |
pip install --upgrade 'attrs==19.2.0' wheel -r requirements.txt
Expand Down
1 change: 1 addition & 0 deletions .pylintrc
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,4 @@ good-names=e,ex,f,fp,i,j,k,n,_

indent-string=' '
max-line-length=160
max-args = 6
26 changes: 25 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,31 @@ cfn test --enforce-timeout 60 -- -k contract_delete_update # combine args
cfn test --log-group-name cw_log_group --log-role-arn log_delivery_role_arn # Handler logs generated by contract tests will be delivered to the specified cw_log_group using the credentials from log_delivery_role_arn
```

Note: To use your type configuration in contract tests, you will need to save your type configuration json file in `~/.cfn-cli/typeConfiguration.json`.
Note:
* To use your type configuration in contract tests, you will need to save your type configuration json file in `~/.cfn-cli/typeConfiguration.json`.

* When the resource type with propertyTransform in schema, need to install PYJQ firstly(propertyTransform for contract tests is not available for Windows system)

zjinmei marked this conversation as resolved.
Show resolved Hide resolved
Install PYJQ for Linux system

```bash
yum install autoconf automake libtool
pip install pyjq
```

Install PYJQ for macOS system

```bash
brew install autoconf automake libtool
brew install jq
pip install pyjq
```

Install PYJQ for Ubuntu system

```bash
pip install pyjq
```

### Command: validate

Expand Down
56 changes: 49 additions & 7 deletions src/rpdk/core/contract/resource_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import json
import logging
import re
import sys
import time
from time import sleep
from uuid import uuid4
Expand Down Expand Up @@ -225,13 +226,35 @@ def _update_schema(self, schema):
self.write_only_paths = self._properties_to_paths("writeOnlyProperties")
self.create_only_paths = self._properties_to_paths("createOnlyProperties")
self.properties_without_insertion_order = self.get_metadata()
self.property_transform_keys = self._properties_to_paths("propertyTransform")
self.property_transform = self._schema.get("propertyTransform")

additional_identifiers = self._schema.get("additionalIdentifiers", [])
self._additional_identifiers_paths = [
{fragment_decode(prop, prefix="") for prop in identifier}
for identifier in additional_identifiers
]

def transform_model(self, input_model):
if not self.property_transform or sys.platform.startswith("win"):
return None
ammokhov marked this conversation as resolved.
Show resolved Hide resolved
import pyjq

for key in self.property_transform_keys:
path = "/" + "/".join(key)
expression = self.property_transform[path]
tranformed_value = pyjq.first(expression, input_model)
input_model = self.update_property(input_model, tranformed_value, key[1:])

return input_model

def update_property(self, model, value, path):
ammokhov marked this conversation as resolved.
Show resolved Hide resolved
if len(path) > 1:
model = self.update_property(model[path[0]], value, path[1:])
elif len(path) == 1:
model[path[0]] = value
return model

def has_only_writable_identifiers(self):
return all(
path in self.create_only_paths for path in self.primary_identifier_paths
Expand Down Expand Up @@ -395,7 +418,7 @@ def generate_invalid_update_example(self, create_model):
example = override_properties(self.invalid_strategy.example(), overrides)
return {**create_model, **example}

def compare(self, inputs, outputs, path=()):
def compare(self, inputs, outputs, transformed_inputs, path=()):
assertion_error_message = (
"All properties specified in the request MUST "
"be present in the model returned, and they MUST"
Expand All @@ -407,27 +430,46 @@ def compare(self, inputs, outputs, path=()):
for key in inputs:
new_path = path + (key,)
if isinstance(inputs[key], dict):
self.compare(inputs[key], outputs[key], new_path)
self.compare(
inputs[key],
outputs[key],
transformed_inputs[key] if transformed_inputs else None,
new_path,
)
elif isinstance(inputs[key], list):
assert len(inputs[key]) == len(outputs[key])

is_ordered = traverse_raw_schema(self._schema, new_path).get(
"insertionOrder", True
)
self.compare_collection(
inputs[key], outputs[key], is_ordered, new_path
inputs[key],
outputs[key],
is_ordered,
new_path,
transformed_inputs[key] if transformed_inputs else None,
)
else:
assert inputs[key] == outputs[key], assertion_error_message
else:
assert inputs == outputs, assertion_error_message
except Exception as exception:
raise AssertionError(assertion_error_message) from exception
except Exception as exception: # pylint: disable=broad-except
# When input model not equal to output model, and there is transformed model,
# need to compare transformed model with output model also
if transformed_inputs:
self.compare(transformed_inputs, outputs, None)
else:
raise AssertionError(assertion_error_message) from exception

def compare_collection(self, inputs, outputs, is_ordered, path):
def compare_collection(self, inputs, outputs, is_ordered, path, transformed_inputs):
if is_ordered:
for index in range(len(inputs)): # pylint: disable=C0200
self.compare(inputs[index], outputs[index], path)
self.compare(
inputs[index],
outputs[index],
transformed_inputs[index] if transformed_inputs else None,
path,
)
return

assert {item_hash(item) for item in inputs} == {
Expand Down
8 changes: 6 additions & 2 deletions src/rpdk/core/contract/suite/handler_commons.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,11 @@ def test_input_equals_output(resource_client, input_model, output_model):
pruned_output_model = prune_properties_if_not_exist_in_path(
pruned_output_model, pruned_input_model, resource_client.create_only_paths
)

transformed_input_model = resource_client.transform_model(
copy.deepcopy(pruned_input_model)
)

zjinmei marked this conversation as resolved.
Show resolved Hide resolved
resource_client.compare(
pruned_input_model,
pruned_output_model,
pruned_input_model, pruned_output_model, transformed_input_model
)
100 changes: 95 additions & 5 deletions tests/contract/test_resource_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,23 @@
"handlers": {"create": {}, "delete": {}, "read": {}},
}

SCHEMA_WITH_PROPERTY_TRANSFORM = {
"properties": {
"a": {"type": "string"},
"b": {"$ref": "#/definitions/c"},
},
"definitions": {
"c": {
"type": "object",
"properties": {"d": {"type": "String"}, "e": {"type": "integer"}},
}
},
"readOnlyProperties": ["/properties/a"],
"primaryIdentifier": ["/properties/a"],
"handlers": {"create": {}, "delete": {}, "read": {}},
"propertyTransform": {"/properties/b/c/d": '.b.c.d + "Test"'},
}

EMPTY_SCHEMA = {"handlers": {"create": [], "delete": [], "read": []}}


Expand Down Expand Up @@ -334,6 +351,45 @@ def resource_client_inputs_composite_key():
return client


@pytest.fixture
def resource_client_inputs_property_transform():
endpoint = "https://"
patch_sesh = patch(
"rpdk.core.contract.resource_client.create_sdk_session", autospec=True
)
patch_creds = patch(
"rpdk.core.contract.resource_client.get_temporary_credentials",
autospec=True,
return_value={},
)
patch_account = patch(
"rpdk.core.contract.resource_client.get_account",
autospec=True,
return_value=ACCOUNT,
)
with patch_sesh as mock_create_sesh, patch_creds as mock_creds:
with patch_account as mock_account:
mock_sesh = mock_create_sesh.return_value
mock_sesh.region_name = DEFAULT_REGION
client = ResourceClient(
DEFAULT_FUNCTION,
endpoint,
DEFAULT_REGION,
SCHEMA_WITH_PROPERTY_TRANSFORM,
EMPTY_OVERRIDE,
)

mock_sesh.client.assert_called_once_with("lambda", endpoint_url=endpoint)
mock_creds.assert_called_once_with(mock_sesh, LOWER_CAMEL_CRED_KEYS, None)
mock_account.assert_called_once_with(mock_sesh, {})
assert client._function_name == DEFAULT_FUNCTION
assert client._schema == SCHEMA_WITH_PROPERTY_TRANSFORM
assert client._overrides == EMPTY_OVERRIDE
assert client.account == ACCOUNT

return client


def test_prune_properties():
document = {
"foo": "bar",
Expand Down Expand Up @@ -692,6 +748,40 @@ def test_update_schema(resource_client):
assert resource_client.create_only_paths == {("properties", "d")}


def test_transform_model(resource_client_inputs_property_transform):
inputs = {"a": "ValueA", "b": {"c": {"d": "ValueD", "e": 1}}}
transformed_inputs = {"a": "ValueA", "b": {"c": {"d": "ValueDTest", "e": 1}}}

resource_client_inputs_property_transform.transform_model(inputs)

assert inputs == transformed_inputs


def test_compare_with_transform_should_pass(resource_client_inputs_property_transform):
inputs = {"a": "ValueA", "b": {"c": {"d": "ValueD", "e": 1}}}
transformed_inputs = {"a": "ValueA", "b": {"c": {"d": "ValueDTest", "e": 1}}}
outputs = {"a": "ValueA", "b": {"c": {"d": "ValueDTest", "e": 1}}}

resource_client_inputs_property_transform.compare(
inputs, outputs, transformed_inputs
)


def test_compare_with_transform_should_throw_exception(
resource_client_inputs_property_transform,
):
inputs = {"a": "ValueA", "b": {"c": {"d": "ValueD", "e": 1}}}
transformed_inputs = {"a": "ValueA", "b": {"c": {"d": "ValueDTest", "e": 1}}}
outputs = {"a": "ValueA", "b": {"c": {"d": "D", "e": 1}}}

try:
resource_client_inputs_property_transform.compare(
inputs, outputs, transformed_inputs
)
except AssertionError:
logging.debug("This test expects Assertion Exception to be thrown")


def test_strategy(resource_client):
schema = {
"properties": {
Expand Down Expand Up @@ -1566,7 +1656,7 @@ def test_compare_should_pass(resource_client):
"h": [{"d": 1, "e": 3}, {"d": 2}],
"i": ["abc", "ghi"],
}
resource_client.compare(inputs, outputs)
resource_client.compare(inputs, outputs, None)


def test_compare_should_throw_exception(resource_client):
Expand All @@ -1579,7 +1669,7 @@ def test_compare_should_throw_exception(resource_client):
"h": [{"d": 1}],
}
try:
resource_client.compare(inputs, outputs)
resource_client.compare(inputs, outputs, None)
except AssertionError:
logging.debug("This test expects Assertion Exception to be thrown")

Expand Down Expand Up @@ -1715,7 +1805,7 @@ def test_compare_should_throw_exception(resource_client):
def test_compare_collection(resource_client, inputs, outputs, schema_fragment):
resource_client._update_schema(schema_fragment)

resource_client.compare(inputs, outputs)
resource_client.compare(inputs, outputs, None)


def test_compare_should_throw_key_error(resource_client):
Expand All @@ -1724,7 +1814,7 @@ def test_compare_should_throw_key_error(resource_client):

outputs = {"b": {"d": 1, "e": 2}, "f": [{"d": 1, "e": 2}, {"d": 2, "e": 3}]}
try:
resource_client.compare(inputs, outputs)
resource_client.compare(inputs, outputs, None)
except AssertionError:
logging.debug("This test expects Assertion Exception to be thrown")

Expand All @@ -1739,6 +1829,6 @@ def test_compare_ordered_list_throws_assertion_exception(resource_client):
"i": ["abc", "ghi", "tt"],
}
try:
resource_client.compare(inputs, outputs)
resource_client.compare(inputs, outputs, None)
except AssertionError:
logging.debug("This test expects Assertion Exception to be thrown")