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
17 changes: 16 additions & 1 deletion src/marvin/utilities/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
TypeVar,
get_args,
get_origin,
get_type_hints,
)

from marvin.utilities.asyncio import run_sync
Expand Down Expand Up @@ -356,13 +357,27 @@ def from_function(
else:
raise

# `inspect.signature` returns string annotations when the module that
# defined `func` uses `from __future__ import annotations` (PEP 563).
# `typing.get_type_hints` resolves those forward references back to the
# actual types, so the downstream LLM call receives a real type object
# (e.g. `Recipe`) rather than the raw string `'Recipe'`.
# Fall back to `sig.return_annotation` if resolution fails (e.g. the
# type is not importable in the current scope).
# See https://github.com/PrefectHQ/marvin/issues/950
try:
hints = get_type_hints(func)
resolved_return = hints.get("return", sig.return_annotation)
except Exception:
resolved_return = sig.return_annotation

function_dict: dict[str, Any] = {
"function": func,
"signature": sig,
"name": name,
"docstring": inspect.cleandoc(docstring) if docstring else None,
"parameters": parameters,
"return_annotation": sig.return_annotation,
"return_annotation": resolved_return,
"source_code": source_code,
}

Expand Down
51 changes: 51 additions & 0 deletions tests/basic/utilities/test_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,3 +132,54 @@ def test_labels_indexed_labels(self):
1: "42",
2: "True",
}


class TestPythonFunctionFromFutureAnnotations:
"""Regression test for https://github.com/PrefectHQ/marvin/issues/950

When a module uses `from __future__ import annotations` (PEP 563), all
annotations are stored as strings rather than the actual types.
`PythonFunction.from_function` must resolve them with `typing.get_type_hints`
so that downstream LLM calls receive a real type, not the string 'Recipe'.
"""

def test_string_annotation_is_resolved_to_type(self):
"""A string return annotation must resolve to the actual type class."""
from pydantic import BaseModel
from marvin.utilities.types import PythonFunction

class Dish(BaseModel):
name: str

# Mimic what `from __future__ import annotations` does: the annotation
# stored on the function is a string, not the type object.
def make_dish(ingredients: list[str]) -> "Dish":
"""Return a dish made from the given ingredients."""

model = PythonFunction.from_function(make_dish)

# Before the fix: return_annotation was the string 'Dish'
# After the fix: it must be the actual Dish class
assert model.return_annotation is Dish, (
f"Expected return_annotation to be the Dish class, got {model.return_annotation!r}. "
"Hint: 'from __future__ import annotations' converts annotations to strings; "
"PythonFunction.from_function must resolve them via typing.get_type_hints()."
)

def test_non_string_annotation_still_works(self):
"""A normal (non-string) annotation must continue to work unchanged."""
from pydantic import BaseModel
from marvin.utilities.types import PythonFunction

class Ingredient(BaseModel):
name: str

# Normal annotation without PEP 563 — sig.return_annotation is already
# the class object.
def list_ingredients(query: str) -> list[Ingredient]:
"""List ingredients matching the query."""

model = PythonFunction.from_function(list_ingredients)
import typing
# The origin must be list (list[Ingredient])
assert typing.get_origin(model.return_annotation) is list
Loading