Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
### Fixed

- Restore `py.typed` marker so type checkers recognize `hcl2` (and `cli`) as typed packages. ([#298](https://github.com/amplify-education/python-hcl2/issues/298))
- `preserve_heredocs=False` combined with `strip_string_quotes` now returns the heredoc body as a plain multi-line string instead of escaping every newline to a literal `\n`. The escaping is still applied to the quoted source form produced without `strip_string_quotes`. ([#303](https://github.com/amplify-education/python-hcl2/issues/303))
- Parse heredocs with an empty body again. A marker immediately followed by its closing delimiter failed to match, and the lexer then ran on to a later delimiter, silently absorbing the attributes in between. ([#309](https://github.com/amplify-education/python-hcl2/issues/309))
- Negative integer literals load as numbers again instead of `${-N}` expression strings, matching negative floats and the pre-8.x behaviour. ([#307](https://github.com/amplify-education/python-hcl2/issues/307))
- `strip_string_quotes` no longer unquotes string literals nested inside expressions, which produced invalid HCL such as `${upper(x)}` from `upper("x")`. ([#310](https://github.com/amplify-education/python-hcl2/issues/310))
Expand Down
2 changes: 1 addition & 1 deletion docs/01_getting_started.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ data = loads(text, serialization_options=SerializationOptions(
| `wrap_objects` | `bool` | `False` | Wrap object values as inline HCL2 strings |
| `wrap_tuples` | `bool` | `False` | Wrap tuple values as inline HCL2 strings |
| `explicit_blocks` | `bool` | `True` | Add `__is_block__: True` markers to blocks. **Mandatory for JSON->HCL2 deserialization and reconstruction.** |
| `preserve_heredocs` | `bool` | `True` | Keep heredocs in their original form |
| `preserve_heredocs` | `bool` | `True` | Keep heredocs in their original form, markers included. When `False`, a heredoc becomes a quoted string with its newlines escaped (`'"a\nb"'`); combine with `strip_string_quotes` to get the body as a plain multi-line value instead. |
| `force_operation_parentheses` | `bool` | `False` | Force parentheses around all operations |
| `preserve_scientific_notation` | `bool` | `True` | Keep scientific notation as-is |
| `strip_string_quotes` | `bool` | `False` | Yield string *values* rather than source text: remove surrounding quotes (e.g. `"hello"` instead of `'"hello"'`) and resolve escape sequences (`"a\nb"` becomes a real newline). String literals inside expressions keep their quotes, so `upper("x")` stays `'${upper("x")}'`. **Breaks JSON->HCL2 deserialization and reconstruction.** |
Expand Down
20 changes: 20 additions & 0 deletions docs/06_migrating_to_v8.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,9 +201,29 @@ V7_COMPAT = SerializationOptions(
strip_string_quotes=True,
explicit_blocks=False,
with_comments=False,
preserve_heredocs=False,
)

data = hcl2.load(f, serialization_options=V7_COMPAT)
```

This restores the v7 dict shape but disables round-trip support and comment preservation.

`preserve_heredocs=False` matters if your configuration uses heredocs. Left at its default, a heredoc value keeps its `<<-EOT` / `EOT` markers embedded in the string. Turned off alongside `strip_string_quotes`, you get the body as a plain multi-line string with real line breaks, which is what v7 returned:

```python
hcl2.loads('x = <<-EOT\n line1\n line2\n EOT\n', serialization_options=V7_COMPAT)
# {'x': 'line1\nline2'}
```

Note that `preserve_heredocs=False` on its own — without `strip_string_quotes` — produces the quoted *source* form with escaped newlines (`'"line1\\nline2"'`), because that output is meant to be reconstructable.

Two details of heredoc values are easy to trip over, and both match how HCL itself behaves:

- **Backslash escapes are not interpreted in heredocs.** `strip_string_quotes` resolves `\n` inside a *quoted* string, but a heredoc body containing the two characters `\n` keeps them verbatim. HCL only processes escape sequences in quoted templates.
- **Line endings come through as written.** A heredoc in a CRLF file yields a body with `\r\n`, because a carriage return inside the body is content rather than structure. Normalize on your side if you need `\n`.

```python
hcl2.loads('x = <<EOT\na\\nb\nEOT\n', serialization_options=V7_COMPAT)
# {'x': 'a\\nb'} — the backslash and the "n" are two literal characters
```
12 changes: 9 additions & 3 deletions hcl2/rules/strings.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,9 +170,12 @@ def serialize(self, options=SerializationOptions(), context=SerializationContext
if not match:
raise RuntimeError(f"Invalid Heredoc token: {heredoc}")
heredoc = _strip_closing_marker_line(match.group(2))
heredoc = heredoc.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
if options.strip_string_quotes:
# The caller asked for the value, so hand back the body as-is:
# real newlines, no escaping. The escaping below exists only to
# build the quoted-string *source* form returned otherwise.
return heredoc
heredoc = heredoc.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
return f'"{heredoc}"'

result = heredoc.rstrip(self._trim_chars)
Expand Down Expand Up @@ -229,10 +232,13 @@ def serialize(self, options=SerializationOptions(), context=SerializationContext
if not options.preserve_heredocs:
lines = [line.replace("\\", "\\\\").replace('"', '\\"') for line in lines]

if options.strip_string_quotes:
# Value, not source: join with real newlines regardless of
# preserve_heredocs, and skip the escaping done for the quoted form.
return "\n".join(lines)

sep = "\\n" if not options.preserve_heredocs else "\n"
inner = sep.join(lines)
if options.strip_string_quotes:
return inner
return '"' + inner + '"'


Expand Down
20 changes: 18 additions & 2 deletions test/unit/rules/test_strings.py
Original file line number Diff line number Diff line change
Expand Up @@ -280,10 +280,18 @@ def test_serialize_strip_string_quotes_preserve(self):
self.assertEqual(rule.serialize(opts), "<<EOF\nhello world\nEOF")

def test_serialize_strip_string_quotes_no_preserve(self):
"""Asking for the value yields real newlines, not escaped ones."""
token = HEREDOC_TEMPLATE("<<EOF\nline1\nline2\nEOF")
rule = HeredocTemplateRule([token])
opts = SerializationOptions(preserve_heredocs=False, strip_string_quotes=True)
self.assertEqual(rule.serialize(opts), "line1\\nline2")
self.assertEqual(rule.serialize(opts), "line1\nline2")

def test_serialize_no_preserve_keeps_escaping_for_the_quoted_form(self):
"""Without strip_string_quotes the result is source, so escapes stay."""
token = HEREDOC_TEMPLATE("<<EOF\nline1\nline2\nEOF")
rule = HeredocTemplateRule([token])
opts = SerializationOptions(preserve_heredocs=False)
self.assertEqual(rule.serialize(opts), '"line1\\nline2"')


# --- HeredocTrimTemplateRule tests ---
Expand Down Expand Up @@ -366,7 +374,15 @@ def test_serialize_strip_string_quotes_preserve(self):
self.assertEqual(rule.serialize(opts), "<<-EOF\n line1\n line2\nEOF")

def test_serialize_strip_string_quotes_no_preserve(self):
"""Asking for the value yields real newlines, not escaped ones."""
token = HEREDOC_TRIM_TEMPLATE("<<-EOF\n line1\n line2\nEOF")
rule = HeredocTrimTemplateRule([token])
opts = SerializationOptions(preserve_heredocs=False, strip_string_quotes=True)
self.assertEqual(rule.serialize(opts), "line1\\nline2")
self.assertEqual(rule.serialize(opts), "line1\nline2")

def test_serialize_no_preserve_keeps_escaping_for_the_quoted_form(self):
"""Without strip_string_quotes the result is source, so escapes stay."""
token = HEREDOC_TRIM_TEMPLATE("<<-EOF\n line1\n line2\nEOF")
rule = HeredocTrimTemplateRule([token])
opts = SerializationOptions(preserve_heredocs=False)
self.assertEqual(rule.serialize(opts), '"line1\\nline2"')
56 changes: 56 additions & 0 deletions test/unit/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -534,3 +534,59 @@ def test_escaped_interpolation_marker_is_left_alone(self):
def test_default_options_still_preserve_source_form(self):
"""Without the option, the source form is kept for reconstruction."""
self.assertEqual(loads(r'a = "line1\nline2"' + "\n"), {"a": r'"line1\nline2"'})


class TestHeredocFlattenedToValue(TestCase):
"""`strip_string_quotes` + `preserve_heredocs=False` yields real newlines.

The flatten path escapes the body to build a quoted-string *source* form
(`'"a\\nb"'`). That escaping ran before the `strip_string_quotes` early
return, so asking for the value handed back escaped source instead: every
line break arrived as a literal backslash-n. Before this fix no combination
of options reproduced v7's plain multi-line string.
"""

_VALUE = SerializationOptions(strip_string_quotes=True, preserve_heredocs=False)
_SOURCE = SerializationOptions(preserve_heredocs=False)

def test_value_has_real_newlines(self):
result = loads("a = <<EOT\nline1\nline2\nEOT\n", serialization_options=self._VALUE)
self.assertEqual(result, {"a": "line1\nline2"})

def test_trimmed_value_has_real_newlines(self):
source = "a = <<-EOT\n line1\n line2\n EOT\n"
result = loads(source, serialization_options=self._VALUE)
self.assertEqual(result, {"a": "line1\nline2"})

def test_multiline_secret_survives_intact(self):
"""The reported case: a heredoc-defined key block must stay multi-line."""
source = (
"keys = {\n"
" private = <<-EOT\n"
" -----BEGIN PGP PRIVATE KEY BLOCK-----\n"
" line1\n"
" -----END PGP PRIVATE KEY BLOCK-----\n"
" EOT\n"
"}\n"
)
value = loads(source, serialization_options=self._VALUE)["keys"]["private"]
self.assertEqual(value.count("\n"), 2)
self.assertNotIn("\\n", value)
self.assertTrue(value.startswith("-----BEGIN"))
self.assertTrue(value.endswith("KEY BLOCK-----"))

def test_quoted_form_still_escapes(self):
"""Without strip_string_quotes the result is source and must escape."""
result = loads("a = <<EOT\nline1\nline2\nEOT\n", serialization_options=self._SOURCE)
self.assertEqual(result, {"a": '"line1\\nline2"'})

def test_embedded_quote_and_backslash_only_escaped_in_source_form(self):
source = 'a = <<EOT\nsay "hi"\nback\\slash\nEOT\n'
self.assertEqual(
loads(source, serialization_options=self._VALUE),
{"a": 'say "hi"\nback\\slash'},
)
self.assertEqual(
loads(source, serialization_options=self._SOURCE),
{"a": '"say \\"hi\\"\\nback\\\\slash"'},
)
Loading