From 3d9d6c8ccbc2a041b8d29c67516d1728442e4f41 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Sun, 5 Jul 2026 14:27:51 +0300 Subject: [PATCH 1/8] gh-59396: Use themed widgets in tkinter.filedialog (GH-152036) The FileDialog, LoadFileDialog and SaveFileDialog dialogs are now built from the themed tkinter.ttk widgets by default instead of the classic tkinter widgets, and gained a use_ttk parameter that selects between the classic Tk widgets and the themed ttk widgets. They were also brought closer to the native Tk file dialog: the buttons and field labels gained Alt key accelerators, the default ring follows the keyboard focus, the Escape key cancels the dialog, the focus traverses the widgets in their visual order, and the directory and file lists gained a horizontal scrollbar and type-ahead selection. The dialog is now transient and centered over its parent, and the SaveFileDialog overwrite confirmation uses a themed message box. Co-Authored-By: Claude Opus 4.8 --- Doc/library/dialog.rst | 22 +- Doc/whatsnew/3.16.rst | 8 + Lib/idlelib/run.py | 10 +- Lib/test/test_tkinter/test_filedialog.py | 100 +++++ Lib/tkinter/filedialog.py | 355 +++++++++++++----- ...6-06-21-23-17-12.gh-issue-59396.Fd9Tk2.rst | 14 + 6 files changed, 400 insertions(+), 109 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-06-21-23-17-12.gh-issue-59396.Fd9Tk2.rst diff --git a/Doc/library/dialog.rst b/Doc/library/dialog.rst index a576cdcfaa2b1e9..06f2d6ddbf4f48b 100644 --- a/Doc/library/dialog.rst +++ b/Doc/library/dialog.rst @@ -217,9 +217,19 @@ These do not emulate the native look-and-feel of the platform. .. note:: The *FileDialog* class should be subclassed for custom event handling and behaviour. -.. class:: FileDialog(master, title=None) +.. class:: FileDialog(master, title=None, *, use_ttk=True) Create a basic file selection dialog. + Its layout -- a filter entry, side-by-side directory and file lists, and a + selection entry -- follows the classic Motif file selection dialog. + When *use_ttk* is true (the default), the dialog is built from the themed + :mod:`tkinter.ttk` widgets; when false, from the classic :mod:`tkinter` + widgets. + + .. versionchanged:: next + The dialog is now built from the themed :mod:`tkinter.ttk` widgets by + default, instead of the classic :mod:`tkinter` widgets. + Added the *use_ttk* parameter. .. method:: cancel_command(event=None) @@ -281,21 +291,27 @@ These do not emulate the native look-and-feel of the platform. Update the current file selection to *file*. -.. class:: LoadFileDialog(master, title=None) +.. class:: LoadFileDialog(master, title=None, *, use_ttk=True) A subclass of FileDialog that creates a dialog window for selecting an existing file. + .. versionchanged:: next + Added the *use_ttk* parameter. + .. method:: ok_command() Test that a file is provided and that the selection indicates an already existing file. -.. class:: SaveFileDialog(master, title=None) +.. class:: SaveFileDialog(master, title=None, *, use_ttk=True) A subclass of FileDialog that creates a dialog window for selecting a destination file. + .. versionchanged:: next + Added the *use_ttk* parameter. + .. method:: ok_command() Test whether or not the selection points to a valid file that is not a diff --git a/Doc/whatsnew/3.16.rst b/Doc/whatsnew/3.16.rst index 1a73a79a58b78b1..8219700cc1e8385 100644 --- a/Doc/whatsnew/3.16.rst +++ b/Doc/whatsnew/3.16.rst @@ -383,6 +383,14 @@ tkinter ttk version, and accepts mappings of button options as *buttons* entries. (Contributed by Serhiy Storchaka in :gh:`59396`.) +* The :class:`!tkinter.filedialog.FileDialog` dialog and its + :class:`!tkinter.filedialog.LoadFileDialog` and + :class:`!tkinter.filedialog.SaveFileDialog` subclasses are now built from the + themed :mod:`tkinter.ttk` widgets by default instead of the classic + :mod:`tkinter` widgets, and gained a *use_ttk* parameter to select between + them. + (Contributed by Serhiy Storchaka in :gh:`59396`.) + xml --- diff --git a/Lib/idlelib/run.py b/Lib/idlelib/run.py index f5564ccf90f7f46..802f0248e4e4594 100644 --- a/Lib/idlelib/run.py +++ b/Lib/idlelib/run.py @@ -31,11 +31,17 @@ import tkinter # Use tcl and, if startup fails, messagebox. if not hasattr(sys.modules['idlelib.run'], 'firstrun'): # Undo modifications of tkinter by idlelib imports; see bpo-25507. + # Which of these submodules got imported (and thus added as a tkinter + # attribute) depends on what idlelib pulled in, so tolerate missing + # ones rather than assuming a fixed set; see gh-59396. for mod in ('simpledialog', 'messagebox', 'font', 'dialog', 'filedialog', 'commondialog', 'ttk'): - delattr(tkinter, mod) - del sys.modules['tkinter.' + mod] + try: + delattr(tkinter, mod) + del sys.modules['tkinter.' + mod] + except (AttributeError, KeyError): + pass # Avoid AttributeError if run again; see bpo-37038. sys.modules['idlelib.run'].firstrun = False diff --git a/Lib/test/test_tkinter/test_filedialog.py b/Lib/test/test_tkinter/test_filedialog.py index 758ef1479ef2cfa..b89862d3fcdcb2b 100644 --- a/Lib/test/test_tkinter/test_filedialog.py +++ b/Lib/test/test_tkinter/test_filedialog.py @@ -1,6 +1,8 @@ import os import unittest +import tkinter from tkinter import filedialog +from tkinter import ttk from tkinter.commondialog import Dialog from test.support import requires, swap_attr from test.test_tkinter.support import setUpModule # noqa: F401 @@ -101,6 +103,104 @@ def test_subclasses(self): self.assertIsInstance(d, filedialog.FileDialog) self.assertEqual(d.top.title(), cls.title) + # --- Themed widgets and keyboard (modernization) --- + + def open(self, **kw): + d = filedialog.FileDialog(self.root, **kw) + self.addCleanup(lambda: d.top.winfo_exists() and d.top.destroy()) + d.top.deiconify() # __init__ leaves the dialog withdrawn until go() + d.top.update() + return d + + def test_use_ttk(self): + # The dialog uses the themed (ttk) widgets by default. + d = self.open() + self.assertEqual(d.ok_button.winfo_class(), 'TButton') + self.assertEqual(d.selection.winfo_class(), 'TEntry') + + def test_use_classic(self): + # use_ttk=False uses the classic Tk widgets. + d = self.open(use_ttk=False) + 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') + + def test_background(self): + # The ttk dialog adopts the ttk background, even a customized one, while + # the classic dialog keeps the default Toplevel background. + style = ttk.Style(self.root) + old = style.lookup('.', 'background') + style.configure('.', background='#123456') + self.addCleanup(style.configure, '.', background=old) + d = self.open() + self.assertEqual(str(d.top.cget('background')), '#123456') + d = self.open(use_ttk=False) + ref = tkinter.Toplevel(self.root) + self.addCleanup(ref.destroy) + self.assertEqual(str(d.top.cget('background')), + str(ref.cget('background'))) + + 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) + + 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') + d.cancel_button.focus_force() + d.top.update() + self.assertEqual(str(d.cancel_button.cget('default')), 'active') + d.ok_button.focus_force() + d.top.update() + self.assertEqual(str(d.cancel_button.cget('default')), 'normal') + + def test_alt_key(self): + # Alt + the underlined letter invokes the matching button. + d = self.open() + invoked = [] + d.cancel_button.configure(command=lambda: invoked.append(True)) + d.top.focus_force() + d.top.update() + d.top.event_generate('') # "&Cancel" + d.top.update() + self.assertTrue(invoked) + + def test_escape_cancels(self): + # The Escape key cancels the dialog. + d = self.open() + d.how = 'spam' + d.top.focus_force() + d.top.update() + d.top.event_generate('') + d.top.update() + self.assertIsNone(d.how) + + 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.assertTrue(d.dirs.cget('xscrollcommand')) + self.assertTrue(d.files.cget('xscrollcommand')) + + def test_type_ahead(self): + # Typing characters over a list jumps to a matching entry. + d = self.open() + d.directory = os.getcwd() # browsing the match fills the selection entry + d.files.delete(0, 'end') + for name in ('alpha', 'bravo', 'charlie'): + d.files.insert('end', name) + d.files.focus_force() + d.top.update() + d.files.event_generate('', keysym='c') + d.top.update() + sel = d.files.curselection() + self.assertEqual([d.files.get(i) for i in sel], ['charlie']) + if __name__ == "__main__": unittest.main() diff --git a/Lib/tkinter/filedialog.py b/Lib/tkinter/filedialog.py index 23cb19249a92835..29020475a3cb538 100644 --- a/Lib/tkinter/filedialog.py +++ b/Lib/tkinter/filedialog.py @@ -18,13 +18,16 @@ import fnmatch import os +import tkinter from tkinter import ( - Frame, LEFT, YES, BOTTOM, Entry, TOP, Button, Tk, X, - Toplevel, RIGHT, Y, END, Listbox, BOTH, Scrollbar, + ACTIVE, CENTER, EW, NORMAL, NS, NSEW, RAISED, W, YES, BOTTOM, TOP, Tk, X, + Toplevel, END, Listbox, BOTH, ) -from tkinter.dialog import Dialog +from tkinter import ttk +from tkinter import messagebox from tkinter import commondialog -from tkinter.simpledialog import _setup_dialog +from tkinter.simpledialog import (_setup_dialog, _place_window, _temp_grab_focus, + _underline_ampersand, _find_alt_key_target) dialogstates = {} @@ -34,6 +37,8 @@ class FileDialog: """Standard file selection dialog -- no checks on selected file. + The layout and behavior follow the classic Motif file selection dialog. + Usage: d = FileDialog(master) @@ -55,69 +60,145 @@ class FileDialog: title = "File Selection Dialog" - def __init__(self, master, title=None): + def _widget(self, klass, master, **kw): + # Create a themed (ttk) or classic (tkinter) widget. ttk has no + # Listbox, so the directory and file lists stay classic. + return getattr(ttk if self.use_ttk else tkinter, klass)(master, **kw) + + def _frame(self, master): + # A structural frame. The classic file dialog gives its frames a + # raised border on X11; the themed one does not (cf. tk_dialog). + frame = self._widget('Frame', master) + if not self.use_ttk and self.top._windowingsystem == 'x11': + frame.configure(relief=RAISED, bd=1) + return frame + + def _make_list(self, column, label, browse, activate): + # Create a labelled listbox with vertical and horizontal scrollbars in + # the middle frame, like the Motif file dialog (cf. xmfbox.tcl + # MotifFDialog_MakeSList). + frame = self._widget('Frame', self.midframe) + frame.grid(row=0, column=column, sticky=NSEW, padx='3p', pady='3p') + frame.grid_rowconfigure(1, weight=1) + frame.grid_columnconfigure(0, weight=1) + vbar = self._widget('Scrollbar', frame, takefocus=0) + hbar = self._widget('Scrollbar', frame, orient='horizontal', takefocus=0) + listbox = Listbox(frame, exportselection=0, + xscrollcommand=(hbar, 'set'), + yscrollcommand=(vbar, 'set')) + vbar.configure(command=(listbox, 'yview')) + hbar.configure(command=(listbox, 'xview')) + self._label(frame, label, listbox).grid( + row=0, column=0, columnspan=2, sticky=EW, padx='1.5p', pady='1.5p') + listbox.grid(row=1, column=0, sticky=NSEW) + vbar.grid(row=1, column=1, sticky=NS) + hbar.grid(row=2, column=0, sticky=EW) + # Instance bindings fire after the Listbox class bindings, which have + # already updated the selection. + btags = listbox.bindtags() + listbox.bindtags(btags[1:] + btags[:1]) + listbox.bind('<>', browse) + listbox.bind('', activate) + listbox.bind('', lambda e: (browse(e), activate(e))) + # Type a few characters to jump to a matching entry, like the Motif + # file dialog (cf. xmfbox.tcl ListBoxKeyAccel). + listbox.bind('', self._listbox_keyaccel) + return listbox, vbar, hbar + + def __init__(self, master, title=None, *, use_ttk=True): if title is None: title = self.title self.master = master + self.use_ttk = use_ttk self.directory = None + self._keyaccel = {} # listbox -> recently typed prefix + self._keyaccel_after = {} # listbox -> pending reset callback id self.top = Toplevel(master) + self.top.withdraw() # remain invisible until placed by go() self.top.title(title) self.top.iconname(title) + # Keep the dialog above its parent, like SimpleDialog and Dialog (cf. + # xmfbox.tcl). Skip it when the master is not viewable, or the dialog + # would itself be opened withdrawn. + if master.winfo_viewable(): + self.top.transient(master) _setup_dialog(self.top) - - self.botframe = Frame(self.top) - self.botframe.pack(side=BOTTOM, fill=X) - - self.selection = Entry(self.top) - self.selection.pack(side=BOTTOM, fill=X) - self.selection.bind('', self.ok_event) - - self.filter = Entry(self.top) - self.filter.pack(side=TOP, fill=X) + if self.use_ttk: + # Use a single themed background for the whole dialog so it blends + # with the ttk widgets (cf. tk::MessageBox). + self.top.configure( + background=ttk.Style(self.top).lookup('.', 'background')) + + # Tk traverses focus in widget-creation order, so the widgets are + # created top to bottom -- filter, lists, selection, buttons -- to make + # Tab follow the visual layout, like the Motif file dialog (cf. + # xmfbox.tcl). + self.filter = self._widget('Entry', self.top) self.filter.bind('', self.filter_command) - - self.midframe = Frame(self.top) - self.midframe.pack(expand=YES, fill=BOTH) - - self.filesbar = Scrollbar(self.midframe) - self.filesbar.pack(side=RIGHT, fill=Y) - self.files = Listbox(self.midframe, exportselection=0, - yscrollcommand=(self.filesbar, 'set')) - self.files.pack(side=RIGHT, expand=YES, fill=BOTH) - btags = self.files.bindtags() - self.files.bindtags(btags[1:] + btags[:1]) - self.files.bind('', self.files_select_event) - self.files.bind('', self.files_double_event) - self.filesbar.config(command=(self.files, 'yview')) - - self.dirsbar = Scrollbar(self.midframe) - self.dirsbar.pack(side=LEFT, fill=Y) - self.dirs = Listbox(self.midframe, exportselection=0, - yscrollcommand=(self.dirsbar, 'set')) - self.dirs.pack(side=LEFT, expand=YES, fill=BOTH) - self.dirsbar.config(command=(self.dirs, 'yview')) - btags = self.dirs.bindtags() - self.dirs.bindtags(btags[1:] + btags[:1]) - self.dirs.bind('', self.dirs_select_event) - self.dirs.bind('', self.dirs_double_event) - - self.ok_button = Button(self.botframe, - text="OK", - command=self.ok_command) - self.ok_button.pack(side=LEFT) - self.filter_button = Button(self.botframe, - text="Filter", - command=self.filter_command) - self.filter_button.pack(side=LEFT, expand=YES) - self.cancel_button = Button(self.botframe, - text="Cancel", - command=self.cancel_command) - self.cancel_button.pack(side=RIGHT) + self.filter_label = self._label(self.top, 'Fil&ter:', self.filter) + self.filter_label.pack(side=TOP, fill=X, padx='4.5p', pady='3p') + self.filter.pack(side=TOP, fill=X, padx='3p') + + self.midframe = self._frame(self.top) + self.midframe.pack(side=TOP, expand=YES, fill=BOTH) + + # Directory list (left) and file list (right), each with a label and + # vertical and horizontal scrollbars, like the Motif file dialog (cf. + # xmfbox.tcl). + self.dirs, self.dirsbar, self.dirshbar = self._make_list( + 0, '&Directory:', self.dirs_select_event, self.dirs_double_event) + self.files, self.filesbar, self.fileshbar = self._make_list( + 1, 'Fi&les:', self.files_select_event, self.files_double_event) + + # Give the file list twice the width of the directory list, like the + # Motif file dialog (cf. xmfbox.tcl). + self.midframe.grid_rowconfigure(0, weight=1) + self.midframe.grid_columnconfigure(0, weight=1) + self.midframe.grid_columnconfigure(1, minsize=150, weight=2) + + self.selection = self._widget('Entry', self.top) + self.selection.bind('', self.ok_event) + self.selection_label = self._label(self.top, '&Selection:', self.selection) + self.selection.pack(side=BOTTOM, fill=X, padx='3p', pady='3p') + self.selection_label.pack(side=BOTTOM, fill=X, padx='4.5p') + + # Created last so the buttons traverse last, but packed below the + # selection field. + self.botframe = self._frame(self.top) + self.botframe.pack(side=BOTTOM, fill=X, before=self.selection) + + # tk::MessageBox and tk_dialog space the buttons differently. + padx, pady = ('3m', '2m') if self.use_ttk else ('7.5p', '3p') + for i, (name, label, command) in enumerate(( + ('ok', '&OK', self.ok_command), + ('filter', '&Filter', self.filter_command), + ('cancel', '&Cancel', self.cancel_command))): + # Create a button with an accelerator key marked by "&" in the text, + # like SimpleDialog and Dialog (cf. tk::AmpWidget). + text, underline = _underline_ampersand(label) + button = self._widget('Button', self.botframe, name=name, text=text, + underline=underline, command=command, + default=ACTIVE if name == 'ok' else NORMAL) + button.bind('<>', lambda e: e.widget.invoke()) + button.grid(column=i, row=0, sticky=EW, padx=padx, pady=pady) + # tk::MessageBox makes the buttons equal width; tk_dialog does not. + self.botframe.grid_columnconfigure( + i, uniform='buttons' if self.use_ttk else '') + if self.top._windowingsystem == 'aqua': + self.botframe.grid_columnconfigure(i, minsize=90) + button.grid_configure(pady=7) + setattr(self, name + '_button', button) + self.botframe.grid_anchor(CENTER) self.top.protocol('WM_DELETE_WINDOW', self.cancel_command) - # XXX Are the following okay for a general audience? - self.top.bind('', self.cancel_command) - self.top.bind('', self.cancel_command) + # Alt + an underlined character invokes the matching button (cf. + # ::tk::AltKeyInDialog), like SimpleDialog and Dialog. + self.top.bind('', self._alt_key) + # The default ring follows the keyboard focus among the buttons (cf. + # tk::MessageBox), like SimpleDialog and Dialog. + self.top.bind('', lambda e: self._set_default(e.widget, ACTIVE)) + self.top.bind('', lambda e: self._set_default(e.widget, NORMAL)) + self.top.bind('', self.cancel_command) def go(self, dir_or_file=os.curdir, pattern="*", default="", key=None): if key and key in dialogstates: @@ -131,11 +212,17 @@ def go(self, dir_or_file=os.curdir, pattern="*", default="", key=None): self.set_filter(self.directory, pattern) self.set_selection(default) self.filter_command() - self.selection.focus_set() + # Center the dialog over its parent and make it visible, like + # SimpleDialog and Dialog (cf. xmfbox.tcl). + _place_window(self.top, self.master) + self.selection.select_range(0, END) # so the user can type a new name self.top.wait_visibility() # window needs to be visible for the grab - self.top.grab_set() self.how = None - self.master.mainloop() # Exited by self.quit(how) + # Grab the input and restore the previous focus and grab afterwards, + # like SimpleDialog and Dialog. go() destroys the window itself below, + # so leave it in place here. + with _temp_grab_focus(self.top, self.selection, destroy=False): + self.master.mainloop() # Exited by self.quit(how) if key: directory, pattern = self.get_filter() if self.how: @@ -148,21 +235,91 @@ def quit(self, how=None): self.how = how self.master.quit() # Exit mainloop() + def _alt_key(self, event): + # Invoke the button whose accelerator matches the Alt key (cf. + # SimpleDialog and Dialog). + target = _find_alt_key_target(self.top, event.char) + if target is not None: + target.event_generate('<>') + + def _set_default(self, widget, state): + # Set a button's default ring. + if widget.winfo_class() in ('Button', 'TButton'): + widget.configure(default=state) + + def _label(self, master, label, target): + # Create a field label whose "&" accelerator focuses the target widget, + # like the labels of the Motif file dialog (cf. xmfbox.tcl). + text, underline = _underline_ampersand(label) + widget = self._widget('Label', master, text=text, underline=underline, + anchor=W) + widget.bind('<>', lambda e: target.focus_set()) + return widget + + def _listbox_keyaccel(self, event): + # Append the typed character and jump to a matching entry, resetting + # after a short pause (cf. xmfbox.tcl ListBoxKeyAccel_Key). + char = event.char + if not char or not char.isprintable(): + return + listbox = event.widget + prefix = self._keyaccel.get(listbox, '') + char + self._keyaccel[listbox] = prefix + self._listbox_keyaccel_goto(listbox, prefix) + after_id = self._keyaccel_after.get(listbox) + if after_id is not None: + listbox.after_cancel(after_id) + self._keyaccel_after[listbox] = listbox.after( + 500, lambda: self._keyaccel.pop(listbox, None)) + + def _listbox_keyaccel_goto(self, listbox, prefix): + # Select the first entry not preceding the typed prefix (cf. xmfbox.tcl + # ListBoxKeyAccel_Goto). + prefix = prefix.lower() + index = -1 + for i in range(listbox.size()): + item = listbox.get(i).lower() + if prefix >= item: + index = i + if prefix <= item: + index = i + break + if index >= 0: + listbox.selection_clear(0, END) + listbox.selection_set(index) + listbox.activate(index) + listbox.see(index) + listbox.event_generate('<>') + def dirs_double_event(self, event): self.filter_command() + # Highlight an entry so the directory list stays keyboard-navigable + # after entering a directory, like the Motif file dialog (cf. + # xmfbox.tcl); prefer the first subdirectory over the ".." entry. + index = 1 if self.dirs.size() > 1 else 0 + self.dirs.selection_set(index) + self.dirs.activate(index) def dirs_select_event(self, event): + # Show a selection in only one list at a time (cf. xmfbox.tcl). + self.files.selection_clear(0, END) dir, pat = self.get_filter() subdir = self.dirs.get('active') dir = os.path.normpath(os.path.join(self.directory, subdir)) self.set_filter(dir, pat) + # Show the end of a long path, like the Motif file dialog (cf. + # xmfbox.tcl). + self.filter.xview(END) def files_double_event(self, event): self.ok_command() def files_select_event(self, event): + # Show a selection in only one list at a time (cf. xmfbox.tcl). + self.dirs.selection_clear(0, END) file = self.files.get('active') self.set_selection(file) + self.selection.xview(END) def ok_event(self, event): self.ok_command() @@ -256,13 +413,10 @@ def ok_command(self): if os.path.isdir(file): self.master.bell() return - d = Dialog(self.top, - title="Overwrite Existing File Question", - text="Overwrite existing file %r?" % (file,), - bitmap='questhead', - default=1, - strings=("Yes", "Cancel")) - if d.num != 0: + if not messagebox.askyesno( + "Overwrite Existing File", + "Overwrite existing file %r?" % (file,), + icon=messagebox.WARNING, parent=self.top): return else: head, tail = os.path.split(file) @@ -446,44 +600,37 @@ def askdirectory (**options): def test(): """Simple test program.""" root = Tk() - root.withdraw() - fd = LoadFileDialog(root) - loadfile = fd.go(key="test") - fd = SaveFileDialog(root) - savefile = fd.go(key="test") - print(loadfile, savefile) - - # Since the file name may contain non-ASCII characters, we need - # to find an encoding that likely supports the file name, and - # displays correctly on the terminal. - - # Start off with UTF-8 - enc = "utf-8" - - # See whether CODESET is defined - try: - import locale - locale.setlocale(locale.LC_ALL,'') - enc = locale.nl_langinfo(locale.CODESET) - except (ImportError, AttributeError): - pass - - # dialog for opening files - - openfilename=askopenfilename(filetypes=[("all files", "*")]) - try: - fp=open(openfilename,"r") - fp.close() - except BaseException as exc: - print("Could not open File: ") - print(exc) - - print("open", openfilename.encode(enc)) - - # dialog for saving files - - saveasfilename=asksaveasfilename() - print("saveas", saveasfilename.encode(enc)) + use_ttk = tkinter.BooleanVar(root, value=True) + + def test_load(): + print('load:', LoadFileDialog(root, use_ttk=use_ttk.get()).go(key='test')) + + def test_save(): + print('save:', SaveFileDialog(root, use_ttk=use_ttk.get()).go(key='test')) + + def test_open(): + print('open:', askopenfilename(filetypes=[('all files', '*')])) + + def test_saveas(): + print('saveas:', asksaveasfilename()) + + def test_directory(): + print('directory:', askdirectory()) + + def test_directory_mustexist(): + print('directory (mustexist):', askdirectory(mustexist=True)) + + tkinter.Checkbutton(root, text='Use themed (ttk) widgets', + variable=use_ttk).pack(fill=X) + tkinter.Button(root, text='LoadFileDialog', command=test_load).pack(fill=X) + tkinter.Button(root, text='SaveFileDialog', command=test_save).pack(fill=X) + tkinter.Button(root, text='askopenfilename', command=test_open).pack(fill=X) + tkinter.Button(root, text='asksaveasfilename', command=test_saveas).pack(fill=X) + tkinter.Button(root, text='askdirectory', command=test_directory).pack(fill=X) + tkinter.Button(root, text='askdirectory (mustexist)', + command=test_directory_mustexist).pack(fill=X) + tkinter.Button(root, text='Quit', command=root.quit).pack(fill=X) + root.mainloop() if __name__ == '__main__': diff --git a/Misc/NEWS.d/next/Library/2026-06-21-23-17-12.gh-issue-59396.Fd9Tk2.rst b/Misc/NEWS.d/next/Library/2026-06-21-23-17-12.gh-issue-59396.Fd9Tk2.rst new file mode 100644 index 000000000000000..890a29cb7adf95d --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-06-21-23-17-12.gh-issue-59396.Fd9Tk2.rst @@ -0,0 +1,14 @@ +The :class:`!tkinter.filedialog.FileDialog` dialog and its +:class:`!tkinter.filedialog.LoadFileDialog` and +:class:`!tkinter.filedialog.SaveFileDialog` subclasses, which follow the layout +of the classic Motif file selection dialog, were modernized to match its look +and feel more closely. +They are now built from the themed :mod:`tkinter.ttk` widgets instead of the +classic :mod:`tkinter` widgets, and gained a *use_ttk* parameter that selects +between the classic Tk widgets and the themed ttk widgets. +The buttons and field labels gained :kbd:`Alt` key accelerators, the default +ring follows the keyboard focus, and the :kbd:`Escape` key cancels the dialog. +The directory and file lists gained a horizontal scrollbar and type-ahead +selection. +The dialog is now centered over its parent, and the overwrite confirmation of +:class:`!SaveFileDialog` uses a themed message box. From 8f06112b8fbc716b2f11fc77fe5fa0731e4f5cd3 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Sun, 5 Jul 2026 15:49:43 +0300 Subject: [PATCH 2/8] gh-43699: Defer the drag cursor in tkinter.dnd until the pointer moves (GH-152371) The drag cursor is now changed only once the pointer starts moving past a small threshold rather than on the initial button press, so that a plain click no longer flashes it. Co-authored-by: Claude Opus 4.8 --- Lib/test/test_tkinter/test_dnd.py | 14 +++++++++++--- Lib/tkinter/dnd.py | 18 +++++++++++++++++- ...26-06-27-12-00-00.gh-issue-43699.Zt46MI.rst | 3 +++ 3 files changed, 31 insertions(+), 4 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-06-27-12-00-00.gh-issue-43699.Zt46MI.rst diff --git a/Lib/test/test_tkinter/test_dnd.py b/Lib/test/test_tkinter/test_dnd.py index fe3685aefe48be9..79c868b68562bce 100644 --- a/Lib/test/test_tkinter/test_dnd.py +++ b/Lib/test/test_tkinter/test_dnd.py @@ -40,10 +40,12 @@ def dnd_end(self, target, event): class FakeEvent: - def __init__(self, widget, num=1): + def __init__(self, widget, num=1, x_root=0, y_root=0): self.num = num self.widget = widget - self.x = self.y = self.x_root = self.y_root = 0 + self.x = self.y = 0 + self.x_root = x_root + self.y_root = y_root class DndTest(AbstractTkTest, unittest.TestCase): @@ -111,10 +113,16 @@ def test_restart_after_finish(self): handler.cancel() def test_drag_cursor(self): + # The drag cursor is not shown on the initial press, only once the + # pointer moves past the threshold, so a plain click does not flash + # it (gh-43699). The original cursor is restored afterwards. self.canvas['cursor'] = 'watch' handler = dnd.dnd_start(self.source, FakeEvent(self.canvas)) - # The drag cursor is shown while dragging, the original restored after. self.assertEqual(handler.save_cursor, 'watch') + self.assertEqual(str(self.canvas['cursor']), 'watch') + handler.on_motion(FakeEvent(self.canvas, x_root=2)) # below threshold + self.assertEqual(str(self.canvas['cursor']), 'watch') + handler.on_motion(FakeEvent(self.canvas, x_root=20)) # past threshold self.assertEqual(str(self.canvas['cursor']), 'hand2') handler.cancel() self.assertEqual(str(self.canvas['cursor']), 'watch') diff --git a/Lib/tkinter/dnd.py b/Lib/tkinter/dnd.py index acec61ba71f1c9f..3a306705c9b1063 100644 --- a/Lib/tkinter/dnd.py +++ b/Lib/tkinter/dnd.py @@ -120,6 +120,10 @@ class DndHandler: root = None + # The drag cursor is shown only once the pointer has moved this many + # pixels from the initial press, so that a plain click does not flash it. + threshold = 3 + def __init__(self, source, event): if event.num > 5: return @@ -134,11 +138,12 @@ def __init__(self, source, event): self.target = None self.initial_button = button = event.num self.initial_widget = widget = event.widget + self.dragging = False + self.x_origin, self.y_origin = event.x_root, event.y_root self.release_pattern = "" % (button, button) self.save_cursor = widget['cursor'] or "" widget.bind(self.release_pattern, self.on_release) widget.bind("", self.on_motion) - widget['cursor'] = "hand2" def __del__(self): root = self.root @@ -175,6 +180,17 @@ def on_motion(self, event): if new_target is not None: new_target.dnd_enter(source, event) self.target = new_target + self.update_cursor(x, y) + + def update_cursor(self, x, y): + # Show the drag cursor only once the pointer has actually started + # moving past the threshold, so that a plain click does not flash it. + if not self.dragging: + if (abs(x - self.x_origin) <= self.threshold and + abs(y - self.y_origin) <= self.threshold): + return + self.dragging = True + self.initial_widget['cursor'] = "hand2" def on_release(self, event): self.finish(event, 1) diff --git a/Misc/NEWS.d/next/Library/2026-06-27-12-00-00.gh-issue-43699.Zt46MI.rst b/Misc/NEWS.d/next/Library/2026-06-27-12-00-00.gh-issue-43699.Zt46MI.rst new file mode 100644 index 000000000000000..93a9864d2445e94 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-06-27-12-00-00.gh-issue-43699.Zt46MI.rst @@ -0,0 +1,3 @@ +The drag cursor in :mod:`tkinter.dnd` is now changed only once the pointer +starts moving rather than on the initial button press, so that a plain +click no longer flashes it. From d2d415b9761a539c37ea4f950df4e9aab0eca6ed Mon Sep 17 00:00:00 2001 From: Savage Mechanic Date: Sun, 5 Jul 2026 14:16:54 +0100 Subject: [PATCH 3/8] gh-149930: Clarify imaplib response helper types (GH-149963) Co-authored-by: savagemechanic <20458938+savagemechanic@users.noreply.github.com> --- Doc/library/imaplib.rst | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/Doc/library/imaplib.rst b/Doc/library/imaplib.rst index dbf4560ab5e53b3..c58c957d03c1d84 100644 --- a/Doc/library/imaplib.rst +++ b/Doc/library/imaplib.rst @@ -127,11 +127,11 @@ The second subclass allows for connections created by a child process: The following utility functions are defined: -.. function:: Internaldate2tuple(datestr) +.. function:: Internaldate2tuple(resp) - Parse an IMAP4 ``INTERNALDATE`` string and return corresponding local - time. The return value is a :class:`time.struct_time` tuple or - ``None`` if the string has wrong format. + Parse a :term:`bytes-like object` containing an IMAP4 ``INTERNALDATE`` + response and return the corresponding local time. The return value is a + :class:`time.struct_time` tuple or ``None`` if the input has wrong format. .. function:: Int2AP(num) @@ -139,9 +139,11 @@ The following utility functions are defined: [``A`` .. ``P``]. -.. function:: ParseFlags(flagstr) +.. function:: ParseFlags(resp) - Converts an IMAP4 ``FLAGS`` response to a tuple of individual flags. + Converts a :term:`bytes-like object` containing an IMAP4 ``FLAGS`` response + to a tuple of individual flags as :class:`bytes`. The return value is an + empty tuple if the input has wrong format. .. function:: Time2Internaldate(date_time) From 74a2438bf783077cf49460ed0fed8853675fb103 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Sun, 5 Jul 2026 17:08:58 +0300 Subject: [PATCH 4/8] gh-68147: Fix RFC references in imaplib (GH-153126) Refer to RFC 3501, which obsoleted RFC 2060. "]" is disallowed in flags, not in tags. Co-authored-by: Claude Fable 5 --- Doc/library/imaplib.rst | 10 +++++----- Lib/imaplib.py | 6 +++--- Lib/test/test_imaplib.py | 4 ++-- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/Doc/library/imaplib.rst b/Doc/library/imaplib.rst index c58c957d03c1d84..3f46c5146acc6ee 100644 --- a/Doc/library/imaplib.rst +++ b/Doc/library/imaplib.rst @@ -16,7 +16,7 @@ This module defines three classes, :class:`IMAP4`, :class:`IMAP4_SSL` and :class:`IMAP4_stream`, which encapsulate a connection to an IMAP4 server and implement a large subset of the IMAP4rev1 client protocol as defined in -:rfc:`2060`. It is backward compatible with IMAP4 (:rfc:`1730`) servers, but +:rfc:`3501`. It is backward compatible with IMAP4 (:rfc:`1730`) servers, but note that the ``STATUS`` command is not supported in IMAP4. .. include:: ../includes/wasm-notavail.rst @@ -632,7 +632,7 @@ An :class:`IMAP4` instance has the following methods: .. method:: IMAP4.store(message_set, command, flag_list) Alters flag dispositions for messages in mailbox. *command* is specified by - section 6.4.6 of :rfc:`2060` as being one of "FLAGS", "+FLAGS", or "-FLAGS", + section 6.4.6 of :rfc:`3501` as being one of "FLAGS", "+FLAGS", or "-FLAGS", optionally with a suffix of ".SILENT". For example, to set the delete flag on all messages:: @@ -646,11 +646,11 @@ An :class:`IMAP4` instance has the following methods: Creating flags containing ']' (for example: "[test]") violates :rfc:`3501` (the IMAP protocol). However, imaplib has historically - allowed creation of such tags, and popular IMAP servers, such as Gmail, + allowed creation of such flags, and popular IMAP servers, such as Gmail, accept and produce such flags. There are non-Python programs which also - create such tags. Although it is an RFC violation and IMAP clients and + create such flags. Although it is an RFC violation and IMAP clients and servers are supposed to be strict, imaplib still continues to allow - such tags to be created for backward compatibility reasons, and as of + such flags to be created for backward compatibility reasons, and as of Python 3.6, handles them if they are sent from the server, since this improves real-world compatibility. diff --git a/Lib/imaplib.py b/Lib/imaplib.py index a871e509b0d82c6..085c8d9a5f224ca 100644 --- a/Lib/imaplib.py +++ b/Lib/imaplib.py @@ -1,6 +1,6 @@ """IMAP4 client. -Based on RFC 2060. +Based on RFC 3501. Public class: IMAP4 Public variable: Debug @@ -43,8 +43,8 @@ AllowedVersions = ('IMAP4REV1', 'IMAP4') # Most recent first # Maximal line length when calling readline(). This is to prevent -# reading arbitrary length lines. RFC 3501 and 2060 (IMAP 4rev1) -# don't specify a line length. RFC 2683 suggests limiting client +# reading arbitrary length lines. RFC 3501 (IMAP 4rev1) +# doesn't specify a line length. RFC 2683 suggests limiting client # command lines to 1000 octets and that servers should be prepared # to accept command lines up to 8000 octets, so we used to use 10K here. # In the modern world (eg: gmail) the response to, for example, a diff --git a/Lib/test/test_imaplib.py b/Lib/test/test_imaplib.py index cdc2f9c8d852322..b186dca27aaac01 100644 --- a/Lib/test/test_imaplib.py +++ b/Lib/test/test_imaplib.py @@ -1895,8 +1895,8 @@ def test_connect(self): @threading_helper.reap_threads def test_bracket_flags(self): - # This violates RFC 3501, which disallows ']' characters in tag names, - # but imaplib has allowed producing such tags forever, other programs + # This violates RFC 3501, which disallows ']' characters in flags, + # but imaplib has allowed producing such flags forever, other programs # also produce them (eg: OtherInbox's Organizer app as of 20140716), # and Gmail, for example, accepts them and produces them. So we # support them. See issue #21815. From d0921efb665aff26b378f495e5ff84f7e3fe649d Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Sun, 5 Jul 2026 18:25:36 +0300 Subject: [PATCH 5/8] gh-143921: Narrow the control character check in imaplib commands (GH-153067) Only NUL, CR and LF are rejected now. Other control characters are valid in quoted strings and can occur in mailbox names returned by the server, so they are now accepted and sent quoted. Co-authored-by: Claude Fable 5 --- Lib/imaplib.py | 6 ++++-- Lib/test/test_imaplib.py | 16 +++++++++++++--- ...026-07-05-10-24-30.gh-issue-143921.wQx3Tn.rst | 4 ++++ 3 files changed, 21 insertions(+), 5 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-07-05-10-24-30.gh-issue-143921.wQx3Tn.rst diff --git a/Lib/imaplib.py b/Lib/imaplib.py index 085c8d9a5f224ca..8e271209a664faa 100644 --- a/Lib/imaplib.py +++ b/Lib/imaplib.py @@ -129,7 +129,9 @@ # We compile these in _mode_xxx. _Literal = br'.*{(?P\d+)}$' _Untagged_status = br'\* (?P\d+) (?P[A-Z-]+)( (?P.*))?' -_control_chars = re.compile(b'[\x00-\x1F\x7F]') +# Only NUL, CR and LF are unsafe (they cannot be represented even in +# a quoted string); other control characters are sent quoted. +_control_chars = re.compile(b'[\x00\r\n]') _non_astring_char = re.compile(br'[(){ \x00-\x1f\x7f-\xff%*\\"]') _non_list_char = re.compile(br'[(){ \x00-\x1f\x7f-\xff\\"]') _quoted = re.compile(br'"(?:[^"\\]|\\.)*+"') @@ -1170,7 +1172,7 @@ def _command(self, name, *args): if isinstance(arg, str): arg = bytes(arg, self._encoding) if _control_chars.search(arg): - raise ValueError("Control characters not allowed in commands") + raise ValueError("NUL, CR and LF not allowed in commands") data = data + b' ' + arg literal = self.literal diff --git a/Lib/test/test_imaplib.py b/Lib/test/test_imaplib.py index b186dca27aaac01..ef283267ff08ef0 100644 --- a/Lib/test/test_imaplib.py +++ b/Lib/test/test_imaplib.py @@ -1751,10 +1751,20 @@ def test_uppercase_command_names(self): client.NONEXISTENT def test_control_characters(self): - client, _ = self._setup(SimpleIMAPHandler) - for c0 in support.control_characters_c0(): + client, server = self._setup(SimpleIMAPHandler) + client.login('user', 'pass') + for c in '\0\r\n': with self.assertRaises(ValueError): - client.login(f'user{c0}', 'pass') + client.select(f'a{c}b') + # Other control characters are valid in a quoted string and can + # occur in mailbox names returned by the server, so the client + # must be able to send them back. + for c in support.control_characters_c0(): + if c in '\0\r\n': + continue + typ, _ = client.select(f'a{c}b') + self.assertEqual(typ, 'OK') + self.assertEqual(server.is_selected, [f'"a{c}b"']) # property tests diff --git a/Misc/NEWS.d/next/Library/2026-07-05-10-24-30.gh-issue-143921.wQx3Tn.rst b/Misc/NEWS.d/next/Library/2026-07-05-10-24-30.gh-issue-143921.wQx3Tn.rst new file mode 100644 index 000000000000000..04e8bd6d6f9d6cb --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-07-05-10-24-30.gh-issue-143921.wQx3Tn.rst @@ -0,0 +1,4 @@ +Narrow the control character check in :mod:`imaplib` commands: only NUL, CR +and LF are now rejected. Other control characters are valid in quoted +strings and can occur in mailbox names returned by the server, so they are +now accepted and sent quoted. From cdcf228f6ab764fcd8237231f0a473b9f80586ad Mon Sep 17 00:00:00 2001 From: Andrew James Date: Mon, 6 Jul 2026 01:57:09 +1000 Subject: [PATCH 6/8] gh-60055: Allow passing a Request instance for RobotParser URLs (#103753) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Allow passing a Request instance for the url parameter * 📜🤖 Added by blurb_it. * gh-60055: rebase onto main and document Request support * Fix the lint warnings and CI Errors. * Addressing RobotFileParser review comments. * Update Doc/library/urllib.robotparser.rst Co-authored-by: Serhiy Storchaka * Address additional Review Comments. * Changed versionchanged to next. --------- Co-authored-by: blurb-it[bot] <43283697+blurb-it[bot]@users.noreply.github.com> Co-authored-by: Senthil Kumaran Co-authored-by: Serhiy Storchaka --- Doc/library/urllib.robotparser.rst | 25 +++++++++++++-- Lib/test/test_robotparser.py | 31 +++++++++++++++++++ Lib/urllib/robotparser.py | 7 ++++- ...3-04-24-11-12-00.gh-issue-60055.UjP6aX.rst | 1 + 4 files changed, 61 insertions(+), 3 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2023-04-24-11-12-00.gh-issue-60055.UjP6aX.rst diff --git a/Doc/library/urllib.robotparser.rst b/Doc/library/urllib.robotparser.rst index 1fa7fc13baa539a..3e84f86d4dd383a 100644 --- a/Doc/library/urllib.robotparser.rst +++ b/Doc/library/urllib.robotparser.rst @@ -24,11 +24,18 @@ structure of :file:`robots.txt` files, see :rfc:`9309`. .. class:: RobotFileParser(url='') This class provides methods to read, parse and answer questions about the - :file:`robots.txt` file at *url*. + :file:`robots.txt` file at *url* or a :class:`urllib.request.Request` object. + + .. versionchanged:: next + *url* parameter can be a :class:`urllib.request.Request` object. .. method:: set_url(url) - Sets the URL referring to a :file:`robots.txt` file. + Sets the URL referring to a :file:`robots.txt` file or a + :class:`urllib.request.Request` object. + + .. versionchanged:: next + *url* parameter can be a :class:`urllib.request.Request` object. .. method:: read() @@ -102,3 +109,17 @@ class:: True >>> rp.can_fetch("*", "http://www.pythontest.net/no-robots-here/") False + + +The following example demonstrates use of a :class:`urllib.request.Request` +object with additional user-agent headers populated:: + + >>> import urllib.robotparser + >>> import urllib.request + >>> rp = urllib.robotparser.RobotFileParser() + >>> rp.set_url(urllib.request.Request("http://www.pythontest.net/robots.txt", headers={"User-Agent": "IsraBot"})) + >>> rp.read() + >>> rp.can_fetch("*", "http://www.pythontest.net/") + True + >>> rp.can_fetch("*", "http://www.pythontest.net/no-robots-here/") + False diff --git a/Lib/test/test_robotparser.py b/Lib/test/test_robotparser.py index cd1477037e94b74..725c6e3b09e1d04 100644 --- a/Lib/test/test_robotparser.py +++ b/Lib/test/test_robotparser.py @@ -773,6 +773,37 @@ def testServiceUnavailable(self): self.assertFalse(parser.can_fetch("*", url + '/path/file.html')) +class UserAgentSiteTestCase(BaseLocalNetworkTestCase, unittest.TestCase): + + class RobotHandler(BaseHTTPRequestHandler): + def do_GET(self): + if self.headers.get('User-Agent').startswith('Python-urllib'): + self.send_error(403, "Forbidden access") + else: + self.send_response(200) + self.end_headers() + self.wfile.write(b"User-agent: *\nDisallow:") + + def log_message(self, format, *args): + pass + + def testUserAgentFilteringSite(self): + addr = self.server.server_address + url = f'http://{socket_helper.HOST}:{addr[1]}' + robots_url = url + "/robots.txt" + file_url = url + "/document" + parser = urllib.robotparser.RobotFileParser() + parser.set_url(robots_url) + parser.read() + self.assertTrue(parser.disallow_all) + self.assertFalse(parser.can_fetch("*", file_url)) + parser = urllib.robotparser.RobotFileParser() + parser.set_url(urllib.request.Request(robots_url, headers={'User-Agent': 'cybermapper'})) + parser.read() + self.assertFalse(parser.disallow_all) + self.assertTrue(parser.can_fetch("*", file_url)) + + @support.requires_working_socket() class NetworkTestCase(unittest.TestCase): diff --git a/Lib/urllib/robotparser.py b/Lib/urllib/robotparser.py index 0c3e5d928909358..61772d90e2d53bf 100644 --- a/Lib/urllib/robotparser.py +++ b/Lib/urllib/robotparser.py @@ -55,8 +55,13 @@ def modified(self): self.last_checked = time.time() def set_url(self, url): - """Sets the URL referring to a robots.txt file.""" + """Sets the URL referring to a robots.txt file. + can be a string or a Request object. + """ self.url = url + + if isinstance(url, urllib.request.Request): + url = url.full_url self.host, self.path = urllib.parse.urlsplit(url)[1:3] def read(self): diff --git a/Misc/NEWS.d/next/Library/2023-04-24-11-12-00.gh-issue-60055.UjP6aX.rst b/Misc/NEWS.d/next/Library/2023-04-24-11-12-00.gh-issue-60055.UjP6aX.rst new file mode 100644 index 000000000000000..58fface7caded53 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2023-04-24-11-12-00.gh-issue-60055.UjP6aX.rst @@ -0,0 +1 @@ +Let ``urllib.robotparser.RobotFileParser`` accept a ``urllib.request.Request`` object as well as a url string when setting a robots.txt url. From 89afed25c3427911c9df815ca4d8aeb261ab46ca Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Sun, 5 Jul 2026 20:02:45 +0300 Subject: [PATCH 7/8] gh-77508: Add imaplib.IMAP4.move method (GH-153121) Add a wrapper for the IMAP MOVE command (RFC 6851), analogous to copy(). The arguments of MOVE and UID MOVE are quoted when necessary. Co-authored-by: Claude Opus 4.8 (1M context) --- Doc/library/imaplib.rst | 9 ++++++ Doc/whatsnew/3.16.rst | 8 +++++ Lib/imaplib.py | 10 ++++++- Lib/test/test_imaplib.py | 29 +++++++++++++++++++ ...6-07-01-15-30-00.gh-issue-77508.Bn2kXt.rst | 2 ++ 5 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 Misc/NEWS.d/next/Library/2026-07-01-15-30-00.gh-issue-77508.Bn2kXt.rst diff --git a/Doc/library/imaplib.rst b/Doc/library/imaplib.rst index 3f46c5146acc6ee..6b7c02f54e90af2 100644 --- a/Doc/library/imaplib.rst +++ b/Doc/library/imaplib.rst @@ -452,6 +452,15 @@ An :class:`IMAP4` instance has the following methods: Returned data are tuples of message part envelope and data. +.. method:: IMAP4.move(message_set, new_mailbox) + + Move *message_set* messages onto end of *new_mailbox*. + + The server must support the ``MOVE`` capability (:rfc:`6851`). + + .. versionadded:: next + + .. method:: IMAP4.myrights(mailbox) Show my ACLs for a mailbox (i.e. the rights that I have on mailbox). diff --git a/Doc/whatsnew/3.16.rst b/Doc/whatsnew/3.16.rst index 8219700cc1e8385..cf105a26d98d45d 100644 --- a/Doc/whatsnew/3.16.rst +++ b/Doc/whatsnew/3.16.rst @@ -228,6 +228,14 @@ io (Contributed by Marcel Martin in :gh:`90533`.) +imaplib +------- + +* Add the :meth:`~imaplib.IMAP4.move` method, + a wrapper for the ``MOVE`` command (:rfc:`6851`). + (Contributed by Serhiy Storchaka in :gh:`77508`.) + + logging ------- diff --git a/Lib/imaplib.py b/Lib/imaplib.py index 8e271209a664faa..adfd8afb9c053bb 100644 --- a/Lib/imaplib.py +++ b/Lib/imaplib.py @@ -789,6 +789,14 @@ def lsub(self, directory='', pattern='*'): self._list_mailbox(pattern)) return self._untagged_response(typ, dat, name) + def move(self, message_set, new_mailbox): + """Move 'message_set' messages onto end of 'new_mailbox'. + + (typ, [data]) = .move(message_set, new_mailbox) + """ + return self._simple_command('MOVE', self._sequence_set(message_set), + self._astring(new_mailbox)) + def myrights(self, mailbox): """Show my ACLs for a mailbox (i.e. the rights that I have on mailbox). @@ -1031,7 +1039,7 @@ def uid(self, command, *args): (command, self.state, ', '.join(Commands[command]))) name = 'UID' - if command == 'COPY': + if command in ('COPY', 'MOVE'): message_set, new_mailbox = args args = (self._sequence_set(message_set), self._astring(new_mailbox)) diff --git a/Lib/test/test_imaplib.py b/Lib/test/test_imaplib.py index ef283267ff08ef0..046d28f4d30c8a4 100644 --- a/Lib/test/test_imaplib.py +++ b/Lib/test/test_imaplib.py @@ -1173,6 +1173,35 @@ def test_uid_copy(self): self.assertEqual(data, [None]) self.assertEqual(server.args, ['COPY', '4827313:4828442', '"New folder"']) + def test_move(self): + client, server = self._setup(make_simple_handler('MOVE')) + client.login('user', 'pass') + client.select() + typ, data = client.move('2:4', 'MEETING') + self.assertEqual(typ, 'OK') + self.assertEqual(data, [b'MOVE completed']) + self.assertEqual(server.args, ['2:4', 'MEETING']) + + typ, data = client.move('2:4', 'New folder') + self.assertEqual(typ, 'OK') + self.assertEqual(data, [b'MOVE completed']) + self.assertEqual(server.args, ['2:4', '"New folder"']) + + def test_uid_move(self): + client, server = self._setup(make_simple_handler('UID', + completed='UID MOVE completed')) + client.login('user', 'pass') + client.select() + typ, data = client.uid('move', '4827313:4828442', 'MEETING') + self.assertEqual(typ, 'OK') + self.assertEqual(data, [None]) + self.assertEqual(server.args, ['MOVE', '4827313:4828442', 'MEETING']) + + typ, data = client.uid('move', '4827313:4828442', 'New folder') + self.assertEqual(typ, 'OK') + self.assertEqual(data, [None]) + self.assertEqual(server.args, ['MOVE', '4827313:4828442', '"New folder"']) + def test_store(self): client, server = self._setup(make_simple_handler('STORE', [ r'* 2 FETCH (FLAGS (\Deleted \Seen))', diff --git a/Misc/NEWS.d/next/Library/2026-07-01-15-30-00.gh-issue-77508.Bn2kXt.rst b/Misc/NEWS.d/next/Library/2026-07-01-15-30-00.gh-issue-77508.Bn2kXt.rst new file mode 100644 index 000000000000000..d6f1bd23be080a3 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-07-01-15-30-00.gh-issue-77508.Bn2kXt.rst @@ -0,0 +1,2 @@ +Add :meth:`imaplib.IMAP4.move`, a wrapper for the IMAP ``MOVE`` command +(:rfc:`6851`), analogous to :meth:`~imaplib.IMAP4.copy`. From a47a66ccef0f98fcf088bc260ca14b889efa9b04 Mon Sep 17 00:00:00 2001 From: Chandra Date: Sun, 5 Jul 2026 12:08:28 -0500 Subject: [PATCH 8/8] gh-105708: 'V' could be case insensitive for IPvFuture hostnames (#105709) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * gh-105708: 'V' could be case insensitive for IPvFuture hostnames * gh-105708: 'V' could be case insensitive for IPvFuture hostnames & checking empty hostnames * 📜🤖 Added by blurb_it. * Fix the Merge changing \z to \Z. * Fixed the News Entry. * Fix the lint. --------- Co-authored-by: blurb-it[bot] <43283697+blurb-it[bot]@users.noreply.github.com> Co-authored-by: Senthil Kumaran --- Lib/test/test_urlparse.py | 15 ++++++++++----- Lib/urllib/parse.py | 4 ++-- ...2023-06-12-21-31-02.gh-issue-105708.5fFwP8.rst | 1 + 3 files changed, 13 insertions(+), 7 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2023-06-12-21-31-02.gh-issue-105708.5fFwP8.rst diff --git a/Lib/test/test_urlparse.py b/Lib/test/test_urlparse.py index 8e4da0e0f099191..a5b7966c7780e9e 100644 --- a/Lib/test/test_urlparse.py +++ b/Lib/test/test_urlparse.py @@ -1653,15 +1653,20 @@ def test_splitting_bracketed_hosts(self): self.assertEqual(p1.username, 'user') self.assertEqual(p1.path, '/path') self.assertEqual(p1.port, 1234) - p2 = urllib.parse.urlsplit('scheme://user@[0439:23af:2309::fae7%test]/path?query') - self.assertEqual(p2.hostname, '0439:23af:2309::fae7%test') + p2 = urllib.parse.urlsplit('scheme://user@[V6a.ip]:1234/path?query') + self.assertEqual(p2.hostname, 'v6a.ip') self.assertEqual(p2.username, 'user') self.assertEqual(p2.path, '/path') - self.assertIs(p2.port, None) - p3 = urllib.parse.urlsplit('scheme://user@[0439:23af:2309::fae7:1234:192.0.2.146%test]/path?query') - self.assertEqual(p3.hostname, '0439:23af:2309::fae7:1234:192.0.2.146%test') + self.assertEqual(p2.port, 1234) + p3 = urllib.parse.urlsplit('scheme://user@[0439:23af:2309::fae7%test]/path?query') + self.assertEqual(p3.hostname, '0439:23af:2309::fae7%test') self.assertEqual(p3.username, 'user') self.assertEqual(p3.path, '/path') + self.assertIs(p3.port, None) + p4 = urllib.parse.urlsplit('scheme://user@[0439:23af:2309::fae7:1234:192.0.2.146%test]/path?query') + self.assertEqual(p4.hostname, '0439:23af:2309::fae7:1234:192.0.2.146%test') + self.assertEqual(p4.username, 'user') + self.assertEqual(p4.path, '/path') def test_port_casting_failure_message(self): message = "Port could not be cast to integer value as 'oracle'" diff --git a/Lib/urllib/parse.py b/Lib/urllib/parse.py index 82b95adbdc283ef..4247b9a4b07fa3f 100644 --- a/Lib/urllib/parse.py +++ b/Lib/urllib/parse.py @@ -544,8 +544,8 @@ def _check_bracketed_netloc(netloc): # Valid bracketed hosts are defined in # https://www.rfc-editor.org/rfc/rfc3986#page-49 and https://url.spec.whatwg.org/ def _check_bracketed_host(hostname): - if hostname.startswith('v'): - if not re.match(r"\Av[a-fA-F0-9]+\..+\z", hostname): + if hostname.startswith(('v', 'V')): + if not re.match(r"\A[vV][a-fA-F0-9]+\..+\z", hostname): raise ValueError(f"IPvFuture address is invalid") else: ip = ipaddress.ip_address(hostname) # Throws Value Error if not IPv6 or IPv4 diff --git a/Misc/NEWS.d/next/Library/2023-06-12-21-31-02.gh-issue-105708.5fFwP8.rst b/Misc/NEWS.d/next/Library/2023-06-12-21-31-02.gh-issue-105708.5fFwP8.rst new file mode 100644 index 000000000000000..79289c1f0bd4217 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2023-06-12-21-31-02.gh-issue-105708.5fFwP8.rst @@ -0,0 +1 @@ +Accept an uppercase V prefix in IPvFuture addresses in :func:`urllib.parse.urlsplit`.