Skip to content

Commit fc1170a

Browse files
TomeHiratakapilreddyCopilot
authored
Support Python 3.14 (#9041)
* FIX - Add 3.14 annotation support for signature construction * Update dspy/signatures/signature.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update dspy/signatures/signature.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update dependencies and Python version compatibility - Updated `requires-python` to support Python 3.15 in `pyproject.toml` and `uv.lock`. - Adjusted dependency markers for various packages to ensure compatibility with Python 3.14. - Removed `magicattr` dependency and added a custom compatibility layer for Python 3.14+. - Updated GitHub Actions workflow to include Python 3.14 in the testing matrix. - Refactored code in `dspy/primitives/module.py` and `dspy/signatures/signature.py` to accommodate changes in Python 3.14. - Added tests for the new `magicattr` utility functions. * Remove `pillow` dependency from `pyproject.toml` and `uv.lock`, and update `typing-extensions` version constraint for compatibility with Python 3.11. * Update `aiohttp` version to 3.13.2 and add `xxhash` version 3.6.0 to `uv.lock` * space * fix --------- Co-authored-by: Kapil Reddy <reddy.kapil@gmail.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent b3c6350 commit fc1170a

File tree

9 files changed

+1320
-739
lines changed

9 files changed

+1320
-739
lines changed

.github/workflows/run_tests.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ jobs:
5050
runs-on: ubuntu-latest
5151
strategy:
5252
matrix:
53-
python-version: ["3.10", "3.11", "3.12", "3.13"]
53+
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
5454
steps:
5555
- uses: actions/checkout@v4
5656
- uses: actions/setup-python@v5
@@ -144,7 +144,7 @@ jobs:
144144
runs-on: ubuntu-latest
145145
strategy:
146146
matrix:
147-
python-version: ["3.10", "3.11", "3.12", "3.13"]
147+
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
148148
steps:
149149
- uses: actions/checkout@v4
150150
- uses: actions/setup-python@v5

dspy/primitives/module.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,12 @@
22
import logging
33
from typing import Any
44

5-
import magicattr
6-
7-
from dspy.dsp.utils.settings import settings, thread_local_overrides
5+
from dspy.dsp.utils.settings import settings
86
from dspy.predict.parallel import Parallel
97
from dspy.primitives.base_module import BaseModule
108
from dspy.primitives.example import Example
119
from dspy.primitives.prediction import Prediction
10+
from dspy.utils import magicattr
1211
from dspy.utils.callback import with_callbacks
1312
from dspy.utils.inspect_history import pretty_print_history
1413
from dspy.utils.usage_tracker import track_usage
@@ -65,6 +64,8 @@ def __setstate__(self, state):
6564

6665
@with_callbacks
6766
def __call__(self, *args, **kwargs) -> Prediction:
67+
from dspy.dsp.utils.settings import thread_local_overrides
68+
6869
caller_modules = settings.caller_modules or []
6970
caller_modules = list(caller_modules)
7071
caller_modules.append(self)
@@ -82,6 +83,8 @@ def __call__(self, *args, **kwargs) -> Prediction:
8283

8384
@with_callbacks
8485
async def acall(self, *args, **kwargs) -> Prediction:
86+
from dspy.dsp.utils.settings import thread_local_overrides
87+
8588
caller_modules = settings.caller_modules or []
8689
caller_modules = list(caller_modules)
8790
caller_modules.append(self)

dspy/signatures/signature.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,28 @@ def __new__(mcs, signature_name, bases, namespace, **kwargs):
138138
# At this point, the orders have been swapped already.
139139
field_order = [name for name, value in namespace.items() if isinstance(value, FieldInfo)]
140140
# Set `str` as the default type for all fields
141-
raw_annotations = namespace.get("__annotations__", {})
141+
if sys.version_info >= (3, 14):
142+
try:
143+
import annotationlib
144+
# Try to get from explicit __annotations__ first (e.g., from __future__ import annotations)
145+
raw_annotations = namespace.get("__annotations__")
146+
147+
if raw_annotations is None:
148+
# In 3.14 with PEP 649, get the annotate function and call it
149+
annotate_func = annotationlib.get_annotate_from_class_namespace(namespace)
150+
if annotate_func:
151+
raw_annotations = annotationlib.call_annotate_function(
152+
annotate_func,
153+
format=annotationlib.Format.FORWARDREF
154+
)
155+
else:
156+
raw_annotations = {}
157+
except ImportError:
158+
raw_annotations = namespace.get("__annotations__", {})
159+
else:
160+
# Python 3.13 and earlier
161+
# Set `str` as the default type for all fields
162+
raw_annotations = namespace.get("__annotations__", {})
142163
for name, field in namespace.items():
143164
if not isinstance(field, FieldInfo):
144165
continue # Don't add types to non-field attributes

dspy/utils/magicattr.py

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
"""
2+
Compatibility layer for magicattr that works with Python 3.14+
3+
4+
This module provides a patched version of magicattr's functionality
5+
that is compatible with Python 3.14's removal of ast.Num and ast.Str.
6+
7+
Based on magicattr 0.1.6 by Jairus Martin (MIT License)
8+
https://github.com/frmdstryr/magicattr
9+
"""
10+
import ast
11+
import sys
12+
from functools import reduce
13+
14+
_AST_TYPES = (ast.Name, ast.Attribute, ast.Subscript, ast.Call)
15+
_STRING_TYPE = str
16+
17+
18+
def get(obj, attr, **kwargs):
19+
"""A getattr that supports nested lookups on objects, dicts, lists, and
20+
any combination in between.
21+
"""
22+
for chunk in _parse(attr):
23+
try:
24+
obj = _lookup(obj, chunk)
25+
except Exception as ex:
26+
if "default" in kwargs:
27+
return kwargs["default"]
28+
else:
29+
raise ex
30+
return obj
31+
32+
33+
def set(obj, attr, val):
34+
"""A setattr that supports nested lookups on objects, dicts, lists, and
35+
any combination in between.
36+
"""
37+
obj, attr_or_key, is_subscript = lookup(obj, attr)
38+
if is_subscript:
39+
obj[attr_or_key] = val
40+
else:
41+
setattr(obj, attr_or_key, val)
42+
43+
44+
def delete(obj, attr):
45+
"""A delattr that supports deletion of a nested lookups on objects,
46+
dicts, lists, and any combination in between.
47+
"""
48+
obj, attr_or_key, is_subscript = lookup(obj, attr)
49+
if is_subscript:
50+
del obj[attr_or_key]
51+
else:
52+
delattr(obj, attr_or_key)
53+
54+
55+
def lookup(obj, attr):
56+
"""Like get but instead of returning the final value it returns the
57+
object and action that will be done.
58+
"""
59+
nodes = tuple(_parse(attr))
60+
if len(nodes) > 1:
61+
obj = reduce(_lookup, nodes[:-1], obj)
62+
node = nodes[-1]
63+
else:
64+
node = nodes[0]
65+
if isinstance(node, ast.Attribute):
66+
return obj, node.attr, False
67+
elif isinstance(node, ast.Subscript):
68+
return obj, _lookup_subscript_value(node.slice), True
69+
elif isinstance(node, ast.Name):
70+
return obj, node.id, False
71+
raise NotImplementedError("Node is not supported: %s" % node)
72+
73+
74+
def _parse(attr):
75+
"""Parse and validate an attr string"""
76+
if not isinstance(attr, _STRING_TYPE):
77+
raise TypeError("Attribute name must be a string")
78+
nodes = ast.parse(attr).body
79+
if not nodes or not isinstance(nodes[0], ast.Expr):
80+
raise ValueError("Invalid expression: %s" % attr)
81+
return reversed([n for n in ast.walk(nodes[0]) if isinstance(n, _AST_TYPES)])
82+
83+
84+
def _lookup_subscript_value(node):
85+
"""Lookup the value of ast node on the object.
86+
87+
Compatible with Python 3.14+ which removed ast.Num and ast.Str
88+
"""
89+
if isinstance(node, ast.Index):
90+
node = node.value
91+
92+
# Python 3.14+ uses ast.Constant for all constants
93+
if isinstance(node, ast.Constant):
94+
return node.value
95+
96+
# Fallback for older Python versions
97+
if sys.version_info < (3, 14):
98+
# Handle numeric indexes
99+
if hasattr(ast, "Num") and isinstance(node, ast.Num):
100+
return node.n
101+
# Handle string keys
102+
elif hasattr(ast, "Str") and isinstance(node, ast.Str):
103+
return node.s
104+
105+
# Handle negative indexes
106+
if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.USub):
107+
operand = node.operand
108+
if isinstance(operand, ast.Constant):
109+
return -operand.value
110+
# Fallback for older Python
111+
elif sys.version_info < (3, 14) and hasattr(ast, "Num") and isinstance(operand, ast.Num):
112+
return -operand.n
113+
114+
raise NotImplementedError("Subscript node is not supported: %s" % ast.dump(node))
115+
116+
117+
def _lookup(obj, node):
118+
"""Lookup the given ast node on the object."""
119+
if isinstance(node, ast.Attribute):
120+
return getattr(obj, node.attr)
121+
elif isinstance(node, ast.Subscript):
122+
return obj[_lookup_subscript_value(node.slice)]
123+
elif isinstance(node, ast.Name):
124+
return getattr(obj, node.id)
125+
elif isinstance(node, ast.Call):
126+
raise ValueError("Function calls are not allowed.")
127+
raise NotImplementedError("Node is not supported: %s" % node)

