diff --git a/docs/api.rst b/docs/api.rst index 35e9f50..24d21b9 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -140,6 +140,7 @@ A handful of types are provided for you: .. autoclass:: tf.types.Number .. autoclass:: tf.types.String .. autoclass:: tf.types.List +.. autoclass:: tf.types.Map .. autoclass:: tf.types.Set Several utility types are also provided: diff --git a/docs/elements.md b/docs/elements.md index 4c62662..a3cbd08 100644 --- a/docs/elements.md +++ b/docs/elements.md @@ -18,12 +18,14 @@ in the State dictionaries. This framework handles the conversion to and from TF types and semantic equivalents. -| Python Type | TF Type | Framework Type | Notes | -|------------------|----------|-------------------|-----------------------------------------------------------| -| `str` | `string` | `String` | | -| `int`, `float` | `number` | `Number` | | -| `bool` | `bool` | `Bool` | | -| `Dict[str, Any]` | `string` | `NormalizedJson` | Key order and whitespace are ignored for diff comparison. | +| Framework Type | Python Type | TF Type | Notes | +|-------------------|------------------|----------|-----------------------------------------------------------| +| `String` | `str` | `string` | | +| `Number` | `int`, `float` | `number` | | +| `Bool` | `bool` | `bool` | | +| `NormalizedJson` | `Dict[str, Any]` | `string` | Key order and whitespace are ignored for diff comparison. | +| `List` | `List[Any]` | `list` | | +| `Map` | `Dict[str, Any]` | `map` | | For `NormalizedJson` in particular, the framework will pass in `dict` and expect `dict` back. diff --git a/e2e/e2e_framework/e2e_framework/framework.py b/e2e/e2e_framework/e2e_framework/framework.py index ab52816..2897201 100644 --- a/e2e/e2e_framework/e2e_framework/framework.py +++ b/e2e/e2e_framework/e2e_framework/framework.py @@ -88,8 +88,23 @@ def _tf_run( return res - def tf_plan(self, hcl: str, expect_error=False, expect_in_output: Optional[Sequence[str]] = None) -> Result: - return self._tf_run(["plan"], hcl=hcl, expect_error=expect_error, expect_in_output=expect_in_output) + def tf_plan( + self, + hcl: str, + expect_error=False, + expect_in_output: Optional[Sequence[str]] = None, + expect_changes: bool | None = None, + ) -> Result: + if expect_changes is not None: + phrase = ( + "No changes. Your infrastructure matches the configuration." + if not expect_changes + else "will perform the following actions:" + ) + expect_in_output = list(expect_in_output or []) + [phrase] + + res = self._tf_run(["plan"], hcl=hcl, expect_error=expect_error, expect_in_output=expect_in_output) + return res def tf_apply(self, hcl: str, expect_error=False, expect_in_output: Optional[Sequence[str]] = None) -> Result: return self._tf_run( diff --git a/e2e/mathprovider/mathprovider/provider.py b/e2e/mathprovider/mathprovider/provider.py index 859d4a1..dadba4b 100644 --- a/e2e/mathprovider/mathprovider/provider.py +++ b/e2e/mathprovider/mathprovider/provider.py @@ -5,10 +5,14 @@ from tf import types as t from tf.iface import ( Config, + CreateContext, DataSource, + DeleteContext, + ReadContext, ReadDataContext, Resource, State, + UpdateContext, ) from tf.provider import Diagnostics, Provider from tf.schema import Attribute, Schema @@ -48,6 +52,37 @@ def __init__(self, provider: "MathProvider"): pass +class Constant(Resource): + @classmethod + def get_name(cls) -> str: + return "constant" + + @classmethod + def get_schema(cls) -> Schema: + return Schema( + attributes=[ + Attribute("name", t.String(), required=True), + Attribute("approx_value", t.Number(), required=True), + Attribute("tags", t.Map(t.String()), required=True), + ] + ) + + def create(self, ctx: CreateContext, planned: State) -> Optional[State]: + return planned + + def read(self, ctx: ReadContext, current: State) -> Optional[State]: + return current + + def update(self, ctx: UpdateContext, current: State, planned: State) -> Optional[State]: + return planned + + def delete(self, ctx: DeleteContext, current: State): + return None + + def __init__(self, provider: "MathProvider"): + pass + + class MathProvider(Provider): def get_model_prefix(self) -> str: return "math_" @@ -68,7 +103,7 @@ def get_data_sources(self) -> list[Type[DataSource]]: return [Divider] def get_resources(self) -> list[Type[Resource]]: - return [] + return [Constant] def main(): diff --git a/e2e/mathprovider/mathprovider/test_provider.py b/e2e/mathprovider/mathprovider/test_provider.py index 25f77cb..948bfeb 100644 --- a/e2e/mathprovider/mathprovider/test_provider.py +++ b/e2e/mathprovider/mathprovider/test_provider.py @@ -107,3 +107,123 @@ def test_apply_for_each(self): expect_error=False, ) self.assertIn("the_sum = 16", result.stdout) + + +class MapTest(ProviderTest): + PROVIDER_NAME = "test.terraform.io/test/math" + + def test_map_happy(self): + self.tf_apply( + """\ + resource "math_constant" "pi" { + name = "pi" + approx_value = 3.14159 + tags = { + category = "irrational" + source = "demo" + } + } + + output "constant_name" { + value = math_constant.pi.name + } + """, + expect_error=False, + ) + + self.assertEqual( + { + "outputs": { + "constant_name": { + "sensitive": False, + "value": "pi", + "type": "string", + } + }, + "root_module": { + "resources": [ + { + "address": "math_constant.pi", + "mode": "managed", + "type": "math_constant", + "name": "pi", + "provider_name": "test.terraform.io/test/math", + "schema_version": 0, + "values": { + "name": "pi", + "approx_value": 3.14159, + "tags": { + "category": "irrational", + "source": "demo", + }, + }, + "sensitive_values": {"tags": {}}, + } + ] + }, + }, + self.tf_state()["values"], + ) + + def test_map_key_order_irrelevant(self): + """Verify map key order does not affect plan diffs.""" + self.tf_apply( + """\ + resource "math_constant" "e" { + name = "e" + approx_value = 2.71828 + tags = { + source = "demo" + category = "irrational" + } + } + """, + expect_error=False, + ) + self.tf_plan( + """\ + resource "math_constant" "e" { + name = "e" + approx_value = 2.71828 + tags = { + category = "irrational" + source = "demo" + } + } + """, + expect_error=False, + expect_changes=False, + ) + + def test_real_map_change(self): + self.tf_apply( + """\ + resource "math_constant" "phi" { + name = "phi" + approx_value = 1.61803 + tags = { + category = "irrational" + source = "demo" + } + } + """, + expect_error=False, + ) + self.tf_plan( + """\ + resource "math_constant" "phi" { + name = "phi" + approx_value = 1.61803 + tags = { + category = "irrational" + source = "updated_demo" + } + } + """, + expect_error=False, + expect_changes=True, + expect_in_output=[ + '~ "source"', + '"demo" -> "updated_demo"', + ], + ) diff --git a/tf/tests/test_types.py b/tf/tests/test_types.py index 8976426..a2d62c8 100644 --- a/tf/tests/test_types.py +++ b/tf/tests/test_types.py @@ -125,3 +125,43 @@ def test_set_semantic_equality_with_dicts(self): self.assertFalse(set_type.semantically_equal([{"a": 1}], [{"a": 1}, {"b": 2}])) # Empty lists self.assertTrue(set_type.semantically_equal([], [])) + + def test_map_encode(self): + map_type = types.Map(types.String()) + + self.assertIsNone(map_type.encode(None)) + self.assertIsNone(map_type.decode(None)) + self.assertIs(map_type.encode(Unknown), Unknown) + self.assertIs(map_type.decode(Unknown), Unknown) + + self.assertEqual(map_type.encode({"a": "x", "b": "y"}), {"a": "x", "b": "y"}) + self.assertEqual(map_type.decode({"a": "x", "b": "y"}), {"a": "x", "b": "y"}) + + def test_map_encode_delegates_to_value_type(self): + """Map.encode/decode applies the value type to each value.""" + from tf.types import NormalizedJson + + map_type = types.Map(NormalizedJson()) + self.assertEqual(map_type.encode({"k": {"a": 1}}), {"k": '{"a": 1}'}) + self.assertEqual(map_type.decode({"k": '{"a": 1}'}), {"k": {"a": 1}}) + + def test_map_type_encoding(self): + table = ( + (types.Map(types.String()), "map of string", b'["map","string"]'), + (types.Map(types.Number()), "map of number", b'["map","number"]'), + (types.Map(types.Bool()), "map of bool", b'["map","bool"]'), + (types.Map(types.List(types.String())), "map of list of string", b'["map",["list","string"]]'), + ) + for map_type, test_name, expected in table: + with self.subTest(test_name): + self.assertEqual(map_type.tf_type(), expected) + + def test_map_semantic_equality(self): + map_type = types.Map(types.String()) + + self.assertTrue(map_type.semantically_equal({"a": "x"}, {"a": "x"})) + self.assertFalse(map_type.semantically_equal({"a": "x"}, {"a": "y"})) + self.assertFalse(map_type.semantically_equal({"a": "x"}, {"b": "x"})) + self.assertTrue(map_type.semantically_equal(None, None)) + self.assertTrue(map_type.semantically_equal(Unknown, Unknown)) + self.assertTrue(map_type.semantically_equal({}, {})) diff --git a/tf/types.py b/tf/types.py index f530aa2..1327901 100644 --- a/tf/types.py +++ b/tf/types.py @@ -166,7 +166,32 @@ def semantically_equal(self, a_decoded, b_decoded) -> bool: return sorted(map(str, a)) == sorted(map(str, b)) -# Map +class Map(TfType): + """ + Maps are collections of string-keyed, homogeneously-typed values. + Maps to Python `dict`. + + :param element_type: The type of the elements in the map. + """ + + def __init__(self, element_type: TfType): + self.element_type = element_type + + def encode(self, value: Any) -> Any: + if value in (None, Unknown): + return value + return {k: self.element_type.encode(v) for k, v in value.items()} + + def decode(self, value: Any) -> Any: + if value in (None, Unknown): + return value + return {k: self.element_type.decode(v) for k, v in value.items()} + + def tf_type(self) -> bytes: + t = self.element_type.tf_type().decode() + return f'["map",{t}]'.encode() + + # Object # Tuple # Dynamic?