Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions docs/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
14 changes: 8 additions & 6 deletions docs/elements.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
19 changes: 17 additions & 2 deletions e2e/e2e_framework/e2e_framework/framework.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
37 changes: 36 additions & 1 deletion e2e/mathprovider/mathprovider/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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_"
Expand All @@ -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():
Expand Down
120 changes: 120 additions & 0 deletions e2e/mathprovider/mathprovider/test_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"',
],
)
40 changes: 40 additions & 0 deletions tf/tests/test_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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({}, {}))
27 changes: 26 additions & 1 deletion tf/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
Loading