https://github.com/python/cpython/commit/2cd5b79284dd2331cbd9e11afabbfbf7e906103d
commit: 2cd5b79284dd2331cbd9e11afabbfbf7e906103d
branch: main
author: Serhiy Storchaka <[email protected]>
committer: serhiy-storchaka <[email protected]>
date: 2026-07-07T09:37:04Z
summary:

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 <[email protected]>

files:
A Misc/NEWS.d/next/Library/2026-06-23-18-42-37.gh-issue-143990.o65v7O.rst
M Doc/library/tkinter.font.rst
M Doc/whatsnew/3.16.rst
M Lib/test/test_tkinter/test_font.py
M Lib/tkinter/font.py

diff --git a/Doc/library/tkinter.font.rst b/Doc/library/tkinter.font.rst
index 2a9563fffab1f7..1ac7ac8e98d166 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 e8c75d2f571061..dd2c7b68d03992 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 8adc9e1151db4e..e6f331332eb20a 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 896e910d69f6f3..d59b04d36638e7 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 00000000000000..c464f81a6bb254
--- /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.

_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: [email protected]

Reply via email to