Skip to content
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
- Python 3.14 is now tested and declared as supported. No source changes were needed; the full
suite passes on 3.14 as-is.

### Fixed

- Concurrent calls to `load`/`loads` no longer corrupt each other's values. `serialize()` declared `context=SerializationContext()` as a default argument, which Python evaluates once at import, so every rule in the process shared one mutable context — and the expression, function and indexing rules mutated it in place via `context.modify(inside_dollar_string=True)`. A thread serializing a function call therefore set that flag for every other thread, and a tuple or object being serialized elsewhere came back as its inline HCL source (`'[1, 2, 3]'`) rather than a list, with no exception raised. Measured before the fix: 400 of 400 interleaved parses corrupted. Every other rule's `serialize` carried the same default and is changed with them: the public API always enters at `StartRule`, so those were unreachable with a shared context, but a caller serializing a rule directly still had one. Both parameters are annotated `Optional[...]`, so a rule that takes `None` and then dereferences it without constructing one is a mypy error rather than an `AttributeError` for the direct caller this protects. `options` loses its shared default too. Nothing in the package assigns to a `SerializationOptions`, so that one was not a live defect, but it is the same construct in the same position -- one mutable object handed to every caller that omits the argument, reachable by any subclass or hook a consumer writes.
- `SerializationContext` is immutable, so a context a caller builds and hands to concurrent calls is safe to share. Removing the shared default fixed only the contexts the package creates for itself; `modify` was a context manager that set fields on whatever context it was given and restored them on exit, so a consumer passing one object to several threads still had several writers and the same silent corruption, with nothing to warn them. A traversal now descends by building a child with `replace`, the dataclass is `frozen=True`, and `modify` is gone -- an assignment that used to be a temporary mutation is a `FrozenInstanceError` where it is written. Racing two threads does not demonstrate the old defect, since `modify` restored the field within one call; the regression test reads the caller's own context from inside the scope that used to mutate it, and sees `True` there before this change. ([#344](https://github.com/amplify-education/python-hcl2/issues/344))

## \[8.1.4\] - 2026-09-08

### Fixed
Expand Down
12 changes: 9 additions & 3 deletions hcl2/rules/abstract.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@ def to_lark(self) -> Any:
raise NotImplementedError()

@abstractmethod
def serialize(self, options=SerializationOptions(), context=SerializationContext()) -> Any:
def serialize(
self, options: Optional[SerializationOptions] = None, context: Optional[SerializationContext] = None
) -> Any:
"""Serialize this element to a Python object (dict, list, str, etc.)."""
raise NotImplementedError()

Expand All @@ -63,7 +65,9 @@ def set_value(self, value: Any):
"""Set the raw value of this token."""
self._value = value

def serialize(self, options=SerializationOptions(), context=SerializationContext()) -> Any:
def serialize(
self, options: Optional[SerializationOptions] = None, context: Optional[SerializationContext] = None
) -> Any:
"""Serialize this token using its serialize_conversion callable."""
return self.serialize_conversion(self.value)

