From 80fc48ec8f9f4d85eab9468921353e978c410296 Mon Sep 17 00:00:00 2001 From: Stan Ulbrych Date: Mon, 26 Oct 2026 17:17:55 +0000 Subject: [PATCH 1/3] Add `list.discard()` method --- Doc/builtins/stdtypes.rst | 9 ++- Doc/builtins/threadsafety.rst | 6 +- Doc/builtins/time-complexity.rst | 2 + Doc/tutorial/datastructures.rst | 7 ++ Doc/whatsnew/3.16.rst | 4 ++ Lib/test/test_list.py | 65 +++++++++++++++++- ...-10-26-17-16-29.gh-issue-160724.23hdnD.rst | 2 + Objects/clinic/listobject.c.h | 26 +++++++- Objects/listobject.c | 66 ++++++++++++++----- 9 files changed, 166 insertions(+), 21 deletions(-) create mode 100644 Misc/NEWS.d/next/Core_and_Builtins/2026-10-26-17-16-29.gh-issue-160724.23hdnD.rst diff --git a/Doc/builtins/stdtypes.rst b/Doc/builtins/stdtypes.rst index cf05f947c5908df..653f188dffe0b58 100644 --- a/Doc/builtins/stdtypes.rst +++ b/Doc/builtins/stdtypes.rst @@ -1683,7 +1683,14 @@ application). Lists implement all of the :ref:`common ` and :ref:`mutable ` sequence operations. Lists also provide the - following additional method: + following additional methods: + + .. method:: list.discard(value, /) + + Remove the first item from the list where ``list[i] == value`` + if such an item is present. + + .. versionadded:: next .. method:: list.sort(*, key=None, reverse=False) diff --git a/Doc/builtins/threadsafety.rst b/Doc/builtins/threadsafety.rst index 32600a35ef6eeaa..e5dddfaa288cb7c 100644 --- a/Doc/builtins/threadsafety.rst +++ b/Doc/builtins/threadsafety.rst @@ -165,9 +165,9 @@ intermediate states since they modify multiple elements in place: lst.pop(idx) # idx not at the end of the list, shifts elements lst *= x # copies elements in place -The :meth:`~list.remove` method may allow concurrent modifications since -element comparison may execute arbitrary Python code (via -:meth:`~object.__eq__`). +The :meth:`~list.remove` and :meth:`~list.discard` methods may allow +concurrent modifications since element comparison may execute arbitrary +Python code (via :meth:`~object.__eq__`). :meth:`~list.extend` is safe to call from multiple threads. However, its guarantees depend on the iterable passed to it. If it is a :class:`list`, a diff --git a/Doc/builtins/time-complexity.rst b/Doc/builtins/time-complexity.rst index 5ce02ac2761b621..444087047a4d3a0 100644 --- a/Doc/builtins/time-complexity.rst +++ b/Doc/builtins/time-complexity.rst @@ -47,6 +47,8 @@ If you need to add or remove at both ends, consider using a - *O*\ (1) * - Delete item (``del l[k]``) [2]_ - *O*\ (*n* - *k*) + * - Discard (``l.discard(x)``, ``l.remove(x)``) + - *O*\ (*n*) * - Iteration - *O*\ (*n*) * - Get slice (``l[i:j]``) diff --git a/Doc/tutorial/datastructures.rst b/Doc/tutorial/datastructures.rst index 77dd8a6e66d5cc4..daffadb01687229 100644 --- a/Doc/tutorial/datastructures.rst +++ b/Doc/tutorial/datastructures.rst @@ -43,6 +43,13 @@ of the methods of list objects: :exc:`ValueError` if there is no such item. +.. method:: list.discard(value, /) + :noindex: + + Remove the first item from the list whose value is equal to *value*, if there + is such an item. + + .. method:: list.pop(index=-1, /) :noindex: diff --git a/Doc/whatsnew/3.16.rst b/Doc/whatsnew/3.16.rst index e342aa71c7924ea..70e6a8510cbd8c5 100644 --- a/Doc/whatsnew/3.16.rst +++ b/Doc/whatsnew/3.16.rst @@ -107,6 +107,10 @@ Other language changes :func:`asyncio.print_call_graph` can see through them into the awaited code. (Contributed by Kumar Aditya in :gh:`157361`.) +* Added :meth:`list.discard`, which removes the first occurrence of a value + from a list if it is present, like :meth:`set.discard`. + (Contributed by Stan Ulbrych in :gh:`160724`.) + Default interactive shell ========================= diff --git a/Lib/test/test_list.py b/Lib/test/test_list.py index 260f9f4f607f152..6b31cb3a28f1240 100644 --- a/Lib/test/test_list.py +++ b/Lib/test/test_list.py @@ -2,7 +2,7 @@ import sys import textwrap from test import list_tests, support -from test.support import cpython_only +from test.support import ALWAYS_EQ, NEVER_EQ, cpython_only from test.support.script_helper import assert_python_failure, assert_python_ok import pickle import unittest @@ -331,6 +331,66 @@ def test_preallocation(self): self.assertEqual(iter_size, sys.getsizeof(list([0] * 10))) self.assertEqual(iter_size, sys.getsizeof(list(range(10)))) + def test_discard(self): + a = [0, 0, 1] + a.discard(1) + self.assertEqual(a, [0, 0]) + a.discard(0) + self.assertEqual(a, [0]) + a.discard(0) + self.assertEqual(a, []) + a.discard(0) + self.assertEqual(a, []) + + self.assertRaises(TypeError, a.discard) + + a = [1, 2] + a.discard(NEVER_EQ) + self.assertEqual(a, [1, 2]) + a.discard(ALWAYS_EQ) + self.assertEqual(a, [2]) + a = [ALWAYS_EQ] + a.discard(1) + self.assertEqual(a, []) + a = [ALWAYS_EQ] + a.discard(NEVER_EQ) + self.assertEqual(a, []) + a = [NEVER_EQ] + a.discard(ALWAYS_EQ) + self.assertEqual(a, [NEVER_EQ]) + + class BadExc(Exception): + pass + + class BadCmp: + def __eq__(self, other): + if other == 2: + raise BadExc() + return False + + a = [0, 1, 2, 3] + self.assertRaises(BadExc, a.discard, BadCmp()) + + class BadCmp2: + def __eq__(self, other): + raise BadExc() + + d = list('abcdefghcij') + d.discard('c') + self.assertEqual(d, list('abdefghcij')) + d.discard('c') + self.assertEqual(d, list('abdefghij')) + d.discard('c') + self.assertEqual(d, list('abdefghij')) + + # Handle comparison errors + d = ['a', 'b', BadCmp2(), 'c'] + e = list(d) + self.assertRaises(BadExc, d.discard, 'c') + for x, y in zip(d, e): + # verify that original order and values are retained. + self.assertIs(x, y) + def test_count_index_remove_crashes(self): # bpo-38610: The count(), index(), and remove() methods were not # holding strong references to list elements while calling @@ -356,6 +416,9 @@ def __eq__(self, other): with self.assertRaises(ValueError): lst.remove(lst) + lst = L([X()]) + lst.discard(lst) + # bpo-39453: list.__contains__ was not holding strong references # to list elements while calling PyObject_RichCompareBool(). lst = [X(), X()] diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-10-26-17-16-29.gh-issue-160724.23hdnD.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-10-26-17-16-29.gh-issue-160724.23hdnD.rst new file mode 100644 index 000000000000000..e7344284a2f85fb --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-10-26-17-16-29.gh-issue-160724.23hdnD.rst @@ -0,0 +1,2 @@ +Add :meth:`list.discard`, which removes the first occurrence of a value from +a list if it is present. diff --git a/Objects/clinic/listobject.c.h b/Objects/clinic/listobject.c.h index 359b9c202694472..fb804f7147de333 100644 --- a/Objects/clinic/listobject.c.h +++ b/Objects/clinic/listobject.c.h @@ -455,6 +455,30 @@ list_remove(PyObject *self, PyObject *value) return return_value; } +PyDoc_STRVAR(list_discard__doc__, +"discard($self, value, /)\n" +"--\n" +"\n" +"Remove first occurrence of value if it is present."); + +#define LIST_DISCARD_METHODDEF \ + {"discard", (PyCFunction)list_discard, METH_O, list_discard__doc__}, + +static PyObject * +list_discard_impl(PyListObject *self, PyObject *value); + +static PyObject * +list_discard(PyObject *self, PyObject *value) +{ + PyObject *return_value = NULL; + + Py_BEGIN_CRITICAL_SECTION(self); + return_value = list_discard_impl((PyListObject *)self, value); + Py_END_CRITICAL_SECTION(); + + return return_value; +} + PyDoc_STRVAR(list___init____doc__, "list(iterable=(), /)\n" "--\n" @@ -528,4 +552,4 @@ list___reversed__(PyObject *self, PyObject *Py_UNUSED(ignored)) { return list___reversed___impl((PyListObject *)self); } -/*[clinic end generated code: output=9eab9551fc4cea1c input=a9049054013a1b77]*/ +/*[clinic end generated code: output=4c2a7e07d1b3ac4b input=a9049054013a1b77]*/ diff --git a/Objects/listobject.c b/Objects/listobject.c index a01f40d57181da5..b045f455e6f3451 100644 --- a/Objects/listobject.c +++ b/Objects/listobject.c @@ -3462,6 +3462,29 @@ list_count_impl(PyListObject *self, PyObject *value) return PyLong_FromSsize_t(count); } +static int +list_remove_lock_held(PyListObject *self, PyObject *value) +{ + Py_ssize_t i; + + for (i = 0; i < Py_SIZE(self); i++) { + PyObject *obj = self->ob_item[i]; + Py_INCREF(obj); + int cmp = PyObject_RichCompareBool(obj, value, Py_EQ); + Py_DECREF(obj); + if (cmp > 0) { + if (list_ass_slice_lock_held(self, i, i+1, NULL) < 0) { + return -1; + } + return 1; + } + else if (cmp < 0) { + return -1; + } + } + return 0; +} + /*[clinic input] @critical_section list.remove @@ -3478,23 +3501,35 @@ static PyObject * list_remove_impl(PyListObject *self, PyObject *value) /*[clinic end generated code: output=b9b76a6633b18778 input=26c813dbb95aa93b]*/ { - Py_ssize_t i; + int res = list_remove_lock_held(self, value); + if (res < 0) { + return NULL; + } + if (res == 0) { + PyErr_SetString(PyExc_ValueError, "list.remove(x): x not in list"); + return NULL; + } + Py_RETURN_NONE; +} - for (i = 0; i < Py_SIZE(self); i++) { - PyObject *obj = self->ob_item[i]; - Py_INCREF(obj); - int cmp = PyObject_RichCompareBool(obj, value, Py_EQ); - Py_DECREF(obj); - if (cmp > 0) { - if (list_ass_slice_lock_held(self, i, i+1, NULL) == 0) - Py_RETURN_NONE; - return NULL; - } - else if (cmp < 0) - return NULL; +/*[clinic input] +@critical_section +list.discard + + value: object + / + +Remove first occurrence of value if it is present. +[clinic start generated code]*/ + +static PyObject * +list_discard_impl(PyListObject *self, PyObject *value) +/*[clinic end generated code: output=4d8c1aee9a101f8c input=e1abe80fdc97f48e]*/ +{ + if (list_remove_lock_held(self, value) < 0) { + return NULL; } - PyErr_SetString(PyExc_ValueError, "list.remove(x): x not in list"); - return NULL; + Py_RETURN_NONE; } static int @@ -3685,6 +3720,7 @@ static PyMethodDef list_methods[] = { LIST_EXTEND_METHODDEF LIST_POP_METHODDEF LIST_REMOVE_METHODDEF + LIST_DISCARD_METHODDEF LIST_INDEX_METHODDEF LIST_COUNT_METHODDEF LIST_REVERSE_METHODDEF From 139c2777a652e7b10dc2b478e522b41324c66ba5 Mon Sep 17 00:00:00 2001 From: Stan Ulbrych Date: Mon, 26 Oct 2026 17:32:27 +0000 Subject: [PATCH 2/3] Add `UserList.discard()` to match `list` --- Lib/collections/__init__.py | 3 ++ Lib/test/list_tests.py | 60 +++++++++++++++++++++++++++++++++++ Lib/test/test_descrtut.py | 1 + Lib/test/test_list.py | 62 +------------------------------------ 4 files changed, 65 insertions(+), 61 deletions(-) diff --git a/Lib/collections/__init__.py b/Lib/collections/__init__.py index f23129d7e3275e7..09f8b762e1757d9 100644 --- a/Lib/collections/__init__.py +++ b/Lib/collections/__init__.py @@ -1386,6 +1386,9 @@ def pop(self, i=-1): def remove(self, item): self.data.remove(item) + def discard(self, item): + self.data.discard(item) + def clear(self): self.data.clear() diff --git a/Lib/test/list_tests.py b/Lib/test/list_tests.py index ad9a9ea83035fb4..ce98aada135c15b 100644 --- a/Lib/test/list_tests.py +++ b/Lib/test/list_tests.py @@ -373,6 +373,66 @@ def __eq__(self, other): # verify that original order and values are retained. self.assertIs(x, y) + def test_discard(self): + a = self.type2test([0, 0, 1]) + a.discard(1) + self.assertEqual(a, self.type2test([0, 0])) + a.discard(0) + self.assertEqual(a, self.type2test([0])) + a.discard(0) + self.assertEqual(a, self.type2test([])) + a.discard(0) + self.assertEqual(a, self.type2test([])) + + self.assertRaises(TypeError, a.discard) + + a = self.type2test([1, 2]) + a.discard(NEVER_EQ) + self.assertEqual(a, self.type2test([1, 2])) + a.discard(ALWAYS_EQ) + self.assertEqual(a, self.type2test([2])) + a = self.type2test([ALWAYS_EQ]) + a.discard(1) + self.assertEqual(a, self.type2test([])) + a = self.type2test([ALWAYS_EQ]) + a.discard(NEVER_EQ) + self.assertEqual(a, self.type2test([])) + a = self.type2test([NEVER_EQ]) + a.discard(ALWAYS_EQ) + self.assertEqual(a, self.type2test([NEVER_EQ])) + + class BadExc(Exception): + pass + + class BadCmp: + def __eq__(self, other): + if other == 2: + raise BadExc() + return False + + a = self.type2test([0, 1, 2, 3]) + self.assertRaises(BadExc, a.discard, BadCmp()) + + class BadCmp2: + def __eq__(self, other): + raise BadExc() + + d = self.type2test('abcdefghcij') + d.discard('c') + self.assertEqual(d, self.type2test('abdefghcij')) + d.discard('c') + self.assertEqual(d, self.type2test('abdefghij')) + d.discard('c') + self.assertEqual(d, self.type2test('abdefghij')) + + # Handle comparison errors + d = self.type2test(['a', 'b', BadCmp2(), 'c']) + e = self.type2test(d) + self.assertRaises(BadExc, d.discard, 'c') + for x, y in zip(d, e): + # verify that original order and values are retained. + self.assertIs(x, y) + def test_index(self): super().test_index() a = self.type2test([-2, -1, 0, 0, 1, 2]) diff --git a/Lib/test/test_descrtut.py b/Lib/test/test_descrtut.py index 828440a993a975d..16f27bf7ca8ac7c 100644 --- a/Lib/test/test_descrtut.py +++ b/Lib/test/test_descrtut.py @@ -209,6 +209,7 @@ def merge(self, other): 'clear', 'copy', 'count', + 'discard', 'extend', 'index', 'insert', diff --git a/Lib/test/test_list.py b/Lib/test/test_list.py index 6b31cb3a28f1240..503082984263892 100644 --- a/Lib/test/test_list.py +++ b/Lib/test/test_list.py @@ -2,7 +2,7 @@ import sys import textwrap from test import list_tests, support -from test.support import ALWAYS_EQ, NEVER_EQ, cpython_only +from test.support import cpython_only from test.support.script_helper import assert_python_failure, assert_python_ok import pickle import unittest @@ -331,66 +331,6 @@ def test_preallocation(self): self.assertEqual(iter_size, sys.getsizeof(list([0] * 10))) self.assertEqual(iter_size, sys.getsizeof(list(range(10)))) - def test_discard(self): - a = [0, 0, 1] - a.discard(1) - self.assertEqual(a, [0, 0]) - a.discard(0) - self.assertEqual(a, [0]) - a.discard(0) - self.assertEqual(a, []) - a.discard(0) - self.assertEqual(a, []) - - self.assertRaises(TypeError, a.discard) - - a = [1, 2] - a.discard(NEVER_EQ) - self.assertEqual(a, [1, 2]) - a.discard(ALWAYS_EQ) - self.assertEqual(a, [2]) - a = [ALWAYS_EQ] - a.discard(1) - self.assertEqual(a, []) - a = [ALWAYS_EQ] - a.discard(NEVER_EQ) - self.assertEqual(a, []) - a = [NEVER_EQ] - a.discard(ALWAYS_EQ) - self.assertEqual(a, [NEVER_EQ]) - - class BadExc(Exception): - pass - - class BadCmp: - def __eq__(self, other): - if other == 2: - raise BadExc() - return False - - a = [0, 1, 2, 3] - self.assertRaises(BadExc, a.discard, BadCmp()) - - class BadCmp2: - def __eq__(self, other): - raise BadExc() - - d = list('abcdefghcij') - d.discard('c') - self.assertEqual(d, list('abdefghcij')) - d.discard('c') - self.assertEqual(d, list('abdefghij')) - d.discard('c') - self.assertEqual(d, list('abdefghij')) - - # Handle comparison errors - d = ['a', 'b', BadCmp2(), 'c'] - e = list(d) - self.assertRaises(BadExc, d.discard, 'c') - for x, y in zip(d, e): - # verify that original order and values are retained. - self.assertIs(x, y) - def test_count_index_remove_crashes(self): # bpo-38610: The count(), index(), and remove() methods were not # holding strong references to list elements while calling From 687d42d42a30e917e14f2639626e6089ff0e17ce Mon Sep 17 00:00:00 2001 From: Stan Ulbrych Date: Mon, 26 Oct 2026 17:41:08 +0000 Subject: [PATCH 3/3] Return `True` if an item was removed, `False` otherwise. --- Doc/builtins/stdtypes.rst | 3 ++- Doc/tutorial/datastructures.rst | 2 +- Doc/whatsnew/3.16.rst | 2 +- Lib/collections/__init__.py | 2 +- Lib/test/list_tests.py | 8 ++++---- .../2026-10-26-17-16-29.gh-issue-160724.23hdnD.rst | 2 +- Objects/clinic/listobject.c.h | 6 ++++-- Objects/listobject.c | 9 ++++++--- 8 files changed, 20 insertions(+), 14 deletions(-) diff --git a/Doc/builtins/stdtypes.rst b/Doc/builtins/stdtypes.rst index 653f188dffe0b58..ef3a6eff3eb0b36 100644 --- a/Doc/builtins/stdtypes.rst +++ b/Doc/builtins/stdtypes.rst @@ -1688,7 +1688,8 @@ application). .. method:: list.discard(value, /) Remove the first item from the list where ``list[i] == value`` - if such an item is present. + if such an item is present. Return ``True`` if an item was removed, + ``False`` otherwise. .. versionadded:: next diff --git a/Doc/tutorial/datastructures.rst b/Doc/tutorial/datastructures.rst index daffadb01687229..238ca619e364d27 100644 --- a/Doc/tutorial/datastructures.rst +++ b/Doc/tutorial/datastructures.rst @@ -47,7 +47,7 @@ of the methods of list objects: :noindex: Remove the first item from the list whose value is equal to *value*, if there - is such an item. + is such an item. Return ``True`` if an item was removed, ``False`` otherwise. .. method:: list.pop(index=-1, /) diff --git a/Doc/whatsnew/3.16.rst b/Doc/whatsnew/3.16.rst index 70e6a8510cbd8c5..4742a9efd9af16b 100644 --- a/Doc/whatsnew/3.16.rst +++ b/Doc/whatsnew/3.16.rst @@ -108,7 +108,7 @@ Other language changes (Contributed by Kumar Aditya in :gh:`157361`.) * Added :meth:`list.discard`, which removes the first occurrence of a value - from a list if it is present, like :meth:`set.discard`. + from a list if it is present and returns whether an item was removed. (Contributed by Stan Ulbrych in :gh:`160724`.) diff --git a/Lib/collections/__init__.py b/Lib/collections/__init__.py index 09f8b762e1757d9..00f487916c06ea6 100644 --- a/Lib/collections/__init__.py +++ b/Lib/collections/__init__.py @@ -1387,7 +1387,7 @@ def remove(self, item): self.data.remove(item) def discard(self, item): - self.data.discard(item) + return self.data.discard(item) def clear(self): self.data.clear() diff --git a/Lib/test/list_tests.py b/Lib/test/list_tests.py index ce98aada135c15b..645627383bfacdc 100644 --- a/Lib/test/list_tests.py +++ b/Lib/test/list_tests.py @@ -375,13 +375,13 @@ def __eq__(self, other): def test_discard(self): a = self.type2test([0, 0, 1]) - a.discard(1) + self.assertIs(a.discard(1), True) self.assertEqual(a, self.type2test([0, 0])) - a.discard(0) + self.assertIs(a.discard(0), True) self.assertEqual(a, self.type2test([0])) - a.discard(0) + self.assertIs(a.discard(0), True) self.assertEqual(a, self.type2test([])) - a.discard(0) + self.assertIs(a.discard(0), False) self.assertEqual(a, self.type2test([])) self.assertRaises(TypeError, a.discard) diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-10-26-17-16-29.gh-issue-160724.23hdnD.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-10-26-17-16-29.gh-issue-160724.23hdnD.rst index e7344284a2f85fb..ac33690e98662f4 100644 --- a/Misc/NEWS.d/next/Core_and_Builtins/2026-10-26-17-16-29.gh-issue-160724.23hdnD.rst +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-10-26-17-16-29.gh-issue-160724.23hdnD.rst @@ -1,2 +1,2 @@ Add :meth:`list.discard`, which removes the first occurrence of a value from -a list if it is present. +a list if it is present and returns whether an item was removed. diff --git a/Objects/clinic/listobject.c.h b/Objects/clinic/listobject.c.h index fb804f7147de333..e5a491c1d89066f 100644 --- a/Objects/clinic/listobject.c.h +++ b/Objects/clinic/listobject.c.h @@ -459,7 +459,9 @@ PyDoc_STRVAR(list_discard__doc__, "discard($self, value, /)\n" "--\n" "\n" -"Remove first occurrence of value if it is present."); +"Remove first occurrence of value if it is present.\n" +"\n" +"Return True if an item was removed, False otherwise."); #define LIST_DISCARD_METHODDEF \ {"discard", (PyCFunction)list_discard, METH_O, list_discard__doc__}, @@ -552,4 +554,4 @@ list___reversed__(PyObject *self, PyObject *Py_UNUSED(ignored)) { return list___reversed___impl((PyListObject *)self); } -/*[clinic end generated code: output=4c2a7e07d1b3ac4b input=a9049054013a1b77]*/ +/*[clinic end generated code: output=fe8e392cf1c521aa input=a9049054013a1b77]*/ diff --git a/Objects/listobject.c b/Objects/listobject.c index b045f455e6f3451..32aa9ae8a505659 100644 --- a/Objects/listobject.c +++ b/Objects/listobject.c @@ -3520,16 +3520,19 @@ list.discard / Remove first occurrence of value if it is present. + +Return True if an item was removed, False otherwise. [clinic start generated code]*/ static PyObject * list_discard_impl(PyListObject *self, PyObject *value) -/*[clinic end generated code: output=4d8c1aee9a101f8c input=e1abe80fdc97f48e]*/ +/*[clinic end generated code: output=4d8c1aee9a101f8c input=6330bc648285d994]*/ { - if (list_remove_lock_held(self, value) < 0) { + int res = list_remove_lock_held(self, value); + if (res < 0) { return NULL; } - Py_RETURN_NONE; + return PyBool_FromLong(res); } static int