From 62cbd34df74e4e07bb50edcaeec7454e71da47ce Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Sun, 13 Sep 2026 11:03:08 +0300 Subject: [PATCH 1/5] gh-156961: Fix tkinter.font.Font for a font name returned as a Tcl object (GH-157028) Tk can return a font name or description as a Tcl object, for example from ttk.Style().lookup("TButton", "font"), Menu.entrycget("font"), ttk.Entry.cget("font"), or the default value in the result of configure(). Such an object does not compare equal to a string, so it was not recognized as the name of an existing named font. Keep it as is, so that it is passed back to Tk, and only convert it where it is compared with a string. --- Lib/test/test_tkinter/test_font.py | 40 +++++++++++++++++++ Lib/tkinter/font.py | 21 ++++++---- ...-09-05-22-39-22.gh-issue-156961.ZtYqoA.rst | 3 ++ 3 files changed, 57 insertions(+), 7 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-09-05-22-39-22.gh-issue-156961.ZtYqoA.rst diff --git a/Lib/test/test_tkinter/test_font.py b/Lib/test/test_tkinter/test_font.py index 3d76ae630d97e3..16c9c1dc22bbca 100644 --- a/Lib/test/test_tkinter/test_font.py +++ b/Lib/test/test_tkinter/test_font.py @@ -24,6 +24,16 @@ def actual_size(self, desc): # The requested size is not always available (e.g. bitmap fonts). return self.root.tk.call('font', 'actual', desc, '-size') + def tcl_font_object(self, desc): + # Return a font name or description as a Tcl object representing a + # font, as Tk returns for example from ttk.Style().lookup(). + tk = self.root.tk + tk.call('set', '_font', desc) + tk.eval('font measure $_font x') # convert the Tcl object to a font + obj = tk.call('set', '_font') + tk.call('unset', '_font') + return obj + def test_configure(self): self.assertEqual(self.font.config, self.font.configure) options = self.font.configure() @@ -150,6 +160,36 @@ def test_existing(self): # A name or a description is required. self.assertRaises(TypeError, font.Font, root=self.root, exists=True) + def test_tcl_object(self): + # Tk can return a font as a Tcl object (gh-156961). + if not self.wantobjects: + self.skipTest('Tcl objects are converted to strings') + obj = self.tcl_font_object(fontname) + self.assertEqual(obj.typename, 'font') + + # It can be used as the name of an existing named font. + for f in (font.Font(root=self.root, name=obj, exists=True), + font.nametofont(obj, root=self.root)): + # The Tcl object is kept as is, so that it is passed back to Tk. + self.assertIs(f.name, obj) + self.assertEqual(str(f), fontname) + self.assertEqual(f.actual(), self.font.actual()) + self.assertEqual(f, self.font) + self.assertEqual(self.font, f) + # Referring to a non-existent named font still fails. + self.assertRaisesRegex(tkinter.TclError, 'named font nosuchfont', + font.Font, root=self.root, exists=True, + name=self.tcl_font_object('nosuchfont')) + + # It can also be wrapped as a font description. + obj = self.tcl_font_object(('Times', 20, 'bold')) + f = font.Font(root=self.root, font=obj, exists=True) + self.assertIs(f.name, obj) + self.assertEqual(str(f), 'Times 20 bold') + self.assertNotIn(f.name, font.names(self.root)) + self.assertEqual(f.actual('weight'), 'bold') + self.assertEqual(f.actual('size'), self.actual_size(('Times', 20, 'bold'))) + def test_copy(self): # size=-20 (pixels): copy() copies the configured options, so the # size is preserved rather than resolved (gh-143990). diff --git a/Lib/tkinter/font.py b/Lib/tkinter/font.py index 5b663a4e456456..1349e49fbd68a5 100644 --- a/Lib/tkinter/font.py +++ b/Lib/tkinter/font.py @@ -104,7 +104,8 @@ def __init__(self, root=None, font=None, name=None, exists=False, if exists: self.name = name # confirm font exists - if self.name not in tk.splitlist(tk.call("font", "names")): + name = getattr(name, 'string', name) # can be a Tcl object + if name not in tk.splitlist(tk.call("font", "names")): raise tkinter._tkinter.TclError( "named font %s does not already exist" % (self.name,)) # if font config info supplied, apply it @@ -123,11 +124,11 @@ def __init__(self, root=None, font=None, name=None, exists=False, self._call = tk.call def __str__(self): - # A wrapped description is a list or tuple, not a string; format it as - # a Tcl word so it can be used as an option value (as ttk does). - if isinstance(self.name, str): - return self.name - return tkinter._join(self.name) + # A wrapped description can be a list or tuple; format it as a Tcl + # word so it can be used as an option value (as ttk does). + if isinstance(self.name, (list, tuple)): + return tkinter._join(self.name) + return str(self.name) def __repr__(self): return f"<{self.__class__.__module__}.{self.__class__.__qualname__}" \ @@ -136,7 +137,13 @@ def __repr__(self): def __eq__(self, other): if not isinstance(other, Font): return NotImplemented - return self.name == other.name and self._tk == other._tk + name = self.name + other_name = other.name + if type(name) is not type(other_name): + # A Tcl object does not compare equal to a string. + name = getattr(name, 'string', name) + other_name = getattr(other_name, 'string', other_name) + return name == other_name and self._tk == other._tk def __getitem__(self, key): return self.cget(key) diff --git a/Misc/NEWS.d/next/Library/2026-09-05-22-39-22.gh-issue-156961.ZtYqoA.rst b/Misc/NEWS.d/next/Library/2026-09-05-22-39-22.gh-issue-156961.ZtYqoA.rst new file mode 100644 index 00000000000000..9df2868c30f16d --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-09-05-22-39-22.gh-issue-156961.ZtYqoA.rst @@ -0,0 +1,3 @@ +Fix :func:`tkinter.font.nametofont` and the :class:`tkinter.font.Font` +constructor for a font name or description returned by Tk as a Tcl object, +for example by :meth:`ttk.Style.lookup() `. From 64740410909891331eb4e4cbdb14837ff4f8ec64 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Sun, 13 Sep 2026 11:04:50 +0300 Subject: [PATCH 2/5] gh-135623: Document that json sorts the keys before coercing them to strings (GH-156987) Co-authored-by: Claude Opus 5 (1M context) --- Doc/library/json.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Doc/library/json.rst b/Doc/library/json.rst index 5e8c452a5ab9a1..ddd12a002f7416 100644 --- a/Doc/library/json.rst +++ b/Doc/library/json.rst @@ -261,6 +261,8 @@ Basic Usage into JSON and then back into a dictionary, the dictionary may not equal the original one. That is, ``loads(dumps(x)) != x`` if x has non-string keys. + *sort_keys* sorts the keys before they are coerced to strings, + so numeric keys are sorted by value, not by their string representation. .. function:: load(fp, *, cls=None, object_hook=None, parse_float=None, \ parse_int=None, parse_constant=None, \ From fde62961044b9419b8a7fb9ddedff6702e247b90 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Sun, 13 Sep 2026 11:08:53 +0300 Subject: [PATCH 3/5] gh-59551: Rename Doc/library/dialog.rst to tkinter.dialogs.rst (GH-151656) --- Doc/library/tk.rst | 2 +- Doc/library/{dialog.rst => tkinter.dialogs.rst} | 0 Doc/tools/removed-ids.txt | 3 +++ .../2026-06-18-16-58-52.gh-issue-59551.oNkjTG.rst | 1 + 4 files changed, 5 insertions(+), 1 deletion(-) rename Doc/library/{dialog.rst => tkinter.dialogs.rst} (100%) create mode 100644 Misc/NEWS.d/next/Documentation/2026-06-18-16-58-52.gh-issue-59551.oNkjTG.rst diff --git a/Doc/library/tk.rst b/Doc/library/tk.rst index e27af48ba7ac48..9ca26a5dfa7659 100644 --- a/Doc/library/tk.rst +++ b/Doc/library/tk.rst @@ -34,7 +34,7 @@ alternative `GUI frameworks and tools Date: Sun, 13 Sep 2026 12:05:20 +0300 Subject: [PATCH 4/5] gh-157406: Report all document type declarations in the Python XMLParser (GH-157408) The Python implementation of XMLParser only reported a document type declaration with an external identifier. Use the Expat handler, like the C implementation does. --- Lib/test/test_xml_etree.py | 15 +++++++ Lib/xml/etree/ElementTree.py | 43 +++++-------------- ...-09-13-10-30-00.gh-issue-157406.a3kZq7.rst | 4 ++ 3 files changed, 29 insertions(+), 33 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-09-13-10-30-00.gh-issue-157406.a3kZq7.rst diff --git a/Lib/test/test_xml_etree.py b/Lib/test/test_xml_etree.py index f9ff8c4c3541ed..90e556ec95308b 100644 --- a/Lib/test/test_xml_etree.py +++ b/Lib/test/test_xml_etree.py @@ -4211,6 +4211,21 @@ def close(self): ('html', '-//W3C//DTD XHTML 1.0 Transitional//EN', 'http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd')) + for doctype, expected in [ + ('', ('html', None, None)), + (']>', ('html', None, None)), + ('', ('html', None, 'a.dtd')), + (']>', + ('html', None, 'a.dtd')), + ('', ('html', '-//P', 'a.dtd')), + ("", + ('html', '-//P', 'a.dtd')), + ]: + with self.subTest(doctype=doctype): + parser = ET.XMLParser(target=DoctypeParser()) + parser.feed(doctype + '') + self.assertEqual(parser.close(), expected) + def test_builder_lookup_errors(self): class RaisingBuilder: def __init__(self, raise_in=None, what=ValueError): diff --git a/Lib/xml/etree/ElementTree.py b/Lib/xml/etree/ElementTree.py index bed8c27df5a384..ce98e4dc24a0d3 100644 --- a/Lib/xml/etree/ElementTree.py +++ b/Lib/xml/etree/ElementTree.py @@ -1591,10 +1591,10 @@ def __init__(self, *, target=None, encoding=None): parser.CommentHandler = target.comment if hasattr(target, 'pi'): parser.ProcessingInstructionHandler = target.pi + parser.StartDoctypeDeclHandler = self._start_doctype # Configure pyexpat: buffering, new-style attribute handling. parser.buffer_text = 1 parser.ordered_attributes = 1 - self._doctype = None self.entity = {} try: self.version = "Expat %d.%d.%d" % expat.version_info @@ -1713,38 +1713,15 @@ def _default(self, text): err.lineno = self.parser.ErrorLineNumber err.offset = self.parser.ErrorColumnNumber raise err - elif prefix == "<" and text[:9] == "": - self._doctype = None - return - text = text.strip(_XML_WHITESPACE) - if not text: - return - self._doctype.append(text) - n = len(self._doctype) - if n > 2: - type = self._doctype[1] - if type == "PUBLIC" and n == 4: - name, type, pubid, system = self._doctype - if pubid: - pubid = pubid[1:-1] - elif type == "SYSTEM" and n == 3: - name, type, system = self._doctype - pubid = None - else: - return - if hasattr(self.target, "doctype"): - self.target.doctype(name, pubid, system[1:-1]) - elif hasattr(self, "doctype"): - warnings.warn( - "The doctype() method of XMLParser is ignored. " - "Define doctype() method on the TreeBuilder target.", - RuntimeWarning) - - self._doctype = None + + def _start_doctype(self, name, system, pubid, has_internal_subset): + if hasattr(self.target, "doctype"): + self.target.doctype(name, pubid, system) + elif hasattr(self, "doctype"): + warnings.warn( + "The doctype() method of XMLParser is ignored. " + "Define doctype() method on the TreeBuilder target.", + RuntimeWarning) def feed(self, data): """Feed encoded data to parser.""" diff --git a/Misc/NEWS.d/next/Library/2026-09-13-10-30-00.gh-issue-157406.a3kZq7.rst b/Misc/NEWS.d/next/Library/2026-09-13-10-30-00.gh-issue-157406.a3kZq7.rst new file mode 100644 index 00000000000000..53f2bf561eefbb --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-09-13-10-30-00.gh-issue-157406.a3kZq7.rst @@ -0,0 +1,4 @@ +Fix the Python implementation of :class:`xml.etree.ElementTree.XMLParser`: +the ``doctype()`` method of the target is now called for a document type +declaration without an external identifier, like ````, +as in the C implementation. From a60343ed17785ebbcd43de9080cadd8e2541db6f Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Sun, 13 Sep 2026 12:49:39 +0300 Subject: [PATCH 5/5] gh-156955: Speed up csv.writer by caching the set of special characters (GH-157298) Cache in the dialect a 128-bit set of ASCII characters which need quoting or escaping (delimiter, quotechar, escapechar, '\r', '\n' and characters of lineterminator) and a flag whether any of them is non-ASCII. Testing a character is now one bit test instead of five comparisons and a call to PyUnicode_FindChar(). Co-Authored-By: Claude Opus 5 (1M context) --- Lib/test/test_csv.py | 23 ++++++- ...-09-11-12-00-00.gh-issue-156955.bLtmAp.rst | 3 + Modules/_csv.c | 65 ++++++++++++++++--- 3 files changed, 80 insertions(+), 11 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-09-11-12-00-00.gh-issue-156955.bLtmAp.rst diff --git a/Lib/test/test_csv.py b/Lib/test/test_csv.py index 73e282d1abf717..36fa7e3572a5ac 100644 --- a/Lib/test/test_csv.py +++ b/Lib/test/test_csv.py @@ -227,6 +227,18 @@ def test_write_quoting(self): quoting = csv.QUOTE_STRINGS) self._write_test(['a','',None,1], '"a","",,"1"', quoting = csv.QUOTE_NOTNULL) + # FULLWIDTH QUOTATION MARK + self._write_test(['a', 1, 'p,q', 'r"s', 'x!y'], + 'a,1,"p,q","r""s",x!y', + quotechar='"') + + def test_write_delimiter(self): + self._write_test(['a', 1, 'p,q', 'x;y'], 'a,1,"p,q",x;y') + self._write_test(['a', 1, 'p;q', 'x,y'], 'a;1;"p;q";x,y', delimiter=';') + self._write_test(['a', 1, 'p\0q', 'x,y'], 'a\x001\0"p\0q"\0x,y', + delimiter='\0') + self._write_test(['a', 1, 'p🍌q', 'x🍍y'], 'a🍌1🍌"p🍌q"🍌x🍍y', + delimiter='🍌') def test_write_escape(self): self._write_test(['a',1,'p,q'], 'a,1,"p,q"', @@ -258,19 +270,26 @@ def test_write_escape(self): escapechar='\\', quoting=csv.QUOTE_MINIMAL) self._write_test(['C\\', '6', '7', 'X"'], 'C\\\\,6,7,"X"""', escapechar='\\', quoting=csv.QUOTE_MINIMAL) + # SYMBOL FOR ESCAPE + self._write_test(['a', 1, 'p,q', 'r\u241bs', 'x\u241ay'], + 'a,1,p\u241b,q,r\u241b\u241bs,x\u241ay', + escapechar='\u241b', quoting=csv.QUOTE_NONE) def test_write_lineterminator(self): - for lineterminator in '\r\n', '\n', '\r', '!@#', '\0': + for lineterminator in ('\r\n', '\n', '\r', '!@#', '\0', + '\x85', '\u2028', '\U0001f600'): with self.subTest(lineterminator=lineterminator): with StringIO() as sio: writer = csv.writer(sio, lineterminator=lineterminator) writer.writerow(['a', 'b']) writer.writerow([1, 2]) writer.writerow(['\r', '\n']) + writer.writerow([f'a{lineterminator[-1]}b', 'c']) self.assertEqual(sio.getvalue(), f'a,b{lineterminator}' f'1,2{lineterminator}' - f'"\r","\n"{lineterminator}') + f'"\r","\n"{lineterminator}' + f'"a{lineterminator[-1]}b",c{lineterminator}') def test_write_iterable(self): self._write_test(iter(['a', 1, 'p,q']), 'a,1,"p,q"') diff --git a/Misc/NEWS.d/next/Library/2026-09-11-12-00-00.gh-issue-156955.bLtmAp.rst b/Misc/NEWS.d/next/Library/2026-09-11-12-00-00.gh-issue-156955.bLtmAp.rst new file mode 100644 index 00000000000000..2a452a2a6e1652 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-09-11-12-00-00.gh-issue-156955.bLtmAp.rst @@ -0,0 +1,3 @@ +Speed up :func:`csv.writer` by caching the set of characters that need +quoting or escaping in the dialect. Writing long fields is now up to 5 times +faster. diff --git a/Modules/_csv.c b/Modules/_csv.c index c640f2d36a8464..6af66c3f09a03b 100644 --- a/Modules/_csv.c +++ b/Modules/_csv.c @@ -117,7 +117,12 @@ typedef struct { Py_UCS4 quotechar; /* quote character */ Py_UCS4 escapechar; /* escape character */ PyObject *lineterminator; /* string to write between records */ - + /* Cache for the writer: bit c is set if the ASCII character c needs + quoting or escaping (delimiter, quotechar, escapechar, '\r', '\n' + and the characters of lineterminator). */ + uint64_t special_chars[2]; + /* Whether any of the special characters is non-ASCII. */ + bool nonascii_special; } DialectObj; typedef struct { @@ -332,6 +337,54 @@ _set_str(const char *name, PyObject **target, PyObject *src, const char *dflt) return 0; } +static void +dialect_add_special_char(DialectObj *self, Py_UCS4 c) +{ + if (c == NOT_SET) { + return; + } + if (c < 128) { + self->special_chars[c / 64] |= (uint64_t)1 << (c % 64); + } + else { + self->nonascii_special = true; + } +} + +static void +dialect_init_special_chars_cache(DialectObj *self) +{ + self->special_chars[0] = self->special_chars[1] = 0; + self->nonascii_special = false; + dialect_add_special_char(self, self->delimiter); + dialect_add_special_char(self, self->quotechar); + dialect_add_special_char(self, self->escapechar); + dialect_add_special_char(self, '\r'); + dialect_add_special_char(self, '\n'); + PyObject *lt = self->lineterminator; + for (Py_ssize_t i = 0; i < PyUnicode_GET_LENGTH(lt); i++) { + dialect_add_special_char(self, PyUnicode_READ_CHAR(lt, i)); + } +} + +/* Whether the character needs quoting or escaping by the writer. */ +static inline bool +dialect_is_special_char(DialectObj *self, Py_UCS4 c) +{ + if (c < 128) { + return (self->special_chars[c / 64] >> (c % 64)) & 1; + } + if (!self->nonascii_special) { + return false; + } + return (c == self->delimiter || + c == self->quotechar || + c == self->escapechar || + PyUnicode_FindChar(self->lineterminator, c, 0, + PyUnicode_GET_LENGTH(self->lineterminator), + 1) >= 0); +} + static int dialect_check_quoting(int quoting) { @@ -558,6 +611,7 @@ dialect_new(PyTypeObject *type, PyObject *args, PyObject *kwargs) { goto err; } + dialect_init_special_chars_cache(self); ret = Py_NewRef(self); err: @@ -1208,14 +1262,7 @@ join_append_data(WriterObj *self, int field_kind, const void *field_data, Py_UCS4 c = PyUnicode_READ(field_kind, field_data, i); int want_escape = 0; - if (c == dialect->delimiter || - c == dialect->escapechar || - c == dialect->quotechar || - c == '\n' || - c == '\r' || - PyUnicode_FindChar( - dialect->lineterminator, c, 0, - PyUnicode_GET_LENGTH(dialect->lineterminator), 1) >= 0) { + if (dialect_is_special_char(dialect, c)) { if (dialect->quoting == QUOTE_NONE) want_escape = 1; else {