Skip to content

Commit ff62a21

Browse files
committed
gh-117486: Improve behavior for user-defined AST subclasses
Now, such classes will no longer require changes in Python 3.13 in the normal case. The test suite for robotframework passes with no DeprecationWarnings under this PR. I also added a new DeprecationWarning for the case where `_field_types` exists but is incomplete, since that seems likely to indicate a user mistake.
1 parent 7e87d30 commit ff62a21

File tree

6 files changed

+94
-33
lines changed

6 files changed

+94
-33
lines changed

Doc/library/ast.rst

+13-1
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ Node classes
6161

6262
.. attribute:: _fields
6363

64-
Each concrete class has an attribute :attr:`_fields` which gives the names
64+
Each concrete class has an attribute :attr:`!_fields` which gives the names
6565
of all child nodes.
6666

6767
Each instance of a concrete class has one attribute for each child node,
@@ -74,6 +74,18 @@ Node classes
7474
as Python lists. All possible attributes must be present and have valid
7575
values when compiling an AST with :func:`compile`.
7676

77+
.. attribute:: _field_types
78+
79+
The :attr:`!_field_types` attribute on each concrete class is a dictionary
80+
mapping field names (as also listed in :attr:`_fields`) to their types.
81+
82+
.. doctest::
83+
84+
>>> ast.TypeVar._field_types
85+
{'name': <class 'str'>, 'bound': ast.expr | None}
86+
87+
.. versionadded:: 3.13
88+
7789
.. attribute:: lineno
7890
col_offset
7991
end_lineno

Doc/whatsnew/3.13.rst

+6
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,12 @@ ast
325325
argument that does not map to a field on the AST node is now deprecated,
326326
and will raise an exception in Python 3.15.
327327

328+
These changes do not apply to user-defined subclasses of :class:`ast.AST`,
329+
unless the class opts in to the new behavior by setting the attribute
330+
:attr:`ast.AST._field_types`.
331+
332+
(Contributed by Jelle Zijlstra in :gh:`105858` and :gh:`117486`.)
333+
328334
* :func:`ast.parse` now accepts an optional argument *optimize*
329335
which is passed on to the :func:`compile` built-in. This makes it
330336
possible to obtain an optimized AST.

Lib/test/test_ast.py

+37-4
Original file line numberDiff line numberDiff line change
@@ -2916,25 +2916,25 @@ def test_FunctionDef(self):
29162916
self.assertEqual(node.name, 'foo')
29172917
self.assertEqual(node.decorator_list, [])
29182918

2919-
def test_custom_subclass(self):
2919+
def test_custom_subclass_with_no_fields(self):
29202920
class NoInit(ast.AST):
29212921
pass
29222922

29232923
obj = NoInit()
29242924
self.assertIsInstance(obj, NoInit)
29252925
self.assertEqual(obj.__dict__, {})
29262926

2927+
def test_fields_but_no_field_types(self):
29272928
class Fields(ast.AST):
29282929
_fields = ('a',)
29292930

2930-
with self.assertWarnsRegex(DeprecationWarning,
2931-
r"Fields provides _fields but not _field_types."):
2932-
obj = Fields()
2931+
obj = Fields()
29332932
with self.assertRaises(AttributeError):
29342933
obj.a
29352934
obj = Fields(a=1)
29362935
self.assertEqual(obj.a, 1)
29372936

2937+
def test_fields_and_types(self):
29382938
class FieldsAndTypes(ast.AST):
29392939
_fields = ('a',)
29402940
_field_types = {'a': int | None}
@@ -2945,6 +2945,7 @@ class FieldsAndTypes(ast.AST):
29452945
obj = FieldsAndTypes(a=1)
29462946
self.assertEqual(obj.a, 1)
29472947

2948+
def test_fields_and_types_no_default(self):
29482949
class FieldsAndTypesNoDefault(ast.AST):
29492950
_fields = ('a',)
29502951
_field_types = {'a': int}
@@ -2957,6 +2958,38 @@ class FieldsAndTypesNoDefault(ast.AST):
29572958
obj = FieldsAndTypesNoDefault(a=1)
29582959
self.assertEqual(obj.a, 1)
29592960

