Skip to content

Commit 3f5bf95

Browse files
mjp41xFrednet
andauthored
Adding tp_reachable (#65)
Includes various fixes to get more passing CI too Co-authored-by: Fridtjof Stoldt <xFrednet@gmail.com>
1 parent 47e7c4b commit 3f5bf95

52 files changed

Lines changed: 1295 additions & 154 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Doc/includes/typestruct.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,4 +92,7 @@ typedef struct _typeobject {
9292
* Otherwise, limited to MAX_VERSIONS_PER_CLASS (defined elsewhere).
9393
*/
9494
uint16_t tp_versions_used;
95+
96+
/* call function for all referenced objects (includes non-cyclic refs) */
97+
traverseproc tp_reachable;
9598
} PyTypeObject;

Include/cpython/object.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,9 @@ struct _typeobject {
239239
* Otherwise, limited to MAX_VERSIONS_PER_CLASS (defined elsewhere).
240240
*/
241241
uint16_t tp_versions_used;
242+
243+
/* call function for all referenced objects (includes non-cyclic refs) */
244+
traverseproc tp_reachable;
242245
};
243246

244247
#define _Py_ATTR_CACHE_UNUSED (30000) // (see tp_versions_used)

Include/internal/pycore_immutability.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,14 @@ extern "C" {
88
# error "Py_BUILD_CORE must be defined to include this header"
99
#endif
1010

11+
typedef struct _Py_hashtable_t _Py_hashtable_t;
12+
1113
struct _Py_immutability_state {
1214
PyObject *module_locks;
1315
PyObject *blocking_on;
1416
PyObject *freezable_types;
1517
PyObject *destroy_cb;
18+
_Py_hashtable_t *warned_types;
1619
#ifdef Py_DEBUG
1720
PyObject *traceback_func; // For debugging purposes, can be NULL
1821
#endif

Include/typeslots.h

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,3 +94,7 @@
9494
/* New in 3.14 */
9595
#define Py_tp_token 83
9696
#endif
97+
#if !defined(Py_LIMITED_API) || Py_LIMITED_API+0 >= 0x030F0000
98+
/* New in 3.15 */
99+
#define Py_tp_reachable 84
100+
#endif

Lib/test/test_freeze/test_common.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ def __init__(self, *args, obj=None, **kwargs):
88
self.obj = obj
99

1010
def setUp(self):
11+
# Explicitly freeze type, then the object
12+
# Types are not implicitly frozen by freeze()
13+
# freeze(type(self.obj))
1114
freeze(self.obj)
1215

1316
def test_immutable(self):
@@ -18,7 +21,8 @@ def test_add_attribute(self):
1821
self.obj.new_attribute = 'value'
1922

2023
def test_type_immutable(self):
21-
self.assertTrue(isfrozen(type(self.obj)))
24+
self.assertTrue(isfrozen(self.obj))
25+
self.assertTrue(isfrozen(type(self.obj)), "Type should be frozen when instance is frozen: {}".format(type(self.obj)))
2226

2327

2428
class BaseNotFreezableTest(unittest.TestCase):
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
"""Tests for freeze warnings when tp_reachable is missing.
2+
3+
Uses the _test_reachable C extension which provides two static types:
4+
5+
HasTraverseNoReachable – has tp_traverse, tp_reachable deliberately NULL
6+
NoTraverseNoReachable – neither tp_traverse nor tp_reachable
7+
"""
8+
import subprocess
9+
import sys
10+
import textwrap
11+
import unittest
12+
13+
14+
class TestReachableWarnings(unittest.TestCase):
15+
"""Test that freeze logs warnings when tp_reachable is missing."""
16+
17+
def _run_code(self, code):
18+
"""Run code in a subprocess and return (stdout, stderr)."""
19+
result = subprocess.run(
20+
[sys.executable, "-c", textwrap.dedent(code)],
21+
capture_output=True,
22+
text=True,
23+
)
24+
self.assertEqual(result.returncode, 0, result.stderr)
25+
return result.stdout, result.stderr
26+
27+
def test_warn_tp_traverse_no_tp_reachable(self):
28+
"""Warn when a C type has tp_traverse but no tp_reachable."""
29+
stdout, stderr = self._run_code("""\
30+
import _immutable, _test_reachable
31+
obj = _test_reachable.HasTraverseNoReachable(42)
32+
_immutable.freeze(obj)
33+
""")
34+
self.assertIn(
35+
"freeze: type '_test_reachable.HasTraverseNoReachable' "
36+
"has tp_traverse but no tp_reachable",
37+
stderr,
38+
)
39+
40+
def test_warn_no_traverse_no_reachable(self):
41+
"""Warn when a C type has neither tp_traverse nor tp_reachable."""
42+
stdout, stderr = self._run_code("""\
43+
import _immutable, _test_reachable
44+
obj = _test_reachable.NoTraverseNoReachable()
45+
_immutable.freeze(obj)
46+
""")
47+
self.assertIn(
48+
"freeze: type '_test_reachable.NoTraverseNoReachable' "
49+
"has no tp_traverse and no tp_reachable",
50+
stderr,
51+
)
52+
53+
def test_warn_only_once_per_type(self):
54+
"""A type should only produce the warning on the first freeze."""
55+
stdout, stderr = self._run_code("""\
56+
import _immutable, _test_reachable
57+
_immutable.freeze(_test_reachable.HasTraverseNoReachable(1))
58+
_immutable.freeze(_test_reachable.HasTraverseNoReachable(2))
59+
_immutable.freeze(_test_reachable.HasTraverseNoReachable(3))
60+
""")
61+
msg = (
62+
"freeze: type '_test_reachable.HasTraverseNoReachable' "
63+
"has tp_traverse but no tp_reachable"
64+
)
65+
count = stderr.count(msg)
66+
self.assertEqual(count, 1, f"Expected 1 warning, got {count}:\n{stderr}")
67+
68+
def test_warn_different_types_separately(self):
69+
"""Different types should each produce their own warning."""
70+
stdout, stderr = self._run_code("""\
71+
import _immutable, _test_reachable
72+
_immutable.freeze(_test_reachable.HasTraverseNoReachable(1))
73+
_immutable.freeze(_test_reachable.NoTraverseNoReachable())
74+
""")
75+
self.assertIn("HasTraverseNoReachable", stderr)
76+
self.assertIn("NoTraverseNoReachable", stderr)
77+
78+
def test_no_warning_with_tp_reachable(self):
79+
"""No warning for a type that has tp_reachable set."""
80+
stdout, stderr = self._run_code("""\
81+
import _immutable, _test_reachable
82+
obj = _test_reachable.HasReachable(42)
83+
_immutable.freeze(obj)
84+
""")
85+
self.assertNotIn("HasReachable", stderr)
86+
87+
88+
if __name__ == "__main__":
89+
unittest.main()

Lib/test/test_sys.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1782,7 +1782,7 @@ def delx(self): del self.__x
17821782
check((1,2,3), vsize('') + self.P + 3*self.P)
17831783
# type
17841784
# static type: PyTypeObject
1785-
fmt = 'P2nPI13Pl4Pn9Pn12PIPc'
1785+
fmt = 'P2nPI13Pl4Pn9Pn12PIPcP'
17861786
s = vsize(fmt)
17871787
check(int, s)
17881788
typeid = 'n' if support.Py_GIL_DISABLED else ''

Modules/Setup.stdlib.in

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,7 @@
179179
@MODULE__TESTLIMITEDCAPI_TRUE@_testlimitedcapi _testlimitedcapi.c _testlimitedcapi/abstract.c _testlimitedcapi/bytearray.c _testlimitedcapi/bytes.c _testlimitedcapi/codec.c _testlimitedcapi/complex.c _testlimitedcapi/dict.c _testlimitedcapi/eval.c _testlimitedcapi/float.c _testlimitedcapi/heaptype_relative.c _testlimitedcapi/import.c _testlimitedcapi/list.c _testlimitedcapi/long.c _testlimitedcapi/object.c _testlimitedcapi/pyos.c _testlimitedcapi/set.c _testlimitedcapi/sys.c _testlimitedcapi/tuple.c _testlimitedcapi/unicode.c _testlimitedcapi/vectorcall_limited.c _testlimitedcapi/version.c _testlimitedcapi/file.c
180180
@MODULE__TESTCLINIC_TRUE@_testclinic _testclinic.c
181181
@MODULE__TESTCLINIC_LIMITED_TRUE@_testclinic_limited _testclinic_limited.c
182+
@MODULE__TEST_REACHABLE_TRUE@_test_reachable _test_reachable.c
182183

183184
# Some testing modules MUST be built as shared libraries.
184185
*shared*

Modules/_test_reachable.c

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
/*
2+
* _test_reachable - Test module for tp_reachable freeze warnings.
3+
*
4+
* Provides types that deliberately lack tp_reachable so we can test
5+
* the freeze-time warnings in traverse_freeze().
6+
*
7+
* HasTraverseNoReachable - has tp_traverse, no tp_reachable
8+
* NoTraverseNoReachable - no tp_traverse, no tp_reachable
9+
* HasReachable - has both tp_traverse and tp_reachable (no warning)
10+
*/
11+
12+
#ifndef Py_BUILD_CORE_BUILTIN
13+
# define Py_BUILD_CORE_MODULE 1
14+
#endif
15+
16+
#include "Python.h"
17+
18+
/* ---- HasTraverseNoReachable ------------------------------------------- */
19+
20+
typedef struct {
21+
PyObject_HEAD
22+
PyObject *value;
23+
} HasTraverseNoReachableObject;
24+
25+
static int
26+
htnr_traverse(PyObject *self, visitproc visit, void *arg)
27+
{
28+
HasTraverseNoReachableObject *obj = (HasTraverseNoReachableObject *)self;
29+
Py_VISIT(obj->value);
30+
return 0;
31+
}
32+
33+
static int
34+
htnr_clear(PyObject *self)
35+
{
36+
HasTraverseNoReachableObject *obj = (HasTraverseNoReachableObject *)self;
37+
Py_CLEAR(obj->value);
38+
return 0;
39+
}
40+
41+
static void
42+
htnr_dealloc(PyObject *self)
43+
{
44+
PyObject_GC_UnTrack(self);
45+
htnr_clear(self);
46+
Py_TYPE(self)->tp_free(self);
47+
}
48+
49+
static int
50+
htnr_init(PyObject *self, PyObject *args, PyObject *kwds)
51+
{
52+
HasTraverseNoReachableObject *obj = (HasTraverseNoReachableObject *)self;
53+
PyObject *value = Py_None;
54+
if (!PyArg_ParseTuple(args, "|O", &value))
55+
return -1;
56+
Py_XSETREF(obj->value, Py_NewRef(value));
57+
return 0;
58+
}
59+
60+
static PyTypeObject HasTraverseNoReachable_Type = {
61+
PyVarObject_HEAD_INIT(NULL, 0)
62+
.tp_name = "_test_reachable.HasTraverseNoReachable",
63+
.tp_basicsize = sizeof(HasTraverseNoReachableObject),
64+
.tp_dealloc = htnr_dealloc,
65+
.tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_HAVE_GC,
66+
.tp_doc = "Type with tp_traverse but no tp_reachable.",
67+
.tp_traverse = htnr_traverse,
68+
.tp_clear = htnr_clear,
69+
.tp_init = htnr_init,
70+
.tp_alloc = PyType_GenericAlloc,
71+
.tp_new = PyType_GenericNew,
72+
.tp_free = PyObject_GC_Del,
73+
/* tp_reachable deliberately left NULL */
74+
};
75+
76+
/* ---- NoTraverseNoReachable ------------------------------------------- */
77+
78+
typedef struct {
79+
PyObject_HEAD
80+
int dummy;
81+
} NoTraverseNoReachableObject;
82+
83+
static int
84+
ntnr_init(PyObject *self, PyObject *args, PyObject *kwds)
85+
{
86+
return 0;
87+
}
88+
89+
static PyTypeObject NoTraverseNoReachable_Type = {
90+
PyVarObject_HEAD_INIT(NULL, 0)
91+
.tp_name = "_test_reachable.NoTraverseNoReachable",
92+
.tp_basicsize = sizeof(NoTraverseNoReachableObject),
93+
.tp_flags = Py_TPFLAGS_DEFAULT,
94+
.tp_doc = "Type with no tp_traverse and no tp_reachable.",
95+
.tp_init = ntnr_init,
96+
.tp_alloc = PyType_GenericAlloc,
97+
.tp_new = PyType_GenericNew,
98+
/* tp_traverse deliberately left NULL */
99+
/* tp_reachable deliberately left NULL */
100+
};
101+
102+
/* ---- HasReachable ---------------------------------------------------- */
103+
104+
typedef struct {
105+
PyObject_HEAD
106+
PyObject *value;
107+
} HasReachableObject;
108+
109+
static int
110+
hr_traverse(PyObject *self, visitproc visit, void *arg)
111+
{
112+
HasReachableObject *obj = (HasReachableObject *)self;
113+
Py_VISIT(obj->value);
114+
return 0;
115+
}
116+
117+
static int
118+
hr_reachable(PyObject *self, visitproc visit, void *arg)
119+
{
120+
Py_VISIT(Py_TYPE(self));
121+
return hr_traverse(self, visit, arg);
122+
}
123+
124+
static int
125+
hr_clear(PyObject *self)
126+
{
127+
HasReachableObject *obj = (HasReachableObject *)self;
128+
Py_CLEAR(obj->value);
129+
return 0;
130+
}
131+
132+
static void
133+
hr_dealloc(PyObject *self)
134+
{
135+
PyObject_GC_UnTrack(self);
136+
hr_clear(self);
137+
Py_TYPE(self)->tp_free(self);
138+
}
139+
140+
static int
141+
hr_init(PyObject *self, PyObject *args, PyObject *kwds)
142+
{
143+
HasReachableObject *obj = (HasReachableObject *)self;
144+
PyObject *value = Py_None;
145+
if (!PyArg_ParseTuple(args, "|O", &value))
146+
return -1;
147+
Py_XSETREF(obj->value, Py_NewRef(value));
148+
return 0;
149+
}
150+
151+
static PyTypeObject HasReachable_Type = {
152+
PyVarObject_HEAD_INIT(NULL, 0)
153+
.tp_name = "_test_reachable.HasReachable",
154+
.tp_basicsize = sizeof(HasReachableObject),
155+
.tp_dealloc = hr_dealloc,
156+
.tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_HAVE_GC,
157+
.tp_doc = "Type with both tp_traverse and tp_reachable.",
158+
.tp_traverse = hr_traverse,
159+
.tp_clear = hr_clear,
160+
.tp_init = hr_init,
161+
.tp_alloc = PyType_GenericAlloc,
162+
.tp_new = PyType_GenericNew,
163+
.tp_free = PyObject_GC_Del,
164+
.tp_reachable = hr_reachable,
165+
};
166+
167+
/* ---- Module ---------------------------------------------------------- */
168+
169+
static int
170+
_test_reachable_exec(PyObject *module)
171+
{
172+
/* Ready both types and register them as freezable */
173+
if (PyType_Ready(&HasTraverseNoReachable_Type) < 0)
174+
return -1;
175+
/* Reset tp_reachable to NULL in case Ready inherited one */
176+
HasTraverseNoReachable_Type.tp_reachable = NULL;
177+
if (PyModule_AddType(module, &HasTraverseNoReachable_Type) != 0)
178+
return -1;
179+
if (_PyImmutability_RegisterFreezable(&HasTraverseNoReachable_Type) < 0)
180+
return -1;
181+
182+
if (PyType_Ready(&NoTraverseNoReachable_Type) < 0)
183+
return -1;
184+
/* Reset tp_reachable to NULL in case Ready inherited one */
185+
NoTraverseNoReachable_Type.tp_reachable = NULL;
186+
if (PyModule_AddType(module, &NoTraverseNoReachable_Type) != 0)
187+
return -1;
188+
if (_PyImmutability_RegisterFreezable(&NoTraverseNoReachable_Type) < 0)
189+
return -1;
190+
191+
if (PyType_Ready(&HasReachable_Type) < 0)
192+
return -1;
193+
if (PyModule_AddType(module, &HasReachable_Type) != 0)
194+
return -1;
195+
if (_PyImmutability_RegisterFreezable(&HasReachable_Type) < 0)
196+
return -1;
197+
198+
return 0;
199+
}
200+
201+
static PyModuleDef_Slot _test_reachable_slots[] = {
202+
{Py_mod_exec, _test_reachable_exec},
203+
{Py_mod_multiple_interpreters, Py_MOD_PER_INTERPRETER_GIL_SUPPORTED},
204+
{Py_mod_gil, Py_MOD_GIL_NOT_USED},
205+
{0, NULL},
206+
};
207+
208+
static struct PyModuleDef _test_reachable_module = {
209+
PyModuleDef_HEAD_INIT,
210+
.m_name = "_test_reachable",
211+
.m_doc = "Test module for tp_reachable freeze warnings.",
212+
.m_size = 0,
213+
.m_slots = _test_reachable_slots,
214+
};
215+
216+
PyMODINIT_FUNC
217+
PyInit__test_reachable(void)
218+
{
219+
return PyModuleDef_Init(&_test_reachable_module);
220+
}

0 commit comments

Comments
 (0)