From 2e5843e13fcfd768a435d82e6182af403844432c Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Fri, 19 Jun 2026 20:49:26 +0300 Subject: [PATCH 1/2] gh-151744: Add curses.nofilter() (GH-151747) Wrap the ncurses nofilter() function, which undoes the effect of filter(). Without it there is no way to restore normal screen sizing after a curses.filter() call in the same process. Co-authored-by: Claude Opus 4.8 (1M context) --- Doc/library/curses.rst | 11 ++++ Doc/whatsnew/3.16.rst | 6 ++ Lib/test/test_curses.py | 3 +- ...-06-19-18-40-00.gh-issue-151744.Kp7mNq.rst | 1 + Modules/_cursesmodule.c | 23 +++++++ Modules/clinic/_cursesmodule.c.h | 32 +++++++++- configure | 60 +++++++++++++++++++ configure.ac | 1 + pyconfig.h.in | 3 + 9 files changed, 138 insertions(+), 2 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-06-19-18-40-00.gh-issue-151744.Kp7mNq.rst diff --git a/Doc/library/curses.rst b/Doc/library/curses.rst index 5726aee5af89b12..d7873054d6b9154 100644 --- a/Doc/library/curses.rst +++ b/Doc/library/curses.rst @@ -204,6 +204,17 @@ The module :mod:`!curses` defines the following functions: character-at-a-time line editing without touching the rest of the screen. +.. function:: nofilter() + + Undo the effect of a previous :func:`.filter` call. + Like :func:`.filter`, it must be called before :func:`initscr` so that the + next initialization uses the full screen again. + + Availability: if the underlying curses library provides ``nofilter()``. + + .. versionadded:: next + + .. function:: flash() Flash the screen. That is, change it to reverse-video and then change it back diff --git a/Doc/whatsnew/3.16.rst b/Doc/whatsnew/3.16.rst index 8e4c4a1e9b1de02..0a110795f371eb7 100644 --- a/Doc/whatsnew/3.16.rst +++ b/Doc/whatsnew/3.16.rst @@ -86,6 +86,12 @@ New modules Improved modules ================ +curses +------ + +* Add :func:`curses.nofilter`, which undoes the effect of :func:`curses.filter`. + (Contributed by Serhiy Storchaka in :gh:`151744`.) + gzip ---- diff --git a/Lib/test/test_curses.py b/Lib/test/test_curses.py index c6a762c04e05253..98f1a7c8a0a2c5c 100644 --- a/Lib/test/test_curses.py +++ b/Lib/test/test_curses.py @@ -120,8 +120,9 @@ def setUp(self): @requires_curses_func('filter') def test_filter(self): # TODO: Should be called before initscr() or newterm() are called. - # TODO: nofilter() curses.filter() + if hasattr(curses, 'nofilter'): + curses.nofilter() @requires_curses_func('use_env') def test_use_env(self): diff --git a/Misc/NEWS.d/next/Library/2026-06-19-18-40-00.gh-issue-151744.Kp7mNq.rst b/Misc/NEWS.d/next/Library/2026-06-19-18-40-00.gh-issue-151744.Kp7mNq.rst new file mode 100644 index 000000000000000..24328a8f9f93427 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-06-19-18-40-00.gh-issue-151744.Kp7mNq.rst @@ -0,0 +1 @@ +Add :func:`curses.nofilter`, which undoes the effect of :func:`curses.filter`. diff --git a/Modules/_cursesmodule.c b/Modules/_cursesmodule.c index 02a8e2c1b1bc105..e60cba3ef87ead1 100644 --- a/Modules/_cursesmodule.c +++ b/Modules/_cursesmodule.c @@ -3220,6 +3220,28 @@ _curses_filter_impl(PyObject *module) } #endif +#ifdef HAVE_CURSES_NOFILTER +/*[clinic input] +_curses.nofilter + +Undo the effect of a preceding filter() call. + +Must be called before initscr(). It restores the normal behaviour +disabled by filter(), so that the next initscr() uses the full screen +rather than a single line. +[clinic start generated code]*/ + +static PyObject * +_curses_nofilter_impl(PyObject *module) +/*[clinic end generated code: output=d95ca4d48a6bdbdf input=58aea83b1a5c969f]*/ +{ + /* not checking for PyCursesInitialised here since nofilter() must + be called before initscr() */ + nofilter(); + Py_RETURN_NONE; +} +#endif + /*[clinic input] _curses.baudrate @@ -5321,6 +5343,7 @@ static PyMethodDef cursesmodule_methods[] = { _CURSES_ENDWIN_METHODDEF _CURSES_ERASECHAR_METHODDEF _CURSES_FILTER_METHODDEF + _CURSES_NOFILTER_METHODDEF _CURSES_FLASH_METHODDEF _CURSES_FLUSHINP_METHODDEF _CURSES_GETMOUSE_METHODDEF diff --git a/Modules/clinic/_cursesmodule.c.h b/Modules/clinic/_cursesmodule.c.h index cab9b068a561da4..f577368680ef572 100644 --- a/Modules/clinic/_cursesmodule.c.h +++ b/Modules/clinic/_cursesmodule.c.h @@ -1866,6 +1866,32 @@ _curses_filter(PyObject *module, PyObject *Py_UNUSED(ignored)) #endif /* defined(HAVE_CURSES_FILTER) */ +#if defined(HAVE_CURSES_NOFILTER) + +PyDoc_STRVAR(_curses_nofilter__doc__, +"nofilter($module, /)\n" +"--\n" +"\n" +"Undo the effect of a preceding filter() call.\n" +"\n" +"Must be called before initscr(). It restores the normal behaviour\n" +"disabled by filter(), so that the next initscr() uses the full screen\n" +"rather than a single line."); + +#define _CURSES_NOFILTER_METHODDEF \ + {"nofilter", (PyCFunction)_curses_nofilter, METH_NOARGS, _curses_nofilter__doc__}, + +static PyObject * +_curses_nofilter_impl(PyObject *module); + +static PyObject * +_curses_nofilter(PyObject *module, PyObject *Py_UNUSED(ignored)) +{ + return _curses_nofilter_impl(module); +} + +#endif /* defined(HAVE_CURSES_NOFILTER) */ + PyDoc_STRVAR(_curses_baudrate__doc__, "baudrate($module, /)\n" "--\n" @@ -4407,6 +4433,10 @@ _curses_has_extended_color_support(PyObject *module, PyObject *Py_UNUSED(ignored #define _CURSES_FILTER_METHODDEF #endif /* !defined(_CURSES_FILTER_METHODDEF) */ +#ifndef _CURSES_NOFILTER_METHODDEF + #define _CURSES_NOFILTER_METHODDEF +#endif /* !defined(_CURSES_NOFILTER_METHODDEF) */ + #ifndef _CURSES_GETSYX_METHODDEF #define _CURSES_GETSYX_METHODDEF #endif /* !defined(_CURSES_GETSYX_METHODDEF) */ @@ -4486,4 +4516,4 @@ _curses_has_extended_color_support(PyObject *module, PyObject *Py_UNUSED(ignored #ifndef _CURSES_ASSUME_DEFAULT_COLORS_METHODDEF #define _CURSES_ASSUME_DEFAULT_COLORS_METHODDEF #endif /* !defined(_CURSES_ASSUME_DEFAULT_COLORS_METHODDEF) */ -/*[clinic end generated code: output=11ab7c93cbc13e75 input=a9049054013a1b77]*/ +/*[clinic end generated code: output=7494804bf2c4d1f5 input=a9049054013a1b77]*/ diff --git a/configure b/configure index d73a88d04016ddb..12fd0d15698ac1d 100755 --- a/configure +++ b/configure @@ -30286,6 +30286,66 @@ fi + { printf "%s\n" "$as_me:${as_lineno-$LINENO}: checking for curses function nofilter" >&5 +printf %s "checking for curses function nofilter... " >&6; } +if test ${ac_cv_lib_curses_nofilter+y} +then : + printf %s "(cached) " >&6 +else case e in #( + e) cat confdefs.h - <<_ACEOF >conftest.$ac_ext +/* end confdefs.h. */ + +#define NCURSES_OPAQUE 0 +#if defined(HAVE_NCURSESW_NCURSES_H) +# include +#elif defined(HAVE_NCURSESW_CURSES_H) +# include +#elif defined(HAVE_NCURSES_NCURSES_H) +# include +#elif defined(HAVE_NCURSES_CURSES_H) +# include +#elif defined(HAVE_NCURSES_H) +# include +#elif defined(HAVE_CURSES_H) +# include +#endif + +int +main (void) +{ + + #ifndef nofilter + void *x=nofilter + #endif + + ; + return 0; +} +_ACEOF +if ac_fn_c_try_compile "$LINENO" +then : + ac_cv_lib_curses_nofilter=yes +else case e in #( + e) ac_cv_lib_curses_nofilter=no ;; +esac +fi +rm -f core conftest.err conftest.$ac_objext conftest.beam conftest.$ac_ext + ;; +esac +fi +{ printf "%s\n" "$as_me:${as_lineno-$LINENO}: result: $ac_cv_lib_curses_nofilter" >&5 +printf "%s\n" "$ac_cv_lib_curses_nofilter" >&6; } + if test "x$ac_cv_lib_curses_nofilter" = xyes +then : + +printf "%s\n" "#define HAVE_CURSES_NOFILTER 1" >>confdefs.h + +fi + + + + + { printf "%s\n" "$as_me:${as_lineno-$LINENO}: checking for curses function has_key" >&5 printf %s "checking for curses function has_key... " >&6; } if test ${ac_cv_lib_curses_has_key+y} diff --git a/configure.ac b/configure.ac index 8ba134c77b34129..3b42fdfe40385dc 100644 --- a/configure.ac +++ b/configure.ac @@ -7192,6 +7192,7 @@ PY_CHECK_CURSES_FUNC([immedok]) PY_CHECK_CURSES_FUNC([syncok]) PY_CHECK_CURSES_FUNC([wchgat]) PY_CHECK_CURSES_FUNC([filter]) +PY_CHECK_CURSES_FUNC([nofilter]) PY_CHECK_CURSES_FUNC([has_key]) PY_CHECK_CURSES_FUNC([typeahead]) PY_CHECK_CURSES_FUNC([use_env]) diff --git a/pyconfig.h.in b/pyconfig.h.in index 999a55a5efd0fb0..2bef8d38497c547 100644 --- a/pyconfig.h.in +++ b/pyconfig.h.in @@ -218,6 +218,9 @@ /* Define if you have the 'is_term_resized' function. */ #undef HAVE_CURSES_IS_TERM_RESIZED +/* Define if you have the 'nofilter' function. */ +#undef HAVE_CURSES_NOFILTER + /* Define if you have the 'resizeterm' function. */ #undef HAVE_CURSES_RESIZETERM From 66cc04855100c3865bd01adfe92a3a02dbc3a914 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Sat, 20 Jun 2026 01:45:47 +0300 Subject: [PATCH 2/2] gh-151678: Add tests for tkinter font, image, variable, Misc and Wm methods (GH-151751) * font: copy(), the config alias, the displayof argument of measure and metrics, and the errors raised for invalid options and a wrong number of arguments; * image: the cget method and the config alias, and the errors raised by transparency_get and transparency_set; * variable: that initialize is an alias of set and the deprecated trace is an alias of trace_variable; * Misc: tk_focusNext, tk_focusPrev, tk_strictMotif, tk_bisque and option_readfile; * Wm: wm_iconphoto. Co-authored-by: Claude Opus 4.8 (1M context) --- Lib/test/test_tkinter/test_font.py | 33 +++++++++++++++ Lib/test/test_tkinter/test_images.py | 13 ++++++ Lib/test/test_tkinter/test_misc.py | 53 +++++++++++++++++++++++++ Lib/test/test_tkinter/test_variables.py | 3 ++ 4 files changed, 102 insertions(+) diff --git a/Lib/test/test_tkinter/test_font.py b/Lib/test/test_tkinter/test_font.py index ee147710fbfc857..8adc9e1151db4ed 100644 --- a/Lib/test/test_tkinter/test_font.py +++ b/Lib/test/test_tkinter/test_font.py @@ -21,6 +21,7 @@ def setUpClass(cls): cls.font = font.Font(root=cls.root, name=fontname, exists=False) def test_configure(self): + self.assertEqual(self.font.config, self.font.configure) options = self.font.configure() self.assertGreaterEqual(set(options), {'family', 'size', 'weight', 'slant', 'underline', 'overstrike'}) @@ -36,6 +37,26 @@ def test_configure(self): self.assertIsInstance(options[key], sizetype) self.assertIsInstance(self.font.cget(key), sizetype) self.assertIsInstance(self.font[key], sizetype) + self.assertRaisesRegex(tkinter.TclError, 'bad option "-spam"', + self.font.cget, 'spam') + self.assertRaisesRegex(tkinter.TclError, 'bad option "-spam"', + self.font.configure, spam='x') + self.assertRaises(TypeError, self.font.cget) + self.assertRaises(TypeError, self.font.cget, 'size', 'weight') + + def test_copy(self): + f = font.Font(root=self.root, family='Times', size=10, weight='bold') + copied = f.copy() + self.assertIsInstance(copied, font.Font) + self.assertIsNot(copied, f) + self.assertNotEqual(copied.name, f.name) + self.assertEqual(copied.actual(), f.actual()) + # The copy is independent of the original. + sizetype = int if self.wantobjects else str + copied.configure(size=20) + self.assertEqual(f.cget('size'), sizetype(10)) + self.assertEqual(copied.cget('size'), sizetype(20)) + self.assertRaises(TypeError, f.copy, 'x') def test_unicode_family(self): family = 'MS \u30b4\u30b7\u30c3\u30af' @@ -60,6 +81,9 @@ def test_actual(self): for key in 'size', 'underline', 'overstrike': self.assertIsInstance(options[key], sizetype) self.assertIsInstance(self.font.actual(key), sizetype) + self.assertRaisesRegex(tkinter.TclError, 'bad option "-spam"', + self.font.actual, 'spam') + self.assertRaises(TypeError, self.font.actual, 'size', 'weight', 'slant') def test_name(self): self.assertEqual(self.font.name, fontname) @@ -83,6 +107,11 @@ def test_equality(self): def test_measure(self): self.assertIsInstance(self.font.measure('abc'), int) + self.assertEqual(self.font.measure(''), 0) + self.assertIsInstance( + self.font.measure('abc', displayof=self.root), int) + self.assertRaises(TypeError, self.font.measure) + self.assertRaises(TypeError, self.font.measure, 'a', 'b', 'c') def test_metrics(self): metrics = self.font.metrics() @@ -90,8 +119,12 @@ def test_metrics(self): {'ascent', 'descent', 'linespace', 'fixed'}) for key in metrics: self.assertEqual(self.font.metrics(key), metrics[key]) + self.assertEqual(self.font.metrics(key, displayof=self.root), + metrics[key]) self.assertIsInstance(metrics[key], int) self.assertIsInstance(self.font.metrics(key), int) + self.assertRaisesRegex(tkinter.TclError, 'bad metric "-spam"', + self.font.metrics, 'spam') def test_families(self): families = font.families(self.root) diff --git a/Lib/test/test_tkinter/test_images.py b/Lib/test/test_tkinter/test_images.py index 3aca9515a33b248..f9b314da9e8a915 100644 --- a/Lib/test/test_tkinter/test_images.py +++ b/Lib/test/test_tkinter/test_images.py @@ -288,6 +288,11 @@ def test_configure_width_height(self): image.configure(height=10) self.assertEqual(image['width'], '20') self.assertEqual(image['height'], '10') + self.assertEqual(image.cget('width'), image['width']) + self.assertEqual(image.cget('height'), image['height']) + self.assertRaises(TypeError, image.cget) + self.assertRaises(TypeError, image.cget, 'width', 'height') + self.assertEqual(image.config, image.configure) self.assertEqual(image.width(), 20) self.assertEqual(image.height(), 10) @@ -656,6 +661,14 @@ def test_transparency(self): self.assertEqual(image.transparency_get(4, 6), True) image.transparency_set(4, 6, False) self.assertEqual(image.transparency_get(4, 6), False) + self.assertRaises(tkinter.TclError, image.transparency_get, -1, 0) + self.assertRaises(tkinter.TclError, image.transparency_get, 16, 0) + self.assertRaises(tkinter.TclError, image.transparency_set, -1, 0, True) + self.assertRaises(tkinter.TclError, image.transparency_set, 16, 0, True) + self.assertRaises(TypeError, image.transparency_get, 0) + self.assertRaises(TypeError, image.transparency_get, 0, 0, 0) + self.assertRaises(TypeError, image.transparency_set, 0, 0) + self.assertRaises(TypeError, image.transparency_set, 0, 0, True, 0) if __name__ == "__main__": diff --git a/Lib/test/test_tkinter/test_misc.py b/Lib/test/test_tkinter/test_misc.py index 1b32689bb5961d4..0819b77e6643bdc 100644 --- a/Lib/test/test_tkinter/test_misc.py +++ b/Lib/test/test_tkinter/test_misc.py @@ -7,6 +7,7 @@ from tkinter import TclError import enum from test import support +from test.support import os_helper from test.test_tkinter.support import setUpModule # noqa: F401 from test.test_tkinter.support import (AbstractTkTest, AbstractDefaultRootTest, requires_tk, get_tk_patchlevel) @@ -357,6 +358,19 @@ def test_option(self): self.root.option_clear() self.assertEqual(b.option_get('background', 'Background'), '') + def test_option_readfile(self): + self.addCleanup(self.root.option_clear) + self.addCleanup(os_helper.unlink, os_helper.TESTFN) + with open(os_helper.TESTFN, 'w') as f: + f.write('*Button.background: red\n') + self.root.option_readfile(os_helper.TESTFN) + b = tkinter.Button(self.root) + self.assertEqual(b.option_get('background', 'Background'), 'red') + self.assertRaises(TclError, self.root.option_readfile, + os_helper.TESTFN + '.nonexistent') + self.assertRaises(TypeError, self.root.option_readfile) + self.assertRaises(TypeError, self.root.option_readfile, 'a', 'b', 'c') + def test_nametowidget(self): b = tkinter.Button(self.root, name='btn') self.assertIs(self.root.nametowidget('btn'), b) @@ -417,6 +431,38 @@ def test_bell(self): self.root.bell() # No exception. self.root.bell(displayof=self.root) + def test_tk_focusNext_focusPrev(self): + f = tkinter.Frame(self.root) + f.pack() + entries = [tkinter.Entry(f) for _ in range(3)] + for entry in entries: + entry.pack() + # tk_focusNext skips widgets that are not viewable. + entries[-1].wait_visibility() + self.assertIs(entries[0].tk_focusNext(), entries[1]) + self.assertIs(entries[1].tk_focusNext(), entries[2]) + self.assertIs(entries[2].tk_focusPrev(), entries[1]) + self.assertIs(entries[1].tk_focusPrev(), entries[0]) + self.assertRaises(TypeError, entries[0].tk_focusNext, 'x') + self.assertRaises(TypeError, entries[0].tk_focusPrev, 'x') + + def test_tk_strictMotif(self): + self.addCleanup(self.root.tk_strictMotif, False) + self.assertIs(self.root.tk_strictMotif(), False) + self.assertIs(self.root.tk_strictMotif(True), True) + self.assertIs(self.root.tk_strictMotif(), True) + self.assertIs(self.root.tk_strictMotif(False), False) + self.assertRaises(TypeError, self.root.tk_strictMotif, 1, 2) + + def test_tk_bisque(self): + # tk_bisque resets the color palette; use a separate root so that + # the shared one is not affected. + root = tkinter.Tk() + self.addCleanup(root.destroy) + root.tk_bisque() + self.assertEqual(root['background'], '#ffe4c4') + self.assertRaises(TypeError, root.tk_bisque, 'x') + def test_event_repr_defaults(self): e = tkinter.Event() e.serial = 12345 @@ -819,6 +865,13 @@ def test_wm_iconbitmap(self): t.destroy() + def test_wm_iconphoto(self): + t = tkinter.Toplevel(self.root) + img = tkinter.PhotoImage(master=t, width=16, height=16) + t.wm_iconphoto(False, img) # No exception. + t.wm_iconphoto(True, img) + self.assertRaises(tkinter.TclError, t.wm_iconphoto, False, 'spam') + def test_wm_title(self): t = tkinter.Toplevel(self.root) t.title('Hello') diff --git a/Lib/test/test_tkinter/test_variables.py b/Lib/test/test_tkinter/test_variables.py index 8733095ffb65f40..5ee4e9467dd0cba 100644 --- a/Lib/test/test_tkinter/test_variables.py +++ b/Lib/test/test_tkinter/test_variables.py @@ -111,6 +111,7 @@ def test_initialize(self): self.assertFalse(v.side_effect) v.set("value") self.assertTrue(v.side_effect) + self.assertEqual(Variable.initialize, Variable.set) def test_trace_old(self): if tcl_version >= (9, 0): @@ -118,6 +119,7 @@ def test_trace_old(self): # Old interface v = Variable(self.root) vname = str(v) + self.assertEqual(v.trace, v.trace_variable) trace = [] def read_tracer(*args): trace.append(('read',) + args) @@ -328,6 +330,7 @@ def test_set(self): self.assertEqual(self.root.globalgetvar("name"), false) v.set("on") self.assertEqual(self.root.globalgetvar("name"), true) + self.assertEqual(BooleanVar.initialize, BooleanVar.set) def test_invalid_value_domain(self): false = 0 if self.root.wantobjects() else "0"