diff --git a/CHANGELOG.md b/CHANGELOG.md index 61dc143a..ad3e6564 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/hcl2/query/blocks.py b/hcl2/query/blocks.py index 269f2209..260de9ac 100644 --- a/hcl2/query/blocks.py +++ b/hcl2/query/blocks.py @@ -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 @@ -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) @@ -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 diff --git a/hcl2/query/body.py b/hcl2/query/body.py index b9f2ce54..a3415d17 100644 --- a/hcl2/query/body.py +++ b/hcl2/query/body.py @@ -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 @@ -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) @@ -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 @@ -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 @@ -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 diff --git a/test/unit/query/test_view_annotations.py b/test/unit/query/test_view_annotations.py new file mode 100644 index 00000000..fccaf648 --- /dev/null +++ b/test/unit/query/test_view_annotations.py @@ -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))