From 8652de4b3f00f701cd0811f306e28d9b1227e6b5 Mon Sep 17 00:00:00 2001 From: Sam Weaver Date: Thu, 24 Oct 2024 10:27:36 -0400 Subject: [PATCH] Add a "builder" class for constructing HCL files from Python --- hcl2/__init__.py | 12 ++- hcl2/api.py | 1 + hcl2/builder.py | 53 +++++++++++ hcl2/reconstructor.py | 8 +- test/helpers/terraform-config/a.tf | 2 +- test/helpers/terraform-config/escapes.tf | 2 +- .../locals_embedded_condition.tf | 2 +- test/unit/test_builder.py | 94 +++++++++++++++++++ 8 files changed, 166 insertions(+), 8 deletions(-) create mode 100644 hcl2/builder.py create mode 100644 test/unit/test_builder.py diff --git a/hcl2/__init__.py b/hcl2/__init__.py index 298eab84..69404cf5 100644 --- a/hcl2/__init__.py +++ b/hcl2/__init__.py @@ -5,4 +5,14 @@ except ImportError: __version__ = "unknown" -from .api import load, loads, parse, parses, transform, reverse_transform, writes, AST +from .api import ( + load, + loads, + parse, + parses, + transform, + reverse_transform, + writes, + AST, + Builder, +) diff --git a/hcl2/api.py b/hcl2/api.py index fa7d5ed2..ddb40e98 100644 --- a/hcl2/api.py +++ b/hcl2/api.py @@ -4,6 +4,7 @@ from lark.tree import Tree as AST from hcl2.parser import hcl2 from hcl2.transformer import DictTransformer +from hcl2.builder import Builder def load(file: TextIO, with_meta=False) -> dict: diff --git a/hcl2/builder.py b/hcl2/builder.py new file mode 100644 index 00000000..5ef0c48b --- /dev/null +++ b/hcl2/builder.py @@ -0,0 +1,53 @@ +"""A utility class for constructing HCL documents from Python code.""" + +from typing import List +from typing_extensions import Self + + +class Builder: + def __init__(self, attributes: dict = {}): + self.blocks = {} + self.attributes = attributes + + def block( + self, block_type: str, labels: List[str] = [], **attributes: dict + ) -> Self: + """Create a block within this HCL document.""" + block = Builder(attributes) + + # initialize a holder for blocks of that type + if block_type not in self.blocks: + self.blocks[block_type] = [] + + # store the block in the document + self.blocks[block_type].append((labels.copy(), block)) + + return block + + def build(self): + """Return the Python dictionary for this HCL document.""" + body = { + "__start_line__": -1, + "__end_line__": -1, + **self.attributes, + } + + for block_type, blocks in self.blocks.items(): + + # initialize a holder for blocks of that type + if block_type not in body: + body[block_type] = [] + + for labels, block_builder in blocks: + # build the sub-block + block = block_builder.build() + + # apply any labels + labels.reverse() + for label in labels: + block = {label: block} + + # store it in the body + body[block_type].append(block) + + return body diff --git a/hcl2/reconstructor.py b/hcl2/reconstructor.py index b5c351ca..b2b57b89 100644 --- a/hcl2/reconstructor.py +++ b/hcl2/reconstructor.py @@ -376,18 +376,18 @@ def transform(self, hcl_dict: dict) -> Tree: start = Tree(Token("RULE", "start"), [body]) return start - def _newline(self, level: int, comma: bool = False) -> Tree: + def _newline(self, level: int, comma: bool = False, count: int = 1) -> Tree: # some rules expect the `new_line_and_or_comma` token if comma: return Tree( Token("RULE", "new_line_and_or_comma"), - [self._newline(level=level, comma=False)], + [self._newline(level=level, comma=False, count=count)], ) # otherwise, return the `new_line_or_comment` token return Tree( Token("RULE", "new_line_or_comment"), - [Token("NL_OR_COMMENT", f"\n{' ' * level}")], + [Token("NL_OR_COMMENT", f"\n{' ' * level}") for _ in range(count)], ) # rules: the value of a block is always an array of dicts, @@ -520,7 +520,7 @@ def _transform_dict_to_body(self, hcl_dict: dict, level: int) -> List[Tree]: [identifier_name] + block_label_tokens + [block_body], ) children.append(block) - children.append(self._newline(level)) + children.append(self._newline(level, count=2)) # if the value isn't a block, it's an attribute else: diff --git a/test/helpers/terraform-config/a.tf b/test/helpers/terraform-config/a.tf index 67ad7e3c..36cf7504 100644 --- a/test/helpers/terraform-config/a.tf +++ b/test/helpers/terraform-config/a.tf @@ -4,4 +4,4 @@ block { block "label" { b = 2 -} \ No newline at end of file +} diff --git a/test/helpers/terraform-config/escapes.tf b/test/helpers/terraform-config/escapes.tf index 73bf873f..0e82ab85 100644 --- a/test/helpers/terraform-config/escapes.tf +++ b/test/helpers/terraform-config/escapes.tf @@ -1,3 +1,3 @@ block "block_with_newlines" { a = "line1\nline2" -} \ No newline at end of file +} diff --git a/test/helpers/terraform-config/locals_embedded_condition.tf b/test/helpers/terraform-config/locals_embedded_condition.tf index 55b57b35..25de5a29 100644 --- a/test/helpers/terraform-config/locals_embedded_condition.tf +++ b/test/helpers/terraform-config/locals_embedded_condition.tf @@ -1,6 +1,6 @@ locals { terraform = { - channels = local.running_in_ci ? local.ci_channels : local.local_channels + channels = (local.running_in_ci ? local.ci_channels : local.local_channels) authentication = [] } } diff --git a/test/unit/test_builder.py b/test/unit/test_builder.py new file mode 100644 index 00000000..0ab3792a --- /dev/null +++ b/test/unit/test_builder.py @@ -0,0 +1,94 @@ +"""Test building an HCL file from scratch""" + +import json +from pathlib import Path +from unittest import TestCase + +import hcl2 +import hcl2.builder + + +HELPERS_DIR = Path(__file__).absolute().parent.parent / "helpers" +HCL2_DIR = HELPERS_DIR / "terraform-config" +JSON_DIR = HELPERS_DIR / "terraform-config-json" +HCL2_FILES = [str(file.relative_to(HCL2_DIR)) for file in HCL2_DIR.iterdir()] + + +class TestBuilder(TestCase): + """Test building a variety of hcl files""" + + # print any differences fully to the console + maxDiff = None + + def test_build_a_tf(self): + builder = hcl2.Builder() + + builder.block("block", a=1) + builder.block("block", ["label"], b=2) + + self.compare_filenames(builder, "a.tf") + + def test_build_escapes_tf(self): + builder = hcl2.Builder() + + builder.block("block", ["block_with_newlines"], a="line1\nline2") + + self.compare_filenames(builder, "escapes.tf") + + def test_locals_embdedded_condition_tf(self): + builder = hcl2.Builder() + + builder.block( + "locals", + terraform={ + "channels": "${local.running_in_ci ? local.ci_channels : local.local_channels}", + "authentication": [], + }, + ) + + self.compare_filenames(builder, "locals_embedded_condition.tf") + + def test_locals_embedded_function_tf(self): + builder = hcl2.Builder() + + builder.block( + "locals", + function_test='${var.basename}-${var.forwarder_function_name}_${md5("${var.vpc_id}${data.aws_region.current.name}")}', + ) + + self.compare_filenames(builder, "locals_embedded_function.tf") + + def test_locals_embedded_interpolation_tf(self): + builder = hcl2.Builder() + + builder.block( + "locals", + embedded_interpolation='${module.special_constants.aws_accounts["aaa-${local.foo}-${local.bar}"]}/us-west-2/key_foo', + ) + + self.compare_filenames(builder, "locals_embedded_interpolation.tf") + + def test_provider_function_tf(self): + builder = hcl2.Builder() + + builder.block( + "locals", + name2='${provider::test2::test("a")}', + name3='${test("a")}', + ) + + self.compare_filenames(builder, "provider_function.tf") + + def compare_filenames(self, builder: hcl2.Builder, filename: str): + hcl_dict = builder.build() + hcl_ast = hcl2.reverse_transform(hcl_dict) + hcl_content_built = hcl2.writes(hcl_ast) + + hcl_path = (HCL2_DIR / filename).absolute() + with hcl_path.open("r") as hcl_file: + hcl_file_content = hcl_file.read() + self.assertMultiLineEqual( + hcl_content_built, + hcl_file_content, + f"file {filename} does not match its programmatically built version.", + )