Skip to content

Commit 285c3a8

Browse files
Full architecture overhaul: bidirectional HCL2 ↔ JSON pipeline with typed rule classes (#203)
1 parent a8e5ac0 commit 285c3a8

File tree

149 files changed

+11057
-3186
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

149 files changed

+11057
-3186
lines changed

.coveragerc

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
11
[run]
22
branch = true
33
omit =
4-
hcl2/__main__.py
54
hcl2/lark_parser.py
5+
hcl2/version.py
6+
hcl2/__main__.py
7+
hcl2/__init__.py
8+
hcl2/rules/__init__.py
9+
cli/__init__.py
610

711
[report]
812
show_missing = true
9-
fail_under = 80
13+
fail_under = 95
14+
exclude_lines =
15+
raise NotImplementedError

.github/ISSUE_TEMPLATE/hcl2-parsing-error.md

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,31 @@
1-
---
1+
______________________________________________________________________
2+
23
name: HCL2 parsing error
34
about: Template for reporting a bug related to parsing HCL2 code
45
title: ''
56
labels: bug
67
assignees: kkozik-amplify
78

8-
---
9+
______________________________________________________________________
910

1011
**Describe the bug**
1112

1213
A clear and concise description of what the bug is.
1314

1415
**Software:**
15-
- OS: [macOS / Windows / Linux]
16-
- Python version (e.g. 3.9.21)
17-
- python-hcl2 version (e.g. 7.0.0)
16+
17+
- OS: \[macOS / Windows / Linux\]
18+
- Python version (e.g. 3.9.21)
19+
- python-hcl2 version (e.g. 7.0.0)
1820

1921
**Snippet of HCL2 code causing the unexpected behaviour:**
22+
2023
```terraform
2124
locals {
2225
foo = "bar"
2326
}
2427
```
28+
2529
**Expected behavior**
2630

2731
A clear and concise description of what you expected to happen, e.g. python dictionary or JSON you expected to receive as a result of parsing.

.pre-commit-config.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ repos:
66
rev: v4.3.0
77
hooks:
88
- id: trailing-whitespace
9+
exclude: ^test/integration/(hcl2_reconstructed|specialized)/
910
- id: end-of-file-fixer
1011
- id: check-added-large-files
1112
- id: no-commit-to-branch # Prevent commits directly to master

README.md

Lines changed: 49 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
[![Codacy Badge](https://app.codacy.com/project/badge/Grade/2e2015f9297346cbaa788c46ab957827)](https://app.codacy.com/gh/amplify-education/python-hcl2/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade)
2-
[![Build Status](https://travis-ci.org/amplify-education/python-hcl2.svg?branch=master)](https://travis-ci.org/amplify-education/python-hcl2)
32
[![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://raw.githubusercontent.com/amplify-education/python-hcl2/master/LICENSE)
43
[![PyPI](https://img.shields.io/pypi/v/python-hcl2.svg)](https://pypi.org/project/python-hcl2/)
54
[![Python Versions](https://img.shields.io/pypi/pyversions/python-hcl2.svg)](https://pypi.python.org/pypi/python-hcl2)
@@ -36,19 +35,58 @@ pip3 install python-hcl2
3635

3736
### Usage
3837

38+
**HCL2 to Python dict:**
39+
3940
```python
4041
import hcl2
41-
with open('foo.tf', 'r') as file:
42-
dict = hcl2.load(file)
42+
43+
with open("main.tf") as f:
44+
data = hcl2.load(f)
4345
```
4446

45-
### Parse Tree to HCL2 reconstruction
47+
**Python dict to HCL2:**
48+
49+
```python
50+
import hcl2
51+
52+
hcl_string = hcl2.dumps(data)
53+
54+
with open("output.tf", "w") as f:
55+
hcl2.dump(data, f)
56+
```
4657

47-
With version 6.x the possibility of HCL2 reconstruction from the Lark Parse Tree and Python dictionaries directly was introduced.
58+
**Building HCL from scratch:**
4859

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.
60+
```python
61+
import hcl2
62+
63+
doc = hcl2.Builder()
64+
res = doc.block("resource", labels=["aws_instance", "web"], ami="abc-123", instance_type="t2.micro")
65+
res.block("tags", Name="HelloWorld")
66+
67+
hcl_string = hcl2.dumps(doc.build())
68+
```
69+
70+
For the full API reference, option dataclasses, intermediate pipeline stages, and more examples
71+
see [docs/usage.md](https://github.com/amplify-education/python-hcl2/blob/main/docs/usage.md).
72+
73+
### CLI Tools
74+
75+
python-hcl2 ships two command-line converters:
76+
77+
```sh
78+
# HCL2 → JSON
79+
hcl2tojson main.tf # prints JSON to stdout
80+
hcl2tojson main.tf output.json # writes to file
81+
hcl2tojson terraform/ output/ # converts a directory
82+
83+
# JSON → HCL2
84+
jsontohcl2 output.json # prints HCL2 to stdout
85+
jsontohcl2 output.json main.tf # writes to file
86+
jsontohcl2 output/ terraform/ # converts a directory
87+
```
5088

51-
More details about reconstruction implementation can be found in PRs #169 and #177.
89+
Both commands accept `-` as PATH to read from stdin. Run `hcl2tojson --help` or `jsontohcl2 --help` for the full list of flags.
5290

5391
## Building From Source
5492

@@ -61,7 +99,7 @@ Running `tox` will automatically execute linters as well as the unit tests.
6199

62100
You can also run them individually with the `-e` argument.
63101

64-
For example, `tox -e py37-unit` will run the unit tests for python 3.7
102+
For example, `tox -e py310-unit` will run the unit tests for python 3.10
65103

66104
To see all the available options, run `tox -l`.
67105

@@ -81,21 +119,10 @@ You can reach us at <mailto:github@amplify.com>
81119
We welcome pull requests! For your pull request to be accepted smoothly, we suggest that you:
82120

83121
- For any sizable change, first open a GitHub issue to discuss your idea.
84-
- Create a pull request. Explain why you want to make the change and what its for.
122+
- Create a pull request. Explain why you want to make the change and what it's for.
85123

86-
Well try to answer any PRs promptly.
124+
We'll try to answer any PR's promptly.
87125

88126
## Limitations
89127

90-
### Using inline expression as an object key
91-
92-
- Object key can be an expression as long as it is wrapped in parentheses:
93-
```terraform
94-
locals {
95-
foo = "bar"
96-
baz = {
97-
(format("key_prefix_%s", local.foo)) : "value"
98-
# format("key_prefix_%s", local.foo) : "value" this will fail
99-
}
100-
}
101-
```
128+
None that are known.
File renamed without changes.

cli/hcl_to_json.py

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
"""``hcl2tojson`` CLI entry point — convert HCL2 files to JSON."""
2+
import argparse
3+
import json
4+
import os
5+
from typing import IO, Optional, TextIO
6+
7+
from hcl2 import load
8+
from hcl2.utils import SerializationOptions
9+
from hcl2.version import __version__
10+
from .helpers import (
11+
HCL_SKIPPABLE,
12+
_convert_single_file,
13+
_convert_directory,
14+
_convert_stdin,
15+
)
16+
17+
18+
def _hcl_to_json(
19+
in_file: TextIO,
20+
out_file: IO,
21+
options: SerializationOptions,
22+
json_indent: Optional[int] = None,
23+
) -> None:
24+
data = load(in_file, serialization_options=options)
25+
json.dump(data, out_file, indent=json_indent)
26+
27+
28+
def main():
29+
"""The ``hcl2tojson`` console_scripts entry point."""
30+
parser = argparse.ArgumentParser(
31+
description="Convert HCL2 files to JSON",
32+
)
33+
parser.add_argument(
34+
"-s", dest="skip", action="store_true", help="Skip un-parsable files"
35+
)
36+
parser.add_argument(
37+
"PATH",
38+
help='The file or directory to convert (use "-" for stdin)',
39+
)
40+
parser.add_argument(
41+
"OUT_PATH",
42+
nargs="?",
43+
help="The path to write output to. Optional for single file (defaults to stdout)",
44+
)
45+
parser.add_argument("--version", action="version", version=__version__)
46+
47+
# SerializationOptions flags
48+
parser.add_argument(
49+
"--with-meta",
50+
action="store_true",
51+
help="Add meta parameters like __start_line__ and __end_line__",
52+
)
53+
parser.add_argument(
54+
"--with-comments",
55+
action="store_true",
56+
help="Include comments in the output",
57+
)
58+
parser.add_argument(
59+
"--wrap-objects",
60+
action="store_true",
61+
help="Wrap object values as an inline HCL2",
62+
)
63+
parser.add_argument(
64+
"--wrap-tuples",
65+
action="store_true",
66+
help="Wrap tuple values an inline HCL2",
67+
)
68+
parser.add_argument(
69+
"--no-explicit-blocks",
70+
action="store_true",
71+
help="Disable explicit block markers",
72+
)
73+
parser.add_argument(
74+
"--no-preserve-heredocs",
75+
action="store_true",
76+
help="Convert heredocs to plain strings",
77+
)
78+
parser.add_argument(
79+
"--force-parens",
80+
action="store_true",
81+
help="Force parentheses around all operations",
82+
)
83+
parser.add_argument(
84+
"--no-preserve-scientific",
85+
action="store_true",
86+
help="Convert scientific notation to standard floats",
87+
)
88+
89+
# JSON output formatting
90+
parser.add_argument(
91+
"--json-indent",
92+
type=int,
93+
default=2,
94+
metavar="N",
95+
help="JSON indentation width (default: 2)",
96+
)
97+
98+
args = parser.parse_args()
99+
100+
options = SerializationOptions(
101+
with_meta=args.with_meta,
102+
with_comments=args.with_comments,
103+
wrap_objects=args.wrap_objects,
104+
wrap_tuples=args.wrap_tuples,
105+
explicit_blocks=not args.no_explicit_blocks,
106+
preserve_heredocs=not args.no_preserve_heredocs,
107+
force_operation_parentheses=args.force_parens,
108+
preserve_scientific_notation=not args.no_preserve_scientific,
109+
)
110+
json_indent = args.json_indent
111+
112+
def convert(in_file, out_file):
113+
_hcl_to_json(in_file, out_file, options, json_indent=json_indent)
114+
115+
if args.PATH == "-":
116+
_convert_stdin(convert)
117+
elif os.path.isfile(args.PATH):
118+
_convert_single_file(
119+
args.PATH, args.OUT_PATH, convert, args.skip, HCL_SKIPPABLE
120+
)
121+
elif os.path.isdir(args.PATH):
122+
_convert_directory(
123+
args.PATH,
124+
args.OUT_PATH,
125+
convert,
126+
args.skip,
127+
HCL_SKIPPABLE,
128+
in_extensions={".tf", ".hcl"},
129+
out_extension=".json",
130+
)
131+
else:
132+
raise RuntimeError(f"Invalid Path: {args.PATH}")

cli/helpers.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
"""Shared file-conversion helpers for the HCL2 CLI commands."""
2+
import json
3+
import os
4+
import sys
5+
from typing import Callable, IO, Set, Tuple, Type
6+
7+
from lark import UnexpectedCharacters, UnexpectedToken
8+
9+
# Exceptions that can be skipped when -s is passed
10+
HCL_SKIPPABLE = (UnexpectedToken, UnexpectedCharacters, UnicodeDecodeError)
11+
JSON_SKIPPABLE = (json.JSONDecodeError, UnicodeDecodeError)
12+
13+
14+
def _convert_single_file(
15+
in_path: str,
16+
out_path: str,
17+
convert_fn: Callable[[IO, IO], None],
18+
skip: bool,
19+
skippable: Tuple[Type[BaseException], ...],
20+
) -> None:
21+
with open(in_path, "r", encoding="utf-8") as in_file:
22+
print(in_path, file=sys.stderr, flush=True)
23+
if out_path is not None:
24+
try:
25+
with open(out_path, "w", encoding="utf-8") as out_file:
26+
convert_fn(in_file, out_file)
27+
except skippable:
28+
if skip:
29+
if os.path.exists(out_path):
30+
os.remove(out_path)
31+
return
32+
raise
33+
else:
34+
try:
35+
convert_fn(in_file, sys.stdout)
36+
sys.stdout.write("\n")
37+
except skippable:
38+
if skip:
39+
return
40+
raise
41+
42+
43+
def _convert_directory(
44+
in_path: str,
45+
out_path: str,
46+
convert_fn: Callable[[IO, IO], None],
47+
skip: bool,
48+
skippable: Tuple[Type[BaseException], ...],
49+
in_extensions: Set[str],
50+
out_extension: str,
51+
) -> None:
52+
if out_path is None:
53+
raise RuntimeError("Positional OUT_PATH parameter shouldn't be empty")
54+
if not os.path.exists(out_path):
55+
os.mkdir(out_path)
56+
57+
processed_files: set = set()
58+
for current_dir, _, files in os.walk(in_path):
59+
dir_prefix = os.path.commonpath([in_path, current_dir])
60+
relative_current_dir = os.path.relpath(current_dir, dir_prefix)
61+
current_out_path = os.path.normpath(
62+
os.path.join(out_path, relative_current_dir)
63+
)
64+
if not os.path.exists(current_out_path):
65+
os.mkdir(current_out_path)
66+
for file_name in files:
67+
_, ext = os.path.splitext(file_name)
68+
if ext not in in_extensions:
69+
continue
70+
71+
in_file_path = os.path.join(current_dir, file_name)
72+
out_file_path = os.path.join(current_out_path, file_name)
73+
out_file_path = os.path.splitext(out_file_path)[0] + out_extension
74+
75+
if in_file_path in processed_files or out_file_path in processed_files:
76+
continue
77+
78+
processed_files.add(in_file_path)
79+
processed_files.add(out_file_path)
80+
81+
with open(in_file_path, "r", encoding="utf-8") as in_file:
82+
print(in_file_path, file=sys.stderr, flush=True)
83+
try:
84+
with open(out_file_path, "w", encoding="utf-8") as out_file:
85+
convert_fn(in_file, out_file)
86+
except skippable:
87+
if skip:
88+
if os.path.exists(out_file_path):
89+
os.remove(out_file_path)
90+
continue
91+
raise
92+
93+
94+
def _convert_stdin(convert_fn: Callable[[IO, IO], None]) -> None:
95+
convert_fn(sys.stdin, sys.stdout)
96+
sys.stdout.write("\n")

0 commit comments

Comments
 (0)