From cc316410177c29f0398135f208e28e9525ca8bd9 Mon Sep 17 00:00:00 2001 From: Daniele Nicolodi Date: Fri, 10 Jul 2026 15:53:01 +0200 Subject: [PATCH 1/8] gh-151669: Normalize symlink targets in tarfile.TarFile.gettarinfo() (GH-151671) This applies a normalization when creating members vrom the filesystem, complementary to the one added to tarfile.TarFile.extract() in gh-138309 that does it in the other direction. This solves an issue with round-tripping through the filesystem. --- Doc/whatsnew/3.15.rst | 3 +++ Lib/tarfile.py | 2 +- Lib/test/test_tarfile.py | 16 ++++++++++++++++ ...026-06-24-23-28-42.gh-issue-151669.tPUavQ.rst | 3 +++ 4 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 Misc/NEWS.d/next/Library/2026-06-24-23-28-42.gh-issue-151669.tPUavQ.rst diff --git a/Doc/whatsnew/3.15.rst b/Doc/whatsnew/3.15.rst index aad4758297c30d..8cba187bf31dd1 100644 --- a/Doc/whatsnew/3.15.rst +++ b/Doc/whatsnew/3.15.rst @@ -1549,6 +1549,9 @@ tarfile now replace slashes with backslashes in symlink targets on Windows to prevent creation of corrupted links. (Contributed by Christoph Walcher in :gh:`57911`.) +* :func:`~tarfile.TarFile.gettarinfo` now replaces backslashes with slashes in + symlink targets on Windows to conform to the tar format standard. (Contributed + by Daniele Nicolodi in :gh:`151669`.) threading diff --git a/Lib/tarfile.py b/Lib/tarfile.py index 385dbb536d8a7d..5d5cef2f139a42 100644 --- a/Lib/tarfile.py +++ b/Lib/tarfile.py @@ -2255,7 +2255,7 @@ def gettarinfo(self, name=None, arcname=None, fileobj=None): type = FIFOTYPE elif stat.S_ISLNK(stmd): type = SYMTYPE - linkname = os.readlink(name) + linkname = os.readlink(name).replace(os.sep, "/") elif stat.S_ISCHR(stmd): type = CHRTYPE elif stat.S_ISBLK(stmd): diff --git a/Lib/test/test_tarfile.py b/Lib/test/test_tarfile.py index 2998a3667b4d17..514cfbb07cd76a 100644 --- a/Lib/test/test_tarfile.py +++ b/Lib/test/test_tarfile.py @@ -1668,6 +1668,22 @@ def test_symlink_size(self): finally: os_helper.unlink(path) + @os_helper.skip_unless_symlink + def test_symlink_target_normalization(self): + # Test for gh-151669. + path = os.path.join(TEMPDIR, "symlink") + target = "subdir/link/target" + os.symlink(target.replace("/", os.sep), path) + try: + tar = tarfile.open(tmpname, self.mode) + try: + tarinfo = tar.gettarinfo(path) + self.assertEqual(tarinfo.linkname, target) + finally: + tar.close() + finally: + os_helper.unlink(path) + def test_add_self(self): # Test for #1257255. dstname = os.path.abspath(tmpname) diff --git a/Misc/NEWS.d/next/Library/2026-06-24-23-28-42.gh-issue-151669.tPUavQ.rst b/Misc/NEWS.d/next/Library/2026-06-24-23-28-42.gh-issue-151669.tPUavQ.rst new file mode 100644 index 00000000000000..d8e4850ba94e14 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-06-24-23-28-42.gh-issue-151669.tPUavQ.rst @@ -0,0 +1,3 @@ +On Windows, when populating tar archives from filesystem content, to +conform to the tar format standard, backslashes in symlink targets are +be replaced by slashes. From b276b88e385d7785bfc11d87fa56163e984ba699 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20S=C5=82awecki?= Date: Fri, 10 Jul 2026 15:54:18 +0200 Subject: [PATCH 2/8] gh-151213: Fix asyncio tools permission requirements link (#153493) --- Doc/library/asyncio-tools.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Doc/library/asyncio-tools.rst b/Doc/library/asyncio-tools.rst index 1782640e83f53a..a6353f0618c941 100644 --- a/Doc/library/asyncio-tools.rst +++ b/Doc/library/asyncio-tools.rst @@ -24,7 +24,7 @@ The following commands inspect the process identified by ``PID``: The commands read the target process state without executing any code in it. They are only available on supported platforms and may require permission to -inspect another process. See the :ref:`permission-requirements ` for details. +inspect another process. See the :ref:`permission requirements ` for details. .. seealso:: From 8a32914f78e0a1b1238548c3d575aa8ce3854d8c Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Fri, 10 Jul 2026 17:29:53 +0300 Subject: [PATCH 3/8] gh-153513: Return str and numbers for more Tcl object types in _tkinter (GH-153514) FromObj() now returns str for the "index", "window", "nsName" and "parsedVarName" object types, whose internal representation is only a context-bound lookup with nothing worth preserving in a Tcl_Obj. An "index" value is one of a small fixed set of enumeration keywords, so its str is interned. A "pixel" screen distance with no unit suffix is screen independent and is now returned as an int or float. A distance with an m/c/i/p suffix needs a Tk_Window to be resolved and is kept as a Tcl_Obj, as are the "dict" and the resource types "color", "border", "font" and "cursor". Co-authored-by: Claude Opus 4.8 --- Doc/library/tkinter.rst | 9 ++ Doc/whatsnew/3.16.rst | 7 + Lib/test/test_tcl.py | 16 ++ Lib/test/test_tkinter/test_misc.py | 64 +++++++- ...-07-10-12-33-30.gh-issue-153513.GxrPuY.rst | 5 + Modules/_tkinter.c | 142 +++++++++++++++++- 6 files changed, 235 insertions(+), 8 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-07-10-12-33-30.gh-issue-153513.GxrPuY.rst diff --git a/Doc/library/tkinter.rst b/Doc/library/tkinter.rst index 5ced35aadbe6d3..90bb5db7eb38d8 100644 --- a/Doc/library/tkinter.rst +++ b/Doc/library/tkinter.rst @@ -790,6 +790,15 @@ distance millimetres, ``p`` for printer's points. For example, 3.5 inches is expressed as ``"3.5i"``. + When a screen distance option is read back (for example with :meth:`!cget`), + a distance with no unit suffix is returned as an :class:`int` or a :class:`float`. + A distance with a unit suffix depends on the screen resolution + and is returned as an opaque Tcl object that can be passed back to Tk. + + .. versionchanged:: next + Screen distances with no unit suffix are returned as an :class:`int` + or a :class:`float`. + font Tk uses a font description such as ``{courier 10 bold}``; in :mod:`!tkinter` this is most naturally passed as a tuple of diff --git a/Doc/whatsnew/3.16.rst b/Doc/whatsnew/3.16.rst index 5e8abc7034bea0..9e1070e8efdaa5 100644 --- a/Doc/whatsnew/3.16.rst +++ b/Doc/whatsnew/3.16.rst @@ -454,6 +454,13 @@ tkinter :meth:`~tkinter.font.Font.measure` and :meth:`~tkinter.font.Font.metrics`. (Contributed by Serhiy Storchaka in :gh:`143990`.) +* Values of several Tcl object types returned by :mod:`tkinter` are now + converted to the corresponding Python type instead of being wrapped in a + :class:`!_tkinter.Tcl_Obj`: ``index``, ``window``, ``nsName`` and + ``parsedVarName`` objects to :class:`str`, and ``pixel`` screen distances + with no unit suffix to :class:`int` or :class:`float`. + (Contributed by Serhiy Storchaka in :gh:`153513`.) + xml --- diff --git a/Lib/test/test_tcl.py b/Lib/test/test_tcl.py index 70731d3222ced9..26be4ee0d7afe4 100644 --- a/Lib/test/test_tcl.py +++ b/Lib/test/test_tcl.py @@ -650,6 +650,22 @@ def testfunc(arg): else: self.assertEqual(a, expected) + def test_return_dict_object(self): + # A dict is returned as a Tcl_Obj to preserve its structure. + tcl = self.interp.tk + a = tcl.call('dict', 'create', 'a', 1, 'b', 2) + if self.wantobjects: + self.assertIsInstance(a, _tkinter.Tcl_Obj) + self.assertEqual(a.typename, 'dict') + self.assertEqual(str(a), 'a 1 b 2') + + def test_return_nsname_object(self): + # An "nsName" object is returned as a str, not wrapped in a Tcl_Obj. + tcl = self.interp.tk + a = tcl.call('namespace', 'current') + self.assertIsInstance(a, str) + self.assertEqual(a, '::') + def test_splitlist(self): splitlist = self.interp.tk.splitlist call = self.interp.tk.call diff --git a/Lib/test/test_tkinter/test_misc.py b/Lib/test/test_tkinter/test_misc.py index 0ebaac22a2971d..92211075ce846a 100644 --- a/Lib/test/test_tkinter/test_misc.py +++ b/Lib/test/test_tkinter/test_misc.py @@ -6,7 +6,7 @@ import unittest import weakref import tkinter -from tkinter import TclError +from tkinter import TclError, ttk import enum from test import support from test.support import os_helper @@ -14,7 +14,7 @@ from test.test_tkinter.support import setUpModule # noqa: F401 from test.test_tkinter.support import (AbstractTkTest, AbstractDefaultRootTest, requires_tk, get_tk_patchlevel, - tcl_version) + tcl_version, tk_version) support.requires('gui') @@ -2054,5 +2054,65 @@ def _info_commands(widget, pattern=None): return widget.tk.splitlist(widget.tk.call('info', 'commands', pattern)) +class TclObjTypeTest(AbstractTkTest, unittest.TestCase): + # See FromObj() in Modules/_tkinter.c: Tcl object types are converted to + # appropriate Python types. These conversions only happen in the object + # mode, so skip when it is disabled. + + def setUp(self): + super().setUp() + if not self.wantobjects: + self.skipTest('requires wantobjects') + + def test_enum_option_returns_str(self): + # An "index" object (an enumeration keyword) is returned as a str. + w = ttk.Scale(self.root, orient='horizontal') + value = w.cget('orient') + self.assertIsInstance(value, str) + self.assertEqual(value, 'horizontal') + + def test_enum_option_is_interned(self): + # Equal "index" keywords share a single interned str object. + a = ttk.Scale(self.root, orient='horizontal').cget('orient') + b = ttk.Scale(self.root, orient='horizontal').cget('orient') + self.assertIs(a, b) + + def test_window_option_returns_str(self): + # A "window" object is returned as a str. + label = tkinter.Label(self.root) + w = ttk.LabelFrame(self.root, labelwidget=label) + value = w.cget('labelwidget') + self.assertIsInstance(value, str) + self.assertEqual(value, str(label)) + + def test_variable_option_returns_str(self): + # A "parsedVarName" object is returned as a str. + w = tkinter.Checkbutton(self.root) + self.assertIsInstance(w.cget('variable'), str) + + def test_pixel_option_without_unit_returns_number(self): + # A screen distance with no unit suffix is already in pixels and thus + # screen independent, so it is returned as an int or a float. + w = tkinter.Frame(self.root) + w['borderwidth'] = 3 + self.assertIsInstance(w.cget('borderwidth'), int) + self.assertEqual(w.cget('borderwidth'), 3) + if tk_version >= (9, 0): + # Tk < 9 rounds a fractional screen distance to an integer. + w['borderwidth'] = 2.5 + self.assertIsInstance(w.cget('borderwidth'), float) + self.assertEqual(w.cget('borderwidth'), 2.5) + + @requires_tk(9, 0) + def test_pixel_option_with_unit_is_not_a_number(self): + # A screen distance with an m/c/i/p suffix depends on the screen + # resolution, so it is not converted to a number here. (Tk < 9 resolves + # it eagerly to an integer pixel count when the option is read.) + w = tkinter.Frame(self.root) + w['borderwidth'] = '3m' + self.assertNotIsInstance(w.cget('borderwidth'), (int, float)) + self.assertEqual(str(w.cget('borderwidth')), '3m') + + if __name__ == "__main__": unittest.main() diff --git a/Misc/NEWS.d/next/Library/2026-07-10-12-33-30.gh-issue-153513.GxrPuY.rst b/Misc/NEWS.d/next/Library/2026-07-10-12-33-30.gh-issue-153513.GxrPuY.rst new file mode 100644 index 00000000000000..a374355d2d4c59 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-07-10-12-33-30.gh-issue-153513.GxrPuY.rst @@ -0,0 +1,5 @@ +Values of several Tcl object types returned by :mod:`tkinter` are now +converted to the corresponding Python type instead of being wrapped in a +:class:`!_tkinter.Tcl_Obj`: ``index``, ``window``, ``nsName`` and +``parsedVarName`` objects to :class:`str`, and ``pixel`` screen distances +with no unit suffix to :class:`int` or :class:`float`. diff --git a/Modules/_tkinter.c b/Modules/_tkinter.c index 62a79128d9726c..30185c08eeabc9 100644 --- a/Modules/_tkinter.c +++ b/Modules/_tkinter.c @@ -356,6 +356,15 @@ typedef struct { const Tcl_ObjType *StringType; const Tcl_ObjType *UTF32StringType; const Tcl_ObjType *PixelType; + const Tcl_ObjType *IndexType; + const Tcl_ObjType *ColorType; + const Tcl_ObjType *BorderType; + const Tcl_ObjType *FontType; + const Tcl_ObjType *CursorType; + const Tcl_ObjType *WindowType; + const Tcl_ObjType *NsNameType; + const Tcl_ObjType *ParsedVarNameType; + const Tcl_ObjType *DictType; } TkappObject; #define TkappObject_CAST(op) ((TkappObject *)(op)) @@ -687,11 +696,22 @@ Tkapp_New(const char *screenName, const char *className, Tcl_DecrRefCount(value); } v->WideIntType = Tcl_GetObjType("wideInt"); - v->BignumType = Tcl_GetObjType("bignum"); v->ListType = Tcl_GetObjType("list"); v->StringType = Tcl_GetObjType("string"); v->UTF32StringType = Tcl_GetObjType("utf32string"); + /* These types may not be registered until first used, so these may be NULL; + FromObj() caches them lazily on first encounter. */ + v->BignumType = Tcl_GetObjType("bignum"); v->PixelType = Tcl_GetObjType("pixel"); + v->IndexType = Tcl_GetObjType("index"); + v->ColorType = Tcl_GetObjType("color"); + v->BorderType = Tcl_GetObjType("border"); + v->FontType = Tcl_GetObjType("font"); + v->CursorType = Tcl_GetObjType("cursor"); + v->WindowType = Tcl_GetObjType("window"); + v->NsNameType = Tcl_GetObjType("nsName"); + v->ParsedVarNameType = Tcl_GetObjType("parsedVarName"); + v->DictType = Tcl_GetObjType("dict"); /* Delete the 'exit' command, which can screw things up */ Tcl_DeleteCommand(v->interp, "exit"); @@ -1257,6 +1277,39 @@ fromBignumObj(TkappObject *tkapp, Tcl_Obj *value) return res; } +/* Convert a "pixel" screen distance to a Python object. A value with no unit + suffix is screen independent and is returned as an int or float. A value with + an m/c/i/p suffix needs a Tk_Window to be resolved to pixels, which is not + available here, so it is kept as a Tcl_Obj. Tcl_Get*FromObj() only set the + object's type on success, so a unit-bearing value does not shimmer. */ +static PyObject* +fromPixelObj(TkappObject *tkapp, Tcl_Obj *value) +{ + PyObject *result = fromWideIntObj(tkapp, value); + if (result != NULL || PyErr_Occurred()) { + return result; + } + Tcl_ResetResult(Tkapp_Interp(tkapp)); + double doubleValue; + if (Tcl_GetDoubleFromObj(NULL, value, &doubleValue) == TCL_OK) { + return PyFloat_FromDouble(doubleValue); + } + return newPyTclObject(value); +} + +/* Convert an "index" object to a Python str. Index values are enumeration + keywords from a small fixed set (e.g. "horizontal", "disabled"), so the result + is interned to share one str object per keyword. */ +static PyObject* +fromIndexObj(TkappObject *tkapp, Tcl_Obj *value) +{ + PyObject *result = unicodeFromTclObj(tkapp, value); + if (result != NULL) { + PyUnicode_InternInPlace(&result); + } + return result; +} + static PyObject* FromObj(TkappObject *tkapp, Tcl_Obj *value) { @@ -1327,19 +1380,96 @@ FromObj(TkappObject *tkapp, Tcl_Obj *value) } if (value->typePtr == tkapp->StringType || - value->typePtr == tkapp->UTF32StringType || - value->typePtr == tkapp->PixelType) + value->typePtr == tkapp->UTF32StringType) { return unicodeFromTclObj(tkapp, value); } - if (tkapp->BignumType == NULL && - strcmp(value->typePtr->name, "bignum") == 0) { - /* bignum type is not registered in Tcl */ + if (value->typePtr == tkapp->PixelType) { + return fromPixelObj(tkapp, value); + } + + if (value->typePtr == tkapp->IndexType) { + return fromIndexObj(tkapp, value); + } + + /* A dict is kept as a Tcl_Obj to preserve its structure for _splitdict(). */ + if (value->typePtr == tkapp->DictType) { + return newPyTclObject(value); + } + + /* Resource types are kept as a Tcl_Obj so that Tk can reuse the parsed + X/font resource when the object is passed back to it. */ + if (value->typePtr == tkapp->ColorType || + value->typePtr == tkapp->BorderType || + value->typePtr == tkapp->FontType || + value->typePtr == tkapp->CursorType) { + return newPyTclObject(value); + } + + /* These types are context-bound lookups (a cached window or namespace + resolution, or a parsed variable name) with nothing worth preserving in + a Tcl_Obj, so they are returned as str. */ + if (value->typePtr == tkapp->WindowType || + value->typePtr == tkapp->NsNameType || + value->typePtr == tkapp->ParsedVarNameType) { + return unicodeFromTclObj(tkapp, value); + } + + /* The types below may not be registered until first used, so they are + matched by name and cached on first encounter; the pointer checks above + then catch them. */ + const char *name = value->typePtr->name; + + if (tkapp->BignumType == NULL && strcmp(name, "bignum") == 0) { tkapp->BignumType = value->typePtr; return fromBignumObj(tkapp, value); } + if (tkapp->DictType == NULL && strcmp(name, "dict") == 0) { + tkapp->DictType = value->typePtr; + return newPyTclObject(value); + } + + if (tkapp->PixelType == NULL && strcmp(name, "pixel") == 0) { + tkapp->PixelType = value->typePtr; + return fromPixelObj(tkapp, value); + } + + if (tkapp->IndexType == NULL && strcmp(name, "index") == 0) { + tkapp->IndexType = value->typePtr; + return fromIndexObj(tkapp, value); + } + + if (tkapp->WindowType == NULL && strcmp(name, "window") == 0) { + tkapp->WindowType = value->typePtr; + return unicodeFromTclObj(tkapp, value); + } + + if (tkapp->NsNameType == NULL && strcmp(name, "nsName") == 0) { + tkapp->NsNameType = value->typePtr; + return unicodeFromTclObj(tkapp, value); + } + + if (tkapp->ParsedVarNameType == NULL && strcmp(name, "parsedVarName") == 0) { + tkapp->ParsedVarNameType = value->typePtr; + return unicodeFromTclObj(tkapp, value); + } + + /* Any other type is returned as a Tcl_Obj. Cache the resource types so the + pointer check above catches them next time. */ + if (tkapp->ColorType == NULL && strcmp(name, "color") == 0) { + tkapp->ColorType = value->typePtr; + } + else if (tkapp->BorderType == NULL && strcmp(name, "border") == 0) { + tkapp->BorderType = value->typePtr; + } + else if (tkapp->FontType == NULL && strcmp(name, "font") == 0) { + tkapp->FontType = value->typePtr; + } + else if (tkapp->CursorType == NULL && strcmp(name, "cursor") == 0) { + tkapp->CursorType = value->typePtr; + } return newPyTclObject(value); } From 5aafbea2c9890ad8d179432a0ea2e06c6ca9cec3 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Fri, 10 Jul 2026 18:42:07 +0300 Subject: [PATCH 4/8] gh-153494: Encode imaplib search criteria to the declared CHARSET (GH-153495) IMAP4.search(), sort(), thread() and the uid SORT/THREAD variants now encode str search criteria to the declared charset, so international search text can be passed as ordinary str. A criterion passed as bytes is sent unchanged, for use with a charset that Python has no codec for. Co-Authored-By: Claude Opus 4.8 --- Doc/library/imaplib.rst | 24 ++++++ Doc/whatsnew/3.16.rst | 8 ++ Lib/imaplib.py | 19 +++++ Lib/test/test_imaplib.py | 76 +++++++++++++++++-- ...-07-10-16-00-00.gh-issue-153494.qCh8rT.rst | 8 ++ 5 files changed, 129 insertions(+), 6 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-07-10-16-00-00.gh-issue-153494.qCh8rT.rst diff --git a/Doc/library/imaplib.rst b/Doc/library/imaplib.rst index 3f26243f77dd5a..7b79d81790b667 100644 --- a/Doc/library/imaplib.rst +++ b/Doc/library/imaplib.rst @@ -610,6 +610,13 @@ An :class:`IMAP4` instance has the following methods: If *uid* is true, the message numbers in the response are UIDs (``UID SEARCH``). + A criterion passed as :class:`str` is encoded to *charset* + (which must name a codec known to Python); + pass :class:`bytes` to send a criterion that is already encoded, + for example when *charset* is one that Python does not support. + When *charset* is ``None`` (as it must be under ``UTF8=ACCEPT``), + the criterion is sent using the connection's encoding instead. + Example:: # M is a connected IMAP4 instance... @@ -621,6 +628,9 @@ An :class:`IMAP4` instance has the following methods: .. versionchanged:: next Added the *uid* parameter. + .. versionchanged:: next + ``str`` search criteria are encoded to *charset*. + .. method:: IMAP4.select(mailbox='INBOX', readonly=False) @@ -682,11 +692,18 @@ An :class:`IMAP4` instance has the following methods: If *uid* is true, the message numbers in the response are UIDs (``UID SORT``). + As with :meth:`search`, + a *search_criterion* passed as :class:`str` is encoded to *charset*; + pass :class:`bytes` to send one already encoded. + This is an ``IMAP4rev1`` extension command. .. versionchanged:: next Added the *uid* parameter. + .. versionchanged:: next + ``str`` search criteria are encoded to *charset*. + .. method:: IMAP4.starttls(ssl_context=None) @@ -772,11 +789,18 @@ An :class:`IMAP4` instance has the following methods: If *uid* is true, the message numbers in the response are UIDs (``UID THREAD``). + As with :meth:`search`, + a *search_criterion* passed as :class:`str` is encoded to *charset*; + pass :class:`bytes` to send one already encoded. + This is an ``IMAP4rev1`` extension command. .. versionchanged:: next Added the *uid* parameter. + .. versionchanged:: next + ``str`` search criteria are encoded to *charset*. + .. method:: IMAP4.uid(command, arg[, ...]) diff --git a/Doc/whatsnew/3.16.rst b/Doc/whatsnew/3.16.rst index 9e1070e8efdaa5..06c3dbb2f0f1ad 100644 --- a/Doc/whatsnew/3.16.rst +++ b/Doc/whatsnew/3.16.rst @@ -276,6 +276,14 @@ imaplib :meth:`~imaplib.IMAP4.uid`. (Contributed by Serhiy Storchaka in :gh:`153502`.) +* :meth:`~imaplib.IMAP4.search`, :meth:`~imaplib.IMAP4.sort` + and :meth:`~imaplib.IMAP4.thread` (and the corresponding ``uid`` commands) + now encode :class:`str` search criteria to the declared *charset*, + so international search text can be passed as an ordinary :class:`str`. + When *charset* is ``None`` (as it must be under ``UTF8=ACCEPT``), + the criteria are sent using the connection encoding instead. + (Contributed by Serhiy Storchaka in :gh:`153494`.) + ipaddress --------- diff --git a/Lib/imaplib.py b/Lib/imaplib.py index 4e7619b61e89cd..ed5b528976448a 100644 --- a/Lib/imaplib.py +++ b/Lib/imaplib.py @@ -950,11 +950,15 @@ def search(self, charset, *criteria, uid=False): If UTF8 is enabled, charset MUST be None. If 'uid' is true, the message numbers in the response are UIDs (UID SEARCH). + + A 'criteria' passed as str is encoded to 'charset'; pass bytes to + send criteria that are already encoded. """ name = 'SEARCH' if charset is not None: if self.utf8_enabled: raise IMAP4.error("Non-None charset not valid in UTF8 mode") + criteria = self._encode_criteria(charset, criteria) args = ('CHARSET', self._astring(charset), *criteria) else: args = criteria @@ -1036,6 +1040,7 @@ def sort(self, sort_criteria, charset, *search_criteria, uid=False): #if not name in self.capabilities: # Let the server decide! # raise self.error('unimplemented extension command: %s' % name) sort_criteria = self._set_quote(sort_criteria) + search_criteria = self._encode_criteria(charset, search_criteria) if charset is not None: charset = self._astring(charset) args = (sort_criteria, charset, *search_criteria) @@ -1117,6 +1122,7 @@ def thread(self, threading_algorithm, charset, *search_criteria, uid=False): (UID THREAD). """ name = 'THREAD' + search_criteria = self._encode_criteria(charset, search_criteria) if charset is not None: charset = self._astring(charset) args = (self._atom(threading_algorithm), charset, *search_criteria) @@ -1158,12 +1164,14 @@ def uid(self, command, *args): self._set_quote(flags)) elif command == 'SORT': sort_criteria, charset, *search_criteria = args + search_criteria = self._encode_criteria(charset, search_criteria) if charset is not None: charset = self._astring(charset) args = (self._set_quote(sort_criteria), charset, *search_criteria) elif command == 'THREAD': threading_algorithm, charset, *search_criteria = args + search_criteria = self._encode_criteria(charset, search_criteria) if charset is not None: charset = self._astring(charset) args = (self._atom(threading_algorithm), charset, @@ -1576,6 +1584,17 @@ def _fetch_parts(self, arg): return arg return self._set_quote(arg) + def _encode_criteria(self, charset, criteria): + # Encode str search criteria to the declared CHARSET so the bytes on + # the wire match it. bytes criteria are already encoded and pass + # through unchanged. charset is None when no CHARSET is sent. + if charset is None: + return criteria + if isinstance(charset, (bytes, bytearray)): + charset = str(charset, 'ascii') + return tuple(c.encode(charset) if isinstance(c, str) else c + for c in criteria) + def _quote(self, arg): if isinstance(arg, str): arg = bytes(arg, self._encoding) diff --git a/Lib/test/test_imaplib.py b/Lib/test/test_imaplib.py index 2413a8728ec72b..3eaca67299e7c3 100644 --- a/Lib/test/test_imaplib.py +++ b/Lib/test/test_imaplib.py @@ -205,6 +205,32 @@ def test_astring(self): self.assertEqual(m._astring('Entwürfe'), '"Entwürfe"'.encode()) self.assertEqual(m._astring(b'Entw\xc3\xbcrfe'), b'"Entw\xc3\xbcrfe"') + def test_encode_criteria(self): + m = imaplib.IMAP4.__new__(imaplib.IMAP4) + enc = m._encode_criteria + # No charset: criteria are returned unchanged. + self.assertEqual(enc(None, ('TEXT', 'x')), ('TEXT', 'x')) + # str criteria are encoded to the charset; ASCII is charset-independent. + self.assertEqual(enc('UTF-8', ('TEXT', 'XXXXXX')), (b'TEXT', b'XXXXXX')) + # Non-ASCII text is encoded to the declared charset, including charsets + # other than ASCII, Latin-1 and UTF-8. + self.assertEqual(enc('UTF-8', ('"café"',)), ('"café"'.encode('utf-8'),)) + self.assertEqual(enc('ISO-8859-1', ('"café"',)), + ('"café"'.encode('latin-1'),)) + self.assertEqual(enc('KOI8-U', ('"Київ"',)), + ('"Київ"'.encode('koi8-u'),)) + self.assertEqual(enc('SHIFT_JIS', ('"日本"',)), + ('"日本"'.encode('shift_jis'),)) + # bytes criteria are already encoded and pass through unchanged. + self.assertEqual(enc('SHIFT_JIS', (b'"already"',)), (b'"already"',)) + # The charset name may itself be bytes. + self.assertEqual(enc(b'UTF-8', ('"café"',)), ('"café"'.encode('utf-8'),)) + # A charset with no codec at all (not even via the iconv codec) cannot + # encode str criteria; bytes criteria must be used with such + # server-only charsets. + self.assertRaises(LookupError, enc, 'no-such-charset', ('TEXT',)) + self.assertEqual(enc('no-such-charset', (b'TEXT',)), (b'TEXT',)) + def test_astring_idempotent(self): # Quoting an already quoted argument should not change it, so that # quoting twice gives the same result as quoting once. @@ -337,7 +363,11 @@ def handle(self): except StopIteration: self.continuation = None continue - splitline = splitargs(line.decode().removesuffix('\r\n')) + self.server.line = line + # surrogateescape so a criterion encoded in a non-UTF-8 charset + # does not crash the handler; tests inspect server.line for bytes. + splitline = splitargs(line.decode('utf-8', 'surrogateescape') + .removesuffix('\r\n')) tag = splitline[0] cmd = splitline[1] args = splitline[2:] @@ -1546,7 +1576,17 @@ def test_search(self): self.assertEqual(data, [b'43']) self.assertEqual(server.args, ['CHARSET', 'UTF-8', 'TEXT', 'XXXXXX']) - typ, data = client.search('NF_Z_62-010_(1973)', 'TEXT', 'XXXXXX') + # A non-ASCII str criterion is encoded to the declared charset (KOI8-U + # here, which is not UTF-8, so check the encoded bytes on the wire). + response[:] = ['* SEARCH 43'] + typ, data = client.search('KOI8-U', 'SUBJECT', '"Київ"') + self.assertEqual(typ, 'OK') + self.assertIn(b'CHARSET KOI8-U ', server.line) + self.assertIn('"Київ"'.encode('koi8-u'), server.line) + + # bytes criteria keep this focused on charset-name quoting (the + # parentheses force the name to be quoted) without criteria encoding. + typ, data = client.search('NF_Z_62-010_(1973)', b'TEXT', b'XXXXXX') self.assertEqual(typ, 'OK') self.assertEqual(server.args, ['CHARSET', '"NF_Z_62-010_(1973)"', 'TEXT', 'XXXXXX']) @@ -1616,10 +1656,16 @@ def test_sort(self): self.assertEqual(data, [br'']) self.assertEqual(server.args, ['(SUBJECT)', 'US-ASCII', 'TEXT', '"not in mailbox"']) - typ, data = client.sort('SUBJECT', 'NF_Z_62-010_(1973)', 'TEXT', '"not in mailbox"') + typ, data = client.sort('SUBJECT', 'NF_Z_62-010_(1973)', b'TEXT', b'"not in mailbox"') self.assertEqual(typ, 'OK') self.assertEqual(server.args, ['(SUBJECT)', '"NF_Z_62-010_(1973)"', 'TEXT', '"not in mailbox"']) + # A non-ASCII str criterion is encoded to the declared charset. + response[:] = ['* SORT'] + typ, data = client.sort('(SUBJECT)', 'KOI8-U', 'TEXT', '"Київ"') + self.assertEqual(typ, 'OK') + self.assertIn('"Київ"'.encode('koi8-u'), server.line) + def test_uid_sort(self): response = [] client, server = self._setup(make_simple_handler('UID', response, @@ -1644,10 +1690,16 @@ def test_uid_sort(self): self.assertEqual(data, [br'']) self.assertEqual(server.args, ['SORT', '(SUBJECT)', 'US-ASCII', 'TEXT', '"not in mailbox"']) - typ, data = client.uid('sort', 'SUBJECT', 'NF_Z_62-010_(1973)', 'TEXT', '"not in mailbox"') + typ, data = client.uid('sort', 'SUBJECT', 'NF_Z_62-010_(1973)', b'TEXT', b'"not in mailbox"') self.assertEqual(typ, 'OK') self.assertEqual(server.args, ['SORT', '(SUBJECT)', '"NF_Z_62-010_(1973)"', 'TEXT', '"not in mailbox"']) + # A non-ASCII str criterion is encoded to the declared charset. + response[:] = ['* SORT'] + typ, data = client.uid('sort', '(SUBJECT)', 'KOI8-U', 'TEXT', '"Київ"') + self.assertEqual(typ, 'OK') + self.assertIn('"Київ"'.encode('koi8-u'), server.line) + # The uid=True keyword is a shorthand for uid('SORT', ...). response[:] = ['* SORT 2 84 882'] typ, data = client.sort('(SUBJECT)', 'UTF-8', 'SINCE', '1-Feb-1994', uid=True) @@ -1692,10 +1744,16 @@ def test_thread(self): b'(199)(200 202)(201)(203)(204)(205 206 207)(208)']) self.assertEqual(server.args, ['ORDEREDSUBJECT', 'US-ASCII', 'TEXT', '"gewp"']) - typ, data = client.thread('ORDEREDSUBJECT', 'NF_Z_62-010_(1973)', 'TEXT', '"gewp"') + typ, data = client.thread('ORDEREDSUBJECT', 'NF_Z_62-010_(1973)', b'TEXT', b'"gewp"') self.assertEqual(typ, 'OK') self.assertEqual(server.args, ['ORDEREDSUBJECT', '"NF_Z_62-010_(1973)"', 'TEXT', '"gewp"']) + # A non-ASCII str criterion is encoded to the declared charset. + response[:] = ['* THREAD (1)'] + typ, data = client.thread('ORDEREDSUBJECT', 'KOI8-U', 'TEXT', '"Київ"') + self.assertEqual(typ, 'OK') + self.assertIn('"Київ"'.encode('koi8-u'), server.line) + def test_uid_thread(self): response = [] client, server = self._setup(make_simple_handler('UID', response, @@ -1734,10 +1792,16 @@ def test_uid_thread(self): b'(199)(200 202)(201)(203)(204)(205 206 207)(208)']) self.assertEqual(server.args, ['THREAD', 'ORDEREDSUBJECT', 'US-ASCII', 'TEXT', '"gewp"']) - typ, data = client.uid('THREAD', 'ORDEREDSUBJECT', 'NF_Z_62-010_(1973)', 'TEXT', '"gewp"') + typ, data = client.uid('THREAD', 'ORDEREDSUBJECT', 'NF_Z_62-010_(1973)', b'TEXT', b'"gewp"') self.assertEqual(typ, 'OK') self.assertEqual(server.args, ['THREAD', 'ORDEREDSUBJECT', '"NF_Z_62-010_(1973)"', 'TEXT', '"gewp"']) + # A non-ASCII str criterion is encoded to the declared charset. + response[:] = ['* THREAD (1)'] + typ, data = client.uid('THREAD', 'ORDEREDSUBJECT', 'KOI8-U', 'TEXT', '"Київ"') + self.assertEqual(typ, 'OK') + self.assertIn('"Київ"'.encode('koi8-u'), server.line) + # The uid=True keyword is a shorthand for uid('THREAD', ...). response[:] = ['* THREAD (166)(167)(168)'] typ, data = client.thread('ORDEREDSUBJECT', 'UTF-8', 'SINCE', '5-MAR-2000', diff --git a/Misc/NEWS.d/next/Library/2026-07-10-16-00-00.gh-issue-153494.qCh8rT.rst b/Misc/NEWS.d/next/Library/2026-07-10-16-00-00.gh-issue-153494.qCh8rT.rst new file mode 100644 index 00000000000000..6e83940efcc180 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-07-10-16-00-00.gh-issue-153494.qCh8rT.rst @@ -0,0 +1,8 @@ +:meth:`imaplib.IMAP4.search`, :meth:`~imaplib.IMAP4.sort` +and :meth:`~imaplib.IMAP4.thread` (and the corresponding ``uid`` commands) +now encode :class:`str` search criteria to the declared *charset*, +so international search text can be passed as ordinary :class:`str`. +When *charset* is ``None`` (as it must be under ``UTF8=ACCEPT``), +the criteria are sent using the connection encoding instead. +A criterion passed as :class:`bytes` is sent unchanged, +for use with a charset that Python has no codec for. From f96c4279e95daac9b9c735cfb3895071fff323bf Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Fri, 10 Jul 2026 19:04:49 +0300 Subject: [PATCH 5/8] gh-153513: Remove redundant conversions in tkinter tests (GH-153520) Now that index, window, parsedVarName and pixel options are returned as str, int or float, drop the str()/int()/float() wrappers that only worked around Tcl_Obj results. The generated test_configure_* tests now also check that int- and float-valued options are returned as numbers. Co-authored-by: Claude Opus 4.8 --- Lib/test/test_tkinter/test_filedialog.py | 15 ++++---- Lib/test/test_tkinter/test_simpledialog.py | 41 ++++++++++++---------- Lib/test/test_tkinter/test_text.py | 16 +++++---- Lib/test/test_tkinter/test_widgets.py | 13 +++---- Lib/test/test_tkinter/widget_tests.py | 7 +++- Lib/test/test_ttk/test_widgets.py | 22 ++++++------ 6 files changed, 64 insertions(+), 50 deletions(-) diff --git a/Lib/test/test_tkinter/test_filedialog.py b/Lib/test/test_tkinter/test_filedialog.py index b89862d3fcdcb2..817990a961e956 100644 --- a/Lib/test/test_tkinter/test_filedialog.py +++ b/Lib/test/test_tkinter/test_filedialog.py @@ -124,7 +124,7 @@ def test_use_classic(self): self.assertEqual(d.ok_button.winfo_class(), 'Button') self.assertEqual(d.selection.winfo_class(), 'Entry') if d.top._windowingsystem == 'x11': - self.assertEqual(str(d.botframe.cget('relief')), 'raised') + self.assertEqual(d.botframe.cget('relief'), 'raised') def test_background(self): # The ttk dialog adopts the ttk background, even a customized one, while @@ -145,18 +145,19 @@ def test_button_accelerator(self): # The buttons' "&" accelerators are parsed. d = self.open() self.assertEqual(str(d.ok_button.cget('text')), 'OK') - self.assertEqual(int(d.ok_button.cget('underline')), 0) + self.assertEqual(d.ok_button.cget('underline'), + 0 if self.wantobjects else '0') def test_default_ring(self): # The default ring follows the keyboard focus among the buttons. d = self.open() - self.assertEqual(str(d.cancel_button.cget('default')), 'normal') + self.assertEqual(d.cancel_button.cget('default'), 'normal') d.cancel_button.focus_force() d.top.update() - self.assertEqual(str(d.cancel_button.cget('default')), 'active') + self.assertEqual(d.cancel_button.cget('default'), 'active') d.ok_button.focus_force() d.top.update() - self.assertEqual(str(d.cancel_button.cget('default')), 'normal') + self.assertEqual(d.cancel_button.cget('default'), 'normal') def test_alt_key(self): # Alt + the underlined letter invokes the matching button. @@ -182,8 +183,8 @@ def test_escape_cancels(self): def test_horizontal_scrollbars(self): # Each list has a horizontal scrollbar besides the vertical one. d = self.open() - self.assertEqual(str(d.dirshbar.cget('orient')), 'horizontal') - self.assertEqual(str(d.fileshbar.cget('orient')), 'horizontal') + self.assertEqual(d.dirshbar.cget('orient'), 'horizontal') + self.assertEqual(d.fileshbar.cget('orient'), 'horizontal') self.assertTrue(d.dirs.cget('xscrollcommand')) self.assertTrue(d.files.cget('xscrollcommand')) diff --git a/Lib/test/test_tkinter/test_simpledialog.py b/Lib/test/test_tkinter/test_simpledialog.py index 33a0173ba67adb..be0be8d3f54631 100644 --- a/Lib/test/test_tkinter/test_simpledialog.py +++ b/Lib/test/test_tkinter/test_simpledialog.py @@ -32,7 +32,7 @@ def test_use_ttk(self): d = self.create(buttons=['OK'], bitmap='warning') self.assertEqual(d._buttons[0].winfo_class(), 'TButton') self.assertEqual(d.message.winfo_class(), 'TLabel') - self.assertEqual(str(d.message.cget('anchor')), 'nw') # cf. MessageBox + self.assertEqual(d.message.cget('anchor'), 'nw') # cf. MessageBox # The standard icons are drawn with themed images (cf. MessageBox). self.assertEqual(d.bitmap.winfo_class(), 'TLabel') self.assertIn('::tk::icons::warning', str(d.bitmap.cget('image'))) @@ -57,7 +57,7 @@ def test_use_classic(self): self.assertEqual(d._buttons[0].winfo_class(), 'Button') self.assertEqual(d.message.winfo_class(), 'Label') if d.root._windowingsystem == 'x11': - self.assertEqual(str(d.frame.cget('relief')), 'raised') + self.assertEqual(d.frame.cget('relief'), 'raised') # tk_dialog does not make the buttons equal width. self.assertIsNone(d.root.children['bot'].grid_columnconfigure(0)['uniform']) # The bitmap is a classic monochrome label. @@ -75,17 +75,20 @@ def test_no_detail(self): # Without a detail message the message label expands. d = self.create() self.assertIsNone(d.detail) - self.assertEqual(int(d.message.pack_info()['expand']), 1) + self.assertEqual(d.message.pack_info()['expand'], + 1 if self.wantobjects else '1') def test_detail(self): # The detail message is shown below the main message. d = self.create(detail='More information.') self.assertEqual(d.detail.winfo_class(), 'TLabel') self.assertEqual(str(d.detail.cget('text')), 'More information.') - self.assertEqual(str(d.detail.cget('anchor')), 'nw') # cf. MessageBox + self.assertEqual(d.detail.cget('anchor'), 'nw') # cf. MessageBox # With a detail message it expands and the main message does not. - self.assertEqual(int(d.message.pack_info()['expand']), 0) - self.assertEqual(int(d.detail.pack_info()['expand']), 1) + self.assertEqual(d.message.pack_info()['expand'], + 0 if self.wantobjects else '0') + self.assertEqual(d.detail.pack_info()['expand'], + 1 if self.wantobjects else '1') def test_bitmap_fallback(self): # A non-standard bitmap has no themed image, so even the ttk version @@ -114,11 +117,11 @@ def test_button_options(self): yes, no = d._buttons self.assertEqual(str(yes.cget('text')), 'Yes') self.assertEqual(str(no.cget('text')), 'No') - self.assertEqual(int(no.cget('underline')), 0) + self.assertEqual(no.cget('underline'), 0 if self.wantobjects else '0') self.assertEqual(str(no.cget('width')), '12') # The dialog still controls the default ring (default=0) ... - self.assertEqual(str(yes.cget('default')), 'active') - self.assertEqual(str(no.cget('default')), 'normal') + self.assertEqual(yes.cget('default'), 'active') + self.assertEqual(no.cget('default'), 'normal') # ... and the command, which records the button index. no.invoke() self.assertEqual(d.num, 1) @@ -128,13 +131,13 @@ def test_default_ring(self): # (cf. tk::MessageBox). d = self.create() # buttons ['Yes', 'No'], default 0 b0, b1 = d._buttons - self.assertEqual(str(b1.cget('default')), 'normal') + self.assertEqual(b1.cget('default'), 'normal') b1.focus_force() d.root.update() - self.assertEqual(str(b1.cget('default')), 'active') # focused -> ring + self.assertEqual(b1.cget('default'), 'active') # focused -> ring b0.focus_force() d.root.update() - self.assertEqual(str(b1.cget('default')), 'normal') # unfocused -> none + self.assertEqual(b1.cget('default'), 'normal') # unfocused -> none def test_alt_key(self): # Alt + an underlined character (the "underline" button option) invokes @@ -269,7 +272,7 @@ def test_use_classic(self): self.assertEqual(d.children['ok'].winfo_class(), 'Button') self.assertEqual(d.entry.winfo_class(), 'Entry') if d._windowingsystem == 'x11': - self.assertEqual(str(d.children['bot'].cget('relief')), 'raised') + self.assertEqual(d.children['bot'].cget('relief'), 'raised') # tk_dialog does not make the buttons equal width. self.assertIsNone(d.children['bot'].grid_columnconfigure(0)['uniform']) # The bindings work with the classic buttons too. @@ -311,8 +314,8 @@ def body(self, master): def test_button_default(self): d = self.open() - self.assertEqual(str(d.children['ok'].cget('default')), 'active') - self.assertEqual(str(d.children['cancel'].cget('default')), 'normal') + self.assertEqual(d.children['ok'].cget('default'), 'active') + self.assertEqual(d.children['cancel'].cget('default'), 'normal') def test_underline_ampersand(self): self.assertEqual(_underline_ampersand('Yes'), ('Yes', -1)) @@ -326,19 +329,19 @@ def test_button_accelerator(self): d = self.open() ok = d.children['ok'] # "&OK" -> underline 0 -> "O" self.assertEqual(str(ok.cget('text')), 'OK') - self.assertEqual(int(ok.cget('underline')), 0) + self.assertEqual(ok.cget('underline'), 0 if self.wantobjects else '0') def test_default_ring(self): # The default ring follows the keyboard focus among the buttons. d = self.open() cancel = d.children['cancel'] - self.assertEqual(str(cancel.cget('default')), 'normal') + self.assertEqual(cancel.cget('default'), 'normal') cancel.focus_force() d.update() - self.assertEqual(str(cancel.cget('default')), 'active') + self.assertEqual(cancel.cget('default'), 'active') d.children['ok'].focus_force() d.update() - self.assertEqual(str(cancel.cget('default')), 'normal') + self.assertEqual(cancel.cget('default'), 'normal') def test_find_alt_key_target(self): d = self.open() diff --git a/Lib/test/test_tkinter/test_text.py b/Lib/test/test_tkinter/test_text.py index 7ff837900f8959..87cb4323fcd662 100644 --- a/Lib/test/test_tkinter/test_text.py +++ b/Lib/test/test_tkinter/test_text.py @@ -508,10 +508,12 @@ def test_image_configure(self): self.assertEqual(str(text.image_cget(name, 'image')), str(image)) for value in ('top', 'center', 'bottom', 'baseline'): text.image_configure(name, align=value) - self.assertEqual(str(text.image_cget(name, 'align')), value) + self.assertEqual(text.image_cget(name, 'align'), value) text.image_configure(name, padx=3, pady=4) - self.assertEqual(text.tk.getint(text.image_cget(name, 'padx')), 3) - self.assertEqual(text.tk.getint(text.image_cget(name, 'pady')), 4) + self.assertEqual(text.image_cget(name, 'padx'), + 3 if self.wantobjects else '3') + self.assertEqual(text.image_cget(name, 'pady'), + 4 if self.wantobjects else '4') # Querying returns the full option set. cnf = text.image_configure(name) @@ -528,10 +530,12 @@ def test_window_configure(self): self.assertEqual(text.window_cget('1.1', 'window'), str(button)) for value in ('top', 'center', 'bottom', 'baseline'): text.window_configure('1.1', align=value) - self.assertEqual(str(text.window_cget('1.1', 'align')), value) + self.assertEqual(text.window_cget('1.1', 'align'), value) text.window_configure('1.1', padx=3, pady=4) - self.assertEqual(text.tk.getint(text.window_cget('1.1', 'padx')), 3) - self.assertEqual(text.tk.getint(text.window_cget('1.1', 'pady')), 4) + self.assertEqual(text.window_cget('1.1', 'padx'), + 3 if self.wantobjects else '3') + self.assertEqual(text.window_cget('1.1', 'pady'), + 4 if self.wantobjects else '4') text.window_configure('1.1', stretch=True) self.assertIs(text.tk.getboolean(text.window_cget('1.1', 'stretch')), True) diff --git a/Lib/test/test_tkinter/test_widgets.py b/Lib/test/test_tkinter/test_widgets.py index ab2fa45146de6e..6cee18b809eef5 100644 --- a/Lib/test/test_tkinter/test_widgets.py +++ b/Lib/test/test_tkinter/test_widgets.py @@ -266,7 +266,7 @@ def test_unique_variables(self): b = tkinter.Checkbutton(f, text=j) b.pack() buttons.append(b) - variables = [str(b['variable']) for b in buttons] + variables = [b['variable'] for b in buttons] self.assertEqual(len(set(variables)), 4, variables) def test_same_name(self): @@ -442,14 +442,15 @@ def test_kwargs(self): # Menubutton options can be passed at construction (gh-101284). widget = tkinter.OptionMenu(self.root, None, 'b', width=10, direction='right') + # Menubutton -width is a string on Tk 8.6, an int on 9.0+. self.assertEqual(int(widget['width']), 10) - self.assertEqual(str(widget['direction']), 'right') + self.assertEqual(widget['direction'], 'right') # They override OptionMenu's own appearance defaults, widget = tkinter.OptionMenu(self.root, None, 'b', relief='flat') - self.assertEqual(str(widget['relief']), 'flat') + self.assertEqual(widget['relief'], 'flat') # which otherwise keep their historical values. widget = tkinter.OptionMenu(self.root, None, 'b') - self.assertEqual(str(widget['relief']), 'raised') + self.assertEqual(widget['relief'], 'raised') def test_bad_kwarg(self): with self.assertRaisesRegex(TclError, r'^unknown option "-spam"$'): @@ -2472,9 +2473,9 @@ def test_entryconfigure_variable(self): v2 = tkinter.BooleanVar(self.root) m1.add_checkbutton(variable=v1, onvalue=True, offvalue=False, label='Nonsense') - self.assertEqual(str(m1.entrycget(1, 'variable')), str(v1)) + self.assertEqual(m1.entrycget(1, 'variable'), str(v1)) m1.entryconfigure(1, variable=v2) - self.assertEqual(str(m1.entrycget(1, 'variable')), str(v2)) + self.assertEqual(m1.entrycget(1, 'variable'), str(v2)) def test_add(self): m = self.create(tearoff=False) diff --git a/Lib/test/test_tkinter/widget_tests.py b/Lib/test/test_tkinter/widget_tests.py index 05e60f7ee118e3..7f3b2e243ec8a8 100644 --- a/Lib/test/test_tkinter/widget_tests.py +++ b/Lib/test/test_tkinter/widget_tests.py @@ -67,6 +67,11 @@ def checkParam(self, widget, name, value, *, expected=_sentinel, expected = tkinter._join(expected) else: expected = str(expected) + elif type(expected) is int: + self.assertIsInstance(widget[name], int) + elif type(expected) is float: + # An integral value may be returned as an int. + self.assertIsInstance(widget[name], (int, float)) if eq is None: eq = tcl_obj_eq self.assertEqual2(widget[name], expected, eq=eq) @@ -439,7 +444,7 @@ def test_configure_justify(self): def test_configure_orient(self): widget = self.create() - self.assertEqual(str(widget['orient']), self.default_orient) + self.assertEqual(widget['orient'], self.default_orient) self.checkEnumParam(widget, 'orient', 'horizontal', 'vertical') @requires_tk(8, 7) diff --git a/Lib/test/test_ttk/test_widgets.py b/Lib/test/test_ttk/test_widgets.py index a51a0b4e61c133..36b8658656076d 100644 --- a/Lib/test/test_ttk/test_widgets.py +++ b/Lib/test/test_ttk/test_widgets.py @@ -310,7 +310,7 @@ def test_unique_variables(self): b = ttk.Checkbutton(f, text=j) b.pack() buttons.append(b) - variables = [str(b['variable']) for b in buttons] + variables = [b['variable'] for b in buttons] self.assertEqual(len(set(variables)), 4, variables) def test_unique_variables2(self): @@ -331,7 +331,7 @@ def test_unique_variables2(self): buttons.append(b) names = [str(b) for b in buttons] self.assertEqual(len(set(names)), len(buttons), names) - variables = [str(b['variable']) for b in buttons] + variables = [b['variable'] for b in buttons] self.assertEqual(len(set(variables)), len(buttons), variables) @@ -618,14 +618,14 @@ def create(self, **kwargs): def test_configure_orient(self): widget = self.create() - self.assertEqual(str(widget['orient']), 'vertical') + self.assertEqual(widget['orient'], 'vertical') errmsg='attempt to change read-only option' if get_tk_patchlevel(self.root) < (8, 6, 0, 'beta', 3): errmsg='Attempt to change read-only option' self.checkInvalidParam(widget, 'orient', 'horizontal', errmsg=errmsg) widget2 = self.create(orient='horizontal') - self.assertEqual(str(widget2['orient']), 'horizontal') + self.assertEqual(widget2['orient'], 'horizontal') def test_add(self): # attempt to add a child that is not a direct child of the paned window @@ -790,7 +790,7 @@ def cb_test(): self.assertEqual(myvar.get(), conv(cbtn.tk.globalgetvar(cbtn['variable']))) - self.assertEqual(str(cbtn['variable']), str(cbtn2['variable'])) + self.assertEqual(cbtn['variable'], cbtn2['variable']) @add_configure_tests(StandardTtkOptionsTests) @@ -985,13 +985,13 @@ def test_configure_value(self): def test_step(self): widget = self.create(maximum=100, mode='determinate') - self.assertEqual(float(widget['value']), 0.0) + self.assertEqual(widget['value'], self._str(0.0)) widget.step() # The default increment is 1.0. - self.assertEqual(float(widget['value']), 1.0) + self.assertEqual(widget['value'], self._str(1.0)) widget.step(5) - self.assertEqual(float(widget['value']), 6.0) + self.assertEqual(widget['value'], self._str(6.0)) widget.step(-2) - self.assertEqual(float(widget['value']), 4.0) + self.assertEqual(widget['value'], self._str(4.0)) def test_start_stop(self): widget = self.create(maximum=100, mode='determinate') @@ -1000,9 +1000,9 @@ def test_start_stop(self): widget.update() widget.stop() # Cancel it. # After stopping, the value no longer changes. - value = float(widget['value']) + value = widget['value'] widget.update() - self.assertEqual(float(widget['value']), value) + self.assertEqual(widget['value'], value) @unittest.skipIf(sys.platform == 'darwin', From c59c95f7b42844266d635e6ae60cae0091d64b56 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Fri, 10 Jul 2026 19:36:12 +0300 Subject: [PATCH 6/8] gh-143070: Use "+" instead of "!" in automatically generated tkinter widget names (GH-151975) The "!" prefix has a special meaning in the tag expressions of the canvas and text widgets ("!", "&&", "||", "^" and parentheses), so an automatically generated widget name could not be used as a tag. "+" has no special meaning there, nor in option database patterns or Tcl lists, and a user is very unlikely to start an explicit name with it. --- Lib/test/test_tkinter/test_misc.py | 21 ++++++++++++++++++- Lib/test/test_tkinter/test_text.py | 8 +++++++ Lib/test/test_tkinter/test_widgets.py | 6 ++++++ Lib/tkinter/__init__.py | 10 ++++++--- ...-06-23-11-00-03.gh-issue-143070.fV5w7s.rst | 3 +++ 5 files changed, 44 insertions(+), 4 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-06-23-11-00-03.gh-issue-143070.fV5w7s.rst diff --git a/Lib/test/test_tkinter/test_misc.py b/Lib/test/test_tkinter/test_misc.py index 92211075ce846a..9f883e6d79c201 100644 --- a/Lib/test/test_tkinter/test_misc.py +++ b/Lib/test/test_tkinter/test_misc.py @@ -49,8 +49,27 @@ class Button2(tkinter.Button): self.assertNotEqual(str(f), str(f2)) b = tkinter.Button(f2) b2 = Button2(f2) - for name in str(b).split('.') + str(b2).split('.'): + for w in (t, f, f2, b, b2): + # The full path name starts with a dot, the name of the root. + self.assertTrue(str(w).startswith('.'), msg=repr(str(w))) + name = w.winfo_name() + # A generated name is not empty and contains no dot, which would + # be interpreted as a path name component separator. + self.assertTrue(name, msg=repr(name)) + self.assertNotIn('.', name, msg=repr(name)) + # A generated name can be used not only as a window name, but also + # as a canvas or text tag, an option database pattern or a Tcl list + # element, so it must avoid characters that are special there. + # It is marked so as not to look like a user-chosen name. self.assertFalse(name.isidentifier(), msg=repr(name)) + # A capital letter starts a class name in an option pattern. + self.assertFalse(name[0].isupper(), msg=repr(name)) + # "!&|^()" are operators in canvas tag expressions (gh-143070), + # "*" separates words in an option pattern, and whitespace and + # "{}[]\\"$;" are special in Tcl lists and scripts. + self.assertNotRegex(name, r'[][!&|^()*\s{}"\\$;]', msg=repr(name)) + # "-", "@" and "~" are special only as the first character. + self.assertNotIn(name[0], '-@~', msg=repr(name)) b3 = tkinter.Button(f2) b4 = Button2(f2) self.assertEqual(len({str(b), str(b2), str(b3), str(b4)}), 4) diff --git a/Lib/test/test_tkinter/test_text.py b/Lib/test/test_tkinter/test_text.py index 87cb4323fcd662..27423006a7e71b 100644 --- a/Lib/test/test_tkinter/test_text.py +++ b/Lib/test/test_tkinter/test_text.py @@ -389,6 +389,9 @@ def test_image(self): # An embedded image occupies a single index position. self.assertEqual(text.index('end - 1 char'), '1.3') + # The image name can be used as an index; it is matched as a whole. + self.assertEqual(text.index(name), '1.1') + # Either a name or an image is required, and the index must be valid. self.assertRaises(TclError, text.image_create, '1.0') self.assertRaises(TclError, text.image_create, 'invalid', @@ -405,6 +408,11 @@ def test_window(self): (str(button),)) self.assertEqual(text.window_cget('1.1', 'window'), str(button)) + # The window can be addressed by its name where an index is expected; + # the name is matched as a whole rather than parsed (gh-143070). + self.assertEqual(text.index(str(button)), '1.1') + self.assertEqual(text.window_cget(str(button), 'window'), str(button)) + text.window_configure('1.1', padx=5) self.assertEqual(text.window_cget('1.1', 'padx'), 5) diff --git a/Lib/test/test_tkinter/test_widgets.py b/Lib/test/test_tkinter/test_widgets.py index 6cee18b809eef5..970eddc955f3aa 100644 --- a/Lib/test/test_tkinter/test_widgets.py +++ b/Lib/test/test_tkinter/test_widgets.py @@ -1486,6 +1486,12 @@ def test_find(self): for result in (c.find_all(), c.find_withtag(r1)): self.assertIsInstance(result, tuple) + # An automatically generated widget name can be used as a tag + # (gh-143070). + w = tkinter.Frame(c) + r4 = c.create_window(0, 0, window=w, tags=str(w)) + self.assertEqual(c.find_withtag(str(w)), (r4,)) + self.assertRaises(TclError, c.find_closest, 'spam', 0) self.assertRaises(TclError, c.find_enclosed, 0, 0, 'spam', 0) self.assertRaises(TclError, c.find_overlapping, 0, 0, 'spam', 0) diff --git a/Lib/tkinter/__init__.py b/Lib/tkinter/__init__.py index b37a504ae9ff21..d8172ff5c4087a 100644 --- a/Lib/tkinter/__init__.py +++ b/Lib/tkinter/__init__.py @@ -2976,16 +2976,20 @@ def _setup(self, master, cnf): del cnf['name'] if not name: name = self.__class__.__name__.lower() + # Generated names are marked with a leading "+", which a user is + # unlikely to use, so they do not clash with explicit names. + # "+" is also one of the few symbols with no special meaning in + # canvas and text tag expressions, so the name can be used as a tag. if name[-1].isdigit(): - name += "!" # Avoid duplication when calculating names below + name += "+" # Avoid duplication when calculating names below if master._last_child_ids is None: master._last_child_ids = {} count = master._last_child_ids.get(name, 0) + 1 master._last_child_ids[name] = count if count == 1: - name = '!%s' % (name,) + name = '+%s' % (name,) else: - name = '!%s%d' % (name, count) + name = '+%s%d' % (name, count) self._name = name if master._w=='.': self._w = '.' + name diff --git a/Misc/NEWS.d/next/Library/2026-06-23-11-00-03.gh-issue-143070.fV5w7s.rst b/Misc/NEWS.d/next/Library/2026-06-23-11-00-03.gh-issue-143070.fV5w7s.rst new file mode 100644 index 00000000000000..51c2dc3d51bc86 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-06-23-11-00-03.gh-issue-143070.fV5w7s.rst @@ -0,0 +1,3 @@ +Automatically generated :mod:`tkinter` widget names now start with ``"+"`` +instead of ``"!"``, so that they can be used as tags in the +:class:`!tkinter.Canvas` and :class:`!tkinter.Text` widgets. From 6d5908368dc7936802d62413344dd4596abc4159 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Fri, 10 Jul 2026 19:44:44 +0300 Subject: [PATCH 7/8] gh-116946: Implement the GC protocol for _tkinter tkapp and tktimertoken (GH-152310) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The _tkinter.tkapp and _tkinter.tktimertoken types never implemented the garbage collector protocol, so reference cycles through an interpreter's trace function or a timer handler's callback could not be collected. A pending timer is kept alive by the Tcl event loop, which fires it even after the Python token is dropped, so it is treated as a GC root (only its callback is traversed) and collecting it never cancels a live timer. The GC slots use a plain cast rather than the type-checking macro, since the collector may visit a surviving object at shutdown after module clearing has reset the global type pointers. Deallocation cancels any pending timer so its callback cannot run on freed memory. Co-Authored-By: Claude Opus 4.8 Co-authored-by: Bénédikt Tran <10796600+picnixz@users.noreply.github.com> --- Lib/test/test_tkinter/test_misc.py | 32 ++++++++ ...-06-26-14-05-00.gh-issue-116946.Kp7raZ.rst | 3 + Modules/_tkinter.c | 79 ++++++++++++++++--- 3 files changed, 101 insertions(+), 13 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-06-26-14-05-00.gh-issue-116946.Kp7raZ.rst diff --git a/Lib/test/test_tkinter/test_misc.py b/Lib/test/test_tkinter/test_misc.py index 9f883e6d79c201..e764ce91b1161c 100644 --- a/Lib/test/test_tkinter/test_misc.py +++ b/Lib/test/test_tkinter/test_misc.py @@ -1,8 +1,10 @@ import collections.abc import functools +import gc import platform import sys import textwrap +import time import unittest import weakref import tkinter @@ -414,6 +416,36 @@ def test_createcommand_no_leak(self): support.gc_collect() self.assertIsNone(ref()) + def test_gc_protocol(self): + # gh-116946: _tkinter objects implement the GC protocol. + self.assertTrue(gc.is_tracked(self.root)) + tok = self.root.tk.createtimerhandler(10_000_000, lambda: None) + try: + self.assertTrue(gc.is_tracked(tok)) + finally: + tok.deletetimerhandler() + + def test_timer_fires_after_gc(self): + # gh-116946: a pending timer is kept alive by the Tcl event loop, not by + # the garbage collector, so collecting it must not cancel it -- it must + # still fire even when the Python token has been dropped. + fired = [] + self.root.tk.createtimerhandler(1, lambda: fired.append(1)) + support.gc_collect() + deadline = time.monotonic() + support.SHORT_TIMEOUT + while not fired and time.monotonic() < deadline: + self.root.update() + self.assertEqual(fired, [1]) + + def test_pending_timer_at_shutdown(self): + # gh-116946: the final garbage collection at interpreter shutdown must + # not crash when it visits a timer that is still pending (its type has + # already been cleared by the module's tp_clear). + assert_python_ok('-c', + 'import tkinter\n' + 'interp = tkinter.Tcl()\n' + 'interp.tk.createtimerhandler(10_000_000, lambda: None)\n') + def test_option(self): self.addCleanup(self.root.option_clear) self.root.option_add('*Button.background', 'red') diff --git a/Misc/NEWS.d/next/Library/2026-06-26-14-05-00.gh-issue-116946.Kp7raZ.rst b/Misc/NEWS.d/next/Library/2026-06-26-14-05-00.gh-issue-116946.Kp7raZ.rst new file mode 100644 index 00000000000000..95957b7d9d9894 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-06-26-14-05-00.gh-issue-116946.Kp7raZ.rst @@ -0,0 +1,3 @@ +The internal :mod:`!_tkinter` ``tkapp`` and ``tktimertoken`` types now +implement the garbage collector protocol, so reference cycles involving a +Tcl interpreter or a timer handler can be collected. diff --git a/Modules/_tkinter.c b/Modules/_tkinter.c index 30185c08eeabc9..f5fb1841f0d594 100644 --- a/Modules/_tkinter.c +++ b/Modules/_tkinter.c @@ -635,7 +635,8 @@ Tkapp_New(const char *screenName, const char *className, TkappObject *v; char *argv0; - v = PyObject_New(TkappObject, (PyTypeObject *) Tkapp_Type); + PyTypeObject *tp = (PyTypeObject *)Tkapp_Type; + v = (TkappObject *)tp->tp_alloc(tp, 0); if (v == NULL) return NULL; @@ -2943,7 +2944,8 @@ Tktt_New(PyObject *func) { TkttObject *v; - v = PyObject_New(TkttObject, (PyTypeObject *) Tktt_Type); + PyTypeObject *tp = (PyTypeObject *)Tktt_Type; + v = (TkttObject *)tp->tp_alloc(tp, 0); if (v == NULL) return NULL; @@ -2954,16 +2956,41 @@ Tktt_New(PyObject *func) return (TkttObject*)Py_NewRef(v); } -static void -Tktt_Dealloc(PyObject *self) +/* Plain cast, not TkttObject_CAST: the GC can run at shutdown after + module_clear() has cleared the global Tktt_Type the macro checks against. */ + +static int +Tktt_Clear(PyObject *op) { - TkttObject *v = TkttObject_CAST(self); - PyObject *func = v->func; - PyObject *tp = (PyObject *) Py_TYPE(self); + TkttObject *self = (TkttObject *)op; + Py_CLEAR(self->func); + return 0; +} - Py_XDECREF(func); +static int +Tktt_Traverse(PyObject *op, visitproc visit, void *arg) +{ + TkttObject *self = (TkttObject *)op; + Py_VISIT(Py_TYPE(op)); + /* Not the extra reference of a pending timer (see Tktt_New): it is owned + by the Tcl event loop, so the timer is a GC root, not part of a cycle. */ + Py_VISIT(self->func); + return 0; +} - PyObject_Free(self); +static void +Tktt_Dealloc(PyObject *op) +{ + TkttObject *self = (TkttObject *)op; /* see GC slots above */ + PyTypeObject *tp = Py_TYPE(op); + PyObject_GC_UnTrack(op); + /* Cancel any pending timer so its callback cannot fire on freed memory. */ + if (self->token != NULL) { + Tcl_DeleteTimerHandler(self->token); + self->token = NULL; + } + (void)Tktt_Clear(op); + tp->tp_free(op); Py_DECREF(tp); } @@ -3257,11 +3284,31 @@ _tkinter_tkapp_willdispatch_impl(TkappObject *self) /**** Tkapp Type Methods ****/ +/* Plain casts -- see the Tktt GC slots above. */ + +static int +Tkapp_Clear(PyObject *op) +{ + TkappObject *self = (TkappObject *)op; + Py_CLEAR(self->trace); + return 0; +} + +static int +Tkapp_Traverse(PyObject *op, visitproc visit, void *arg) +{ + TkappObject *self = (TkappObject *)op; + Py_VISIT(Py_TYPE(op)); + Py_VISIT(self->trace); + return 0; +} + static void Tkapp_Dealloc(PyObject *op) { - TkappObject *self = TkappObject_CAST(op); - PyTypeObject *tp = Py_TYPE(self); + TkappObject *self = (TkappObject *)op; + PyTypeObject *tp = Py_TYPE(op); + PyObject_GC_UnTrack(op); if (self->threaded && self->thread_id != Tcl_GetCurrentThread()) { /* Deleting the interpreter from another thread aborts the process ("Tcl_AsyncDelete: async handler deleted by the wrong thread"). @@ -3280,8 +3327,8 @@ Tkapp_Dealloc(PyObject *op) Tcl_DeleteInterp(Tkapp_Interp(self)); LEAVE_TCL } - Py_XDECREF(self->trace); - PyObject_Free(self); + (void)Tkapp_Clear(op); + tp->tp_free(op); Py_DECREF(tp); DisableEventHook(); } @@ -3488,6 +3535,8 @@ static PyMethodDef Tktt_methods[] = static PyType_Slot Tktt_Type_slots[] = { {Py_tp_dealloc, Tktt_Dealloc}, + {Py_tp_traverse, Tktt_Traverse}, + {Py_tp_clear, Tktt_Clear}, {Py_tp_repr, Tktt_Repr}, {Py_tp_methods, Tktt_methods}, {0, 0} @@ -3500,6 +3549,7 @@ static PyType_Spec Tktt_Type_spec = { Py_TPFLAGS_DEFAULT | Py_TPFLAGS_DISALLOW_INSTANTIATION | Py_TPFLAGS_IMMUTABLETYPE + | Py_TPFLAGS_HAVE_GC ), .slots = Tktt_Type_slots, }; @@ -3547,6 +3597,8 @@ static PyMethodDef Tkapp_methods[] = static PyType_Slot Tkapp_Type_slots[] = { {Py_tp_dealloc, Tkapp_Dealloc}, + {Py_tp_traverse, Tkapp_Traverse}, + {Py_tp_clear, Tkapp_Clear}, {Py_tp_methods, Tkapp_methods}, {0, 0} }; @@ -3559,6 +3611,7 @@ static PyType_Spec Tkapp_Type_spec = { Py_TPFLAGS_DEFAULT | Py_TPFLAGS_DISALLOW_INSTANTIATION | Py_TPFLAGS_IMMUTABLETYPE + | Py_TPFLAGS_HAVE_GC ), .slots = Tkapp_Type_slots, }; From fbce45b58711e9d02d270db2cd72a38dcbb345de Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Fri, 10 Jul 2026 20:16:53 +0300 Subject: [PATCH 8/8] gh-59396: Add use_ttk parameter to tkinter ScrolledText (GH-153119) ScrolledText gained a keyword-only use_ttk parameter to build the surrounding frame and the vertical scroll bar from the themed tkinter.ttk widgets instead of the classic tkinter widgets. The classic widgets remain the default. Co-authored-by: Claude Opus 4.8 --- Doc/library/tkinter.scrolledtext.rst | 11 ++++++++++- Doc/whatsnew/3.16.rst | 5 +++++ Lib/test/test_tkinter/test_scrolledtext.py | 10 ++++++++++ Lib/tkinter/scrolledtext.py | 15 +++++++++++---- .../2026-07-05-11-36-51.gh-issue-59396.Apl7sI.rst | 3 +++ 5 files changed, 39 insertions(+), 5 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-07-05-11-36-51.gh-issue-59396.Apl7sI.rst diff --git a/Doc/library/tkinter.scrolledtext.rst b/Doc/library/tkinter.scrolledtext.rst index 30aef8748edb72..2b147873cc2349 100644 --- a/Doc/library/tkinter.scrolledtext.rst +++ b/Doc/library/tkinter.scrolledtext.rst @@ -23,7 +23,16 @@ most normal geometry management behavior. Should more specific control be necessary, the following attributes are available: -.. class:: ScrolledText(master=None, **kw) +.. class:: ScrolledText(master=None, *, use_ttk=False, **kw) + + The keyword arguments are passed to the :class:`~tkinter.Text` widget. + + When *use_ttk* is true, the surrounding frame and the scroll bar are the + themed :mod:`tkinter.ttk` widgets; + the default is the classic :mod:`tkinter` widgets. + + .. versionchanged:: next + Added the *use_ttk* parameter. .. attribute:: frame diff --git a/Doc/whatsnew/3.16.rst b/Doc/whatsnew/3.16.rst index 06c3dbb2f0f1ad..7cf1ad7ae29834 100644 --- a/Doc/whatsnew/3.16.rst +++ b/Doc/whatsnew/3.16.rst @@ -456,6 +456,11 @@ tkinter them. (Contributed by Serhiy Storchaka in :gh:`59396`.) +* :class:`tkinter.scrolledtext.ScrolledText` gained a *use_ttk* parameter to use + the themed :mod:`tkinter.ttk` frame and scroll bar instead of the classic + :mod:`tkinter` widgets. + (Contributed by Serhiy Storchaka in :gh:`59396`.) + * :class:`tkinter.font.Font` can now wrap a font description without creating a new named font, by passing it as *font* with ``exists=True`` and no *name*. This avoids a loss of precision in :meth:`~tkinter.font.Font.actual`, diff --git a/Lib/test/test_tkinter/test_scrolledtext.py b/Lib/test/test_tkinter/test_scrolledtext.py index 913e7d8aab6c46..ea4789771bb091 100644 --- a/Lib/test/test_tkinter/test_scrolledtext.py +++ b/Lib/test/test_tkinter/test_scrolledtext.py @@ -1,5 +1,6 @@ import unittest import tkinter +from tkinter import ttk from tkinter.scrolledtext import ScrolledText from test.support import requires from test.test_tkinter.support import setUpModule # noqa: F401 @@ -19,8 +20,11 @@ def test_create(self): st = self.create(background='red', height=5) # It is a Text widget held in a Frame together with a Scrollbar. self.assertIsInstance(st, tkinter.Text) + # By default the frame and scroll bar are the classic tkinter widgets. self.assertIsInstance(st.frame, tkinter.Frame) + self.assertNotIsInstance(st.frame, ttk.Frame) self.assertIsInstance(st.vbar, tkinter.Scrollbar) + self.assertNotIsInstance(st.vbar, ttk.Scrollbar) self.assertEqual(st.winfo_parent(), str(st.frame)) # str() returns the frame, so that geometry managers manage it. self.assertEqual(str(st), str(st.frame)) @@ -28,6 +32,12 @@ def test_create(self): self.assertEqual(str(st['background']), 'red') self.assertEqual(st['height'], 5 if self.wantobjects else '5') + def test_use_ttk(self): + # use_ttk=True uses the themed tkinter.ttk widgets. + st = self.create(use_ttk=True) + self.assertIsInstance(st.frame, ttk.Frame) + self.assertIsInstance(st.vbar, ttk.Scrollbar) + def test_text_methods(self): st = self.create() st.insert('1.0', 'hello\nworld') diff --git a/Lib/tkinter/scrolledtext.py b/Lib/tkinter/scrolledtext.py index 8dcead5e31930e..b7b8ca1e165971 100644 --- a/Lib/tkinter/scrolledtext.py +++ b/Lib/tkinter/scrolledtext.py @@ -9,18 +9,25 @@ the Scrollbar widget. Most methods calls are inherited from the Text widget; Pack, Grid and Place methods are redirected to the Frame widget however. + +Pass use_ttk=True for the themed tkinter.ttk frame and scroll bar +instead of the classic tkinter widgets. """ -from tkinter import Frame, Text, Scrollbar, Pack, Grid, Place +from tkinter import Frame, Text, Scrollbar, Pack, Grid, Place, ttk from tkinter.constants import RIGHT, LEFT, Y, BOTH __all__ = ['ScrolledText'] class ScrolledText(Text): - def __init__(self, master=None, **kw): - self.frame = Frame(master) - self.vbar = Scrollbar(self.frame) + def __init__(self, master=None, *, use_ttk=False, **kw): + if use_ttk: + self.frame = ttk.Frame(master) + self.vbar = ttk.Scrollbar(self.frame) + else: + self.frame = Frame(master) + self.vbar = Scrollbar(self.frame) self.vbar.pack(side=RIGHT, fill=Y) kw['yscrollcommand'] = self.vbar.set diff --git a/Misc/NEWS.d/next/Library/2026-07-05-11-36-51.gh-issue-59396.Apl7sI.rst b/Misc/NEWS.d/next/Library/2026-07-05-11-36-51.gh-issue-59396.Apl7sI.rst new file mode 100644 index 00000000000000..d4c0473ef372e8 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-07-05-11-36-51.gh-issue-59396.Apl7sI.rst @@ -0,0 +1,3 @@ +:class:`tkinter.scrolledtext.ScrolledText` gained a *use_ttk* parameter to use +the themed :mod:`tkinter.ttk` frame and scroll bar instead of the classic +:mod:`tkinter` widgets.