Skip to content

Commit f8a2c88

Browse files
Support full reconstruction of HCL from output dictionary (#177)
* Initial commit of a "reverse transformer" to turn HCL2 dicts into Lark trees * Add tests for the reverse reconstructor * Add different handling to the reverse reconstructor depending on data type * Add support for multiple block labels * Fix accidentally escaping quotes within interpolated strings * Properly handle escapes within HCL strings (closes #171) * Standardize string output from transformer within nested structures to match Terraform syntax instead of Python (fixes #172) * Fix block labels and booleans during reconstruction * Better handle nested interpolation (fixes #173) * Begin refactor of whitespace handling (more to come) * overhaul of whitespace handling, remove old logic. * Fix Pylint warnings * Fix a few formatting issues in reconstruction * Add a "builder" class for constructing HCL files from Python * Update the docs for reconstruction * fix suggested by Nfsaavedra #177 (comment) * a bit of refactoring * update interpolation test case to include long non-interpolated substring --------- Co-authored-by: Kamil Kozik <[email protected]>
1 parent eb2032a commit f8a2c88

26 files changed

+1215
-190
lines changed

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,11 @@ with open('foo.tf', 'r') as file:
4444

4545
### Parse Tree to HCL2 reconstruction
4646

47-
With version 5.0.0 the possibility of HCL2 reconstruction from Lark Parse Tree was introduced.
47+
With version 5.x the possibility of HCL2 reconstruction from the Lark Parse Tree and Python dictionaries directly was introduced.
4848

49-
Example of manipulating Lark Parse Tree and reconstructing it back into valid HCL2 can be found in [tree-to-hcl2-reconstruction.md](https://github.com/amplify-education/python-hcl2/blob/main/tree-to-hcl2-reconstruction.md) file.
49+
Documentation and an example of manipulating Lark Parse Tree and reconstructing it back into valid HCL2 can be found in [tree-to-hcl2-reconstruction.md](https://github.com/amplify-education/python-hcl2/blob/main/tree-to-hcl2-reconstruction.md) file.
5050

51-
More details about reconstruction implementation can be found in this [PR](https://github.com/amplify-education/python-hcl2/pull/169).
51+
More details about reconstruction implementation can be found in PRs #169 and #177.
5252

5353
## Building From Source
5454

hcl2/__init__.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,15 @@
55
except ImportError:
66
__version__ = "unknown"
77

8-
from .api import load, loads, parse, parses, transform, writes, AST
8+
from .api import (
9+
load,
10+
loads,
11+
parse,
12+
parses,
13+
transform,
14+
reverse_transform,
15+
writes,
16+
AST,
17+
)
18+
19+
from .builder import Builder

hcl2/api.py

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from typing import TextIO
33

44
from lark.tree import Tree as AST
5-
from hcl2.parser import hcl2
5+
from hcl2.parser import parser
66
from hcl2.transformer import DictTransformer
77

88

@@ -25,7 +25,7 @@ def loads(text: str, with_meta=False) -> dict:
2525
# Lark doesn't support a EOF token so our grammar can't look for "new line or end of file"
2626
# This means that all blocks must end in a new line even if the file ends
2727
# Append a new line as a temporary fix
28-
tree = hcl2.parse(text + "\n")
28+
tree = parser().parse(text + "\n")
2929
return DictTransformer(with_meta=with_meta).transform(tree)
3030

3131

@@ -42,11 +42,11 @@ def parses(text: str) -> AST:
4242
"""
4343
# defer this import until this method is called, due to the performance hit
4444
# of rebuilding the grammar without cache
45-
from hcl2.reconstructor import ( # pylint: disable=import-outside-toplevel
46-
hcl2 as uncached_hcl2,
45+
from hcl2.parser import ( # pylint: disable=import-outside-toplevel
46+
reconstruction_parser,
4747
)
4848

49-
return uncached_hcl2.parse(text)
49+
return reconstruction_parser().parse(text)
5050

5151

5252
def transform(ast: AST, with_meta=False) -> dict:
@@ -56,6 +56,19 @@ def transform(ast: AST, with_meta=False) -> dict:
5656
return DictTransformer(with_meta=with_meta).transform(ast)
5757

5858

59+
def reverse_transform(hcl2_dict: dict) -> AST:
60+
"""Convert a dictionary to an HCL2 AST.
61+
:param dict: a dictionary produced by `load` or `transform`
62+
"""
63+
# defer this import until this method is called, due to the performance hit
64+
# of rebuilding the grammar without cache
65+
from hcl2.reconstructor import ( # pylint: disable=import-outside-toplevel
66+
hcl2_reverse_transformer,
67+
)
68+
69+
return hcl2_reverse_transformer.transform(hcl2_dict)
70+
71+
5972
def writes(ast: AST) -> str:
6073
"""Convert an HCL2 syntax tree to a string.
6174
:param ast: HCL2 syntax tree, output from `parse` or `parses`

hcl2/builder.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
"""A utility class for constructing HCL documents from Python code."""
2+
3+
from typing import List, Optional
4+
5+
6+
class Builder:
7+
"""
8+
The `hcl2.Builder` class produces a dictionary that should be identical to the
9+
output of `hcl2.load(example_file, with_meta=True)`. The `with_meta` keyword
10+
argument is important here. HCL "blocks" in the Python dictionary are
11+
identified by the presence of `__start_line__` and `__end_line__` metadata
12+
within them. The `Builder` class handles adding that metadata. If that metadata
13+
is missing, the `hcl2.reconstructor.HCLReverseTransformer` class fails to
14+
identify what is a block and what is just an attribute with an object value.
15+
"""
16+
17+
def __init__(self, attributes: Optional[dict] = None):
18+
self.blocks: dict = {}
19+
self.attributes = attributes or {}
20+
21+
def block(
22+
self, block_type: str, labels: Optional[List[str]] = None, **attributes
23+
) -> "Builder":
24+
"""Create a block within this HCL document."""
25+
labels = labels or []
26+
block = Builder(attributes)
27+
28+
# initialize a holder for blocks of that type
29+
if block_type not in self.blocks:
30+
self.blocks[block_type] = []
31+
32+
# store the block in the document
33+
self.blocks[block_type].append((labels.copy(), block))
34+
35+
return block
36+
37+
def build(self):
38+
"""Return the Python dictionary for this HCL document."""
39+
body = {
40+
"__start_line__": -1,
41+
"__end_line__": -1,
42+
**self.attributes,
43+
}
44+
45+
for block_type, blocks in self.blocks.items():
46+
47+
# initialize a holder for blocks of that type
48+
if block_type not in body:
49+
body[block_type] = []
50+
51+
for labels, block_builder in blocks:
52+
# build the sub-block
53+
block = block_builder.build()
54+
55+
# apply any labels
56+
labels.reverse()
57+
for label in labels:
58+
block = {label: block}
59+
60+
# store it in the body
61+
body[block_type].append(block)
62+
63+
return body

hcl2/hcl2.lark

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
start : body
22
body : (new_line_or_comment? (attribute | block))* new_line_or_comment?
33
attribute : identifier EQ expression
4-
block : identifier (identifier | STRING_LIT)* new_line_or_comment? "{" body "}"
4+
block : identifier (identifier | STRING_LIT | string_with_interpolation)* new_line_or_comment? "{" body "}"
55
new_line_and_or_comma: new_line_or_comment | "," | "," new_line_or_comment
66
new_line_or_comment: ( NL_OR_COMMENT )+
77
NL_OR_COMMENT: /\n[ \t]*/ | /#.*\n/ | /\/\/.*\n/ | /\/\*(.|\n)*?(\*\/)/
@@ -22,12 +22,26 @@ conditional : expression "?" new_line_or_comment? expression new_line_or_comment
2222
binary_op : expression binary_term new_line_or_comment?
2323
!binary_operator : BINARY_OP
2424
binary_term : binary_operator new_line_or_comment? expression
25-
BINARY_OP : "==" | "!=" | "<" | ">" | "<=" | ">=" | "-" | "*" | "/" | "%" | "&&" | "||" | "+"
25+
BINARY_OP : DOUBLE_EQ | NEQ | LT | GT | LEQ | GEQ | MINUS | ASTERISK | SLASH | PERCENT | DOUBLE_AMP | DOUBLE_PIPE | PLUS
26+
DOUBLE_EQ : "=="
27+
NEQ : "!="
28+
LT : "<"
29+
GT : ">"
30+
LEQ : "<="
31+
GEQ : ">="
32+
MINUS : "-"
33+
ASTERISK : "*"
34+
SLASH : "/"
35+
PERCENT : "%"
36+
DOUBLE_AMP : "&&"
37+
DOUBLE_PIPE : "||"
38+
PLUS : "+"
2639

2740
expr_term : "(" new_line_or_comment? expression new_line_or_comment? ")"
2841
| float_lit
2942
| int_lit
3043
| STRING_LIT
44+
| string_with_interpolation
3145
| tuple
3246
| object
3347
| function_call
@@ -42,11 +56,10 @@ expr_term : "(" new_line_or_comment? expression new_line_or_comment? ")"
4256
| for_tuple_expr
4357
| for_object_expr
4458

45-
46-
STRING_LIT : "\"" (STRING_CHARS | INTERPOLATION)* "\""
47-
STRING_CHARS : /(?:(?!\${)([^"\\]|\\.))+/+ // any character except '"" unless inside a interpolation string
48-
NESTED_INTERPOLATION : "${" /[^}]+/ "}"
49-
INTERPOLATION : "${" (/(?:(?!\${)([^}]))+/ | NESTED_INTERPOLATION)+ "}"
59+
STRING_LIT : "\"" STRING_CHARS? "\""
60+
STRING_CHARS : /(?:(?!\${)([^"\\]|\\.))+/ // any character except '"'
61+
string_with_interpolation: "\"" (STRING_CHARS)* interpolation_maybe_nested (STRING_CHARS | interpolation_maybe_nested)* "\""
62+
interpolation_maybe_nested: "${" expression "}"
5063

5164
int_lit : DECIMAL+
5265
!float_lit: DECIMAL+ "." DECIMAL+ (EXP_MARK DECIMAL+)?
@@ -77,8 +90,9 @@ get_attr : "." identifier
7790
attr_splat : ".*" get_attr*
7891
full_splat : "[*]" (get_attr | index)*
7992

93+
FOR_OBJECT_ARROW : "=>"
8094
!for_tuple_expr : "[" new_line_or_comment? for_intro new_line_or_comment? expression new_line_or_comment? for_cond? new_line_or_comment? "]"
81-
!for_object_expr : "{" new_line_or_comment? for_intro new_line_or_comment? expression "=>" new_line_or_comment? expression "..."? new_line_or_comment? for_cond? new_line_or_comment? "}"
95+
!for_object_expr : "{" new_line_or_comment? for_intro new_line_or_comment? expression FOR_OBJECT_ARROW new_line_or_comment? expression "..."? new_line_or_comment? for_cond? new_line_or_comment? "}"
8296
!for_intro : "for" new_line_or_comment? identifier ("," identifier new_line_or_comment?)? new_line_or_comment? "in" new_line_or_comment? expression new_line_or_comment? ":" new_line_or_comment?
8397
!for_cond : "if" new_line_or_comment? expression
8498

hcl2/parser.py

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
"""A parser for HCL2 implemented using the Lark parser"""
2+
import functools
23
from pathlib import Path
34

45
from lark import Lark
@@ -7,10 +8,35 @@
78
PARSER_FILE = Path(__file__).absolute().resolve().parent / ".lark_cache.bin"
89

910

10-
hcl2 = Lark.open(
11-
"hcl2.lark",
12-
parser="lalr",
13-
cache=str(PARSER_FILE), # Disable/Delete file to effect changes to the grammar
14-
rel_to=__file__,
15-
propagate_positions=True,
16-
)
11+
@functools.lru_cache()
12+
def parser() -> Lark:
13+
"""Build standard parser for transforming HCL2 text into python structures"""
14+
return Lark.open(
15+
"hcl2.lark",
16+
parser="lalr",
17+
cache=str(PARSER_FILE), # Disable/Delete file to effect changes to the grammar
18+
rel_to=__file__,
19+
propagate_positions=True,
20+
)
21+
22+
23+
@functools.lru_cache()
24+
def reconstruction_parser() -> Lark:
25+
"""
26+
Build parser for transforming python structures into HCL2 text.
27+
This is duplicated from `parser` because we need different options here for
28+
the reconstructor. Please make sure changes are kept in sync between the two
29+
if necessary.
30+
"""
31+
return Lark.open(
32+
"hcl2.lark",
33+
parser="lalr",
34+
# Caching must be disabled to allow for reconstruction until lark-parser/lark#1472 is fixed:
35+
#
36+
# https://github.com/lark-parser/lark/issues/1472
37+
#
38+
# cache=str(PARSER_FILE), # Disable/Delete file to effect changes to the grammar
39+
rel_to=__file__,
40+
propagate_positions=True,
41+
maybe_placeholders=False, # Needed for reconstruction
42+
)

0 commit comments

Comments
 (0)