Expand All @@ -89,7 +93,9 @@ class LarkRule(LarkElement, ABC):
"""

@abstractmethod
def serialize(self, options=SerializationOptions(), context=SerializationContext()) -> Any:
def serialize(
self, options: Optional[SerializationOptions] = None, context: Optional[SerializationContext] = None
) -> Any:
"""Serialize this rule and its children to a Python object."""
raise NotImplementedError()

Expand Down
40 changes: 28 additions & 12 deletions hcl2/rules/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,13 @@ def expression(self) -> ExprTermRule:
"""Return the attribute value expression."""
return self._children[2]

def serialize(self, options=SerializationOptions(), context=SerializationContext()) -> Any:
def serialize(
self, options: Optional[SerializationOptions] = None, context: Optional[SerializationContext] = None
) -> Any:
"""Serialize to a single-entry dict."""
return {self.identifier.serialize(options): self.expression.serialize(options)}
options = options if options is not None else SerializationOptions()
context = context if context is not None else SerializationContext()
return {self.identifier.serialize(options, context): self.expression.serialize(options, context)}


class BodyRule(LarkRule):
Expand All @@ -60,8 +64,12 @@ def lark_name() -> str:
"""Return the grammar rule name."""
return "body"

def serialize(self, options=SerializationOptions(), context=SerializationContext()) -> Any:
def serialize(
self, options: Optional[SerializationOptions] = None, context: Optional[SerializationContext] = None
) -> Any:
"""Serialize to a dict, grouping blocks under their type name."""
options = options if options is not None else SerializationOptions()
context = context if context is not None else SerializationContext()
attribute_names = set()
comments = []
inline_comments = []
Expand All @@ -70,14 +78,14 @@ def serialize(self, options=SerializationOptions(), context=SerializationContext

for child in self._children:
if isinstance(child, BlockRule):
name = child.labels[0].serialize(options)
name = child.labels[0].serialize(options, context)
if name in attribute_names:
raise RuntimeError(f"Attribute {name} is already defined.")
result[name].append(child.serialize(options))
result[name].append(child.serialize(options, context))

if isinstance(child, AttributeRule):
attribute_names.add(child.identifier.serialize(options))
result.update(child.serialize(options))
attribute_names.add(child.identifier.serialize(options, context))
result.update(child.serialize(options, context))
if options.with_comments:
inline_comments.extend(child.expression.inline_comments())
comments.extend(child.expression.absorbed_comments())
Expand Down Expand Up @@ -111,9 +119,13 @@ def lark_name() -> str:
"""Return the grammar rule name."""
return "start"

def serialize(self, options=SerializationOptions(), context=SerializationContext()) -> Any:
def serialize(
self, options: Optional[SerializationOptions] = None, context: Optional[SerializationContext] = None
) -> Any:
"""Serialize by delegating to the body."""
return self.body.serialize(options)
options = options if options is not None else SerializationOptions()
context = context if context is not None else SerializationContext()
return self.body.serialize(options, context)


class BlockRule(LarkRule):
Expand Down Expand Up @@ -147,14 +159,18 @@ def body(self) -> BodyRule:
"""Return the block body."""
return self._body

def serialize(self, options=SerializationOptions(), context=SerializationContext()) -> Any:
def serialize(
self, options: Optional[SerializationOptions] = None, context: Optional[SerializationContext] = None
) -> Any:
"""Serialize to a nested dict with labels as keys."""
result = self._body.serialize(options)
options = options if options is not None else SerializationOptions()
context = context if context is not None else SerializationContext()
result = self._body.serialize(options, context)
if options.explicit_blocks:
result.update({IS_BLOCK: True})

labels = self._labels
for label in reversed(labels[1:]):
result = {label.serialize(options): result}
result = {label.serialize(options, context): result}

return result
58 changes: 38 additions & 20 deletions hcl2/rules/containers.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,15 +60,19 @@ def elements(self) -> List[ExpressionRule]:
"""Return the expression elements of the tuple."""
return [child for child in self.children[1:-1] if isinstance(child, ExpressionRule)]

def serialize(self, options=SerializationOptions(), context=SerializationContext()) -> Any:
def serialize(
self, options: Optional[SerializationOptions] = None, context: Optional[SerializationContext] = None
) -> Any:
"""Serialize to a Python list or bracketed string."""
options = options if options is not None else SerializationOptions()
context = context if context is not None else SerializationContext()
if not options.wrap_tuples and not context.inside_dollar_string:
return [element.serialize(options, context) for element in self.elements]

with context.modify(inside_dollar_string=True):
result = "["
result += ", ".join(str(element.serialize(options, context)) for element in self.elements)
result += "]"
inner = context.replace(inside_dollar_string=True)
result = "["
result += ", ".join(str(element.serialize(options, inner)) for element in self.elements)
result += "]"

if not context.inside_dollar_string:
result = to_dollar_string(result)
Expand All @@ -93,8 +97,12 @@ def value(self) -> key_T:
"""Return the key value (identifier, string, or number)."""
return self._children[0]

def serialize(self, options=SerializationOptions(), context=SerializationContext()) -> Any:
def serialize(
self, options: Optional[SerializationOptions] = None, context: Optional[SerializationContext] = None
) -> Any:
"""Serialize the key, coercing numbers to strings."""
options = options if options is not None else SerializationOptions()
context = context if context is not None else SerializationContext()
result = self.value.serialize(options, context)
# Object keys must be strings for JSON compatibility
if isinstance(result, (int, float)):
Expand Down Expand Up @@ -123,10 +131,14 @@ def expression(self) -> ExpressionRule:
"""Return the key expression."""
return self._children[0]

def serialize(self, options=SerializationOptions(), context=SerializationContext()) -> Any:
def serialize(
self, options: Optional[SerializationOptions] = None, context: Optional[SerializationContext] = None
) -> Any:
"""Serialize to '${expression}' string."""
with context.modify(inside_dollar_string=True):
result = str(self.expression.serialize(options, context))
options = options if options is not None else SerializationOptions()
context = context if context is not None else SerializationContext()
inner = context.replace(inside_dollar_string=True)
result = str(self.expression.serialize(options, inner))
if not context.inside_dollar_string:
result = to_dollar_string(result)
return result
Expand Down Expand Up @@ -156,8 +168,12 @@ def expression(self):
"""Return the value expression."""
return self._children[2]

def serialize(self, options=SerializationOptions(), context=SerializationContext()) -> Any:
def serialize(
self, options: Optional[SerializationOptions] = None, context: Optional[SerializationContext] = None
) -> Any:
"""Serialize to a single-entry dict."""
options = options if options is not None else SerializationOptions()
context = context if context is not None else SerializationContext()
return {self.key.serialize(options, context): self.expression.serialize(options, context)}


Expand Down Expand Up @@ -186,23 +202,25 @@ def elements(self) -> List[ObjectElemRule]:
"""Return the list of object element rules."""
return [child for child in self.children[1:-1] if isinstance(child, ObjectElemRule)]

def serialize(self, options=SerializationOptions(), context=SerializationContext()) -> Any:
def serialize(
self, options: Optional[SerializationOptions] = None, context: Optional[SerializationContext] = None
) -> Any:
"""Serialize to a Python dict or braced string."""
options = options if options is not None else SerializationOptions()
context = context if context is not None else SerializationContext()
if not options.wrap_objects and not context.inside_dollar_string:
dict_result: dict = {}
for element in self.elements:
dict_result.update(element.serialize(options, context))
return dict_result

with context.modify(inside_dollar_string=True):
str_result = "{"
str_result += ", ".join(
f"{element.key.serialize(options, context)}"
f" = "
f"{element.expression.serialize(options, context)}"
for element in self.elements
)
str_result += "}"
inner = context.replace(inside_dollar_string=True)
str_result = "{"
str_result += ", ".join(
f"{element.key.serialize(options, inner)} = {element.expression.serialize(options, inner)}"
for element in self.elements
)
str_result += "}"

if not context.inside_dollar_string:
str_result = to_dollar_string(str_result)
Expand Down
50 changes: 36 additions & 14 deletions hcl2/rules/directives.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,10 +86,14 @@ def strip_close(self) -> bool:
"""Check if there's a strip marker before }."""
return self._children[4] is not None

