From 12ed8b16feb3502cea427ff846e9d0b3d7fef721 Mon Sep 17 00:00:00 2001 From: sobolevn Date: Tue, 7 Jul 2026 11:25:30 +0300 Subject: [PATCH 1/3] gh-153210: Fix `array` module import crash under a memory pressure (#153238) --- Lib/test/test_array.py | 19 ++++++++++++++++++- ...-07-07-02-26-43.gh-issue-153210.LUXIcm.rst | 1 + Modules/arraymodule.c | 7 ------- 3 files changed, 19 insertions(+), 8 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-07-07-02-26-43.gh-issue-153210.LUXIcm.rst diff --git a/Lib/test/test_array.py b/Lib/test/test_array.py index 23d9f3c9667d868..27bd63bde08eb8b 100755 --- a/Lib/test/test_array.py +++ b/Lib/test/test_array.py @@ -5,7 +5,7 @@ import collections.abc import unittest from test import support -from test.support import import_helper +from test.support import import_helper, script_helper from test.support import os_helper from test.support import _2G from test.support import subTests @@ -42,6 +42,23 @@ def test_bad_constructor(self): self.assertRaises(ValueError, array.array, 'x') self.assertRaises(ValueError, array.array, 'Z') + @support.cpython_only + def test_does_not_crash_on_broken_imports(self): + # gh-153210 + code = """if 1: + import collections.abc + + del collections.abc.MutableSequence + + try: + import array # it used to crash before + except AttributeError: + pass + else: + raise AssertionError('AttributeError was not raised') + """ + script_helper.assert_python_ok('-c', code) + @support.cpython_only def test_disallow_instantiation(self): my_array = array.array("I") diff --git a/Misc/NEWS.d/next/Library/2026-07-07-02-26-43.gh-issue-153210.LUXIcm.rst b/Misc/NEWS.d/next/Library/2026-07-07-02-26-43.gh-issue-153210.LUXIcm.rst new file mode 100644 index 000000000000000..d5d4b7c1b8c0ed4 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-07-07-02-26-43.gh-issue-153210.LUXIcm.rst @@ -0,0 +1 @@ +Fix crash on :mod:`array` import under a memory pressure. diff --git a/Modules/arraymodule.c b/Modules/arraymodule.c index 9f613927be63159..45aad5d6fe21918 100644 --- a/Modules/arraymodule.c +++ b/Modules/arraymodule.c @@ -3401,22 +3401,15 @@ array_modexec(PyObject *m) CREATE_TYPE(m, state->ArrayIterType, &arrayiter_spec); Py_SET_TYPE(state->ArrayIterType, &PyType_Type); - if (PyModule_AddObjectRef(m, "ArrayType", - (PyObject *)state->ArrayType) < 0) { - return -1; - } - PyObject *mutablesequence = PyImport_ImportModuleAttrString( "collections.abc", "MutableSequence"); if (!mutablesequence) { - Py_DECREF((PyObject *)state->ArrayType); return -1; } PyObject *res = PyObject_CallMethod(mutablesequence, "register", "O", (PyObject *)state->ArrayType); Py_DECREF(mutablesequence); if (!res) { - Py_DECREF((PyObject *)state->ArrayType); return -1; } Py_DECREF(res); From d066496d0b706618ffaf6ba2879ec8c07368eaed Mon Sep 17 00:00:00 2001 From: Peter Bierma Date: Tue, 7 Jul 2026 05:08:34 -0400 Subject: [PATCH 2/3] gh-153227: Use a development version of Pygments (#153228) --- Doc/pylock.toml | 34 +++++++++++++++++----------------- Doc/requirements.txt | 4 ++++ 2 files changed, 21 insertions(+), 17 deletions(-) diff --git a/Doc/pylock.toml b/Doc/pylock.toml index 154eee301ec9749..64a051c2399b0af 100644 --- a/Doc/pylock.toml +++ b/Doc/pylock.toml @@ -2,6 +2,7 @@ # uv pip compile Doc/requirements.txt --exclude-newer P14D --exclude-newer-package linklint=PT0S --exclude-newer-package python-docs-theme=PT0S --no-cache --output-file Doc/pylock.toml --python-version 3.12 --universal lock-version = "1.0" created-by = "uv" +requires-python = ">=3.12" [[packages]] name = "alabaster" @@ -23,9 +24,9 @@ wheels = [{ url = "https://files.pythonhosted.org/packages/b4/03/374bd9e31b58e8a [[packages]] name = "certifi" -version = "2026.4.22" -sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", upload-time = 2026-04-22T11:26:11Z, size = 137077, hashes = { sha256 = "8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580" } } -wheels = [{ url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", upload-time = 2026-04-22T11:26:09Z, size = 135707, hashes = { sha256 = "3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a" } }] +version = "2026.6.17" +sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", upload-time = 2026-06-17T10:31:07Z, size = 134594, hashes = { sha256 = "024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432" } } +wheels = [{ url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", upload-time = 2026-06-17T10:31:06Z, size = 133289, hashes = { sha256 = "2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db" } }] [[packages]] name = "charset-normalizer" @@ -114,9 +115,9 @@ wheels = [{ url = "https://files.pythonhosted.org/packages/8f/d7/9322c609343d929 [[packages]] name = "idna" -version = "3.13" -sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", upload-time = 2026-04-22T16:42:42Z, size = 194210, hashes = { sha256 = "585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242" } } -wheels = [{ url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", upload-time = 2026-04-22T16:42:40Z, size = 68629, hashes = { sha256 = "892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3" } }] +version = "3.18" +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", upload-time = 2026-06-02T14:34:07Z, size = 196711, hashes = { sha256 = "ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848" } } +wheels = [{ url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", upload-time = 2026-06-02T14:34:06Z, size = 65455, hashes = { sha256 = "7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2" } }] [[packages]] name = "imagesize" @@ -132,9 +133,9 @@ wheels = [{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f [[packages]] name = "linklint" -version = "1.0.0" -sdist = { url = "https://files.pythonhosted.org/packages/f3/59/a2bb261b3fc0e3bfdc9d3e6f8a37bdf1fb6eca8d992f23f55a83bf19acb1/linklint-1.0.0.tar.gz", upload-time = 2026-05-02T11:39:39Z, size = 21310, hashes = { sha256 = "52dc292f27b7eb4f3825d23ec1222d4a17bd116945b77c486406d97bb936e6c2" } } -wheels = [{ url = "https://files.pythonhosted.org/packages/7b/78/98e52e4262416060a0eedc2e5514035cfcfc2fd962d09b84e27848c1aef3/linklint-1.0.0-py3-none-any.whl", upload-time = 2026-05-02T11:39:37Z, size = 12521, hashes = { sha256 = "bbb3f589ab65709cf23655ef6097bdc25180f80c0d42a4d9a1714366d2d3edab" } }] +version = "1.0.1" +sdist = { url = "https://files.pythonhosted.org/packages/62/22/c65a97b2192c3c317c472058bf52d7ca88e421998fa8d5c183b2280ab5d4/linklint-1.0.1.tar.gz", upload-time = 2026-06-09T12:30:47Z, size = 22412, hashes = { sha256 = "10201b4366edecfbeb3281883aff857dbe77d40bb56f16f212d372f597342d5f" } } +wheels = [{ url = "https://files.pythonhosted.org/packages/7b/d8/b2f1708f55f4dd5358b48d28be07ff8e0dadd6d23e41ead2ed1424e90256/linklint-1.0.1-py3-none-any.whl", upload-time = 2026-06-09T12:30:46Z, size = 12828, hashes = { sha256 = "c931f110482f1d17808aa2df78b116adc44d7a14e9e79922c57bf9a7cba40a2d" } }] [[packages]] name = "markupsafe" @@ -162,8 +163,7 @@ wheels = [{ url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c [[packages]] name = "pygments" version = "2.20.0" -sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", upload-time = 2026-03-29T13:29:33Z, size = 4955991, hashes = { sha256 = "6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f" } } -wheels = [{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", upload-time = 2026-03-29T13:29:30Z, size = 1231151, hashes = { sha256 = "81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176" } }] +archive = { url = "https://github.com/pygments/pygments/archive/2cad2642058441b59782a6a18f03c98c42d081f1.tar.gz", hashes = { sha256 = "e6ae46831285e86355eabb969bf2d0520655b001dd882147637f4bc500e56bfb" } } [[packages]] name = "python-docs-theme" @@ -173,9 +173,9 @@ wheels = [{ url = "https://files.pythonhosted.org/packages/db/05/b9298eb9330c70a [[packages]] name = "requests" -version = "2.33.1" -sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", upload-time = 2026-03-30T16:09:15Z, size = 134120, hashes = { sha256 = "18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517" } } -wheels = [{ url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", upload-time = 2026-03-30T16:09:13Z, size = 64947, hashes = { sha256 = "4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a" } }] +version = "2.34.2" +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", upload-time = 2026-05-14T19:25:27Z, size = 142856, hashes = { sha256 = "f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed" } } +wheels = [{ url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", upload-time = 2026-05-14T19:25:26Z, size = 73075, hashes = { sha256 = "2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0" } }] [[packages]] name = "roman-numerals" @@ -251,6 +251,6 @@ wheels = [{ url = "https://files.pythonhosted.org/packages/bf/a4/66c1fd4f8fab88f [[packages]] name = "urllib3" -version = "2.6.3" -sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", upload-time = 2026-01-07T16:24:43Z, size = 435556, hashes = { sha256 = "1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed" } } -wheels = [{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", upload-time = 2026-01-07T16:24:42Z, size = 131584, hashes = { sha256 = "bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4" } }] +version = "2.7.0" +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", upload-time = 2026-05-07T16:13:18Z, size = 433602, hashes = { sha256 = "231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c" } } +wheels = [{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", upload-time = 2026-05-07T16:13:17Z, size = 131087, hashes = { sha256 = "9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897" } }] diff --git a/Doc/requirements.txt b/Doc/requirements.txt index 536ae57e4efc298..c5cd360ff317521 100644 --- a/Doc/requirements.txt +++ b/Doc/requirements.txt @@ -9,6 +9,10 @@ # Keep this version in sync with ``Doc/conf.py``. sphinx<9.0.0 +# Temporary direct requirement, pending release of Pygments > 2.20.0 +# https://github.com/pygments/pygments/discussions/3145 +pygments @ https://github.com/pygments/pygments/archive/2cad2642058441b59782a6a18f03c98c42d081f1.tar.gz + blurb sphinxext-opengraph~=0.13.0 From 2cd5b79284dd2331cbd9e11afabbfbf7e906103d Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Tue, 7 Jul 2026 12:37:04 +0300 Subject: [PATCH 3/3] gh-143990: Allow tkinter.font.Font to wrap a font description (GH-152025) With exists=True and no name, Font now wraps the font description as is, without creating a new named font, so that it is used without loss of precision by actual(), measure() and metrics(). Its name attribute is then the description rather than a string. Keyword options now override the corresponding settings of the given font instead of being ignored. Co-Authored-By: Claude Opus 4.8 --- Doc/library/tkinter.font.rst | 70 +++++++++++----- Doc/whatsnew/3.16.rst | 6 ++ Lib/test/test_tkinter/test_font.py | 82 ++++++++++++++++++- Lib/tkinter/font.py | 63 +++++++++----- ...-06-23-18-42-37.gh-issue-143990.o65v7O.rst | 6 ++ 5 files changed, 180 insertions(+), 47 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-06-23-18-42-37.gh-issue-143990.o65v7O.rst diff --git a/Doc/library/tkinter.font.rst b/Doc/library/tkinter.font.rst index 2a9563fffab1f71..1ac7ac8e98d1662 100644 --- a/Doc/library/tkinter.font.rst +++ b/Doc/library/tkinter.font.rst @@ -20,33 +20,53 @@ The different font weights and slants are: .. class:: Font(root=None, font=None, name=None, exists=False, **options) - The :class:`Font` class represents a named font. *Font* instances are given - unique names and can be specified by their family, size, and style - configuration. Named fonts are Tk's method of creating and identifying - fonts as a single object, rather than specifying a font by its attributes - with each occurrence. + The :class:`Font` class represents a font used by Tk widgets. + It either creates a new *named font* or refers to an existing font. + A named font is Tk's way of identifying a font as a single object that can + be referred to by name and reconfigured in place, + rather than respecifying its attributes at each use. + + With *exists* false (the default), a new named font is created. + Its attributes are taken from the font description *font* if it is given, + overridden by any keyword *options*. + The new font is named *name*, or a generated unique name if *name* is + omitted. + + With *exists* true, an existing font is referred to instead of being + created. + If *name* is given, it is the name of the font, + which is reconfigured by *font* and *options* if either is given. + If *name* is omitted, the font description *font* is wrapped as is, + without creating a named font, + so that it is used without loss of precision by :meth:`actual`, + :meth:`measure` and :meth:`metrics`. + In this case no keyword options are accepted, + and the :attr:`!name` attribute is the description itself rather than a + string. + + The font description *font* is a tuple of the family name, the size and + zero or more styles, + or any other form accepted by Tk, such as the name of a named font. + + The keyword *options* are: + + | *family* - font family, for example, Courier, Times + | *size* - font size + | If *size* is positive it is interpreted as size in points. + | If *size* is a negative number its absolute value is treated + | as size in pixels. + | *weight* - font emphasis (NORMAL, BOLD) + | *slant* - ROMAN, ITALIC + | *underline* - font underlining (0 - none, 1 - underline) + | *overstrike* - font strikeout (0 - none, 1 - strikeout) .. versionchanged:: 3.10 Two fonts now compare equal (``==``) only when both are :class:`Font` instances with the same name belonging to the same Tcl interpreter. - arguments: - - | *font* - font specifier tuple (family, size, options) - | *name* - unique font name - | *exists* - self points to existing named font if true - - additional keyword options (ignored if *font* is specified): - - | *family* - font family, for example, Courier, Times - | *size* - font size - | If *size* is positive it is interpreted as size in points. - | If *size* is a negative number its absolute value is treated - | as size in pixels. - | *weight* - font emphasis (NORMAL, BOLD) - | *slant* - ROMAN, ITALIC - | *underline* - font underlining (0 - none, 1 - underline) - | *overstrike* - font strikeout (0 - none, 1 - strikeout) + .. versionchanged:: next + A font description can now be wrapped without creating a new named font, + and keyword options now override the attributes of the specified *font*. .. method:: actual(option=None, displayof=None) @@ -59,6 +79,12 @@ The different font weights and slants are: Retrieve an attribute of the font. + .. note:: + + :meth:`!cget` and :meth:`configure` operate on a named font and raise + :exc:`~tkinter.TclError` for a wrapped font description. + Use :meth:`actual` to query the attributes of the latter. + .. method:: config(**options) :no-typesetting: diff --git a/Doc/whatsnew/3.16.rst b/Doc/whatsnew/3.16.rst index e8c75d2f571061a..dd2c7b68d039921 100644 --- a/Doc/whatsnew/3.16.rst +++ b/Doc/whatsnew/3.16.rst @@ -408,6 +408,12 @@ tkinter them. (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`, + :meth:`~tkinter.font.Font.measure` and :meth:`~tkinter.font.Font.metrics`. + (Contributed by Serhiy Storchaka in :gh:`143990`.) + xml --- diff --git a/Lib/test/test_tkinter/test_font.py b/Lib/test/test_tkinter/test_font.py index 8adc9e1151db4ed..e6f331332eb20a9 100644 --- a/Lib/test/test_tkinter/test_font.py +++ b/Lib/test/test_tkinter/test_font.py @@ -44,6 +44,70 @@ def test_configure(self): self.assertRaises(TypeError, self.font.cget) self.assertRaises(TypeError, self.font.cget, 'size', 'weight') + def test_create(self): + sizetype = int if self.wantobjects else str + + # A new named font is created from the font description... + f = font.Font(root=self.root, font=('Times', 20, 'bold')) + self.assertIn(f.name, font.names(self.root)) + self.assertEqual(f.actual('weight'), 'bold') + self.assertEqual(f.cget('size'), sizetype(20)) + + # ... or from the keyword options. + f = font.Font(root=self.root, family='Times', size=20, weight='bold') + self.assertIn(f.name, font.names(self.root)) + self.assertEqual(f.actual('weight'), 'bold') + self.assertEqual(f.cget('size'), sizetype(20)) + + # Explicit options override the corresponding settings of *font*. + f = font.Font(root=self.root, font=('Times', 20, 'bold'), weight='normal') + self.assertEqual(f.actual('weight'), 'normal') + self.assertEqual(f.cget('size'), sizetype(20)) + + # The new font can be given an explicit name. + f = font.Font(root=self.root, name='testfont', font=('Times', 20)) + self.assertEqual(f.name, 'testfont') + self.assertIn('testfont', font.names(self.root)) + self.assertEqual(f.cget('size'), sizetype(20)) + # Reusing the name of an existing font fails. + self.assertRaises(tkinter.TclError, font.Font, root=self.root, + name='testfont', font=('Times', 10)) + + def test_existing(self): + sizetype = int if self.wantobjects else str + + # With a name, refer to the existing named font. + named = font.Font(root=self.root, name='existingfont', family='Times', size=20) + f = font.Font(root=self.root, name='existingfont', exists=True) + self.assertEqual(f.name, 'existingfont') + self.assertEqual(f.cget('size'), sizetype(20)) + # Referring to a non-existent named font fails. + self.assertRaises(tkinter.TclError, font.Font, root=self.root, + name='nosuchfont', exists=True) + # A name and options reconfigure the existing font. + font.Font(root=self.root, name='existingfont', exists=True, size=8) + self.assertEqual(f.cget('size'), sizetype(8)) + + # With a description and no name, the description is wrapped without + # creating a new named font (gh-143990), so that it is used without + # loss of precision by actual(), measure() and metrics(). + f = font.Font(root=self.root, font=('Times', 20, 'bold'), exists=True) + self.assertEqual(f.name, ('Times', 20, 'bold')) + 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'), sizetype(20)) + # It can be used as a widget option, with the same effect as the + # description itself (gh-143990). + self.assertEqual(tkinter.Label(self.root, font=f).cget('font'), + tkinter.Label(self.root, font=f.name).cget('font')) + + # Options cannot be combined with a wrapped description. + self.assertRaises(TypeError, font.Font, root=self.root, + font=('Times', 20), exists=True, weight='bold') + # A name or a description is required. + self.assertRaises(TypeError, font.Font, root=self.root, exists=True) + def test_copy(self): f = font.Font(root=self.root, family='Times', size=10, weight='bold') copied = f.copy() @@ -60,10 +124,7 @@ def test_copy(self): def test_unicode_family(self): family = 'MS \u30b4\u30b7\u30c3\u30af' - try: - f = font.Font(root=self.root, family=family, exists=True) - except tkinter.TclError: - f = font.Font(root=self.root, family=family, exists=False) + f = font.Font(root=self.root, family=family) self.assertEqual(f.cget('family'), family) del f gc_collect() @@ -96,6 +157,19 @@ def test_equality(self): self.assertEqual(font1, font2) self.assertNotEqual(font1, font1.copy()) + # Wrapped descriptions (gh-143990) compare by the description. + w1 = font.Font(root=self.root, font=('Times', 20, 'bold'), exists=True) + w2 = font.Font(root=self.root, font=('Times', 20, 'bold'), exists=True) + self.assertIsNot(w1, w2) + self.assertEqual(w1, w2) + w3 = font.Font(root=self.root, font=('Times', 12), exists=True) + self.assertNotEqual(w1, w3) + # A wrapped description never equals a named font, even one whose name + # is the string form of the description. + named = font.Font(root=self.root, name=str(w1), family='Courier') + self.assertNotEqual(w1, named) + self.assertNotEqual(named, w1) + self.assertNotEqual(font1, 0) self.assertEqual(font1, ALWAYS_EQ) diff --git a/Lib/tkinter/font.py b/Lib/tkinter/font.py index 896e910d69f6f33..d59b04d36638e79 100644 --- a/Lib/tkinter/font.py +++ b/Lib/tkinter/font.py @@ -70,34 +70,55 @@ def __init__(self, root=None, font=None, name=None, exists=False, if root is None: root = tkinter._get_default_root('use font') tk = getattr(root, 'tk', root) - if font: - # get actual settings corresponding to the given font - font = tk.splitlist(tk.call("font", "actual", font)) + self.delete_font = False + if exists and not name: + if not font: + raise TypeError("font name or description is required if exists=True") + if options: + raise TypeError("cannot specify font options when wrapping an " + "existing font description") + # Wrap the description without creating a new named font, so that + # it is used as is by actual(), measure() and metrics(). 'name' is + # then the description rather than a string. + self.name = font else: - font = self._set(options) - if not name: - name = "font" + str(next(self.counter)) - self.name = name - - if exists: - self.delete_font = False - # confirm font exists - if self.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 if font: - tk.call("font", "configure", self.name, *font) - else: - # create new font (raises TclError if the font exists) - tk.call("font", "create", self.name, *font) - self.delete_font = True + # start from the actual settings of the given font + font = tk.splitlist(tk.call("font", "actual", font)) + if options: + # explicit options override the corresponding settings + settings = self._mkdict(font) + settings.update(options) + font = self._set(settings) + else: + font = self._set(options) + if exists: + self.name = name + # confirm font exists + if self.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 + if font: + tk.call("font", "configure", self.name, *font) + else: + if name: + self.name = name + else: + self.name = "font" + str(next(self.counter)) + # create new font (raises TclError if the font exists) + tk.call("font", "create", self.name, *font) + self.delete_font = True # set after creation self._tk = tk self._split = tk.splitlist self._call = tk.call def __str__(self): - return self.name + # 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) def __repr__(self): return f"<{self.__class__.__module__}.{self.__class__.__qualname__}" \ diff --git a/Misc/NEWS.d/next/Library/2026-06-23-18-42-37.gh-issue-143990.o65v7O.rst b/Misc/NEWS.d/next/Library/2026-06-23-18-42-37.gh-issue-143990.o65v7O.rst new file mode 100644 index 000000000000000..c464f81a6bb2547 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-06-23-18-42-37.gh-issue-143990.o65v7O.rst @@ -0,0 +1,6 @@ +: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`, +:meth:`~tkinter.font.Font.measure` and :meth:`~tkinter.font.Font.metrics`. +Keyword options now override the corresponding settings of the given *font* +instead of being ignored.