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.
### Changed

- `blocks()` and `attributes()` on the query views are annotated as returning `List[BlockView]` and `List[AttributeView]` rather than `List[NodeView]`, and `BlockView.body` as `BodyView`. Each only ever returns the concrete class; the wider annotation put `block_type`, `labels`, `name_labels` and `AttributeView.name` behind an `isinstance` narrowing or a cast for callers under a strict type checker. The view classes are now imported at module level rather than inside each method, so `typing.get_type_hints` can resolve the annotations the way any consumer reads them; the values returned are unchanged.

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

Expand Down
23 changes: 11 additions & 12 deletions hcl2/query/blocks.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

from hcl2.const import COMMENTS_KEY
from hcl2.query._base import NodeView, register_view
from hcl2.query.attributes import AttributeView
from hcl2.rules.abstract import LarkElement
from hcl2.rules.base import BlockRule
from hcl2.rules.literal_rules import IdentifierRule
Expand Down Expand Up @@ -54,10 +55,8 @@ def name_labels(self) -> List[str]:
return self.labels[1:]

@property
def body(self) -> "NodeView":
def body(self) -> "BodyView":
"""Return the block body as a BodyView."""
from hcl2.query.body import BodyView

node: BlockRule = self._node # type: ignore[assignment]
return BodyView(node.body)

Expand All @@ -76,23 +75,23 @@ def to_dict(self, options: Optional[SerializationOptions] = None) -> Any:
result[COMMENTS_KEY] = self._adjacent_comments + existing
return result

def blocks(self, block_type: Optional[str] = None, *labels: str) -> List["NodeView"]:
def blocks(self, block_type: Optional[str] = None, *labels: str) -> List["BlockView"]:
"""Delegate to body."""
from hcl2.query.body import BodyView

node: BlockRule = self._node # type: ignore[assignment]
return BodyView(node.body).blocks(block_type, *labels)

def attributes(self, name: Optional[str] = None) -> List["NodeView"]:
def attributes(self, name: Optional[str] = None) -> List["AttributeView"]:
"""Delegate to body."""
from hcl2.query.body import BodyView

node: BlockRule = self._node # type: ignore[assignment]
return BodyView(node.body).attributes(name)

def attribute(self, name: str) -> Optional["NodeView"]:
def attribute(self, name: str) -> Optional["AttributeView"]:
"""Delegate to body."""
from hcl2.query.body import BodyView

node: BlockRule = self._node # type: ignore[assignment]
return BodyView(node.body).attribute(name)


# See the note in `hcl2/query/body.py`: the two modules name each other in their
# annotations, and binding the name here rather than inside each method is what
# lets `typing.get_type_hints` resolve them.
from hcl2.query.body import BodyView # noqa: E402 pylint: disable=wrong-import-position,cyclic-import
29 changes: 17 additions & 12 deletions hcl2/query/body.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from typing import List, Optional

from hcl2.query._base import NodeView, register_view
from hcl2.query.attributes import AttributeView
from hcl2.rules.base import AttributeRule, BlockRule, BodyRule, StartRule
from hcl2.rules.whitespace import NewLineOrCommentRule

Expand Down Expand Up @@ -59,15 +60,15 @@ def body(self) -> "BodyView":
node: StartRule = self._node # type: ignore[assignment]
return BodyView(node.body)

def blocks(self, block_type: Optional[str] = None, *labels: str) -> List["NodeView"]:
def blocks(self, block_type: Optional[str] = None, *labels: str) -> List["BlockView"]:
"""Return matching blocks, delegating to body."""
return self.body.blocks(block_type, *labels)

def attributes(self, name: Optional[str] = None) -> List["NodeView"]:
def attributes(self, name: Optional[str] = None) -> List["AttributeView"]:
"""Return matching attributes, delegating to body."""
return self.body.attributes(name)

def attribute(self, name: str) -> Optional["NodeView"]:
def attribute(self, name: str) -> Optional["AttributeView"]:
"""Return a single attribute by name, or None."""
return self.body.attribute(name)

Expand All @@ -76,12 +77,10 @@ def attribute(self, name: str) -> Optional["NodeView"]:
class BodyView(NodeView):
"""View over an HCL2 body (BodyRule)."""

def blocks(self, block_type: Optional[str] = None, *labels: str) -> List["NodeView"]:
def blocks(self, block_type: Optional[str] = None, *labels: str) -> List["BlockView"]:
"""Return blocks, optionally filtered by type and labels."""
from hcl2.query.blocks import BlockView