def serialize(self, options=SerializationOptions(), context=SerializationContext()) -> Any:
def serialize(
self, options: Optional[SerializationOptions] = None, context: Optional[SerializationContext] = None
) -> Any:
"""Serialize to %{ if EXPR } or %{~ if EXPR ~}."""
with context.modify(inside_dollar_string=True):
cond_str = self.condition.serialize(options, context)
options = options if options is not None else SerializationOptions()
context = context if context is not None else SerializationContext()
inner = context.replace(inside_dollar_string=True)
cond_str = self.condition.serialize(options, inner)
prefix = _strip_prefix(self.strip_open)
suffix = _strip_suffix(self.strip_close)
return f"%{{{prefix}if {cond_str}{suffix}}}"
Expand Down Expand Up @@ -125,7 +129,9 @@ def strip_close(self) -> bool:
"""Check if there's a strip marker before }."""
return self._children[3] is not None

def serialize(self, options=SerializationOptions(), context=SerializationContext()) -> Any:
def serialize(
self, options: Optional[SerializationOptions] = None, context: Optional[SerializationContext] = None
) -> Any:
"""Serialize to %{ else } or %{~ else ~}."""
prefix = _strip_prefix(self.strip_open)
suffix = _strip_suffix(self.strip_close)
Expand Down Expand Up @@ -162,7 +168,9 @@ def strip_close(self) -> bool:
"""Check if there's a strip marker before }."""
return self._children[3] is not None