pyproject.toml

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ description = "DSPy"
1313
readme = "README.md"
1414
authors = [{ name = "Omar Khattab", email = "okhattab@stanford.edu" }]
1515
license = {file = "LICENSE"}
16-
requires-python = ">=3.10, <3.14"
16+
requires-python = ">=3.10, <3.15"
1717
classifiers = [
1818
"Development Status :: 3 - Alpha",
1919
"Intended Audience :: Science/Research",
@@ -28,7 +28,6 @@ dependencies = [
2828
"requests>=2.31.0",
2929
"optuna>=3.4.0",
3030
"pydantic>=2.0",
31-
"magicattr>=0.1.6",
3231
"litellm>=1.64.0",
3332
"diskcache>=5.6.0",
3433
"json-repair>=0.30.0",
@@ -37,7 +36,6 @@ dependencies = [
3736
"asyncer==0.0.8",
3837
"cachetools>=5.5.0",
3938
"cloudpickle>=3.0.0",
40-
"pillow>=10.1.0",
4139
"numpy>=1.26.0",
4240
"xxhash>=3.5.0",
4341
"gepa[dspy]==0.0.18",
@@ -57,8 +55,8 @@ dev = [
5755
"pillow>=10.1.0",
5856
"datamodel_code_generator>=0.26.3",
5957
"build>=1.0.3",
60-
"litellm>=1.64.0; sys_platform == 'win32'",
61-
"litellm[proxy]>=1.64.0; sys_platform != 'win32'",
58+
"litellm>=1.64.0; sys_platform == 'win32' or python_version == '3.14'",
59+
"litellm[proxy]>=1.64.0; sys_platform != 'win32' and python_version < '3.14'", # Remove 3.14 condition once uvloop supports
6260
]
6361
test_extras = [
6462
"mcp; python_version >= '3.10'",

tests/adapters/test_xml_adapter.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import sys
12
from unittest import mock
23

34
import pydantic
@@ -276,10 +277,12 @@ class QA(dspy.Signature):
276277
assert messages[0]["role"] == "system"
277278
assert messages[1]["role"] == "user"
278279

280+
union_type_repr = "Union[str, NoneType]" if sys.version_info >= (3, 14) else "UnionType[str, NoneType]"
281+
279282
expected_system = (
280283
"Your input fields are:\n"
281284
"1. `query` (str): \n"
282-
"2. `context` (UnionType[str, NoneType]):\n"
285+
f"2. `context` ({union_type_repr}):\n"
283286
"Your output fields are:\n"
284287
"1. `answer` (str):\n"
285288
"All interactions will be structured in the following way, with the appropriate values filled in.\n\n"

tests/test_utils/server/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import os
33
import socket
44
import subprocess
5+
import sys
56
import tempfile
67
import time
78
from typing import Any
@@ -17,6 +18,8 @@ def litellm_test_server() -> tuple[str, str]:
1718
Start a LiteLLM test server for a DSPy integration test case, and tear down the
1819
server when the test case completes.
1920
"""
21+
if sys.version_info[:2] == (3, 14):
22+
pytest.skip("Litellm proxy server is not supported on Python 3.14.")
2023
with tempfile.TemporaryDirectory() as server_log_dir_path:
2124
# Create a server log file used to store request logs
2225
server_log_file_path = os.path.join(server_log_dir_path, "request_logs.jsonl")

0 commit comments

Comments
 (0)