From b9018119f8bd2e923ef460364f08f2d8d60e2bc1 Mon Sep 17 00:00:00 2001 From: Kamil Kozik Date: Mon, 7 Apr 2025 12:49:01 +0200 Subject: [PATCH] `hcl2.builder.Builder` - nested blocks support --- CHANGELOG.md | 4 ++ hcl2/builder.py | 63 +++++++++++++------ hcl2/const.py | 4 ++ hcl2/reconstructor.py | 20 ++++-- .../helpers/terraform-config-json/blocks.json | 40 +++++++++--- test/helpers/terraform-config/blocks.tf | 15 +++++ test/unit/test_builder.py | 9 ++- 7 files changed, 118 insertions(+), 37 deletions(-) create mode 100644 hcl2/const.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c5cc8bda..4c4e93bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## \[Unreleased\] +### Added + +- `hcl2.builder.Builder` - nested blocks support ([#214](https://github.com/amplify-education/python-hcl2/pull/214)) + ### Fixed - Issue parsing parenthesesed identifier (reference) as an object key ([#212](https://github.com/amplify-education/python-hcl2/pull/212)) diff --git a/hcl2/builder.py b/hcl2/builder.py index 1ba9f7b0..b5b149da 100644 --- a/hcl2/builder.py +++ b/hcl2/builder.py @@ -1,7 +1,10 @@ """A utility class for constructing HCL documents from Python code.""" - from typing import List, Optional +from collections import defaultdict + +from hcl2.const import START_LINE_KEY, END_LINE_KEY + class Builder: """ @@ -15,49 +18,69 @@ class Builder: """ def __init__(self, attributes: Optional[dict] = None): - self.blocks: dict = {} + self.blocks: dict = defaultdict(list) self.attributes = attributes or {} def block( - self, block_type: str, labels: Optional[List[str]] = None, **attributes + self, + block_type: str, + labels: Optional[List[str]] = None, + __nested_builder__: Optional["Builder"] = None, + **attributes ) -> "Builder": """Create a block within this HCL document.""" + + if __nested_builder__ is self: + raise ValueError( + "__nested__builder__ cannot be the same instance as instance this method is called on" + ) + labels = labels or [] 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)) + self.blocks[block_type].append((labels.copy(), block, __nested_builder__)) return block def build(self): """Return the Python dictionary for this HCL document.""" - body = { - "__start_line__": -1, - "__end_line__": -1, - **self.attributes, - } + body = defaultdict(list) - for block_type, blocks in self.blocks.items(): + body.update( + { + START_LINE_KEY: -1, + END_LINE_KEY: -1, + **self.attributes, + } + ) - # initialize a holder for blocks of that type - if block_type not in body: - body[block_type] = [] + for block_type, blocks in self.blocks.items(): - for labels, block_builder in blocks: + for labels, block_builder, nested_blocks in blocks: # build the sub-block block = block_builder.build() + if nested_blocks: + self._add_nested_blocks(block, nested_blocks) + # apply any labels - labels.reverse() - for label in labels: + for label in reversed(labels): block = {label: block} # store it in the body body[block_type].append(block) return body + + def _add_nested_blocks( + self, block: dict, nested_blocks_builder: "Builder" + ) -> "dict": + """Add nested blocks defined within another `Builder` instance to the `block` dictionary""" + nested_block = nested_blocks_builder.build() + for key, value in nested_block.items(): + if key not in (START_LINE_KEY, END_LINE_KEY): + if key not in block.keys(): + block[key] = [] + block[key].extend(value) + return block diff --git a/hcl2/const.py b/hcl2/const.py new file mode 100644 index 00000000..1d46f35a --- /dev/null +++ b/hcl2/const.py @@ -0,0 +1,4 @@ +"""Module for various constants used across the library""" + +START_LINE_KEY = "__start_line__" +END_LINE_KEY = "__end_line__" diff --git a/hcl2/reconstructor.py b/hcl2/reconstructor.py index f2580114..304d51c6 100644 --- a/hcl2/reconstructor.py +++ b/hcl2/reconstructor.py @@ -10,6 +10,8 @@ from lark.reconstruct import Reconstructor from lark.tree_matcher import is_discarded_terminal from lark.visitors import Transformer_InPlace + +from hcl2.const import START_LINE_KEY, END_LINE_KEY from hcl2.parser import reconstruction_parser @@ -423,7 +425,7 @@ def _dict_is_a_block(self, sub_obj: Any) -> bool: # if the sub object has "start_line" and "end_line" metadata, # the block itself is unlabeled, but it is a block - if "__start_line__" in sub_obj.keys() or "__end_line__" in sub_obj.keys(): + if START_LINE_KEY in sub_obj.keys() or END_LINE_KEY in sub_obj.keys(): return True # if the objects in the array have no metadata and more than 2 keys and @@ -454,8 +456,8 @@ def _calculate_block_labels(self, block: dict) -> Tuple[List[str], dict]: # __start_line__ and __end_line__ metadata are not labels if ( - "__start_line__" in potential_body.keys() - or "__end_line__" in potential_body.keys() + START_LINE_KEY in potential_body.keys() + or END_LINE_KEY in potential_body.keys() ): return [curr_label], potential_body @@ -463,6 +465,7 @@ def _calculate_block_labels(self, block: dict) -> Tuple[List[str], dict]: next_label, block_body = self._calculate_block_labels(potential_body) return [curr_label] + next_label, block_body + # pylint:disable=R0914 def _transform_dict_to_body(self, hcl_dict: dict, level: int) -> Tree: # we add a newline at the top of a body within a block, not the root body # >2 here is to ignore the __start_line__ and __end_line__ metadata @@ -473,7 +476,7 @@ def _transform_dict_to_body(self, hcl_dict: dict, level: int) -> Tree: # iterate through each attribute or sub-block of this block for key, value in hcl_dict.items(): - if key in ["__start_line__", "__end_line__"]: + if key in [START_LINE_KEY, END_LINE_KEY]: continue # construct the identifier, whether that be a block type name or an attribute key @@ -499,7 +502,12 @@ def _transform_dict_to_body(self, hcl_dict: dict, level: int) -> Tree: [identifier_name] + block_label_tokens + [block_body], ) children.append(block) - children.append(self._newline(level, count=2)) + # add empty line after block + new_line = self._newline(level - 1) + # add empty line with indentation for next element in the block + new_line.children.append(self._newline(level).children[0]) + + children.append(new_line) # if the value isn't a block, it's an attribute else: @@ -562,7 +570,7 @@ def _transform_value_to_expr_term(self, value, level) -> Union[Token, Tree]: # iterate through the items and add them to the object for i, (k, dict_v) in enumerate(value.items()): - if k in ["__start_line__", "__end_line__"]: + if k in [START_LINE_KEY, END_LINE_KEY]: continue value_expr_term = self._transform_value_to_expr_term(dict_v, level + 1) diff --git a/test/helpers/terraform-config-json/blocks.json b/test/helpers/terraform-config-json/blocks.json index f2486849..716ece56 100644 --- a/test/helpers/terraform-config-json/blocks.json +++ b/test/helpers/terraform-config-json/blocks.json @@ -1,12 +1,34 @@ { - "block": [ - { - "a": 1 - }, - { - "label": { - "b": 2 - } - } + "block": [ + { + "a": 1 + }, + { + "label": { + "b": 2, + "nested_block_1": [ + { + "a": { + "foo": "bar" + } + }, + { + "a": { + "b": { + "bar": "foo" + } + } + }, + { + "foobar": "barfoo" + } + ], + "nested_block_2": [ + { + "barfoo": "foobar" + } ] + } + } + ] } diff --git a/test/helpers/terraform-config/blocks.tf b/test/helpers/terraform-config/blocks.tf index 36cf7504..bd8e5159 100644 --- a/test/helpers/terraform-config/blocks.tf +++ b/test/helpers/terraform-config/blocks.tf @@ -4,4 +4,19 @@ block { block "label" { b = 2 + nested_block_1 "a" { + foo = "bar" + } + + nested_block_1 "a" "b" { + bar = "foo" + } + + nested_block_1 { + foobar = "barfoo" + } + + nested_block_2 { + barfoo = "foobar" + } } diff --git a/test/unit/test_builder.py b/test/unit/test_builder.py index ec9d7fac..1dfb5b23 100644 --- a/test/unit/test_builder.py +++ b/test/unit/test_builder.py @@ -22,10 +22,15 @@ class TestBuilder(TestCase): maxDiff = None def test_build_blocks_tf(self): - builder = hcl2.Builder() + nested_builder = hcl2.Builder() + nested_builder.block("nested_block_1", ["a"], foo="bar") + nested_builder.block("nested_block_1", ["a", "b"], bar="foo") + nested_builder.block("nested_block_1", foobar="barfoo") + nested_builder.block("nested_block_2", barfoo="foobar") + builder = hcl2.Builder() builder.block("block", a=1) - builder.block("block", ["label"], b=2) + builder.block("block", ["label"], __nested_builder__=nested_builder, b=2) self.compare_filenames(builder, "blocks.tf")