diff --git a/Lib/test/test_set.py b/Lib/test/test_set.py index c0df9507bd7f5e..9e4c45c840ff5e 100644 --- a/Lib/test/test_set.py +++ b/Lib/test/test_set.py @@ -9,6 +9,7 @@ import warnings import weakref from random import randrange, shuffle +import _testcapi from test import support from test.support import warnings_helper @@ -2154,6 +2155,31 @@ def test_cuboctahedron(self): for cubevert in edge: self.assertIn(cubevert, g) +class TestPySet_Add(unittest.TestCase): + def test_set(self): + # Test the PySet_Add c-api for set objects + s = set() + self.assertEqual(_testcapi.pyset_add(s, 1), {1}) + self.assertRaises(TypeError, _testcapi.pyset_add, s, []) + + def test_frozenset(self): + # Test the PySet_Add c-api for frozenset objects + self.assertEqual(_testcapi.pyset_add(frozenset(), 1), frozenset([1])) + frozen_set = frozenset() + # if the argument to PySet_Add is a frozenset that is not uniquely references an error is generated + self.assertRaises(SystemError, _testcapi.pyset_add, frozen_set, 1) + + def test_frozenset_gc_tracking(self): + # see gh-140234 + class TrackedHashableClass(): + pass + + a = TrackedHashableClass() + result_set = _testcapi.pyset_add(frozenset(), 1) + self.assertFalse(gc.is_tracked(result_set)) + result_set = _testcapi.pyset_add(frozenset(), a) + self.assertTrue(gc.is_tracked(result_set)) + #============================================================================== diff --git a/Lib/test/test_sys.py b/Lib/test/test_sys.py index 1198c6d35113c8..779f48750326d5 100644 --- a/Lib/test/test_sys.py +++ b/Lib/test/test_sys.py @@ -1876,7 +1876,10 @@ class S(set): check(S(), set(), '3P') class FS(frozenset): __slots__ = 'a', 'b', 'c' - check(FS(), frozenset(), '3P') + + class mytuple(tuple): + pass + check(FS([mytuple()]), frozenset([mytuple()]), '3P') from collections import OrderedDict class OD(OrderedDict): __slots__ = 'a', 'b', 'c' diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2025-10-16-22-36-05.gh-issue-140232.u3srgv.rst b/Misc/NEWS.d/next/Core_and_Builtins/2025-10-16-22-36-05.gh-issue-140232.u3srgv.rst new file mode 100644 index 00000000000000..e40daacbc45b7b --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2025-10-16-22-36-05.gh-issue-140232.u3srgv.rst @@ -0,0 +1 @@ +Frozenset objects with immutable elements are no longer tracked by the garbage collector. diff --git a/Modules/_testcapimodule.c b/Modules/_testcapimodule.c index 4e73be20e1b709..176d807c586305 100644 --- a/Modules/_testcapimodule.c +++ b/Modules/_testcapimodule.c @@ -2435,6 +2435,26 @@ test_critical_sections(PyObject *module, PyObject *Py_UNUSED(args)) } + +static PyObject * +// Interface to PySet_Add, returning the set +pyset_add(PyObject* self, PyObject* const* args, Py_ssize_t nargsf) +{ + Py_ssize_t nargs = _PyVectorcall_NARGS(nargsf); + if (nargs != 2) { + PyErr_SetString(PyExc_ValueError, "pyset_add requires exactly two arguments"); + return NULL; + } + PyObject *set = args[0]; + PyObject *item = args[1]; + + int return_value = PySet_Add(set, item); + if (return_value < 0) { + return NULL; + } + return Py_NewRef(set); +} + // Used by `finalize_thread_hang`. #if defined(_POSIX_THREADS) && !defined(__wasi__) static void finalize_thread_hang_cleanup_callback(void *Py_UNUSED(arg)) { @@ -2625,7 +2645,7 @@ static PyMethodDef TestMethods[] = { {"return_null_without_error", return_null_without_error, METH_NOARGS}, {"return_result_with_error", return_result_with_error, METH_NOARGS}, {"getitem_with_error", getitem_with_error, METH_VARARGS}, - {"Py_CompileString", pycompilestring, METH_O}, + {"Py_CompileString", pycompilestring, METH_O}, {"raise_SIGINT_then_send_None", raise_SIGINT_then_send_None, METH_VARARGS}, {"stack_pointer", stack_pointer, METH_NOARGS}, #ifdef W_STOPCODE @@ -2646,6 +2666,7 @@ static PyMethodDef TestMethods[] = { {"gen_get_code", gen_get_code, METH_O, NULL}, {"get_feature_macros", get_feature_macros, METH_NOARGS, NULL}, {"test_code_api", test_code_api, METH_NOARGS, NULL}, + {"pyset_add", _PyCFunction_CAST(pyset_add), METH_FASTCALL, NULL}, {"settrace_to_error", settrace_to_error, METH_O, NULL}, {"settrace_to_record", settrace_to_record, METH_O, NULL}, {"test_macros", test_macros, METH_NOARGS, NULL}, diff --git a/Objects/setobject.c b/Objects/setobject.c index 213bd821d8a1b9..60542a06e69323 100644 --- a/Objects/setobject.c +++ b/Objects/setobject.c @@ -1174,6 +1174,26 @@ make_new_set_basetype(PyTypeObject *type, PyObject *iterable) return make_new_set(type, iterable); } +void +// gh-140232: check whether a frozenset can be untracked from the GC +_PyFrozenSet_MaybeUntrack(PyObject *op) +{ + assert(op != NULL); + // subclasses of a frozenset can generate reference cycles, so do not untrack + if (!PyFrozenSet_CheckExact(op)) { + return; + } + // if no elements of a frozenset are tracked by the GC, we untrack the object + Py_ssize_t pos = 0; + setentry *entry; + while (set_next((PySetObject *)op, &pos, &entry)) { + if (_PyObject_GC_MAY_BE_TRACKED(entry->key)) { + return; + } + } + _PyObject_GC_UNTRACK(op); +} + static PyObject * make_new_frozenset(PyTypeObject *type, PyObject *iterable) { @@ -1185,7 +1205,11 @@ make_new_frozenset(PyTypeObject *type, PyObject *iterable) /* frozenset(f) is idempotent */ return Py_NewRef(iterable); } - return make_new_set(type, iterable); + PyObject *obj = make_new_set(type, iterable); + if (obj != NULL) { + _PyFrozenSet_MaybeUntrack(obj); + } + return obj; } static PyObject * @@ -2710,7 +2734,11 @@ PySet_New(PyObject *iterable) PyObject * PyFrozenSet_New(PyObject *iterable) { - return make_new_set(&PyFrozenSet_Type, iterable); + PyObject *result = make_new_set(&PyFrozenSet_Type, iterable); + if (result != 0) { + _PyFrozenSet_MaybeUntrack(result); + } + return result; } Py_ssize_t @@ -2779,6 +2807,9 @@ PySet_Add(PyObject *anyset, PyObject *key) return -1; } + if (PyFrozenSet_CheckExact(anyset) && PyObject_GC_IsTracked(key) && !PyObject_GC_IsTracked(anyset) ) { + _PyObject_GC_TRACK(anyset); + } int rv; Py_BEGIN_CRITICAL_SECTION(anyset); rv = set_add_key((PySetObject *)anyset, key);