Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.

## \[Unreleased\]

- Nothing yet.
### Fixed

- `heredocs_to_strings` writes the heredoc's value rather than its own text. It was quoting the source -- markers and all -- so `<<EOT\nhello\nEOT` became `"<<EOT\nhello\nEOT"`, a quoted string spanning three physical lines. A quoted template cannot span lines, so OpenTofu rejects that with "Invalid multi-line string", and reading it back here gave the marker text rather than the value: neither a valid file nor the right content. It now reuses the flattening the reader already performs, so the two cannot drift. ([#337](https://github.com/amplify-education/python-hcl2/issues/337))

## \[8.1.3\] - 2026-08-26

Expand Down
32 changes: 25 additions & 7 deletions hcl2/deserializer.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@
IntLiteral,
)
from hcl2.transformer import RuleTransformer
from hcl2.utils import HEREDOC_PATTERN, HEREDOC_TRIM_PATTERN
from hcl2.utils import HEREDOC_PATTERN, HEREDOC_TRIM_PATTERN, SerializationOptions


@dataclass
Expand Down Expand Up @@ -168,15 +168,15 @@ def _deserialize_text(self, value: Any) -> LarkRule:

if isinstance(value, str):
if value.startswith('"') and value.endswith('"'):
if not self.options.heredocs_to_strings and value.startswith('"<<-'):
match = HEREDOC_TRIM_PATTERN.match(value[1:-1])
if match:
if value.startswith('"<<-') and HEREDOC_TRIM_PATTERN.match(value[1:-1]):
if not self.options.heredocs_to_strings:
return self._deserialize_heredoc(value[1:-1], True)
return self._deserialize_string(self._heredoc_as_quoted(value[1:-1], True))

if not self.options.heredocs_to_strings and value.startswith('"<<'):
match = HEREDOC_PATTERN.match(value[1:-1])
if match:
if value.startswith('"<<') and HEREDOC_PATTERN.match(value[1:-1]):
if not self.options.heredocs_to_strings:
return self._deserialize_heredoc(value[1:-1], False)
return self._deserialize_string(self._heredoc_as_quoted(value[1:-1], False))

if self.options.strings_to_heredocs:
inner = value[1:-1]
Expand Down Expand Up @@ -252,6 +252,24 @@ def _deserialize_string_part(self, value: str) -> StringPartRule:

return StringPartRule([STRING_CHARS(value)])

def _heredoc_as_quoted(self, heredoc: str, trim: bool) -> str:
"""Return the quoted-string source for *heredoc*'s value.

Not by quoting the heredoc's own text: that is what this option used to
do, and it produced `"<<EOT\nhello\nEOT"` -- a quoted string spanning
three physical lines, markers and all. A quoted template cannot span
lines, so OpenTofu rejects it with "Invalid multi-line string", and
reading it back here gave the marker text rather than the value.

The flattening the reader already performs is reused rather than
written a second time, so the two cannot drift: serializing the rule
with `preserve_heredocs=False` is exactly the quoted form
`preserve_heredocs=False` produces on the way in.
"""
rule = self._deserialize_heredoc(heredoc, trim)
quoted: str = rule.serialize(SerializationOptions(preserve_heredocs=False))
return quoted

def _deserialize_heredoc(
self, value: str, trim: bool
) -> Union[HeredocTemplateRule, HeredocTrimTemplateRule]:
Expand Down
71 changes: 71 additions & 0 deletions test/unit/test_heredocs_to_strings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# pylint: disable=C0103,C0114,C0115,C0116
r"""`heredocs_to_strings` writes a value, not the heredoc's own text (GH #337).

The option converts a heredoc into a quoted string. It was quoting the
heredoc's *source* -- markers and all -- across as many physical lines as the
original occupied:

a = "<<EOT
hello
EOT"

A quoted template cannot span lines, so OpenTofu rejects that with "Invalid
multi-line string", and reading it back here gave the marker text rather than
the value. Neither a valid file nor the right content.

The flattening the reader already performs is reused rather than written a
second time, so the two cannot drift.
"""

from unittest import TestCase

from hcl2.api import dumps, loads
from hcl2.deserializer import DeserializerOptions

STRINGS = DeserializerOptions(heredocs_to_strings=True)


class TestTheOutputIsAQuotedValue(TestCase):
def _convert(self, source: str) -> str:
return dumps(loads(source), deserializer_options=STRINGS)

def test_a_plain_heredoc(self):
self.assertEqual(self._convert("a = <<EOT\nhello\nEOT\n"), 'a = "hello"\n')

def test_a_trimmed_heredoc(self):
self.assertEqual(self._convert("a = <<-EOT\n indented\n EOT\n"), 'a = "indented"\n')

def test_quotes_in_the_body_are_escaped(self):
self.assertEqual(self._convert('a = <<EOT\nsay "hi"\nEOT\n'), 'a = "say \\"hi\\""\n')

def test_the_result_is_one_line(self):
for source in ("a = <<EOT\nhello\nEOT\n", "a = <<EOT\none\ntwo\nEOT\n"):
with self.subTest(source=source):
written = self._convert(source)
self.assertEqual(written.count("\n"), 1, written)

def test_the_result_parses_again(self):
for source in (
"a = <<EOT\nhello\nEOT\n",
'a = <<EOT\nsay "hi"\nEOT\n',
"a = <<EOT\none\ntwo\nEOT\n",
"a = <<-EOT\n indented\n EOT\n",
):
with self.subTest(source=source):
loads(self._convert(source))

def test_no_marker_survives_into_the_output(self):
written = self._convert("a = <<EOT\nhello\nEOT\n")
self.assertNotIn("EOT", written)
self.assertNotIn("<<", written)


class TestTheOptionOffIsUnchanged(TestCase):
def test_a_heredoc_stays_a_heredoc(self):
source = "a = <<EOT\nhello\nEOT\n"
self.assertEqual(dumps(loads(source)), source)

def test_a_plain_string_is_unaffected_either_way(self):
source = 'a = "hello"\n'
self.assertEqual(dumps(loads(source), deserializer_options=STRINGS), source)
self.assertEqual(dumps(loads(source)), source)