Skip to content

Commit 099982b

Browse files
authored
fix(dspy): render PEP 604 unions like typing.Union in get_annotation_name (#10575)
1 parent a7f448a commit 099982b

4 files changed

Lines changed: 33 additions & 3 deletions

File tree

‎dspy/adapters/utils.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,10 @@ def parse_value(value, annotation):
240240
def get_annotation_name(annotation):
241241
origin = get_origin(annotation)
242242
args = get_args(annotation)
243+
if origin is types.UnionType:
244+
# Render PEP 604 unions (e.g. `str | None`) the same way as `typing.Union`/`Optional` on Python < 3.14,
245+
# matching Python 3.14+ where `get_origin(str | None)` already returns `typing.Union`.
246+
origin = Union
243247
if origin is None:
244248
if annotation is Reasoning:
245249
# Keep backward compatibility with the old behavior in `dspy.ChainOfThought`, where reasoning

‎tests/adapters/test_adapter_utils.py‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import pytest
66
from pydantic import BaseModel
77

8-
from dspy.adapters.utils import parse_value
8+
from dspy.adapters.utils import get_annotation_name, parse_value
99

1010

1111
class Profile(BaseModel):
@@ -115,3 +115,18 @@ def test_parse_value_json_repair():
115115
malformed = "not json or literal"
116116
with pytest.raises(Exception):
117117
parse_value(malformed, dict)
118+
119+
120+
@pytest.mark.parametrize(
121+
"pep604_annotation,typing_annotation,expected",
122+
[
123+
(str | None, Optional[str], "Union[str, NoneType]"), # noqa: UP045
124+
(int | str, Union[int, str], "Union[int, str]"),
125+
(int | str | None, Union[int, str, None], "Union[int, str, NoneType]"),
126+
(list[str] | None, Optional[list[str]], "Union[list[str], NoneType]"), # noqa: UP045
127+
(dict[str, int | None], dict[str, Optional[int]], "dict[str, Union[int, NoneType]]"), # noqa: UP045
128+
],
129+
)
130+
def test_get_annotation_name_pep604_union_matches_typing_union(pep604_annotation, typing_annotation, expected):
131+
assert get_annotation_name(typing_annotation) == expected
132+
assert get_annotation_name(pep604_annotation) == expected

‎tests/adapters/test_chat_adapter.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3050,3 +3050,15 @@ class OptionalSyntaxSignature(dspy.Signature):
30503050
with dspy.context(lm=DummyLM(responses), adapter=dspy.ChatAdapter()):
30513051
with pytest.raises(AdapterParseError):
30523052
dspy.Predict(OptionalSyntaxSignature)(question="anything")
3053+
3054+
3055+
def test_chat_adapter_formats_pep604_union_like_typing_union():
3056+
class QA(dspy.Signature):
3057+
question: str = dspy.InputField()
3058+
answer: int | None = dspy.OutputField()
3059+
3060+
messages = dspy.ChatAdapter().format(QA, [], {"question": "How many?"})
3061+
3062+
assert "1. `answer` (Union[int, NoneType])" in messages[0]["content"]
3063+
assert "must be formatted as a valid Python Union[int, NoneType]" in messages[-1]["content"]
3064+
assert "UnionType" not in messages[0]["content"] + messages[-1]["content"]

‎tests/adapters/test_xml_adapter.py‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import sys
21
from unittest import mock
32

43
import pydantic
@@ -454,7 +453,7 @@ class QA(dspy.Signature):
454453
assert messages[0]["role"] == "system"
455454
assert messages[1]["role"] == "user"
456455

457-
union_type_repr = "Union[str, NoneType]" if sys.version_info >= (3, 14) else "UnionType[str, NoneType]"
456+
union_type_repr = "Union[str, NoneType]"
458457

459458
expected_system = (
460459
"Your input fields are:\n"

0 commit comments

Comments
 (0)