diff --git a/mellea/stdlib/components/genstub.py b/mellea/stdlib/components/genstub.py index a071c910a..e82c40804 100644 --- a/mellea/stdlib/components/genstub.py +++ b/mellea/stdlib/components/genstub.py @@ -215,7 +215,7 @@ def describe_function(func: Callable) -> FunctionDict: """ return { "name": func.__name__, - "signature": str(inspect.signature(func)), + "signature": str(inspect.signature(func, eval_str=True)), "docstring": inspect.getdoc(func), } @@ -233,7 +233,7 @@ def get_argument(func: Callable, key: str, val: Any) -> Argument: Returns: Argument: an argument object representing the given parameter. """ - sig = inspect.signature(func) + sig = inspect.signature(func, eval_str=True) param = sig.parameters.get(key) if param and param.annotation is not inspect.Parameter.empty: param_type = param.annotation diff --git a/test/stdlib/components/_pep563_fixtures.py b/test/stdlib/components/_pep563_fixtures.py new file mode 100644 index 000000000..549ad8cb3 --- /dev/null +++ b/test/stdlib/components/_pep563_fixtures.py @@ -0,0 +1,29 @@ +# Copyright IBM Corp. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Fixtures with postponed annotations (PEP 563), used by test_genstub_unit.py. + +Kept in a separate module because `from __future__ import annotations` is a +module-level switch — isolating it here keeps the rest of the test suite on +normal (resolved) annotations. +""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass +class Requirement: + """A single extracted requirement.""" + + id: str + text: str + + +def extract_requirements(product_description: str) -> list[Requirement]: + """Extract requirements from a product description.""" + + +def greet(name: str) -> str: + """Say hello.""" diff --git a/test/stdlib/components/test_genstub_unit.py b/test/stdlib/components/test_genstub_unit.py index 718a35412..335f44444 100644 --- a/test/stdlib/components/test_genstub_unit.py +++ b/test/stdlib/components/test_genstub_unit.py @@ -26,6 +26,7 @@ get_argument, ) from mellea.stdlib.requirements.requirement import reqify +from test.stdlib.components._pep563_fixtures import extract_requirements, greet # --- describe_function --- @@ -64,6 +65,26 @@ def bare(): assert result["docstring"] is None +def test_describe_function_resolves_postponed_annotations(): + # Regression test: `from __future__ import annotations` made + # `describe_function` render literal annotation strings (e.g. + # "(product_description: 'str') -> 'list[Requirement]'") instead of the + # resolved types, corrupting the prompt sent to the model. + # Guard the precondition: if the fixture module ever drops its + # `from __future__ import annotations`, this test would otherwise keep + # passing without exercising postponed annotations at all. + assert extract_requirements.__annotations__["return"] == "list[Requirement]" + + result = describe_function(extract_requirements) + assert "'str'" not in result["signature"] + assert "'list[Requirement]'" not in result["signature"] + assert "product_description: str" in result["signature"] + assert ( + "list[test.stdlib.components._pep563_fixtures.Requirement]" + in result["signature"] + ) + + # --- get_argument --- @@ -85,6 +106,18 @@ def fn(count: int) -> None: assert "int" in str(arg._argument_dict["annotation"]) +def test_get_argument_string_value_quoted_under_postponed_annotations(): + # Regression test: under `from __future__ import annotations`, + # `param.annotation` was the literal string "str" rather than the `str` + # type, so the `is str` check failed and string arguments were rendered + # unquoted in the prompt. + # Guard the precondition: same reasoning as above. + assert greet.__annotations__["name"] == "str" + + arg = get_argument(greet, "name", "Alice") + assert arg._argument_dict["value"] == '"Alice"' + + def test_get_argument_no_annotation_falls_back_to_runtime_type(): # No annotation on kwargs — should fall back to type(val) def fn(**kwargs) -> None: