Skip to content

Commit

Permalink
Add a "builder" class for constructing HCL files from Python
Browse files Browse the repository at this point in the history
  • Loading branch information
weaversam8 committed Oct 24, 2024
1 parent 1ad6758 commit 8652de4
Show file tree
Hide file tree
Showing 8 changed files with 166 additions and 8 deletions.
12 changes: 11 additions & 1 deletion hcl2/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
1 change: 1 addition & 0 deletions hcl2/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
53 changes: 53 additions & 0 deletions hcl2/builder.py
Original file line number Diff line number Diff line change
@@ -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
8 changes: 4 additions & 4 deletions hcl2/reconstructor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion test/helpers/terraform-config/a.tf
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@ block {

block "label" {
b = 2
}
}
2 changes: 1 addition & 1 deletion test/helpers/terraform-config/escapes.tf
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
block "block_with_newlines" {
a = "line1\nline2"
}
}
2 changes: 1 addition & 1 deletion test/helpers/terraform-config/locals_embedded_condition.tf
Original file line number Diff line number Diff line change
@@ -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 = []
}
}
94 changes: 94 additions & 0 deletions test/unit/test_builder.py
Original file line number Diff line number Diff line change
@@ -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.",
)

0 comments on commit 8652de4

Please sign in to comment.