2961+
def test_incomplete_field_types(self):
2962+
class MoreFieldsThanTypes(ast.AST):
2963+
_fields = ('a', 'b')
2964+
_field_types = {'a': int | None}
2965+
a: int | None = None
2966+
b: int | None = None
2967+
2968+
with self.assertWarnsRegex(
2969+
DeprecationWarning,
2970+
r"Field 'b' is missing from MoreFieldsThanTypes\._field_types"
2971+
):
2972+
obj = MoreFieldsThanTypes()
2973+
self.assertIs(obj.a, None)
2974+
self.assertIs(obj.b, None)
2975+
2976+
obj = MoreFieldsThanTypes(a=1, b=2)
2977+
self.assertEqual(obj.a, 1)
2978+
self.assertEqual(obj.b, 2)
2979+
2980+
def test_complete_field_types(self):
2981+
class _AllFieldTypes(ast.AST):
2982+
_fields = ('a', 'b')
2983+
_field_types = {'a': int | None, 'b': list[str]}
2984+
# This must be set explicitly
2985+
a: int | None = None
2986+
# This will add an implicit empty list default
2987+
b: list[str]
2988+
2989+
obj = _AllFieldTypes()
2990+
self.assertIs(obj.a, None)
2991+
self.assertEqual(obj.b, [])
2992+
29602993

29612994
@support.cpython_only
29622995
class ModuleStateTests(unittest.TestCase):
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Improve the behavior of user-defined subclasses of :class:`ast.AST`. Such
2+
classes will now require no changes in the usual case to conform with the
3+
behavior changes of the :mod:`ast` module in Python 3.13. Patch by Jelle
4+
Zijlstra.

Parser/asdl_c.py

+17-14
Original file line numberDiff line numberDiff line change
@@ -979,14 +979,9 @@ def visitModule(self, mod):
979979
goto cleanup;
980980
}
981981
if (field_types == NULL) {
982-
if (PyErr_WarnFormat(
983-
PyExc_DeprecationWarning, 1,
984-
"%.400s provides _fields but not _field_types. "
985-
"This will become an error in Python 3.15.",
986-
Py_TYPE(self)->tp_name
987-
) < 0) {
988-
res = -1;
989-
}
982+
// Probably a user-defined subclass of AST that lacks _field_types.
983+
// This will continue to work as it did before 3.13; i.e., attributes
984+
// that are not passed in simply do not exist on the instance.
990985
goto cleanup;
991986
}
992987
remaining_list = PySequence_List(remaining_fields);
@@ -997,12 +992,21 @@ def visitModule(self, mod):
997992
PyObject *name = PyList_GET_ITEM(remaining_list, i);
998993
PyObject *type = PyDict_GetItemWithError(field_types, name);
999994
if (!type) {
1000-
if (!PyErr_Occurred()) {
1001-
PyErr_SetObject(PyExc_KeyError, name);
995+
if (PyErr_Occurred()) {
996+
goto set_remaining_cleanup;
997+
}
998+
else {
999+
if (PyErr_WarnFormat(
1000+
PyExc_DeprecationWarning, 1,
1001+
"Field '%U' is missing from %.400s._field_types. "
1002+
"This will become an error in Python 3.15.",
1003+
name, Py_TYPE(self)->tp_name
1004+
) < 0) {
1005+
goto set_remaining_cleanup;
1006+
}
10021007
}
1003-
goto set_remaining_cleanup;
10041008
}
1005-
if (_PyUnion_Check(type)) {
1009+
else if (_PyUnion_Check(type)) {
10061010
// optional field
10071011
// do nothing, we'll have set a None default on the class
10081012
}
@@ -1026,8 +1030,7 @@ def visitModule(self, mod):
10261030
"This will become an error in Python 3.15.",
10271031
Py_TYPE(self)->tp_name, name
10281032
) < 0) {
1029-
res = -1;
1030-
goto cleanup;
1033+
goto set_remaining_cleanup;
10311034
}
10321035
}
10331036
}

Python/Python-ast.c

+17-14
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)