diff --git a/Doc/library/os.rst b/Doc/library/os.rst index 9327d616ffa05da..7b043f257ca0b57 100644 --- a/Doc/library/os.rst +++ b/Doc/library/os.rst @@ -541,7 +541,7 @@ process and user. Return a tuple (ruid, euid, suid) denoting the current process's real, effective, and saved user ids. - .. availability:: Unix, not WASI. + .. availability:: Unix, not WASI, not macOS, not iOS. .. versionadded:: 3.2 @@ -551,7 +551,7 @@ process and user. Return a tuple (rgid, egid, sgid) denoting the current process's real, effective, and saved group ids. - .. availability:: Unix, not WASI. + .. availability:: Unix, not WASI, not macOS, not iOS. .. versionadded:: 3.2 @@ -725,7 +725,7 @@ process and user. Set the current process's real, effective, and saved group ids. - .. availability:: Unix, not WASI, not Android. + .. availability:: Unix, not WASI, not Android, not macOS, not iOS. .. versionadded:: 3.2 @@ -734,7 +734,7 @@ process and user. Set the current process's real, effective, and saved user ids. - .. availability:: Unix, not WASI, not Android. + .. availability:: Unix, not WASI, not Android, not macOS, not iOS. .. versionadded:: 3.2 @@ -800,29 +800,19 @@ process and user. single: gethostbyaddr() (in module socket) Returns information identifying the current operating system. - The return value is an object with five attributes: - - * :attr:`sysname` - operating system name - * :attr:`nodename` - name of machine on network (implementation-defined) - * :attr:`release` - operating system release - * :attr:`version` - operating system version - * :attr:`machine` - hardware identifier - - For backwards compatibility, this object is also iterable, behaving - like a five-tuple containing :attr:`sysname`, :attr:`nodename`, - :attr:`release`, :attr:`version`, and :attr:`machine` - in that order. - - Some systems truncate :attr:`nodename` to 8 characters or to the - leading component; a better way to get the hostname is - :func:`socket.gethostname` or even - ``socket.gethostbyaddr(socket.gethostname())``. + The return value is a :class:`uname_result`. On macOS, iOS and Android, this returns the *kernel* name and version (i.e., ``'Darwin'`` on macOS and iOS; ``'Linux'`` on Android). :func:`platform.uname` can be used to get the user-facing operating system name and version on iOS and Android. + .. seealso:: + :data:`sys.platform` which has finer granularity. + + The :mod:`platform` module provides detailed checks for the + system's identity. + .. availability:: Unix. .. versionchanged:: 3.3 @@ -830,6 +820,41 @@ process and user. with named attributes. +.. class:: uname_result + + Name and information about the system returned by :func:`os.uname`. + These attributes correspond to the members described in :manpage:`uname(2)`. + + For backwards compatibility, this object is also iterable, behaving + like a five-tuple containing :attr:`~uname_result.sysname`, + :attr:`~uname_result.nodename`, :attr:`~uname_result.release`, + :attr:`~uname_result.version`, and :attr:`~uname_result.machine` + in that order. + + .. attribute:: sysname + + Operating system name. + + .. attribute:: nodename + + Name of machine on network. Some systems truncate + :attr:`~uname_result.nodename` to 8 characters or to the leading + component; a better way to get the hostname is :func:`socket.gethostname` + or even ``socket.gethostbyaddr(socket.gethostname())``. + + .. attribute:: release + + Operating system release. + + .. attribute:: version + + Operating system version. + + .. attribute:: machine + + Hardware identifier. + + .. function:: unsetenv(key, /) .. index:: single: environment variables; deleting @@ -1071,10 +1096,7 @@ as internal buffering of data. Force write of file with filedescriptor *fd* to disk. Does not force update of metadata. - .. availability:: Unix. - - .. note:: - This function is not available on MacOS. + .. availability:: Unix, not macOS, not iOS. .. function:: fpathconf(fd, name, /) @@ -1112,8 +1134,8 @@ as internal buffering of data. .. function:: fstatvfs(fd, /) Return information about the filesystem containing the file associated with - file descriptor *fd*, like :func:`statvfs`. As of Python 3.3, this is - equivalent to ``os.statvfs(fd)``. + file descriptor *fd* in a :class:`statvfs_result`, like :func:`statvfs`. + As of Python 3.3, this is equivalent to ``os.statvfs(fd)``. .. availability:: Unix. @@ -1426,7 +1448,7 @@ or `the MSDN `_ on Windo Return a pair of file descriptors ``(r, w)`` usable for reading and writing, respectively. - .. availability:: Unix, not WASI. + .. availability:: Unix, not WASI, not macOS, not iOS. .. versionadded:: 3.3 @@ -1436,7 +1458,7 @@ or `the MSDN `_ on Windo Ensures that enough disk space is allocated for the file specified by *fd* starting from *offset* and continuing for *len* bytes. - .. availability:: Unix. + .. availability:: Unix, not macOS, not iOS. .. versionadded:: 3.3 @@ -1451,7 +1473,7 @@ or `the MSDN `_ on Windo :data:`POSIX_FADV_RANDOM`, :data:`POSIX_FADV_NOREUSE`, :data:`POSIX_FADV_WILLNEED` or :data:`POSIX_FADV_DONTNEED`. - .. availability:: Unix. + .. availability:: Unix, not macOS, not iOS. .. versionadded:: 3.3 @@ -3784,48 +3806,174 @@ features: .. function:: statvfs(path) - Perform a :c:func:`!statvfs` system call on the given path. The return value is - an object whose attributes describe the filesystem on the given path, and - correspond to the members of the :c:struct:`statvfs` structure, namely: - :attr:`f_bsize`, :attr:`f_frsize`, :attr:`f_blocks`, :attr:`f_bfree`, - :attr:`f_bavail`, :attr:`f_files`, :attr:`f_ffree`, :attr:`f_favail`, - :attr:`f_flag`, :attr:`f_namemax`, :attr:`f_fsid`. - - Two module-level constants are defined for the :attr:`f_flag` attribute's - bit-flags: if :const:`ST_RDONLY` is set, the filesystem is mounted - read-only, and if :const:`ST_NOSUID` is set, the semantics of - setuid/setgid bits are disabled or not supported. - - Additional module-level constants are defined for GNU/glibc based systems. - These are :const:`ST_NODEV` (disallow access to device special files), - :const:`ST_NOEXEC` (disallow program execution), :const:`ST_SYNCHRONOUS` - (writes are synced at once), :const:`ST_MANDLOCK` (allow mandatory locks on an FS), - :const:`ST_WRITE` (write on file/directory/symlink), :const:`ST_APPEND` - (append-only file), :const:`ST_IMMUTABLE` (immutable file), :const:`ST_NOATIME` - (do not update access times), :const:`ST_NODIRATIME` (do not update directory access - times), :const:`ST_RELATIME` (update atime relative to mtime/ctime). + Perform a :manpage:`statvfs(3)` system call on the given path. The return value + is a :class:`statvfs_result` whose attributes describe the filesystem + on the given path and correspond to the members of the :c:struct:`statvfs` + structure. This function can support :ref:`specifying a file descriptor `. .. availability:: Unix. - .. versionchanged:: 3.2 - The :const:`ST_RDONLY` and :const:`ST_NOSUID` constants were added. - .. versionchanged:: 3.3 Added support for specifying *path* as an open file descriptor. - .. versionchanged:: 3.4 - The :const:`ST_NODEV`, :const:`ST_NOEXEC`, :const:`ST_SYNCHRONOUS`, - :const:`ST_MANDLOCK`, :const:`ST_WRITE`, :const:`ST_APPEND`, - :const:`ST_IMMUTABLE`, :const:`ST_NOATIME`, :const:`ST_NODIRATIME`, - and :const:`ST_RELATIME` constants were added. - .. versionchanged:: 3.6 Accepts a :term:`path-like object`. - .. versionchanged:: 3.7 - Added the :attr:`f_fsid` attribute. + +.. class:: statvfs_result + + Filesystem statistics returned by :func:`os.statvfs` and :func:`os.fstatvfs`. + See :manpage:`statvfs(3)` for more details. + + .. attribute:: f_bsize + + Block size. + + .. attribute:: f_frsize + + Fragment size. + + .. attribute:: f_blocks + + Number of :attr:`~statvfs_result.f_frsize` sized blocks the filesystem + can contain. + + .. attribute:: f_bfree + + Number of free blocks. + + .. attribute:: f_bavail + + Number of free blocks for unprivileged users. + + .. attribute:: f_files + + Number of file entries, inodes, the filesystem can contain. + + .. attribute:: f_ffree + + Number of free files entries. + + .. attribute:: f_favail + + Number of free file entries for unprivileged users. + + .. attribute:: f_flag + + Bit-mask of mount flags. The following flags are defined: + :data:`ST_RDONLY`, :data:`ST_NOSUID`, :data:`ST_NODEV`, + :data:`ST_NOEXEC`, :data:`ST_SYNCHRONOUS`, :data:`ST_MANDLOCK`, + :data:`ST_WRITE`, :data:`ST_APPEND`, :data:`ST_IMMUTABLE`, + :data:`ST_NOATIME`, :data:`ST_NODIRATIME`, and :data:`ST_RELATIME`. + + .. attribute:: f_namemax + + Filesystem max filename length. OS specific limitations such as + :ref:`Windows MAX_PATH ` and those described in Linux + :manpage:`pathname(7)` may exist. + + .. attribute:: f_fsid + + Filesystem ID. + + .. versionadded:: 3.7 + + +The following flags are used in :attr:`statvfs_result.f_flag`. + +.. data:: ST_RDONLY + + Read-only filesystem. + + .. versionadded:: 3.2 + +.. data:: ST_NOSUID + + Setuid/setgid bits are disabled or not supported. + + .. versionadded:: 3.2 + +.. data:: ST_NODEV + + Disallow access to device special files. + + .. availability:: Linux. + + .. versionadded:: 3.4 + +.. data:: ST_NOEXEC + + Disallow program execution. + + .. availability:: Linux. + + .. versionadded:: 3.4 + +.. data:: ST_SYNCHRONOUS + + Writes are synced at once. + + .. availability:: Linux. + + .. versionadded:: 3.4 + +.. data:: ST_MANDLOCK + + Allow mandatory locks on an FS. + + .. availability:: Linux. + + .. versionadded:: 3.4 + +.. data:: ST_WRITE + + Write on file/directory/symlink. + + .. availability:: Linux. + + .. versionadded:: 3.4 + +.. data:: ST_APPEND + + Append-only file. + + .. availability:: Linux. + + .. versionadded:: 3.4 + +.. data:: ST_IMMUTABLE + + Immutable file. + + .. availability:: Linux. + + .. versionadded:: 3.4 + +.. data:: ST_NOATIME + + Do not update access times. + + .. availability:: Linux. + + .. versionadded:: 3.4 + +.. data:: ST_NODIRATIME + + Do not update directory access times. + + .. availability:: Linux. + + .. versionadded:: 3.4 + +.. data:: ST_RELATIME + + Update atime relative to mtime/ctime. + + .. availability:: Linux. + + .. versionadded:: 3.4 .. data:: supports_dir_fd @@ -5107,7 +5255,7 @@ written in Python, such as a mail server's external command delivery program. Lock program segments into memory. The value of *op* (defined in ````) determines which segments are locked. - .. availability:: Unix, not WASI, not iOS. + .. availability:: Unix, not WASI, not macOS, not iOS. .. function:: popen(cmd, mode='r', buffering=-1) diff --git a/Doc/tools/.nitignore b/Doc/tools/.nitignore index 51ce9b66fadc7ff..2255c745c003838 100644 --- a/Doc/tools/.nitignore +++ b/Doc/tools/.nitignore @@ -18,7 +18,6 @@ Doc/library/lzma.rst Doc/library/mmap.rst Doc/library/multiprocessing.rst Doc/library/optparse.rst -Doc/library/os.rst Doc/library/pickletools.rst Doc/library/pyexpat.rst Doc/library/select.rst diff --git a/Lib/test/test_capi/test_abstract.py b/Lib/test/test_capi/test_abstract.py index 3a2ed9f5db82f0f..e455a8636209247 100644 --- a/Lib/test/test_capi/test_abstract.py +++ b/Lib/test/test_capi/test_abstract.py @@ -411,6 +411,11 @@ def test_mapping_getoptionalitem(self): self.assertEqual(getitem(dct2, 'a'), 1) self.assertEqual(getitem(dct2, 'b'), KeyError) + frozendct = frozendict(dct) + self.assertEqual(getitem(frozendct, 'a'), 1) + self.assertEqual(getitem(frozendct, 'b'), KeyError) + self.assertEqual(getitem(frozendct, '\U0001f40d'), 2) + self.assertEqual(getitem(['a', 'b', 'c'], 1), 'b') self.assertRaises(TypeError, getitem, 42, 'a') @@ -431,6 +436,11 @@ def test_mapping_getoptionalitemstring(self): self.assertEqual(getitemstring(dct2, b'a'), 1) self.assertEqual(getitemstring(dct2, b'b'), KeyError) + frozendct = frozendict(dct) + self.assertEqual(getitemstring(frozendct, 'a'), 1) + self.assertEqual(getitemstring(frozendct, 'b'), KeyError) + self.assertEqual(getitemstring(frozendct, '\U0001f40d'.encode()), 2) + self.assertRaises(TypeError, getitemstring, 42, b'a') self.assertRaises(UnicodeDecodeError, getitemstring, {}, b'\xff') self.assertRaises(SystemError, getitemstring, {}, NULL) @@ -677,8 +687,10 @@ def items(self): dict_obj = {'foo': 1, 'bar': 2, 'spam': 3} for mapping in [{}, OrderedDict(), Mapping1(), Mapping2(), + frozendict(), dict_obj, OrderedDict(dict_obj), - Mapping1(dict_obj), Mapping2(dict_obj)]: + Mapping1(dict_obj), Mapping2(dict_obj), + frozendict(dict_obj)]: self.assertListEqual(_testlimitedcapi.mapping_keys(mapping), list(mapping.keys())) self.assertListEqual(_testlimitedcapi.mapping_values(mapping), diff --git a/Lib/test/test_curses.py b/Lib/test/test_curses.py index d5ca7f2ca1ae658..647959146a792c1 100644 --- a/Lib/test/test_curses.py +++ b/Lib/test/test_curses.py @@ -334,6 +334,63 @@ def test_output_string_embedded_null_chars(self): self.assertRaises(ValueError, stdscr.insstr, arg) self.assertRaises(ValueError, stdscr.insnstr, arg, 1) + def test_add_string_behavior(self): + # addstr() advances the cursor past the written text; addnstr() + # writes at most n characters. + win = curses.newwin(1, 10, 0, 0) + win.addstr(0, 0, 'abc') + self.assertEqual(win.getyx(), (0, 3)) + win.erase() + win.addnstr(0, 0, 'abcdef', 3) + self.assertEqual(win.instr(0, 0), b'abc ') + + def test_insert_string_behavior(self): + # insstr()/insnstr() insert at the cursor, shift the rest of the + # line right (losing characters off the edge), and leave the cursor + # where it was. + win = curses.newwin(1, 10, 0, 0) + win.addstr(0, 0, 'abcde') + win.move(0, 1) + win.insstr('XY') + self.assertEqual(win.getyx(), (0, 1)) # cursor did not advance + self.assertEqual(win.instr(0, 0), b'aXYbcde ') + + win.erase() + win.addstr(0, 0, 'ZZZZZ') + win.move(0, 0) + win.insnstr('abcdef', 3) # at most 3 characters + self.assertEqual(win.instr(0, 0), b'abcZZZZZ ') + + def test_insch(self): + # insch() inserts a single character at the cursor (or at y, x), + # shifting the rest of the line right. + win = curses.newwin(2, 10, 0, 0) + win.addstr(0, 0, 'abc') + win.move(0, 1) + win.insch(ord('X')) + self.assertEqual(win.instr(0, 0), b'aXbc ') + win.insch(1, 0, 'Y', curses.A_BOLD) + self.assertEqual(win.inch(1, 0), b'Y'[0] | curses.A_BOLD) + + def test_pad(self): + pad = curses.newpad(10, 20) + pad.addstr(0, 0, 'PADTEXT') + self.assertEqual(pad.instr(0, 0, 7), b'PADTEXT') + + # subpad() shares the parent pad's character cells. + sub = pad.subpad(3, 5, 0, 0) + self.assertEqual(sub.getmaxyx(), (3, 5)) + self.assertEqual(sub.instr(0, 0, 5), b'PADTE') + + # A pad is refreshed onto an explicit screen rectangle; the + # 6-argument form is required (and rejected for ordinary windows). + pad.refresh(0, 0, 0, 0, 4, 10) + pad.noutrefresh(0, 0, 0, 0, 4, 10) + curses.doupdate() + self.assertRaises(TypeError, pad.refresh) + win = curses.newwin(5, 5, 0, 0) + self.assertRaises(TypeError, win.refresh, 0, 0, 0, 0, 4, 4) + def test_read_from_window(self): stdscr = self.stdscr stdscr.addstr(0, 1, 'ABCD', curses.A_BOLD) @@ -350,6 +407,26 @@ def test_read_from_window(self): self.assertRaises(ValueError, stdscr.instr, -2) self.assertRaises(ValueError, stdscr.instr, 0, 2, -2) + def test_coordinate_errors(self): + # Addressing a cell outside the window raises curses.error. + win = curses.newwin(5, 10, 0, 0) + self.assertRaises(curses.error, win.move, 100, 100) + self.assertRaises(curses.error, win.move, -1, -1) + self.assertRaises(curses.error, win.addch, 100, 100, ord('x')) + self.assertRaises(curses.error, win.inch, 100, 100) + self.assertRaises(curses.error, win.chgat, 100, 0, curses.A_BOLD) + + def test_argument_errors(self): + win = curses.newwin(5, 10, 0, 0) + # A character argument must be an int, a byte or a one-element string. + self.assertRaises(TypeError, win.addch, []) + self.assertRaises(OverflowError, win.addch, 2**64) + # A string method rejects a non-string, non-bytes argument. + self.assertRaises(TypeError, win.addstr, 5) + self.assertRaises(TypeError, win.addstr) + # Wrong number of positional arguments. + self.assertRaises(TypeError, win.instr, 0, 0, 0, 0) + def test_getch(self): win = curses.newwin(5, 12, 5, 2) @@ -819,6 +896,10 @@ def test_prog_mode(self): self.skipTest('requires terminal') curses.def_prog_mode() curses.reset_prog_mode() + # def_shell_mode()/reset_shell_mode() are intentionally not exercised + # here: they capture and restore curses' "shell mode" terminal state, + # which is only meaningful before initscr(). Calling them mid-suite + # corrupts the modes that endwin() restores and breaks later tests. def test_beep(self): if (curses.tigetstr("bel") is not None @@ -1031,7 +1112,8 @@ def test_keyname(self): @requires_curses_func('has_key') def test_has_key(self): - curses.has_key(13) + self.assertIsInstance(curses.has_key(13), bool) + self.assertIsInstance(curses.has_key(curses.KEY_LEFT), bool) @requires_curses_func('getmouse') def test_getmouse(self): @@ -1083,6 +1165,200 @@ def test_disallow_instantiation(self): panel = curses.panel.new_panel(w) check_disallow_instantiation(self, type(panel)) + @requires_curses_func('panel') + def test_panel_stack(self): + panel = curses.panel + # new_panel() puts the panel on top of the stack, so the three + # panels end up ordered bottom -> top as p1, p2, p3. + p1 = panel.new_panel(curses.newwin(3, 6, 0, 0)) + p2 = panel.new_panel(curses.newwin(3, 6, 1, 1)) + p3 = panel.new_panel(curses.newwin(3, 6, 2, 2)) + self.addCleanup(self._delete_panels, p1, p2, p3) + + # The most recently created panel is on top. + self.assertIs(panel.top_panel(), p3) + # window() returns the wrapped window. + self.assertEqual(p2.window().getbegyx(), (1, 1)) + + # above()/below() walk the stack one step at a time. + self.assertIs(p1.above(), p2) + self.assertIs(p2.above(), p3) + self.assertIsNone(p3.above()) # nothing above the top panel + self.assertIs(p3.below(), p2) + self.assertIs(p2.below(), p1) + + # top() raises a panel to the top, bottom() lowers it to the bottom. + p1.top() + self.assertIs(panel.top_panel(), p1) + self.assertIsNone(p1.above()) + p1.bottom() + self.assertIs(panel.bottom_panel(), p1) + self.assertIsNone(p1.below()) + + # update_panels() refreshes the virtual screen from the stack. + panel.update_panels() + + @requires_curses_func('panel') + def test_panel_hide_show(self): + p = curses.panel.new_panel(curses.newwin(3, 6, 0, 0)) + self.addCleanup(self._delete_panels, p) + self.assertIs(p.hidden(), False) + p.hide() + self.assertIs(p.hidden(), True) + p.show() + self.assertIs(p.hidden(), False) + + @requires_curses_func('panel') + def test_panel_move(self): + win = curses.newwin(3, 6, 1, 2) + p = curses.panel.new_panel(win) + self.addCleanup(self._delete_panels, p) + self.assertEqual(win.getbegyx(), (1, 2)) + p.move(4, 5) + self.assertEqual(win.getbegyx(), (4, 5)) + + @requires_curses_func('panel') + def test_panel_replace(self): + win1 = curses.newwin(3, 6, 0, 0) + win2 = curses.newwin(4, 8, 1, 1) + p = curses.panel.new_panel(win1) + self.addCleanup(self._delete_panels, p) + self.assertIs(p.window(), win1) + p.replace(win2) + self.assertIs(p.window(), win2) + + @requires_curses_func('panel') + def test_panel_userptr(self): + p = curses.panel.new_panel(curses.newwin(3, 6, 0, 0)) + self.addCleanup(self._delete_panels, p) + obj = ['userptr'] + p.set_userptr(obj) + self.assertIs(p.userptr(), obj) + + def _delete_panels(self, *panels): + # Drop the panels from the global stack so they do not leak into + # later tests that inspect top_panel()/bottom_panel(). + for p in panels: + try: + p.bottom() + except curses.panel.error: + pass + del panels + gc_collect() + + def _make_textbox(self, nlines, ncols, *, insert_mode=False, stripspaces=1): + win = curses.newwin(nlines, ncols, 0, 0) + box = curses.textpad.Textbox(win, insert_mode=insert_mode) + box.stripspaces = stripspaces + return box, win + + def _type(self, box, text): + for ch in text: + box.do_command(ch if isinstance(ch, int) else ord(ch)) + + def test_textbox_gather(self): + # Typed text is read back by gather(). With stripspaces on (the + # default) gather() keeps a single trailing blank on a line and + # drops trailing empty lines. + box, win = self._make_textbox(3, 10) + self._type(box, 'Hello') + self.assertEqual(box.gather(), 'Hello \n') + + def test_textbox_gather_multiline(self): + box, win = self._make_textbox(3, 10) + self._type(box, 'ab') + box.do_command(curses.ascii.NL) # ^j -> start of next line + self._type(box, 'cd') + self.assertEqual(box.gather(), 'ab \ncd \n') + + def test_textbox_stripspaces(self): + box, win = self._make_textbox(1, 8, stripspaces=1) + self._type(box, 'hi') + self.assertEqual(box.gather(), 'hi ') + + box, win = self._make_textbox(1, 8, stripspaces=0) + self._type(box, 'hi') + self.assertEqual(box.gather(), 'hi ') + + def test_textbox_insert_mode(self): + # In insert mode a typed character shifts the rest of the line right. + box, win = self._make_textbox(1, 10, insert_mode=True) + self._type(box, 'aXc') + win.move(0, 1) + self._type(box, 'b') + self.assertEqual(box.gather(), 'abXc ') + + def test_textbox_movement(self): + box, win = self._make_textbox(3, 10) + self._type(box, 'abc') + box.do_command(curses.ascii.SOH) # ^a -> left edge + self.assertEqual(win.getyx(), (0, 0)) + box.do_command(curses.ascii.ENQ) # ^e -> end of line + self.assertEqual(win.getyx(), (0, 3)) + + def test_textbox_kill_to_eol(self): + box, win = self._make_textbox(1, 10) + self._type(box, 'abcdef') + win.move(0, 3) + box.do_command(curses.ascii.VT) # ^k -> clear to end of line + self.assertEqual(box.gather(), 'abc ') + + def test_textbox_backspace(self): + box, win = self._make_textbox(1, 10) + self._type(box, 'abc') + box.do_command(curses.ascii.BS) # ^h -> delete backward + self.assertEqual(box.gather(), 'ab ') + + def test_textbox_edit(self): + # edit() reads characters until Ctrl-G and returns the contents. + box, win = self._make_textbox(1, 10) + for ch in reversed('Hi' + chr(curses.ascii.BEL)): + curses.ungetch(ch) + self.assertEqual(box.edit(), 'Hi ') + + def test_textbox_edit_validate(self): + # The validate hook can rewrite an incoming keystroke. + box, win = self._make_textbox(1, 10) + for ch in reversed('abc' + chr(curses.ascii.BEL)): + curses.ungetch(ch) + box.edit(lambda ch: ord('X') if ch == ord('b') else ch) + self.assertEqual(box.gather(), 'aXc ') + + def test_textpad_rectangle(self): + # rectangle() draws a box with ACS line/corner characters. + win = curses.newwin(6, 12, 0, 0) + curses.textpad.rectangle(win, 0, 0, 4, 8) + chartext = curses.A_CHARTEXT + self.assertEqual(win.inch(0, 0) & chartext, + curses.ACS_ULCORNER & chartext) + self.assertEqual(win.inch(0, 8) & chartext, + curses.ACS_URCORNER & chartext) + self.assertEqual(win.inch(4, 0) & chartext, + curses.ACS_LLCORNER & chartext) + self.assertEqual(win.inch(4, 8) & chartext, + curses.ACS_LRCORNER & chartext) + self.assertEqual(win.inch(0, 1) & chartext, + curses.ACS_HLINE & chartext) + self.assertEqual(win.inch(1, 0) & chartext, + curses.ACS_VLINE & chartext) + + def test_wrapper(self): + # wrapper() sets up curses, passes the screen to the callable along + # with extra arguments, returns its result and restores the terminal. + if not self.isatty: + self.skipTest('requires terminal') + + def body(stdscr, a, b): + self.assertIsInstance(stdscr, type(self.stdscr)) + self.assertIs(curses.isendwin(), False) + return a + b + + self.assertEqual(curses.wrapper(body, 2, 3), 5) + self.assertIs(curses.isendwin(), True) + # wrapper() left the screen ended; revive it so the per-test + # endwin() cleanup does not fail with ERR. + curses.doupdate() + @requires_curses_func('is_term_resized') def test_is_term_resized(self): lines, cols = curses.LINES, curses.COLS diff --git a/Lib/test/test_tkinter/test_text.py b/Lib/test/test_tkinter/test_text.py index 453a4505a0a4da8..0303c2ac1ed1dab 100644 --- a/Lib/test/test_tkinter/test_text.py +++ b/Lib/test/test_tkinter/test_text.py @@ -1,8 +1,9 @@ import unittest import tkinter +from tkinter import TclError from test.support import requires from test.test_tkinter.support import setUpModule # noqa: F401 -from test.test_tkinter.support import AbstractTkTest +from test.test_tkinter.support import AbstractTkTest, requires_tk requires('gui') @@ -25,21 +26,557 @@ def test_debug(self): text.debug(olddebug) self.assertEqual(text.debug(), olddebug) + def test_index(self): + text = self.text + text.insert('1.0', 'Lorem ipsum\ndolor sit amet') + self.assertEqual(text.index('1.0'), '1.0') + self.assertEqual(text.index('1.end'), '1.11') + self.assertEqual(text.index('end'), '3.0') + self.assertEqual(text.index('2.5'), '2.5') + # Index past the end of a line is clamped to its end. + self.assertEqual(text.index('1.100'), '1.11') + self.assertRaises(TclError, text.index, 'invalid') + self.assertRaises(TypeError, text.index) + self.assertRaises(TypeError, text.index, '1.0', '2.5') + + def test_compare(self): + text = self.text + text.insert('1.0', 'Lorem ipsum\ndolor sit amet') + self.assertIs(text.compare('1.0', '<', '2.0'), True) + self.assertIs(text.compare('2.0', '<', '1.0'), False) + self.assertIs(text.compare('1.5', '==', '1.5'), True) + self.assertIs(text.compare('1.5', '!=', '1.5'), False) + self.assertIs(text.compare('2.0', '>=', '2.0'), True) + self.assertIs(text.compare('1.0', '<=', 'end'), True) + self.assertRaises(TclError, text.compare, '1.0', 'invalid', '2.0') + self.assertRaises(TypeError, text.compare, '1.0', '<') + self.assertRaises(TypeError, text.compare, '1.0', '<', '2.0', '3.0') + + def test_insert_get_delete(self): + text = self.text + text.insert('1.0', 'Lorem ipsum') + self.assertEqual(text.get('1.0', 'end'), 'Lorem ipsum\n') + self.assertEqual(text.get('1.0', '1.5'), 'Lorem') + self.assertEqual(text.get('1.6'), 'i') # single character + + # insert before an existing index + text.insert('1.0', '*** ') + self.assertEqual(text.get('1.0', 'end'), '*** Lorem ipsum\n') + + text.delete('1.0', '1.4') + self.assertEqual(text.get('1.0', 'end'), 'Lorem ipsum\n') + text.delete('1.5') # delete a single character + self.assertEqual(text.get('1.0', 'end'), 'Loremipsum\n') + self.assertRaises(TypeError, text.get) + self.assertRaises(TypeError, text.get, '1.0', '1.5', 'end') + self.assertRaises(TypeError, text.delete, '1.0', '1.5', 'end') + + def test_insert_with_tags(self): + text = self.text + text.insert('1.0', 'hello', 'a', ' ', ('a', 'b'), 'world', 'b') + self.assertEqual(text.get('1.0', 'end'), 'hello world\n') + self.assertEqual([str(i) for i in text.tag_ranges('a')], + ['1.0', '1.6']) + self.assertEqual([str(i) for i in text.tag_ranges('b')], + ['1.5', '1.11']) + + def test_replace(self): + text = self.text + text.insert('1.0', 'Lorem ipsum') + text.replace('1.0', '1.5', 'Hello') + self.assertEqual(text.get('1.0', 'end'), 'Hello ipsum\n') + text.replace('1.6', 'end - 1 char', 'world') + self.assertEqual(text.get('1.0', 'end'), 'Hello world\n') + self.assertRaises(TclError, text.replace, 'invalid', '1.5', 'x') + # The first index must not be after the second. + self.assertRaises(TclError, text.replace, '1.5', '1.0', 'x') + self.assertRaises(TypeError, text.replace, '1.0', '1.5') + + def test_mark(self): + text = self.text + text.insert('1.0', 'Lorem ipsum\ndolor sit amet') + # 'insert' and 'current' marks always exist. + self.assertIn('insert', text.mark_names()) + self.assertIn('current', text.mark_names()) + + text.mark_set('here', '1.5') + self.assertIn('here', text.mark_names()) + self.assertEqual(text.index('here'), '1.5') + text.mark_set('here', '2.3') + self.assertEqual(text.index('here'), '2.3') + + text.mark_unset('here') + self.assertNotIn('here', text.mark_names()) + + # Unsetting a non-existent mark is not an error. + text.mark_unset('nonexistent') + self.assertRaises(TclError, text.mark_set, 'm', 'invalid') + self.assertRaises(TypeError, text.mark_set, 'm') + self.assertRaises(TypeError, text.mark_set, 'm', '1.0', '2.0') + + def test_mark_gravity(self): + text = self.text + text.insert('1.0', 'Lorem ipsum') + text.mark_set('here', '1.5') + # The default gravity is right. + self.assertEqual(text.mark_gravity('here'), 'right') + text.mark_gravity('here', 'left') + self.assertEqual(text.mark_gravity('here'), 'left') + # With left gravity the mark stays before inserted text. + text.insert('here', 'XXX') + self.assertEqual(text.index('here'), '1.5') + # With right gravity the mark moves after inserted text. + text.mark_gravity('here', 'right') + text.insert('here', 'YYY') + self.assertEqual(text.index('here'), '1.8') + + self.assertRaises(TclError, text.mark_gravity, 'nonexistent') + self.assertRaises(TclError, text.mark_gravity, 'here', 'invalid') + self.assertRaises(TypeError, text.mark_gravity) + self.assertRaises(TypeError, text.mark_gravity, 'here', 'left', 'extra') + + def test_mark_next_previous(self): + text = self.text + text.insert('1.0', 'Lorem ipsum') + # Keep the builtin 'insert' and 'current' marks at 1.0 so they do + # not interfere with the queries below. + text.mark_set('insert', '1.0') + text.mark_set('current', '1.0') + text.mark_set('m1', '1.3') + text.mark_set('m2', '1.7') + # mark_next finds a mark at a position at or after the index, + # mark_previous finds one at a position strictly before it. + self.assertEqual(text.mark_next('1.1'), 'm1') + self.assertEqual(text.mark_next('1.3'), 'm1') + self.assertEqual(text.mark_next('1.4'), 'm2') + self.assertIsNone(text.mark_next('1.8')) + self.assertEqual(text.mark_previous('1.4'), 'm1') + self.assertEqual(text.mark_previous('1.7'), 'm1') + self.assertEqual(text.mark_previous('end'), 'm2') + self.assertIsNone(text.mark_previous('1.0')) + + self.assertRaises(TclError, text.mark_next, 'invalid') + self.assertRaises(TclError, text.mark_previous, 'invalid') + self.assertRaises(TypeError, text.mark_next) + self.assertRaises(TypeError, text.mark_previous) + self.assertRaises(TypeError, text.mark_next, '1.0', '2.0') + self.assertRaises(TypeError, text.mark_previous, '1.0', '2.0') + + def test_tag_add_remove_ranges(self): + text = self.text + text.insert('1.0', 'Lorem ipsum\ndolor sit amet') + self.assertEqual(text.tag_ranges('sel'), ()) + + text.tag_add('big', '1.0', '1.5') + self.assertEqual([str(i) for i in text.tag_ranges('big')], + ['1.0', '1.5']) + # Add a second, disjoint range. + text.tag_add('big', '2.0', '2.5') + self.assertEqual([str(i) for i in text.tag_ranges('big')], + ['1.0', '1.5', '2.0', '2.5']) + + text.tag_remove('big', '1.0', '1.3') + self.assertEqual([str(i) for i in text.tag_ranges('big')], + ['1.3', '1.5', '2.0', '2.5']) + + # tag_ranges of an undefined tag is empty, not an error. + self.assertEqual(text.tag_ranges('nonexistent'), ()) + self.assertRaises(TclError, text.tag_add, 'big', 'invalid') + self.assertRaises(TypeError, text.tag_add, 'big') + self.assertRaises(TclError, text.tag_remove, 'big', 'invalid') + self.assertRaises(TypeError, text.tag_remove, 'big', '1.0', '2.0', '3.0') + self.assertRaises(TypeError, text.tag_ranges, 'big', 'extra') + + def test_tag_names(self): + text = self.text + text.insert('1.0', 'Lorem ipsum') + text.tag_add('a', '1.0', '1.5') + text.tag_add('b', '1.3', '1.8') + # 'sel' always exists; order reflects priority (creation order). + self.assertEqual(set(text.tag_names()), {'sel', 'a', 'b'}) + # Tags applied to the character at the given index. + self.assertEqual(set(text.tag_names('1.4')), {'a', 'b'}) + self.assertEqual(set(text.tag_names('1.0')), {'a'}) + self.assertEqual(set(text.tag_names('1.10')), set()) + self.assertRaises(TclError, text.tag_names, 'invalid') + self.assertRaises(TypeError, text.tag_names, '1.0', '2.0') + + def test_tag_nextrange_prevrange(self): + text = self.text + text.insert('1.0', 'Lorem ipsum dolor') + text.tag_add('a', '1.0', '1.5') + text.tag_add('a', '1.12', '1.17') + + self.assertEqual([str(i) for i in text.tag_nextrange('a', '1.0')], + ['1.0', '1.5']) + self.assertEqual([str(i) for i in text.tag_nextrange('a', '1.5')], + ['1.12', '1.17']) + self.assertEqual(text.tag_nextrange('a', '1.17'), ()) + + self.assertEqual([str(i) for i in text.tag_prevrange('a', 'end')], + ['1.12', '1.17']) + self.assertEqual([str(i) for i in text.tag_prevrange('a', '1.12')], + ['1.0', '1.5']) + self.assertEqual(text.tag_prevrange('a', '1.0'), ()) + + # An undefined tag has no ranges, but the index must be valid. + self.assertEqual(text.tag_nextrange('nonexistent', '1.0'), ()) + self.assertRaises(TclError, text.tag_nextrange, 'a', 'invalid') + self.assertRaises(TclError, text.tag_prevrange, 'a', 'invalid') + self.assertRaises(TypeError, text.tag_nextrange, 'a') + self.assertRaises(TypeError, text.tag_prevrange, 'a') + self.assertRaises(TypeError, text.tag_nextrange, 'a', '1.0', '2.0', '3.0') + self.assertRaises(TypeError, text.tag_prevrange, 'a', '1.0', '2.0', '3.0') + + def test_tag_delete(self): + text = self.text + text.insert('1.0', 'Lorem ipsum') + text.tag_add('a', '1.0', '1.5') + text.tag_add('b', '1.6', '1.11') + self.assertEqual(set(text.tag_names()), {'sel', 'a', 'b'}) + text.tag_delete('a', 'b') + self.assertEqual(set(text.tag_names()), {'sel'}) + self.assertEqual(text.tag_ranges('a'), ()) + + def test_tag_raise_lower(self): + text = self.text + text.tag_configure('a', foreground='red') + text.tag_configure('b', foreground='green') + text.tag_configure('c', foreground='blue') + # Creation order is lowest-to-highest priority; tag_names lists + # them in increasing priority order. + self.assertEqual(text.tag_names(), ('sel', 'a', 'b', 'c')) + text.tag_raise('a') + self.assertEqual(text.tag_names(), ('sel', 'b', 'c', 'a')) + text.tag_lower('a') + self.assertEqual(text.tag_names(), ('a', 'sel', 'b', 'c')) + text.tag_raise('a', 'b') + self.assertEqual(text.tag_names(), ('sel', 'b', 'a', 'c')) + text.tag_lower('c', 'b') + self.assertEqual(text.tag_names(), ('sel', 'c', 'b', 'a')) + + self.assertRaises(TclError, text.tag_raise, 'nonexistent') + self.assertRaises(TclError, text.tag_lower, 'nonexistent') + self.assertRaises(TclError, text.tag_raise, 'a', 'nonexistent') + self.assertRaises(TypeError, text.tag_raise) + self.assertRaises(TypeError, text.tag_raise, 'a', 'b', 'c') + self.assertRaises(TypeError, text.tag_lower, 'a', 'b', 'c') + + def test_tag_cget_configure(self): + text = self.text + text.tag_configure('a', foreground='red', underline=True) + self.assertEqual(str(text.tag_cget('a', 'foreground')), 'red') + self.assertIn(text.tag_cget('a', 'underline'), (1, '1', True)) + # tag_cget normalizes the option name (no leading hyphen needed). + self.assertEqual(str(text.tag_cget('a', '-foreground')), 'red') + # configure() query returns the full option spec. + self.assertEqual(text.tag_configure('a', 'foreground')[-1], 'red') + text.tag_configure('a', foreground='blue') + self.assertEqual(str(text.tag_cget('a', 'foreground')), 'blue') + + self.assertRaises(TclError, text.tag_cget, 'nonexistent', + 'foreground') + self.assertRaises(TypeError, text.tag_cget, 'a') + self.assertRaises(TypeError, text.tag_cget, 'a', 'foreground', 'extra') + + def test_edit_modified(self): + text = self.text + self.assertEqual(text.edit_modified(), 0) + text.insert('1.0', 'spam') + self.assertEqual(text.edit_modified(), 1) + text.edit_modified(False) + self.assertEqual(text.edit_modified(), 0) + text.edit_modified(True) + self.assertEqual(text.edit_modified(), 1) + + def test_edit_undo_redo(self): + text = self.text + text.configure(undo=True) + text.insert('1.0', 'spam') + text.edit_separator() + text.insert('end', ' eggs') + self.assertEqual(text.get('1.0', 'end'), 'spam eggs\n') + + text.edit_undo() + self.assertEqual(text.get('1.0', 'end'), 'spam\n') + text.edit_redo() + self.assertEqual(text.get('1.0', 'end'), 'spam eggs\n') + + # An empty undo stack raises. + text.edit_reset() + self.assertRaises(TclError, text.edit_undo) + + def test_dump(self): + text = self.text + text.insert('1.0', 'hello') + text.tag_add('a', '1.0', '1.3') + text.mark_set('here', '1.2') + + dump = text.dump('1.0', 'end') + self.assertIsInstance(dump, list) + for item in dump: + self.assertIsInstance(item, tuple) + self.assertEqual(len(item), 3) + kinds = {item[0] for item in dump} + self.assertIn('text', kinds) + self.assertIn('tagon', kinds) + self.assertIn('mark', kinds) + + # Filtering by kind. + text_only = text.dump('1.0', 'end', text=True) + self.assertEqual({item[0] for item in text_only}, {'text'}) + self.assertEqual(''.join(item[1] for item in text_only), 'hello\n') + + # The command callback receives each triple and suppresses the result. + collected = [] + result = text.dump('1.0', 'end', text=True, + command=lambda *a: collected.append(a)) + self.assertIsNone(result) + self.assertEqual(''.join(key_value_index[1] + for key_value_index in collected), 'hello\n') + + self.assertRaises(TclError, text.dump, 'invalid') + self.assertRaises(TypeError, text.dump) + self.assertRaises(TypeError, text.dump, '1.0', 'end', None, 'extra') + + def test_image(self): + text = self.text + image = tkinter.PhotoImage(master=self.root, width=10, height=10) + text.insert('1.0', 'AB') + name = text.image_create('1.1', image=image) + self.assertIsInstance(name, str) + self.assertIn(name, self.root.splitlist(text.image_names())) + self.assertEqual(str(text.image_cget(name, 'image')), str(image)) + + text.image_configure(name, align='top') + self.assertEqual(str(text.image_cget(name, 'align')), 'top') + + # An embedded image occupies a single index position. + self.assertEqual(text.index('end - 1 char'), '1.3') + + # Either a name or an image is required, and the index must be valid. + self.assertRaises(TclError, text.image_create, '1.0') + self.assertRaises(TclError, text.image_create, 'invalid', + image=image) + self.assertRaises(TypeError, text.image_cget, name) + self.assertRaises(TypeError, text.image_cget, name, 'image', 'extra') + + def test_window(self): + text = self.text + button = tkinter.Button(text, text='ok') + text.insert('1.0', 'AB') + text.window_create('1.1', window=button) + self.assertEqual(self.root.splitlist(text.window_names()), + (str(button),)) + self.assertEqual(text.window_cget('1.1', 'window'), str(button)) + + text.window_configure('1.1', padx=5) + self.assertEqual(text.window_cget('1.1', 'padx'), 5) + + # No embedded window at this index, and the index must be valid. + self.assertRaises(TclError, text.window_cget, '1.0', 'window') + self.assertRaises(TclError, text.window_cget, 'invalid', 'window') + self.assertRaises(TypeError, text.window_cget, '1.1') + self.assertRaises(TypeError, text.window_cget, '1.1', 'window', 'extra') + button.destroy() + + def test_tag_configure(self): + text = self.text + tag = 'a' + getint = text.tk.getint + getboolean = text.tk.getboolean + + # Color options. + for opt in ('foreground', 'background'): + text.tag_configure(tag, **{opt: '#ff0000'}) + self.assertEqual(str(text.tag_cget(tag, opt)), '#ff0000') + + # Stipple (bitmap) options. + for opt in ('bgstipple', 'fgstipple'): + text.tag_configure(tag, **{opt: 'gray50'}) + self.assertEqual(str(text.tag_cget(tag, opt)), 'gray50') + + # Enumerated options. + for opt, value in (('justify', 'center'), ('wrap', 'word'), + ('relief', 'raised'), ('tabstyle', 'wordprocessor'), + ('offset', '5')): + text.tag_configure(tag, **{opt: value}) + self.assertEqual(str(text.tag_cget(tag, 'justify')), 'center') + self.assertEqual(str(text.tag_cget(tag, 'wrap')), 'word') + self.assertEqual(str(text.tag_cget(tag, 'relief')), 'raised') + self.assertEqual(str(text.tag_cget(tag, 'tabstyle')), 'wordprocessor') + + # Boolean options. + for opt in ('underline', 'overstrike', 'elide'): + text.tag_configure(tag, **{opt: True}) + self.assertIs(getboolean(text.tag_cget(tag, opt)), True) + text.tag_configure(tag, **{opt: False}) + self.assertIs(getboolean(text.tag_cget(tag, opt)), False) + + # Screen-distance (pixel) options. + for opt in ('borderwidth', 'lmargin1', 'lmargin2', 'rmargin', + 'spacing1', 'spacing2', 'spacing3'): + text.tag_configure(tag, **{opt: 7}) + self.assertEqual(getint(text.tag_cget(tag, opt)), 7) + + # Other options. + text.tag_configure(tag, font='Helvetica 12') + self.assertEqual(str(text.tag_cget(tag, 'font')), 'Helvetica 12') + text.tag_configure(tag, tabs=(10.2, '1i')) + self.assertEqual([str(x) for x in text.tag_ranges('sel')], []) + + self.assertRaises(TclError, text.tag_cget, tag, 'spam') + + @requires_tk(8, 6, 6) + def test_tag_configure_colors(self): + # Tag color options added in Tk 8.6.6. + text = self.text + tag = 'a' + for opt in ('selectforeground', 'selectbackground', + 'lmargincolor', 'rmargincolor', + 'underlinefg', 'overstrikefg'): + text.tag_configure(tag, **{opt: '#00ff00'}) + self.assertEqual(str(text.tag_cget(tag, opt)), '#00ff00') + + def test_tag_configure_query(self): + text = self.text + tag = 'a' + # Querying all options returns a dict keyed by option name. + cnf = text.tag_configure(tag) + self.assertIsInstance(cnf, dict) + self.assertIn('foreground', cnf) + # The value is the full 5-tuple option specification. + self.assertEqual(len(cnf['foreground']), 5) + + # Querying a single option returns its specification. + spec = text.tag_configure(tag, 'foreground') + self.assertEqual(spec[0], 'foreground') + self.assertEqual(spec[-1], '') # unset by default + + # Setting via keyword arguments and via a dict are equivalent. + text.tag_configure(tag, foreground='red') + self.assertEqual(str(text.tag_configure(tag, 'foreground')[-1]), 'red') + text.tag_configure(tag, {'foreground': 'blue'}) + self.assertEqual(str(text.tag_cget(tag, 'foreground')), 'blue') + + # tag_config is an alias of tag_configure. + self.assertEqual(text.tag_config, text.tag_configure) + + def test_image_configure(self): + text = self.text + image = tkinter.PhotoImage(master=self.root, width=10, height=10) + text.insert('1.0', 'AB') + name = text.image_create('1.1', image=image, name='img') + self.assertEqual(name, 'img') + + self.assertEqual(str(text.image_cget(name, 'name')), 'img') + self.assertEqual(str(text.image_cget(name, 'image')), str(image)) + for value in ('top', 'center', 'bottom', 'baseline'): + text.image_configure(name, align=value) + self.assertEqual(str(text.image_cget(name, 'align')), value) + text.image_configure(name, padx=3, pady=4) + self.assertEqual(text.tk.getint(text.image_cget(name, 'padx')), 3) + self.assertEqual(text.tk.getint(text.image_cget(name, 'pady')), 4) + + # Querying returns the full option set. + cnf = text.image_configure(name) + self.assertIsInstance(cnf, dict) + self.assertIn('align', cnf) + self.assertRaises(TclError, text.image_cget, name, 'spam') + + def test_window_configure(self): + text = self.text + button = tkinter.Button(text, text='ok') + text.insert('1.0', 'AB') + text.window_create('1.1', window=button) + + self.assertEqual(text.window_cget('1.1', 'window'), str(button)) + for value in ('top', 'center', 'bottom', 'baseline'): + text.window_configure('1.1', align=value) + self.assertEqual(str(text.window_cget('1.1', 'align')), value) + text.window_configure('1.1', padx=3, pady=4) + self.assertEqual(text.tk.getint(text.window_cget('1.1', 'padx')), 3) + self.assertEqual(text.tk.getint(text.window_cget('1.1', 'pady')), 4) + text.window_configure('1.1', stretch=True) + self.assertIs(text.tk.getboolean(text.window_cget('1.1', 'stretch')), + True) + + cnf = text.window_configure('1.1') + self.assertIsInstance(cnf, dict) + self.assertIn('stretch', cnf) + self.assertRaises(TclError, text.window_cget, '1.1', 'spam') + button.destroy() + + def test_peer(self): + text = self.text + text.insert('1.0', 'Lorem ipsum') + self.assertEqual(text.peer_names(), ()) + + text.peer_create('.peer1') + names = self.root.splitlist(text.tk.call('winfo', 'children', '.')) + self.assertIn('.peer1', [str(n) for n in names]) + self.assertEqual([str(p) for p in text.peer_names()], ['.peer1']) + # Peers share content. + self.assertEqual(text.tk.call('.peer1', 'get', '1.0', 'end'), + 'Lorem ipsum\n') + text.tk.call('destroy', '.peer1') + + def test_bbox(self): + text = self.text + text.insert('1.0', 'hello') + text.update() + bbox = text.bbox('1.0') + self.assertIsInstance(bbox, tuple) + self.assertEqual(len(bbox), 4) + for v in bbox: + self.assertIsInstance(v, int) + # A character that is not displayed has no bounding box. + self.assertIsNone(text.bbox('end')) + self.assertRaises(TclError, text.bbox, 'invalid') + self.assertRaises(TypeError, text.bbox) + self.assertRaises(TypeError, text.bbox, '1.0', '2.0') + + def test_dlineinfo(self): + text = self.text + text.insert('1.0', 'hello\nworld') + text.update() + info = text.dlineinfo('1.0') + self.assertIsInstance(info, tuple) + self.assertEqual(len(info), 5) + for v in info: + self.assertIsInstance(v, int) + self.assertRaises(TclError, text.dlineinfo, 'invalid') + self.assertRaises(TypeError, text.dlineinfo) + self.assertRaises(TypeError, text.dlineinfo, '1.0', '2.0') + + def test_see(self): + text = self.text + text.insert('1.0', '\n'.join('line %d' % i for i in range(200))) + text.update() + # Initially the last line is not visible. + self.assertIsNone(text.bbox('200.0')) + text.see('200.0') + text.update() + self.assertIsNotNone(text.bbox('200.0')) + self.assertRaises(TclError, text.see, 'invalid') + self.assertRaises(TypeError, text.see) + self.assertRaises(TypeError, text.see, '1.0', '2.0') + def test_search(self): text = self.text # pattern and index are obligatory arguments. - self.assertRaises(tkinter.TclError, text.search, None, '1.0') - self.assertRaises(tkinter.TclError, text.search, 'a', None) - self.assertRaises(tkinter.TclError, text.search, None, None) + self.assertRaises(TclError, text.search, None, '1.0') + self.assertRaises(TclError, text.search, 'a', None) + self.assertRaises(TclError, text.search, None, None) # Invalid text index. - self.assertRaises(tkinter.TclError, text.search, '', 0) - self.assertRaises(tkinter.TclError, text.search, '', '') - self.assertRaises(tkinter.TclError, text.search, '', 'invalid') - self.assertRaises(tkinter.TclError, text.search, '', '1.0', 0) - self.assertRaises(tkinter.TclError, text.search, '', '1.0', '') - self.assertRaises(tkinter.TclError, text.search, '', '1.0', 'invalid') + self.assertRaises(TclError, text.search, '', 0) + self.assertRaises(TclError, text.search, '', '') + self.assertRaises(TclError, text.search, '', 'invalid') + self.assertRaises(TclError, text.search, '', '1.0', 0) + self.assertRaises(TclError, text.search, '', '1.0', '') + self.assertRaises(TclError, text.search, '', '1.0', 'invalid') text.insert('1.0', 'This is a test. This is only a test.\n' @@ -88,20 +625,20 @@ def test_search_all(self): text = self.text # pattern and index are obligatory arguments. - self.assertRaises(tkinter.TclError, text.search_all, None, '1.0') - self.assertRaises(tkinter.TclError, text.search_all, 'a', None) - self.assertRaises(tkinter.TclError, text.search_all, None, None) + self.assertRaises(TclError, text.search_all, None, '1.0') + self.assertRaises(TclError, text.search_all, 'a', None) + self.assertRaises(TclError, text.search_all, None, None) # Keyword-only arguments self.assertRaises(TypeError, text.search_all, 'a', '1.0', 'end', None) # Invalid text index. - self.assertRaises(tkinter.TclError, text.search_all, '', 0) - self.assertRaises(tkinter.TclError, text.search_all, '', '') - self.assertRaises(tkinter.TclError, text.search_all, '', 'invalid') - self.assertRaises(tkinter.TclError, text.search_all, '', '1.0', 0) - self.assertRaises(tkinter.TclError, text.search_all, '', '1.0', '') - self.assertRaises(tkinter.TclError, text.search_all, '', '1.0', 'invalid') + self.assertRaises(TclError, text.search_all, '', 0) + self.assertRaises(TclError, text.search_all, '', '') + self.assertRaises(TclError, text.search_all, '', 'invalid') + self.assertRaises(TclError, text.search_all, '', '1.0', 0) + self.assertRaises(TclError, text.search_all, '', '1.0', '') + self.assertRaises(TclError, text.search_all, '', '1.0', 'invalid') def eq(res, expected): self.assertIsInstance(res, tuple) @@ -181,8 +718,8 @@ def test_count(self): self.assertEqual(text.count('1.0', 'end'), (124,)) self.assertEqual(text.count('1.0', 'end', 'indices', return_ints=True), 124) self.assertEqual(text.count('1.0', 'end', 'indices'), (124,)) - self.assertRaises(tkinter.TclError, text.count, '1.0', 'end', 'spam') - self.assertRaises(tkinter.TclError, text.count, '1.0', 'end', '-lines') + self.assertRaises(TclError, text.count, '1.0', 'end', 'spam') + self.assertRaises(TclError, text.count, '1.0', 'end', '-lines') self.assertIsInstance(text.count('1.3', '1.5', 'ypixels', return_ints=True), int) self.assertIsInstance(text.count('1.3', '1.5', 'ypixels'), tuple) diff --git a/Lib/test/test_tkinter/test_widgets.py b/Lib/test/test_tkinter/test_widgets.py index 1c400e970eb02da..8ce71bc37ca2e4f 100644 --- a/Lib/test/test_tkinter/test_widgets.py +++ b/Lib/test/test_tkinter/test_widgets.py @@ -208,6 +208,23 @@ def test_configure_default(self): widget = self.create() self.checkEnumParam(widget, 'default', 'active', 'disabled', 'normal') + def test_invoke(self): + success = [] + widget = self.create(command=lambda: success.append(1)) + widget.pack() + widget.invoke() + self.assertEqual(success, [1]) + # invoke does nothing for a disabled button. + widget.configure(state='disabled') + widget.invoke() + self.assertEqual(success, [1]) + + def test_flash(self): + widget = self.create() + widget.pack() + widget.update_idletasks() + widget.flash() # No exception. + @add_configure_tests(StandardOptionsTests) class CheckbuttonTest(AbstractLabelTest, unittest.TestCase): @@ -263,6 +280,39 @@ def test_same_name(self): b2.deselect() self.assertEqual(v.get(), 0) + def test_invoke(self): + success = [] + v = tkinter.IntVar(self.root) + widget = self.create(variable=v, onvalue=1, offvalue=0, + command=lambda: success.append(v.get())) + widget.pack() + widget.invoke() + self.assertEqual(v.get(), 1) + self.assertEqual(success, [1]) + widget.invoke() + self.assertEqual(v.get(), 0) + self.assertEqual(success, [1, 0]) + # A disabled checkbutton is not toggled and its command is not called. + widget.configure(state='disabled') + widget.invoke() + self.assertEqual(v.get(), 0) + self.assertEqual(success, [1, 0]) + + def test_toggle(self): + v = tkinter.IntVar(self.root) + widget = self.create(variable=v, onvalue=1, offvalue=0) + self.assertEqual(v.get(), 0) + widget.toggle() + self.assertEqual(v.get(), 1) + widget.toggle() + self.assertEqual(v.get(), 0) + + def test_flash(self): + widget = self.create() + widget.pack() + widget.update_idletasks() + widget.flash() # No exception. + @add_configure_tests(StandardOptionsTests) class RadiobuttonTest(AbstractLabelTest, unittest.TestCase): OPTIONS = ( @@ -285,6 +335,28 @@ def test_configure_value(self): widget = self.create() self.checkParams(widget, 'value', 1, 2.3, '', 'any string') + def test_invoke(self): + success = [] + v = tkinter.StringVar(self.root) + widget = self.create(variable=v, value='on', + command=lambda: success.append(v.get())) + widget.pack() + widget.invoke() + self.assertEqual(v.get(), 'on') + self.assertEqual(success, ['on']) + # invoke does nothing for a disabled radiobutton. + v.set('') + widget.configure(state='disabled') + widget.invoke() + self.assertEqual(v.get(), '') + self.assertEqual(success, ['on']) + + def test_flash(self): + widget = self.create() + widget.pack() + widget.update_idletasks() + widget.flash() # No exception. + @add_configure_tests(StandardOptionsTests) class MenubuttonTest(AbstractLabelTest, unittest.TestCase): @@ -476,6 +548,47 @@ def test_selection_methods(self): self.assertEqual(widget.selection_get(), '12345') widget.selection_adjust(0) + def test_delete(self): + widget = self.create() + widget.insert(0, 'abcdef') + widget.delete(1, 3) + self.assertEqual(widget.get(), 'adef') + widget.delete(1) + self.assertEqual(widget.get(), 'aef') + widget.delete(0, 'end') + self.assertEqual(widget.get(), '') + self.assertRaisesRegex(TclError, r'bad (entry|spinbox) index "xyz"', + widget.delete, 'xyz') + self.assertRaises(TypeError, widget.delete) + + def test_icursor(self): + widget = self.create() + widget.insert(0, 'abcdef') + widget.icursor(3) + widget.insert('insert', 'XYZ') + self.assertEqual(widget.get(), 'abcXYZdef') + self.assertRaisesRegex(TclError, r'bad (entry|spinbox) index "xyz"', + widget.icursor, 'xyz') + self.assertRaises(TypeError, widget.icursor) + + def test_select_aliases(self): + # The select_* methods are aliases of the selection_* methods. + widget = self.create() + widget.insert(0, '12345') + self.assertFalse(widget.select_present()) + widget.select_range(0, 'end') + self.assertTrue(widget.select_present()) + self.assertEqual(widget.selection_get(), '12345') + widget.select_from(1) + widget.select_to(3) + self.assertEqual(widget.selection_get(), '23') + widget.select_adjust(4) + self.assertEqual(widget.selection_get(), '234') + widget.select_clear() + self.assertFalse(widget.select_present()) + self.assertRaisesRegex(TclError, 'bad entry index "xyz"', + widget.select_range, 'xyz', 'end') + @add_configure_tests(StandardOptionsTests) class SpinboxTest(EntryTest, unittest.TestCase): @@ -624,6 +737,38 @@ def test_selection_element(self): widget.selection_element("buttondown") self.assertEqual(widget.selection_element(), "buttondown") + # Spinbox has no select_* aliases, unlike Entry. + test_select_aliases = None + + def test_invoke(self): + widget = self.create(from_=0, to=10) + widget.delete(0, 'end') + widget.insert(0, '5') + widget.invoke('buttonup') + self.assertEqual(widget.get(), '6') + widget.invoke('buttondown') + self.assertEqual(widget.get(), '5') + self.assertRaisesRegex(TclError, 'bad element "spam"', + widget.invoke, 'spam') + + def test_identify(self): + widget = self.create() + widget.pack() + widget.update_idletasks() + # The empty string is returned for a point over no element. + self.assertIn(widget.identify(5, 5), + ('entry', 'buttonup', 'buttondown', 'none', '')) + self.assertRaises(TclError, widget.identify, 'a', 'b') + + def test_scan(self): + widget = self.create() + widget.insert(0, 'a' * 100) + widget.pack() + widget.update_idletasks() + self.assertEqual(widget.scan_mark(10), ()) + self.assertEqual(widget.scan_dragto(0), ()) + self.assertRaises(TypeError, widget.scan_mark) + @add_configure_tests(StandardOptionsTests) class TextTest(AbstractWidgetTest, unittest.TestCase): @@ -978,6 +1123,122 @@ def test_create_polygon(self): self._test_option_smooth(c, lambda **kwargs: c.create_polygon(20, 30, 40, 50, 60, 10, **kwargs)) + def test_create_arc(self): + c = self.create() + i = c.create_arc(10, 20, 30, 40) + self.assertEqual(c.coords(i), [10.0, 20.0, 30.0, 40.0]) + self.assertEqual(c.itemcget(i, 'style'), 'pieslice') + self.assertEqual(float(c.itemcget(i, 'start')), 0.0) + self.assertEqual(float(c.itemcget(i, 'extent')), 90.0) + + for style in 'pieslice', 'chord', 'arc': + i = c.create_arc(10, 20, 30, 40, style=style) + self.assertEqual(c.itemcget(i, 'style'), style) + self.assertRaises(TclError, c.create_arc, 10, 20, 30, 40, style='spam') + + i = c.create_arc(10, 20, 30, 40, start=45, extent=120, + outline='red', fill='blue', width=3) + self.assertEqual(float(c.itemcget(i, 'start')), 45.0) + self.assertEqual(float(c.itemcget(i, 'extent')), 120.0) + self.assertEqual(str(c.itemcget(i, 'outline')), 'red') + self.assertEqual(str(c.itemcget(i, 'fill')), 'blue') + self.assertEqual(float(c.itemcget(i, 'width')), 3.0) + self.assertRaises(TclError, c.create_arc, 10, 20, 30, 40, extent='spam') + + def test_create_oval(self): + c = self.create() + i = c.create_oval(10, 20, 30, 40) + self.assertEqual(c.coords(i), [10.0, 20.0, 30.0, 40.0]) + self.assertEqual(c.bbox(i), (9, 19, 31, 41)) + self.assertEqual(c.itemcget(i, 'stipple'), '') + + i = c.create_oval(10, 20, 30, 40, fill='red', outline='blue', width=2) + self.assertEqual(str(c.itemcget(i, 'fill')), 'red') + self.assertEqual(str(c.itemcget(i, 'outline')), 'blue') + self.assertEqual(float(c.itemcget(i, 'width')), 2.0) + self.assertRaises(TclError, c.create_oval, 10, 20, 30, 40, width='spam') + + def test_create_bitmap(self): + c = self.create() + i = c.create_bitmap(10, 20, bitmap='gray50') + self.assertEqual(c.coords(i), [10.0, 20.0]) + self.assertEqual(c.itemcget(i, 'bitmap'), 'gray50') + self.assertEqual(c.itemcget(i, 'anchor'), 'center') + + for anchor in 'n', 'ne', 'e', 'se', 's', 'sw', 'w', 'nw', 'center': + i = c.create_bitmap(10, 20, bitmap='gray50', anchor=anchor) + self.assertEqual(c.itemcget(i, 'anchor'), anchor) + self.assertRaises(TclError, c.create_bitmap, 10, 20, + bitmap='gray50', anchor='spam') + + i = c.create_bitmap(10, 20, bitmap='gray50', + foreground='red', background='blue') + self.assertEqual(str(c.itemcget(i, 'foreground')), 'red') + self.assertEqual(str(c.itemcget(i, 'background')), 'blue') + if c._windowingsystem != 'aqua': + # Aqua resolves bitmaps lazily and does not report a bad name here. + self.assertRaises(TclError, c.create_bitmap, 10, 20, bitmap='spam') + + def test_create_image(self): + c = self.create() + image = tkinter.PhotoImage(master=self.root, width=10, height=10) + i = c.create_image(10, 20, image=image) + self.assertEqual(c.coords(i), [10.0, 20.0]) + self.assertEqual(str(c.itemcget(i, 'image')), str(image)) + self.assertEqual(c.itemcget(i, 'anchor'), 'center') + + for anchor in 'n', 'ne', 'e', 'se', 's', 'sw', 'w', 'nw', 'center': + i = c.create_image(10, 20, image=image, anchor=anchor) + self.assertEqual(c.itemcget(i, 'anchor'), anchor) + self.assertRaises(TclError, c.create_image, 10, 20, + image=image, anchor='spam') + + def test_create_text(self): + c = self.create() + i = c.create_text(10, 20, text='Hello') + self.assertEqual(c.coords(i), [10.0, 20.0]) + self.assertEqual(c.itemcget(i, 'text'), 'Hello') + self.assertEqual(c.itemcget(i, 'anchor'), 'center') + self.assertEqual(c.itemcget(i, 'justify'), 'left') + + for justify in 'left', 'right', 'center': + i = c.create_text(10, 20, text='Hello', justify=justify) + self.assertEqual(c.itemcget(i, 'justify'), justify) + self.assertRaises(TclError, c.create_text, 10, 20, justify='spam') + + for anchor in 'n', 'ne', 'e', 'se', 's', 'sw', 'w', 'nw', 'center': + i = c.create_text(10, 20, text='Hello', anchor=anchor) + self.assertEqual(c.itemcget(i, 'anchor'), anchor) + self.assertRaises(TclError, c.create_text, 10, 20, anchor='spam') + + i = c.create_text(10, 20, text='Hello', fill='red', angle=45, + font='TkFixedFont') + self.assertEqual(str(c.itemcget(i, 'fill')), 'red') + self.assertEqual(float(c.itemcget(i, 'angle')), 45.0) + self.assertEqual(str(c.itemcget(i, 'font')), 'TkFixedFont') + self.assertRaises(TclError, c.create_text, 10, 20, angle='spam') + + def test_create_window(self): + c = self.create() + button = tkinter.Button(c, text='ok') + i = c.create_window(10, 20, window=button) + self.assertEqual(c.coords(i), [10.0, 20.0]) + self.assertEqual(c.itemcget(i, 'window'), str(button)) + self.assertEqual(c.itemcget(i, 'anchor'), 'center') + + for anchor in 'n', 'ne', 'e', 'se', 's', 'sw', 'w', 'nw', 'center': + i = c.create_window(10, 20, window=tkinter.Button(c), anchor=anchor) + self.assertEqual(c.itemcget(i, 'anchor'), anchor) + self.assertRaises(TclError, c.create_window, 10, 20, + window=button, anchor='spam') + + i = c.create_window(10, 20, window=tkinter.Button(c), + width=30, height=40) + self.assertEqual(int(c.itemcget(i, 'width')), 30) + self.assertEqual(int(c.itemcget(i, 'height')), 40) + self.assertRaises(TclError, c.create_window, 10, 20, + window=button, width='spam') + def test_coords(self): c = self.create() i = c.create_line(20, 30, 40, 50, 60, 10, tags='x') @@ -1038,6 +1299,391 @@ def test_moveto(self): self.assertEqual(x2_2 - x1_2, x2_3 - x1_3) self.assertEqual(y2_2 - y1_2, y2_3 - y1_3) + def test_create_items(self): + c = self.create() + image = tkinter.PhotoImage(master=self.root, width=10, height=10) + button = tkinter.Button(c, text='ok') + items = { + 'arc': c.create_arc(10, 20, 30, 40), + 'bitmap': c.create_bitmap(10, 20, bitmap='gray50'), + 'image': c.create_image(10, 20, image=image), + 'line': c.create_line(10, 20, 30, 40), + 'oval': c.create_oval(10, 20, 30, 40), + 'polygon': c.create_polygon(10, 20, 30, 40, 50, 20), + 'rectangle': c.create_rectangle(10, 20, 30, 40), + 'text': c.create_text(10, 20, text='hello'), + 'window': c.create_window(10, 20, window=button), + } + for itemtype, item in items.items(): + self.assertIsInstance(item, int) + self.assertEqual(c.type(item), itemtype) + # All items are reported by find_all in creation (stacking) order. + self.assertEqual(c.find_all(), tuple(sorted(items.values()))) + + # No coordinates at all is an IndexError; a bad number is a TclError. + self.assertRaises(IndexError, c.create_arc) + self.assertRaises(TclError, c.create_arc, 1, 2, 3) + self.assertRaises(TclError, c.create_oval, 1, 2) + + def test_type(self): + c = self.create() + rect = c.create_rectangle(10, 20, 30, 40) + self.assertEqual(c.type(rect), 'rectangle') + # An unmatched tag or id is not an error. + self.assertIsNone(c.type('nonexistent')) + self.assertIsNone(c.type(9999)) + self.assertRaises(TypeError, c.type) + self.assertRaises(TypeError, c.type, rect, 'extra') + + def test_bbox(self): + c = self.create() + rect = c.create_rectangle(10, 10, 30, 30) + bbox = c.bbox(rect) + self.assertIsInstance(bbox, tuple) + self.assertEqual(len(bbox), 4) + for v in bbox: + self.assertIsInstance(v, int) + # The bounding box encloses the item (with a small margin). + self.assertEqual(bbox, (9, 9, 31, 31)) + # bbox over several items is their union. + rect2 = c.create_rectangle(50, 50, 70, 70) + self.assertEqual(c.bbox(rect, rect2), (9, 9, 71, 71)) + # An unmatched tag has no bounding box. + self.assertIsNone(c.bbox('nonexistent')) + # At least one tag or id is required. + self.assertRaises(TclError, c.bbox) + + def test_canvasx_canvasy(self): + c = self.create(borderwidth=0, highlightthickness=0) + self.assertEqual(c.canvasx(0), 0.0) + self.assertEqual(c.canvasx(10), 10.0) + self.assertIsInstance(c.canvasx(10), float) + # gridspacing rounds to the nearest multiple. + self.assertEqual(c.canvasx(13, 5), 15.0) + self.assertEqual(c.canvasy(0), 0.0) + self.assertEqual(c.canvasy(10), 10.0) + self.assertRaises(TypeError, c.canvasx) + self.assertRaises(TypeError, c.canvasx, 0, 5, 1) + self.assertRaises(TypeError, c.canvasy) + self.assertRaises(TypeError, c.canvasy, 0, 5, 1) + self.assertRaises(TclError, c.canvasx, 'spam') + self.assertRaises(TclError, c.canvasy, 'spam') + + def test_move(self): + c = self.create() + rect = c.create_rectangle(10, 10, 30, 30) + c.move(rect, 5, 7) + self.assertEqual(c.coords(rect), [15.0, 17.0, 35.0, 37.0]) + c.move(rect, -5, -7) + self.assertEqual(c.coords(rect), [10.0, 10.0, 30.0, 30.0]) + # move() takes variable arguments; bad or missing values reach Tcl. + self.assertRaises(TclError, c.move, rect, 'spam', 0) + self.assertRaises(TclError, c.move, rect) + + def test_scale(self): + c = self.create() + rect = c.create_rectangle(10, 10, 30, 30) + c.scale(rect, 0, 0, 2, 2) + self.assertEqual(c.coords(rect), [20.0, 20.0, 60.0, 60.0]) + c.scale(rect, 0, 0, 0.5, 0.5) + self.assertEqual(c.coords(rect), [10.0, 10.0, 30.0, 30.0]) + self.assertRaises(TclError, c.scale, rect, 0, 0, 'spam', 2) + self.assertRaises(TclError, c.scale, rect, 0, 0) # missing factors + + def test_delete(self): + c = self.create() + r1 = c.create_rectangle(10, 10, 30, 30) + r2 = c.create_rectangle(50, 50, 70, 70) + r3 = c.create_rectangle(90, 90, 110, 110) + self.assertEqual(c.find_all(), (r1, r2, r3)) + c.delete(r2) + self.assertEqual(c.find_all(), (r1, r3)) + # Deleting a non-existent item is not an error. + c.delete(9999) + c.delete('all') + self.assertEqual(c.find_all(), ()) + + def test_find(self): + c = self.create() + r1 = c.create_rectangle(10, 10, 30, 30) + r2 = c.create_rectangle(50, 50, 70, 70) + r3 = c.create_rectangle(100, 100, 120, 120) + + self.assertEqual(c.find_all(), (r1, r2, r3)) + # find_above/find_below return the single adjacent item. + self.assertEqual(c.find_above(r1), (r2,)) + self.assertEqual(c.find_below(r3), (r2,)) + self.assertEqual(c.find_above(r3), ()) # nothing above the top item + self.assertEqual(c.find_withtag(r2), (r2,)) + self.assertEqual(c.find_closest(60, 60), (r2,)) + self.assertEqual(c.find_enclosed(0, 0, 80, 80), (r1, r2)) + self.assertEqual(c.find_overlapping(0, 0, 20, 20), (r1,)) + # An unmatched query returns an empty tuple. + self.assertEqual(c.find_withtag('nonexistent'), ()) + for result in (c.find_all(), c.find_withtag(r1)): + self.assertIsInstance(result, tuple) + + self.assertRaises(TclError, c.find_closest, 'spam', 0) + self.assertRaises(TclError, c.find_enclosed, 0, 0, 'spam', 0) + self.assertRaises(TclError, c.find_overlapping, 0, 0, 'spam', 0) + self.assertRaises(TypeError, c.find_withtag) + self.assertRaises(TypeError, c.find_withtag, r1, 'extra') + self.assertRaises(TypeError, c.find_above) + self.assertRaises(TypeError, c.find_above, r1, 'extra') + self.assertRaises(TypeError, c.find_below) + self.assertRaises(TypeError, c.find_closest) + self.assertRaises(TypeError, c.find_closest, 0, 0, 1, 2, 3) + self.assertRaises(TypeError, c.find_enclosed, 0, 0, 1) + self.assertRaises(TypeError, c.find_enclosed, 0, 0, 1, 2, 3) + + def test_addtag_gettags_dtag(self): + c = self.create() + r1 = c.create_rectangle(10, 10, 30, 30) + r2 = c.create_rectangle(50, 50, 70, 70) + + self.assertEqual(c.gettags(r1), ()) + c.addtag_withtag('spam', r1) + self.assertEqual(c.gettags(r1), ('spam',)) + self.assertEqual(c.find_withtag('spam'), (r1,)) + + c.addtag_all('all') + self.assertIn('all', c.gettags(r1)) + self.assertIn('all', c.gettags(r2)) + + c.addtag_above('above1', r1) + self.assertIn('above1', c.gettags(r2)) + c.addtag_below('below2', r2) + self.assertIn('below2', c.gettags(r1)) + + c.addtag_enclosed('enc', 0, 0, 40, 40) + self.assertEqual(c.find_withtag('enc'), (r1,)) + c.addtag_overlapping('ov', 0, 0, 20, 20) + self.assertEqual(c.find_withtag('ov'), (r1,)) + c.addtag_closest('close', 60, 60) + self.assertEqual(c.find_withtag('close'), (r2,)) + + # gettags of an unmatched tag is empty. + self.assertEqual(c.gettags('nonexistent'), ()) + + # dtag removes a tag from an item. + c.dtag(r1, 'spam') + self.assertNotIn('spam', c.gettags(r1)) + + self.assertRaises(TypeError, c.addtag_withtag, 'tag') + self.assertRaises(TypeError, c.addtag_withtag, 'tag', r1, 'extra') + self.assertRaises(TypeError, c.addtag_all) + self.assertRaises(TypeError, c.addtag_enclosed, 'tag', 0, 0, 1) + self.assertRaises(TypeError, c.addtag_enclosed, 'tag', 0, 0, 1, 2, 3) + self.assertRaises(TclError, c.addtag_closest, 'tag', 'spam', 0) + self.assertRaises(TclError, c.addtag_enclosed, 'tag', 0, 0, 'spam', 0) + self.assertRaises(TclError, c.gettags) # needs an item + + def test_itemconfigure(self): + c = self.create() + rect = c.create_rectangle(10, 10, 30, 30) + c.itemconfigure(rect, fill='red', width=3) + self.assertEqual(str(c.itemcget(rect, 'fill')), 'red') + self.assertEqual(float(c.itemcget(rect, 'width')), 3.0) + + # Querying all options returns a dict; a single one returns its spec. + cnf = c.itemconfigure(rect) + self.assertIsInstance(cnf, dict) + self.assertIn('fill', cnf) + self.assertEqual(c.itemconfigure(rect, 'fill')[-1], 'red') + + # itemconfig is an alias of itemconfigure. + self.assertEqual(c.itemconfig, c.itemconfigure) + + self.assertRaises(TclError, c.itemcget, rect, 'badoption') + self.assertRaises(TclError, c.itemconfigure, rect, badoption='x') + self.assertRaises(TypeError, c.itemcget, rect) + self.assertRaises(TypeError, c.itemcget, rect, 'fill', 'extra') + + def test_tag_raise_lower(self): + c = self.create() + r1 = c.create_rectangle(10, 10, 30, 30) + r2 = c.create_rectangle(50, 50, 70, 70) + r3 = c.create_rectangle(90, 90, 110, 110) + self.assertEqual(c.find_all(), (r1, r2, r3)) + + c.tag_raise(r1) + self.assertEqual(c.find_all(), (r2, r3, r1)) + c.tag_lower(r1) + self.assertEqual(c.find_all(), (r1, r2, r3)) + # Raise above / lower below a specific item. + c.tag_raise(r1, r2) + self.assertEqual(c.find_all(), (r2, r1, r3)) + c.tag_lower(r3, r2) + self.assertEqual(c.find_all(), (r3, r2, r1)) + + # lower/lift are aliases of tag_lower/tag_raise. + self.assertEqual(c.lower, c.tag_lower) + self.assertEqual(c.lift, c.tag_raise) + + # raise/lower need at least an item; an unmatched tag is not an error. + self.assertRaises(TclError, c.tag_raise) + self.assertRaises(TclError, c.tag_lower) + self.assertIsNone(c.tag_raise('nonexistent')) + + def test_text_item(self): + c = self.create() + item = c.create_text(10, 10, text='Hello') + self.assertEqual(c.index(item, 'end'), 5) + self.assertIsInstance(c.index(item, 'end'), int) + + c.insert(item, 'end', ' world') + self.assertEqual(c.itemcget(item, 'text'), 'Hello world') + self.assertEqual(c.index(item, 'end'), 11) + c.insert(item, 0, '>> ') + self.assertEqual(c.itemcget(item, 'text'), '>> Hello world') + + c.dchars(item, 0, 2) + self.assertEqual(c.itemcget(item, 'text'), 'Hello world') + c.icursor(item, 3) + + # index requires an indexable (text) item and a valid index. + self.assertRaises(TclError, c.index, item, 'badspec') + self.assertRaises(TclError, c.index, item) # missing index + self.assertRaises(TclError, c.dchars, item, 'badspec', 'end') + rect = c.create_rectangle(10, 10, 30, 30) + self.assertRaises(TclError, c.index, rect, 'end') + + def test_select(self): + c = self.create() + item = c.create_text(10, 10, text='Hello world') + self.assertIsNone(c.select_item()) + + c.select_from(item, 0) + c.select_to(item, 4) + self.assertEqual(int(c.select_item()), item) + c.select_adjust(item, 6) + + c.select_clear() + self.assertIsNone(c.select_item()) + self.assertRaises(TypeError, c.select_from, item) + self.assertRaises(TypeError, c.select_from, item, 0, 'extra') + # A bad index reaches Tcl; selection applies only to text items. + self.assertRaises(TclError, c.select_from, item, 'badspec') + rect = c.create_rectangle(10, 10, 30, 30) + self.assertRaises(TclError, c.select_from, rect, 0) + + def test_focus(self): + c = self.create() + item = c.create_text(10, 10, text='Hello') + # Only text items can take the focus. + c.focus(item) + self.assertEqual(int(c.focus()), item) + c.focus('') + self.assertIn(c.focus(), ('', None)) + + def test_scan(self): + c = self.create() + c.create_rectangle(10, 10, 300, 300) + c.scan_mark(0, 0) + c.scan_dragto(5, 5) # default gain=10 + c.scan_dragto(5, 5, 1) + self.assertRaises(TypeError, c.scan_mark) + self.assertRaises(TypeError, c.scan_mark, 0, 0, 0) + self.assertRaises(TclError, c.scan_mark, 'spam', 0) + + def test_postscript(self): + c = self.create() + c.create_rectangle(10, 10, 30, 30, fill='black') + ps = c.postscript() + self.assertIsInstance(ps, str) + self.assertStartsWith(ps, '%!PS-Adobe') + self.assertRaises(TclError, c.postscript, badoption='spam') + + def assertCommandExist(self, widget, funcid): + self.assertEqual( + widget.tk.splitlist(widget.tk.call('info', 'commands', funcid)), + (funcid,)) + + def assertCommandNotExist(self, widget, funcid): + self.assertEqual( + widget.tk.splitlist(widget.tk.call('info', 'commands', funcid)), + ()) + + def test_tag_bind(self): + c = self.create() + c.pack() + item = c.create_rectangle(20, 20, 100, 100, fill='red') + self.assertEqual(c.tag_bind(item), ()) + self.assertEqual(c.tag_bind(item, ''), '') + + events = [] + def test1(e): events.append('a') + def test2(e): events.append('b') + + funcid = c.tag_bind(item, '', test1) + self.assertEqual(c.tag_bind(item), ('',)) + script = c.tag_bind(item, '') + self.assertIn(funcid, script) + self.assertCommandExist(c, funcid) + + funcid2 = c.tag_bind(item, '', test2, add=True) + script = c.tag_bind(item, '') + self.assertIn(funcid, script) + self.assertIn(funcid2, script) + self.assertCommandExist(c, funcid) + self.assertCommandExist(c, funcid2) + + c.wait_visibility() + c.focus_force() + c.update() + c.event_generate('', x=50, y=50) + c.update() + self.assertEqual(events, ['a', 'b']) + + # Binding to a tag applies to all items carrying it. + c.addtag_withtag('spam', item) + events.clear() + c.tag_bind('spam', '', test1) + c.event_generate('', x=50, y=50) + c.update() + self.assertEqual(events, ['a']) + + def test_tag_unbind(self): + c = self.create() + c.pack() + item = c.create_rectangle(20, 20, 100, 100, fill='red') + + events = [] + def test1(e): events.append('a') + def test2(e): events.append('b') + + funcid = c.tag_bind(item, '', test1) + funcid2 = c.tag_bind(item, '', test2, add=True) + c.wait_visibility() + c.focus_force() + c.update() + c.event_generate('', x=50, y=50) + c.update() + self.assertEqual(events, ['a', 'b']) + + # Removing one function leaves the other in place. + c.tag_unbind(item, '', funcid) + script = c.tag_bind(item, '') + self.assertNotIn(funcid, script) + self.assertIn(funcid2, script) + self.assertCommandNotExist(c, funcid) + self.assertCommandExist(c, funcid2) + events.clear() + c.event_generate('', x=50, y=50) + c.update() + self.assertEqual(events, ['b']) + + # Without a funcid all bindings for the sequence are removed. + c.tag_unbind(item, '') + self.assertEqual(c.tag_bind(item, ''), '') + self.assertEqual(c.tag_bind(item), ()) + events.clear() + c.event_generate('', x=50, y=50) + c.update() + self.assertEqual(events, []) + + self.assertRaises(TypeError, c.tag_unbind, item) + @add_configure_tests(IntegerSizeTests, StandardOptionsTests) class ListboxTest(AbstractWidgetTest, unittest.TestCase): @@ -1180,6 +1826,106 @@ def test_get(self): self.assertRaises(TypeError, lb.get, 1, 2, 3) self.assertRaises(TclError, lb.get, 2.4) + def test_size(self): + lb = self.create() + self.assertEqual(lb.size(), 0) + lb.insert(0, *('el%d' % i for i in range(8))) + self.assertEqual(lb.size(), 8) + lb.delete(0, 2) + self.assertEqual(lb.size(), 5) + self.assertRaises(TypeError, lb.size, 0) + + def test_delete(self): + lb = self.create() + lb.insert(0, *('el%d' % i for i in range(8))) + lb.delete(0) + self.assertEqual(lb.get(0, 'end'), + ('el1', 'el2', 'el3', 'el4', 'el5', 'el6', 'el7')) + lb.delete(2, 4) + self.assertEqual(lb.get(0, 'end'), ('el1', 'el2', 'el6', 'el7')) + lb.delete(0, 'end') + self.assertEqual(lb.size(), 0) + self.assertRaises(TclError, lb.delete, 'noindex') + self.assertRaises(TypeError, lb.delete) + + def test_index(self): + lb = self.create() + lb.insert(0, *('el%d' % i for i in range(8))) + self.assertEqual(lb.index(3), 3) + self.assertEqual(lb.index('end'), 8) # the number of elements + lb.activate(4) + self.assertEqual(lb.index('active'), 4) + lb.selection_anchor(2) + self.assertEqual(lb.index('anchor'), 2) + self.assertRaisesRegex(TclError, 'bad listbox index "spam"', + lb.index, 'spam') + + def test_nearest(self): + lb = self.create() + lb.insert(0, *('el%d' % i for i in range(8))) + lb.pack() + lb.wait_visibility() + lb.update() + # Derive the line height from the first item, which is always + # displayed (bbox() returns None for items that are not). + x, y, w, h = lb.bbox(0) + self.assertEqual(lb.nearest(y + h // 2), 0) + self.assertEqual(lb.nearest(y + 3 * h + h // 2), 3) + self.assertRaises(TclError, lb.nearest, 'spam') + self.assertRaises(TypeError, lb.nearest) + + def test_see(self): + lb = self.create(height=5) + lb.insert(0, *('el%d' % i for i in range(20))) + lb.pack() + lb.update_idletasks() + lb.see('end') + lb.update_idletasks() + self.assertEqual(lb.yview()[1], 1.0) + lb.see(0) + lb.update_idletasks() + self.assertEqual(lb.yview()[0], 0.0) + self.assertRaises(TclError, lb.see, 'spam') + + def test_activate(self): + lb = self.create() + lb.insert(0, *('el%d' % i for i in range(8))) + lb.activate(3) + self.assertEqual(lb.index('active'), 3) + lb.activate('end') + self.assertEqual(lb.index('active'), 7) + self.assertRaises(TclError, lb.activate, 'spam') + self.assertRaises(TypeError, lb.activate) + + def test_selection(self): + lb = self.create() + lb.insert(0, *('el%d' % i for i in range(8))) + self.assertEqual(lb.curselection(), ()) + self.assertFalse(lb.selection_includes(0)) + + lb.selection_set(2, 4) + lb.selection_set(6) + self.assertEqual(lb.curselection(), (2, 3, 4, 6)) + self.assertTrue(lb.selection_includes(3)) + self.assertFalse(lb.selection_includes(5)) + + lb.selection_clear(3, 4) + self.assertEqual(lb.curselection(), (2, 6)) + + lb.selection_anchor(5) + self.assertEqual(lb.index('anchor'), 5) + + # select_* are aliases of the selection_* methods. + lb.select_clear(0, 'end') + self.assertEqual(lb.curselection(), ()) + lb.select_set(1) + self.assertTrue(lb.select_includes(1)) + lb.select_anchor(1) + self.assertEqual(lb.index('anchor'), 1) + + self.assertRaisesRegex(TclError, 'bad listbox index "spam"', + lb.selection_includes, 'spam') + @add_configure_tests(PixelSizeTests, StandardOptionsTests) class ScaleTest(AbstractWidgetTest, unittest.TestCase): @@ -1249,6 +1995,14 @@ def test_configure_to(self): self.checkFloatParam(widget, 'to', 300, 14.9, 15.1, -10, conv=float_round) + def test_identify(self): + widget = self.create() + widget.pack() + widget.update_idletasks() + self.assertIn(widget.identify(5, 5), + ('slider', 'trough1', 'trough2', '')) + self.assertRaises(TclError, widget.identify, 'a', 'b') + @add_configure_tests(PixelSizeTests, StandardOptionsTests) class ScrollbarTest(AbstractWidgetTest, unittest.TestCase): @@ -1302,6 +2056,34 @@ def test_set(self): self.assertRaises(TypeError, sb.set, 0.6) self.assertRaises(TypeError, sb.set, 0.6, 0.7, 0.8) + def test_fraction(self): + sb = self.create() + sb.pack(fill='y', expand=True) + sb.update_idletasks() + self.assertIsInstance(sb.fraction(0, 0), float) + f = sb.fraction(0, 1000) + self.assertIsInstance(f, float) + self.assertGreaterEqual(f, 0.0) + self.assertLessEqual(f, 1.0) + self.assertRaises(TclError, sb.fraction, 'a', 'b') + self.assertRaises(TypeError, sb.fraction, 0) + + def test_delta(self): + sb = self.create() + sb.pack(fill='y', expand=True) + sb.update_idletasks() + self.assertIsInstance(sb.delta(0, 10), float) + self.assertRaises(TclError, sb.delta, 'a', 'b') + self.assertRaises(TypeError, sb.delta, 0) + + def test_identify(self): + sb = self.create() + sb.pack(fill='y', expand=True) + sb.update_idletasks() + self.assertIn(sb.identify(5, 5), + ('arrow1', 'arrow2', 'slider', 'trough1', 'trough2', '')) + self.assertRaises(TclError, sb.identify, 'a', 'b') + @add_configure_tests(PixelSizeTests, StandardOptionsTests) class PanedWindowTest(AbstractWidgetTest, unittest.TestCase): @@ -1379,6 +2161,75 @@ def create2(self): p.add(c) return p, b, c + def test_panes(self): + p, b, c = self.create2() + self.assertEqual([str(x) for x in p.panes()], [str(b), str(c)]) + + def test_remove(self): + p, b, c = self.create2() + p.remove(b) + self.assertEqual([str(x) for x in p.panes()], [str(c)]) + p.forget(c) # forget is an alias of remove. + self.assertEqual(p.panes(), ()) + + def test_sash(self): + p, b, c = self.create2() + p.configure(width=200, height=50) + p.pack() + p.update() + x, y = p.sash_coord(0) + self.assertIsInstance(x, int) + self.assertIsInstance(y, int) + p.sash_place(0, 120, 0) + p.update() + self.assertEqual(p.sash_coord(0)[0], 120) + p.sash_mark(0) # No exception. + self.assertRaises(TclError, p.sash_coord, 5) + + def test_proxy(self): + p, b, c = self.create2() + p.configure(width=200, height=50) + p.pack() + p.update() + p.proxy_place(100, 10) + p.update() + self.assertEqual(p.proxy_coord()[0], 100) + p.proxy_forget() + p.update() + + def test_identify(self): + p, b, c = self.create2() + p.configure(width=200, height=50) + p.pack() + p.update() + x, y = p.sash_coord(0) + # A point over the sash reports the sash. + self.assertIn('sash', p.identify(x + 1, y + 5)) + # A point over a pane reports nothing. + self.assertFalse(p.identify(2, 2)) + self.assertRaises(TclError, p.identify, 'a', 'b') + + def test_add_options(self): + p = self.create() + b = tkinter.Button(p) + p.add(b, minsize=40, padx=3, sticky='ns') + self.assertEqual(p.panecget(b, 'minsize'), + 40 if self.wantobjects else '40') + self.assertEqual(p.panecget(b, 'padx'), + 3 if self.wantobjects else '3') + self.assertEqual(p.panecget(b, 'sticky'), 'ns') + self.assertRaisesRegex(TclError, 'unknown option "-spam"', + p.add, tkinter.Button(p), spam='x') + self.assertRaisesRegex(TclError, 'bad window path name "spam"', + p.add, 'spam') + + def test_paneconfigure_errors(self): + p, b, c = self.create2() + self.assertRaisesRegex(TclError, 'unknown option "-spam"', + p.paneconfigure, b, spam='x') + self.assertRaises(TclError, p.panecget, b, 'spam') + self.assertRaises(TclError, p.paneconfigure, 'spam') + def test_paneconfigure(self): p, b, c = self.create2() self.assertRaises(TypeError, p.paneconfigure) @@ -1542,6 +2393,192 @@ def test_entryconfigure_variable(self): m1.entryconfigure(1, variable=v2) self.assertEqual(str(m1.entrycget(1, 'variable')), str(v2)) + def test_add(self): + m = self.create(tearoff=False) + m.add_command(label='Command') + m.add_checkbutton(label='Checkbutton') + m.add_radiobutton(label='Radiobutton') + m.add_separator() + m.add_cascade(label='Cascade', menu=tkinter.Menu(m, tearoff=False)) + self.assertEqual(m.index('end'), 4) + self.assertEqual([m.type(i) for i in range(5)], + ['command', 'checkbutton', 'radiobutton', + 'separator', 'cascade']) + self.assertEqual(m.entrycget(0, 'label'), 'Command') + self.assertRaisesRegex(TclError, 'bad menu entry type "spam"', + m.add, 'spam') + + def test_insert(self): + m = self.create(tearoff=False) + m.add_command(label='A') + m.add_command(label='C') + m.insert_command(1, label='B') + m.insert_separator(0) + m.insert_checkbutton('end', label='D') + m.insert_radiobutton(0, label='top') + m.insert_cascade(2, label='sub', + menu=tkinter.Menu(m, tearoff=False)) + self.assertEqual( + [m.type(i) for i in range(m.index('end') + 1)], + ['radiobutton', 'separator', 'cascade', 'command', + 'command', 'command', 'checkbutton']) + self.assertEqual( + [m.entrycget(i, 'label') for i in (3, 4, 5)], + ['A', 'B', 'C']) + self.assertRaisesRegex(TclError, 'bad menu entry type "spam"', + m.insert, 0, 'spam') + self.assertRaisesRegex(TclError, 'bad menu entry index "spam"', + m.insert_command, 'spam', label='z') + + def test_delete(self): + m = self.create(tearoff=False) + commands = [] + for label in 'ABCDE': + m.add_command(label=label, + command=lambda label=label: commands.append(label)) + # The Tcl command for a deleted item is cleaned up. + funcid = str(m.entrycget(2, 'command')) + self.assertEqual( + m.tk.splitlist(m.tk.call('info', 'commands', funcid)), (funcid,)) + + m.delete(2) # Delete a single item ('C'). + self.assertEqual([m.entrycget(i, 'label') for i in range(4)], + ['A', 'B', 'D', 'E']) + self.assertEqual( + m.tk.splitlist(m.tk.call('info', 'commands', funcid)), ()) + + m.delete(1, 2) # Delete a range ('B' and 'D'). + self.assertEqual([m.entrycget(i, 'label') for i in range(2)], + ['A', 'E']) + self.assertRaises(TypeError, m.delete) + + def test_index(self): + m = self.create(tearoff=False) + self.assertIsNone(m.index('end')) + m.add_command(label='First') + m.add_command(label='Second') + self.assertEqual(m.index('end'), 1) + self.assertEqual(m.index('last'), 1) + self.assertEqual(m.index('Second'), 1) + self.assertEqual(m.index(0), 0) + # 'active' and 'none' map to None when no item is active. + self.assertIsNone(m.index('active')) + self.assertIsNone(m.index('none')) + self.assertRaisesRegex(TclError, 'bad menu entry index "spam"', + m.index, 'spam') + + def test_invoke(self): + m = self.create(tearoff=False) + commands = [] + m.add_command(label='Command', + command=lambda: commands.append('invoked')) + var = tkinter.IntVar(self.root) + m.add_checkbutton(label='Check', variable=var, + onvalue=1, offvalue=0) + rvar = tkinter.StringVar(self.root) + m.add_radiobutton(label='Radio', variable=rvar, value='on') + + m.invoke(0) + self.assertEqual(commands, ['invoked']) + m.invoke(1) + self.assertEqual(var.get(), 1) + m.invoke(1) + self.assertEqual(var.get(), 0) + m.invoke(2) + self.assertEqual(rvar.get(), 'on') + self.assertRaisesRegex(TclError, 'bad menu entry index "spam"', + m.invoke, 'spam') + + def test_xposition_yposition(self): + m = self.create(tearoff=False) + m.add_command(label='First') + m.add_command(label='Second') + m.update_idletasks() + self.assertIsInstance(m.xposition(0), int) + y0 = m.yposition(0) + y1 = m.yposition(1) + self.assertIsInstance(y0, int) + self.assertLess(y0, y1) + # An out-of-range index gives the position past the last item. + self.assertEqual(m.xposition('end'), m.xposition(1)) + self.assertRaisesRegex(TclError, 'bad menu entry index "spam"', + m.xposition, 'spam') + self.assertRaisesRegex(TclError, 'bad menu entry index "spam"', + m.yposition, 'spam') + + def test_post_unpost(self): + m = self.create(tearoff=False) + if m._windowingsystem != 'x11': + # Posting a menu is modal on Windows and uses a native, unmapped + # menu on Aqua, so it cannot be tested synchronously there. + self.skipTest('menu posting is not testable on this platform') + m.add_command(label='First') + m.add_command(label='Second') + self.assertFalse(m.winfo_ismapped()) + + m.post(0, 0) + m.update() + self.assertTrue(m.winfo_ismapped()) + m.unpost() + m.update() + self.assertFalse(m.winfo_ismapped()) + + m.tk_popup(0, 0) + m.update() + self.assertTrue(m.winfo_ismapped()) + m.unpost() + m.update() + self.assertFalse(m.winfo_ismapped()) + + def check_entry_option(self, m, index, option, value, expected=None): + if expected is None: + expected = value + m.entryconfigure(index, **{option: value}) + self.assertEqual(str(m.entrycget(index, option)), str(expected)) + self.assertEqual(str(m.entryconfigure(index, option)[4]), str(expected)) + + def test_entry_options(self): + m = self.create(tearoff=False) + m.add_command(label='Command') + self.check_entry_option(m, 0, 'accelerator', 'Ctrl+O') + self.check_entry_option(m, 0, 'underline', 2) + self.check_entry_option(m, 0, 'state', 'disabled') + self.check_entry_option(m, 0, 'background', 'red') + self.check_entry_option(m, 0, 'foreground', 'blue') + self.check_entry_option(m, 0, 'columnbreak', 1) + self.check_entry_option(m, 0, 'hidemargin', 1) + + m.add_checkbutton(label='Checkbutton') + self.check_entry_option(m, 1, 'onvalue', 'Y') + self.check_entry_option(m, 1, 'offvalue', 'N') + self.check_entry_option(m, 1, 'indicatoron', 0) + + m.add_radiobutton(label='Radiobutton') + self.check_entry_option(m, 2, 'value', 'V') + self.check_entry_option(m, 2, 'selectcolor', 'green') + + def test_entry_options_invalid(self): + m = self.create(tearoff=False) + m.add_command(label='Command') + self.assertRaisesRegex(TclError, 'unknown option "-spam"', + m.entrycget, 0, 'spam') + self.assertRaisesRegex(TclError, 'unknown option "-spam"', + m.entryconfigure, 0, spam='x') + self.assertRaisesRegex(TclError, 'bad state "spam"', + m.entryconfigure, 0, state='spam') + # Tk < 9 reports "expected integer but got ...", while Tk 9, where + # underline accepts an index, reports "bad index ...". + self.assertRaisesRegex(TclError, + r'(expected integer but got|bad index) "spam"', + m.entryconfigure, 0, underline='spam') + self.assertRaisesRegex(TclError, 'unknown color name "spam"', + m.entryconfigure, 0, background='spam') + self.assertRaisesRegex(TclError, 'expected boolean value but got "spam"', + m.entryconfigure, 0, columnbreak='spam') + # onvalue applies only to checkbutton and radiobutton entries. + self.assertRaisesRegex(TclError, 'unknown option "-onvalue"', + m.entrycget, 0, 'onvalue') + @add_configure_tests(PixelSizeTests, StandardOptionsTests) class MessageTest(AbstractWidgetTest, unittest.TestCase): diff --git a/Misc/NEWS.d/3.10.0a4.rst b/Misc/NEWS.d/3.10.0a4.rst index cd419dfaaee2e8d..16eca7a55db746e 100644 --- a/Misc/NEWS.d/3.10.0a4.rst +++ b/Misc/NEWS.d/3.10.0a4.rst @@ -622,7 +622,7 @@ Harmonized :func:`random.randrange` argument handling to match :func:`range`. .. nonce: O4VcCY .. section: Library -Restore compatibility for ``uname_result`` around deepcopy and _replace. +Restore compatibility for :class:`os.uname_result` around deepcopy and _replace. .. diff --git a/Misc/NEWS.d/3.12.0a3.rst b/Misc/NEWS.d/3.12.0a3.rst index d2c717afcb6e8d8..c71a66757c65662 100644 --- a/Misc/NEWS.d/3.12.0a3.rst +++ b/Misc/NEWS.d/3.12.0a3.rst @@ -454,8 +454,8 @@ event loop but the current event loop was set. .. nonce: humlhz .. section: Library -On ``uname_result``, restored expectation that ``_fields`` and ``_asdict`` -would include all six properties including ``processor``. +On :class:`os.uname_result`, restored expectation that ``_fields`` and +``_asdict`` would include all six properties including ``processor``. .. diff --git a/Misc/NEWS.d/next/C_API/2026-06-18-18-24-11.gh-issue-141510.-EOHJ1.rst b/Misc/NEWS.d/next/C_API/2026-06-18-18-24-11.gh-issue-141510.-EOHJ1.rst new file mode 100644 index 000000000000000..c77b462e97bdd1d --- /dev/null +++ b/Misc/NEWS.d/next/C_API/2026-06-18-18-24-11.gh-issue-141510.-EOHJ1.rst @@ -0,0 +1 @@ +Add :class:`frozendict` to the fast paths of :c:func:`PyMapping_GetOptionalItem`, :c:func:`PyMapping_Keys`, :c:func:`PyMapping_Values`, and :c:func:`PyMapping_Items`. diff --git a/Misc/NEWS.d/next/Library/2026-06-19-07-26-20.gh-issue-151695.IBDlkN.rst b/Misc/NEWS.d/next/Library/2026-06-19-07-26-20.gh-issue-151695.IBDlkN.rst new file mode 100644 index 000000000000000..f44cb6b93071656 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-06-19-07-26-20.gh-issue-151695.IBDlkN.rst @@ -0,0 +1,4 @@ +Fix a use-after-free in the :mod:`curses` module. The encoding of the initial +screen, used by :func:`curses.unctrl` and :func:`curses.ungetch` to encode +non-ASCII characters, is now kept as a private copy instead of a borrowed +pointer to a window object that may be deallocated. diff --git a/Modules/_cursesmodule.c b/Modules/_cursesmodule.c index 01cb6786e88aec4..02a8e2c1b1bc105 100644 --- a/Modules/_cursesmodule.c +++ b/Modules/_cursesmodule.c @@ -208,7 +208,11 @@ static int curses_initscr_called = FALSE; /* Tells whether start_color() has been called to initialise color usage. */ static int curses_start_color_called = FALSE; -static const char *curses_screen_encoding = NULL; +/* Encoding of the initial screen, used by module-level functions that have + no window object to take it from (e.g. unctrl(), ungetch()). This is a + private copy: the window object that initscr() returns may be deallocated + while these functions are still in use. */ +static char *curses_screen_encoding = NULL; /* Utility Error Procedures */ @@ -3799,6 +3803,21 @@ _curses_init_pair_impl(PyObject *module, int pair_number, int fg, int bg) Py_RETURN_NONE; } +/* Refresh the private copy of the screen encoding from a freshly created + stdscr window object. Returns 0 on success, -1 with an exception set. */ +static int +curses_update_screen_encoding(PyObject *winobj) +{ + char *copy = _PyMem_Strdup(((PyCursesWindowObject *)winobj)->encoding); + if (copy == NULL) { + PyErr_NoMemory(); + return -1; + } + PyMem_Free(curses_screen_encoding); + curses_screen_encoding = copy; + return 0; +} + /*[clinic input] _curses.initscr @@ -3820,7 +3839,15 @@ _curses_initscr_impl(PyObject *module) _curses_set_null_error(state, "wrefresh", "initscr"); return NULL; } - return PyCursesWindow_New(state, stdscr, NULL, NULL); + PyObject *winobj = PyCursesWindow_New(state, stdscr, NULL, NULL); + if (winobj == NULL) { + return NULL; + } + if (curses_update_screen_encoding(winobj) < 0) { + Py_DECREF(winobj); + return NULL; + } + return winobj; } win = initscr(); @@ -3927,7 +3954,10 @@ _curses_initscr_impl(PyObject *module) if (winobj == NULL) { return NULL; } - curses_screen_encoding = ((PyCursesWindowObject *)winobj)->encoding; + if (curses_update_screen_encoding(winobj) < 0) { + Py_DECREF(winobj); + return NULL; + } return winobj; } @@ -5480,6 +5510,8 @@ static void cursesmodule_free(void *mod) { (void)cursesmodule_clear((PyObject *)mod); + PyMem_Free(curses_screen_encoding); + curses_screen_encoding = NULL; curses_module_loaded = 0; // allow reloading once garbage-collected } diff --git a/Objects/abstract.c b/Objects/abstract.c index 48b3137152e7bf3..28f751965f36b94 100644 --- a/Objects/abstract.c +++ b/Objects/abstract.c @@ -208,7 +208,7 @@ PyObject_GetItem(PyObject *o, PyObject *key) int PyMapping_GetOptionalItem(PyObject *obj, PyObject *key, PyObject **result) { - if (PyDict_CheckExact(obj)) { + if (PyAnyDict_CheckExact(obj)) { return PyDict_GetItemRef(obj, key, result); } @@ -2462,7 +2462,7 @@ PyMapping_Keys(PyObject *o) if (o == NULL) { return null_error(); } - if (PyDict_CheckExact(o)) { + if (PyAnyDict_CheckExact(o)) { return PyDict_Keys(o); } return method_output_as_list(o, &_Py_ID(keys)); @@ -2474,7 +2474,7 @@ PyMapping_Items(PyObject *o) if (o == NULL) { return null_error(); } - if (PyDict_CheckExact(o)) { + if (PyAnyDict_CheckExact(o)) { return PyDict_Items(o); } return method_output_as_list(o, &_Py_ID(items)); @@ -2486,7 +2486,7 @@ PyMapping_Values(PyObject *o) if (o == NULL) { return null_error(); } - if (PyDict_CheckExact(o)) { + if (PyAnyDict_CheckExact(o)) { return PyDict_Values(o); } return method_output_as_list(o, &_Py_ID(values));