Skip to content

Commit b8f69bb

Browse files
zjinmeiMichael Maeng
andauthored
Add property transform for contract tests (#843)
* Add property transform for contract tests * Install JQ as dependency * test without windows * test ubuntu system * test macos system * Test windows system * Fix pylint and test windows * add more dependencies for jq * use jq instead of pyjq and test windows * test three system to install jq * update pr-ci yaml file * Using string for system * Use pyjq for macOS and Linux * Use apt instead of yum for Ubuntu system * remove apt for Ubuntu system * Update readme with Ubuntu system to install pyjq * fix whitespace issue * Reduce function parameters from 6 to 5 and update readme * Add comments and annotations, do copy inside transform function * don't install pyjq on windows, it is not supported Co-authored-by: Michael Maeng <[email protected]>
1 parent a676569 commit b8f69bb

File tree

5 files changed

+188
-9
lines changed

5 files changed

+188
-9
lines changed

.github/workflows/pr-ci.yaml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,15 @@ jobs:
2020
with:
2121
python-version: ${{ matrix.python }}
2222
cache: 'pip'
23+
- name: Install PYJQ dependencies on Macos
24+
run: |
25+
brew install autoconf automake libtool
26+
brew install jq
27+
if: matrix.os == 'macos-latest'
28+
- name: Install PYJQ on non-Windows
29+
run: |
30+
pip install pyjq
31+
if: matrix.os != 'windows-latest'
2332
- name: Install dependencies
2433
run: |
2534
pip install --upgrade 'attrs==19.2.0' wheel -r requirements.txt

README.md

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,31 @@ cfn test --enforce-timeout 60 -- -k contract_delete_update # combine arguments
5959
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
6060
```
6161

62-
Note: To use your type configuration in contract tests, you will need to save your type configuration json file in `~/.cfn-cli/typeConfiguration.json`.
62+
Note:
63+
* To use your type configuration in contract tests, you will need to save your type configuration json file in `~/.cfn-cli/typeConfiguration.json`.
64+
65+
* 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
66+
67+
Install PYJQ for Linux system
68+
69+
```bash
70+
yum install autoconf automake libtool
71+
pip install pyjq
72+
```
73+
74+
Install PYJQ for macOS system
75+
76+
```bash
77+
brew install autoconf automake libtool
78+
brew install jq
79+
pip install pyjq
80+
```
81+
82+
Install PYJQ for Ubuntu system
83+
84+
```bash
85+
pip install pyjq
86+
```
6387

6488
### Command: validate
6589

src/rpdk/core/contract/resource_client.py

Lines changed: 65 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
# pylint: disable=import-outside-toplevel
22
# pylint: disable=R0904
3+
import copy
34
import json
45
import logging
56
import re
7+
import sys
68
import time
79
from time import sleep
10+
from typing import Any, Dict, Tuple
811
from uuid import uuid4
912

1013
import docker
@@ -226,13 +229,50 @@ def _update_schema(self, schema):
226229
self.write_only_paths = self._properties_to_paths("writeOnlyProperties")
227230
self.create_only_paths = self._properties_to_paths("createOnlyProperties")
228231
self.properties_without_insertion_order = self.get_metadata()
232+
self.property_transform_keys = self._properties_to_paths("propertyTransform")
233+
self.property_transform = self._schema.get("propertyTransform")
229234

230235
additional_identifiers = self._schema.get("additionalIdentifiers", [])
231236
self._additional_identifiers_paths = [
232237
{fragment_decode(prop, prefix="") for prop in identifier}
233238
for identifier in additional_identifiers
234239
]
235240

241+
def transform_model(self, input_model):
242+
if not self.property_transform:
243+
return None
244+
# When CT input and output not equal, and with property transform
245+
# Need to check system as property transform for CT not supported on Windows
246+
if sys.platform.startswith("win"):
247+
raise EnvironmentError(
248+
"Property transform not available with contract tests on Windows OS"
249+
)
250+
251+
import pyjq
252+
253+
transformed_input_model = copy.deepcopy(input_model)
254+
for key in self.property_transform_keys:
255+
path = "/" + "/".join(key)
256+
expression = self.property_transform[path]
257+
transformed_value = pyjq.first(expression, transformed_input_model)
258+
# key is a tuple like ("properties", "A", "B")
259+
# input model is like: {"A": {"B": "valueB"}}
260+
# use key[1:] here to remove "properties"
261+
transformed_input_model = self.update_property(
262+
transformed_input_model, transformed_value, key[1:]
263+
)
264+
265+
return transformed_input_model
266+
267+
def update_property(
268+
self, model: Dict[str, Any], value: Any, path: Tuple[str, ...]
269+
) -> Dict[str, Any]:
270+
if len(path) > 1:
271+
model[path[0]] = self.update_property(model[path[0]], value, path[1:])
272+
elif len(path) == 1:
273+
model[path[0]] = value
274+
return model
275+
236276
def has_only_writable_identifiers(self):
237277
return all(
238278
path in self.create_only_paths for path in self.primary_identifier_paths
@@ -393,7 +433,17 @@ def generate_invalid_update_example(self, create_model):
393433
example = override_properties(self.invalid_strategy.example(), overrides)
394434
return {**create_model, **example}
395435

396-
def compare(self, inputs, outputs, path=()):
436+
def compare(self, inputs, outputs):
437+
try:
438+
self.compare_model(inputs, outputs)
439+
except AssertionError as exception:
440+
transformed_inputs = self.transform_model(inputs)
441+
if transformed_inputs:
442+
self.compare_model(transformed_inputs, outputs)
443+
else:
444+
raise exception
445+
446+
def compare_model(self, inputs, outputs, path=()):
397447
assertion_error_message = (
398448
"All properties specified in the request MUST "
399449
"be present in the model returned, and they MUST"
@@ -405,7 +455,11 @@ def compare(self, inputs, outputs, path=()):
405455
for key in inputs:
406456
new_path = path + (key,)
407457
if isinstance(inputs[key], dict):
408-
self.compare(inputs[key], outputs[key], new_path)
458+
self.compare_model(
459+
inputs[key],
460+
outputs[key],
461+
new_path,
462+
)
409463
elif isinstance(inputs[key], list):
410464
assert len(inputs[key]) == len(outputs[key])
411465

@@ -414,7 +468,10 @@ def compare(self, inputs, outputs, path=()):
414468
)
415469

416470
self.compare_collection(
417-
inputs[key], outputs[key], is_ordered, new_path
471+
inputs[key],
472+
outputs[key],
473+
is_ordered,
474+
new_path,
418475
)
419476
else:
420477
assert inputs[key] == outputs[key], assertion_error_message
@@ -426,7 +483,11 @@ def compare(self, inputs, outputs, path=()):
426483
def compare_collection(self, inputs, outputs, is_ordered, path):
427484
if is_ordered:
428485
for index in range(len(inputs)): # pylint: disable=C0200
429-
self.compare(inputs[index], outputs[index], path)
486+
self.compare_model(
487+
inputs[index],
488+
outputs[index],
489+
path,
490+
)
430491
return
431492

432493
assert {item_hash(item) for item in inputs} == {

src/rpdk/core/contract/suite/resource/handler_commons.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,5 @@ def test_input_equals_output(resource_client, input_model, output_model):
164164
pruned_output_model = prune_properties_if_not_exist_in_path(
165165
pruned_output_model, pruned_input_model, resource_client.create_only_paths
166166
)
167-
resource_client.compare(
168-
pruned_input_model,
169-
pruned_output_model,
170-
)
167+
168+
resource_client.compare(pruned_input_model, pruned_output_model)

tests/contract/test_resource_client.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,23 @@
130130
"handlers": {"create": {}, "delete": {}, "read": {}},
131131
}
132132

133+
SCHEMA_WITH_PROPERTY_TRANSFORM = {
134+
"properties": {
135+
"a": {"type": "string"},
136+
"b": {"$ref": "#/definitions/c"},
137+
},
138+
"definitions": {
139+
"c": {
140+
"type": "object",
141+
"properties": {"d": {"type": "String"}, "e": {"type": "integer"}},
142+
}
143+
},
144+
"readOnlyProperties": ["/properties/a"],
145+
"primaryIdentifier": ["/properties/a"],
146+
"handlers": {"create": {}, "delete": {}, "read": {}},
147+
"propertyTransform": {"/properties/b/c/d": '.b.c.d + "Test"'},
148+
}
149+
133150
EMPTY_SCHEMA = {"handlers": {"create": [], "delete": [], "read": []}}
134151

135152

@@ -334,6 +351,45 @@ def resource_client_inputs_composite_key():
334351
return client
335352

336353

354+
@pytest.fixture
355+
def resource_client_inputs_property_transform():
356+
endpoint = "https://"
357+
patch_sesh = patch(
358+
"rpdk.core.contract.resource_client.create_sdk_session", autospec=True
359+
)
360+
patch_creds = patch(
361+
"rpdk.core.contract.resource_client.get_temporary_credentials",
362+
autospec=True,
363+
return_value={},
364+
)
365+
patch_account = patch(
366+
"rpdk.core.contract.resource_client.get_account",
367+
autospec=True,
368+
return_value=ACCOUNT,
369+
)
370+
with patch_sesh as mock_create_sesh, patch_creds as mock_creds:
371+
with patch_account as mock_account:
372+
mock_sesh = mock_create_sesh.return_value
373+
mock_sesh.region_name = DEFAULT_REGION
374+
client = ResourceClient(
375+
DEFAULT_FUNCTION,
376+
endpoint,
377+
DEFAULT_REGION,
378+
SCHEMA_WITH_PROPERTY_TRANSFORM,
379+
EMPTY_OVERRIDE,
380+
)
381+
382+
mock_sesh.client.assert_called_once_with("lambda", endpoint_url=endpoint)
383+
mock_creds.assert_called_once_with(mock_sesh, LOWER_CAMEL_CRED_KEYS, None)
384+
mock_account.assert_called_once_with(mock_sesh, {})
385+
assert client._function_name == DEFAULT_FUNCTION
386+
assert client._schema == SCHEMA_WITH_PROPERTY_TRANSFORM
387+
assert client._overrides == EMPTY_OVERRIDE
388+
assert client.account == ACCOUNT
389+
390+
return client
391+
392+
337393
def test_prune_properties():
338394
document = {
339395
"foo": "bar",
@@ -692,6 +748,37 @@ def test_update_schema(resource_client):
692748
assert resource_client.create_only_paths == {("properties", "d")}
693749

694750

751+
def test_transform_model(resource_client_inputs_property_transform):
752+
inputs = {"a": "ValueA", "b": {"c": {"d": "ValueD", "e": 1}}}
753+
expected_inputs = {"a": "ValueA", "b": {"c": {"d": "ValueDTest", "e": 1}}}
754+
755+
transformed_inputs = resource_client_inputs_property_transform.transform_model(
756+
inputs
757+
)
758+
759+
assert transformed_inputs == expected_inputs
760+
761+
762+
def test_compare_with_transform_should_pass(resource_client_inputs_property_transform):
763+
inputs = {"a": "ValueA", "b": {"c": {"d": "ValueD", "e": 1}}}
764+
# transformed_inputs = {"a": "ValueA", "b": {"c": {"d": "ValueDTest", "e": 1}}}
765+
outputs = {"a": "ValueA", "b": {"c": {"d": "ValueDTest", "e": 1}}}
766+
767+
resource_client_inputs_property_transform.compare(inputs, outputs)
768+
769+
770+
def test_compare_with_transform_should_throw_exception(
771+
resource_client_inputs_property_transform,
772+
):
773+
inputs = {"a": "ValueA", "b": {"c": {"d": "ValueD", "e": 1}}}
774+
outputs = {"a": "ValueA", "b": {"c": {"d": "D", "e": 1}}}
775+
776+
try:
777+
resource_client_inputs_property_transform.compare(inputs, outputs)
778+
except AssertionError:
779+
logging.debug("This test expects Assertion Exception to be thrown")
780+
781+
695782
def test_strategy(resource_client):
696783
schema = {
697784
"properties": {

0 commit comments

Comments
 (0)