def serialize(self, options=SerializationOptions(), context=SerializationContext()) -> Any:
def serialize(
self, options: Optional[SerializationOptions] = None, context: Optional[SerializationContext] = None
) -> Any:
"""Serialize to %{ endif } or %{~ endif ~}."""
prefix = _strip_prefix(self.strip_open)
suffix = _strip_suffix(self.strip_close)
Expand Down Expand Up @@ -247,15 +255,19 @@ def collection(self) -> ExpressionRule:
"""Return the collection expression after IN."""
return self._children[7]

def serialize(self, options=SerializationOptions(), context=SerializationContext()) -> Any:
def serialize(
self, options: Optional[SerializationOptions] = None, context: Optional[SerializationContext] = None
) -> Any:
"""Serialize to %{ for VAR in EXPR } or %{~ for VAR in EXPR ~}."""
options = options if options is not None else SerializationOptions()
context = context if context is not None else SerializationContext()
prefix = _strip_prefix(self.strip_open)
suffix = _strip_suffix(self.strip_close)
with context.modify(inside_dollar_string=True):
iter_str = self.iterator.serialize(options, context)
if self.key_iterator is not None:
iter_str += f", {self.key_iterator.serialize(options, context)}"
coll_str = self.collection.serialize(options, context)
inner = context.replace(inside_dollar_string=True)
iter_str = self.iterator.serialize(options, inner)
if self.key_iterator is not None:
iter_str += f", {self.key_iterator.serialize(options, inner)}"
coll_str = self.collection.serialize(options, inner)
return f"%{{{prefix}for {iter_str} in {coll_str}{suffix}}}"


Expand Down Expand Up @@ -289,7 +301,9 @@ def strip_close(self) -> bool:
"""Check if there's a strip marker before }."""
return self._children[3] is not None

def serialize(self, options=SerializationOptions(), context=SerializationContext()) -> Any:
def serialize(
self, options: Optional[SerializationOptions] = None, context: Optional[SerializationContext] = None
) -> Any:
"""Serialize to %{ endfor } or %{~ endfor ~}."""
prefix = _strip_prefix(self.strip_open)
suffix = _strip_suffix(self.strip_close)
Expand Down Expand Up @@ -339,8 +353,12 @@ def __init__( # pylint: disable=R0917
children.append(endif)
super().__init__(children, meta)

def serialize(self, options=SerializationOptions(), context=SerializationContext()) -> Any:
def serialize(
self, options: Optional[SerializationOptions] = None, context: Optional[SerializationContext] = None
) -> Any:
"""Serialize the full if/else/endif directive."""
options = options if options is not None else SerializationOptions()
context = context if context is not None else SerializationContext()
result = self._if_start.serialize(options, context)
for part in self._if_body:
result += part.serialize(options, context)
Expand Down Expand Up @@ -395,8 +413,12 @@ def __init__(
children = [for_start, *body, endfor]
super().__init__(children, meta)

def serialize(self, options=SerializationOptions(), context=SerializationContext()) -> Any:
def serialize(
self, options: Optional[SerializationOptions] = None, context: Optional[SerializationContext] = None
) -> Any:
"""Serialize the full for/endfor directive."""
options = options if options is not None else SerializationOptions()
context = context if context is not None else SerializationContext()
result = self._for_start.serialize(options, context)
for part in self._body:
result += part.serialize(options, context)
Expand Down
Loading