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/library/imaplib.rst b/Doc/library/imaplib.rst index dbf4560ab5e53b3..6b7c02f54e90af2 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 @@ -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) @@ -450,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). @@ -630,7 +641,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:: @@ -644,11 +655,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/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/Doc/whatsnew/3.16.rst b/Doc/whatsnew/3.16.rst index 1a73a79a58b78b1..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 ------- @@ -383,6 +391,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/imaplib.py b/Lib/imaplib.py index a871e509b0d82c6..adfd8afb9c053bb 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 @@ -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'"(?:[^"\\]|\\.)*+"') @@ -787,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). @@ -1029,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)) @@ -1170,7 +1180,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 cdc2f9c8d852322..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))', @@ -1751,10 +1780,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 @@ -1895,8 +1934,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. 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/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/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/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/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/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/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/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. 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`. 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. 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. 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`. 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.