node: BodyRule = self._node # type: ignore[assignment]
results: List[NodeView] = []
results: List["BlockView"] = []
for child in node.children:
if not isinstance(child, BlockRule):
continue
Expand All @@ -98,12 +97,10 @@ def blocks(self, block_type: Optional[str] = None, *labels: str) -> List["NodeVi
results.append(block_view)
return results

def attributes(self, name: Optional[str] = None) -> List["NodeView"]:
def attributes(self, name: Optional[str] = None) -> List["AttributeView"]:
"""Return attributes, optionally filtered by name."""
from hcl2.query.attributes import AttributeView

node: BodyRule = self._node # type: ignore[assignment]
results: List[NodeView] = []
results: List["AttributeView"] = []
for child in node.children:
if not isinstance(child, AttributeRule):
continue
Expand All @@ -114,7 +111,15 @@ def attributes(self, name: Optional[str] = None) -> List["NodeView"]:
results.append(attr_view)
return results

def attribute(self, name: str) -> Optional["NodeView"]:
def attribute(self, name: str) -> Optional["AttributeView"]:
"""Return a single attribute by name, or None."""
attrs = self.attributes(name)
return attrs[0] if attrs else None


# `BlockView` subclasses nothing here but names `BodyView` in its own annotations,
# so the two modules refer to each other. Importing at the bottom -- after both
# classes exist -- breaks the cycle while still binding the name in this module's
# globals, which is where `typing.get_type_hints` looks. Deferring it into the
# methods instead would leave the public annotations unresolvable to any caller.
from hcl2.query.blocks import BlockView # noqa: E402 pylint: disable=wrong-import-position,cyclic-import
120 changes: 120 additions & 0 deletions test/unit/query/test_view_annotations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
# pylint: disable=C0103,C0114,C0115,C0116
"""The query views' return annotations name the class they actually return.

`blocks()` only ever appends a `BlockView` and `attributes()` only ever an
`AttributeView`, but both were annotated `List[NodeView]`. Under a strict type
checker that put `block_type`, `labels`, `name_labels` and `AttributeView.name`
out of reach without an `isinstance` narrowing or a cast for a runtime type
that is never anything else.

These assert the annotations rather than the runtime types, because a runtime
check passes either way -- it is only the declaration that was wrong.

They resolve them the way a consumer does: a bare `get_type_hints`, with no
namespace supplied. Passing one would hide a name the annotation cannot reach
on its own, which is the failure mode a forward reference invites.
"""

from typing import List, Optional, get_type_hints
from unittest import TestCase

from hcl2.query import blocks as blocks_module
from hcl2.query import body as body_module
from hcl2.query.attributes import AttributeView
from hcl2.query.blocks import BlockView
from hcl2.query.body import BodyView, DocumentView


def _returns(method):
return get_type_hints(method)["return"]


class TestBodyViewAnnotations(TestCase):
def test_blocks_returns_block_views(self):
self.assertEqual(_returns(BodyView.blocks), List[BlockView])

def test_attributes_returns_attribute_views(self):
self.assertEqual(_returns(BodyView.attributes), List[AttributeView])

def test_attribute_returns_an_optional_attribute_view(self):
self.assertEqual(_returns(BodyView.attribute), Optional[AttributeView])


class TestDocumentViewAnnotations(TestCase):
"""The document-level methods delegate to the body and must not re-widen."""

def test_blocks_returns_block_views(self):
self.assertEqual(_returns(DocumentView.blocks), List[BlockView])

def test_attributes_returns_attribute_views(self):
self.assertEqual(_returns(DocumentView.attributes), List[AttributeView])

def test_attribute_returns_an_optional_attribute_view(self):
self.assertEqual(_returns(DocumentView.attribute), Optional[AttributeView])


class TestBlockViewAnnotations(TestCase):
def test_blocks_returns_block_views(self):
self.assertEqual(_returns(BlockView.blocks), List[BlockView])

def test_attributes_returns_attribute_views(self):
self.assertEqual(_returns(BlockView.attributes), List[AttributeView])

def test_attribute_returns_an_optional_attribute_view(self):
self.assertEqual(_returns(BlockView.attribute), Optional[AttributeView])

def test_body_returns_a_body_view(self):
self.assertEqual(_returns(BlockView.body.fget), BodyView)


class TestAnnotationsMatchRuntime(TestCase):
"""The declarations above are only worth having if they stay true."""

SOURCE = 'resource "aws_instance" "web" {\n ami = "ami-1"\n}\n'

def test_blocks_are_block_views(self):
doc = DocumentView.parse(self.SOURCE)
self.assertTrue(all(isinstance(block, BlockView) for block in doc.blocks()))

def test_attributes_are_attribute_views(self):
doc = DocumentView.parse(self.SOURCE)
block = doc.blocks("resource")[0]
self.assertTrue(all(isinstance(attr, AttributeView) for attr in block.attributes()))

def test_block_body_is_a_body_view(self):
doc = DocumentView.parse(self.SOURCE)
self.assertIsInstance(doc.blocks("resource")[0].body, BodyView)


class TestAnnotationsResolveUnaided(TestCase):
"""The names the annotations use have to live in the defining module.

`get_type_hints` reads a function's own globals. While the view classes were
imported inside the methods, every one of these annotations raised
`NameError` for anyone who introspected them -- pydantic, a documentation
builder, a runtime validator -- even though the classes were importable.
"""

def test_body_module_binds_block_view(self):
self.assertIs(body_module.BlockView, BlockView)

def test_blocks_module_binds_body_view(self):
self.assertIs(blocks_module.BodyView, BodyView)

def test_every_annotated_member_resolves_without_a_namespace(self):
members = [
BodyView.blocks,
BodyView.attributes,
BodyView.attribute,
DocumentView.blocks,
DocumentView.attributes,
DocumentView.attribute,
DocumentView.body.fget,
BlockView.blocks,
BlockView.attributes,
BlockView.attribute,
BlockView.body.fget,
]
for member in members:
with self.subTest(member=member.__qualname__):
self.assertIn("return", get_type_hints(member))