Skip to content
16 changes: 11 additions & 5 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.

## \[Unreleased\]

### Added

- Python 3.14 is now tested and declared as supported. No source changes were needed; the full
suite passes on 3.14 as-is.
- `BlockView.start_line` and `BlockView.end_line`, so a block's span can be read from the query API without serializing it. `with_meta` puts the numbers in the output dict, which meant reaching them through the label nesting, or through the rule's private `_meta`. Both are `None` for a tree built by the deserializer, which carries no positions. `hq` picks them up through its property accessors: `hq 'resource[*] | .start_line' main.tf`. Thanks, @livingstaccato ([#333](https://github.com/amplify-education/python-hcl2/pull/333))

### Fixed

- `with_meta` emits `__start_line__` and `__end_line__` again. The option, the `hcl2tojson --with-meta` flag and the migration guide's promise that the v7 keys are "still available" all survived the v8 rewrite; the code that produced the keys did not, leaving the option read nowhere in the package. Blocks carry the same spans 7.3.1 produced for the same input; attributes carry none, as in v7. Thanks, @livingstaccato ([#333](https://github.com/amplify-education/python-hcl2/pull/333))
- The deserializer reads `__start_line__` and `__end_line__` as metadata only where `with_meta` writes them: together, as integers, on a block's body. An attribute of either name anywhere else still survives `dumps(loads(...))`, as it did before. A block that declares both with integer values cannot be told apart from the metadata and loses them; [#331](https://github.com/amplify-education/python-hcl2/issues/331) tracks moving the keys out of band.

### Changed

- **Breaking for direct `cli.*` imports.** The CLI modules moved from a top-level `cli` package
Expand All @@ -19,11 +30,6 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
because a shim would still occupy the colliding name.
- The redundant `cli/py.typed` marker is gone; `hcl2/py.typed` already covers `hcl2.cli`.

### Added

- Python 3.14 is now tested and declared as supported. No source changes were needed; the full
suite passes on 3.14 as-is.

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

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ The **Direct** pipeline (`parse_to_tree` → `transform` → `to_lark` → `reco
| `hcl2/builder.py` | Programmatic HCL document construction |
| `hcl2/walk.py` | Generic tree-walking primitives for the LarkElement IR tree |
| `hcl2/utils.py` | `SerializationOptions`, `SerializationContext`, string helpers |
| `hcl2/const.py` | Constants: `IS_BLOCK`, `COMMENTS_KEY`, `INLINE_COMMENTS_KEY` |
| `hcl2/const.py` | Constants: `IS_BLOCK`, `COMMENTS_KEY`, `INLINE_COMMENTS_KEY`, `START_LINE`, `END_LINE` |
| `hcl2/cli/helpers.py` | File/directory/stdin conversion helpers |
| `hcl2/cli/hcl_to_json.py` | `hcl2tojson` entry point |
| `hcl2/cli/json_to_hcl.py` | `jsontohcl2` entry point |
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 @@ -70,7 +70,7 @@ data = loads(text, serialization_options=SerializationOptions(
| Field | Type | Default | Description |
|---|---|---|-------------------------------------------------------------------------------------------------------------------------------------------------|
| `with_comments` | `bool` | `True` | Include comments as `__comments__` and `__inline_comments__` keys (see [Comment Format](#comment-format)) |
| `with_meta` | `bool` | `False` | Add `__start_line__` / `__end_line__` metadata |
| `with_meta` | `bool` | `False` | Add `__start_line__` / `__end_line__` metadata to each block, alongside its attributes. Attributes carry no metadata of their own. |
| `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.** |
Expand Down
4 changes: 4 additions & 0 deletions docs/02_querying.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ block.block_type # "resource"
block.labels # ["resource", "aws_instance", "main"]
block.name_labels # ["aws_instance", "main"]
block.body # BodyView
block.start_line # 1
block.end_line # 12
```

| Property / Method | Returns | Description |
Expand All @@ -64,6 +66,8 @@ block.body # BodyView
| `labels` | `List[str]` | All labels as plain strings |
| `name_labels` | `List[str]` | Labels after the block type (`labels[1:]`) |
| `body` | `BodyView` | The block body |
| `start_line` | `int \| None` | Line the block opens on; `None` for a tree with no positions |
| `end_line` | `int \| None` | Line the block closes on; `None` for a tree with no positions |
| `blocks(...)` | `List[BlockView]` | Nested blocks (delegates to body) |
| `attributes(...)` | `List[AttributeView]` | Nested attributes (delegates to body) |
| `attribute(name)` | `AttributeView \| None` | Single nested attribute |
Expand Down
2 changes: 1 addition & 1 deletion docs/04_hq.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ hq 'x | length' file.tf --value

| View Type | Available Properties |
|---|---|
| `BlockView` | `.block_type` (e.g. `"resource"`), `.labels` (all labels including type), `.name_labels` (labels after the block type, e.g. `["aws_instance", "main"]`) |
| `BlockView` | `.block_type` (e.g. `"resource"`), `.labels` (all labels including type), `.name_labels` (labels after the block type, e.g. `["aws_instance", "main"]`), `.start_line` / `.end_line` (the block's span in the file) |
| `AttributeView` | `.name` (attribute name), `.value` (serialized value) |
| `FunctionCallView` | `.name` (function name), `.args` (argument list), `.has_ellipsis` |
| `ForTupleView` | `.iterator_name`, `.second_iterator_name`, `.iterable`, `.value_expr`, `.has_condition`, `.condition` |
Expand Down
2 changes: 2 additions & 0 deletions hcl2/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@
IS_BLOCK = "__is_block__"
COMMENTS_KEY = "__comments__"
INLINE_COMMENTS_KEY = "__inline_comments__"
START_LINE = "__start_line__"
END_LINE = "__end_line__"
20 changes: 18 additions & 2 deletions hcl2/deserializer.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

from regex import regex

from hcl2.const import COMMENTS_KEY, INLINE_COMMENTS_KEY, IS_BLOCK
from hcl2.const import COMMENTS_KEY, END_LINE, INLINE_COMMENTS_KEY, IS_BLOCK, START_LINE
from hcl2.parser import parser as _get_parser
from hcl2.rules.abstract import LarkElement, LarkRule
from hcl2.rules.base import (
Expand Down Expand Up @@ -136,6 +136,7 @@ def _deserialize(self, value: Any) -> LarkElement:

def _deserialize_block_elements(self, value: dict) -> List[LarkElement]:
children: List[LarkElement] = []
line_keys = (START_LINE, END_LINE)
for key, val in value.items():
if self._is_block(val):
# this value is a list of blocks, iterate over each block and deserialize them
Expand All @@ -144,7 +145,7 @@ def _deserialize_block_elements(self, value: dict) -> List[LarkElement]:

else:
# otherwise it's just an attribute
if not self._is_reserved_key(key):
if not self._is_reserved_key(key) and not (key in line_keys and self._is_line_meta(value)):
children.append(self._deserialize_attribute(key, val))

return children
Expand Down Expand Up @@ -371,6 +372,21 @@ def _is_reserved_key(self, key: str) -> bool:
"""Check if a key is a reserved metadata key that should be skipped during deserialization."""
return key in (IS_BLOCK, COMMENTS_KEY, INLINE_COMMENTS_KEY)

@staticmethod
def _is_line_meta(body: dict) -> bool:
"""Whether *body* carries the line span `with_meta` writes.

The keys travel in-band, beside the block's attributes, so an attribute
of either name cannot be told apart from them. `with_meta` only ever
writes both, as integers, on a block's body; reading the keys as
metadata anywhere else would drop attributes that nothing reserved
before the option produced them.
"""
if not body.get(IS_BLOCK):
return False
values = (body.get(START_LINE), body.get(END_LINE))
return all(isinstance(v, int) and not isinstance(v, bool) for v in values)

def _is_expression(self, value: Any) -> bool:
return isinstance(value, str) and value.startswith("${") and value.endswith("}")

Expand Down
29 changes: 29 additions & 0 deletions hcl2/query/blocks.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,19 @@
from hcl2.utils import SerializationOptions


def _meta_line(node: BlockRule, attribute: str) -> Optional[int]:
"""Read a line number off a block's lark ``Meta``, or None when it has none.

A tree built by the deserializer rather than the parser carries an empty
``Meta``, whose line attributes do not exist at all.
"""
meta = node._meta # pylint: disable=protected-access
if meta.empty:
return None
line: int = getattr(meta, attribute)
return line


def _label_to_str(label) -> str:
"""Convert a block label (IdentifierRule or StringRule) to a plain string."""
if isinstance(label, IdentifierRule):
Expand Down Expand Up @@ -53,6 +66,22 @@ def name_labels(self) -> List[str]:
"""Return labels after the block type (labels[1:]) as plain strings."""
return self.labels[1:]

@property
def start_line(self) -> Optional[int]:
"""Return the line the block opens on, or None if it has no position.

The same number ``with_meta`` reports as ``__start_line__``, without
serializing the block to get at it.
"""
node: BlockRule = self._node # type: ignore[assignment]
return _meta_line(node, "line")

@property
def end_line(self) -> Optional[int]:
"""Return the line the block closes on, or None if it has no position."""
node: BlockRule = self._node # type: ignore[assignment]
return _meta_line(node, "end_line")

@property
def body(self) -> "NodeView":
"""Return the block body as a BodyView."""
Expand Down
9 changes: 8 additions & 1 deletion hcl2/rules/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

from lark.tree import Meta

from hcl2.const import INLINE_COMMENTS_KEY, IS_BLOCK
from hcl2.const import END_LINE, INLINE_COMMENTS_KEY, IS_BLOCK, START_LINE
from hcl2.rules.abstract import LarkRule, LarkToken
from hcl2.rules.expressions import ExprTermRule
from hcl2.rules.literal_rules import IdentifierRule
Expand Down Expand Up @@ -152,6 +152,13 @@ def serialize(self, options=SerializationOptions(), context=SerializationContext
result = self._body.serialize(options)
if options.explicit_blocks:
result.update({IS_BLOCK: True})
if options.with_meta:
# Alongside the body, not wrapping it: the keys land on the same
# innermost dict the labels nest around, which is where v7 put them.
# A tree built by the deserializer carries no positions, so an empty
# Meta means "no line numbers to report" rather than line zero.
if not self._meta.empty:
result.update({START_LINE: self._meta.line, END_LINE: self._meta.end_line})

labels = self._labels
for label in reversed(labels[1:]):
Expand Down
4 changes: 3 additions & 1 deletion hcl2/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ class SerializationOptions:

# Include __comments__ and __inline_comments__ keys in the output.
with_comments: bool = True
# Add __start_line__ and __end_line__ metadata to each block/attribute.
# Add __start_line__ and __end_line__ metadata to each block. Attributes get
# none: an attribute serializes to its own {name: value} pair, which has
# nowhere to hang the keys without changing the shape of the value.
with_meta: bool = False
# Serialize nested objects as inline HCL strings (e.g. "${{key = value}}")
# instead of Python dicts.
Expand Down
3 changes: 3 additions & 0 deletions test/unit/cli/test_hcl_to_json.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,9 @@ def test_with_meta_flag(self):

result = json.loads(stdout.getvalue())
self.assertIn("resource", result)
body = result["resource"][0]['"a"']['"b"']
self.assertEqual(body["__start_line__"], 1)
self.assertEqual(body["__end_line__"], 3)

def test_no_comments_flag(self):
hcl_with_comment = "# a comment\nx = 1\n"
Expand Down
40 changes: 40 additions & 0 deletions test/unit/query/test_blocks.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,3 +118,43 @@ def test_no_adjacent_comments(self):
block = doc.blocks("resource")[0]
result = block.to_dict(options=self._OPTS)
self.assertNotIn("__comments__", result)


class TestBlockViewLines(TestCase):
"""`start_line` / `end_line` report the span `with_meta` serializes.

The line numbers were reachable only by serializing the block with
`with_meta=True` and digging past the label nesting, or by reading the
rule's private `_meta`. Both are what these properties replace.
"""

NESTED = 'resource "aws_instance" "web" {\n ami = "ami-1"\n\n network_interface {\n x = 0\n }\n}\n'

def test_span_of_a_top_level_block(self):
block = DocumentView.parse(self.NESTED).blocks("resource")[0]
self.assertEqual((block.start_line, block.end_line), (1, 7))

def test_span_of_a_nested_block(self):
block = DocumentView.parse(self.NESTED).blocks("resource")[0]
nested = block.blocks("network_interface")[0]
self.assertEqual((nested.start_line, nested.end_line), (4, 6))

def test_an_empty_block_spans_one_line(self):
block = DocumentView.parse('variable "x" {}\n').blocks("variable")[0]
self.assertEqual((block.start_line, block.end_line), (1, 1))

def test_agrees_with_with_meta(self):
block = DocumentView.parse(self.NESTED).blocks("resource")[0]
body = block.to_dict(options=SerializationOptions(with_meta=True))['"aws_instance"']['"web"']
self.assertEqual(block.start_line, body["__start_line__"])
self.assertEqual(block.end_line, body["__end_line__"])

def test_a_block_without_a_position_reports_none(self):
# A tree built by the deserializer carries an empty Meta.
from hcl2.api import from_dict
from hcl2.query.body import DocumentView as Doc

tree = from_dict({"resource": [{"aws_instance": {"web": {"__is_block__": True}}}]})
block = Doc(tree).blocks("resource")[0]
self.assertIsNone(block.start_line)
self.assertIsNone(block.end_line)
7 changes: 5 additions & 2 deletions test/unit/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,11 @@ def test_with_serialization_options(self):
def test_with_meta_option(self):
result = loads(BLOCK_HCL, serialization_options=SerializationOptions(with_meta=True))
self.assertIn("resource", result)
# Verify the option is accepted and produces a dict with expected content
self.assertIsInstance(result, dict)
# Assert on the metadata itself, not just that the option is accepted:
# this test passed throughout #291, when the option emitted nothing.
body = result["resource"][0]['"aws_instance"']['"example"']
self.assertEqual(body["__start_line__"], 1)
self.assertEqual(body["__end_line__"], 3)

def test_block_parsing(self):
result = loads(BLOCK_HCL)
Expand Down
Loading