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
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.

## \[Unreleased\]

- Nothing yet.
### 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.3\] - 2026-08-26

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