diff --git a/Lib/idlelib/CREDITS.txt b/Lib/idlelib/CREDITS.txt new file mode 100644 index 000000000..1b853e8cc --- /dev/null +++ b/Lib/idlelib/CREDITS.txt @@ -0,0 +1,48 @@ +Guido van Rossum, as well as being the creator of the Python language, is the +original creator of IDLE. Other contributors prior to Version 0.8 include +Mark Hammond, Jeremy Hylton, Tim Peters, and Moshe Zadka. + +Until Python 2.3, IDLE's development was carried out in the SF IDLEfork project. The +objective was to develop a version of IDLE which had an execution environment +which could be initialized prior to each run of user code. +IDLefork was merged into the Python code base in 2003. + +The IDLEfork project was initiated by David Scherer, with some help from Peter +Schneider-Kamp and Nicholas Riley. David wrote the first version of the RPC +code and designed a fast turn-around environment for VPython. Guido developed +the RPC code and Remote Debugger currently integrated in IDLE. Bruce Sherwood +contributed considerable time testing and suggesting improvements. + +Besides David and Guido, the main developers who were active on IDLEfork +are Stephen M. Gava, who implemented the configuration GUI, the new +configuration system, and the About dialog, and Kurt B. Kaiser, who completed +the integration of the RPC and remote debugger, implemented the threaded +subprocess, and made a number of usability enhancements. + +Other contributors include Raymond Hettinger, Tony Lownds (Mac integration), +Neal Norwitz (code check and clean-up), Ronald Oussoren (Mac integration), +Noam Raphael (Code Context, Call Tips, many other patches), and Chui Tey (RPC +integration, debugger integration and persistent breakpoints). + +Scott David Daniels, Tal Einat, Hernan Foffani, Christos Georgiou, +Jim Jewett, Martin v. Löwis, Jason Orendorff, Guilherme Polo, Josh Robb, +Nigel Rowe, Bruce Sherwood, Jeff Shute, and Weeble have submitted useful +patches. Thanks, guys! + +Major contributors since 2005: + +- 2005: Tal Einat +- 2010: Terry Jan Reedy (current maintainer) +- 2013: Roger Serwy +- 2014: Saimadhav Heblikar +- 2015: Mark Roseman +- 2017: Louie Lu, Cheryl Sabella, and Serhiy Storchaka +- 2025: Stan Ulbrych + +For additional details refer to NEWS.txt and Changelog. + +If we missed you, feel free to submit a PR with a summary of +contributions (for instance, at least 5 merged PRs). + + + diff --git a/Lib/idlelib/ChangeLog b/Lib/idlelib/ChangeLog new file mode 100644 index 000000000..c8960cfa5 --- /dev/null +++ b/Lib/idlelib/ChangeLog @@ -0,0 +1,1591 @@ +Please refer to the IDLEfork and IDLE CVS repositories for +change details subsequent to the 0.8.1 release. + + +IDLEfork ChangeLog +================== + +2001-07-20 11:35 elguavas + + * README.txt, NEWS.txt: bring up to date for 0.8.1 release + +2001-07-19 16:40 elguavas + + * IDLEFORK.html: replaced by IDLEFORK-index.html + +2001-07-19 16:39 elguavas + + * IDLEFORK-index.html: updated placeholder idlefork homepage + +2001-07-19 14:49 elguavas + + * ChangeLog, EditorWindow.py, INSTALLATION, NEWS.txt, README.txt, + TODO.txt, idlever.py: + minor tidy-ups ready for 0.8.1 alpha tarball release + +2001-07-17 15:12 kbk + + * INSTALLATION, setup.py: INSTALLATION: Remove the coexist.patch + instructions + + **************** setup.py: + + Remove the idles script, add some words on IDLE Fork to the + long_description, and clean up some line spacing. + +2001-07-17 15:01 kbk + + * coexist.patch: Put this in the attic, at least for now... + +2001-07-17 14:59 kbk + + * PyShell.py, idle, idles: Implement idle command interface as + suggested by GvR [idle-dev] 16 July **************** PyShell: Added + functionality: + + usage: idle.py [-c command] [-d] [-i] [-r script] [-s] [-t title] + [arg] ... + + idle file(s) (without options) edit the file(s) + + -c cmd run the command in a shell -d enable the + debugger -i open an interactive shell -i file(s) open a + shell and also an editor window for each file -r script run a file + as a script in a shell -s run $IDLESTARTUP or + $PYTHONSTARTUP before anything else -t title set title of shell + window + + Remaining arguments are applied to the command (-c) or script (-r). + + ****************** idles: Removed the idles script, not needed + + ****************** idle: Removed the IdleConf references, not + required anymore + +2001-07-16 17:08 kbk + + * INSTALLATION, coexist.patch: Added installation instructions. + + Added a patch which modifies idlefork so that it can co-exist with + "official" IDLE in the site-packages directory. This patch is not + necessary if only idlefork IDLE is installed. See INSTALLATION for + further details. + +2001-07-16 15:50 kbk + + * idles: Add a script "idles" which opens a Python Shell window. + + The default behaviour of idlefork idle is to open an editor window + instead of a shell. Complex expressions may be run in a fresh + environment by selecting "run". There are times, however, when a + shell is desired. Though one can be started by "idle -t 'foo'", + this script is more convenient. In addition, a shell and an editor + window can be started in parallel by "idles -e foo.py". + +2001-07-16 15:25 kbk + + * PyShell.py: Call out IDLE Fork in startup message. + +2001-07-16 14:00 kbk + + * PyShell.py, setup.py: Add a script "idles" which opens a Python + Shell window. + + The default behaviour of idlefork idle is to open an editor window + instead of a shell. Complex expressions may be run in a fresh + environment by selecting "run". There are times, however, when a + shell is desired. Though one can be started by "idle -t 'foo'", + this script is more convenient. In addition, a shell and an editor + window can be started in parallel by "idles -e foo.py". + +2001-07-15 03:06 kbk + + * pyclbr.py, tabnanny.py: tabnanny and pyclbr are now found in /Lib + +2001-07-15 02:29 kbk + + * BrowserControl.py: Remove, was retained for 1.5.2 support + +2001-07-14 15:48 kbk + + * setup.py: Installing Idle to site-packages via Distutils does not + copy the Idle help.txt file. + + Ref SF Python Patch 422471 + +2001-07-14 15:26 kbk + + * keydefs.py: py-cvs-2001_07_13 (Rev 1.3) merge + + "Make copy, cut and paste events case insensitive. Reported by + Patrick K. O'Brien on idle-dev. (Should other bindings follow + suit?)" --GvR + +2001-07-14 15:21 kbk + + * idle.py: py-cvs-2001_07_13 (Rev 1.4) merge + + "Move the action of loading the configuration to the IdleConf + module rather than the idle.py script. This has advantages and + disadvantages; the biggest advantage being that we can more easily + have an alternative main program." --GvR + +2001-07-14 15:18 kbk + + * extend.txt: py-cvs-2001_07_13 (Rev 1.4) merge + + "Quick update to the extension mechanism (extend.py is gone, long + live config.txt)" --GvR + +2001-07-14 15:15 kbk + + * StackViewer.py: py-cvs-2001_07_13 (Rev 1.16) merge + + "Refactored, with some future plans in mind. This now uses the new + gotofileline() method defined in FileList.py" --GvR + +2001-07-14 15:10 kbk + + * PyShell.py: py-cvs-2001_07_13 (Rev 1.34) merge + + "Amazing. A very subtle change in policy in descr-branch actually + found a bug here. Here's the deal: Class PyShell derives from + class OutputWindow. Method PyShell.close() wants to invoke its + parent method, but because PyShell long ago was inherited from + class PyShellEditorWindow, it invokes + PyShelEditorWindow.close(self). Now, class PyShellEditorWindow + itself derives from class OutputWindow, and inherits the close() + method from there without overriding it. Under the old rules, + PyShellEditorWindow.close would return an unbound method restricted + to the class that defined the implementation of close(), which was + OutputWindow.close. Under the new rules, the unbound method is + restricted to the class whose method was requested, that is + PyShellEditorWindow, and this was correctly trapped as an error." + --GvR + +2001-07-14 14:59 kbk + + * PyParse.py: py-cvs-2001_07_13 (Rel 1.9) merge + + "Taught IDLE's autoident parser that "yield" is a keyword that + begins a stmt. Along w/ the preceding change to keyword.py, making + all this work w/ a future-stmt just looks harder and harder." + --tim_one + + (From Rel 1.8: "Hack to make this still work with Python 1.5.2. + ;-( " --fdrake) + +2001-07-14 14:51 kbk + + * IdleConf.py: py-cvs-2001_07_13 (Rel 1.7) merge + + "Move the action of loading the configuration to the IdleConf + module rather than the idle.py script. This has advantages and + disadvantages; the biggest advantage being that we can more easily + have an alternative main program." --GvR + +2001-07-14 14:45 kbk + + * FileList.py: py-cvs-2000_07_13 (Rev 1.9) merge + + "Delete goodname() method, which is unused. Add gotofileline(), a + convenience method which I intend to use in a variant. Rename + test() to _test()." --GvR + + This was an interesting merge. The join completely missed removing + goodname(), which was adjacent, but outside of, a small conflict. + I only caught it by comparing the 1.1.3.2/1.1.3.3 diff. CVS ain't + infallible. + +2001-07-14 13:58 kbk + + * EditorWindow.py: py-cvs-2000_07_13 (Rev 1.38) merge "Remove + legacy support for the BrowserControl module; the webbrowser module + has been included since Python 2.0, and that is the preferred + interface." --fdrake + +2001-07-14 13:32 kbk + + * EditorWindow.py, FileList.py, IdleConf.py, PyParse.py, + PyShell.py, StackViewer.py, extend.txt, idle.py, keydefs.py: Import + the 2001 July 13 23:59 GMT version of Python CVS IDLE on the + existing 1.1.3 vendor branch named py-cvs-vendor-branch. Release + tag is py-cvs-2001_07_13. + +2001-07-14 12:02 kbk + + * Icons/python.gif: py-cvs-rel2_1 (Rev 1.2) merge Copied py-cvs rev + 1.2 changed file to idlefork MAIN + +2001-07-14 11:58 kbk + + * Icons/minusnode.gif: py-cvs-rel2_1 (Rev 1.2) merge Copied py-cvs + 1.2 changed file to idlefork MAIN + +2001-07-14 11:23 kbk + + * ScrolledList.py: py-cvs-rel2_1 (rev 1.5) merge - whitespace + normalization + +2001-07-14 11:20 kbk + + * Separator.py: py-cvs-rel2_1 (Rev 1.3) merge - whitespace + normalization + +2001-07-14 11:16 kbk + + * StackViewer.py: py-cvs-rel2_1 (Rev 1.15) merge - whitespace + normalization + +2001-07-14 11:14 kbk + + * ToolTip.py: py-cvs-rel2_1 (Rev 1.2) merge - whitespace + normalization + +2001-07-14 10:13 kbk + + * PyShell.py: cvs-py-rel2_1 (Rev 1.29 - 1.33) merge + + Merged the following py-cvs revs without conflict: 1.29 Reduce + copyright text output at startup 1.30 Delay setting sys.args until + Tkinter is fully initialized 1.31 Whitespace normalization 1.32 + Turn syntax warning into error when interactive 1.33 Fix warning + initialization bug + + Note that module is extensively modified wrt py-cvs + +2001-07-14 06:33 kbk + + * PyParse.py: py-cvs-rel2_1 (Rev 1.6 - 1.8) merge Fix autoindent + bug and deflect Unicode from text.get() + +2001-07-14 06:00 kbk + + * Percolator.py: py-cvs-rel2_1 (Rev 1.3) "move "from Tkinter import + *" to module level" --jhylton + +2001-07-14 05:57 kbk + + * PathBrowser.py: py-cvs-rel2_1 (Rev 1.6) merge - whitespace + normalization + +2001-07-14 05:49 kbk + + * ParenMatch.py: cvs-py-rel2_1 (Rev 1.5) merge - whitespace + normalization + +2001-07-14 03:57 kbk + + * ObjectBrowser.py: py-cvs-rel2_1 (Rev 1.3) merge "Make the test + program work outside IDLE." -- GvR + +2001-07-14 03:52 kbk + + * MultiStatusBar.py: py-cvs-rel2_1 (Rev 1.2) merge - whitespace + normalization + +2001-07-14 03:44 kbk + + * MultiScrolledLists.py: py-cvs-rel2_1 (Rev 1.2) merge - whitespace + normalization + +2001-07-14 03:40 kbk + + * IdleHistory.py: py-cvs-rel2_1 (Rev 1.4) merge - whitespace + normalization + +2001-07-14 03:38 kbk + + * IdleConf.py: py-cvs-rel2_1 (Rev 1.6) merge - whitespace + normalization + +2001-07-13 14:18 kbk + + * IOBinding.py: py-cvs-rel2_1 (Rev 1.4) merge - move "import *" to + module level + +2001-07-13 14:12 kbk + + * FormatParagraph.py: py-cvs-rel2_1 (Rev 1.9) merge - whitespace + normalization + +2001-07-13 14:07 kbk + + * FileList.py: py-cvs-rel2_1 (Rev 1.8) merge - whitespace + normalization + +2001-07-13 13:35 kbk + + * EditorWindow.py: py-cvs-rel2_1 (Rev 1.33 - 1.37) merge + + VP IDLE version depended on VP's ExecBinding.py and spawn.py to get + the path to the Windows Doc directory (relative to python.exe). + Removed this conflicting code in favor of py-cvs updates which on + Windows use a hard coded path relative to the location of this + module. py-cvs updates include support for webbrowser.py. Module + still has BrowserControl.py for 1.5.2 support. + + At this point, the differences wrt py-cvs relate to menu + functionality. + +2001-07-13 11:30 kbk + + * ConfigParser.py: py-cvs-rel2_1 merge - Remove, lives in /Lib + +2001-07-13 10:10 kbk + + * Delegator.py: py-cvs-rel2_1 (Rev 1.3) merge - whitespace + normalization + +2001-07-13 10:07 kbk + + * Debugger.py: py-cvs-rel2_1 (Rev 1.15) merge - whitespace + normalization + +2001-07-13 10:04 kbk + + * ColorDelegator.py: py-cvs-rel2_1 (Rev 1.11 and 1.12) merge + Colorize "as" after "import" / use DEBUG instead of __debug__ + +2001-07-13 09:54 kbk + + * ClassBrowser.py: py-cvs-rel2_1 (Rev 1.12) merge - whitespace + normalization + +2001-07-13 09:41 kbk + + * BrowserControl.py: py-cvs-rel2_1 (Rev 1.1) merge - New File - + Force HEAD to trunk with -f Note: browser.py was renamed + BrowserControl.py 10 May 2000. It provides a collection of classes + and convenience functions to control external browsers "for 1.5.2 + support". It was removed from py-cvs 18 April 2001. + +2001-07-13 09:10 kbk + + * CallTips.py: py-cvs-rel2_1 (Rev 1.8) merge - whitespace + normalization + +2001-07-13 08:26 kbk + + * CallTipWindow.py: py-cvs-rel2_1 (Rev 1.3) merge - whitespace + normalization + +2001-07-13 08:13 kbk + + * AutoExpand.py: py-cvs-rel1_2 (Rev 1.4) merge, "Add Alt-slash to + Unix keydefs (I somehow need it on RH 6.2). Get rid of assignment + to unused self.text.wordlist." --GvR + +2001-07-12 16:54 elguavas + + * ReplaceDialog.py: py-cvs merge, python 1.5.2 compatibility + +2001-07-12 16:46 elguavas + + * ScriptBinding.py: py-cvs merge, better error dialog + +2001-07-12 16:38 elguavas + + * TODO.txt: py-cvs merge, additions + +2001-07-12 15:35 elguavas + + * WindowList.py: py-cvs merge, correct indentation + +2001-07-12 15:24 elguavas + + * config.txt: py-cvs merge, correct typo + +2001-07-12 15:21 elguavas + + * help.txt: py-cvs merge, update colour changing info + +2001-07-12 14:51 elguavas + + * idle.py: py-cvs merge, idle_dir loading changed + +2001-07-12 14:44 elguavas + + * idlever.py: py-cvs merge, version update + +2001-07-11 12:53 kbk + + * BrowserControl.py: Initial revision + +2001-07-11 12:53 kbk + + * AutoExpand.py, BrowserControl.py, CallTipWindow.py, CallTips.py, + ClassBrowser.py, ColorDelegator.py, Debugger.py, Delegator.py, + EditorWindow.py, FileList.py, FormatParagraph.py, IOBinding.py, + IdleConf.py, IdleHistory.py, MultiScrolledLists.py, + MultiStatusBar.py, ObjectBrowser.py, OutputWindow.py, + ParenMatch.py, PathBrowser.py, Percolator.py, PyParse.py, + PyShell.py, RemoteInterp.py, ReplaceDialog.py, ScriptBinding.py, + ScrolledList.py, Separator.py, StackViewer.py, TODO.txt, + ToolTip.py, WindowList.py, config.txt, help.txt, idle, idle.bat, + idle.py, idlever.py, setup.py, Icons/minusnode.gif, + Icons/python.gif: Import the release 2.1 version of Python CVS IDLE + on the existing 1.1.3 vendor branch named py-cvs-vendor-branch, + with release tag py-cvs-rel2_1. + +2001-07-11 12:34 kbk + + * AutoExpand.py, AutoIndent.py, Bindings.py, CallTipWindow.py, + CallTips.py, ChangeLog, ClassBrowser.py, ColorDelegator.py, + Debugger.py, Delegator.py, EditorWindow.py, FileList.py, + FormatParagraph.py, FrameViewer.py, GrepDialog.py, IOBinding.py, + IdleConf.py, IdleHistory.py, MultiScrolledLists.py, + MultiStatusBar.py, NEWS.txt, ObjectBrowser.py, OldStackViewer.py, + OutputWindow.py, ParenMatch.py, PathBrowser.py, Percolator.py, + PyParse.py, PyShell.py, README.txt, RemoteInterp.py, + ReplaceDialog.py, ScriptBinding.py, ScrolledList.py, + SearchBinding.py, SearchDialog.py, SearchDialogBase.py, + SearchEngine.py, Separator.py, StackViewer.py, TODO.txt, + ToolTip.py, TreeWidget.py, UndoDelegator.py, WidgetRedirector.py, + WindowList.py, ZoomHeight.py, __init__.py, config-unix.txt, + config-win.txt, config.txt, eventparse.py, extend.txt, help.txt, + idle.bat, idle.py, idle.pyw, idlever.py, keydefs.py, pyclbr.py, + tabnanny.py, testcode.py, Icons/folder.gif, Icons/minusnode.gif, + Icons/openfolder.gif, Icons/plusnode.gif, Icons/python.gif, + Icons/tk.gif: Import the 9 March 2000 version of Python CVS IDLE as + 1.1.3 vendor branch named py-cvs-vendor-branch. + +2001-07-04 13:43 kbk + + * Icons/: folder.gif, minusnode.gif, openfolder.gif, plusnode.gif, + python.gif, tk.gif: Null commit with -f option to force an uprev + and put HEADs firmly on the trunk. + +2001-07-04 13:15 kbk + + * AutoExpand.py, AutoIndent.py, Bindings.py, CallTipWindow.py, + CallTips.py, ChangeLog, ClassBrowser.py, ColorDelegator.py, + ConfigParser.py, Debugger.py, Delegator.py, EditorWindow.py, + ExecBinding.py, FileList.py, FormatParagraph.py, FrameViewer.py, + GrepDialog.py, IDLEFORK.html, IOBinding.py, IdleConf.py, + IdleHistory.py, MultiScrolledLists.py, MultiStatusBar.py, NEWS.txt, + ObjectBrowser.py, OldStackViewer.py, OutputWindow.py, + ParenMatch.py, PathBrowser.py, Percolator.py, PyParse.py, + PyShell.py, README.txt, Remote.py, RemoteInterp.py, + ReplaceDialog.py, ScriptBinding.py, ScrolledList.py, + SearchBinding.py, SearchDialog.py, SearchDialogBase.py, + SearchEngine.py, Separator.py, StackViewer.py, TODO.txt, + ToolTip.py, TreeWidget.py, UndoDelegator.py, WidgetRedirector.py, + WindowList.py, ZoomHeight.py, __init__.py, config-unix.txt, + config-win.txt, config.txt, eventparse.py, extend.txt, help.txt, + idle, idle.bat, idle.py, idle.pyw, idlever.py, keydefs.py, + loader.py, protocol.py, pyclbr.py, setup.py, spawn.py, tabnanny.py, + testcode.py: Null commit with -f option to force an uprev and put + HEADs firmly on the trunk. + +2001-06-27 10:24 elguavas + + * IDLEFORK.html: updated contact details + +2001-06-25 17:23 elguavas + + * idle, RemoteInterp.py, setup.py: Initial revision + +2001-06-25 17:23 elguavas + + * idle, RemoteInterp.py, setup.py: import current python cvs idle + as a vendor branch + +2001-06-24 15:10 elguavas + + * IDLEFORK.html: tiny change to test new syncmail setup + +2001-06-24 14:41 elguavas + + * IDLEFORK.html: change to new developer contact, also a test + commit for new syncmail setup + +2001-06-23 18:15 elguavas + + * IDLEFORK.html: tiny test update for revitalised idle-fork + +2000-09-24 17:29 nriley + + * protocol.py: Fixes for Python 1.6 compatibility - socket bind and + connect get a tuple instead two arguments. + +2000-09-24 17:28 nriley + + * spawn.py: Change for Python 1.6 compatibility - UNIX's 'os' + module defines 'spawnv' now, so we check for 'fork' first. + +2000-08-15 22:51 nowonder + + * IDLEFORK.html: + corrected email address + +2000-08-15 22:47 nowonder + + * IDLEFORK.html: + added .html file for http://idlefork.sourceforge.net + +2000-08-15 11:13 dscherer + + * AutoExpand.py, AutoIndent.py, Bindings.py, CallTipWindow.py, + CallTips.py, __init__.py, ChangeLog, ClassBrowser.py, + ColorDelegator.py, ConfigParser.py, Debugger.py, Delegator.py, + FileList.py, FormatParagraph.py, FrameViewer.py, GrepDialog.py, + IOBinding.py, IdleConf.py, IdleHistory.py, MultiScrolledLists.py, + MultiStatusBar.py, NEWS.txt, ObjectBrowser.py, OldStackViewer.py, + OutputWindow.py, ParenMatch.py, PathBrowser.py, Percolator.py, + PyParse.py, PyShell.py, README.txt, ReplaceDialog.py, + ScriptBinding.py, ScrolledList.py, SearchBinding.py, + SearchDialog.py, SearchDialogBase.py, SearchEngine.py, + Separator.py, StackViewer.py, TODO.txt, ToolTip.py, TreeWidget.py, + UndoDelegator.py, WidgetRedirector.py, WindowList.py, help.txt, + ZoomHeight.py, config-unix.txt, config-win.txt, config.txt, + eventparse.py, extend.txt, idle.bat, idle.py, idle.pyw, idlever.py, + keydefs.py, loader.py, pyclbr.py, tabnanny.py, testcode.py, + EditorWindow.py, ExecBinding.py, Remote.py, protocol.py, spawn.py, + Icons/folder.gif, Icons/minusnode.gif, Icons/openfolder.gif, + Icons/plusnode.gif, Icons/python.gif, Icons/tk.gif: Initial + revision + +2000-08-15 11:13 dscherer + + * AutoExpand.py, AutoIndent.py, Bindings.py, CallTipWindow.py, + CallTips.py, __init__.py, ChangeLog, ClassBrowser.py, + ColorDelegator.py, ConfigParser.py, Debugger.py, Delegator.py, + FileList.py, FormatParagraph.py, FrameViewer.py, GrepDialog.py, + IOBinding.py, IdleConf.py, IdleHistory.py, MultiScrolledLists.py, + MultiStatusBar.py, NEWS.txt, ObjectBrowser.py, OldStackViewer.py, + OutputWindow.py, ParenMatch.py, PathBrowser.py, Percolator.py, + PyParse.py, PyShell.py, README.txt, ReplaceDialog.py, + ScriptBinding.py, ScrolledList.py, SearchBinding.py, + SearchDialog.py, SearchDialogBase.py, SearchEngine.py, + Separator.py, StackViewer.py, TODO.txt, ToolTip.py, TreeWidget.py, + UndoDelegator.py, WidgetRedirector.py, WindowList.py, help.txt, + ZoomHeight.py, config-unix.txt, config-win.txt, config.txt, + eventparse.py, extend.txt, idle.bat, idle.py, idle.pyw, idlever.py, + keydefs.py, loader.py, pyclbr.py, tabnanny.py, testcode.py, + EditorWindow.py, ExecBinding.py, Remote.py, protocol.py, spawn.py, + Icons/folder.gif, Icons/minusnode.gif, Icons/openfolder.gif, + Icons/plusnode.gif, Icons/python.gif, Icons/tk.gif: Modified IDLE + from VPython 0.2 + + +original IDLE ChangeLog: +======================== + +Tue Feb 15 18:08:19 2000 Guido van Rossum + + * NEWS.txt: Notice status bar and stack viewer. + + * EditorWindow.py: Support for Moshe's status bar. + + * MultiStatusBar.py: Status bar code -- by Moshe Zadka. + + * OldStackViewer.py: + Adding the old stack viewer implementation back, for the debugger. + + * StackViewer.py: New stack viewer, uses a tree widget. + (XXX: the debugger doesn't yet use this.) + + * WindowList.py: + Correct a typo and remove an unqualified except that was hiding the error. + + * ClassBrowser.py: Add an XXX comment about the ClassBrowser AIP. + + * ChangeLog: Updated change log. + + * NEWS.txt: News update. Probably incomplete; what else is new? + + * README.txt: + Updated for pending IDLE 0.5 release (still very rough -- just getting + it out in a more convenient format than CVS). + + * TODO.txt: Tiny addition. + +Thu Sep 9 14:16:02 1999 Guido van Rossum + + * TODO.txt: A few new TODO entries. + +Thu Aug 26 23:06:22 1999 Guido van Rossum + + * Bindings.py: Add Python Documentation entry to Help menu. + + * EditorWindow.py: + Find the help.txt file relative to __file__ or ".", not in sys.path. + (Suggested by Moshe Zadka, but implemented differently.) + + Add <> event which, on Unix, brings up Netscape pointing + to http://www.python.doc/current/ (a local copy would be nice but its + location can't be predicted). Windows solution TBD. + +Wed Aug 11 14:55:43 1999 Guido van Rossum + + * TreeWidget.py: + Moshe noticed an inconsistency in his comment, so I'm rephrasing it to + be clearer. + + * TreeWidget.py: + Patch inspired by Moshe Zadka to search for the Icons directory in the + same directory as __file__, rather than searching for it along sys.path. + This works better when idle is a package. + +Thu Jul 15 13:11:02 1999 Guido van Rossum + + * TODO.txt: New wishes. + +Sat Jul 10 13:17:35 1999 Guido van Rossum + + * IdlePrefs.py: + Make the color for stderr red (i.e. the standard warning/danger/stop + color) rather than green. Suggested by Sam Schulenburg. + +Fri Jun 25 17:26:34 1999 Guido van Rossum + + * PyShell.py: Close debugger when closing. This may break a cycle. + + * Debugger.py: Break cycle on close. + + * ClassBrowser.py: Destroy the tree when closing. + + * TreeWidget.py: Add destroy() method to recursively destroy a tree. + + * PyShell.py: Extend _close() to break cycles. + Break some other cycles too (and destroy the root when done). + + * EditorWindow.py: + Add _close() method that does the actual cleanup (close() asks the + user what they want first if there's unsaved stuff, and may cancel). + It closes more than before. + + Add unload_extensions() method to unload all extensions; called from + _close(). It calls an extension's close() method if it has one. + + * Percolator.py: Add close() method that breaks cycles. + + * WidgetRedirector.py: Add unregister() method. + Unregister everything at closing. + Don't call close() in __del__, rely on explicit call to close(). + + * IOBinding.py, FormatParagraph.py, CallTips.py: + Add close() method that breaks a cycle. + +Fri Jun 11 15:03:00 1999 Guido van Rossum + + * AutoIndent.py, EditorWindow.py, FormatParagraph.py: + Tim Peters smart.patch: + + EditorWindow.py: + + + Added get_tabwidth & set_tabwidth "virtual text" methods, that get/set the + widget's view of what a tab means. + + + Moved TK_TABWIDTH_DEFAULT here from AutoIndent. + + + Renamed Mark's get_selection_index to get_selection_indices (sorry, Mark, + but the name was plain wrong ). + + FormatParagraph.py: renamed use of get_selection_index. + + AutoIndent.py: + + + Moved TK_TABWIDTH_DEFAULT to EditorWindow. + + + Rewrote set_indentation_params to use new VTW get/set_tabwidth methods. + + + Changed smart_backspace_event to delete whitespace back to closest + preceding virtual tab stop or real character (note that this may require + inserting characters if backspacing over a tab!). + + + Nuked almost references to the selection tag, in favor of using + get_selection_indices. The sole exception is in set_region, for which no + "set_selection" abstraction has yet been agreed upon. + + + Had too much fun using the spiffy new features of the format-paragraph + cmd. + +Thu Jun 10 17:48:02 1999 Guido van Rossum + + * FormatParagraph.py: + Code by Mark Hammond to format paragraphs embedded in comments. + Read the comments (which I reformatted using the new feature :-) + for some limitations. + + * EditorWindow.py: + Added abstraction get_selection_index() (Mark Hammond). Also + reformatted some comment blocks to show off a cool feature I'm about + to check in next. + + * ClassBrowser.py: + Adapt to the new pyclbr's support of listing top-level functions. If + this functionality is not present (e.g. when used with a vintage + Python 1.5.2 installation) top-level functions are not listed. + + (Hmm... Any distribution of IDLE 0.5 should probably include a copy + of the new pyclbr.py!) + + * AutoIndent.py: + Fix off-by-one error in Tim's recent change to comment_region(): the + list of lines returned by get_region() contains an empty line at the + end representing the start of the next line, and this shouldn't be + commented out! + + * CallTips.py: + Mark Hammond writes: Here is another change that allows it to work for + class creation - tries to locate an __init__ function. Also updated + the test code to reflect your new "***" change. + + * CallTipWindow.py: + Mark Hammond writes: Tim's suggestion of copying the font for the + CallTipWindow from the text control makes sense, and actually makes + the control look better IMO. + +Wed Jun 9 20:34:57 1999 Guido van Rossum + + * CallTips.py: + Append "..." if the appropriate flag (for varargs) in co_flags is set. + Ditto "***" for kwargs. + +Tue Jun 8 13:06:07 1999 Guido van Rossum + + * ReplaceDialog.py: + Hmm... Tim didn't turn "replace all" into a single undo block. + I think I like it better if it os, so here. + + * ReplaceDialog.py: Tim Peters: made replacement atomic for undo/redo. + + * AutoIndent.py: Tim Peters: + + + Set usetabs=1. Editing pyclbr.py was driving me nuts <0.6 wink>. + usetabs=1 is the Emacs pymode default too, and thanks to indentwidth != + tabwidth magical usetabs disabling, new files are still created with tabs + turned off. The only implication is that if you open a file whose first + indent is a single tab, IDLE will now magically use tabs for that file (and + set indentwidth to 8). Note that the whole scheme doesn't work right for + PythonWin, though, since Windows users typically set tabwidth to 4; Mark + probably has to hide the IDLE algorithm from them (which he already knows). + + + Changed comment_region_event to stick "##" in front of every line. The + "holes" previously left on blank lines were visually confusing (made it + needlessly hard to figure out what to uncomment later). + +Mon Jun 7 15:38:40 1999 Guido van Rossum + + * TreeWidget.py, ObjectBrowser.py: + Remove unnecessary reference to pyclbr from test() code. + + * PyParse.py: Tim Peters: + + Smarter logic for finding a parse synch point. + + Does a half to a fifth the work in normal cases; don't notice the speedup, + but makes more breathing room for other extensions. + + Speeds terrible cases by at least a factor of 10. "Terrible" == e.g. you put + """ at the start of Tkinter.py, undo it, zoom to the bottom, and start + typing in code. Used to take about 8 seconds for ENTER to respond, now some + large fraction of a second. The new code gets indented correctly, despite + that it all remains "string colored" until the colorizer catches up (after + which, ENTER appears instantaneous again). + +Fri Jun 4 19:21:19 1999 Guido van Rossum + + * extend.py: Might as well enable CallTips by default. + If there are too many complaints I'll remove it again or fix it. + +Thu Jun 3 14:32:16 1999 Guido van Rossum + + * AutoIndent.py, EditorWindow.py, PyParse.py: + New offerings by Tim Peters; he writes: + + IDLE is now the first Python editor in the Universe not confused by my + doctest.py . + + As threatened, this defines IDLE's is_char_in_string function as a + method of EditorWindow. You just need to define one similarly in + whatever it is you pass as editwin to AutoIndent; looking at the + EditorWindow.py part of the patch should make this clear. + + * GrepDialog.py: Enclose pattern in quotes in status message. + + * CallTips.py: + Mark Hammond fixed some comments and improved the way the tip text is + constructed. + +Wed Jun 2 18:18:57 1999 Guido van Rossum + + * CallTips.py: + My fix to Mark's code: restore the universal check on . + Always cancel on or . + + * CallTips.py: + A version that Mark Hammond posted to the newsgroup. Has some newer + stuff for getting the tip. Had to fix the Key-( and Key-) events + for Unix. Will have to re-apply my patch for catching KeyRelease and + ButtonRelease events. + + * CallTipWindow.py, CallTips.py: + Call tips by Mark Hammond (plus tiny fix by me.) + + * IdleHistory.py: + Changes by Mark Hammond: (1) support optional output_sep argument to + the constructor so he can eliminate the sys.ps2 that PythonWin leaves + in the source; (2) remove duplicate history items. + + * AutoIndent.py: + Changes by Mark Hammond to allow using IDLE extensions in PythonWin as + well: make three dialog routines instance variables. + + * EditorWindow.py: + Change by Mark Hammond to allow using IDLE extensions in PythonWin as + well: make three dialog routines instance variables. + +Tue Jun 1 20:06:44 1999 Guido van Rossum + + * AutoIndent.py: Hah! A fix of my own to Tim's code! + Unix bindings for <> and <> were + missing, and somehow that meant the events were never generated, + even though they were in the menu. The new Unix bindings are now + the same as the Windows bindings (M-t and M-u). + + * AutoIndent.py, PyParse.py, PyShell.py: Tim Peters again: + + The new version (attached) is fast enough all the time in every real module + I have . You can make it slow by, e.g., creating an open list with + 5,000 90-character identifiers (+ trailing comma) each on its own line, then + adding an item to the end -- but that still consumes less than a second on + my P5-166. Response time in real code appears instantaneous. + + Fixed some bugs. + + New feature: when hitting ENTER and the cursor is beyond the line's leading + indentation, whitespace is removed on both sides of the cursor; before + whitespace was removed only on the left; e.g., assuming the cursor is + between the comma and the space: + + def something(arg1, arg2): + ^ cursor to the left of here, and hit ENTER + arg2): # new line used to end up here + arg2): # but now lines up the way you expect + + New hack: AutoIndent has grown a context_use_ps1 Boolean config option, + defaulting to 0 (false) and set to 1 (only) by PyShell. Reason: handling + the fancy stuff requires looking backward for a parsing synch point; ps1 + lines are the only sensible thing to look for in a shell window, but are a + bad thing to look for in a file window (ps1 lines show up in my module + docstrings often). PythonWin's shell should set this true too. + + Persistent problem: strings containing def/class can still screw things up + completely. No improvement. Simplest workaround is on the user's head, and + consists of inserting e.g. + + def _(): pass + + (or any other def/class) after the end of the multiline string that's + screwing them up. This is especially irksome because IDLE's syntax coloring + is *not* confused, so when this happens the colors don't match the + indentation behavior they see. + + * AutoIndent.py: Tim Peters again: + + [Tim, after adding some bracket smarts to AutoIndent.py] + > ... + > What it can't possibly do without reparsing large gobs of text is + > suggest a reasonable indent level after you've *closed* a bracket + > left open on some previous line. + > ... + + The attached can, and actually fast enough to use -- most of the time. The + code is tricky beyond belief to achieve that, but it works so far; e.g., + + return len(string.expandtabs(str[self.stmt_start : + ^ indents to caret + i], + ^ indents to caret + self.tabwidth)) + 1 + ^ indents to caret + + It's about as smart as pymode now, wrt both bracket and backslash + continuation rules. It does require reparsing large gobs of text, and if it + happens to find something that looks like a "def" or "class" or sys.ps1 + buried in a multiline string, but didn't suck up enough preceding text to + see the start of the string, it's completely hosed. I can't repair that -- + it's just too slow to reparse from the start of the file all the time. + + AutoIndent has grown a new num_context_lines tuple attribute that controls + how far to look back, and-- like other params --this could/should be made + user-overridable at startup and per-file on the fly. + + * PyParse.py: New file by Tim Peters: + + One new file in the attached, PyParse.py. The LineStudier (whatever it was + called ) class was removed from AutoIndent; PyParse subsumes its + functionality. + + * AutoIndent.py: Tim Peters keeps revising this module (more to come): + + Removed "New tabwidth" menu binding. + + Added "a tab means how many spaces?" dialog to block tabify and untabify. I + think prompting for this is good now: they're usually at-most-once-per-file + commands, and IDLE can't let them change tabwidth from the Tk default + anymore, so IDLE can no longer presume to have any idea what a tab means. + + Irony: for the purpose of keeping comments aligned via tabs, Tk's + non-default approach is much nicer than the Emacs/Notepad/Codewright/vi/etc + approach. + + * EditorWindow.py: + 1. Catch NameError on import (could be raised by case mismatch on Windows). + 2. No longer need to reset pyclbr cache and show watch cursor when calling + ClassBrowser -- the ClassBrowser takes care of pyclbr and the TreeWidget + takes care of the watch cursor. + 3. Reset the focus to the current window after error message about class + browser on buffer without filename. + + * Icons/minusnode.gif, Icons/plusnode.gif: Missed a few. + + * ClassBrowser.py, PathBrowser.py: Rewritten based on TreeWidget.py + + * ObjectBrowser.py: Object browser, based on TreeWidget.py. + + * TreeWidget.py: Tree widget done right. + + * ToolTip.py: As yet unused code for tool tips. + + * ScriptBinding.py: + Ensure sys.argv[0] is the script name on Run Script. + + * ZoomHeight.py: Move zoom height functionality to separate function. + + * Icons/folder.gif, Icons/openfolder.gif, Icons/python.gif, Icons/tk.gif: + A few icons used by ../TreeWidget.py and its callers. + + * AutoIndent.py: New version by Tim Peters improves block opening test. + +Fri May 21 04:46:17 1999 Guido van Rossum + + * Attic/History.py, PyShell.py: Rename History to IdleHistory. + Add isatty() to pseudo files. + + * StackViewer.py: Make initial stack viewer wider + + * TODO.txt: New wishes + + * AutoIndent.py, EditorWindow.py, PyShell.py: + Much improved autoindent and handling of tabs, + by Tim Peters. + +Mon May 3 15:49:52 1999 Guido van Rossum + + * AutoIndent.py, EditorWindow.py, FormatParagraph.py, UndoDelegator.py: + Tim Peters writes: + + I'm still unsure, but couldn't stand the virtual event trickery so tried a + different sin (adding undo_block_start/stop methods to the Text instance in + EditorWindow.py). Like it or not, it's efficient and works . Better + idea? + + Give the attached a whirl. Even if you hate the implementation, I think + you'll like the results. Think I caught all the "block edit" cmds, + including Format Paragraph, plus subtler ones involving smart indents and + backspacing. + + * WidgetRedirector.py: Tim Peters writes: + + [W]hile trying to dope out how redirection works, stumbled into two + possible glitches. In the first, it doesn't appear to make sense to try to + rename a command that's already been destroyed; in the second, the name + "previous" doesn't really bring to mind "ignore the previous value" . + +Fri Apr 30 19:39:25 1999 Guido van Rossum + + * __init__.py: Support for using idle as a package. + + * PathBrowser.py: + Avoid listing files more than once (e.g. foomodule.so has two hits: + once for foo + module.so, once for foomodule + .so). + +Mon Apr 26 22:20:38 1999 Guido van Rossum + + * ChangeLog, ColorDelegator.py, PyShell.py: Tim Peters strikes again: + + Ho ho ho -- that's trickier than it sounded! The colorizer is working with + "line.col" strings instead of Text marks, and the absolute coordinates of + the point of interest can change across the self.update call (voice of + baffled experience, when two quick backspaces no longer fooled it, but a + backspace followed by a quick ENTER did ). + + Anyway, the attached appears to do the trick. CPU usage goes way up when + typing quickly into a long triple-quoted string, but the latency is fine for + me (a relatively fast typist on a relatively slow machine). Most of the + changes here are left over from reducing the # of vrbl names to help me + reason about the logic better; I hope the code is a *little* easier to + +Fri Apr 23 14:01:25 1999 Guido van Rossum + + * EditorWindow.py: + Provide full arguments to __import__ so it works in packagized IDLE. + +Thu Apr 22 23:20:17 1999 Guido van Rossum + + * help.txt: + Bunch of updates necessary due to recent changes; added docs for File + menu, command line and color preferences. + + * Bindings.py: Remove obsolete 'script' menu. + + * TODO.txt: Several wishes fulfilled. + + * OutputWindow.py: + Moved classes OnDemandOutputWindow and PseudoFile here, + from ScriptBinding.py where they are no longer needed. + + * ScriptBinding.py: + Mostly rewritten. Instead of the old Run module and Debug module, + there are two new commands: + + Import module (F5) imports or reloads the module and also adds its + name to the __main__ namespace. This gets executed in the PyShell + window under control of its debug settings. + + Run script (Control-F5) is similar but executes the contents of the + file directly in the __main__ namespace. + + * PyShell.py: Nits: document use of $IDLESTARTUP; display idle version + + * idlever.py: New version to celebrate new command line + + * OutputWindow.py: Added flush(), for completeness. + + * PyShell.py: + A lot of changes to make the command line more useful. You can now do: + idle.py -e file ... -- to edit files + idle.py script arg ... -- to run a script + idle.py -c cmd arg ... -- to run a command + Other options, see also the usage message (also new!) for more details: + -d -- enable debugger + -s -- run $IDLESTARTUP or $PYTHONSTARTUP + -t title -- set Python Shell window's title + sys.argv is set accordingly, unless -e is used. + sys.path is absolutized, and all relevant paths are inserted into it. + + Other changes: + - the environment in which commands are executed is now the + __main__ module + - explicitly save sys.stdout etc., don't restore from sys.__stdout__ + - new interpreter methods execsource(), execfile(), stuffsource() + - a few small nits + + * TODO.txt: + Some more TODO items. Made up my mind about command line args, + Run/Import, __main__. + + * ColorDelegator.py: + Super-elegant patch by Tim Peters that speeds up colorization + dramatically (up to 15 times he claims). Works by reading more than + one line at a time, up to 100-line chunks (starting with one line and + then doubling up to the limit). On a typical machine (e.g. Tim's + P5-166) this doesn't reduce interactive responsiveness in a noticeable + way. + +Wed Apr 21 15:49:34 1999 Guido van Rossum + + * ColorDelegator.py: + Patch by Tim Peters to speed up colorizing of big multiline strings. + +Tue Apr 20 17:32:52 1999 Guido van Rossum + + * extend.txt: + For an event 'foo-bar', the corresponding method must be called + foo_bar_event(). Therefore, fix the references to zoom_height() in + the example. + + * IdlePrefs.py: Restored the original IDLE color scheme. + + * PyShell.py, IdlePrefs.py, ColorDelegator.py, EditorWindow.py: + Color preferences code by Loren Luke (massaged by me somewhat) + + * SearchEngine.py: + Patch by Mark Favas: it fixes the search engine behaviour where an + unsuccessful search wraps around and re-searches that part of the file + between the start of the search and the end of the file - only really + an issue for very large files, but... (also removes a redundant + m.span() call). + +Mon Apr 19 16:26:02 1999 Guido van Rossum + + * TODO.txt: A few wishes are now fulfilled. + + * AutoIndent.py: Tim Peters implements some of my wishes: + + o Makes the tab key intelligently insert spaces when appropriate + (see Help list banter twixt David Ascher and me; idea stolen from + every other editor on earth ). + + o newline_and_indent_event trims trailing whitespace on the old + line (pymode and Codewright). + + o newline_and_indent_event no longer fooled by trailing whitespace or + comment after ":" (pymode, PTUI). + + o newline_and_indent_event now reduces the new line's indentation after + return, break, continue, raise and pass stmts (pymode). + + The last two are easy to fool in the presence of strings & + continuations, but pymode requires Emacs's high-powered C parsing + functions to avoid that in finite time. + +====================================================================== + Python release 1.5.2c1, IDLE version 0.4 +====================================================================== + +Wed Apr 7 18:41:59 1999 Guido van Rossum + + * README.txt, NEWS.txt: New version. + + * idlever.py: Version bump awaiting impending new release. + (Not much has changed :-( ) + +Mon Mar 29 14:52:28 1999 Guido van Rossum + + * ScriptBinding.py, PyShell.py: + At Tim Peters' recommendation, add a dummy flush() method to + PseudoFile. + +Thu Mar 11 23:21:23 1999 Guido van Rossum + + * PathBrowser.py: Don't crash when sys.path contains an empty string. + + * Attic/Outline.py: This file was never supposed to be part of IDLE. + + * PathBrowser.py: + - Don't crash in the case where a superclass is a string instead of a + pyclbr.Class object; this can happen when the superclass is + unrecognizable (to pyclbr), e.g. when module renaming is used. + + - Show a watch cursor when calling pyclbr (since it may take a while + recursively parsing imported modules!). + +Wed Mar 10 05:18:02 1999 Guido van Rossum + + * EditorWindow.py, Bindings.py: Add PathBrowser to File module + + * PathBrowser.py: "Path browser" - 4 scrolled lists displaying: + directories on sys.path + modules in selected directory + classes in selected module + methods of selected class + + Single clicking in a directory, module or class item updates the next + column with info about the selected item. Double clicking in a + module, class or method item opens the file (and selects the clicked + item if it is a class or method). + + I guess eventually I should be using a tree widget for this, but the + ones I've seen don't work well enough, so for now I use the old + Smalltalk or NeXT style multi-column hierarchical browser. + + * MultiScrolledLists.py: + New utility: multiple scrolled lists in parallel + + * ScrolledList.py: - White background. + - Display "(None)" (or text of your choosing) when empty. + - Don't set the focus. + +====================================================================== + Python release 1.5.2b2, IDLE version 0.3 +====================================================================== + +Wed Feb 17 22:47:41 1999 Guido van Rossum + + * NEWS.txt: News in 0.3. + + * README.txt, idlever.py: Bump version to 0.3. + + * EditorWindow.py: + After all, we don't need to call the callbacks ourselves! + + * WindowList.py: + When deleting, call the callbacks *after* deleting the window from our list! + + * EditorWindow.py: + Fix up the Windows menu via the new callback mechanism instead of + depending on menu post commands (which don't work when the menu is + torn off). + + * WindowList.py: + Support callbacks to patch up Windows menus everywhere. + + * ChangeLog: Oh, why not. Checking in the Emacs-generated change log. + +Tue Feb 16 22:34:17 1999 Guido van Rossum + + * ScriptBinding.py: + Only pop up the stack viewer when requested in the Debug menu. + +Mon Feb 8 22:27:49 1999 Guido van Rossum + + * WindowList.py: Don't crash if a window no longer exists. + + * TODO.txt: Restructured a bit. + +Mon Feb 1 23:06:17 1999 Guido van Rossum + + * PyShell.py: Add current dir or paths of file args to sys.path. + + * Debugger.py: Add canonic() function -- for brand new bdb.py feature. + + * StackViewer.py: Protect against accessing an empty stack. + +Fri Jan 29 20:44:45 1999 Guido van Rossum + + * ZoomHeight.py: + Use only the height to decide whether to zoom in or out. + +Thu Jan 28 22:24:30 1999 Guido van Rossum + + * EditorWindow.py, FileList.py: + Make sure the Tcl variables are shared between windows. + + * PyShell.py, EditorWindow.py, Bindings.py: + Move menu/key binding code from Bindings.py to EditorWindow.py, + with changed APIs -- it makes much more sense there. + Also add a new feature: if the first character of a menu label is + a '!', it gets a checkbox. Checkboxes are bound to Boolean Tcl variables + that can be accessed through the new getvar/setvar/getrawvar API; + the variable is named after the event to which the menu is bound. + + * Debugger.py: Add Quit button to the debugger window. + + * SearchDialog.py: + When find_again() finds exactly the current selection, it's a failure. + + * idle.py, Attic/idle: Rename idle -> idle.py + +Mon Jan 18 15:18:57 1999 Guido van Rossum + + * EditorWindow.py, WindowList.py: Only deiconify when iconic. + + * TODO.txt: Misc + +Tue Jan 12 22:14:34 1999 Guido van Rossum + + * testcode.py, Attic/test.py: + Renamed test.py to testcode.py so one can import Python's + test package from inside IDLE. (Suggested by Jack Jansen.) + + * EditorWindow.py, ColorDelegator.py: + Hack to close a window that is colorizing. + + * Separator.py: Vladimir Marangozov's patch: + The separator dances too much and seems to jump by arbitrary amounts + in arbitrary directions when I try to move it for resizing the frames. + This patch makes it more quiet. + +Mon Jan 11 14:52:40 1999 Guido van Rossum + + * TODO.txt: Some requests have been fulfilled. + + * EditorWindow.py: + Set the cursor to a watch when opening the class browser (which may + take quite a while, browsing multiple files). + + Newer, better center() -- but assumes no wrapping. + + * SearchBinding.py: + Got rid of debug print statement in goto_line_event(). + + * ScriptBinding.py: + I think I like it better if it prints the traceback even when it displays + the stack viewer. + + * Debugger.py: Bind ESC to close-window. + + * ClassBrowser.py: Use a HSeparator between the classes and the items. + Make the list of classes wider by default (40 chars). + Bind ESC to close-window. + + * Separator.py: + Separator classes (draggable divider between two panes). + +Sat Jan 9 22:01:33 1999 Guido van Rossum + + * WindowList.py: + Don't traceback when wakeup() is called when the window has been destroyed. + This can happen when a torn-of Windows menu references closed windows. + And Tim Peters claims that the Windows menu is his favorite to tear off... + + * EditorWindow.py: Allow tearing off of the Windows menu. + + * StackViewer.py: Close on ESC. + + * help.txt: Updated a bunch of things (it was mostly still 0.1!) + + * extend.py: Added ScriptBinding to standard bindings. + + * ScriptBinding.py: + This now actually works. See doc string. It can run a module (i.e. + import or reload) or debug it (same with debugger control). Output + goes to a fresh output window, only created when needed. + +====================================================================== + Python release 1.5.2b1, IDLE version 0.2 +====================================================================== + +Fri Jan 8 17:26:02 1999 Guido van Rossum + + * README.txt, NEWS.txt: What's new in this release. + + * Bindings.py, PyShell.py: + Paul Prescod's patches to allow the stack viewer to pop up when a + traceback is printed. + +Thu Jan 7 00:12:15 1999 Guido van Rossum + + * FormatParagraph.py: + Change paragraph width limit to 70 (like Emacs M-Q). + + * README.txt: + Separating TODO from README. Slight reformulation of features. No + exact release date. + + * TODO.txt: Separating TODO from README. + +Mon Jan 4 21:19:09 1999 Guido van Rossum + + * FormatParagraph.py: + Hm. There was a boundary condition error at the end of the file too. + + * SearchBinding.py: Hm. Add Unix binding for replace, too. + + * keydefs.py: Ran eventparse.py again. + + * FormatParagraph.py: Added Unix Meta-q key binding; + fix find_paragraph when at start of file. + + * AutoExpand.py: Added Meta-/ binding for Unix as alt for Alt-/. + + * SearchBinding.py: + Add unix binding for grep (otherwise the menu entry doesn't work!) + + * ZoomHeight.py: Adjusted Unix height to work with fvwm96. :=( + + * GrepDialog.py: Need to import sys! + + * help.txt, extend.txt, README.txt: Formatted some paragraphs + + * extend.py, FormatParagraph.py: + Add new extension to reformat a (text) paragraph. + + * ZoomHeight.py: Typo in Win specific height setting. + +Sun Jan 3 00:47:35 1999 Guido van Rossum + + * AutoIndent.py: Added something like Tim Peters' backspace patch. + + * ZoomHeight.py: Adapted to Unix (i.e., more hardcoded constants). + +Sat Jan 2 21:28:54 1999 Guido van Rossum + + * keydefs.py, idlever.py, idle.pyw, idle.bat, help.txt, extend.txt, extend.py, eventparse.py, ZoomHeight.py, WindowList.py, UndoDelegator.py, StackViewer.py, SearchEngine.py, SearchDialogBase.py, SearchDialog.py, ScrolledList.py, SearchBinding.py, ScriptBinding.py, ReplaceDialog.py, Attic/README, README.txt, PyShell.py, Attic/PopupMenu.py, OutputWindow.py, IOBinding.py, Attic/HelpWindow.py, History.py, GrepDialog.py, FileList.py, FrameViewer.py, EditorWindow.py, Debugger.py, Delegator.py, ColorDelegator.py, Bindings.py, ClassBrowser.py, AutoExpand.py, AutoIndent.py: + Checking in IDLE 0.2. + + Much has changed -- too much, in fact, to write down. + The big news is that there's a standard way to write IDLE extensions; + see extend.txt. Some sample extensions have been provided, and + some existing code has been converted to extensions. Probably the + biggest new user feature is a new search dialog with more options, + search and replace, and even search in files (grep). + + This is exactly as downloaded from my laptop after returning + from the holidays -- it hasn't even been tested on Unix yet. + +Fri Dec 18 15:52:54 1998 Guido van Rossum + + * FileList.py, ClassBrowser.py: + Fix the class browser to work even when the file is not on sys.path. + +Tue Dec 8 20:39:36 1998 Guido van Rossum + + * Attic/turtle.py: Moved to Python 1.5.2/Lib + +Fri Nov 27 03:19:20 1998 Guido van Rossum + + * help.txt: Typo + + * EditorWindow.py, FileList.py: Support underlining of menu labels + + * Bindings.py: + New approach, separate tables for menus (platform-independent) and key + definitions (platform-specific), and generating accelerator strings + automatically from the key definitions. + +Mon Nov 16 18:37:42 1998 Guido van Rossum + + * Attic/README: Clarify portability and main program. + + * Attic/README: Added intro for 0.1 release and append Grail notes. + +Mon Oct 26 18:49:00 1998 Guido van Rossum + + * Attic/turtle.py: root is now a global called _root + +Sat Oct 24 16:38:38 1998 Guido van Rossum + + * Attic/turtle.py: Raise the root window on reset(). + Different action on WM_DELETE_WINDOW is more likely to do the right thing, + allowing us to destroy old windows. + + * Attic/turtle.py: + Split the goto() function in two: _goto() is the internal one, + using Canvas coordinates, and goto() uses turtle coordinates + and accepts variable argument lists. + + * Attic/turtle.py: Cope with destruction of the window + + * Attic/turtle.py: Turtle graphics + + * Debugger.py: Use of Breakpoint class should be bdb.Breakpoint. + +Mon Oct 19 03:33:40 1998 Guido van Rossum + + * SearchBinding.py: + Speed up the search a bit -- don't drag a mark around... + + * PyShell.py: + Change our special entries from to . + Patch linecache.checkcache() to keep our special entries alive. + Add popup menu to all editor windows to set a breakpoint. + + * Debugger.py: + Use and pass through the 'force' flag to set_dict() where appropriate. + Default source and globals checkboxes to false. + Don't interact in user_return(). + Add primitive set_breakpoint() method. + + * ColorDelegator.py: + Raise priority of 'sel' tag so its foreground (on Windows) will take + priority over text colorization (which on Windows is almost the + same color as the selection background). + + Define a tag and color for breakpoints ("BREAK"). + + * Attic/PopupMenu.py: Disable "Open stack viewer" and "help" commands. + + * StackViewer.py: + Add optional 'force' argument (default 0) to load_dict(). + If set, redo the display even if it's the same dict. + +Fri Oct 16 21:10:12 1998 Guido van Rossum + + * StackViewer.py: Do nothing when loading the same dict as before. + + * PyShell.py: Details for debugger interface. + + * Debugger.py: + Restructured and more consistent. Save checkboxes across instantiations. + + * EditorWindow.py, Attic/README, Bindings.py: + Get rid of conflicting ^X binding. Use ^W. + + * Debugger.py, StackViewer.py: + Debugger can now show local and global variables. + + * Debugger.py: Oops + + * Debugger.py, PyShell.py: Better debugger support (show stack etc). + + * Attic/PopupMenu.py: Follow renames in StackViewer module + + * StackViewer.py: + Rename classes to StackViewer (the widget) and StackBrowser (the toplevel). + + * ScrolledList.py: Add close() method + + * EditorWindow.py: Clarify 'Open Module' dialog text + + * StackViewer.py: Restructured into a browser and a widget. + +Thu Oct 15 23:27:08 1998 Guido van Rossum + + * ClassBrowser.py, ScrolledList.py: + Generalized the scrolled list which is the base for the class and + method browser into a separate class in its own module. + + * Attic/test.py: Cosmetic change + + * Debugger.py: Don't show function name if there is none + +Wed Oct 14 03:43:05 1998 Guido van Rossum + + * Debugger.py, PyShell.py: Polish the Debugger GUI a bit. + Closing it now also does the right thing. + +Tue Oct 13 23:51:13 1998 Guido van Rossum + + * Debugger.py, PyShell.py, Bindings.py: + Ad primitive debugger interface (so far it will step and show you the + source, but it doesn't yet show the stack). + + * Attic/README: Misc + + * StackViewer.py: Whoops -- referenced self.top before it was set. + + * help.txt: Added history and completion commands. + + * help.txt: Updated + + * FileList.py: Add class browser functionality. + + * StackViewer.py: + Add a close() method and bind to WM_DELETE_WINDOW protocol + + * PyShell.py: Clear the linecache before printing a traceback + + * Bindings.py: Added class browser binding. + + * ClassBrowser.py: Much improved, much left to do. + + * PyShell.py: Make the return key do what I mean more often. + + * ClassBrowser.py: + Adding the beginnings of a Class browser. Incomplete, yet. + + * EditorWindow.py, Bindings.py: + Add new command, "Open module". You select or type a module name, + and it opens the source. + +Mon Oct 12 23:59:27 1998 Guido van Rossum + + * PyShell.py: Subsume functionality from Popup menu in Debug menu. + Other stuff so the PyShell window can be resurrected from the Windows menu. + + * FileList.py: Get rid of PopUp menu. + Create a simple Windows menu. (Imperfect when Untitled windows exist.) + Add wakeup() method: deiconify, raise, focus. + + * EditorWindow.py: Generalize menu creation. + + * Bindings.py: Add Debug and Help menu items. + + * EditorWindow.py: Added a menu bar to every window. + + * Bindings.py: Add menu configuration to the event configuration. + + * Attic/PopupMenu.py: Pass a root to the help window. + + * SearchBinding.py: + Add parent argument to 'go to line number' dialog box. + +Sat Oct 10 19:15:32 1998 Guido van Rossum + + * StackViewer.py: + Add a label at the top showing (very basic) help for the stack viewer. + Add a label at the bottom showing the exception info. + + * Attic/test.py, Attic/idle: Add Unix main script and test program. + + * idle.pyw, help.txt, WidgetRedirector.py, UndoDelegator.py, StackViewer.py, SearchBinding.py, Attic/README, PyShell.py, Attic/PopupMenu.py, Percolator.py, Outline.py, IOBinding.py, History.py, Attic/HelpWindow.py, FrameViewer.py, FileList.py, EditorWindow.py, Delegator.py, ColorDelegator.py, Bindings.py, AutoIndent.py, AutoExpand.py: + Initial checking of Tk-based Python IDE. + Features: text editor with syntax coloring and undo; + subclassed into interactive Python shell which adds history. + diff --git a/Lib/idlelib/HISTORY.txt b/Lib/idlelib/HISTORY.txt new file mode 100644 index 000000000..8bd4e8902 --- /dev/null +++ b/Lib/idlelib/HISTORY.txt @@ -0,0 +1,296 @@ +IDLE History from Python 1.5.2 to 2.1 +===================================== + +This file contains the release messages for previous IDLE releases. +As you read on you go back to the dark ages of IDLE's history. + + +What's New in IDLEfork 0.8.1? +============================= + +*Release date: 22-Jul-2001* + +- New tarball released as a result of the 'revitalisation' of the IDLEfork + project. + +- This release requires python 2.1 or better. Compatibility with earlier + versions of python (especially ancient ones like 1.5x) is no longer a + priority in IDLEfork development. + +- This release is based on a merging of the earlier IDLE fork work with current + cvs IDLE (post IDLE version 0.8), with some minor additional coding by Kurt + B. Kaiser and Stephen M. Gava. + +- This release is basically functional but also contains some known breakages, + particularly with running things from the shell window. Also the debugger is + not working, but I believe this was the case with the previous IDLE fork + release (0.7.1) as well. + +- This release is being made now to mark the point at which IDLEfork is + launching into a new stage of development. + +- IDLEfork CVS will now be branched to enable further development and + exploration of the two "execution in a remote process" patches submitted by + David Scherer (David's is currently in IDLEfork) and GvR, while stabilisation + and development of less heavyweight improvements (like user customisation) + can continue on the trunk. + + +What's New in IDLEfork 0.7.1? +============================== + +*Release date: 15-Aug-2000* + +- First project tarball released. + +- This was the first release of IDLE fork, which at this stage was a + combination of IDLE 0.5 and the VPython idle fork, with additional changes + coded by David Scherer, Peter Schneider-Kamp and Nicholas Riley. + + + +IDLEfork 0.7.1 - 29 May 2000 +----------------------------- + + David Scherer + +- This is a modification of the CVS version of IDLE 0.5, updated as of + 2000-03-09. It is alpha software and might be unstable. If it breaks, you + get to keep both pieces. + +- If you have problems or suggestions, you should either contact me or post to + the idle-dev mailing list (making it clear that you are using this modified + version of IDLE). + +- Changes: + + - The ExecBinding module, a replacement for ScriptBinding, executes programs + in a separate process, piping standard I/O through an RPC mechanism to an + OnDemandOutputWindow in IDLE. It supports executing unnamed programs + (through a temporary file). It does not yet support debugging. + + - When running programs with ExecBinding, tracebacks will be clipped to + exclude system modules. If, however, a system module calls back into the + user program, that part of the traceback will be shown. + + - The OnDemandOutputWindow class has been improved. In particular, it now + supports a readline() function used to implement user input, and a + scroll_clear() operation which is used to hide the output of a previous run + by scrolling it out of the window. + + - Startup behavior has been changed. By default IDLE starts up with just a + blank editor window, rather than an interactive window. Opening a file in + such a blank window replaces the (nonexistent) contents of that window + instead of creating another window. Because of the need to have a + well-known port for the ExecBinding protocol, only one copy of IDLE can be + running. Additional invocations use the RPC mechanism to report their + command line arguments to the copy already running. + + - The menus have been reorganized. In particular, the excessively large + 'edit' menu has been split up into 'edit', 'format', and 'run'. + + - 'Python Documentation' now works on Windows, if the win32api module is + present. + + - A few key bindings have been changed: F1 now loads Python Documentation + instead of the IDLE help; shift-TAB is now a synonym for unindent. + +- New modules: + + ExecBinding.py Executes program through loader + loader.py Bootstraps user program + protocol.py RPC protocol + Remote.py User-process interpreter + spawn.py OS-specific code to start programs + +- Files modified: + + autoindent.py ( bindings tweaked ) + bindings.py ( menus reorganized ) + config.txt ( execbinding enabled ) + editorwindow.py ( new menus, fixed 'Python Documentation' ) + filelist.py ( hook for "open in same window" ) + formatparagraph.py ( bindings tweaked ) + idle.bat ( removed absolute pathname ) + idle.pyw ( weird bug due to import with same name? ) + iobinding.py ( open in same window, EOL convention ) + keydefs.py ( bindings tweaked ) + outputwindow.py ( readline, scroll_clear, etc ) + pyshell.py ( changed startup behavior ) + readme.txt ( ) + + + +IDLE 0.5 - February 2000 - Release Notes +---------------------------------------- + +This is an early release of IDLE, my own attempt at a Tkinter-based +IDE for Python. + +(For a more detailed change log, see the file ChangeLog.) + +FEATURES + +IDLE has the following features: + +- coded in 100% pure Python, using the Tkinter GUI toolkit (i.e. Tcl/Tk) + +- cross-platform: works on Windows and Unix (on the Mac, there are +currently problems with Tcl/Tk) + +- multi-window text editor with multiple undo, Python colorizing +and many other features, e.g. smart indent and call tips + +- Python shell window (a.k.a. interactive interpreter) + +- debugger (not complete, but you can set breakpoints, view and step) + +USAGE + +The main program is in the file "idle.py"; on Unix, you should be able +to run it by typing "./idle.py" to your shell. On Windows, you can +run it by double-clicking it; you can use idle.pyw to avoid popping up +a DOS console. If you want to pass command line arguments on Windows, +use the batch file idle.bat. + +Command line arguments: files passed on the command line are executed, +not opened for editing, unless you give the -e command line option. +Try "./idle.py -h" to see other command line options. + +IDLE requires Python 1.5.2, so it is currently only usable with a +Python 1.5.2 distribution. (An older version of IDLE is distributed +with Python 1.5.2; you can drop this version on top of it.) + +COPYRIGHT + +IDLE is covered by the standard Python copyright notice +(https://www.python.org/doc/copyright). + + +New in IDLE 0.5 (2/15/2000) +--------------------------- + +Tons of stuff, much of it contributed by Tim Peters and Mark Hammond: + +- Status bar, displaying current line/column (Moshe Zadka). + +- Better stack viewer, using tree widget. (XXX Only used by Stack +Viewer menu, not by the debugger.) + +- Format paragraph now recognizes Python block comments and reformats +them correctly (MH) + +- New version of pyclbr.py parses top-level functions and understands +much more of Python's syntax; this is reflected in the class and path +browsers (TP) + +- Much better auto-indent; knows how to indent the insides of +multi-line statements (TP) + +- Call tip window pops up when you type the name of a known function +followed by an open parenthesis. Hit ESC or click elsewhere in the +window to close the tip window (MH) + +- Comment out region now inserts ## to make it stand out more (TP) + +- New path and class browsers based on a tree widget that looks +familiar to Windows users + +- Reworked script running commands to be more intuitive: I/O now +always goes to the *Python Shell* window, and raw_input() works +correctly. You use F5 to import/reload a module: this adds the module +name to the __main__ namespace. You use Control-F5 to run a script: +this runs the script *in* the __main__ namespace. The latter also +sets sys.argv[] to the script name + + +New in IDLE 0.4 (4/7/99) +------------------------ + +Most important change: a new menu entry "File -> Path browser", shows +a 4-column hierarchical browser which lets you browse sys.path, +directories, modules, and classes. Yes, it's a superset of the Class +browser menu entry. There's also a new internal module, +MultiScrolledLists.py, which provides the framework for this dialog. + + +New in IDLE 0.3 (2/17/99) +------------------------- + +Most important changes: + +- Enabled support for running a module, with or without the debugger. +Output goes to a new window. Pressing F5 in a module is effectively a +reload of that module; Control-F5 loads it under the debugger. + +- Re-enable tearing off the Windows menu, and make a torn-off Windows +menu update itself whenever a window is opened or closed. + +- Menu items can now be have a checkbox (when the menu label starts +with "!"); use this for the Debugger and "Auto-open stack viewer" +(was: JIT stack viewer) menu items. + +- Added a Quit button to the Debugger API. + +- The current directory is explicitly inserted into sys.path. + +- Fix the debugger (when using Python 1.5.2b2) to use canonical +filenames for breakpoints, so these actually work. (There's still a +lot of work to be done to the management of breakpoints in the +debugger though.) + +- Closing a window that is still colorizing now actually works. + +- Allow dragging of the separator between the two list boxes in the +class browser. + +- Bind ESC to "close window" of the debugger, stack viewer and class +browser. It removes the selection highlighting in regular text +windows. (These are standard Windows conventions.) + + +New in IDLE 0.2 (1/8/99) +------------------------ + +Lots of changes; here are the highlights: + +General: + +- You can now write and configure your own IDLE extension modules; see +extend.txt. + + +File menu: + +The command to open the Python shell window is now in the File menu. + + +Edit menu: + +New Find dialog with more options; replace dialog; find in files dialog. + +Commands to tabify or untabify a region. + +Command to format a paragraph. + + +Debug menu: + +JIT (Just-In-Time) stack viewer toggle -- if set, the stack viewer +automatically pops up when you get a traceback. + +Windows menu: + +Zoom height -- make the window full height. + + +Help menu: + +The help text now show up in a regular window so you can search and +even edit it if you like. + + + +IDLE 0.1 was distributed with the Python 1.5.2b1 release on 12/22/98. + +====================================================================== diff --git a/Lib/idlelib/Icons/README.txt b/Lib/idlelib/Icons/README.txt new file mode 100644 index 000000000..e245bc0b2 --- /dev/null +++ b/Lib/idlelib/Icons/README.txt @@ -0,0 +1,51 @@ +IDLE-PYTHON LOGOS + +These are sent to tk on Windows, *NIX, and non-Aqua macOS +in pyshell following "# set application icon". + + +2006?: Andrew Clover made variously sized python icons for win23. +https://www.doxdesk.com/software/py/pyicons.html + +2006: 16, 32, and 48 bit .png versions were copied to CPython +as Python application icons, maybe in PC/icons/py.ico. +https://github.com/python/cpython/issues/43372 + +2014: They were copied (perhaps a bit revised) to idlelib/Icons. +https://github.com/python/cpython/issues/64605 +.gif versions were also added. + +2020: Add Clover's 256-bit image. +https://github.com/python/cpython/issues/82620 +Other fixups were done. + +The idle.ico file used for Windows was created with ImageMagick: + $ convert idle_16.png idle_32.png idle_48.png idle_256.png idle.ico +** This needs redoing whenever files are changed. +?? Do Start, Desktop, and Taskbar use idlelib/Icons files? + +Issue added Windows Store PC/icons/idlex44.png and .../idlex150.png. +https://github.com/python/cpython/pull/22817 +?? Should these be updated with major changes? + +2022: Optimize .png images in CPython repository with external program. +https://github.com/python/cpython/pull/21348 +idle.ico (and idlex##) were not updated. + +The idlexx.gif files are only needed for *nix running tcl/tk 8.5. +As of 2022, this was known true for 1 'major' Linux distribution. +(Same would be true for any non-Aqua macOS with 8.5, but now none?) +Can be deleted when we require 8.6 or it is known always used. + +Future: Derivatives of Python logo should be submitted for approval. +PSF Trademark Working Group / Committee psf-trademarks@python.org +https://www.python.org/community/logos/ # Original files +https://www.python.org/psf/trademarks-faq/ +https://www.python.org/psf/trademarks/ # Usage. + + +OTHER GIFS: These are used by browsers using idlelib.tree. +At least some will not be used when tree is replaced by ttk.Treeview. + + +Edited 2024 August 26 by TJR. diff --git a/Lib/idlelib/Icons/folder.gif b/Lib/idlelib/Icons/folder.gif new file mode 100644 index 000000000..effe8dc8a Binary files /dev/null and b/Lib/idlelib/Icons/folder.gif differ diff --git a/Lib/idlelib/Icons/idle.ico b/Lib/idlelib/Icons/idle.ico new file mode 100644 index 000000000..2aa9a8300 Binary files /dev/null and b/Lib/idlelib/Icons/idle.ico differ diff --git a/Lib/idlelib/Icons/idle_16.gif b/Lib/idlelib/Icons/idle_16.gif new file mode 100644 index 000000000..bcedcd640 Binary files /dev/null and b/Lib/idlelib/Icons/idle_16.gif differ diff --git a/Lib/idlelib/Icons/idle_16.png b/Lib/idlelib/Icons/idle_16.png new file mode 100644 index 000000000..77d8f7118 Binary files /dev/null and b/Lib/idlelib/Icons/idle_16.png differ diff --git a/Lib/idlelib/Icons/idle_256.png b/Lib/idlelib/Icons/idle_256.png new file mode 100644 index 000000000..84ffdfddc Binary files /dev/null and b/Lib/idlelib/Icons/idle_256.png differ diff --git a/Lib/idlelib/Icons/idle_32.gif b/Lib/idlelib/Icons/idle_32.gif new file mode 100644 index 000000000..a1b12a053 Binary files /dev/null and b/Lib/idlelib/Icons/idle_32.gif differ diff --git a/Lib/idlelib/Icons/idle_32.png b/Lib/idlelib/Icons/idle_32.png new file mode 100644 index 000000000..2aaa55805 Binary files /dev/null and b/Lib/idlelib/Icons/idle_32.png differ diff --git a/Lib/idlelib/Icons/idle_48.gif b/Lib/idlelib/Icons/idle_48.gif new file mode 100644 index 000000000..fc5304f31 Binary files /dev/null and b/Lib/idlelib/Icons/idle_48.gif differ diff --git a/Lib/idlelib/Icons/idle_48.png b/Lib/idlelib/Icons/idle_48.png new file mode 100644 index 000000000..705eec42e Binary files /dev/null and b/Lib/idlelib/Icons/idle_48.png differ diff --git a/Lib/idlelib/Icons/minusnode.gif b/Lib/idlelib/Icons/minusnode.gif new file mode 100644 index 000000000..173e97095 Binary files /dev/null and b/Lib/idlelib/Icons/minusnode.gif differ diff --git a/Lib/idlelib/Icons/openfolder.gif b/Lib/idlelib/Icons/openfolder.gif new file mode 100644 index 000000000..24aea1beb Binary files /dev/null and b/Lib/idlelib/Icons/openfolder.gif differ diff --git a/Lib/idlelib/Icons/plusnode.gif b/Lib/idlelib/Icons/plusnode.gif new file mode 100644 index 000000000..abedde047 Binary files /dev/null and b/Lib/idlelib/Icons/plusnode.gif differ diff --git a/Lib/idlelib/Icons/python.gif b/Lib/idlelib/Icons/python.gif new file mode 100644 index 000000000..01b13e358 Binary files /dev/null and b/Lib/idlelib/Icons/python.gif differ diff --git a/Lib/idlelib/Icons/tk.gif b/Lib/idlelib/Icons/tk.gif new file mode 100644 index 000000000..03bf3832a Binary files /dev/null and b/Lib/idlelib/Icons/tk.gif differ diff --git a/Lib/idlelib/NEWS2x.txt b/Lib/idlelib/NEWS2x.txt new file mode 100644 index 000000000..372119300 --- /dev/null +++ b/Lib/idlelib/NEWS2x.txt @@ -0,0 +1,660 @@ +What's New in IDLE 2.7? (Merged into 3.1 before 2.7 release.) +======================= +*Release date: 03-Jul-2010* + +- idle.py modified and simplified to better support developing experimental + versions of IDLE which are not installed in the standard location. + +- OutputWindow/PyShell right click menu "Go to file/line" wasn't working with + file paths containing spaces. Bug 5559. + +- Windows: Version string for the .chm help file changed, file not being + accessed Patch 5783 Guilherme Polo + +- Allow multiple IDLE GUI/subprocess pairs to exist simultaneously. Thanks to + David Scherer for suggesting the use of an ephemeral port for the GUI. + Patch 1529142 Weeble. + +- Remove port spec from run.py and fix bug where subprocess fails to + extract port from command line when warnings are present. + +- Tk 8.5 Text widget requires 'wordprocessor' tabstyle attr to handle + mixed space/tab properly. Issue 5129, patch by Guilherme Polo. + +- Issue #3549: On MacOS the preferences menu was not present + +- IDLE would print a "Unhandled server exception!" message when internal + debugging is enabled. + +- Issue #4455: IDLE failed to display the windows list when two windows have + the same title. + +- Issue #4383: When IDLE cannot make the connection to its subprocess, it would + fail to properly display the error message. + +- help() was not paging to the shell. Issue1650. + +- CodeContext was not importing. + +- Corrected two 3.0 compatibility errors reported by Mark Summerfield: + http://mail.python.org/pipermail/python-3000/2007-December/011491.html + +- Shell was not colorizing due to bug introduced at r57998, Bug 1586. + +- Issue #1585: IDLE uses non-existent xrange() function. + +- Windows EOL sequence not converted correctly, encoding error. + Caused file save to fail. Bug 1130. + +- IDLE converted to Python 3000 syntax. + +- Strings became Unicode. + +- CallTips module now uses the inspect module to produce the argspec. + +- IDLE modules now use absolute import instead of implied relative import. + +- atexit call replaces sys.exitfunc. The functionality of delete-exitfunc flag + in config-main.cfg remains unchanged: if set, registered exit functions will + be cleared before IDLE exits. + + +What's New in IDLE 2.6 +====================== +*Release date: 01-Oct-2008*, merged into 3.0 releases detailed above (3.0rc2) + +- Issue #2665: On Windows, an IDLE installation upgraded from an old version + would not start if a custom theme was defined. + +- Home / Control-A toggles between left margin and end of leading white + space. issue1196903, patch by Jeff Shute. + +- Improved AutoCompleteWindow logic. issue2062, patch by Tal Einat. + +- Autocompletion of filenames now support alternate separators, e.g. the + '/' char on Windows. issue2061 Patch by Tal Einat. + +- Configured selection highlighting colors were ignored; updating highlighting + in the config dialog would cause non-Python files to be colored as if they + were Python source; improve use of ColorDelagator. Patch 1334. Tal Einat. + +- ScriptBinding event handlers weren't returning 'break'. Patch 2050, Tal Einat + +- There was an error on exit if no sys.exitfunc was defined. Issue 1647. + +- Could not open files in .idlerc directory if latter was hidden on Windows. + Issue 1743, Issue 1862. + +- Configure Dialog: improved layout for keybinding. Patch 1457 Tal Einat. + +- tabpage.py updated: tabbedPages.py now supports multiple dynamic rows + of tabs. Patch 1612746 Tal Einat. + +- Add confirmation dialog before printing. Patch 1717170 Tal Einat. + +- Show paste position if > 80 col. Patch 1659326 Tal Einat. + +- Update cursor color without restarting. Patch 1725576 Tal Einat. + +- Allow keyboard interrupt only when user code is executing in subprocess. + Patch 1225 Tal Einat (reworked from IDLE-Spoon). + +- configDialog cleanup. Patch 1730217 Tal Einat. + +- textView cleanup. Patch 1718043 Tal Einat. + +- Clean up EditorWindow close. + +- Patch 1693258: Fix for duplicate "preferences" menu-OS X. Backport of r56204. + +- OSX: Avoid crash for those versions of Tcl/Tk which don't have a console + +- Bug in idlelib.MultiCall: Options dialog was crashing IDLE if there was an + option in config-extensions w/o a value. Patch #1672481, Tal Einat + +- Corrected some bugs in AutoComplete. Also, Page Up/Down in ACW implemented; + mouse and cursor selection in ACWindow implemented; double Tab inserts + current selection and closes ACW (similar to double-click and Return); scroll + wheel now works in ACW. Added AutoComplete instructions to IDLE Help. + +- AutoCompleteWindow moved below input line, will move above if there + isn't enough space. Patch 1621265 Tal Einat + +- Calltips now 'handle' tuples in the argument list (display '' :) + Suggested solution by Christos Georgiou, Bug 791968. + +- Add 'raw' support to configHandler. Patch 1650174 Tal Einat. + +- Avoid hang when encountering a duplicate in a completion list. Bug 1571112. + +- Patch #1362975: Rework CodeContext indentation algorithm to + avoid hard-coding pixel widths. + +- Bug #813342: Start the IDLE subprocess with -Qnew if the parent + is started with that option. + +- Honor the "Cancel" action in the save dialog (Debian bug #299092) + +- Some syntax errors were being caught by tokenize during the tabnanny + check, resulting in obscure error messages. Do the syntax check + first. Bug 1562716, 1562719 + +- IDLE's version number takes a big jump to match the version number of + the Python release of which it's a part. + + +What's New in IDLE 1.2? +======================= +*Release date: 19-SEP-2006* + +- File menu hotkeys: there were three 'p' assignments. Reassign the + 'Save Copy As' and 'Print' hotkeys to 'y' and 't'. Change the + Shell hotkey from 's' to 'l'. + +- IDLE honors new quit() and exit() commands from site.py Quitter() object. + Patch 1540892, Jim Jewett + +- The 'with' statement is now a Code Context block opener. + Patch 1540851, Jim Jewett + +- Retrieval of previous shell command was not always preserving indentation + (since 1.2a1) Patch 1528468 Tal Einat. + +- Changing tokenize (39046) to detect dedent broke tabnanny check (since 1.2a1) + +- ToggleTab dialog was setting indent to 8 even if cancelled (since 1.2a1). + +- When used w/o subprocess, all exceptions were preceded by an error + message claiming they were IDLE internal errors (since 1.2a1). + +- Bug #1525817: Don't truncate short lines in IDLE's tool tips. + +- Bug #1517990: IDLE keybindings on MacOS X now work correctly + +- Bug #1517996: IDLE now longer shows the default Tk menu when a + path browser, class browser or debugger is the frontmost window on MacOS X + +- EditorWindow.test() was failing. Bug 1417598 + +- EditorWindow failed when used stand-alone if sys.ps1 not set. + Bug 1010370 Dave Florek + +- Tooltips failed on new-syle class __init__ args. Bug 1027566 Loren Guthrie + +- Avoid occasional failure to detect closing paren properly. + Patch 1407280 Tal Einat + +- Rebinding Tab key was inserting 'tab' instead of 'Tab'. Bug 1179168. + +- Colorizer now handles # correctly, also unicode strings and + 'as' keyword in comment directly following import command. Closes 1325071. + Patch 1479219 Tal Einat + +- Patch #1162825: Support non-ASCII characters in IDLE window titles. + +- Source file f.flush() after writing; trying to avoid lossage if user + kills GUI. + +- Options / Keys / Advanced dialog made functional. Also, allow binding + of 'movement' keys. + +- 'syntax' patch adds improved calltips and a new class attribute listbox. + MultiCall module allows binding multiple actions to an event. + Patch 906702 Noam Raphael + +- Better indentation after first line of string continuation. + IDLEfork Patch 681992, Noam Raphael + +- Fixed CodeContext alignment problem, following suggestion from Tal Einat. + +- Increased performance in CodeContext extension Patch 936169 Noam Raphael + +- Mac line endings were incorrect when pasting code from some browsers + when using X11 and the Fink distribution. Python Bug 1263656. + +- when cursor is on a previous command retrieves that command. Instead + of replacing the input line, the previous command is now appended to the + input line. Indentation is preserved, and undo is enabled. + Patch 1196917 Jeff Shute + +- Clarify "tab/space" Error Dialog and "Tab Width" Dialog associated with + the Untabify command. + +- Corrected "tab/space" Error Dialog to show correct menu for Untabify. + Patch 1196980 Jeff Shute + +- New files are colorized by default, and colorizing is removed when + saving as non-Python files. Patch 1196895 Jeff Shute + Closes Python Bugs 775012 and 800432, partial fix IDLEfork 763524 + +- Improve subprocess link error notification. + +- run.py: use Queue's blocking feature instead of sleeping in the main + loop. Patch # 1190163 Michiel de Hoon + +- Add config-main option to make the 'history' feature non-cyclic. + Default remains cyclic. Python Patch 914546 Noam Raphael. + +- Removed ability to configure tabs indent from Options dialog. This 'feature' + has never worked and no one has complained. It is still possible to set a + default tabs (v. spaces) indent 'manually' via config-main.def (or to turn on + tabs for the current EditorWindow via the Format menu) but IDLE will + encourage indentation via spaces. + +- Enable setting the indentation width using the Options dialog. + Bug # 783877 + +- Add keybindings for del-word-left and del-word-right. + +- Discourage using an indent width other than 8 when using tabs to indent + Python code. + +- Restore use of EditorWindow.set_indentation_params(), was dead code since + Autoindent was merged into EditorWindow. This allows IDLE to conform to the + indentation width of a loaded file. (But it still will not switch to tabs + even if the file uses tabs.) Any change in indent width is local to that + window. + +- Add Tabnanny check before Run/F5, not just when Checking module. + +- If an extension can't be loaded, print warning and skip it instead of + erroring out. + +- Improve error handling when .idlerc can't be created (warn and exit). + +- The GUI was hanging if the shell window was closed while a raw_input() + was pending. Restored the quit() of the readline() mainloop(). + http://mail.python.org/pipermail/idle-dev/2004-December/002307.html + +- The remote procedure call module rpc.py can now access data attributes of + remote registered objects. Changes to these attributes are local, however. + + +What's New in IDLE 1.1? +======================= +*Release date: 30-NOV-2004* + +- On OpenBSD, terminating IDLE with ctrl-c from the command line caused a + stuck subprocess MainThread because only the SocketThread was exiting. + +- Saving a Keyset w/o making changes (by using the "Save as New Custom Key Set" + button) caused IDLE to fail on restart (no new keyset was created in + config-keys.cfg). Also true for Theme/highlights. Python Bug 1064535. + +- A change to the linecache.py API caused IDLE to exit when an exception was + raised while running without the subprocess (-n switch). Python Bug 1063840. + +- When paragraph reformat width was made configurable, a bug was + introduced that caused reformatting of comment blocks to ignore how + far the block was indented, effectively adding the indentation width + to the reformat width. This has been repaired, and the reformat + width is again a bound on the total width of reformatted lines. + +- Improve keyboard focus binding, especially in Windows menu. Improve + window raising, especially in the Windows menu and in the debugger. + IDLEfork 763524. + +- If user passes a non-existent filename on the commandline, just + open a new file, don't raise a dialog. IDLEfork 854928. + +- EditorWindow.py was not finding the .chm help file on Windows. Typo + at Rev 1.54. Python Bug 990954 + +- checking sys.platform for substring 'win' was breaking IDLE docs on Mac + (darwin). Also, Mac Safari browser requires full file:// URIs. SF 900580. + +- Redirect the warning stream to the shell during the ScriptBinding check of + user code and format the warning similarly to an exception for both that + check and for runtime warnings raised in the subprocess. + +- CodeContext hint pane visibility state is now persistent across sessions. + The pane no longer appears in the shell window. Added capability to limit + extensions to shell window or editor windows. Noam Raphael addition + to Patch 936169. + +- Paragraph reformat width is now a configurable parameter in the + Options GUI. + +- New Extension: CodeContext. Provides block structuring hints for code + which has scrolled above an edit window. Patch 936169 Noam Raphael. + +- If nulls somehow got into the strings in recent-files.lst + EditorWindow.update_recent_files_list() was failing. Python Bug 931336. + +- If the normal background is changed via Configure/Highlighting, it will + update immediately, thanks to the previously mentioned patch by Nigel Rowe. + +- Add a highlight theme for builtin keywords. Python Patch 805830 Nigel Rowe + This also fixed IDLEfork bug [ 693418 ] Normal text background color not + refreshed and Python bug [897872 ] Unknown color name on HP-UX + +- rpc.py:SocketIO - Large modules were generating large pickles when downloaded + to the execution server. The return of the OK response from the subprocess + initialization was interfering and causing the sending socket to be not + ready. Add an IO ready test to fix this. Moved the polling IO ready test + into pollpacket(). + +- Fix typo in rpc.py, s/b "pickle.PicklingError" not "pickle.UnpicklingError". + +- Added a Tk error dialog to run.py inform the user if the subprocess can't + connect to the user GUI process. Added a timeout to the GUI's listening + socket. Added Tk error dialogs to PyShell.py to announce a failure to bind + the port or connect to the subprocess. Clean up error handling during + connection initiation phase. This is an update of Python Patch 778323. + +- Print correct exception even if source file changed since shell was + restarted. IDLEfork Patch 869012 Noam Raphael + +- Keybindings with the Shift modifier now work correctly. So do bindings which + use the Space key. Limit unmodified user keybindings to the function keys. + Python Bug 775353, IDLEfork Bugs 755647, 761557 + +- After an exception, run.py was not setting the exception vector. Noam + Raphael suggested correcting this so pdb's postmortem pm() would work. + IDLEfork Patch 844675 + +- IDLE now does not fail to save the file anymore if the Tk buffer is not a + Unicode string, yet eol_convention is. Python Bugs 774680, 788378 + +- IDLE didn't start correctly when Python was installed in "Program Files" on + W2K and XP. Python Bugs 780451, 784183 + +- config-main.def documentation incorrectly referred to idle- instead of + config- filenames. SF 782759 Also added note about .idlerc location. + + +What's New in IDLE 1.0? +======================= +*Release date: 29-Jul-2003* + +- Added a banner to the shell discussing warnings possibly raised by personal + firewall software. Added same comment to README.txt. + +- Calltip error when docstring was None Python Bug 775541 + +- Updated extend.txt, help.txt, and config-extensions.def to correctly + reflect the current status of the configuration system. Python Bug 768469 + +- Fixed: Call Tip Trimming May Loop Forever. Python Patch 769142 (Daniels) + +- Replaced apply(f, args, kwds) with f(*args, **kwargs) to improve performance + Python Patch 768187 + +- Break or continue statements outside a loop were causing IDLE crash + Python Bug 767794 + +- Convert Unicode strings from readline to IOBinding.encoding. Also set + sys.std{in|out|err}.encoding, for both the local and the subprocess case. + SF IDLEfork patch 682347. + +- Extend AboutDialog.ViewFile() to support file encodings. Make the CREDITS + file Latin-1. + +- Updated the About dialog to reflect re-integration into Python. Provide + buttons to display Python's NEWS, License, and Credits, plus additional + buttons for IDLE's README and NEWS. + +- TextViewer() now has a third parameter which allows inserting text into the + viewer instead of reading from a file. + +- (Created the .../Lib/idlelib directory in the Python CVS, which is a clone of + IDLEfork modified to install in the Python environment. The code in the + interrupt module has been moved to thread.interrupt_main(). ) + +- Printing the Shell window was failing if it was not saved first SF 748975 + +- When using the Search in Files dialog, if the user had a selection + highlighted in his Editor window, insert it into the dialog search field. + +- The Python Shell entry was disappearing from the Windows menu. + +- Update the Windows file list when a file name change occurs + +- Change to File / Open Module: always pop up the dialog, using the current + selection as the default value. This is easier to use habitually. + +- Avoided a problem with starting the subprocess when 'localhost' doesn't + resolve to the user's loopback interface. SF 747772 + +- Fixed an issue with highlighted errors never de-colorizing. SF 747677. Also + improved notification of Tabnanny Token Error. + +- File / New will by default save in the directory of the Edit window from + which it was initiated. SF 748973 Guido van Rossum patch. + + +What's New in IDLEfork 0.9b1? +============================= +*Release date: 02-Jun-2003* + +- The current working directory of the execution environment (and shell + following completion of execution) is now that of the module being run. + +- Added the delete-exitfunc option to config-main.def. (This option is not + included in the Options dialog.) Setting this to True (the default) will + cause IDLE to not run sys.exitfunc/atexit when the subprocess exits. + +- IDLE now preserves the line ending codes when editing a file produced on + a different platform. SF 661759, SF 538584 + +- Reduced default editor font size to 10 point and increased window height + to provide a better initial impression on Windows. + +- Options / Fonts/Tabs / Set Base Editor Font: List box was not highlighting + the default font when first installed on Windows. SF 661676 + +- Added Autosave feature: when user runs code from edit window, if the file + has been modified IDLE will silently save it if Autosave is enabled. The + option is set in the Options dialog, and the default is to prompt the + user to save the file. SF 661318 Bruce Sherwood patch. + +- Improved the RESTART annotation in the shell window when the user restarts + the shell while it is generating output. Also improved annotation when user + repeatedly hammers the Ctrl-F6 restart. + +- Allow IDLE to run when not installed and cwd is not the IDLE directory + SF Patch 686254 "Run IDLEfork from any directory without set-up" - Raphael + +- When a module is run from an EditorWindow: if its directory is not in + sys.path, prepend it. This allows the module to import other modules in + the same directory. Do the same for a script run from the command line. + +- Correctly restart the subprocess if it is running user code and the user + attempts to run some other module or restarts the shell. Do the same if + the link is broken and it is possible to restart the subprocess and re- + connect to the GUI. SF RFE 661321. + +- Improved exception reporting when running commands or scripts from the + command line. + +- Added a -n command line switch to start IDLE without the subprocess. + Removed the Shell menu when running in that mode. Updated help messages. + +- Added a comment to the shell startup header to indicate when IDLE is not + using the subprocess. + +- Restore the ability to run without the subprocess. This can be important for + some platforms or configurations. (Running without the subprocess allows the + debugger to trace through parts of IDLE itself, which may or may not be + desirable, depending on your point of view. In addition, the traditional + reload/import tricks must be use if user source code is changed.) This is + helpful for developing IDLE using IDLE, because one instance can be used to + edit the code and a separate instance run to test changes. (Multiple + concurrent IDLE instances with subprocesses is a future feature) + +- Improve the error message a user gets when saving a file with non-ASCII + characters and no source encoding is specified. Done by adding a dialog + 'EncodingMessage', which contains the line to add in a fixed-font entry + widget, and which has a button to add that line to the file automatically. + Also, add a configuration option 'EditorWindow/encoding', which has three + possible values: none, utf-8, and locale. None is the default: IDLE will show + this dialog when non-ASCII characters are encountered. utf-8 means that files + with non-ASCII characters are saved as utf-8-with-bom. locale means that + files are saved in the locale's encoding; the dialog is only displayed if the + source contains characters outside the locale's charset. SF 710733 - Loewis + +- Improved I/O response by tweaking the wait parameter in various + calls to signal.signal(). + +- Implemented a threaded subprocess which allows interrupting a pass + loop in user code using the 'interrupt' extension. User code runs + in MainThread, while the RPCServer is handled by SockThread. This is + necessary because Windows doesn't support signals. + +- Implemented the 'interrupt' extension module, which allows a subthread + to raise a KeyboardInterrupt in the main thread. + +- Attempting to save the shell raised an error related to saving + breakpoints, which are not implemented in the shell + +- Provide a correct message when 'exit' or 'quit' are entered at the + IDLE command prompt SF 695861 + +- Eliminate extra blank line in shell output caused by not flushing + stdout when user code ends with an unterminated print. SF 695861 + +- Moved responsibility for exception formatting (i.e. pruning IDLE internal + calls) out of rpc.py into the client and server. + +- Exit IDLE cleanly even when doing subprocess I/O + +- Handle subprocess interrupt with an RPC message. + +- Restart the subprocess if it terminates itself. (VPython programs do that) + +- Support subclassing of exceptions, including in the shell, by moving the + exception formatting to the subprocess. + + +What's New in IDLEfork 0.9 Alpha 2? +=================================== +*Release date: 27-Jan-2003* + +- Updated INSTALL.txt to claify use of the python2 rpm. + +- Improved formatting in IDLE Help. + +- Run menu: Replace "Run Script" with "Run Module". + +- Code encountering an unhandled exception under the debugger now shows + the correct traceback, with IDLE internal levels pruned out. + +- If an exception occurs entirely in IDLE, don't prune the IDLE internal + modules from the traceback displayed. + +- Class Browser and Path Browser now use Alt-Key-2 for vertical zoom. + +- IDLE icons will now install correctly even when setup.py is run from the + build directory + +- Class Browser now compatible with Python2.3 version of pyclbr.py + +- Left cursor move in presence of selected text now moves from left end + of the selection. + +- Add Meta keybindings to "IDLE Classic Windows" to handle reversed + Alt/Meta on some Linux distros. + +- Change default: IDLE now starts with Python Shell. + +- Removed the File Path from the Additional Help Sources scrolled list. + +- Add capability to access Additional Help Sources on the web if the + Help File Path begins with //http or www. (Otherwise local path is + validated, as before.) + +- Additional Help Sources were not being posted on the Help menu in the + order entered. Implement sorting the list by [HelpFiles] 'option' + number. + +- Add Browse button to New Help Source dialog. Arrange to start in + Python/Doc if platform is Windows, otherwise start in current directory. + +- Put the Additional Help Sources directly on the Help menu instead of in + an Extra Help cascade menu. Rearrange the Help menu so the Additional + Help Sources come last. Update help.txt appropriately. + +- Fix Tk root pop-ups in configSectionNameDialog.py and configDialog.py + +- Uniform capitalization in General tab of ConfigDialog, update the doc string. + +- Fix bug in ConfigDialog where SaveAllChangedConfig() was unexpectedly + deleting Additional Help Sources from the user's config file. + +- Make configHelpSourceEdit OK button the default and bind + +- Fix Tk root pop-ups in configHelpSourceEdit: error dialogs not attached + to parents. + +- Use os.startfile() to open both Additional Help and Python Help on the + Windows platform. The application associated with the file type will act as + the viewer. Windows help files (.chm) are now supported via the + Settings/General/Additional Help facility. + +- If Python Help files are installed locally on Linux, use them instead of + accessing python.org. + +- Make the methods for finding the Python help docs more robust, and make + them work in the installed configuration, also. + +- On the Save Before Run dialog, make the OK button the default. One + less mouse action! + +- Add a method: EditorWindow.get_geometry() for future use in implementing + window location persistence. + +- Removed the "Help/Advice" menu entry. Thanks, David! We'll remember! + +- Change the "Classic Windows" theme's paste key to be . + +- Rearrange the Shell menu to put Stack Viewer entries adjacent. + +- Add the ability to restart the subprocess interpreter from the shell window; + add an associated menu entry "Shell/Restart" with binding Control-F6. Update + IDLE help. + +- Upon a restart, annotate the shell window with a "restart boundary". Add a + shell window menu "Shell/View Restart" with binding F6 to jump to the most + recent restart boundary. + +- Add Shell menu to Python Shell; change "Settings" to "Options". + +- Remove incorrect comment in setup.py: IDLEfork is now installed as a package. + +- Add INSTALL.txt, HISTORY.txt, NEWS.txt to installed configuration. + +- In installer text, fix reference to Visual Python, should be VPython. + Properly credit David Scherer. + +- Modified idle, idle.py, idle.pyw to improve exception handling. + + +What's New in IDLEfork 0.9 Alpha 1? +=================================== +*Release date: 31-Dec-2002* + +- First release of major new functionality. For further details refer to + Idle-dev and/or the Sourceforge CVS. + +- Adapted to the Mac platform. + +- Overhauled the IDLE startup options and revised the idle -h help message, + which provides details of command line usage. + +- Multiple bug fixes and usability enhancements. + +- Introduced the new RPC implementation, which includes a debugger. The output + of user code is to the shell, and the shell may be used to inspect the + environment after the run has finished. (In version 0.8.1 the shell + environment was separate from the environment of the user code.) + +- Introduced the configuration GUI and a new About dialog. + +- Removed David Scherer's Remote Procedure Call code and replaced with Guido + van Rossum's. GvR code has support for the IDLE debugger and uses the shell + to inspect the environment of code Run from an Edit window. Files removed: + ExecBinding.py, loader.py, protocol.py, Remote.py, spawn.py + +-------------------------------------------------------------------- +Refer to HISTORY.txt for additional information on earlier releases. +-------------------------------------------------------------------- diff --git a/Lib/idlelib/News3.txt b/Lib/idlelib/News3.txt new file mode 100644 index 000000000..da3a14581 --- /dev/null +++ b/Lib/idlelib/News3.txt @@ -0,0 +1,1415 @@ +What's New in IDLE 3.14.z +(since 3.14.0) +Released after 2025-10-07 +========================= + + +gh-143774: Better explain the operation of Format / Format Paragraph. +Patch by Terry J. Reedy. + +gh-139742: Colorize t-string prefixes for template strings in IDLE, +as done for f-string prefixes. Patch by Anuradha Agrawal. + +What's New in IDLE 3.14.0 +(since 3.13.0) +Released on 2025-10-07 +========================= + +gh-129873: Simplify displaying the IDLE doc by only copying the text +section of idle.html to idlelib/help.html. Patch by Stan Ulbrych. + +gh-112936: IDLE - Include Shell menu in single-process mode, +though with Restart Shell and View Last Restart disabled. +Patch by Zhikang Yan. + +gh-112938: IDLE - Fix uninteruptable hang when Shell gets +rapid continuous output. + +gh-127060: Set TERM environment variable to 'dumb' to not add ANSI escape +sequences for text color in tracebacks. IDLE does not understand them. +Patch by Victor Stinner. + +gh-122392: Increase currently inadequate vertical spacing for the IDLE +browsers (path, module, and stack) on high-resolution monitors. + + +What's New in IDLE 3.13.0 +(since 3.12.0) +Released on 2024-10-07 +========================= + +gh-120104: Fix padding in config and search dialog windows in IDLE. + +gh-120083: Add explicit black IDLE Hovertip foreground color needed for +recent macOS. Fixes Sonoma showing unreadable white on pale yellow. +Patch by John Riggles. + +gh-122482: Change About IDLE to direct users to discuss.python.org +instead of the now unused idle-dev email and mailing list. + +gh-78889: Stop Shell freezes by blocking user access to non-method +sys.stdout.shell attributes, which are all private. + +gh-78955: Use user-selected color theme for Help => IDLE Doc. + +gh-96905: In idlelib code, stop redefining built-ins 'dict' and 'object'. + +gh-72284: Improve the lists of features, editor key bindings, +and shell key bingings in the IDLE doc. + +gh-113903: Fix rare failure of test.test_idle, in test_configdialog. + +gh-113729: Fix the "Help -> IDLE Doc" menu bug in 3.11.7 and 3.12.1. + +gh-57795: Enter selected text into the Find box when opening +a Replace dialog. Patch by Roger Serwy and Zackery Spytz. + +gh-113269: Fix test_editor hang on macOS Catalina. +Patch by Terry Reedy. + +gh-112939: Fix processing unsaved files when quitting IDLE on macOS. +Patch by Ronald Oussoren and Christopher Chavez. + +gh-79871: Add docstrings to debugger.py. Fix two bugs in +test_debugger and expand coverage by 47%. Patch by Anthony Shaw. + + +What's New in IDLE 3.12.0 +(since 3.11.0) +Released on 2023-10-02 +========================= + +gh-104719: Remove IDLE's modification of tokenize.tabsize and test +other uses of tokenize data and methods. + +gh-104499: Fix completions for Tk Aqua 8.7 (currently blank). + +gh-104486: Make About print both tcl and tk versions if different, +as is expected someday. + +gh-88496 Fix IDLE test hang on macOS. + +gh-103314 Support sys.last_exc after exceptions in Shell. +Patch by Irit Katriel. + + +What's New in IDLE 3.11.0 +(since 3.10.0) +Released on 2022-10-24 +========================= + +gh-97527: Fix a bug in the previous bugfix that caused IDLE to not +start when run with 3.10.8, 3.12.0a1, and at least Microsoft Python +3.10.2288.0 installed without the Lib/test package. 3.11.0 was never +affected. + +gh-65802: Document handling of extensions in Save As dialogs. + +gh-95191: Include prompts when saving Shell (interactive input/output). + +gh-95511: Fix the Shell context menu copy-with-prompts bug of copying +an extra line when one selects whole lines. + +gh-95471: Tweak Edit menu. Move 'Select All' above 'Cut' as it is used +with 'Cut' and 'Copy' but not 'Paste'. Add a separator between 'Replace' +and 'Go to Line' to help IDLE issue triagers. + +gh-95411: Enable using IDLE's module browser with .pyw files. + +gh-89610: Add .pyi as a recognized extension for IDLE on macOS. This allows +opening stub files by double clicking on them in the Finder. + +bpo-28950: Apply IDLE syntax highlighting to `.pyi` files. Add util.py +for common components. Patch by Alex Waygood and Terry Jan Reedy. + +bpo-46630: Make query dialogs on Windows start with a cursor in the +entry box. + +bpo-46591: Make the IDLE doc URL on the About IDLE dialog clickable. + +bpo-45296: Clarify close, quit, and exit in IDLE. In the File menu, +'Close' and 'Exit' are now 'Close Window' (the current one) and 'Exit' +is now 'Exit IDLE' (by closing all windows). In Shell, 'quit()' and +'exit()' mean 'close Shell'. If there are no other windows, +this also exits IDLE. + +bpo-45495: Add context keywords 'case' and 'match' to completions list. + +bpo-45296: On Windows, change exit/quit message to suggest Ctrl-D, which +works, instead of , which does not work in IDLE. + + +What's New in IDLE 3.10.0 +(since 3.9.0) +Released on 2021-10-04 +========================= + +bpo-45193: Make completion boxes appear on Ubuntu again. + +bpo-40128: Mostly fix completions on macOS when not using tcl/tk 8.6.11 +(as with 3.9). + +bpo-33962: Move the indent space setting from the Font tab to the new Windows +tab. Patch by Mark Roseman and Terry Jan Reedy. + +bpo-40468: Split the settings dialog General tab into Windows and Shell/Ed +tabs. Move help sources, which extend the Help menu, to the Extensions tab. +Make space for new options and shorten the dialog. The latter makes the +dialog better fit small screens. + +bpo-44010: Highlight the new match statement's soft keywords: match, case, +and _. This highlighting is not perfect and will be incorrect in some rare +cases, especially for some _s in case patterns. + +bpo-44026: Include interpreter's typo fix suggestions in message line +for NameErrors and AttributeErrors. Patch by E. Paine. + +bpo-41611: Avoid occasional uncaught exceptions and freezing when using +completions on macOS. + +bpo-37903: Add mouse actions to the shell sidebar. Left click and +optional drag selects one or more lines of text, as with the +editor line number sidebar. Right click after selecting text lines +displays a context menu with 'copy with prompts'. This zips together +prompts from the sidebar with lines from the selected text. This option +also appears on the context menu for the text. + +bpo-43981: Fix reference leaks in test_sidebar and test_squeezer. +Patches by Terry Jan Reedy and Pablo Galindo + +bpo-37892: Change Shell input indents from tabs to spaces. Shell input +now 'looks right'. Making this feasible motivated the shell sidebar. + +bpo-37903: Move the Shell input prompt to a side bar. + +bpo-43655: Make window managers on macOS and X Window recognize +IDLE dialog windows as dialogs. + +bpo-42225: Document that IDLE can fail on Unix either from misconfigured IP +masquerade rules or failure displaying complex colored (non-ascii) characters. + +bpo-43283: Document why printing to IDLE's Shell is often slower than +printing to a system terminal and that it can be made faster by +pre-formatting a single string before printing. + +bpo-23544: Disable Debug=>Stack Viewer when user code is running or +Debugger is active, to prevent hang or crash. Patch by Zackery Spytz. + +bpo-43008: Make IDLE invoke :func:`sys.excepthook` in normal, +2-process mode. User hooks were previously ignored. +Patch by Ken Hilton. + +bpo-33065: Fix problem debugging user classes with __repr__ method. + +bpo-32631: Finish zzdummy example extension module: make menu entries +work; add docstrings and tests with 100% coverage. + +bpo-42508: Keep IDLE running on macOS. Remove obsolete workaround +that prevented running files with shortcuts when using new universal2 +installers built on macOS 11. + +bpo-42426: Fix reporting offset of the RE error in searchengine. + +bpo-42416: Display docstrings in IDLE calltips in more cases, +by using inspect.getdoc. + +bpo-33987: Mostly finish using ttk widgets, mainly for editor, +settings, and searches. Some patches by Mark Roseman. + +bpo-40511: Stop unnecessary "flashing" when typing opening and closing +parentheses inside the parentheses of a function call. + +bpo-38439: Add a 256x256 pixel IDLE icon to the Windows .ico file. Created by +Andrew Clover. Remove the low-color gif variations from the .ico file. + +bpo-41775: Make 'IDLE Shell' the shell title. + +bpo-35764: Rewrite the Calltips doc section. + +bpo-40181: In calltips, stop reminding that '/' marks the end of +positional-only arguments. + + +What's New in IDLE 3.9.0 (since 3.8.0) +Released on 2020-10-05? +====================================== + +bpo-41468: Improve IDLE run crash error message (which users should +never see). + +bpo-41373: Save files loaded with no line ending, as when blank, or +different line endings, by setting its line ending to the system +default. Fix regression in 3.8.4 and 3.9.0b4. + +bpo-41300: Save files with non-ascii chars. Fix regression in +3.9.0b4 and 3.8.4. + +bpo-37765: Add keywords to module name completion list. Rewrite +Completions section of IDLE doc. + +bpo-41152: The encoding of ``stdin``, ``stdout`` and ``stderr`` in IDLE +is now always UTF-8. + +bpo-41144: Make Open Module open a special module such as os.path. + +bpo-40723: Make test_idle pass when run after import. +Patch by Florian Dahlitz. + +bpo-38689: IDLE will no longer freeze when inspect.signature fails +when fetching a calltip. + +bpo-27115: For 'Go to Line', use a Query entry box subclass with +IDLE standard behavior and improved error checking. + +bpo-39885: When a context menu is invoked by right-clicking outside +of a selection, clear the selection and move the cursor. Cut and +Copy require that the click be within the selection. + +bpo-39852: Edit "Go to line" now clears any selection, preventing +accidental deletion. It also updates Ln and Col on the status bar. + +bpo-39781: Selecting code context lines no longer causes a jump. + +bpo-39663: Add tests for pyparse find_good_parse_start(). + +bpo-39600: Remove duplicate font names from configuration list. + +bpo-38792: Close a shell calltip if a :exc:`KeyboardInterrupt` +or shell restart occurs. Patch by Zackery Spytz. + +bpo-30780: Add remaining configdialog tests for buttons and +highlights and keys tabs. + +bpo-39388: Settings dialog Cancel button cancels pending changes. + +bpo-39050: Settings dialog Help button again displays help text. + +bpo-32989: Add tests for editor newline_and_indent_event method. +Remove unneeded arguments and dead code from pyparse +find_good_parse_start method. + +bpo-38943: Fix autocomplete windows not always appearing on some +systems. Patch by Johnny Najera. + +bpo-38944: Escape key now closes IDLE completion windows. Patch by +Johnny Najera. + +bpo-38862: 'Strip Trailing Whitespace' on the Format menu removes extra +newlines at the end of non-shell files. + +bpo-38636: Fix IDLE Format menu tab toggle and file indent width. These +functions (default shortcuts Alt-T and Alt-U) were mistakenly disabled +in 3.7.5 and 3.8.0. + +bpo-4630: Add an option to toggle IDLE's cursor blink for shell, +editor, and output windows. See Settings, General, Window Preferences, +Cursor Blink. Patch by Zackery Spytz. + +bpo-26353: Stop adding newline when saving an IDLE shell window. + +bpo-38598: Do not try to compile IDLE shell or output windows. + + +What's New in IDLE 3.8.0 (since 3.7.0) +Released on 2019-10-14 +====================================== + +bpo-36698: IDLE no longer fails when writing non-encodable characters +to stderr. It now escapes them with a backslash, like the regular +Python interpreter. Add an errors field to the standard streams. + +bpo-13153: Improve tkinter's handing of non-BMP (astral) unicode +characters, such as 'rocket \U0001f680'. Whether a proper glyph or +replacement char is displayed depends on the OS and font. For IDLE, +astral chars in code interfere with editing. + +bpo-35379: When exiting IDLE, catch any AttributeError. One happens +when EditorWindow.close is called twice. Printing a traceback, when +IDLE is run from a terminal, is useless and annoying. + +bpo-38183: To avoid test issues, test_idle ignores the user config +directory. It no longer tries to create or access .idlerc or any files +within. Users must run IDLE to discover problems with saving settings. + +bpo-38077: IDLE no longer adds 'argv' to the user namespace when +initializing it. This bug only affected 3.7.4 and 3.8.0b2 to 3.8.0b4. + +bpo-38401: Shell restart lines now fill the window width, always start +with '=', and avoid wrapping unnecessarily. The line will still wrap +if the included file name is long relative to the width. + +bpo-37092: Add mousewheel scrolling for IDLE module, path, and stack +browsers. Patch by George Zhang. + +bpo-35771: To avoid occasional spurious test_idle failures on slower +machines, increase the ``hover_delay`` in test_tooltip. + +bpo-37824: Properly handle user input warnings in IDLE shell. +Cease turning SyntaxWarnings into SyntaxErrors. + +bpo-37929: IDLE Settings dialog now closes properly when there is no +shell window. + +bpo-37849: Fix completions list appearing too high or low when shown +above the current line. + +bpo-36419: Refactor autocompete and improve testing. + +bpo-37748: Reorder the Run menu. Put the most common choice, +Run Module, at the top. + +bpo-37692: Improve highlight config sample with example shell +interaction and better labels for shell elements. + +bpo-37628: Settings dialog no longer expands with font size. +The font and highlight sample boxes gain scrollbars instead. + +bpo-17535: Add optional line numbers for IDLE editor windows. + +bpo-37627: Initialize the Customize Run dialog with the command line +arguments most recently entered before. The user can optionally edit +before submitting them. + +bpo-33610: Code context always shows the correct context when toggled on. + +bpo-36390: Gather Format menu functions into format.py. Combine +paragraph.py, rstrip.py, and format methods from editor.py. + +bpo-37530: Optimize code context to reduce unneeded background activity. +Font and highlight changes now occur along with text changes instead +of after a random delay. + +bpo-27452: Cleanup config.py by inlining RemoveFile and simplifying +the handling of __file__ in CreateConfigHandlers/ + +bpo-26806: To compensate for stack frames added by IDLE and avoid +possible problems with low recursion limits, add 30 to limits in the +user code execution process. Subtract 30 when reporting recursion +limits to make this addition mostly transparent. + +bpo-37325: Fix tab focus traversal order for help source and custom +run dialogs. + +bpo-37321: Both subprocess connection error messages now refer to +the 'Startup failure' section of the IDLE doc. + +bpo-37177: Properly attach search dialogs to their main window so +that they behave like other dialogs and do not get hidden behind +their main window. + +bpo-37039: Adjust "Zoom Height" to individual screens by momentarily +maximizing the window on first use with a particular screen. Changing +screen settings may invalidate the saved height. While a window is +maximized, "Zoom Height" has no effect. + +bpo-35763: Make calltip reminder about '/' meaning positional-only less +obtrusive by only adding it when there is room on the first line. + +bpo-5680: Add 'Run Customized' to the Run menu to run a module with +customized settings. Any command line arguments entered are added +to sys.argv. One can suppress the normal Shell main module restart. + +bpo-35610: Replace now redundant editor.context_use_ps1 with +.prompt_last_line. This finishes change started in bpo-31858. + +bpo-32411: Stop sorting dict created with desired line order. + +bpo-37038: Make idlelib.run runnable; add test clause. + +bpo-36958: Print any argument other than None or int passed to +SystemExit or sys.exit(). + +bpo-36807: When saving a file, call file.flush() and os.fsync() +so bits are flushed to e.g. a USB drive. + +bpo-36429: Fix starting IDLE with pyshell. +Add idlelib.pyshell alias at top; remove pyshell alias at bottom. +Remove obsolete __name__=='__main__' command. + +bpo-30348: Increase test coverage of idlelib.autocomplete by 30%. +Patch by Louie Lu. + +bpo-23205: Add tests and refactor grep's findfiles. + +bpo-36405: Use dict unpacking in idlelib. + +bpo-36396: Remove fgBg param of idlelib.config.GetHighlight(). +This param was only used twice and changed the return type. + +bpo-23216: IDLE: Add docstrings to search modules. + +bpo-36176: Fix IDLE autocomplete & calltip popup colors. +Prevent conflicts with Linux dark themes +(and slightly darken calltip background). + +bpo-36152: Remove colorizer.ColorDelegator.close_when_done and the +corresponding argument of .close(). In IDLE, both have always been +None or False since 2007. + +bpo-36096: Make colorizer state variables instance-only. + +bpo-32129: Avoid blurry IDLE application icon on macOS with Tk 8.6. +Patch by Kevin Walzer. + +bpo-24310: Document settings dialog font tab sample. + +bpo-35689: Add docstrings and tests for colorizer. + +bpo-35833: Revise IDLE doc for control codes sent to Shell. +Add a code example block. + +bpo-35770: IDLE macosx deletes Options => Configure IDLE. +It previously deleted Window => Zoom Height by mistake. +(Zoom Height is now on the Options menu). On Mac, the settings +dialog is accessed via Preferences on the IDLE menu. + +bpo-35769: Change new file name from 'Untitled' to 'untitled'. + +bpo-35660: Fix imports in window module. + +bpo-35641: Properly format calltip for function without docstring. + +bpo-33987: Use ttk Frame for ttk widgets. + +bpo-34055: Fix erroneous 'smart' indents and newlines in IDLE Shell. + +bpo-28097: Add Previous/Next History entries to Shell menu. + +bpo-35591: Find Selection now works when selection not found. + +bpo-35598: Update config_key: use PEP 8 names and ttk widgets, +make some objects global, and add tests. + +bpo-35196: Speed up squeezer line counting. + +bpo-35208: Squeezer now counts wrapped lines before newlines. + +bpo-35555: Gray out Code Context menu entry when it's not applicable. + +bpo-22703: Improve the Code Context and Zoom Height menu labels. +The Code Context menu label now toggles between Show/Hide Code Context. +The Zoom Height menu now toggles between Zoom/Restore Height. +Zoom Height has moved from the Window menu to the Options menu. + +bpo-35521: Document the editor code context feature. +Add some internal references within the IDLE doc. + +bpo-34864: When starting IDLE on MacOS, warn if the system setting +"Prefer tabs when opening documents" is "Always". As previous +documented for this issue, running IDLE with this setting causes +problems. If the setting is changed while IDLE is running, +there will be no warning until IDLE is restarted. + +bpo-35213: Where appropriate, use 'macOS' in idlelib. + +bpo-34864: Document two IDLE on MacOS issues. The System Preferences +Dock "prefer tabs always" setting disables some IDLE features. +Menus are a bit different than as described for Windows and Linux. + +bpo-35202: Remove unused imports in idlelib. + +bpo-33000: Document that IDLE's shell has no line limit. +A program that runs indefinitely can overfill memory. + +bpo-23220: Explain how IDLE's Shell displays output. +Add new subsection "User output in Shell". + +bpo-35099: Improve the doc about IDLE running user code. +"IDLE -- console differences" is renamed "Running user code". +It mostly covers the implications of using custom sys.stdxxx objects. + +bpo-35097: Add IDLE doc subsection explaining editor windows. +Topics include opening, title and status bars, .py* extension, and running. + +Issue 35093: Document the IDLE document viewer in the IDLE doc. +Add a paragraph in "Help and preferences", "Help sources" subsection. + +bpo-1529353: Explain Shell text squeezing in the IDLE doc. + +bpo-35088: Update idlelib.help.copy_string docstring. +We now use git and backporting instead of hg and forward merging. + +bpo-35087: Update idlelib help files for the current doc build. +The main change is the elimination of chapter-section numbers. + +bpo-1529353: Output over N lines (50 by default) is squeezed down to a button. +N can be changed in the PyShell section of the General page of the +Settings dialog. Fewer, but possibly extra long, lines can be squeezed by +right clicking on the output. Squeezed output can be expanded in place +by double-clicking the button or into the clipboard or a separate window +by right-clicking the button. + +bpo-34548: Use configured color theme for read-only text views. + +bpo-33839: Refactor ToolTip and CallTip classes; add documentation +and tests. + +bpo-34047: Fix mouse wheel scrolling direction on macOS. + +bpo-34275: Make calltips always visible on Mac. +Patch by Kevin Walzer. + +bpo-34120: Fix freezing after closing some dialogs on Mac. +This is one of multiple regressions from using newer tcl/tk. + +bpo-33975: Avoid small type when running htests. +Since part of the purpose of human-viewed tests is to determine that +widgets look right, it is important that they look the same for +testing as when running IDLE. + +bpo-33905: Add test for idlelib.stackview.StackBrowser. + +bpo-33924: Change mainmenu.menudefs key 'windows' to 'window'. +Every other menudef key is the lowercase version of the +corresponding main menu entry (in this case, 'Window'). + +bpo-33906: Rename idlelib.windows as window +Match Window on the main menu and remove last plural module name. +Change imports, test, and attribute references to match new name. + +bpo-33917: Fix and document idlelib/idle_test/template.py. +The revised file compiles, runs, and tests OK. idle_test/README.txt +explains how to use it to create new IDLE test files. + +bpo-33904: In rstrip module, rename class RstripExtension as Rstrip. + +bpo-33907: For consistency and clarity, rename calltip objects. +Module calltips and its class CallTips are now calltip and Calltip. +In module calltip_w, class CallTip is now CalltipWindow. + +bpo-33855: Minimally test all IDLE modules. +Standardize the test file format. Add missing test files that import +the tested module and perform at least one test. Check and record the +coverage of each test. + +bpo-33856: Add 'help' to Shell's initial welcome message. + + +What's New in IDLE 3.7.0 (since 3.6.0) +Released on 2018-06-27 +====================================== + +bpo-33656: On Windows, add API call saying that tk scales for DPI. +On Windows 8.1+ or 10, with DPI compatibility properties of the Python +binary unchanged, and a monitor resolution greater than 96 DPI, this +should make text and lines sharper and some colors brighter. +On other systems, it should have no effect. If you have a custom theme, +you may want to adjust a color or two. If perchance it make text worse +on your monitor, you can disable the ctypes.OleDLL call near the top of +pyshell.py and report the problem on python-list or idle-dev@python.org. + +bpo-33768: Clicking on a context line moves that line to the top +of the editor window. + +bpo-33763: Replace the code context label widget with a text widget. + +bpo-33664: Scroll IDLE editor text by lines. +(Previously, the mouse wheel and scrollbar slider moved text by a fixed +number of pixels, resulting in partial lines at the top of the editor +box.) This change also applies to the shell and grep output windows, +but currently not to read-only text views. + +bpo-33679: Enable theme-specific color configuration for Code Context. +(Previously, there was one code context foreground and background font +color setting, default or custom, on the extensions tab, that applied +to all themes.) For built-in themes, the foreground is the same as +normal text and the background is a contrasting gray. Context colors for +custom themes are set on the Highlights tab along with other colors. +When one starts IDLE from a console and loads a custom theme without +definitions for 'context', one will see a warning message on the +console. + +bpo-33642: Display up to maxlines non-blank lines for Code Context. +If there is no current context, show a single blank line. (Previously, +the Code Context had numlines lines, usually with some blank.) The use +of a new option, 'maxlines' (default 15), avoids possible interference +with user settings of the old option, 'numlines' (default 3). + +bpo-33628: Cleanup codecontext.py and its test. + +bpo-32831: Add docstrings and tests for codecontext.py. +Coverage is 100%. Patch by Cheryl Sabella. + +bpo-33564: Code context now recognizes async as a block opener. + +bpo-21474: Update word/identifier definition from ascii to unicode. +In text and entry boxes, this affects selection by double-click, +movement left/right by control-left/right, and deletion left/right +by control-BACKSPACE/DEL. + +bpo-33204: Consistently color invalid string prefixes. +A 'u' string prefix cannot be paired with either 'r' or 'f'. +IDLE now consistently colors as much of the prefix, starting at the +right, as is valid. Revise and extend colorizer test. + +bpo-32984: Set __file__ while running a startup file. +Like Python, IDLE optionally runs 1 startup file in the Shell window +before presenting the first interactive input prompt. For IDLE, +option -s runs a file named in environmental variable IDLESTARTUP or +PYTHONSTARTUP; -r file runs file. Python sets __file__ to the startup +file name before running the file and unsets it before the first +prompt. IDLE now does the same when run normally, without the -n +option. + +bpo-32940: Replace StringTranslatePseudoMapping with faster code. + +bpo-32916: Change 'str' to 'code' in idlelib.pyparse and users. + +bpo-32905: Remove unused code in pyparse module. + +bpo-32874: IDLE - add pyparse tests with 97% coverage. + +bpo-32837: IDLE - require encoding argument for textview.view_file. +Using the system and place-dependent default encoding for open() +is a bad idea for IDLE's system and location-independent files. + +bpo-32826: Add "encoding=utf-8" to open() in IDLE's test_help_about. +GUI test test_file_buttons() only looks at initial ascii-only lines, +but failed on systems where open() defaults to 'ascii' because +readline() internally reads and decodes far enough ahead to encounter +a non-ascii character in CREDITS.txt. + +bpo-32765: Update configdialog General tab create page docstring. +Add new widgets to the widget list. + +bpo-32207: Improve tk event exception tracebacks in IDLE. +When tk event handling is driven by IDLE's run loop, a confusing +and distracting queue.EMPTY traceback context is no longer added +to tk event exception tracebacks. The traceback is now the same +as when event handling is driven by user code. Patch based on +a suggestion by Serhiy Storchaka. + +bpo-32164: Delete unused file idlelib/tabbedpages.py. +Use of TabbedPageSet in configdialog was replaced by ttk.Notebook. + +bpo-32100: Fix old and new bugs in pathbrowser; improve tests. +Patch mostly by Cheryl Sabella. + +bpo-31860: The font sample in the settings dialog is now editable. +Edits persist while IDLE remains open. +Patch by Serhiy Storchake and Terry Jan Reedy. + +bpo-31858: Restrict shell prompt manipulation to the shell. +Editor and output windows only see an empty last prompt line. This +simplifies the code and fixes a minor bug when newline is inserted. +Sys.ps1, if present, is read on Shell start-up, but is not set or changed. +Patch by Terry Jan Reedy. + +bpo-28603: Fix a TypeError that caused a shell restart when printing +a traceback that includes an exception that is unhashable. +Patch by Zane Bitter. + +bpo-13802: Use non-Latin characters in the Font settings sample. +Even if one selects a font that defines a limited subset of the unicode +Basic Multilingual Plane, tcl/tk will use other fonts that define a +character. The expanded example give users of non-Latin characters +a better idea of what they might see in the shell and editors. + +To make room for the expanded sample, frames on the Font tab are +re-arranged. The Font/Tabs help explains a bit about the additions. +Patch by Terry Jan Reedy + +bpo-31460: Simplify the API of IDLE's Module Browser. +Passing a widget instead of an flist with a root widget opens the +option of creating a browser frame that is only part of a window. +Passing a full file name instead of pieces assumed to come from a +.py file opens the possibility of browsing python files that do not +end in .py. + +bpo-31649: Make _htest and _utest parameters keyword-only. +These are used to adjust code for human and unit tests. + +bpo-31459: Rename module browser from Class Browser to Module Browser. +The original module-level class and method browser became a module +browser, with the addition of module-level functions, years ago. +Nested classes and functions were added yesterday. For back- +compatibility, the virtual event <>, which +appears on the Keys tab of the Settings dialog, is not changed. +Patch by Cheryl Sabella. + +bpo-1612262: Module browser now shows nested classes and functions. +Original patches for code and tests by Guilherme Polo and +Cheryl Sabella, respectively. Revisions by Terry Jan Reedy. + +bpo-31500: Tk's default fonts now are scaled on HiDPI displays. +This affects all dialogs. Patch by Serhiy Storchaka. + +bpo-31493: Fix code context update and font update timers. +Canceling timers prevents a warning message when test_idle completes. + +bpo-31488: Update non-key options in former extension classes. +When applying configdialog changes, call .reload for each feature class. +Change ParenMatch so updated options affect existing instances attached +to existing editor windows. + +bpo-31477: Improve rstrip entry in IDLE doc. +Strip Trailing Whitespace strips more than blank spaces. +Multiline string literals are not skipped. + +bpo-31480: fix tests to pass with zzdummy extension disabled. (#3590) +To see the example in action, enable it on options extensions tab. + +bpo-31421: Document how IDLE runs tkinter programs. +IDLE calls tcl/tk update in the background in order to make live +interaction and experimentation with tkinter applications much easier. + +bpo-31414: Fix tk entry box tests by deleting first. +Adding to an int entry is not the same as deleting and inserting +because int('') will fail. Patch by Terry Jan Reedy. + +bpo-27099: Convert IDLE's built-in 'extensions' to regular features. + About 10 IDLE features were implemented as supposedly optional +extensions. Their different behavior could be confusing or worse for +users and not good for maintenance. Hence the conversion. + The main difference for users is that user configurable key bindings +for builtin features are now handled uniformly. Now, editing a binding +in a keyset only affects its value in the keyset. All bindings are +defined together in the system-specific default keysets in config- +extensions.def. All custom keysets are saved as a whole in config- +extension.cfg. All take effect as soon as one clicks Apply or Ok. + The affected events are '<>', +'<>', '<>', '<>', +'<>', '<>', '<>', and +'<>'. Any (global) customizations made before 3.6.3 will +not affect their keyset-specific customization after 3.6.3. and vice +versa. + Initial patch by Charles Wohlganger, revised by Terry Jan Reedy. + +bpo-31051: Rearrange configdialog General tab. +Sort non-Help options into Window (Shell+Editor) and Editor (only). +Leave room for the addition of new options. +Patch by Terry Jan Reedy. + +bpo-30617: Add docstrings and tests for outwin subclass of editor. +Move some data and functions from the class to module level. +Patch by Cheryl Sabella. + +bpo-31287: Do not modify tkinter.messagebox in test_configdialog. +Instead, mask it with an instance mock that can be deleted. +Patch by Terry Jan Reedy. + +bpo-30781: Use ttk widgets in ConfigDialog pages. +These should especially look better on MacOSX. +Patches by Terry Jan Reedy and Cheryl Sabella. + +bpo-31206: Factor HighPage(Frame) class from ConfigDialog. +Patch by Cheryl Sabella. + +bp0-31001: Add tests for configdialog highlight tab. +Patch by Cheryl Sabella. + +bpo-31205: Factor KeysPage(Frame) class from ConfigDialog. +The slightly modified tests continue to pass. +Patch by Cheryl Sabella. + +bpo-31002: Add tests for configdialog keys tab. +Patch by Cheryl Sabella. + +bpo-19903: Change calltipes to use inspect.signature. +Idlelib.calltips.get_argspec now uses inspect.signature instead of +inspect.getfullargspec, like help() does. This improves the signature +in the call tip in a few different cases, including builtins converted +to provide a signature. A message is added if the object is not +callable, has an invalid signature, or if it has positional-only +parameters. Patch by Louie Lu. + +bop-31083: Add an outline of a TabPage class in configdialog. +Add template as comment. Update existing classes to match outline. +Initial patch by Cheryl Sabella. + +bpo-31050: Factor GenPage(Frame) class from ConfigDialog. +The slightly modified tests for the General tab continue to pass. +Patch by Cheryl Sabella. + +bpo-31004: Factor FontPage(Frame) class from ConfigDialog. +The slightly modified tests continue to pass. The General test +broken by the switch to ttk.Notebook is fixed. +Patch mostly by Cheryl Sabella. + +bpo-30781: IDLE - Use ttk Notebook in ConfigDialog. +This improves navigation by tabbing. +Patch by Terry Jan Reedy. + +bpo-31060: IDLE - Finish rearranging methods of ConfigDialog. +Grouping methods pertaining to each tab and the buttons will aid +writing tests and improving the tabs and will enable splitting the +groups into classes. +Patch by Terry Jan Reedy. + +bpo-30853: IDLE -- Factor a VarTrace class out of ConfigDialog. +Instance tracers manages pairs consisting of a tk variable and a +callback function. When tracing is turned on, setting the variable +calls the function. Test coverage for the new class is 100%. +Patch by Terry Jan Reedy. + +bpo-31003: IDLE: Add more tests for General tab. +Patch by Terry Jan Reedy. + +bpo-30993: IDLE - Improve configdialog font page and tests. +*In configdialog: Document causal pathways in create_font_tab +docstring. Simplify some attribute names. Move set_samples calls to +var_changed_font (idea from Cheryl Sabella). Move related functions to +positions after the create widgets function. +* In test_configdialog: Fix test_font_set so not order dependent. Fix +renamed test_indent_scale so it tests the widget. Adjust tests for +movement of set_samples call. Add tests for load functions. Put all +font tests in one class and tab indent tests in another. Except for +two lines, these tests completely cover the related functions. +Patch by Terry Jan Reedy. + +bpo-30981: IDLE -- Add more configdialog font page tests. + +bpo-28523: IDLE: replace 'colour' with 'color' in configdialog. + +bpo-30917: Add tests for idlelib.config.IdleConf. +Increase coverage from 46% to 96%. +Patch by Louie Lu. + +bpo-30913: Document ConfigDialog tk Vars, methods, and widgets in docstrings +This will facilitate improving the dialog and splitting up the class. +Original patch by Cheryl Sabella. + +bpo-30899: Add tests for ConfigParser subclasses in config. +Coverage is 100% for those classes and ConfigChanges. +Patch by Louie Lu. + +bpo-30881: Add docstrings to browser.py. +Patch by Cheryl Sabella. + +bpo-30851: Remove unused tk variables in configdialog. +One is a duplicate, one is set but cannot be altered by users. +Patch by Cheryl Sabella. + +bpo-30870: Select font option with Up and Down keys, as well as with mouse. +Added test increases configdialog coverage to 60% +Patches mostly by Louie Lu. + +bpo-8231: Call config.IdleConf.GetUserCfgDir only once per process. + +bpo-30779: Factor ConfigChanges class from configdialog, put in config; test. +* In config, put dump test code in a function; run it and unittest in + 'if __name__ == '__main__'. +* Add class config.ConfigChanges based on changes_class_v4.py on bpo issue. +* Add class test_config.ChangesTest, partly using configdialog_tests_v1.py. +* Revise configdialog to use ConfigChanges; see tracker msg297804. +* Revise test_configdialog to match configdialog changes. +* Remove configdialog functions unused or moved to ConfigChanges. +Cheryl Sabella contributed parts of the patch. + +bpo-30777: Configdialog - add docstrings and improve comments. +Patch by Cheryl Sabella. + +bpo-30495: Improve textview with docstrings, PEP8 names, and more tests. +Split TextViewer class into ViewWindow, ViewFrame, and TextFrame classes +so that instances of the latter two can be placed with other widgets +within a multiframe window. +Patches by Cheryl Sabella and Terry Jan Reedy. + +bpo-30723: Make several improvements to parenmatch. +* Add 'parens' style to highlight both opener and closer. +* Make 'default' style, which is not default, a synonym for 'opener'. +* Make time-delay work the same with all styles. +* Add help for config dialog extensions tab, including parenmatch. +* Add new tests. +Original patch by Charles Wohlganger. Revisions by Terry Jan Reedy + +bpo-30674: Grep -- Add docstrings. Patch by Cheryl Sabella. + +bpo-21519: IDLE's basic custom key entry dialog now detects +duplicates properly. Original patch by Saimadhav Heblikar. + +bpo-29910: IDLE no longer deletes a character after commenting out a +region by a key shortcut. Add "return 'break'" for this and other +potential conflicts between IDLE and default key bindings. +Patch by Serhiy Storchaka. + +bpo-30728: Modernize idlelib.configdialog: +* replace import * with specific imports; +* lowercase method and attribute lines. +Patch by Cheryl Sabella. + +bpo-6739: Verify user-entered key sequences by trying to bind them +with to a tk widget. Add tests for all 3 validation functions. +Original patch by G Polo. Tests added by Cheryl Sabella. +Code revised and more tests added by Terry Jan Reedy + +bpo-24813: Add icon to help_about and make other changes. + +bpo-15786: Fix several problems with IDLE's autocompletion box. +The following should now work: clicking on selection box items; +using the scrollbar; selecting an item by hitting Return. +Hangs on MacOSX should no longer happen. Patch by Louie Lu. + +bpo-25514: Add doc subsubsection about IDLE failure to start. +Popup no-connection message directs users to this section. + +bpo-30642: Fix reference leaks in IDLE tests. +Patches by Louie Lu and Terry Jan Reedy. + +bpo-30495: Add docstrings for textview.py and use PEP8 names. +Patches by Cheryl Sabella and Terry Jan Reedy. + +bpo-30290: Help-about: use pep8 names and add tests. +Increase coverage to 100%. +Patches by Louie Lu, Cheryl Sabella, and Terry Jan Reedy. + +bpo-30303: Add _utest option to textview; add new tests. +Increase coverage to 100%. +Patches by Louie Lu and Terry Jan Reedy. + +Issue #29071: IDLE colors f-string prefixes but not invalid ur prefixes. + +Issue #28572: Add 10% to coverage of IDLE's test_configdialog. +Update and augment description of the configuration system. + + +What's New in IDLE 3.6.0 (since 3.5.0) +Released on 2016-12-23 +====================================== + +- Issue #15308: Add 'interrupt execution' (^C) to Shell menu. + Patch by Roger Serwy, updated by Bayard Randel. + +- Issue #27922: Stop IDLE tests from 'flashing' gui widgets on the screen. + +- Issue #27891: Consistently group and sort imports within idlelib modules. + +- Issue #17642: add larger font sizes for classroom projection. + +- Add version to title of IDLE help window. + +- Issue #25564: In section on IDLE -- console differences, mention that + using exec means that __builtins__ is defined for each statement. + +- Issue #27821: Fix 3.6.0a3 regression that prevented custom key sets + from being selected when no custom theme was defined. + +- Issue #27714: text_textview and test_autocomplete now pass when re-run + in the same process. This occurs when test_idle fails when run with the + -w option but without -jn. Fix warning from test_config. + +- Issue #27621: Put query response validation error messages in the query + box itself instead of in a separate messagebox. Redo tests to match. + Add Mac OSX refinements. Original patch by Mark Roseman. + +- Issue #27620: Escape key now closes Query box as cancelled. + +- Issue #27609: IDLE: tab after initial whitespace should tab, not + autocomplete. This fixes problem with writing docstrings at least + twice indented. + +- Issue #27609: Explicitly return None when there are also non-None + returns. In a few cases, reverse a condition and eliminate a return. + +- Issue #25507: IDLE no longer runs buggy code because of its tkinter imports. + Users must include the same imports required to run directly in Python. + +- Issue #27173: Add 'IDLE Modern Unix' to the built-in key sets. + Make the default key set depend on the platform. + Add tests for the changes to the config module. + +- Issue #27452: add line counter and crc to IDLE configHandler test dump. + +- Issue #27477: IDLE search dialogs now use ttk widgets. + +- Issue #27173: Add 'IDLE Modern Unix' to the built-in key sets. + Make the default key set depend on the platform. + Add tests for the changes to the config module. + +- Issue #27452: make command line "idle-test> python test_help.py" work. + __file__ is relative when python is started in the file's directory. + +- Issue #27452: add line counter and crc to IDLE configHandler test dump. + +- Issue #27380: IDLE: add query.py with base Query dialog and ttk widgets. + Module had subclasses SectionName, ModuleName, and HelpSource, which are + used to get information from users by configdialog and file =>Load Module. + Each subclass has itw own validity checks. Using ModuleName allows users + to edit bad module names instead of starting over. + Add tests and delete the two files combined into the new one. + +- Issue #27372: Test_idle no longer changes the locale. + +- Issue #27365: Allow non-ascii chars in IDLE NEWS.txt, for contributor names. + +- Issue #27245: IDLE: Cleanly delete custom themes and key bindings. + Previously, when IDLE was started from a console or by import, a cascade + of warnings was emitted. Patch by Serhiy Storchaka. + +- Issue #24137: Run IDLE, test_idle, and htest with tkinter default root disabled. + Fix code and tests that fail with this restriction. + Fix htests to not create a second and redundant root and mainloop. + +- Issue #27310: Fix IDLE.app failure to launch on OS X due to vestigial import. + +- Issue #5124: Paste with text selected now replaces the selection on X11. + This matches how paste works on Windows, Mac, most modern Linux apps, + and ttk widgets. Original patch by Serhiy Storchaka. + +- Issue #24750: Switch all scrollbars in IDLE to ttk versions. + Where needed, minimal tests are added to cover changes. + +- Issue #24759: IDLE requires tk 8.5 and availability ttk widgets. + Delete now unneeded tk version tests and code for older versions. + Add test for IDLE syntax colorizer. + +- Issue #27239: idlelib.macosx.isXyzTk functions initialize as needed. + +- Issue #27262: move Aqua unbinding code, which enable context menus, to macosx. + +- Issue #24759: Make clear in idlelib.idle_test.__init__ that the directory + is a private implementation of test.test_idle and tool for maintainers. + +- Issue #27196: Stop 'ThemeChanged' warnings when running IDLE tests. + These persisted after other warnings were suppressed in #20567. + Apply Serhiy Storchaka's update_idletasks solution to four test files. + Record this additional advice in idle_test/README.txt + +- Issue #20567: Revise idle_test/README.txt with advice about avoiding + tk warning messages from tests. Apply advice to several IDLE tests. + +- Issue # 24225: Update idlelib/README.txt with new file names + and event handlers. + +- Issue #27156: Remove obsolete code not used by IDLE. Replacements: + 1. help.txt, replaced by help.html, is out-of-date and should not be used. + Its dedicated viewer has be replaced by the html viewer in help.py. + 2. 'import idlever; I = idlever.IDLE_VERSION' is the same as + 'import sys; I = version[:version.index(' ')]' + 3. After 'ob = stackviewer.VariablesTreeItem(*args)', + 'ob.keys()' == 'list(ob.object.keys). + 4. In macosc, runningAsOSXAPP == isAquaTk; idCarbonAquaTk == isCarbonTk + +- Issue #27117: Make colorizer htest and turtledemo work with dark themes. + Move code for configuring text widget colors to a new function. + +- Issue #24225: Rename many idlelib/*.py and idle_test/test_*.py files. + Edit files to replace old names with new names when the old name + referred to the module rather than the class it contained. + See the issue and IDLE section in What's New in 3.6 for more. + +- Issue #26673: When tk reports font size as 0, change to size 10. + Such fonts on Linux prevented the configuration dialog from opening. + +- Issue #21939: Add test for IDLE's percolator. + Original patch by Saimadhav Heblikar. + +- Issue #21676: Add test for IDLE's replace dialog. + Original patch by Saimadhav Heblikar. + +- Issue #18410: Add test for IDLE's search dialog. + Original patch by Westley Martínez. + +- Issue #21703: Add test for undo delegator. Patch mostly by + Saimadhav Heblikar . + +- Issue #27044: Add ConfigDialog.remove_var_callbacks to stop memory leaks. + +- Issue #23977: Add more asserts to test_delegator. + +- Issue #20640: Add tests for idlelib.configHelpSourceEdit. + Patch by Saimadhav Heblikar. + +- In the 'IDLE-console differences' section of the IDLE doc, clarify + how running with IDLE affects sys.modules and the standard streams. + +- Issue #25507: fix incorrect change in IOBinding that prevented printing. + Augment IOBinding htest to include all major IOBinding functions. + +- Issue #25905: Revert unwanted conversion of ' to ’ RIGHT SINGLE QUOTATION + MARK in README.txt and open this and NEWS.txt with 'ascii'. + Re-encode CREDITS.txt to utf-8 and open it with 'utf-8'. + +- Issue 15348: Stop the debugger engine (normally in a user process) + before closing the debugger window (running in the IDLE process). + This prevents the RuntimeErrors that were being caught and ignored. + +- Issue #24455: Prevent IDLE from hanging when a) closing the shell while the + debugger is active (15347); b) closing the debugger with the [X] button + (15348); and c) activating the debugger when already active (24455). + The patch by Mark Roseman does this by making two changes. + 1. Suspend and resume the gui.interaction method with the tcl vwait + mechanism intended for this purpose (instead of root.mainloop & .quit). + 2. In gui.run, allow any existing interaction to terminate first. + +- Change 'The program' to 'Your program' in an IDLE 'kill program?' message + to make it clearer that the program referred to is the currently running + user program, not IDLE itself. + +- Issue #24750: Improve the appearance of the IDLE editor window status bar. + Patch by Mark Roseman. + +- Issue #25313: Change the handling of new built-in text color themes to better + address the compatibility problem introduced by the addition of IDLE Dark. + Consistently use the revised idleConf.CurrentTheme everywhere in idlelib. + +- Issue #24782: Extension configuration is now a tab in the IDLE Preferences + dialog rather than a separate dialog. The former tabs are now a sorted + list. Patch by Mark Roseman. + +- Issue #22726: Re-activate the config dialog help button with some content + about the other buttons and the new IDLE Dark theme. + +- Issue #24820: IDLE now has an 'IDLE Dark' built-in text color theme. + It is more or less IDLE Classic inverted, with a cobalt blue background. + Strings, comments, keywords, ... are still green, red, orange, ... . + To use it with IDLEs released before November 2015, hit the + 'Save as New Custom Theme' button and enter a new name, + such as 'Custom Dark'. The custom theme will work with any IDLE + release, and can be modified. + +- Issue #25224: README.txt is now an idlelib index for IDLE developers and + curious users. The previous user content is now in the IDLE doc chapter. + 'IDLE' now means 'Integrated Development and Learning Environment'. + +- Issue #24820: Users can now set breakpoint colors in + Settings -> Custom Highlighting. Original patch by Mark Roseman. + +- Issue #24972: Inactive selection background now matches active selection + background, as configured by users, on all systems. Found items are now + always highlighted on Windows. Initial patch by Mark Roseman. + +- Issue #24570: Idle: make calltip and completion boxes appear on Macs + affected by a tk regression. Initial patch by Mark Roseman. + +- Issue #24988: Idle ScrolledList context menus (used in debugger) + now work on Mac Aqua. Patch by Mark Roseman. + +- Issue #24801: Make right-click for context menu work on Mac Aqua. + Patch by Mark Roseman. + +- Issue #25173: Associate tkinter messageboxes with a specific widget. + For Mac OSX, make them a 'sheet'. Patch by Mark Roseman. + +- Issue #25198: Enhance the initial html viewer now used for Idle Help. + * Properly indent fixed-pitch text (patch by Mark Roseman). + * Give code snippet a very Sphinx-like light blueish-gray background. + * Re-use initial width and height set by users for shell and editor. + * When the Table of Contents (TOC) menu is used, put the section header + at the top of the screen. + +- Issue #25225: Condense and rewrite Idle doc section on text colors. + +- Issue #21995: Explain some differences between IDLE and console Python. + +- Issue #22820: Explain need for *print* when running file from Idle editor. + +- Issue #25224: Doc: augment Idle feature list and no-subprocess section. + +- Issue #25219: Update doc for Idle command line options. + Some were missing and notes were not correct. + +- Issue #24861: Most of idlelib is private and subject to change. + Use idleib.idle.* to start Idle. See idlelib.__init__.__doc__. + +- Issue #25199: Idle: add synchronization comments for future maintainers. + +- Issue #16893: Replace help.txt with help.html for Idle doc display. + The new idlelib/help.html is rstripped Doc/build/html/library/idle.html. + It looks better than help.txt and will better document Idle as released. + The tkinter html viewer that works for this file was written by Mark Roseman. + The now unused EditorWindow.HelpDialog class and helt.txt file are deprecated. + +- Issue #24199: Deprecate unused idlelib.idlever with possible removal in 3.6. + +- Issue #24790: Remove extraneous code (which also create 2 & 3 conflicts). + + +What's New in IDLE 3.5.0? +========================= +*Release date: 2015-09-13* + +- Issue #23672: Allow Idle to edit and run files with astral chars in name. + Patch by Mohd Sanad Zaki Rizvi. + +- Issue 24745: Idle editor default font. Switch from Courier to + platform-sensitive TkFixedFont. This should not affect current customized + font selections. If there is a problem, edit $HOME/.idlerc/config-main.cfg + and remove 'fontxxx' entries from [Editor Window]. Patch by Mark Roseman. + +- Issue #21192: Idle editor. When a file is run, put its name in the restart bar. + Do not print false prompts. Original patch by Adnan Umer. + +- Issue #13884: Idle menus. Remove tearoff lines. Patch by Roger Serwy. + +- Issue #23184: remove unused names and imports in idlelib. + Initial patch by Al Sweigart. + +- Issue #20577: Configuration of the max line length for the FormatParagraph + extension has been moved from the General tab of the Idle preferences dialog + to the FormatParagraph tab of the Config Extensions dialog. + Patch by Tal Einat. + +- Issue #16893: Update Idle doc chapter to match current Idle and add new + information. + +- Issue #3068: Add Idle extension configuration dialog to Options menu. + Changes are written to HOME/.idlerc/config-extensions.cfg. + Original patch by Tal Einat. + +- Issue #16233: A module browser (File : Class Browser, Alt+C) requires an + editor window with a filename. When Class Browser is requested otherwise, + from a shell, output window, or 'Untitled' editor, Idle no longer displays + an error box. It now pops up an Open Module box (Alt+M). If a valid name + is entered and a module is opened, a corresponding browser is also opened. + +- Issue #4832: Save As to type Python files automatically adds .py to the + name you enter (even if your system does not display it). Some systems + automatically add .txt when type is Text files. + +- Issue #21986: Code objects are not normally pickled by the pickle module. + To match this, they are no longer pickled when running under Idle. + +- Issue #23180: Rename IDLE "Windows" menu item to "Window". + Patch by Al Sweigart. + +- Issue #17390: Adjust Editor window title; remove 'Python', + move version to end. + +- Issue #14105: Idle debugger breakpoints no longer disappear + when inserting or deleting lines. + +- Issue #17172: Turtledemo can now be run from Idle. + Currently, the entry is on the Help menu, but it may move to Run. + Patch by Ramchandra Apt and Lita Cho. + +- Issue #21765: Add support for non-ascii identifiers to HyperParser. + +- Issue #21940: Add unittest for WidgetRedirector. Initial patch by Saimadhav + Heblikar. + +- Issue #18592: Add unittest for SearchDialogBase. Patch by Phil Webster. + +- Issue #21694: Add unittest for ParenMatch. Patch by Saimadhav Heblikar. + +- Issue #21686: add unittest for HyperParser. Original patch by Saimadhav + Heblikar. + +- Issue #12387: Add missing upper(lower)case versions of default Windows key + bindings for Idle so Caps Lock does not disable them. Patch by Roger Serwy. + +- Issue #21695: Closing a Find-in-files output window while the search is + still in progress no longer closes Idle. + +- Issue #18910: Add unittest for textView. Patch by Phil Webster. + +- Issue #18292: Add unittest for AutoExpand. Patch by Saihadhav Heblikar. + +- Issue #18409: Add unittest for AutoComplete. Patch by Phil Webster. + +- Issue #21477: htest.py - Improve framework, complete set of tests. + Patches by Saimadhav Heblikar + +- Issue #18104: Add idlelib/idle_test/htest.py with a few sample tests to begin + consolidating and improving human-validated tests of Idle. Change other files + as needed to work with htest. Running the module as __main__ runs all tests. + +- Issue #21139: Change default paragraph width to 72, the PEP 8 recommendation. + +- Issue #21284: Paragraph reformat test passes after user changes reformat width. + +- Issue #17654: Ensure IDLE menus are customized properly on OS X for + non-framework builds and for all variants of Tk. + + +What's New in IDLE 3.4.0? +========================= +*Release date: 2014-03-16* + +- Issue #17390: Display Python version on Idle title bar. + Initial patch by Edmond Burnett. + +- Issue #5066: Update IDLE docs. Patch by Todd Rovito. + +- Issue #17625: Close the replace dialog after it is used. + +- Issue #16226: Fix IDLE Path Browser crash. + (Patch by Roger Serwy) + +- Issue #15853: Prevent IDLE crash on OS X when opening Preferences menu + with certain versions of Tk 8.5. Initial patch by Kevin Walzer. + + +What's New in IDLE 3.3.0? +========================= +*Release date: 2012-09-29* + +- Issue #17625: Close the replace dialog after it is used. + +- Issue #7163: Propagate return value of sys.stdout.write. + +- Issue #15318: Prevent writing to sys.stdin. + +- Issue #4832: Modify IDLE to save files with .py extension by + default on Windows and OS X (Tk 8.5) as it already does with X11 Tk. + +- Issue #13532, #15319: Check that arguments to sys.stdout.write are strings. + +- Issue # 12510: Attempt to get certain tool tips no longer crashes IDLE. + Erroneous tool tips have been corrected. Default added for callables. + +- Issue #10365: File open dialog now works instead of crashing even when + parent window is closed while dialog is open. + +- Issue 14876: use user-selected font for highlight configuration. + +- Issue #14937: Perform auto-completion of filenames in strings even for + non-ASCII filenames. Likewise for identifiers. + +- Issue #8515: Set __file__ when run file in IDLE. + Initial patch by Bruce Frederiksen. + +- IDLE can be launched as `python -m idlelib` + +- Issue #14409: IDLE now properly executes commands in the Shell window + when it cannot read the normal config files on startup and + has to use the built-in default key bindings. + There was previously a bug in one of the defaults. + +- Issue #3573: IDLE hangs when passing invalid command line args + (directory(ies) instead of file(s)). + +- Issue #14018: Update checks for unstable system Tcl/Tk versions on OS X + to include versions shipped with OS X 10.7 and 10.8 in addition to 10.6. + + +What's New in IDLE 3.2.1? +========================= +*Release date: 15-May-11* + +- Issue #6378: Further adjust idle.bat to start associated Python + +- Issue #11896: Save on Close failed despite selecting "Yes" in dialog. + +- Issue #1028: Ctrl-space binding to show completions was causing IDLE to exit. + Tk < 8.5 was sending invalid Unicode null; replaced with valid null. + +- Issue #4676: toggle failing on Tk 8.5, causing IDLE exits and strange selection + behavior. Improve selection extension behaviour. + +- Issue #3851: toggle non-functional when NumLock set on Windows. + + +What's New in IDLE 3.1b1? +========================= +*Release date: 06-May-09* + +- Issue #5707: Use of 'filter' in keybindingDialog.py was causing custom key assignment to + fail. Patch by Amaury Forgeot d'Arc. + +- Issue #4815: Offer conversion to UTF-8 if source files have + no encoding declaration and are not encoded in UTF-8. + +- Issue #4008: Fix problems with non-ASCII source files. + +- Issue #4323: Always encode source as UTF-8 without asking + the user (unless a different encoding is declared); remove + user configuration of source encoding; all according to + PEP 3120. + +- Issue #2665: On Windows, an IDLE installation upgraded from an old version + would not start if a custom theme was defined. + +------------------------------------------------------------------------ +Refer to NEWS2x.txt and HISTORY.txt for information on earlier releases. +------------------------------------------------------------------------ diff --git a/Lib/idlelib/README.txt b/Lib/idlelib/README.txt new file mode 100644 index 000000000..76aec5891 --- /dev/null +++ b/Lib/idlelib/README.txt @@ -0,0 +1,290 @@ +README.txt: an index to idlelib files and the IDLE menu. + +IDLE is Python's Integrated Development and Learning +Environment. The user documentation is part of the Library Reference and +is available in IDLE by selecting Help => IDLE Help. This README documents +idlelib for IDLE developers and curious users. + +IDLELIB FILES lists files alphabetically by category, +with a short description of each. + +IDLE MENU show the menu tree, annotated with the module +or module object that implements the corresponding function. + +This file is descriptive, not prescriptive, and may have errors +and omissions and lag behind changes in idlelib. + + +IDLELIB FILES +============= + +Implementation files not in IDLE MENU are marked (nim). + +Startup +------- +__init__.py # import, does nothing +__main__.py # -m, starts IDLE +idle.bat +idle.py +idle.pyw + +Implementation +-------------- +autocomplete.py # Complete attribute names or filenames. +autocomplete_w.py # Display completions. +autoexpand.py # Expand word with previous word in file. +browser.py # Create module browser window. +calltip.py # Create calltip text. +calltip_w.py # Display calltip. +codecontext.py # Show compound statement headers otherwise not visible. +colorizer.py # Colorize text (nim). +config.py # Load, fetch, and save configuration (nim). +configdialog.py # Display user configuration dialogs. +config_key.py # Change keybindings. +debugger.py # Debug code run from shell or editor; show window. +debugger_r.py # Debug code run in remote process. +debugobj.py # Define class used in stackviewer. +debugobj_r.py # Communicate objects between processes with rpc (nim). +delegator.py # Define base class for delegators (nim). +dynoption.py # Define mutable OptionMenu widget (nim) +editor.py # Define most of editor and utility functions. +filelist.py # Open files and manage list of open windows (nim). +format.py # Define format menu options. +grep.py # Find all occurrences of pattern in multiple files. +help.py # Display IDLE's html doc. +help_about.py # Display About IDLE dialog. +history.py # Get previous or next user input in shell (nim) +hyperparser.py # Parse code around a given index. +iomenu.py # Open, read, and write files +macosx.py # Help IDLE run on Macs (nim). +mainmenu.py # Define most of IDLE menu. +multicall.py # Wrap tk widget to allow multiple calls per event (nim). +outwin.py # Create window for grep output. +parenmatch.py # Match fenceposts: (), [], and {}. +pathbrowser.py # Create path browser window. +percolator.py # Manage delegator stack (nim). +pyparse.py # Give information on code indentation +pyshell.py # Start IDLE, manage shell, complete editor window +query.py # Query user for information +redirector.py # Intercept widget subcommands (for percolator) (nim). +replace.py # Search and replace pattern in text. +rpc.py # Communicate between idle and user processes (nim). +run.py # Manage user code execution subprocess. +runscript.py # Check and run user code. +scrolledlist.py # Define scrolledlist widget for IDLE (nim). +search.py # Search for pattern in text. +searchbase.py # Define base for search, replace, and grep dialogs. +searchengine.py # Define engine for all 3 search dialogs. +sidebar.py # Define line number and shell prompt sidebars. +squeezer.py # Squeeze long shell output (nim). +stackviewer.py # View stack after exception. +statusbar.py # Define status bar for windows (nim). +tabbedpages.py # Define tabbed pages widget (nim). +textview.py # Define read-only text widget (nim). +tooltip.py # Define popups for calltips, squeezer (nim). +tree.py # Define tree widget, used in browsers (nim). +undo.py # Manage undo stack. +util.py # Define common objects imported elsewhere (nim). +windows.py # Manage window list and define listed top level. +zoomheight.py # Zoom window to full height of screen. +zzdummy.py # Example extension. + +Configuration +------------- +config-extensions.def # Defaults for extensions +config-highlight.def # Defaults for colorizing +config-keys.def # Defaults for key bindings +config-main.def # Defaults for font and general tabs + +Text +---- +CREDITS.txt # not maintained, displayed by About IDLE +HISTORY.txt # NEWS up to July 2001 +NEWS.txt # commits, displayed by About IDLE +NEWS2.txt # commits to Python2 +README.txt # this file, displayed by About IDLE +TODO.txt # needs review +extend.txt # about writing extensions +help.html # copy of idle.html in docs, displayed by IDLE Help + +Subdirectories +-------------- +Icons # small image files +idle_test # files for human test and automated unit tests + + +IDLE MENUS +========== + +Top level items and most submenu items are defined in mainmenu. +Extensions add submenu items when active. The names given are +found, quoted, in one of these modules, paired with a '<>'. +Each pseudoevent is bound to an event handler. Some event handlers +call another function that does the actual work. The annotations below +are intended to at least give the module where the actual work is done. +'eEW' = editor.EditorWindow + +File + New File # eEW.new_callback + Open... # iomenu.open + Open Module # eEw.open_module + Recent Files + Class Browser # eEW.open_class_browser, browser.ClassBrowser + Path Browser # eEW.open_path_browser, pathbrowser + --- + Save # iomenu.save + Save As... # iomenu.save_as + Save Copy As... # iomenu.save_a_copy + --- + Print Window # iomenu.print_window + --- + Close # eEW.close_event + Exit # flist.close_all_callback (bound in eEW) + +Edit + Undo # undodelegator + Redo # undodelegator + --- # eEW.right_menu_event + Cut # eEW.cut + Copy # eEW.copy + Paste # eEW.past + Select All # eEW.select_all (+ see eEW.remove_selection) + --- # Next 5 items use searchengine; dialogs use searchbase + Find # eEW.find_event, search.SearchDialog.find + Find Again # eEW.find_again_event, sSD.find_again + Find Selection # eEW.find_selection_event, sSD.find_selection + Find in Files... # eEW.find_in_files_event, grep + Replace... # eEW.replace_event, replace.ReplaceDialog.replace + Go to Line # eEW.goto_line_event + Show Completions # autocomplete extension and autocompleteWidow (&HP) + Expand Word # autoexpand extension + Show call tip # Calltips extension and CalltipWindow (& Hyperparser) + Show surrounding parens # parenmatch (& Hyperparser) + +Format (Editor only) [fFR = format.FormatRegion] + Format Paragraph # format.FormatParagraph.format_paragraph_event + Indent Region # fFR.indent_region_event + Dedent Region # fFR.dedent_region_event + Comment Out Reg. # fFR.comment_region_event + Uncomment Region # fFR.uncomment_region_event + Tabify Region # fFR.tabify_region_event + Untabify Region # fFR.untabify_region_event + Toggle Tabs # format.Indents.toggle_tabs_event + New Indent Width # format.Indents.change_indentwidth_event + Strip tailing whitespace # format.rstrip + Zin # zzdummy + Zout # zzdummy + +Run (Editor only) + Run Module # runscript.ScriptBinding.run_module_event + Run... Customized # runscript.ScriptBinding.run_custom_event + Check Module # runscript.ScriptBinding.check_module_event + Python Shell # pyshell.Pyshell, pyshell.ModifiedInterpreter + +Shell # pyshell + View Last Restart # pyshell.PyShell.view_restart_mark + Restart Shell # pyshell.PyShell.restart_shell + Previous History # history.History.history_prev + Next History # history.History.history_next + Interrupt Execution # pyshell.PyShell.cancel_callback + +Debug (Shell only) + Go to File/Line # outwin.OutputWindow.goto_file_line + debugger # debugger, debugger_r, PyShell.toggle_debugger + Stack Viewer # stackviewer, PyShell.open_stack_viewer + Auto-open Stack Viewer # stackviewer + +Options + Configure IDLE # eEW.config_dialog, config, configdialog (cd) + (Parts of the dialog) + Buttons # cd.ConfigDialog + Font tab # cd.FontPage, config-main.def + Highlight tab # cd.HighPage, query, config-highlight.def + Keys tab # cd.KeysPage, query, config_key, config_keys.def + Windows tab # cd.WinPage, config_main.def + Shell/Ed tab # cd.ShedPage, config-main.def + Extensions tab # config-extensions.def, corresponding .py files + --- + ... Code Context # codecontext + ... Line Numbers # sidebar + Zoomheight # zoomheight + +Window + # windows + +Help + About IDLE # eEW.about_dialog, help_about.AboutDialog + --- + IDLE Help # eEW.help_dialog, help.show_idlehelp + Python Docs # eEW.python_docs + Turtle Demo # eEW.open_turtle_demo + --- + + + (right click) + Defined in editor, PyShell.pyshell + Cut + Copy + Paste + --- + Go to file/line (shell and output only) + Set Breakpoint (editor only) + Clear Breakpoint (editor only) + Defined in debugger + Go to source line + Show stack frame + + +Center Insert # eEW.center_insert_event + + +OTHER TOPICS +============ + +Generally use PEP 8. + +import statements +----------------- +Put imports at the top, unless there is a good reason otherwise. +PEP 8 says to group stdlib, 3rd-party dependencies, and package imports. +For idlelib, the groups are general stdlib, tkinter, and idlelib. +Sort modules within each group, except that tkinter.ttk follows tkinter. +Sort 'from idlelib import mod1' and 'from idlelib.mod2 import object' +together by module, ignoring within module objects. +Put 'import __main__' after other idlelib imports. + +Imports only needed for testing are put not at the top but in an +htest function def or "if __name__ == '__main__'" clause. + +Within module imports like "from idlelib.mod import class" may cause +circular imports to deadlock. Even without this, circular imports may +require at least one of the imports to be delayed until a function call. + +What's New entries +------------------ + +Repository directory Doc/whatsnew/ has a file 3.n.rst for each 3.n +Python version. For the first entry in each file, add subsection +'IDLE and idlelib', in alphabetical position, to the 'Improved Modules' +section. For the rest of cpython, entries to 3.(n+1).rst begin with +the release of 3.n.0b1. For IDLE, entries for features backported from +'main' to '3.n' during its beta period do not got in 3.(n+1).rst. The +latter usually gets its first entry during the 3.n.0 candidate period +or after the 3.n.0 release. + +When, as per PEP 434, feature changes are backported, entries are placed +in the 3.n.rst file *in the main branch* for each Python version n that +gets the backport. (Note: the format of entries have varied between +versions.) Add a line "New in 3.n maintenance releases." before the +first back-ported feature after 3.n.0 is released. Since each older +version file gets a different number of backports, it is easiest to +make a separate PR for each file and label it with the backports +needed. + +Github repository and issues +---------------------------- + +The CPython repository is https://github.com/python/cpython. The +IDLE Issues listing is https://github.com/orgs/python/projects/31. +The main classification is by Topic, based on the IDLE menu. View the +topics list by clicking the [<]] button in the upper right. diff --git a/Lib/idlelib/TODO.txt b/Lib/idlelib/TODO.txt new file mode 100644 index 000000000..41b86b0c6 --- /dev/null +++ b/Lib/idlelib/TODO.txt @@ -0,0 +1,210 @@ +Original IDLE todo, much of it now outdated: +============================================ +TO DO: + +- improve debugger: + - manage breakpoints globally, allow bp deletion, tbreak, cbreak etc. + - real object browser + - help on how to use it (a simple help button will do wonders) + - performance? (updates of large sets of locals are slow) + - better integration of "debug module" + - debugger should be global resource (attached to flist, not to shell) + - fix the stupid bug where you need to step twice + - display class name in stack viewer entries for methods + - suppress tracing through IDLE internals (e.g. print) DONE + - add a button to suppress through a specific module or class or method + - more object inspection to stack viewer, e.g. to view all array items +- insert the initial current directory into sys.path DONE +- default directory attribute for each window instead of only for windows + that have an associated filename +- command expansion from keywords, module contents, other buffers, etc. +- "Recent documents" menu item DONE +- Filter region command +- Optional horizontal scroll bar +- more Emacsisms: + - ^K should cut to buffer + - M-[, M-] to move by paragraphs + - incremental search? +- search should indicate wrap-around in some way +- restructure state sensitive code to avoid testing flags all the time +- persistent user state (e.g. window and cursor positions, bindings) +- make backups when saving +- check file mtimes at various points +- Pluggable interface with RCS/CVS/Perforce/Clearcase +- better help? +- don't open second class browser on same module (nor second path browser) +- unify class and path browsers +- Need to define a standard way whereby one can determine one is running + inside IDLE (needed for Tk mainloop, also handy for $PYTHONSTARTUP) +- Add more utility methods for use by extensions (a la get_selection) +- Way to run command in totally separate interpreter (fork+os.system?) DONE +- Way to find definition of fully-qualified name: + In other words, select "UserDict.UserDict", hit some magic key and + it loads up UserDict.py and finds the first def or class for UserDict. +- need a way to force colorization on/off +- need a way to force auto-indent on/off + +Details: + +- ^O (on Unix -- open-line) should honor autoindent +- after paste, show end of pasted text +- on Windows, should turn short filename to long filename (not only in argv!) + (shouldn't this be done -- or undone -- by ntpath.normpath?) +- new autoindent after colon even indents when the colon is in a comment! +- sometimes forward slashes in pathname remain +- sometimes star in window name remains in Windows menu +- With unix bindings, ESC by itself is ignored +- Sometimes for no apparent reason a selection from the cursor to the + end of the command buffer appears, which is hard to get rid of + because it stays when you are typing! +- The Line/Col in the status bar can be wrong initially in PyShell DONE + +Structural problems: + +- too much knowledge in FileList about EditorWindow (for example) +- should add some primitives for accessing the selection etc. + to repeat cumbersome code over and over + +====================================================================== + +Jeff Bauer suggests: + +- Open Module doesn't appear to handle hierarchical packages. +- Class browser should also allow hierarchical packages. +- Open and Open Module could benefit from a history, DONE + either command line style, or Microsoft recent-file + style. +- Add a Smalltalk-style inspector (i.e. Tkinspect) + +The last suggestion is already a reality, but not yet +integrated into IDLE. I use a module called inspector.py, +that used to be available from python.org(?) It no longer +appears to be in the contributed section, and the source +has no author attribution. + +In any case, the code is useful for visually navigating +an object's attributes, including its container hierarchy. + + >>> from inspector import Tkinspect + >>> Tkinspect(None, myObject) + +Tkinspect could probably be extended and refined to +integrate better into IDLE. + +====================================================================== + +Comparison to PTUI +------------------ + ++ PTUI's help is better (HTML!) + ++ PTUI can attach a shell to any module + ++ PTUI has some more I/O commands: + open multiple + append + examine (what's that?) + +====================================================================== + +Notes after trying to run Grail +------------------------------- + +- Grail does stuff to sys.path based on sys.argv[0]; you must set +sys.argv[0] to something decent first (it is normally set to the path of +the idle script). + +- Grail must be exec'ed in __main__ because that's imported by some +other parts of Grail. + +- Grail uses a module called History and so does idle :-( + +====================================================================== + +Robin Friedrich's items: + +Things I'd like to see: + - I'd like support for shift-click extending the selection. There's a + bug now that it doesn't work the first time you try it. + - Printing is needed. How hard can that be on Windows? FIRST CUT DONE + - The python-mode trick of autoindenting a line with is neat and + very handy. + - (someday) a spellchecker for docstrings and comments. + - a pagedown/up command key which moves to next class/def statement (top + level) + - split window capability + - DnD text relocation/copying + +Things I don't want to see. + - line numbers... will probably slow things down way too much. + - Please use another icon for the tree browser leaf. The small snake + isn't cutting it. + +---------------------------------------------------------------------- + +- Customizable views (multi-window or multi-pane). (Markus Gritsch) + +- Being able to double click (maybe double right click) on a callable +object in the editor which shows the source of the object, if +possible. (Gerrit Holl) + +- Hooks into the guts, like in Emacs. (Mike Romberg) + +- Sharing the editor with a remote tutor. (Martijn Faassen) + +- Multiple views on the same file. (Tony J Ibbs) + +- Store breakpoints in a global (per-project) database (GvR); Dirk +Heise adds: save some space-trimmed context and search around when +reopening a file that might have been edited by someone else. + +- Capture menu events in extensions without changing the IDLE source. +(Matthias Barmeier) + +- Use overlapping panels (a "notebook" in MFC terms I think) for info +that doesn't need to be accessible simultaneously (e.g. HTML source +and output). Use multi-pane windows for info that does need to be +shown together (e.g. class browser and source). (Albert Brandl) + +- A project should invisibly track all symbols, for instant search, +replace and cross-ref. Projects should be allowed to span multiple +directories, hosts, etc. Project management files are placed in a +directory you specify. A global mapping between project names and +project directories should exist [not so sure --GvR]. (Tim Peters) + +- Merge attr-tips and auto-expand. (Mark Hammond, Tim Peters) + +- Python Shell should behave more like a "shell window" as users know +it -- i.e. you can only edit the current command, and the cursor can't +escape from the command area. (Albert Brandl) + +- Set X11 class to "idle/Idle", set icon and title to something +beginning with "idle" -- for window managers. (Randall Hopper) + +- Config files editable through a preferences dialog. (me) DONE + +- Config files still editable outside the preferences dialog. +(Randall Hopper) DONE + +- When you're editing a command in PyShell, and there are only blank +lines below the cursor, hitting Return should ignore or delete those +blank lines rather than deciding you're not on the last line. (me) + +- Run command (F5 c.s.) should be more like Pythonwin's Run -- a +dialog with options to give command line arguments, run the debugger, +etc. (me) + +- Shouldn't be able to delete part of the prompt (or any text before +it) in the PyShell. (Martijn Faassen) DONE + +- Emacs style auto-fill (also smart about comments and strings). +(Jeremy Hylton) + +- Output of Run Script should go to a separate output window, not to +the shell window. Output of separate runs should all go to the same +window but clearly delimited. (David Scherer) REJECT FIRST, LATTER DONE + +- GUI form designer to kick VB's butt. (Robert Geiger) THAT'S NOT IDLE + +- Printing! Possibly via generation of PDF files which the user must +then send to the printer separately. (Dinu Gherman) FIRST CUT diff --git a/Lib/idlelib/__init__.py b/Lib/idlelib/__init__.py new file mode 100644 index 000000000..791ddeab7 --- /dev/null +++ b/Lib/idlelib/__init__.py @@ -0,0 +1,10 @@ +"""The idlelib package implements the Idle application. + +Idle includes an interactive shell and editor. +Starting with Python 3.6, IDLE requires tcl/tk 8.5 or later. +Use the files named idle.* to start Idle. + +The other files are private implementations. Their details are subject to +change. See PEP 434 for more. Import them at your own risk. +""" +testing = False # Set True by test.test_idle. diff --git a/Lib/idlelib/__main__.py b/Lib/idlelib/__main__.py new file mode 100644 index 000000000..ec3915b26 --- /dev/null +++ b/Lib/idlelib/__main__.py @@ -0,0 +1,7 @@ +""" +IDLE main entry point + +Run IDLE as python -m idlelib +""" +import idlelib.pyshell +idlelib.pyshell.main() diff --git a/Lib/idlelib/autocomplete.py b/Lib/idlelib/autocomplete.py new file mode 100644 index 000000000..032d31225 --- /dev/null +++ b/Lib/idlelib/autocomplete.py @@ -0,0 +1,228 @@ +"""Complete either attribute names or file names. + +Either on demand or after a user-selected delay after a key character, +pop up a list of candidates. +""" +import __main__ +import keyword +import os +import string +import sys + +# Modified keyword list is used in fetch_completions. +completion_kwds = [s for s in keyword.kwlist + if s not in {'True', 'False', 'None'}] # In builtins. +completion_kwds.extend(('match', 'case')) # Context keywords. +completion_kwds.sort() + +# Two types of completions; defined here for autocomplete_w import below. +ATTRS, FILES = 0, 1 +from idlelib import autocomplete_w +from idlelib.config import idleConf +from idlelib.hyperparser import HyperParser + +# Tuples passed to open_completions. +# EvalFunc, Complete, WantWin, Mode +FORCE = True, False, True, None # Control-Space. +TAB = False, True, True, None # Tab. +TRY_A = False, False, False, ATTRS # '.' for attributes. +TRY_F = False, False, False, FILES # '/' in quotes for file name. + +# This string includes all chars that may be in an identifier. +# TODO Update this here and elsewhere. +ID_CHARS = string.ascii_letters + string.digits + "_" + +SEPS = f"{os.sep}{os.altsep if os.altsep else ''}" +TRIGGERS = f".{SEPS}" + +class AutoComplete: + + def __init__(self, editwin=None, tags=None): + self.editwin = editwin + if editwin is not None: # not in subprocess or no-gui test + self.text = editwin.text + self.tags = tags + self.autocompletewindow = None + # id of delayed call, and the index of the text insert when + # the delayed call was issued. If _delayed_completion_id is + # None, there is no delayed call. + self._delayed_completion_id = None + self._delayed_completion_index = None + + @classmethod + def reload(cls): + cls.popupwait = idleConf.GetOption( + "extensions", "AutoComplete", "popupwait", type="int", default=0) + + def _make_autocomplete_window(self): # Makes mocking easier. + return autocomplete_w.AutoCompleteWindow(self.text, tags=self.tags) + + def _remove_autocomplete_window(self, event=None): + if self.autocompletewindow: + self.autocompletewindow.hide_window() + self.autocompletewindow = None + + def force_open_completions_event(self, event): + "(^space) Open completion list, even if a function call is needed." + self.open_completions(FORCE) + return "break" + + def autocomplete_event(self, event): + "(tab) Complete word or open list if multiple options." + if hasattr(event, "mc_state") and event.mc_state or\ + not self.text.get("insert linestart", "insert").strip(): + # A modifier was pressed along with the tab or + # there is only previous whitespace on this line, so tab. + return None + if self.autocompletewindow and self.autocompletewindow.is_active(): + self.autocompletewindow.complete() + return "break" + else: + opened = self.open_completions(TAB) + return "break" if opened else None + + def try_open_completions_event(self, event=None): + "(./) Open completion list after pause with no movement." + lastchar = self.text.get("insert-1c") + if lastchar in TRIGGERS: + args = TRY_A if lastchar == "." else TRY_F + self._delayed_completion_index = self.text.index("insert") + if self._delayed_completion_id is not None: + self.text.after_cancel(self._delayed_completion_id) + self._delayed_completion_id = self.text.after( + self.popupwait, self._delayed_open_completions, args) + + def _delayed_open_completions(self, args): + "Call open_completions if index unchanged." + self._delayed_completion_id = None + if self.text.index("insert") == self._delayed_completion_index: + self.open_completions(args) + + def open_completions(self, args): + """Find the completions and create the AutoCompleteWindow. + Return True if successful (no syntax error or so found). + If complete is True, then if there's nothing to complete and no + start of completion, won't open completions and return False. + If mode is given, will open a completion list only in this mode. + """ + evalfuncs, complete, wantwin, mode = args + # Cancel another delayed call, if it exists. + if self._delayed_completion_id is not None: + self.text.after_cancel(self._delayed_completion_id) + self._delayed_completion_id = None + + hp = HyperParser(self.editwin, "insert") + curline = self.text.get("insert linestart", "insert") + i = j = len(curline) + if hp.is_in_string() and (not mode or mode==FILES): + # Find the beginning of the string. + # fetch_completions will look at the file system to determine + # whether the string value constitutes an actual file name + # XXX could consider raw strings here and unescape the string + # value if it's not raw. + self._remove_autocomplete_window() + mode = FILES + # Find last separator or string start + while i and curline[i-1] not in "'\"" + SEPS: + i -= 1 + comp_start = curline[i:j] + j = i + # Find string start + while i and curline[i-1] not in "'\"": + i -= 1 + comp_what = curline[i:j] + elif hp.is_in_code() and (not mode or mode==ATTRS): + self._remove_autocomplete_window() + mode = ATTRS + while i and (curline[i-1] in ID_CHARS or ord(curline[i-1]) > 127): + i -= 1 + comp_start = curline[i:j] + if i and curline[i-1] == '.': # Need object with attributes. + hp.set_index("insert-%dc" % (len(curline)-(i-1))) + comp_what = hp.get_expression() + if (not comp_what or + (not evalfuncs and comp_what.find('(') != -1)): + return None + else: + comp_what = "" + else: + return None + + if complete and not comp_what and not comp_start: + return None + comp_lists = self.fetch_completions(comp_what, mode) + if not comp_lists[0]: + return None + self.autocompletewindow = self._make_autocomplete_window() + return not self.autocompletewindow.show_window( + comp_lists, "insert-%dc" % len(comp_start), + complete, mode, wantwin) + + def fetch_completions(self, what, mode): + """Return a pair of lists of completions for something. The first list + is a sublist of the second. Both are sorted. + + If there is a Python subprocess, get the comp. list there. Otherwise, + either fetch_completions() is running in the subprocess itself or it + was called in an IDLE EditorWindow before any script had been run. + + The subprocess environment is that of the most recently run script. If + two unrelated modules are being edited some calltips in the current + module may be inoperative if the module was not the last to run. + """ + try: + rpcclt = self.editwin.flist.pyshell.interp.rpcclt + except: + rpcclt = None + if rpcclt: + return rpcclt.remotecall("exec", "get_the_completion_list", + (what, mode), {}) + else: + if mode == ATTRS: + if what == "": # Main module names. + namespace = {**__main__.__builtins__.__dict__, + **__main__.__dict__} + bigl = eval("dir()", namespace) + bigl.extend(completion_kwds) + bigl.sort() + if "__all__" in bigl: + smalll = sorted(eval("__all__", namespace)) + else: + smalll = [s for s in bigl if s[:1] != '_'] + else: + try: + entity = self.get_entity(what) + bigl = dir(entity) + bigl.sort() + if "__all__" in bigl: + smalll = sorted(entity.__all__) + else: + smalll = [s for s in bigl if s[:1] != '_'] + except: + return [], [] + + elif mode == FILES: + if what == "": + what = "." + try: + expandedpath = os.path.expanduser(what) + bigl = os.listdir(expandedpath) + bigl.sort() + smalll = [s for s in bigl if s[:1] != '.'] + except OSError: + return [], [] + + if not smalll: + smalll = bigl + return smalll, bigl + + def get_entity(self, name): + "Lookup name in a namespace spanning sys.modules and __main.dict__." + return eval(name, {**sys.modules, **__main__.__dict__}) + + +AutoComplete.reload() + +if __name__ == '__main__': + from unittest import main + main('idlelib.idle_test.test_autocomplete', verbosity=2) diff --git a/Lib/idlelib/autocomplete_w.py b/Lib/idlelib/autocomplete_w.py new file mode 100644 index 000000000..24320b5a3 --- /dev/null +++ b/Lib/idlelib/autocomplete_w.py @@ -0,0 +1,498 @@ +""" +An auto-completion window for IDLE, used by the autocomplete extension +""" +import platform + +from tkinter import * +from tkinter.ttk import Scrollbar + +from idlelib.autocomplete import FILES, ATTRS +from idlelib.multicall import MC_SHIFT + +HIDE_VIRTUAL_EVENT_NAME = "<>" +HIDE_FOCUS_OUT_SEQUENCE = "" +HIDE_SEQUENCES = (HIDE_FOCUS_OUT_SEQUENCE, "") +KEYPRESS_VIRTUAL_EVENT_NAME = "<>" +# We need to bind event beyond so that the function will be called +# before the default specific IDLE function +KEYPRESS_SEQUENCES = ("", "", "", "", + "", "", "", "", + "", "", "") +KEYRELEASE_VIRTUAL_EVENT_NAME = "<>" +KEYRELEASE_SEQUENCE = "" +LISTUPDATE_SEQUENCE = "" +WINCONFIG_SEQUENCE = "" +DOUBLECLICK_SEQUENCE = "" + +class AutoCompleteWindow: + + def __init__(self, widget, tags): + # The widget (Text) on which we place the AutoCompleteWindow + self.widget = widget + # Tags to mark inserted text with + self.tags = tags + # The widgets we create + self.autocompletewindow = self.listbox = self.scrollbar = None + # The default foreground and background of a selection. Saved because + # they are changed to the regular colors of list items when the + # completion start is not a prefix of the selected completion + self.origselforeground = self.origselbackground = None + # The list of completions + self.completions = None + # A list with more completions, or None + self.morecompletions = None + # The completion mode, either autocomplete.ATTRS or .FILES. + self.mode = None + # The current completion start, on the text box (a string) + self.start = None + # The index of the start of the completion + self.startindex = None + # The last typed start, used so that when the selection changes, + # the new start will be as close as possible to the last typed one. + self.lasttypedstart = None + # Do we have an indication that the user wants the completion window + # (for example, he clicked the list) + self.userwantswindow = None + # event ids + self.hideid = self.keypressid = self.listupdateid = \ + self.winconfigid = self.keyreleaseid = self.doubleclickid = None + # Flag set if last keypress was a tab + self.lastkey_was_tab = False + # Flag set to avoid recursive callback invocations. + self.is_configuring = False + + def _change_start(self, newstart): + min_len = min(len(self.start), len(newstart)) + i = 0 + while i < min_len and self.start[i] == newstart[i]: + i += 1 + if i < len(self.start): + self.widget.delete("%s+%dc" % (self.startindex, i), + "%s+%dc" % (self.startindex, len(self.start))) + if i < len(newstart): + self.widget.insert("%s+%dc" % (self.startindex, i), + newstart[i:], + self.tags) + self.start = newstart + + def _binary_search(self, s): + """Find the first index in self.completions where completions[i] is + greater or equal to s, or the last index if there is no such. + """ + i = 0; j = len(self.completions) + while j > i: + m = (i + j) // 2 + if self.completions[m] >= s: + j = m + else: + i = m + 1 + return min(i, len(self.completions)-1) + + def _complete_string(self, s): + """Assuming that s is the prefix of a string in self.completions, + return the longest string which is a prefix of all the strings which + s is a prefix of them. If s is not a prefix of a string, return s. + """ + first = self._binary_search(s) + if self.completions[first][:len(s)] != s: + # There is not even one completion which s is a prefix of. + return s + # Find the end of the range of completions where s is a prefix of. + i = first + 1 + j = len(self.completions) + while j > i: + m = (i + j) // 2 + if self.completions[m][:len(s)] != s: + j = m + else: + i = m + 1 + last = i-1 + + if first == last: # only one possible completion + return self.completions[first] + + # We should return the maximum prefix of first and last + first_comp = self.completions[first] + last_comp = self.completions[last] + min_len = min(len(first_comp), len(last_comp)) + i = len(s) + while i < min_len and first_comp[i] == last_comp[i]: + i += 1 + return first_comp[:i] + + def _selection_changed(self): + """Call when the selection of the Listbox has changed. + + Updates the Listbox display and calls _change_start. + """ + cursel = int(self.listbox.curselection()[0]) + + self.listbox.see(cursel) + + lts = self.lasttypedstart + selstart = self.completions[cursel] + if self._binary_search(lts) == cursel: + newstart = lts + else: + min_len = min(len(lts), len(selstart)) + i = 0 + while i < min_len and lts[i] == selstart[i]: + i += 1 + newstart = selstart[:i] + self._change_start(newstart) + + if self.completions[cursel][:len(self.start)] == self.start: + # start is a prefix of the selected completion + self.listbox.configure(selectbackground=self.origselbackground, + selectforeground=self.origselforeground) + else: + self.listbox.configure(selectbackground=self.listbox.cget("bg"), + selectforeground=self.listbox.cget("fg")) + # If there are more completions, show them, and call me again. + if self.morecompletions: + self.completions = self.morecompletions + self.morecompletions = None + self.listbox.delete(0, END) + for item in self.completions: + self.listbox.insert(END, item) + self.listbox.select_set(self._binary_search(self.start)) + self._selection_changed() + + def show_window(self, comp_lists, index, complete, mode, userWantsWin): + """Show the autocomplete list, bind events. + + If complete is True, complete the text, and if there is exactly + one matching completion, don't open a list. + """ + # Handle the start we already have + self.completions, self.morecompletions = comp_lists + self.mode = mode + self.startindex = self.widget.index(index) + self.start = self.widget.get(self.startindex, "insert") + if complete: + completed = self._complete_string(self.start) + start = self.start + self._change_start(completed) + i = self._binary_search(completed) + if self.completions[i] == completed and \ + (i == len(self.completions)-1 or + self.completions[i+1][:len(completed)] != completed): + # There is exactly one matching completion + return completed == start + self.userwantswindow = userWantsWin + self.lasttypedstart = self.start + + self.autocompletewindow = acw = Toplevel(self.widget) + acw.withdraw() + acw.wm_overrideredirect(1) + try: + # Prevent grabbing focus on macOS. + acw.tk.call("::tk::unsupported::MacWindowStyle", "style", acw._w, + "help", "noActivates") + except TclError: + pass + self.scrollbar = scrollbar = Scrollbar(acw, orient=VERTICAL) + self.listbox = listbox = Listbox(acw, yscrollcommand=scrollbar.set, + exportselection=False) + for item in self.completions: + listbox.insert(END, item) + self.origselforeground = listbox.cget("selectforeground") + self.origselbackground = listbox.cget("selectbackground") + scrollbar.config(command=listbox.yview) + scrollbar.pack(side=RIGHT, fill=Y) + listbox.pack(side=LEFT, fill=BOTH, expand=True) + #acw.update_idletasks() # Need for tk8.6.8 on macOS: #40128. + acw.lift() # work around bug in Tk 8.5.18+ (issue #24570) + + # Initialize the listbox selection + self.listbox.select_set(self._binary_search(self.start)) + self._selection_changed() + + # bind events + self.hideaid = acw.bind(HIDE_VIRTUAL_EVENT_NAME, self.hide_event) + self.hidewid = self.widget.bind(HIDE_VIRTUAL_EVENT_NAME, self.hide_event) + acw.event_add(HIDE_VIRTUAL_EVENT_NAME, HIDE_FOCUS_OUT_SEQUENCE) + for seq in HIDE_SEQUENCES: + self.widget.event_add(HIDE_VIRTUAL_EVENT_NAME, seq) + + self.keypressid = self.widget.bind(KEYPRESS_VIRTUAL_EVENT_NAME, + self.keypress_event) + for seq in KEYPRESS_SEQUENCES: + self.widget.event_add(KEYPRESS_VIRTUAL_EVENT_NAME, seq) + self.keyreleaseid = self.widget.bind(KEYRELEASE_VIRTUAL_EVENT_NAME, + self.keyrelease_event) + self.widget.event_add(KEYRELEASE_VIRTUAL_EVENT_NAME,KEYRELEASE_SEQUENCE) + self.listupdateid = listbox.bind(LISTUPDATE_SEQUENCE, + self.listselect_event) + self.is_configuring = False + self.winconfigid = acw.bind(WINCONFIG_SEQUENCE, self.winconfig_event) + self.doubleclickid = listbox.bind(DOUBLECLICK_SEQUENCE, + self.doubleclick_event) + return None + + def winconfig_event(self, event): + if self.is_configuring: + # Avoid running on recursive callback invocations. + return + + self.is_configuring = True + if not self.is_active(): + return + + # Since the event may occur after the completion window is gone, + # catch potential TclError exceptions when accessing acw. See: bpo-41611. + try: + # Position the completion list window + text = self.widget + text.see(self.startindex) + x, y, cx, cy = text.bbox(self.startindex) + acw = self.autocompletewindow + if platform.system().startswith('Windows'): + # On Windows an update() call is needed for the completion + # list window to be created, so that we can fetch its width + # and height. However, this is not needed on other platforms + # (tested on Ubuntu and macOS) but at one point began + # causing freezes on macOS. See issues 37849 and 41611. + acw.update() + acw_width, acw_height = acw.winfo_width(), acw.winfo_height() + text_width, text_height = text.winfo_width(), text.winfo_height() + new_x = text.winfo_rootx() + min(x, max(0, text_width - acw_width)) + new_y = text.winfo_rooty() + y + if (text_height - (y + cy) >= acw_height # enough height below + or y < acw_height): # not enough height above + # place acw below current line + new_y += cy + else: + # place acw above current line + new_y -= acw_height + acw.wm_geometry("+%d+%d" % (new_x, new_y)) + acw.deiconify() + acw.update_idletasks() + except TclError: + pass + + if platform.system().startswith('Windows'): + # See issue 15786. When on Windows platform, Tk will misbehave + # to call winconfig_event multiple times, we need to prevent this, + # otherwise mouse button double click will not be able to used. + try: + acw.unbind(WINCONFIG_SEQUENCE, self.winconfigid) + except TclError: + pass + self.winconfigid = None + + self.is_configuring = False + + def _hide_event_check(self): + if not self.autocompletewindow: + return + + try: + if not self.autocompletewindow.focus_get(): + self.hide_window() + except KeyError: + # See issue 734176, when user click on menu, acw.focus_get() + # will get KeyError. + self.hide_window() + + def hide_event(self, event): + # Hide autocomplete list if it exists and does not have focus or + # mouse click on widget / text area. + if self.is_active(): + if event.type == EventType.FocusOut: + # On Windows platform, it will need to delay the check for + # acw.focus_get() when click on acw, otherwise it will return + # None and close the window + self.widget.after(1, self._hide_event_check) + elif event.type == EventType.ButtonPress: + # ButtonPress event only bind to self.widget + self.hide_window() + + def listselect_event(self, event): + if self.is_active(): + self.userwantswindow = True + cursel = int(self.listbox.curselection()[0]) + self._change_start(self.completions[cursel]) + + def doubleclick_event(self, event): + # Put the selected completion in the text, and close the list + cursel = int(self.listbox.curselection()[0]) + self._change_start(self.completions[cursel]) + self.hide_window() + + def keypress_event(self, event): + if not self.is_active(): + return None + keysym = event.keysym + if hasattr(event, "mc_state"): + state = event.mc_state + else: + state = 0 + if keysym != "Tab": + self.lastkey_was_tab = False + if (len(keysym) == 1 or keysym in ("underscore", "BackSpace") + or (self.mode == FILES and keysym in + ("period", "minus"))) \ + and not (state & ~MC_SHIFT): + # Normal editing of text + if len(keysym) == 1: + self._change_start(self.start + keysym) + elif keysym == "underscore": + self._change_start(self.start + '_') + elif keysym == "period": + self._change_start(self.start + '.') + elif keysym == "minus": + self._change_start(self.start + '-') + else: + # keysym == "BackSpace" + if len(self.start) == 0: + self.hide_window() + return None + self._change_start(self.start[:-1]) + self.lasttypedstart = self.start + self.listbox.select_clear(0, int(self.listbox.curselection()[0])) + self.listbox.select_set(self._binary_search(self.start)) + self._selection_changed() + return "break" + + elif keysym == "Return": + self.complete() + self.hide_window() + return 'break' + + elif (self.mode == ATTRS and keysym in + ("period", "space", "parenleft", "parenright", "bracketleft", + "bracketright")) or \ + (self.mode == FILES and keysym in + ("slash", "backslash", "quotedbl", "apostrophe")) \ + and not (state & ~MC_SHIFT): + # If start is a prefix of the selection, but is not '' when + # completing file names, put the whole + # selected completion. Anyway, close the list. + cursel = int(self.listbox.curselection()[0]) + if self.completions[cursel][:len(self.start)] == self.start \ + and (self.mode == ATTRS or self.start): + self._change_start(self.completions[cursel]) + self.hide_window() + return None + + elif keysym in ("Home", "End", "Prior", "Next", "Up", "Down") and \ + not state: + # Move the selection in the listbox + self.userwantswindow = True + cursel = int(self.listbox.curselection()[0]) + if keysym == "Home": + newsel = 0 + elif keysym == "End": + newsel = len(self.completions)-1 + elif keysym in ("Prior", "Next"): + jump = self.listbox.nearest(self.listbox.winfo_height()) - \ + self.listbox.nearest(0) + if keysym == "Prior": + newsel = max(0, cursel-jump) + else: + assert keysym == "Next" + newsel = min(len(self.completions)-1, cursel+jump) + elif keysym == "Up": + newsel = max(0, cursel-1) + else: + assert keysym == "Down" + newsel = min(len(self.completions)-1, cursel+1) + self.listbox.select_clear(cursel) + self.listbox.select_set(newsel) + self._selection_changed() + self._change_start(self.completions[newsel]) + return "break" + + elif (keysym == "Tab" and not state): + if self.lastkey_was_tab: + # two tabs in a row; insert current selection and close acw + cursel = int(self.listbox.curselection()[0]) + self._change_start(self.completions[cursel]) + self.hide_window() + return "break" + else: + # first tab; let AutoComplete handle the completion + self.userwantswindow = True + self.lastkey_was_tab = True + return None + + elif any(s in keysym for s in ("Shift", "Control", "Alt", + "Meta", "Command", "Option")): + # A modifier key, so ignore + return None + + elif event.char and event.char >= ' ': + # Regular character with a non-length-1 keycode + self._change_start(self.start + event.char) + self.lasttypedstart = self.start + self.listbox.select_clear(0, int(self.listbox.curselection()[0])) + self.listbox.select_set(self._binary_search(self.start)) + self._selection_changed() + return "break" + + else: + # Unknown event, close the window and let it through. + self.hide_window() + return None + + def keyrelease_event(self, event): + if not self.is_active(): + return + if self.widget.index("insert") != \ + self.widget.index("%s+%dc" % (self.startindex, len(self.start))): + # If we didn't catch an event which moved the insert, close window + self.hide_window() + + def is_active(self): + return self.autocompletewindow is not None + + def complete(self): + self._change_start(self._complete_string(self.start)) + # The selection doesn't change. + + def hide_window(self): + if not self.is_active(): + return + + # unbind events + self.autocompletewindow.event_delete(HIDE_VIRTUAL_EVENT_NAME, + HIDE_FOCUS_OUT_SEQUENCE) + for seq in HIDE_SEQUENCES: + self.widget.event_delete(HIDE_VIRTUAL_EVENT_NAME, seq) + + self.autocompletewindow.unbind(HIDE_VIRTUAL_EVENT_NAME, self.hideaid) + self.widget.unbind(HIDE_VIRTUAL_EVENT_NAME, self.hidewid) + self.hideaid = None + self.hidewid = None + for seq in KEYPRESS_SEQUENCES: + self.widget.event_delete(KEYPRESS_VIRTUAL_EVENT_NAME, seq) + self.widget.unbind(KEYPRESS_VIRTUAL_EVENT_NAME, self.keypressid) + self.keypressid = None + self.widget.event_delete(KEYRELEASE_VIRTUAL_EVENT_NAME, + KEYRELEASE_SEQUENCE) + self.widget.unbind(KEYRELEASE_VIRTUAL_EVENT_NAME, self.keyreleaseid) + self.keyreleaseid = None + self.listbox.unbind(LISTUPDATE_SEQUENCE, self.listupdateid) + self.listupdateid = None + if self.winconfigid: + self.autocompletewindow.unbind(WINCONFIG_SEQUENCE, self.winconfigid) + self.winconfigid = None + + # Re-focusOn frame.text (See issue #15786) + self.widget.focus_set() + + # destroy widgets + self.scrollbar.destroy() + self.scrollbar = None + self.listbox.destroy() + self.listbox = None + self.autocompletewindow.destroy() + self.autocompletewindow = None + + +if __name__ == '__main__': + from unittest import main + main('idlelib.idle_test.test_autocomplete_w', verbosity=2, exit=False) + +# TODO: autocomplete/w htest here diff --git a/Lib/idlelib/autoexpand.py b/Lib/idlelib/autoexpand.py new file mode 100644 index 000000000..92f5c84eb --- /dev/null +++ b/Lib/idlelib/autoexpand.py @@ -0,0 +1,96 @@ +'''Complete the current word before the cursor with words in the editor. + +Each menu selection or shortcut key selection replaces the word with a +different word with the same prefix. The search for matches begins +before the target and moves toward the top of the editor. It then starts +after the cursor and moves down. It then returns to the original word and +the cycle starts again. + +Changing the current text line or leaving the cursor in a different +place before requesting the next selection causes AutoExpand to reset +its state. + +There is only one instance of Autoexpand. +''' +import re +import string + + +class AutoExpand: + wordchars = string.ascii_letters + string.digits + "_" + + def __init__(self, editwin): + self.text = editwin.text + self.bell = self.text.bell + self.state = None + + def expand_word_event(self, event): + "Replace the current word with the next expansion." + curinsert = self.text.index("insert") + curline = self.text.get("insert linestart", "insert lineend") + if not self.state: + words = self.getwords() + index = 0 + else: + words, index, insert, line = self.state + if insert != curinsert or line != curline: + words = self.getwords() + index = 0 + if not words: + self.bell() + return "break" + word = self.getprevword() + self.text.delete("insert - %d chars" % len(word), "insert") + newword = words[index] + index = (index + 1) % len(words) + if index == 0: + self.bell() # Warn we cycled around + self.text.insert("insert", newword) + curinsert = self.text.index("insert") + curline = self.text.get("insert linestart", "insert lineend") + self.state = words, index, curinsert, curline + return "break" + + def getwords(self): + "Return a list of words that match the prefix before the cursor." + word = self.getprevword() + if not word: + return [] + before = self.text.get("1.0", "insert wordstart") + wbefore = re.findall(r"\b" + word + r"\w+\b", before) + del before + after = self.text.get("insert wordend", "end") + wafter = re.findall(r"\b" + word + r"\w+\b", after) + del after + if not wbefore and not wafter: + return [] + words = [] + dict = {} + # search backwards through words before + wbefore.reverse() + for w in wbefore: + if dict.get(w): + continue + words.append(w) + dict[w] = w + # search onwards through words after + for w in wafter: + if dict.get(w): + continue + words.append(w) + dict[w] = w + words.append(word) + return words + + def getprevword(self): + "Return the word prefix before the cursor." + line = self.text.get("insert linestart", "insert") + i = len(line) + while i > 0 and line[i-1] in self.wordchars: + i = i-1 + return line[i:] + + +if __name__ == '__main__': + from unittest import main + main('idlelib.idle_test.test_autoexpand', verbosity=2) diff --git a/Lib/idlelib/browser.py b/Lib/idlelib/browser.py new file mode 100644 index 000000000..8b9060e57 --- /dev/null +++ b/Lib/idlelib/browser.py @@ -0,0 +1,260 @@ +"""Module browser. + +XXX TO DO: + +- reparse when source changed (maybe just a button would be OK?) + (or recheck on window popup) +- add popup menu with more options (e.g. doc strings, base classes, imports) +- add base classes to class browser tree +""" + +import os +import pyclbr +import sys + +from idlelib.config import idleConf +from idlelib import pyshell +from idlelib.tree import TreeNode, TreeItem, ScrolledCanvas +from idlelib.util import py_extensions +from idlelib.window import ListedToplevel + + +file_open = None # Method...Item and Class...Item use this. +# Normally pyshell.flist.open, but there is no pyshell.flist for htest. + +# The browser depends on pyclbr and importlib which do not support .pyi files. +browseable_extension_blocklist = ('.pyi',) + + +def is_browseable_extension(path): + _, ext = os.path.splitext(path) + ext = os.path.normcase(ext) + return ext in py_extensions and ext not in browseable_extension_blocklist + + +def transform_children(child_dict, modname=None): + """Transform a child dictionary to an ordered sequence of objects. + + The dictionary maps names to pyclbr information objects. + Filter out imported objects. + Augment class names with bases. + The insertion order of the dictionary is assumed to have been in line + number order, so sorting is not necessary. + + The current tree only calls this once per child_dict as it saves + TreeItems once created. A future tree and tests might violate this, + so a check prevents multiple in-place augmentations. + """ + obs = [] # Use list since values should already be sorted. + for key, obj in child_dict.items(): + if modname is None or obj.module == modname: + if hasattr(obj, 'super') and obj.super and obj.name == key: + # If obj.name != key, it has already been suffixed. + supers = [] + for sup in obj.super: + if isinstance(sup, str): + sname = sup + else: + sname = sup.name + if sup.module != obj.module: + sname = f'{sup.module}.{sname}' + supers.append(sname) + obj.name += '({})'.format(', '.join(supers)) + obs.append(obj) + return obs + + +class ModuleBrowser: + """Browse module classes and functions in IDLE. + """ + # This class is also the base class for pathbrowser.PathBrowser. + # Init and close are inherited, other methods are overridden. + # PathBrowser.__init__ does not call __init__ below. + + def __init__(self, master, path, *, _htest=False, _utest=False): + """Create a window for browsing a module's structure. + + Args: + master: parent for widgets. + path: full path of file to browse. + _htest - bool; change box location when running htest. + -utest - bool; suppress contents when running unittest. + + Global variables: + file_open: Function used for opening a file. + + Instance variables: + name: Module name. + file: Full path and module with supported extension. + Used in creating ModuleBrowserTreeItem as the rootnode for + the tree and subsequently in the children. + """ + self.master = master + self.path = path + self._htest = _htest + self._utest = _utest + self.init() + + def close(self, event=None): + "Dismiss the window and the tree nodes." + self.top.destroy() + self.node.destroy() + + def init(self): + "Create browser tkinter widgets, including the tree." + global file_open + root = self.master + flist = (pyshell.flist if not (self._htest or self._utest) + else pyshell.PyShellFileList(root)) + file_open = flist.open + pyclbr._modules.clear() + + # create top + self.top = top = ListedToplevel(root) + top.protocol("WM_DELETE_WINDOW", self.close) + top.bind("", self.close) + if self._htest: # place dialog below parent if running htest + top.geometry("+%d+%d" % + (root.winfo_rootx(), root.winfo_rooty() + 200)) + self.settitle() + top.focus_set() + + # create scrolled canvas + theme = idleConf.CurrentTheme() + background = idleConf.GetHighlight(theme, 'normal')['background'] + sc = ScrolledCanvas(top, bg=background, highlightthickness=0, + takefocus=1) + sc.frame.pack(expand=1, fill="both") + item = self.rootnode() + self.node = node = TreeNode(sc.canvas, None, item) + if not self._utest: + node.update() + node.expand() + + def settitle(self): + "Set the window title." + self.top.wm_title("Module Browser - " + os.path.basename(self.path)) + self.top.wm_iconname("Module Browser") + + def rootnode(self): + "Return a ModuleBrowserTreeItem as the root of the tree." + return ModuleBrowserTreeItem(self.path) + + +class ModuleBrowserTreeItem(TreeItem): + """Browser tree for Python module. + + Uses TreeItem as the basis for the structure of the tree. + Used by both browsers. + """ + + def __init__(self, file): + """Create a TreeItem for the file. + + Args: + file: Full path and module name. + """ + self.file = file + + def GetText(self): + "Return the module name as the text string to display." + return os.path.basename(self.file) + + def GetIconName(self): + "Return the name of the icon to display." + return "python" + + def GetSubList(self): + "Return ChildBrowserTreeItems for children." + return [ChildBrowserTreeItem(obj) for obj in self.listchildren()] + + def OnDoubleClick(self): + "Open a module in an editor window when double clicked." + if not is_browseable_extension(self.file): + return + if not os.path.exists(self.file): + return + file_open(self.file) + + def IsExpandable(self): + "Return True if Python file." + return is_browseable_extension(self.file) + + def listchildren(self): + "Return sequenced classes and functions in the module." + if not is_browseable_extension(self.file): + return [] + dir, base = os.path.split(self.file) + name, _ = os.path.splitext(base) + try: + tree = pyclbr.readmodule_ex(name, [dir] + sys.path) + except ImportError: + return [] + return transform_children(tree, name) + + +class ChildBrowserTreeItem(TreeItem): + """Browser tree for child nodes within the module. + + Uses TreeItem as the basis for the structure of the tree. + """ + + def __init__(self, obj): + "Create a TreeItem for a pyclbr class/function object." + self.obj = obj + self.name = obj.name + self.isfunction = isinstance(obj, pyclbr.Function) + + def GetText(self): + "Return the name of the function/class to display." + name = self.name + if self.isfunction: + return "def " + name + "(...)" + else: + return "class " + name + + def GetIconName(self): + "Return the name of the icon to display." + if self.isfunction: + return "python" + else: + return "folder" + + def IsExpandable(self): + "Return True if self.obj has nested objects." + return self.obj.children != {} + + def GetSubList(self): + "Return ChildBrowserTreeItems for children." + return [ChildBrowserTreeItem(obj) + for obj in transform_children(self.obj.children)] + + def OnDoubleClick(self): + "Open module with file_open and position to lineno." + try: + edit = file_open(self.obj.file) + edit.gotoline(self.obj.lineno) + except (OSError, AttributeError): + pass + + +def _module_browser(parent): # htest # + if len(sys.argv) > 1: # If pass file on command line. + file = sys.argv[1] + else: + file = __file__ + # Add nested objects for htest. + class Nested_in_func(TreeNode): + def nested_in_class(): pass + def closure(): + class Nested_in_closure: pass + ModuleBrowser(parent, file, _htest=True) + + +if __name__ == "__main__": + if len(sys.argv) == 1: # If pass file on command line, unittest fails. + from unittest import main + main('idlelib.idle_test.test_browser', verbosity=2, exit=False) + + from idlelib.idle_test.htest import run + run(_module_browser) diff --git a/Lib/idlelib/calltip.py b/Lib/idlelib/calltip.py new file mode 100644 index 000000000..40bc5a0ad --- /dev/null +++ b/Lib/idlelib/calltip.py @@ -0,0 +1,205 @@ +"""Pop up a reminder of how to call a function. + +Call Tips are floating windows which display function, class, and method +parameter and docstring information when you type an opening parenthesis, and +which disappear when you type a closing parenthesis. +""" +import __main__ +import inspect +import re +import sys +import textwrap +import types + +from idlelib import calltip_w +from idlelib.hyperparser import HyperParser + + +class Calltip: + + def __init__(self, editwin=None): + if editwin is None: # subprocess and test + self.editwin = None + else: + self.editwin = editwin + self.text = editwin.text + self.active_calltip = None + self._calltip_window = self._make_tk_calltip_window + + def close(self): + self._calltip_window = None + + def _make_tk_calltip_window(self): + # See __init__ for usage + return calltip_w.CalltipWindow(self.text) + + def remove_calltip_window(self, event=None): + if self.active_calltip: + self.active_calltip.hidetip() + self.active_calltip = None + + def force_open_calltip_event(self, event): + "The user selected the menu entry or hotkey, open the tip." + self.open_calltip(True) + return "break" + + def try_open_calltip_event(self, event): + """Happens when it would be nice to open a calltip, but not really + necessary, for example after an opening bracket, so function calls + won't be made. + """ + self.open_calltip(False) + + def refresh_calltip_event(self, event): + if self.active_calltip and self.active_calltip.tipwindow: + self.open_calltip(False) + + def open_calltip(self, evalfuncs): + """Maybe close an existing calltip and maybe open a new calltip. + + Called from (force_open|try_open|refresh)_calltip_event functions. + """ + hp = HyperParser(self.editwin, "insert") + sur_paren = hp.get_surrounding_brackets('(') + + # If not inside parentheses, no calltip. + if not sur_paren: + self.remove_calltip_window() + return + + # If a calltip is shown for the current parentheses, do + # nothing. + if self.active_calltip: + opener_line, opener_col = map(int, sur_paren[0].split('.')) + if ( + (opener_line, opener_col) == + (self.active_calltip.parenline, self.active_calltip.parencol) + ): + return + + hp.set_index(sur_paren[0]) + try: + expression = hp.get_expression() + except ValueError: + expression = None + if not expression: + # No expression before the opening parenthesis, e.g. + # because it's in a string or the opener for a tuple: + # Do nothing. + return + + # At this point, the current index is after an opening + # parenthesis, in a section of code, preceded by a valid + # expression. If there is a calltip shown, it's not for the + # same index and should be closed. + self.remove_calltip_window() + + # Simple, fast heuristic: If the preceding expression includes + # an opening parenthesis, it likely includes a function call. + if not evalfuncs and (expression.find('(') != -1): + return + + argspec = self.fetch_tip(expression) + if not argspec: + return + self.active_calltip = self._calltip_window() + self.active_calltip.showtip(argspec, sur_paren[0], sur_paren[1]) + + def fetch_tip(self, expression): + """Return the argument list and docstring of a function or class. + + If there is a Python subprocess, get the calltip there. Otherwise, + either this fetch_tip() is running in the subprocess or it was + called in an IDLE running without the subprocess. + + The subprocess environment is that of the most recently run script. If + two unrelated modules are being edited some calltips in the current + module may be inoperative if the module was not the last to run. + + To find methods, fetch_tip must be fed a fully qualified name. + + """ + try: + rpcclt = self.editwin.flist.pyshell.interp.rpcclt + except AttributeError: + rpcclt = None + if rpcclt: + return rpcclt.remotecall("exec", "get_the_calltip", + (expression,), {}) + else: + return get_argspec(get_entity(expression)) + + +def get_entity(expression): + """Return the object corresponding to expression evaluated + in a namespace spanning sys.modules and __main.dict__. + """ + if expression: + namespace = {**sys.modules, **__main__.__dict__} + try: + return eval(expression, namespace) # Only protect user code. + except BaseException: + # An uncaught exception closes idle, and eval can raise any + # exception, especially if user classes are involved. + return None + +# The following are used in get_argspec and some in tests +_MAX_COLS = 85 +_MAX_LINES = 5 # enough for bytes +_INDENT = ' '*4 # for wrapped signatures +_first_param = re.compile(r'(?<=\()\w*\,?\s*') +_default_callable_argspec = "See source or doc" +_invalid_method = "invalid method signature" + +def get_argspec(ob): + '''Return a string describing the signature of a callable object, or ''. + + For Python-coded functions and methods, the first line is introspected. + Delete 'self' parameter for classes (.__init__) and bound methods. + The next lines are the first lines of the doc string up to the first + empty line or _MAX_LINES. For builtins, this typically includes + the arguments in addition to the return value. + ''' + # Determine function object fob to inspect. + try: + ob_call = ob.__call__ + except BaseException: # Buggy user object could raise anything. + return '' # No popup for non-callables. + # For Get_argspecTest.test_buggy_getattr_class, CallA() & CallB(). + fob = ob_call if isinstance(ob_call, types.MethodType) else ob + + # Initialize argspec and wrap it to get lines. + try: + argspec = str(inspect.signature(fob)) + except Exception as err: + msg = str(err) + if msg.startswith(_invalid_method): + return _invalid_method + else: + argspec = '' + + if isinstance(fob, type) and argspec == '()': + # If fob has no argument, use default callable argspec. + argspec = _default_callable_argspec + + lines = (textwrap.wrap(argspec, _MAX_COLS, subsequent_indent=_INDENT) + if len(argspec) > _MAX_COLS else [argspec] if argspec else []) + + # Augment lines from docstring, if any, and join to get argspec. + doc = inspect.getdoc(ob) + if doc: + for line in doc.split('\n', _MAX_LINES)[:_MAX_LINES]: + line = line.strip() + if not line: + break + if len(line) > _MAX_COLS: + line = line[: _MAX_COLS - 3] + '...' + lines.append(line) + argspec = '\n'.join(lines) + + return argspec or _default_callable_argspec + + +if __name__ == '__main__': + from unittest import main + main('idlelib.idle_test.test_calltip', verbosity=2) diff --git a/Lib/idlelib/calltip_w.py b/Lib/idlelib/calltip_w.py new file mode 100644 index 000000000..938637605 --- /dev/null +++ b/Lib/idlelib/calltip_w.py @@ -0,0 +1,202 @@ +"""A call-tip window class for Tkinter/IDLE. + +After tooltip.py, which uses ideas gleaned from PySol. +Used by calltip.py. +""" +from tkinter import Label, LEFT, SOLID, TclError + +from idlelib.tooltip import TooltipBase + +HIDE_EVENT = "<>" +HIDE_SEQUENCES = ("", "") +CHECKHIDE_EVENT = "<>" +CHECKHIDE_SEQUENCES = ("", "") +CHECKHIDE_TIME = 100 # milliseconds + +MARK_RIGHT = "calltipwindowregion_right" + + +class CalltipWindow(TooltipBase): + """A call-tip widget for tkinter text widgets.""" + + def __init__(self, text_widget): + """Create a call-tip; shown by showtip(). + + text_widget: a Text widget with code for which call-tips are desired + """ + # Note: The Text widget will be accessible as self.anchor_widget + super().__init__(text_widget) + + self.label = self.text = None + self.parenline = self.parencol = self.lastline = None + self.hideid = self.checkhideid = None + self.checkhide_after_id = None + + def get_position(self): + """Choose the position of the call-tip.""" + curline = int(self.anchor_widget.index("insert").split('.')[0]) + if curline == self.parenline: + anchor_index = (self.parenline, self.parencol) + else: + anchor_index = (curline, 0) + box = self.anchor_widget.bbox("%d.%d" % anchor_index) + if not box: + box = list(self.anchor_widget.bbox("insert")) + # align to left of window + box[0] = 0 + box[2] = 0 + return box[0] + 2, box[1] + box[3] + + def position_window(self): + "Reposition the window if needed." + curline = int(self.anchor_widget.index("insert").split('.')[0]) + if curline == self.lastline: + return + self.lastline = curline + self.anchor_widget.see("insert") + super().position_window() + + def showtip(self, text, parenleft, parenright): + """Show the call-tip, bind events which will close it and reposition it. + + text: the text to display in the call-tip + parenleft: index of the opening parenthesis in the text widget + parenright: index of the closing parenthesis in the text widget, + or the end of the line if there is no closing parenthesis + """ + # Only called in calltip.Calltip, where lines are truncated + self.text = text + if self.tipwindow or not self.text: + return + + self.anchor_widget.mark_set(MARK_RIGHT, parenright) + self.parenline, self.parencol = map( + int, self.anchor_widget.index(parenleft).split(".")) + + super().showtip() + + self._bind_events() + + def showcontents(self): + """Create the call-tip widget.""" + self.label = Label(self.tipwindow, text=self.text, justify=LEFT, + background="#ffffd0", foreground="black", + relief=SOLID, borderwidth=1, + font=self.anchor_widget['font']) + self.label.pack() + + def checkhide_event(self, event=None): + """Handle CHECK_HIDE_EVENT: call hidetip or reschedule.""" + if not self.tipwindow: + # If the event was triggered by the same event that unbound + # this function, the function will be called nevertheless, + # so do nothing in this case. + return None + + # Hide the call-tip if the insertion cursor moves outside of the + # parenthesis. + curline, curcol = map(int, self.anchor_widget.index("insert").split('.')) + if curline < self.parenline or \ + (curline == self.parenline and curcol <= self.parencol) or \ + self.anchor_widget.compare("insert", ">", MARK_RIGHT): + self.hidetip() + return "break" + + # Not hiding the call-tip. + + self.position_window() + # Re-schedule this function to be called again in a short while. + if self.checkhide_after_id is not None: + self.anchor_widget.after_cancel(self.checkhide_after_id) + self.checkhide_after_id = \ + self.anchor_widget.after(CHECKHIDE_TIME, self.checkhide_event) + return None + + def hide_event(self, event): + """Handle HIDE_EVENT by calling hidetip.""" + if not self.tipwindow: + # See the explanation in checkhide_event. + return None + self.hidetip() + return "break" + + def hidetip(self): + """Hide the call-tip.""" + if not self.tipwindow: + return + + try: + self.label.destroy() + except TclError: + pass + self.label = None + + self.parenline = self.parencol = self.lastline = None + try: + self.anchor_widget.mark_unset(MARK_RIGHT) + except TclError: + pass + + try: + self._unbind_events() + except (TclError, ValueError): + # ValueError may be raised by MultiCall + pass + + super().hidetip() + + def _bind_events(self): + """Bind event handlers.""" + self.checkhideid = self.anchor_widget.bind(CHECKHIDE_EVENT, + self.checkhide_event) + for seq in CHECKHIDE_SEQUENCES: + self.anchor_widget.event_add(CHECKHIDE_EVENT, seq) + self.anchor_widget.after(CHECKHIDE_TIME, self.checkhide_event) + self.hideid = self.anchor_widget.bind(HIDE_EVENT, + self.hide_event) + for seq in HIDE_SEQUENCES: + self.anchor_widget.event_add(HIDE_EVENT, seq) + + def _unbind_events(self): + """Unbind event handlers.""" + for seq in CHECKHIDE_SEQUENCES: + self.anchor_widget.event_delete(CHECKHIDE_EVENT, seq) + self.anchor_widget.unbind(CHECKHIDE_EVENT, self.checkhideid) + self.checkhideid = None + for seq in HIDE_SEQUENCES: + self.anchor_widget.event_delete(HIDE_EVENT, seq) + self.anchor_widget.unbind(HIDE_EVENT, self.hideid) + self.hideid = None + + +def _calltip_window(parent): # htest # + from tkinter import Toplevel, Text, LEFT, BOTH + + top = Toplevel(parent) + top.title("Test call-tips") + x, y = map(int, parent.geometry().split('+')[1:]) + top.geometry("250x100+%d+%d" % (x + 175, y + 150)) + text = Text(top) + text.pack(side=LEFT, fill=BOTH, expand=1) + text.insert("insert", "string.split") + top.update() + + calltip = CalltipWindow(text) + def calltip_show(event): + calltip.showtip("(s='Hello world')", "insert", "end") + def calltip_hide(event): + calltip.hidetip() + text.event_add("<>", "(") + text.event_add("<>", ")") + text.bind("<>", calltip_show) + text.bind("<>", calltip_hide) + + text.focus_set() + + +if __name__ == '__main__': + from unittest import main + main('idlelib.idle_test.test_calltip_w', verbosity=2, exit=False) + + from idlelib.idle_test.htest import run + run(_calltip_window) diff --git a/Lib/idlelib/codecontext.py b/Lib/idlelib/codecontext.py new file mode 100644 index 000000000..f2f44f5f8 --- /dev/null +++ b/Lib/idlelib/codecontext.py @@ -0,0 +1,270 @@ +"""codecontext - display the block context above the edit window + +Once code has scrolled off the top of a window, it can be difficult to +determine which block you are in. This extension implements a pane at the top +of each IDLE edit window which provides block structure hints. These hints are +the lines which contain the block opening keywords, e.g. 'if', for the +enclosing block. The number of hint lines is determined by the maxlines +variable in the codecontext section of config-extensions.def. Lines which do +not open blocks are not shown in the context hints pane. + +For EditorWindows, <> is bound to CodeContext(self). +toggle_code_context_event. +""" +import re +from sys import maxsize as INFINITY + +from tkinter import Frame, Text, TclError +from tkinter.constants import NSEW, SUNKEN + +from idlelib.config import idleConf + +BLOCKOPENERS = {'class', 'def', 'if', 'elif', 'else', 'while', 'for', + 'try', 'except', 'finally', 'with', 'async'} + + +def get_spaces_firstword(codeline, c=re.compile(r"^(\s*)(\w*)")): + "Extract the beginning whitespace and first word from codeline." + return c.match(codeline).groups() + + +def get_line_info(codeline): + """Return tuple of (line indent value, codeline, block start keyword). + + The indentation of empty lines (or comment lines) is INFINITY. + If the line does not start a block, the keyword value is False. + """ + spaces, firstword = get_spaces_firstword(codeline) + indent = len(spaces) + if len(codeline) == indent or codeline[indent] == '#': + indent = INFINITY + opener = firstword in BLOCKOPENERS and firstword + return indent, codeline, opener + + +class CodeContext: + "Display block context above the edit window." + UPDATEINTERVAL = 100 # millisec + + def __init__(self, editwin): + """Initialize settings for context block. + + editwin is the Editor window for the context block. + self.text is the editor window text widget. + + self.context displays the code context text above the editor text. + Initially None, it is toggled via <>. + self.topvisible is the number of the top text line displayed. + self.info is a list of (line number, indent level, line text, + block keyword) tuples for the block structure above topvisible. + self.info[0] is initialized with a 'dummy' line which + starts the toplevel 'block' of the module. + + self.t1 and self.t2 are two timer events on the editor text widget to + monitor for changes to the context text or editor font. + """ + self.editwin = editwin + self.text = editwin.text + self._reset() + + def _reset(self): + self.context = None + self.cell00 = None + self.t1 = None + self.topvisible = 1 + self.info = [(0, -1, "", False)] + + @classmethod + def reload(cls): + "Load class variables from config." + cls.context_depth = idleConf.GetOption("extensions", "CodeContext", + "maxlines", type="int", + default=15) + + def __del__(self): + "Cancel scheduled events." + if self.t1 is not None: + try: + self.text.after_cancel(self.t1) + except TclError: # pragma: no cover + pass + self.t1 = None + + def toggle_code_context_event(self, event=None): + """Toggle code context display. + + If self.context doesn't exist, create it to match the size of the editor + window text (toggle on). If it does exist, destroy it (toggle off). + Return 'break' to complete the processing of the binding. + """ + if self.context is None: + # Calculate the border width and horizontal padding required to + # align the context with the text in the main Text widget. + # + # All values are passed through getint(), since some + # values may be pixel objects, which can't simply be added to ints. + widgets = self.editwin.text, self.editwin.text_frame + # Calculate the required horizontal padding and border width. + padx = 0 + border = 0 + for widget in widgets: + info = (widget.grid_info() + if widget is self.editwin.text + else widget.pack_info()) + padx += widget.tk.getint(info['padx']) + padx += widget.tk.getint(widget.cget('padx')) + border += widget.tk.getint(widget.cget('border')) + context = self.context = Text( + self.editwin.text_frame, + height=1, + width=1, # Don't request more than we get. + highlightthickness=0, + padx=padx, border=border, relief=SUNKEN, state='disabled') + self.update_font() + self.update_highlight_colors() + context.bind('', self.jumptoline) + # Get the current context and initiate the recurring update event. + self.timer_event() + # Grid the context widget above the text widget. + context.grid(row=0, column=1, sticky=NSEW) + + line_number_colors = idleConf.GetHighlight(idleConf.CurrentTheme(), + 'linenumber') + self.cell00 = Frame(self.editwin.text_frame, + bg=line_number_colors['background']) + self.cell00.grid(row=0, column=0, sticky=NSEW) + menu_status = 'Hide' + else: + self.context.destroy() + self.context = None + self.cell00.destroy() + self.cell00 = None + self.text.after_cancel(self.t1) + self._reset() + menu_status = 'Show' + self.editwin.update_menu_label(menu='options', index='*ode*ontext', + label=f'{menu_status} Code Context') + return "break" + + def get_context(self, new_topvisible, stopline=1, stopindent=0): + """Return a list of block line tuples and the 'last' indent. + + The tuple fields are (linenum, indent, text, opener). + The list represents header lines from new_topvisible back to + stopline with successively shorter indents > stopindent. + The list is returned ordered by line number. + Last indent returned is the smallest indent observed. + """ + assert stopline > 0 + lines = [] + # The indentation level we are currently in. + lastindent = INFINITY + # For a line to be interesting, it must begin with a block opening + # keyword, and have less indentation than lastindent. + for linenum in range(new_topvisible, stopline-1, -1): + codeline = self.text.get(f'{linenum}.0', f'{linenum}.end') + indent, text, opener = get_line_info(codeline) + if indent < lastindent: + lastindent = indent + if opener in ("else", "elif"): + # Also show the if statement. + lastindent += 1 + if opener and linenum < new_topvisible and indent >= stopindent: + lines.append((linenum, indent, text, opener)) + if lastindent <= stopindent: + break + lines.reverse() + return lines, lastindent + + def update_code_context(self): + """Update context information and lines visible in the context pane. + + No update is done if the text hasn't been scrolled. If the text + was scrolled, the lines that should be shown in the context will + be retrieved and the context area will be updated with the code, + up to the number of maxlines. + """ + new_topvisible = self.editwin.getlineno("@0,0") + if self.topvisible == new_topvisible: # Haven't scrolled. + return + if self.topvisible < new_topvisible: # Scroll down. + lines, lastindent = self.get_context(new_topvisible, + self.topvisible) + # Retain only context info applicable to the region + # between topvisible and new_topvisible. + while self.info[-1][1] >= lastindent: + del self.info[-1] + else: # self.topvisible > new_topvisible: # Scroll up. + stopindent = self.info[-1][1] + 1 + # Retain only context info associated + # with lines above new_topvisible. + while self.info[-1][0] >= new_topvisible: + stopindent = self.info[-1][1] + del self.info[-1] + lines, lastindent = self.get_context(new_topvisible, + self.info[-1][0]+1, + stopindent) + self.info.extend(lines) + self.topvisible = new_topvisible + # Last context_depth context lines. + context_strings = [x[2] for x in self.info[-self.context_depth:]] + showfirst = 0 if context_strings[0] else 1 + # Update widget. + self.context['height'] = len(context_strings) - showfirst + self.context['state'] = 'normal' + self.context.delete('1.0', 'end') + self.context.insert('end', '\n'.join(context_strings[showfirst:])) + self.context['state'] = 'disabled' + + def jumptoline(self, event=None): + """ Show clicked context line at top of editor. + + If a selection was made, don't jump; allow copying. + If no visible context, show the top line of the file. + """ + try: + self.context.index("sel.first") + except TclError: + lines = len(self.info) + if lines == 1: # No context lines are showing. + newtop = 1 + else: + # Line number clicked. + contextline = int(float(self.context.index('insert'))) + # Lines not displayed due to maxlines. + offset = max(1, lines - self.context_depth) - 1 + newtop = self.info[offset + contextline][0] + self.text.yview(f'{newtop}.0') + self.update_code_context() + + def timer_event(self): + "Event on editor text widget triggered every UPDATEINTERVAL ms." + if self.context is not None: + self.update_code_context() + self.t1 = self.text.after(self.UPDATEINTERVAL, self.timer_event) + + def update_font(self): + if self.context is not None: + font = idleConf.GetFont(self.text, 'main', 'EditorWindow') + self.context['font'] = font + + def update_highlight_colors(self): + if self.context is not None: + colors = idleConf.GetHighlight(idleConf.CurrentTheme(), 'context') + self.context['background'] = colors['background'] + self.context['foreground'] = colors['foreground'] + + if self.cell00 is not None: + line_number_colors = idleConf.GetHighlight(idleConf.CurrentTheme(), + 'linenumber') + self.cell00.config(bg=line_number_colors['background']) + + +CodeContext.reload() + + +if __name__ == "__main__": + from unittest import main + main('idlelib.idle_test.test_codecontext', verbosity=2, exit=False) + + # Add htest. diff --git a/Lib/idlelib/colorizer.py b/Lib/idlelib/colorizer.py new file mode 100644 index 000000000..bffa2ddd3 --- /dev/null +++ b/Lib/idlelib/colorizer.py @@ -0,0 +1,384 @@ +import builtins +import keyword +import re +import time + +from idlelib.config import idleConf +from idlelib.delegator import Delegator + +DEBUG = False + + +def any(name, alternates): + "Return a named group pattern matching list of alternates." + return "(?P<%s>" % name + "|".join(alternates) + ")" + + +def make_pat(): + kw = r"\b" + any("KEYWORD", keyword.kwlist) + r"\b" + match_softkw = ( + r"^[ \t]*" + # at beginning of line + possible indentation + r"(?Pmatch)\b" + + r"(?![ \t]*(?:" + "|".join([ # not followed by ... + r"[:,;=^&|@~)\]}]", # a character which means it can't be a + # pattern-matching statement + r"\b(?:" + r"|".join(keyword.kwlist) + r")\b", # a keyword + ]) + + r"))" + ) + case_default = ( + r"^[ \t]*" + # at beginning of line + possible indentation + r"(?Pcase)" + + r"[ \t]+(?P_\b)" + ) + case_softkw_and_pattern = ( + r"^[ \t]*" + # at beginning of line + possible indentation + r"(?Pcase)\b" + + r"(?![ \t]*(?:" + "|".join([ # not followed by ... + r"_\b", # a lone underscore + r"[:,;=^&|@~)\]}]", # a character which means it can't be a + # pattern-matching case + r"\b(?:" + r"|".join(keyword.kwlist) + r")\b", # a keyword + ]) + + r"))" + ) + builtinlist = [str(name) for name in dir(builtins) + if not name.startswith('_') and + name not in keyword.kwlist] + builtin = r"([^.'\"\\#]\b|^)" + any("BUILTIN", builtinlist) + r"\b" + comment = any("COMMENT", [r"#[^\n]*"]) + stringprefix = r"(?i:r|u|f|fr|rf|b|br|rb|t|rt|tr)?" + sqstring = stringprefix + r"'[^'\\\n]*(\\.[^'\\\n]*)*'?" + dqstring = stringprefix + r'"[^"\\\n]*(\\.[^"\\\n]*)*"?' + sq3string = stringprefix + r"'''[^'\\]*((\\.|'(?!''))[^'\\]*)*(''')?" + dq3string = stringprefix + r'"""[^"\\]*((\\.|"(?!""))[^"\\]*)*(""")?' + string = any("STRING", [sq3string, dq3string, sqstring, dqstring]) + prog = re.compile("|".join([ + builtin, comment, string, kw, + match_softkw, case_default, + case_softkw_and_pattern, + any("SYNC", [r"\n"]), + ]), + re.DOTALL | re.MULTILINE) + return prog + + +prog = make_pat() +idprog = re.compile(r"\s+(\w+)") +prog_group_name_to_tag = { + "MATCH_SOFTKW": "KEYWORD", + "CASE_SOFTKW": "KEYWORD", + "CASE_DEFAULT_UNDERSCORE": "KEYWORD", + "CASE_SOFTKW2": "KEYWORD", +} + + +def matched_named_groups(re_match): + "Get only the non-empty named groups from an re.Match object." + return ((k, v) for (k, v) in re_match.groupdict().items() if v) + + +def color_config(text): + """Set color options of Text widget. + + If ColorDelegator is used, this should be called first. + """ + # Called from htest, TextFrame, Editor, and Turtledemo. + # Not automatic because ColorDelegator does not know 'text'. + theme = idleConf.CurrentTheme() + normal_colors = idleConf.GetHighlight(theme, 'normal') + cursor_color = idleConf.GetHighlight(theme, 'cursor')['foreground'] + select_colors = idleConf.GetHighlight(theme, 'hilite') + text.config( + foreground=normal_colors['foreground'], + background=normal_colors['background'], + insertbackground=cursor_color, + selectforeground=select_colors['foreground'], + selectbackground=select_colors['background'], + inactiveselectbackground=select_colors['background'], # new in 8.5 + ) + + +class ColorDelegator(Delegator): + """Delegator for syntax highlighting (text coloring). + + Instance variables: + delegate: Delegator below this one in the stack, meaning the + one this one delegates to. + + Used to track state: + after_id: Identifier for scheduled after event, which is a + timer for colorizing the text. + allow_colorizing: Boolean toggle for applying colorizing. + colorizing: Boolean flag when colorizing is in process. + stop_colorizing: Boolean flag to end an active colorizing + process. + """ + + def __init__(self): + Delegator.__init__(self) + self.init_state() + self.prog = prog + self.idprog = idprog + self.LoadTagDefs() + + def init_state(self): + "Initialize variables that track colorizing state." + self.after_id = None + self.allow_colorizing = True + self.stop_colorizing = False + self.colorizing = False + + def setdelegate(self, delegate): + """Set the delegate for this instance. + + A delegate is an instance of a Delegator class and each + delegate points to the next delegator in the stack. This + allows multiple delegators to be chained together for a + widget. The bottom delegate for a colorizer is a Text + widget. + + If there is a delegate, also start the colorizing process. + """ + if self.delegate is not None: + self.unbind("<>") + Delegator.setdelegate(self, delegate) + if delegate is not None: + self.config_colors() + self.bind("<>", self.toggle_colorize_event) + self.notify_range("1.0", "end") + else: + # No delegate - stop any colorizing. + self.stop_colorizing = True + self.allow_colorizing = False + + def config_colors(self): + "Configure text widget tags with colors from tagdefs." + for tag, cnf in self.tagdefs.items(): + self.tag_configure(tag, **cnf) + self.tag_raise('sel') + + def LoadTagDefs(self): + "Create dictionary of tag names to text colors." + theme = idleConf.CurrentTheme() + self.tagdefs = { + "COMMENT": idleConf.GetHighlight(theme, "comment"), + "KEYWORD": idleConf.GetHighlight(theme, "keyword"), + "BUILTIN": idleConf.GetHighlight(theme, "builtin"), + "STRING": idleConf.GetHighlight(theme, "string"), + "DEFINITION": idleConf.GetHighlight(theme, "definition"), + "SYNC": {'background': None, 'foreground': None}, + "TODO": {'background': None, 'foreground': None}, + "ERROR": idleConf.GetHighlight(theme, "error"), + # "hit" is used by ReplaceDialog to mark matches. It shouldn't be changed by Colorizer, but + # that currently isn't technically possible. This should be moved elsewhere in the future + # when fixing the "hit" tag's visibility, or when the replace dialog is replaced with a + # non-modal alternative. + "hit": idleConf.GetHighlight(theme, "hit"), + } + if DEBUG: print('tagdefs', self.tagdefs) + + def insert(self, index, chars, tags=None): + "Insert chars into widget at index and mark for colorizing." + index = self.index(index) + self.delegate.insert(index, chars, tags) + self.notify_range(index, index + "+%dc" % len(chars)) + + def delete(self, index1, index2=None): + "Delete chars between indexes and mark for colorizing." + index1 = self.index(index1) + self.delegate.delete(index1, index2) + self.notify_range(index1) + + def notify_range(self, index1, index2=None): + "Mark text changes for processing and restart colorizing, if active." + self.tag_add("TODO", index1, index2) + if self.after_id: + if DEBUG: print("colorizing already scheduled") + return + if self.colorizing: + self.stop_colorizing = True + if DEBUG: print("stop colorizing") + if self.allow_colorizing: + if DEBUG: print("schedule colorizing") + self.after_id = self.after(1, self.recolorize) + return + + def close(self): + if self.after_id: + after_id = self.after_id + self.after_id = None + if DEBUG: print("cancel scheduled recolorizer") + self.after_cancel(after_id) + self.allow_colorizing = False + self.stop_colorizing = True + + def toggle_colorize_event(self, event=None): + """Toggle colorizing on and off. + + When toggling off, if colorizing is scheduled or is in + process, it will be cancelled and/or stopped. + + When toggling on, colorizing will be scheduled. + """ + if self.after_id: + after_id = self.after_id + self.after_id = None + if DEBUG: print("cancel scheduled recolorizer") + self.after_cancel(after_id) + if self.allow_colorizing and self.colorizing: + if DEBUG: print("stop colorizing") + self.stop_colorizing = True + self.allow_colorizing = not self.allow_colorizing + if self.allow_colorizing and not self.colorizing: + self.after_id = self.after(1, self.recolorize) + if DEBUG: + print("auto colorizing turned", + "on" if self.allow_colorizing else "off") + return "break" + + def recolorize(self): + """Timer event (every 1ms) to colorize text. + + Colorizing is only attempted when the text widget exists, + when colorizing is toggled on, and when the colorizing + process is not already running. + + After colorizing is complete, some cleanup is done to + make sure that all the text has been colorized. + """ + self.after_id = None + if not self.delegate: + if DEBUG: print("no delegate") + return + if not self.allow_colorizing: + if DEBUG: print("auto colorizing is off") + return + if self.colorizing: + if DEBUG: print("already colorizing") + return + try: + self.stop_colorizing = False + self.colorizing = True + if DEBUG: print("colorizing...") + t0 = time.perf_counter() + self.recolorize_main() + t1 = time.perf_counter() + if DEBUG: print("%.3f seconds" % (t1-t0)) + finally: + self.colorizing = False + if self.allow_colorizing and self.tag_nextrange("TODO", "1.0"): + if DEBUG: print("reschedule colorizing") + self.after_id = self.after(1, self.recolorize) + + def recolorize_main(self): + "Evaluate text and apply colorizing tags." + next = "1.0" + while todo_tag_range := self.tag_nextrange("TODO", next): + self.tag_remove("SYNC", todo_tag_range[0], todo_tag_range[1]) + sync_tag_range = self.tag_prevrange("SYNC", todo_tag_range[0]) + head = sync_tag_range[1] if sync_tag_range else "1.0" + + chars = "" + next = head + lines_to_get = 1 + ok = False + while not ok: + mark = next + next = self.index(mark + "+%d lines linestart" % + lines_to_get) + lines_to_get = min(lines_to_get * 2, 100) + ok = "SYNC" in self.tag_names(next + "-1c") + line = self.get(mark, next) + ##print head, "get", mark, next, "->", repr(line) + if not line: + return + for tag in self.tagdefs: + self.tag_remove(tag, mark, next) + chars += line + self._add_tags_in_section(chars, head) + if "SYNC" in self.tag_names(next + "-1c"): + head = next + chars = "" + else: + ok = False + if not ok: + # We're in an inconsistent state, and the call to + # update may tell us to stop. It may also change + # the correct value for "next" (since this is a + # line.col string, not a true mark). So leave a + # crumb telling the next invocation to resume here + # in case update tells us to leave. + self.tag_add("TODO", next) + self.update_idletasks() + if self.stop_colorizing: + if DEBUG: print("colorizing stopped") + return + + def _add_tag(self, start, end, head, matched_group_name): + """Add a tag to a given range in the text widget. + + This is a utility function, receiving the range as `start` and + `end` positions, each of which is a number of characters + relative to the given `head` index in the text widget. + + The tag to add is determined by `matched_group_name`, which is + the name of a regular expression "named group" as matched by + by the relevant highlighting regexps. + """ + tag = prog_group_name_to_tag.get(matched_group_name, + matched_group_name) + self.tag_add(tag, + f"{head}+{start:d}c", + f"{head}+{end:d}c") + + def _add_tags_in_section(self, chars, head): + """Parse and add highlighting tags to a given part of the text. + + `chars` is a string with the text to parse and to which + highlighting is to be applied. + + `head` is the index in the text widget where the text is found. + """ + for m in self.prog.finditer(chars): + for name, matched_text in matched_named_groups(m): + a, b = m.span(name) + self._add_tag(a, b, head, name) + if matched_text in ("def", "class"): + if m1 := self.idprog.match(chars, b): + a, b = m1.span(1) + self._add_tag(a, b, head, "DEFINITION") + + def removecolors(self): + "Remove all colorizing tags." + for tag in self.tagdefs: + self.tag_remove(tag, "1.0", "end") + + +def _color_delegator(parent): # htest # + from tkinter import Toplevel, Text + from idlelib.idle_test.test_colorizer import source + from idlelib.percolator import Percolator + + top = Toplevel(parent) + top.title("Test ColorDelegator") + x, y = map(int, parent.geometry().split('+')[1:]) + top.geometry("700x550+%d+%d" % (x + 20, y + 175)) + + text = Text(top, background="white") + text.pack(expand=1, fill="both") + text.insert("insert", source) + text.focus_set() + + color_config(text) + p = Percolator(text) + d = ColorDelegator() + p.insertfilter(d) + + +if __name__ == "__main__": + from unittest import main + main('idlelib.idle_test.test_colorizer', verbosity=2, exit=False) + + from idlelib.idle_test.htest import run + run(_color_delegator) diff --git a/Lib/idlelib/config-extensions.def b/Lib/idlelib/config-extensions.def new file mode 100644 index 000000000..7e23fb0a7 --- /dev/null +++ b/Lib/idlelib/config-extensions.def @@ -0,0 +1,62 @@ +# config-extensions.def +# +# The following sections are for features that are no longer extensions. +# Their options values are left here for back-compatibility. + +[AutoComplete] +popupwait= 2000 + +[CodeContext] +maxlines= 15 + +[FormatParagraph] +max-width= 72 + +[ParenMatch] +style= expression +flash-delay= 500 +bell= True + +# IDLE reads several config files to determine user preferences. This +# file is the default configuration file for IDLE extensions settings. +# +# Each extension must have at least one section, named after the +# extension module. This section must contain an 'enable' item (=True to +# enable the extension, =False to disable it), it may contain +# 'enable_editor' or 'enable_shell' items, to apply it only to editor ir +# shell windows, and may also contain any other general configuration +# items for the extension. Other True/False values will also be +# recognized as boolean by the Extension Configuration dialog. +# +# Each extension must define at least one section named +# ExtensionName_bindings or ExtensionName_cfgBindings. If present, +# ExtensionName_bindings defines virtual event bindings for the +# extension that are not user re-configurable. If present, +# ExtensionName_cfgBindings defines virtual event bindings for the +# extension that may be sensibly re-configured. +# +# If there are no keybindings for a menus' virtual events, include lines +# like <>=. +# +# Currently it is necessary to manually modify this file to change +# extension key bindings and default values. To customize, create +# ~/.idlerc/config-extensions.cfg and append the appropriate customized +# section(s). Those sections will override the defaults in this file. +# +# Note: If a keybinding is already in use when the extension is loaded, +# the extension's virtual event's keybinding will be set to ''. +# +# See config-keys.def for notes on specifying keys and extend.txt for +# information on creating IDLE extensions. + +# A fake extension for testing and example purposes. When enabled and +# invoked, inserts or deletes z-text at beginning of every line. +[ZzDummy] +enable= False +enable_shell = False +enable_editor = True +z-text= Z +[ZzDummy_cfgBindings] +z-in= +[ZzDummy_bindings] +z-out= diff --git a/Lib/idlelib/config-highlight.def b/Lib/idlelib/config-highlight.def new file mode 100644 index 000000000..a7b043383 --- /dev/null +++ b/Lib/idlelib/config-highlight.def @@ -0,0 +1,105 @@ +# IDLE reads several config files to determine user preferences. This +# file is the default config file for idle highlight theme settings. + +[IDLE Classic] +normal-foreground= #000000 +normal-background= #ffffff +keyword-foreground= #ff7700 +keyword-background= #ffffff +builtin-foreground= #900090 +builtin-background= #ffffff +comment-foreground= #dd0000 +comment-background= #ffffff +string-foreground= #00aa00 +string-background= #ffffff +definition-foreground= #0000ff +definition-background= #ffffff +hilite-foreground= #000000 +hilite-background= gray +break-foreground= black +break-background= #ffff55 +hit-foreground= #ffffff +hit-background= #000000 +error-foreground= #000000 +error-background= #ff7777 +context-foreground= #000000 +context-background= lightgray +linenumber-foreground= gray +linenumber-background= #ffffff +#cursor (only foreground can be set, restart IDLE) +cursor-foreground= black +#shell window +stdout-foreground= blue +stdout-background= #ffffff +stderr-foreground= red +stderr-background= #ffffff +console-foreground= #770000 +console-background= #ffffff + +[IDLE New] +normal-foreground= #000000 +normal-background= #ffffff +keyword-foreground= #ff7700 +keyword-background= #ffffff +builtin-foreground= #900090 +builtin-background= #ffffff +comment-foreground= #dd0000 +comment-background= #ffffff +string-foreground= #00aa00 +string-background= #ffffff +definition-foreground= #0000ff +definition-background= #ffffff +hilite-foreground= #000000 +hilite-background= gray +break-foreground= black +break-background= #ffff55 +hit-foreground= #ffffff +hit-background= #000000 +error-foreground= #000000 +error-background= #ff7777 +context-foreground= #000000 +context-background= lightgray +linenumber-foreground= gray +linenumber-background= #ffffff +#cursor (only foreground can be set, restart IDLE) +cursor-foreground= black +#shell window +stdout-foreground= blue +stdout-background= #ffffff +stderr-foreground= red +stderr-background= #ffffff +console-foreground= #770000 +console-background= #ffffff + +[IDLE Dark] +comment-foreground = #dd0000 +console-foreground = #ff4d4d +error-foreground = #FFFFFF +hilite-background = #7e7e7e +string-foreground = #02ff02 +stderr-background = #002240 +stderr-foreground = #ffb3b3 +console-background = #002240 +hit-background = #fbfbfb +string-background = #002240 +normal-background = #002240 +hilite-foreground = #FFFFFF +keyword-foreground = #ff8000 +error-background = #c86464 +keyword-background = #002240 +builtin-background = #002240 +break-background = #808000 +builtin-foreground = #ff00ff +definition-foreground = #5e5eff +stdout-foreground = #c2d1fa +definition-background = #002240 +normal-foreground = #FFFFFF +cursor-foreground = #ffffff +stdout-background = #002240 +hit-foreground = #002240 +comment-background = #002240 +break-foreground = #FFFFFF +context-foreground= #ffffff +context-background= #454545 +linenumber-foreground= gray +linenumber-background= #002240 diff --git a/Lib/idlelib/config-keys.def b/Lib/idlelib/config-keys.def new file mode 100644 index 000000000..f71269b5b --- /dev/null +++ b/Lib/idlelib/config-keys.def @@ -0,0 +1,309 @@ +# IDLE reads several config files to determine user preferences. This +# file is the default config file for idle key binding settings. +# Where multiple keys are specified for an action: if they are separated +# by a space (eg. action= ) then the keys are alternatives, if +# there is no space (eg. action=) then the keys comprise a +# single 'emacs style' multi-keystoke binding. The tk event specifier 'Key' +# is used in all cases, for consistency in auto key conflict checking in the +# configuration gui. + +[IDLE Classic Windows] +copy= +cut= +paste= +beginning-of-line= +center-insert= +close-all-windows= +close-window= +do-nothing= +end-of-file= +python-docs= +python-context-help= +history-next= +history-previous= +interrupt-execution= +view-restart= +restart-shell= +open-class-browser= +open-module= +open-new-window= +open-window-from-file= +plain-newline-and-indent= +print-window= +redo= +remove-selection= +save-copy-of-window-as-file= +save-window-as-file= +save-window= +select-all= +toggle-auto-coloring= +undo= +find= +find-again= +find-in-files= +find-selection= +replace= +goto-line= +smart-backspace= +newline-and-indent= +smart-indent= +indent-region= +dedent-region= +comment-region= +uncomment-region= +tabify-region= +untabify-region= +toggle-tabs= +change-indentwidth= +del-word-left= +del-word-right= +force-open-completions= +expand-word= +force-open-calltip= +format-paragraph= +flash-paren= +run-module= +run-custom= +check-module= +zoom-height= + +[IDLE Classic Unix] +copy= +cut= +paste= +beginning-of-line= +center-insert= +close-all-windows= +close-window= +do-nothing= +end-of-file= +history-next= +history-previous= +interrupt-execution= +view-restart= +restart-shell= +open-class-browser= +open-module= +open-new-window= +open-window-from-file= +plain-newline-and-indent= +print-window= +python-docs= +python-context-help= +redo= +remove-selection= +save-copy-of-window-as-file= +save-window-as-file= +save-window= +select-all= +toggle-auto-coloring= +undo= +find= +find-again= +find-in-files= +find-selection= +replace= +goto-line= +smart-backspace= +newline-and-indent= +smart-indent= +indent-region= +dedent-region= +comment-region= +uncomment-region= +tabify-region= +untabify-region= +toggle-tabs= +change-indentwidth= +del-word-left= +del-word-right= +force-open-completions= +expand-word= +force-open-calltip= +format-paragraph= +flash-paren= +run-module= +run-custom= +check-module= +zoom-height= + +[IDLE Modern Unix] +copy = +cut = +paste = +beginning-of-line = +center-insert = +close-all-windows = +close-window = +do-nothing = +end-of-file = +history-next = +history-previous = +interrupt-execution = +view-restart = +restart-shell = +open-class-browser = +open-module = +open-new-window = +open-window-from-file = +plain-newline-and-indent = +print-window = +python-context-help = +python-docs = +redo = +remove-selection = +save-copy-of-window-as-file = +save-window-as-file = +save-window = +select-all = +toggle-auto-coloring = +undo = +find = +find-again = +find-in-files = +find-selection = +replace = +goto-line = +smart-backspace = +newline-and-indent = +smart-indent = +indent-region = +dedent-region = +comment-region = +uncomment-region = +tabify-region = +untabify-region = +toggle-tabs = +change-indentwidth = +del-word-left = +del-word-right = +force-open-completions= +expand-word= +force-open-calltip= +format-paragraph= +flash-paren= +run-module= +run-custom= +check-module= +zoom-height= + +[IDLE Classic Mac] +copy= +cut= +paste= +beginning-of-line= +center-insert= +close-all-windows= +close-window= +do-nothing= +end-of-file= +python-docs= +python-context-help= +history-next= +history-previous= +interrupt-execution= +view-restart= +restart-shell= +open-class-browser= +open-module= +open-new-window= +open-window-from-file= +plain-newline-and-indent= +print-window= +redo= +remove-selection= +save-window-as-file= +save-window= +save-copy-of-window-as-file= +select-all= +toggle-auto-coloring= +undo= +find= +find-again= +find-in-files= +find-selection= +replace= +goto-line= +smart-backspace= +newline-and-indent= +smart-indent= +indent-region= +dedent-region= +comment-region= +uncomment-region= +tabify-region= +untabify-region= +toggle-tabs= +change-indentwidth= +del-word-left= +del-word-right= +force-open-completions= +expand-word= +force-open-calltip= +format-paragraph= +flash-paren= +run-module= +run-custom= +check-module= +zoom-height= + +[IDLE Classic OSX] +toggle-tabs = +interrupt-execution = +untabify-region = +remove-selection = +print-window = +replace = +goto-line = +plain-newline-and-indent = +history-previous = +beginning-of-line = +end-of-line = +comment-region = +redo = +close-window = +restart-shell = +save-window-as-file = +close-all-windows = +view-restart = +tabify-region = +find-again = +find = +toggle-auto-coloring = +select-all = +smart-backspace = +change-indentwidth = +do-nothing = +smart-indent = +center-insert = +history-next = +del-word-right = +undo = +save-window = +uncomment-region = +cut = +find-in-files = +dedent-region = +copy = +paste = +indent-region = +del-word-left = +newline-and-indent = +end-of-file = +open-class-browser = +open-new-window = +open-module = +find-selection = +python-context-help = +save-copy-of-window-as-file = +open-window-from-file = +python-docs = +force-open-completions= +expand-word= +force-open-calltip= +format-paragraph= +flash-paren= +run-module= +run-custom= +check-module= +zoom-height= diff --git a/Lib/idlelib/config-main.def b/Lib/idlelib/config-main.def new file mode 100644 index 000000000..54bdce34a --- /dev/null +++ b/Lib/idlelib/config-main.def @@ -0,0 +1,92 @@ +# IDLE reads several config files to determine user preferences. This +# file is the default config file for general idle settings. +# +# When IDLE starts, it will look in +# the following two sets of files, in order: +# +# default configuration files in idlelib +# -------------------------------------- +# config-main.def default general config file +# config-extensions.def default extension config file +# config-highlight.def default highlighting config file +# config-keys.def default keybinding config file +# +# user configuration files in ~/.idlerc +# ------------------------------------- +# config-main.cfg user general config file +# config-extensions.cfg user extension config file +# config-highlight.cfg user highlighting config file +# config-keys.cfg user keybinding config file +# +# On Windows, the default location of the home directory ('~' above) +# depends on the version. For Windows 10, it is C:\Users\. +# +# Any options the user saves through the config dialog will be saved to +# the relevant user config file. Reverting any general or extension +# setting to the default causes that entry to be wiped from the user +# file and re-read from the default file. This rule applies to each +# item, except that the three editor font items are saved as a group. +# +# User highlighting themes and keybinding sets must have (section) names +# distinct from the default names. All items are added and saved as a +# group. They are retained unless specifically deleted within the config +# dialog. Choosing one of the default themes or keysets just applies the +# relevant settings from the default file. +# +# Additional help sources are listed in the [HelpFiles] section below +# and should be viewable by a web browser. These sources will be listed +# on the Help menu. The pattern, and two examples, are: +# +# +# 1 = IDLE;C:/Programs/Python36/Lib/idlelib/help.html +# 2 = Pillow;https://pillow.readthedocs.io/en/latest/ +# +# You can't use a semi-colon in a menu item or path. The path will be +# platform specific because of path separators, drive specs etc. +# +# The default files should not be edited except to add new sections to +# config-extensions.def for added extensions. The user files should be +# modified through the Settings dialog. + +[General] +editor-on-startup= 0 +autosave= 0 +print-command-posix=lpr %%s +print-command-win=start /min notepad /p %%s +delete-exitfunc= 1 + +[EditorWindow] +width= 80 +height= 40 +cursor-blink= 1 +font= TkFixedFont +# For TkFixedFont, the actual size and boldness are obtained from tk +# and override 10 and 0. See idlelib.config.IdleConf.GetFont +font-size= 10 +font-bold= 0 +encoding= none +line-numbers-default= 0 + +[PyShell] +auto-squeeze-min-lines= 50 + +[Indent] +use-spaces= 1 +num-spaces= 4 + +[Theme] +default= 1 +name= IDLE Classic +name2= +# name2 set in user config-main.cfg for themes added after 2015 Oct 1 + +[Keys] +default= 1 +name= +name2= +# name2 set in user config-main.cfg for keys added after 2016 July 1 + +[History] +cyclic=1 + +[HelpFiles] diff --git a/Lib/idlelib/config.py b/Lib/idlelib/config.py new file mode 100644 index 000000000..d10c88a43 --- /dev/null +++ b/Lib/idlelib/config.py @@ -0,0 +1,917 @@ +"""idlelib.config -- Manage IDLE configuration information. + +The comments at the beginning of config-main.def describe the +configuration files and the design implemented to update user +configuration information. In particular, user configuration choices +which duplicate the defaults will be removed from the user's +configuration files, and if a user file becomes empty, it will be +deleted. + +The configuration database maps options to values. Conceptually, the +database keys are tuples (config-type, section, item). As implemented, +there are separate dicts for default and user values. Each has +config-type keys 'main', 'extensions', 'highlight', and 'keys'. The +value for each key is a ConfigParser instance that maps section and item +to values. For 'main' and 'extensions', user values override +default values. For 'highlight' and 'keys', user sections augment the +default sections (and must, therefore, have distinct names). + +Throughout this module there is an emphasis on returning usable defaults +when a problem occurs in returning a requested configuration value back to +idle. This is to allow IDLE to continue to function in spite of errors in +the retrieval of config information. When a default is returned instead of +a requested config value, a message is printed to stderr to aid in +configuration problem notification and resolution. +""" +# TODOs added Oct 2014, tjr + +from configparser import ConfigParser +import os +import sys + +from tkinter.font import Font +import idlelib + +class InvalidConfigType(Exception): pass +class InvalidConfigSet(Exception): pass +class InvalidTheme(Exception): pass + +class IdleConfParser(ConfigParser): + """ + A ConfigParser specialised for idle configuration file handling + """ + def __init__(self, cfgFile, cfgDefaults=None): + """ + cfgFile - string, fully specified configuration file name + """ + self.file = cfgFile # This is currently '' when testing. + ConfigParser.__init__(self, defaults=cfgDefaults, strict=False) + + def Get(self, section, option, type=None, default=None, raw=False): + """ + Get an option value for given section/option or return default. + If type is specified, return as type. + """ + # TODO Use default as fallback, at least if not None + # Should also print Warning(file, section, option). + # Currently may raise ValueError + if not self.has_option(section, option): + return default + if type == 'bool': + return self.getboolean(section, option) + elif type == 'int': + return self.getint(section, option) + else: + return self.get(section, option, raw=raw) + + def GetOptionList(self, section): + "Return a list of options for given section, else []." + if self.has_section(section): + return self.options(section) + else: #return a default value + return [] + + def Load(self): + "Load the configuration file from disk." + if self.file: + self.read(self.file) + +class IdleUserConfParser(IdleConfParser): + """ + IdleConfigParser specialised for user configuration handling. + """ + + def SetOption(self, section, option, value): + """Return True if option is added or changed to value, else False. + + Add section if required. False means option already had value. + """ + if self.has_option(section, option): + if self.get(section, option) == value: + return False + else: + self.set(section, option, value) + return True + else: + if not self.has_section(section): + self.add_section(section) + self.set(section, option, value) + return True + + def RemoveOption(self, section, option): + """Return True if option is removed from section, else False. + + False if either section does not exist or did not have option. + """ + if self.has_section(section): + return self.remove_option(section, option) + return False + + def AddSection(self, section): + "If section doesn't exist, add it." + if not self.has_section(section): + self.add_section(section) + + def RemoveEmptySections(self): + "Remove any sections that have no options." + for section in self.sections(): + if not self.GetOptionList(section): + self.remove_section(section) + + def IsEmpty(self): + "Return True if no sections after removing empty sections." + self.RemoveEmptySections() + return not self.sections() + + def Save(self): + """Update user configuration file. + + If self not empty after removing empty sections, write the file + to disk. Otherwise, remove the file from disk if it exists. + """ + fname = self.file + if fname and fname[0] != '#': + if not self.IsEmpty(): + try: + cfgFile = open(fname, 'w') + except OSError: + os.unlink(fname) + cfgFile = open(fname, 'w') + with cfgFile: + self.write(cfgFile) + elif os.path.exists(self.file): + os.remove(self.file) + +class IdleConf: + """Hold config parsers for all idle config files in singleton instance. + + Default config files, self.defaultCfg -- + for config_type in self.config_types: + (idle install dir)/config-{config-type}.def + + User config files, self.userCfg -- + for config_type in self.config_types: + (user home dir)/.idlerc/config-{config-type}.cfg + """ + def __init__(self, _utest=False): + self.config_types = ('main', 'highlight', 'keys', 'extensions') + self.defaultCfg = {} + self.userCfg = {} + self.cfg = {} # TODO use to select userCfg vs defaultCfg + + # See https://bugs.python.org/issue4630#msg356516 for following. + # self.blink_off_time = ['insertofftime'] + + if not _utest: + self.CreateConfigHandlers() + self.LoadCfgFiles() + + def CreateConfigHandlers(self): + "Populate default and user config parser dictionaries." + idledir = os.path.dirname(__file__) + self.userdir = userdir = '' if idlelib.testing else self.GetUserCfgDir() + for cfg_type in self.config_types: + self.defaultCfg[cfg_type] = IdleConfParser( + os.path.join(idledir, f'config-{cfg_type}.def')) + self.userCfg[cfg_type] = IdleUserConfParser( + os.path.join(userdir or '#', f'config-{cfg_type}.cfg')) + + def GetUserCfgDir(self): + """Return a filesystem directory for storing user config files. + + Creates it if required. + """ + cfgDir = '.idlerc' + userDir = os.path.expanduser('~') + if userDir != '~': # expanduser() found user home dir + if not os.path.exists(userDir): + if not idlelib.testing: + warn = ('\n Warning: os.path.expanduser("~") points to\n ' + + userDir + ',\n but the path does not exist.') + try: + print(warn, file=sys.stderr) + except OSError: + pass + userDir = '~' + if userDir == "~": # still no path to home! + # traditionally IDLE has defaulted to os.getcwd(), is this adequate? + userDir = os.getcwd() + userDir = os.path.join(userDir, cfgDir) + if not os.path.exists(userDir): + try: + os.mkdir(userDir) + except OSError: + if not idlelib.testing: + warn = ('\n Warning: unable to create user config directory\n' + + userDir + '\n Check path and permissions.\n Exiting!\n') + try: + print(warn, file=sys.stderr) + except OSError: + pass + raise SystemExit + # TODO continue without userDIr instead of exit + return userDir + + def GetOption(self, configType, section, option, default=None, type=None, + warn_on_default=True, raw=False): + """Return a value for configType section option, or default. + + If type is not None, return a value of that type. Also pass raw + to the config parser. First try to return a valid value + (including type) from a user configuration. If that fails, try + the default configuration. If that fails, return default, with a + default of None. + + Warn if either user or default configurations have an invalid value. + Warn if default is returned and warn_on_default is True. + """ + try: + if self.userCfg[configType].has_option(section, option): + return self.userCfg[configType].Get(section, option, + type=type, raw=raw) + except ValueError: + warning = ('\n Warning: config.py - IdleConf.GetOption -\n' + ' invalid %r value for configuration option %r\n' + ' from section %r: %r' % + (type, option, section, + self.userCfg[configType].Get(section, option, raw=raw))) + _warn(warning, configType, section, option) + try: + if self.defaultCfg[configType].has_option(section,option): + return self.defaultCfg[configType].Get( + section, option, type=type, raw=raw) + except ValueError: + pass + #returning default, print warning + if warn_on_default: + warning = ('\n Warning: config.py - IdleConf.GetOption -\n' + ' problem retrieving configuration option %r\n' + ' from section %r.\n' + ' returning default value: %r' % + (option, section, default)) + _warn(warning, configType, section, option) + return default + + def SetOption(self, configType, section, option, value): + """Set section option to value in user config file.""" + self.userCfg[configType].SetOption(section, option, value) + + def GetSectionList(self, configSet, configType): + """Return sections for configSet configType configuration. + + configSet must be either 'user' or 'default' + configType must be in self.config_types. + """ + if not (configType in self.config_types): + raise InvalidConfigType('Invalid configType specified') + if configSet == 'user': + cfgParser = self.userCfg[configType] + elif configSet == 'default': + cfgParser=self.defaultCfg[configType] + else: + raise InvalidConfigSet('Invalid configSet specified') + return cfgParser.sections() + + def GetHighlight(self, theme, element): + """Return dict of theme element highlight colors. + + The keys are 'foreground' and 'background'. The values are + tkinter color strings for configuring backgrounds and tags. + """ + cfg = ('default' if self.defaultCfg['highlight'].has_section(theme) + else 'user') + theme_dict = self.GetThemeDict(cfg, theme) + fore = theme_dict[element + '-foreground'] + if element == 'cursor': + element = 'normal' + back = theme_dict[element + '-background'] + return {"foreground": fore, "background": back} + + def GetThemeDict(self, type, themeName): + """Return {option:value} dict for elements in themeName. + + type - string, 'default' or 'user' theme type + themeName - string, theme name + Values are loaded over ultimate fallback defaults to guarantee + that all theme elements are present in a newly created theme. + """ + if type == 'user': + cfgParser = self.userCfg['highlight'] + elif type == 'default': + cfgParser = self.defaultCfg['highlight'] + else: + raise InvalidTheme('Invalid theme type specified') + # Provide foreground and background colors for each theme + # element (other than cursor) even though some values are not + # yet used by idle, to allow for their use in the future. + # Default values are generally black and white. + # TODO copy theme from a class attribute. + theme ={'normal-foreground':'#000000', + 'normal-background':'#ffffff', + 'keyword-foreground':'#000000', + 'keyword-background':'#ffffff', + 'builtin-foreground':'#000000', + 'builtin-background':'#ffffff', + 'comment-foreground':'#000000', + 'comment-background':'#ffffff', + 'string-foreground':'#000000', + 'string-background':'#ffffff', + 'definition-foreground':'#000000', + 'definition-background':'#ffffff', + 'hilite-foreground':'#000000', + 'hilite-background':'gray', + 'break-foreground':'#ffffff', + 'break-background':'#000000', + 'hit-foreground':'#ffffff', + 'hit-background':'#000000', + 'error-foreground':'#ffffff', + 'error-background':'#000000', + 'context-foreground':'#000000', + 'context-background':'#ffffff', + 'linenumber-foreground':'#000000', + 'linenumber-background':'#ffffff', + #cursor (only foreground can be set) + 'cursor-foreground':'#000000', + #shell window + 'stdout-foreground':'#000000', + 'stdout-background':'#ffffff', + 'stderr-foreground':'#000000', + 'stderr-background':'#ffffff', + 'console-foreground':'#000000', + 'console-background':'#ffffff', + } + for element in theme: + if not (cfgParser.has_option(themeName, element) or + # Skip warning for new elements. + element.startswith(('context-', 'linenumber-'))): + # Print warning that will return a default color + warning = ('\n Warning: config.IdleConf.GetThemeDict' + ' -\n problem retrieving theme element %r' + '\n from theme %r.\n' + ' returning default color: %r' % + (element, themeName, theme[element])) + _warn(warning, 'highlight', themeName, element) + theme[element] = cfgParser.Get( + themeName, element, default=theme[element]) + return theme + + def CurrentTheme(self): + "Return the name of the currently active text color theme." + return self.current_colors_and_keys('Theme') + + def CurrentKeys(self): + """Return the name of the currently active key set.""" + return self.current_colors_and_keys('Keys') + + def current_colors_and_keys(self, section): + """Return the currently active name for Theme or Keys section. + + idlelib.config-main.def ('default') includes these sections + + [Theme] + default= 1 + name= IDLE Classic + name2= + + [Keys] + default= 1 + name= + name2= + + Item 'name2', is used for built-in ('default') themes and keys + added after 2015 Oct 1 and 2016 July 1. This kludge is needed + because setting 'name' to a builtin not defined in older IDLEs + to display multiple error messages or quit. + See https://bugs.python.org/issue25313. + When default = True, 'name2' takes precedence over 'name', + while older IDLEs will just use name. When default = False, + 'name2' may still be set, but it is ignored. + """ + cfgname = 'highlight' if section == 'Theme' else 'keys' + default = self.GetOption('main', section, 'default', + type='bool', default=True) + name = '' + if default: + name = self.GetOption('main', section, 'name2', default='') + if not name: + name = self.GetOption('main', section, 'name', default='') + if name: + source = self.defaultCfg if default else self.userCfg + if source[cfgname].has_section(name): + return name + return "IDLE Classic" if section == 'Theme' else self.default_keys() + + @staticmethod + def default_keys(): + if sys.platform[:3] == 'win': + return 'IDLE Classic Windows' + elif sys.platform == 'darwin': + return 'IDLE Classic OSX' + else: + return 'IDLE Modern Unix' + + def GetExtensions(self, active_only=True, + editor_only=False, shell_only=False): + """Return extensions in default and user config-extensions files. + + If active_only True, only return active (enabled) extensions + and optionally only editor or shell extensions. + If active_only False, return all extensions. + """ + extns = self.RemoveKeyBindNames( + self.GetSectionList('default', 'extensions')) + userExtns = self.RemoveKeyBindNames( + self.GetSectionList('user', 'extensions')) + for extn in userExtns: + if extn not in extns: #user has added own extension + extns.append(extn) + for extn in ('AutoComplete','CodeContext', + 'FormatParagraph','ParenMatch'): + extns.remove(extn) + # specific exclusions because we are storing config for mainlined old + # extensions in config-extensions.def for backward compatibility + if active_only: + activeExtns = [] + for extn in extns: + if self.GetOption('extensions', extn, 'enable', default=True, + type='bool'): + #the extension is enabled + if editor_only or shell_only: # TODO both True contradict + if editor_only: + option = "enable_editor" + else: + option = "enable_shell" + if self.GetOption('extensions', extn,option, + default=True, type='bool', + warn_on_default=False): + activeExtns.append(extn) + else: + activeExtns.append(extn) + return activeExtns + else: + return extns + + def RemoveKeyBindNames(self, extnNameList): + "Return extnNameList with keybinding section names removed." + return [n for n in extnNameList if not n.endswith(('_bindings', '_cfgBindings'))] + + def GetExtnNameForEvent(self, virtualEvent): + """Return the name of the extension binding virtualEvent, or None. + + virtualEvent - string, name of the virtual event to test for, + without the enclosing '<< >>' + """ + extName = None + vEvent = '<<' + virtualEvent + '>>' + for extn in self.GetExtensions(active_only=0): + for event in self.GetExtensionKeys(extn): + if event == vEvent: + extName = extn # TODO return here? + return extName + + def GetExtensionKeys(self, extensionName): + """Return dict: {configurable extensionName event : active keybinding}. + + Events come from default config extension_cfgBindings section. + Keybindings come from GetCurrentKeySet() active key dict, + where previously used bindings are disabled. + """ + keysName = extensionName + '_cfgBindings' + activeKeys = self.GetCurrentKeySet() + extKeys = {} + if self.defaultCfg['extensions'].has_section(keysName): + eventNames = self.defaultCfg['extensions'].GetOptionList(keysName) + for eventName in eventNames: + event = '<<' + eventName + '>>' + binding = activeKeys[event] + extKeys[event] = binding + return extKeys + + def __GetRawExtensionKeys(self,extensionName): + """Return dict {configurable extensionName event : keybinding list}. + + Events come from default config extension_cfgBindings section. + Keybindings list come from the splitting of GetOption, which + tries user config before default config. + """ + keysName = extensionName+'_cfgBindings' + extKeys = {} + if self.defaultCfg['extensions'].has_section(keysName): + eventNames = self.defaultCfg['extensions'].GetOptionList(keysName) + for eventName in eventNames: + binding = self.GetOption( + 'extensions', keysName, eventName, default='').split() + event = '<<' + eventName + '>>' + extKeys[event] = binding + return extKeys + + def GetExtensionBindings(self, extensionName): + """Return dict {extensionName event : active or defined keybinding}. + + Augment self.GetExtensionKeys(extensionName) with mapping of non- + configurable events (from default config) to GetOption splits, + as in self.__GetRawExtensionKeys. + """ + bindsName = extensionName + '_bindings' + extBinds = self.GetExtensionKeys(extensionName) + #add the non-configurable bindings + if self.defaultCfg['extensions'].has_section(bindsName): + eventNames = self.defaultCfg['extensions'].GetOptionList(bindsName) + for eventName in eventNames: + binding = self.GetOption( + 'extensions', bindsName, eventName, default='').split() + event = '<<' + eventName + '>>' + extBinds[event] = binding + + return extBinds + + def GetKeyBinding(self, keySetName, eventStr): + """Return the keybinding list for keySetName eventStr. + + keySetName - name of key binding set (config-keys section). + eventStr - virtual event, including brackets, as in '<>'. + """ + eventName = eventStr[2:-2] #trim off the angle brackets + binding = self.GetOption('keys', keySetName, eventName, default='', + warn_on_default=False).split() + return binding + + def GetCurrentKeySet(self): + "Return CurrentKeys with 'darwin' modifications." + result = self.GetKeySet(self.CurrentKeys()) + + if sys.platform == "darwin": + # macOS (OS X) Tk variants do not support the "Alt" + # keyboard modifier. Replace it with "Option". + # TODO (Ned?): the "Option" modifier does not work properly + # for Cocoa Tk and XQuartz Tk so we should not use it + # in the default 'OSX' keyset. + for k, v in result.items(): + v2 = [ x.replace('>' + """ + return ('<<'+virtualEvent+'>>') in self.GetCoreKeys() + +# TODO make keyBindings a file or class attribute used for test above +# and copied in function below. + + former_extension_events = { # Those with user-configurable keys. + '<>', '<>', + '<>', '<>', '<>', + '<>', '<>', '<>', + '<>', + } + + def GetCoreKeys(self, keySetName=None): + """Return dict of core virtual-key keybindings for keySetName. + + The default keySetName None corresponds to the keyBindings base + dict. If keySetName is not None, bindings from the config + file(s) are loaded _over_ these defaults, so if there is a + problem getting any core binding there will be an 'ultimate last + resort fallback' to the CUA-ish bindings defined here. + """ + # TODO: = dict(sorted([(v-event, keys), ...]))? + keyBindings={ + # virtual-event: list of key events. + '<>': ['', ''], + '<>': ['', ''], + '<>': ['', ''], + '<>': ['', ''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': ['', ''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': ['', ''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': [''], + '<>': [''], + } + + if keySetName: + if not (self.userCfg['keys'].has_section(keySetName) or + self.defaultCfg['keys'].has_section(keySetName)): + warning = ( + '\n Warning: config.py - IdleConf.GetCoreKeys -\n' + ' key set %r is not defined, using default bindings.' % + (keySetName,) + ) + _warn(warning, 'keys', keySetName) + else: + for event in keyBindings: + binding = self.GetKeyBinding(keySetName, event) + if binding: + keyBindings[event] = binding + # Otherwise return default in keyBindings. + elif event not in self.former_extension_events: + warning = ( + '\n Warning: config.py - IdleConf.GetCoreKeys -\n' + ' problem retrieving key binding for event %r\n' + ' from key set %r.\n' + ' returning default value: %r' % + (event, keySetName, keyBindings[event]) + ) + _warn(warning, 'keys', keySetName, event) + return keyBindings + + def GetExtraHelpSourceList(self, configSet): + """Return list of extra help sources from a given configSet. + + Valid configSets are 'user' or 'default'. Return a list of tuples of + the form (menu_item , path_to_help_file , option), or return the empty + list. 'option' is the sequence number of the help resource. 'option' + values determine the position of the menu items on the Help menu, + therefore the returned list must be sorted by 'option'. + + """ + helpSources = [] + if configSet == 'user': + cfgParser = self.userCfg['main'] + elif configSet == 'default': + cfgParser = self.defaultCfg['main'] + else: + raise InvalidConfigSet('Invalid configSet specified') + options=cfgParser.GetOptionList('HelpFiles') + for option in options: + value=cfgParser.Get('HelpFiles', option, default=';') + if value.find(';') == -1: #malformed config entry with no ';' + menuItem = '' #make these empty + helpPath = '' #so value won't be added to list + else: #config entry contains ';' as expected + value=value.split(';') + menuItem=value[0].strip() + helpPath=value[1].strip() + if menuItem and helpPath: #neither are empty strings + helpSources.append( (menuItem,helpPath,option) ) + helpSources.sort(key=lambda x: x[2]) + return helpSources + + def GetAllExtraHelpSourcesList(self): + """Return a list of the details of all additional help sources. + + Tuples in the list are those of GetExtraHelpSourceList. + """ + allHelpSources = (self.GetExtraHelpSourceList('default') + + self.GetExtraHelpSourceList('user') ) + return allHelpSources + + def GetFont(self, root, configType, section): + """Retrieve a font from configuration (font, font-size, font-bold) + Intercept the special value 'TkFixedFont' and substitute + the actual font, factoring in some tweaks if needed for + appearance sakes. + + The 'root' parameter can normally be any valid Tkinter widget. + + Return a tuple (family, size, weight) suitable for passing + to tkinter.Font + """ + family = self.GetOption(configType, section, 'font', default='courier') + size = self.GetOption(configType, section, 'font-size', type='int', + default='10') + bold = self.GetOption(configType, section, 'font-bold', default=0, + type='bool') + if (family == 'TkFixedFont'): + f = Font(name='TkFixedFont', exists=True, root=root) + actualFont = Font.actual(f) + family = actualFont['family'] + size = actualFont['size'] + if size <= 0: + size = 10 # if font in pixels, ignore actual size + bold = actualFont['weight'] == 'bold' + return (family, size, 'bold' if bold else 'normal') + + def LoadCfgFiles(self): + "Load all configuration files." + for key in self.defaultCfg: + self.defaultCfg[key].Load() + self.userCfg[key].Load() #same keys + + def SaveUserCfgFiles(self): + "Write all loaded user configuration files to disk." + for key in self.userCfg: + self.userCfg[key].Save() + + +idleConf = IdleConf() + +_warned = set() +def _warn(msg, *key): + key = (msg,) + key + if key not in _warned: + try: + print(msg, file=sys.stderr) + except OSError: + pass + _warned.add(key) + + +class ConfigChanges(dict): + """Manage a user's proposed configuration option changes. + + Names used across multiple methods: + page -- one of the 4 top-level dicts representing a + .idlerc/config-x.cfg file. + config_type -- name of a page. + section -- a section within a page/file. + option -- name of an option within a section. + value -- value for the option. + + Methods + add_option: Add option and value to changes. + save_option: Save option and value to config parser. + save_all: Save all the changes to the config parser and file. + delete_section: If section exists, + delete from changes, userCfg, and file. + clear: Clear all changes by clearing each page. + """ + def __init__(self): + "Create a page for each configuration file" + self.pages = [] # List of unhashable dicts. + for config_type in idleConf.config_types: + self[config_type] = {} + self.pages.append(self[config_type]) + + def add_option(self, config_type, section, item, value): + "Add item/value pair for config_type and section." + page = self[config_type] + value = str(value) # Make sure we use a string. + if section not in page: + page[section] = {} + page[section][item] = value + + @staticmethod + def save_option(config_type, section, item, value): + """Return True if the configuration value was added or changed. + + Helper for save_all. + """ + if idleConf.defaultCfg[config_type].has_option(section, item): + if idleConf.defaultCfg[config_type].Get(section, item) == value: + # The setting equals a default setting, remove it from user cfg. + return idleConf.userCfg[config_type].RemoveOption(section, item) + # If we got here, set the option. + return idleConf.userCfg[config_type].SetOption(section, item, value) + + def save_all(self): + """Save configuration changes to the user config file. + + Clear self in preparation for additional changes. + Return changed for testing. + """ + idleConf.userCfg['main'].Save() + + changed = False + for config_type in self: + cfg_type_changed = False + page = self[config_type] + for section in page: + if section == 'HelpFiles': # Remove it for replacement. + idleConf.userCfg['main'].remove_section('HelpFiles') + cfg_type_changed = True + for item, value in page[section].items(): + if self.save_option(config_type, section, item, value): + cfg_type_changed = True + if cfg_type_changed: + idleConf.userCfg[config_type].Save() + changed = True + for config_type in ['keys', 'highlight']: + # Save these even if unchanged! + idleConf.userCfg[config_type].Save() + self.clear() + # ConfigDialog caller must add the following call + # self.save_all_changed_extensions() # Uses a different mechanism. + return changed + + def delete_section(self, config_type, section): + """Delete a section from self, userCfg, and file. + + Used to delete custom themes and keysets. + """ + if section in self[config_type]: + del self[config_type][section] + configpage = idleConf.userCfg[config_type] + configpage.remove_section(section) + configpage.Save() + + def clear(self): + """Clear all 4 pages. + + Called in save_all after saving to idleConf. + XXX Mark window *title* when there are changes; unmark here. + """ + for page in self.pages: + page.clear() + + +# TODO Revise test output, write expanded unittest +def _dump(): # htest # (not really, but ignore in coverage) + from zlib import crc32 + line, crc = 0, 0 + + def sprint(obj): + nonlocal line, crc + txt = str(obj) + line += 1 + crc = crc32(txt.encode(encoding='utf-8'), crc) + print(txt) + #print('***', line, crc, '***') # Uncomment for diagnosis. + + def dumpCfg(cfg): + print('\n', cfg, '\n') # Cfg has variable '0xnnnnnnnn' address. + for key in sorted(cfg): + sections = cfg[key].sections() + sprint(key) + sprint(sections) + for section in sections: + options = cfg[key].options(section) + sprint(section) + sprint(options) + for option in options: + sprint(option + ' = ' + cfg[key].Get(section, option)) + + dumpCfg(idleConf.defaultCfg) + dumpCfg(idleConf.userCfg) + print('\nlines = ', line, ', crc = ', crc, sep='') + + +if __name__ == '__main__': + from unittest import main + main('idlelib.idle_test.test_config', verbosity=2, exit=False) + + _dump() + # Run revised _dump() (700+ lines) as htest? More sorting. + # Perhaps as window with tabs for textviews, making it config viewer. diff --git a/Lib/idlelib/config_key.py b/Lib/idlelib/config_key.py new file mode 100644 index 000000000..e5f67e8d4 --- /dev/null +++ b/Lib/idlelib/config_key.py @@ -0,0 +1,354 @@ +""" +Dialog for building Tkinter accelerator key bindings +""" +from tkinter import Toplevel, Listbox, StringVar, TclError +from tkinter.ttk import Frame, Button, Checkbutton, Entry, Label, Scrollbar +from tkinter import messagebox +from tkinter.simpledialog import _setup_dialog +import string +import sys + + +FUNCTION_KEYS = ('F1', 'F2' ,'F3' ,'F4' ,'F5' ,'F6', + 'F7', 'F8' ,'F9' ,'F10' ,'F11' ,'F12') +ALPHANUM_KEYS = tuple(string.ascii_lowercase + string.digits) +PUNCTUATION_KEYS = tuple('~!@#%^&*()_-+={}[]|;:,.<>/?') +WHITESPACE_KEYS = ('Tab', 'Space', 'Return') +EDIT_KEYS = ('BackSpace', 'Delete', 'Insert') +MOVE_KEYS = ('Home', 'End', 'Page Up', 'Page Down', 'Left Arrow', + 'Right Arrow', 'Up Arrow', 'Down Arrow') +AVAILABLE_KEYS = (ALPHANUM_KEYS + PUNCTUATION_KEYS + FUNCTION_KEYS + + WHITESPACE_KEYS + EDIT_KEYS + MOVE_KEYS) + + +def translate_key(key, modifiers): + "Translate from keycap symbol to the Tkinter keysym." + mapping = {'Space':'space', + '~':'asciitilde', '!':'exclam', '@':'at', '#':'numbersign', + '%':'percent', '^':'asciicircum', '&':'ampersand', + '*':'asterisk', '(':'parenleft', ')':'parenright', + '_':'underscore', '-':'minus', '+':'plus', '=':'equal', + '{':'braceleft', '}':'braceright', + '[':'bracketleft', ']':'bracketright', '|':'bar', + ';':'semicolon', ':':'colon', ',':'comma', '.':'period', + '<':'less', '>':'greater', '/':'slash', '?':'question', + 'Page Up':'Prior', 'Page Down':'Next', + 'Left Arrow':'Left', 'Right Arrow':'Right', + 'Up Arrow':'Up', 'Down Arrow': 'Down', 'Tab':'Tab'} + key = mapping.get(key, key) + if 'Shift' in modifiers and key in string.ascii_lowercase: + key = key.upper() + return f'Key-{key}' + + +class GetKeysFrame(Frame): + + # Dialog title for invalid key sequence + keyerror_title = 'Key Sequence Error' + + def __init__(self, parent, action, current_key_sequences): + """ + parent - parent of this dialog + action - the name of the virtual event these keys will be + mapped to + current_key_sequences - a list of all key sequence lists + currently mapped to virtual events, for overlap checking + """ + super().__init__(parent) + self['borderwidth'] = 2 + self['relief'] = 'sunken' + self.parent = parent + self.action = action + self.current_key_sequences = current_key_sequences + self.result = '' + self.key_string = StringVar(self) + self.key_string.set('') + # Set self.modifiers, self.modifier_label. + self.set_modifiers_for_platform() + self.modifier_vars = [] + for modifier in self.modifiers: + variable = StringVar(self) + variable.set('') + self.modifier_vars.append(variable) + self.advanced = False + self.create_widgets() + + def showerror(self, *args, **kwargs): + # Make testing easier. Replace in #30751. + messagebox.showerror(*args, **kwargs) + + def create_widgets(self): + # Basic entry key sequence. + self.frame_keyseq_basic = Frame(self, name='keyseq_basic') + self.frame_keyseq_basic.grid(row=0, column=0, sticky='nsew', + padx=5, pady=5) + basic_title = Label(self.frame_keyseq_basic, + text=f"New keys for '{self.action}' :") + basic_title.pack(anchor='w') + + basic_keys = Label(self.frame_keyseq_basic, justify='left', + textvariable=self.key_string, relief='groove', + borderwidth=2) + basic_keys.pack(ipadx=5, ipady=5, fill='x') + + # Basic entry controls. + self.frame_controls_basic = Frame(self) + self.frame_controls_basic.grid(row=1, column=0, sticky='nsew', padx=5) + + # Basic entry modifiers. + self.modifier_checkbuttons = {} + column = 0 + for modifier, variable in zip(self.modifiers, self.modifier_vars): + label = self.modifier_label.get(modifier, modifier) + check = Checkbutton(self.frame_controls_basic, + command=self.build_key_string, text=label, + variable=variable, onvalue=modifier, offvalue='') + check.grid(row=0, column=column, padx=2, sticky='w') + self.modifier_checkbuttons[modifier] = check + column += 1 + + # Basic entry help text. + help_basic = Label(self.frame_controls_basic, justify='left', + text="Select the desired modifier keys\n"+ + "above, and the final key from the\n"+ + "list on the right.\n\n" + + "Use upper case Symbols when using\n" + + "the Shift modifier. (Letters will be\n" + + "converted automatically.)") + help_basic.grid(row=1, column=0, columnspan=4, padx=2, sticky='w') + + # Basic entry key list. + self.list_keys_final = Listbox(self.frame_controls_basic, width=15, + height=10, selectmode='single') + self.list_keys_final.insert('end', *AVAILABLE_KEYS) + self.list_keys_final.bind('', self.final_key_selected) + self.list_keys_final.grid(row=0, column=4, rowspan=4, sticky='ns') + scroll_keys_final = Scrollbar(self.frame_controls_basic, + orient='vertical', + command=self.list_keys_final.yview) + self.list_keys_final.config(yscrollcommand=scroll_keys_final.set) + scroll_keys_final.grid(row=0, column=5, rowspan=4, sticky='ns') + self.button_clear = Button(self.frame_controls_basic, + text='Clear Keys', + command=self.clear_key_seq) + self.button_clear.grid(row=2, column=0, columnspan=4) + + # Advanced entry key sequence. + self.frame_keyseq_advanced = Frame(self, name='keyseq_advanced') + self.frame_keyseq_advanced.grid(row=0, column=0, sticky='nsew', + padx=5, pady=5) + advanced_title = Label(self.frame_keyseq_advanced, justify='left', + text=f"Enter new binding(s) for '{self.action}' :\n" + + "(These bindings will not be checked for validity!)") + advanced_title.pack(anchor='w') + self.advanced_keys = Entry(self.frame_keyseq_advanced, + textvariable=self.key_string) + self.advanced_keys.pack(fill='x') + + # Advanced entry help text. + self.frame_help_advanced = Frame(self) + self.frame_help_advanced.grid(row=1, column=0, sticky='nsew', padx=5) + help_advanced = Label(self.frame_help_advanced, justify='left', + text="Key bindings are specified using Tkinter keysyms as\n"+ + "in these samples: , , ,\n" + ", , .\n" + "Upper case is used when the Shift modifier is present!\n\n" + + "'Emacs style' multi-keystroke bindings are specified as\n" + + "follows: , where the first key\n" + + "is the 'do-nothing' keybinding.\n\n" + + "Multiple separate bindings for one action should be\n"+ + "separated by a space, eg., ." ) + help_advanced.grid(row=0, column=0, sticky='nsew') + + # Switch between basic and advanced. + self.button_level = Button(self, command=self.toggle_level, + text='<< Basic Key Binding Entry') + self.button_level.grid(row=2, column=0, stick='ew', padx=5, pady=5) + self.toggle_level() + + def set_modifiers_for_platform(self): + """Determine list of names of key modifiers for this platform. + + The names are used to build Tk bindings -- it doesn't matter if the + keyboard has these keys; it matters if Tk understands them. The + order is also important: key binding equality depends on it, so + config-keys.def must use the same ordering. + """ + if sys.platform == "darwin": + self.modifiers = ['Shift', 'Control', 'Option', 'Command'] + else: + self.modifiers = ['Control', 'Alt', 'Shift'] + self.modifier_label = {'Control': 'Ctrl'} # Short name. + + def toggle_level(self): + "Toggle between basic and advanced keys." + if self.button_level.cget('text').startswith('Advanced'): + self.clear_key_seq() + self.button_level.config(text='<< Basic Key Binding Entry') + self.frame_keyseq_advanced.lift() + self.frame_help_advanced.lift() + self.advanced_keys.focus_set() + self.advanced = True + else: + self.clear_key_seq() + self.button_level.config(text='Advanced Key Binding Entry >>') + self.frame_keyseq_basic.lift() + self.frame_controls_basic.lift() + self.advanced = False + + def final_key_selected(self, event=None): + "Handler for clicking on key in basic settings list." + self.build_key_string() + + def build_key_string(self): + "Create formatted string of modifiers plus the key." + keylist = modifiers = self.get_modifiers() + final_key = self.list_keys_final.get('anchor') + if final_key: + final_key = translate_key(final_key, modifiers) + keylist.append(final_key) + self.key_string.set(f"<{'-'.join(keylist)}>") + + def get_modifiers(self): + "Return ordered list of modifiers that have been selected." + mod_list = [variable.get() for variable in self.modifier_vars] + return [mod for mod in mod_list if mod] + + def clear_key_seq(self): + "Clear modifiers and keys selection." + self.list_keys_final.select_clear(0, 'end') + self.list_keys_final.yview('moveto', '0.0') + for variable in self.modifier_vars: + variable.set('') + self.key_string.set('') + + def ok(self): + self.result = '' + keys = self.key_string.get().strip() + if not keys: + self.showerror(title=self.keyerror_title, parent=self, + message="No key specified.") + return + if (self.advanced or self.keys_ok(keys)) and self.bind_ok(keys): + self.result = keys + return + + def keys_ok(self, keys): + """Validity check on user's 'basic' keybinding selection. + + Doesn't check the string produced by the advanced dialog because + 'modifiers' isn't set. + """ + final_key = self.list_keys_final.get('anchor') + modifiers = self.get_modifiers() + title = self.keyerror_title + key_sequences = [key for keylist in self.current_key_sequences + for key in keylist] + if not keys.endswith('>'): + self.showerror(title, parent=self, + message='Missing the final Key') + elif (not modifiers + and final_key not in FUNCTION_KEYS + MOVE_KEYS): + self.showerror(title=title, parent=self, + message='No modifier key(s) specified.') + elif (modifiers == ['Shift']) \ + and (final_key not in + FUNCTION_KEYS + MOVE_KEYS + ('Tab', 'Space')): + msg = 'The shift modifier by itself may not be used with'\ + ' this key symbol.' + self.showerror(title=title, parent=self, message=msg) + elif keys in key_sequences: + msg = 'This key combination is already in use.' + self.showerror(title=title, parent=self, message=msg) + else: + return True + return False + + def bind_ok(self, keys): + "Return True if Tcl accepts the new keys else show message." + try: + binding = self.bind(keys, lambda: None) + except TclError as err: + self.showerror( + title=self.keyerror_title, parent=self, + message=(f'The entered key sequence is not accepted.\n\n' + f'Error: {err}')) + return False + else: + self.unbind(keys, binding) + return True + + +class GetKeysWindow(Toplevel): + + def __init__(self, parent, title, action, current_key_sequences, + *, _htest=False, _utest=False): + """ + parent - parent of this dialog + title - string which is the title of the popup dialog + action - string, the name of the virtual event these keys will be + mapped to + current_key_sequences - list, a list of all key sequence lists + currently mapped to virtual events, for overlap checking + _htest - bool, change box location when running htest + _utest - bool, do not wait when running unittest + """ + super().__init__(parent) + self.withdraw() # Hide while setting geometry. + self['borderwidth'] = 5 + self.resizable(height=False, width=False) + # Needed for winfo_reqwidth(). + self.update_idletasks() + # Center dialog over parent (or below htest box). + x = (parent.winfo_rootx() + + (parent.winfo_width()//2 - self.winfo_reqwidth()//2)) + y = (parent.winfo_rooty() + + ((parent.winfo_height()//2 - self.winfo_reqheight()//2) + if not _htest else 150)) + self.geometry(f"+{x}+{y}") + + self.title(title) + self.frame = frame = GetKeysFrame(self, action, current_key_sequences) + self.protocol("WM_DELETE_WINDOW", self.cancel) + frame_buttons = Frame(self) + self.button_ok = Button(frame_buttons, text='OK', + width=8, command=self.ok) + self.button_cancel = Button(frame_buttons, text='Cancel', + width=8, command=self.cancel) + self.button_ok.grid(row=0, column=0, padx=5, pady=5) + self.button_cancel.grid(row=0, column=1, padx=5, pady=5) + frame.pack(side='top', expand=True, fill='both') + frame_buttons.pack(side='bottom', fill='x') + + self.transient(parent) + _setup_dialog(self) + self.grab_set() + if not _utest: + self.deiconify() # Geometry set, unhide. + self.wait_window() + + @property + def result(self): + return self.frame.result + + @result.setter + def result(self, value): + self.frame.result = value + + def ok(self, event=None): + self.frame.ok() + self.grab_release() + self.destroy() + + def cancel(self, event=None): + self.result = '' + self.grab_release() + self.destroy() + + +if __name__ == '__main__': + from unittest import main + main('idlelib.idle_test.test_config_key', verbosity=2, exit=False) + + from idlelib.idle_test.htest import run + run(GetKeysWindow) diff --git a/Lib/idlelib/configdialog.py b/Lib/idlelib/configdialog.py new file mode 100644 index 000000000..e618ef07a --- /dev/null +++ b/Lib/idlelib/configdialog.py @@ -0,0 +1,2408 @@ +"""IDLE Configuration Dialog: support user customization of IDLE by GUI + +Customize font faces, sizes, and colorization attributes. Set indentation +defaults. Customize keybindings. Colorization and keybindings can be +saved as user defined sets. Select startup options including shell/editor +and default window size. Define additional help sources. + +Note that tab width in IDLE is currently fixed at eight due to Tk issues. +Refer to comments in EditorWindow autoindent code for details. + +""" +import re + +from tkinter import (Toplevel, Listbox, Canvas, + StringVar, BooleanVar, IntVar, TRUE, FALSE, + TOP, BOTTOM, RIGHT, LEFT, SOLID, GROOVE, + NONE, BOTH, X, Y, W, E, EW, NS, NSEW, NW, + HORIZONTAL, VERTICAL, ANCHOR, ACTIVE, END, TclError) +from tkinter.ttk import (Frame, LabelFrame, Button, Checkbutton, Entry, Label, + OptionMenu, Notebook, Radiobutton, Scrollbar, Style, + Spinbox, Combobox) +from tkinter import colorchooser +import tkinter.font as tkfont +from tkinter import messagebox + +from idlelib.config import idleConf, ConfigChanges +from idlelib.config_key import GetKeysWindow +from idlelib.dynoption import DynOptionMenu +from idlelib import macosx +from idlelib.query import SectionName, HelpSource +from idlelib.textview import view_text +from idlelib.autocomplete import AutoComplete +from idlelib.codecontext import CodeContext +from idlelib.parenmatch import ParenMatch +from idlelib.format import FormatParagraph +from idlelib.squeezer import Squeezer +from idlelib.textview import ScrollableTextFrame + +changes = ConfigChanges() +# Reload changed options in the following classes. +reloadables = (AutoComplete, CodeContext, ParenMatch, FormatParagraph, + Squeezer) + + +class ConfigDialog(Toplevel): + """Config dialog for IDLE. + """ + + def __init__(self, parent, title='', *, _htest=False, _utest=False): + """Show the tabbed dialog for user configuration. + + Args: + parent - parent of this dialog + title - string which is the title of this popup dialog + _htest - bool, change box location when running htest + _utest - bool, don't wait_window when running unittest + + Note: Focus set on font page fontlist. + + Methods: + create_widgets + cancel: Bound to DELETE_WINDOW protocol. + """ + Toplevel.__init__(self, parent) + self.parent = parent + if _htest: + parent.instance_dict = {} + if not _utest: + self.withdraw() + + self.title(title or 'IDLE Preferences') + x = parent.winfo_rootx() + 20 + y = parent.winfo_rooty() + (30 if not _htest else 150) + self.geometry(f'+{x}+{y}') + # Each theme element key is its display name. + # The first value of the tuple is the sample area tag name. + # The second value is the display name list sort index. + self.create_widgets() + self.resizable(height=FALSE, width=FALSE) + self.transient(parent) + self.protocol("WM_DELETE_WINDOW", self.cancel) + self.fontpage.fontlist.focus_set() + # XXX Decide whether to keep or delete these key bindings. + # Key bindings for this dialog. + # self.bind('', self.Cancel) #dismiss dialog, no save + # self.bind('', self.Apply) #apply changes, save + # self.bind('', self.Help) #context help + # Attach callbacks after loading config to avoid calling them. + tracers.attach() + + if not _utest: + self.grab_set() + self.wm_deiconify() + self.wait_window() + + def create_widgets(self): + """Create and place widgets for tabbed dialog. + + Widgets Bound to self: + frame: encloses all other widgets + note: Notebook + highpage: HighPage + fontpage: FontPage + keyspage: KeysPage + winpage: WinPage + shedpage: ShedPage + extpage: ExtPage + + Methods: + create_action_buttons + load_configs: Load pages except for extensions. + activate_config_changes: Tell editors to reload. + """ + self.frame = frame = Frame(self, padding=5) + self.frame.grid(sticky="nwes") + self.note = note = Notebook(frame) + self.extpage = ExtPage(note) + self.highpage = HighPage(note, self.extpage) + self.fontpage = FontPage(note, self.highpage) + self.keyspage = KeysPage(note, self.extpage) + self.winpage = WinPage(note) + self.shedpage = ShedPage(note) + + note.add(self.fontpage, text=' Fonts ') + note.add(self.highpage, text='Highlights') + note.add(self.keyspage, text=' Keys ') + note.add(self.winpage, text=' Windows ') + note.add(self.shedpage, text=' Shell/Ed ') + note.add(self.extpage, text='Extensions') + note.enable_traversal() + note.pack(side=TOP, expand=TRUE, fill=BOTH) + self.create_action_buttons().pack(side=BOTTOM) + + def create_action_buttons(self): + """Return frame of action buttons for dialog. + + Methods: + ok + apply + cancel + help + + Widget Structure: + outer: Frame + buttons: Frame + (no assignment): Button (ok) + (no assignment): Button (apply) + (no assignment): Button (cancel) + (no assignment): Button (help) + (no assignment): Frame + """ + if macosx.isAquaTk(): + # Changing the default padding on OSX results in unreadable + # text in the buttons. + padding_args = {} + else: + padding_args = {'padding': (6, 3)} + outer = Frame(self.frame, padding=2) + buttons_frame = Frame(outer, padding=2) + self.buttons = {} + for txt, cmd in ( + ('Ok', self.ok), + ('Apply', self.apply), + ('Cancel', self.cancel), + ('Help', self.help)): + self.buttons[txt] = Button(buttons_frame, text=txt, command=cmd, + takefocus=FALSE, **padding_args) + self.buttons[txt].pack(side=LEFT, padx=5) + # Add space above buttons. + Frame(outer, height=2, borderwidth=0).pack(side=TOP) + buttons_frame.pack(side=BOTTOM) + return outer + + def ok(self): + """Apply config changes, then dismiss dialog.""" + self.apply() + self.destroy() + + def apply(self): + """Apply config changes and leave dialog open.""" + self.deactivate_current_config() + changes.save_all() + self.extpage.save_all_changed_extensions() + self.activate_config_changes() + + def cancel(self): + """Dismiss config dialog. + + Methods: + destroy: inherited + """ + changes.clear() + self.destroy() + + def destroy(self): + global font_sample_text + font_sample_text = self.fontpage.font_sample.get('1.0', 'end') + self.grab_release() + super().destroy() + + def help(self): + """Create textview for config dialog help. + + Attributes accessed: + note + Methods: + view_text: Method from textview module. + """ + page = self.note.tab(self.note.select(), option='text').strip() + view_text(self, title='Help for IDLE preferences', + contents=help_common+help_pages.get(page, '')) + + def deactivate_current_config(self): + """Remove current key bindings in current windows.""" + for instance in self.parent.instance_dict: + instance.RemoveKeybindings() + + def activate_config_changes(self): + """Apply configuration changes to current windows. + + Dynamically update the current parent window instances + with some of the configuration changes. + """ + for instance in self.parent.instance_dict: + instance.ResetColorizer() + instance.ResetFont() + instance.set_notabs_indentwidth() + instance.ApplyKeybindings() + instance.reset_help_menu_entries() + instance.update_cursor_blink() + for klass in reloadables: + klass.reload() + + +# class TabPage(Frame): # A template for Page classes. +# def __init__(self, master): +# super().__init__(master) +# self.create_page_tab() +# self.load_tab_cfg() +# def create_page_tab(self): +# # Define tk vars and register var and callback with tracers. +# # Create subframes and widgets. +# # Pack widgets. +# def load_tab_cfg(self): +# # Initialize widgets with data from idleConf. +# def var_changed_var_name(): +# # For each tk var that needs other than default callback. +# def other_methods(): +# # Define tab-specific behavior. + +font_sample_text = ( + '\n' + 'AaBbCcDdEeFfGgHhIiJj\n1234567890#:+=(){}[]\n' + '\u00a2\u00a3\u00a5\u00a7\u00a9\u00ab\u00ae\u00b6\u00bd\u011e' + '\u00c0\u00c1\u00c2\u00c3\u00c4\u00c5\u00c7\u00d0\u00d8\u00df\n' + '\n\n' + '\u0250\u0255\u0258\u025e\u025f\u0264\u026b\u026e\u0270\u0277' + '\u027b\u0281\u0283\u0286\u028e\u029e\u02a2\u02ab\u02ad\u02af\n' + '\u0391\u03b1\u0392\u03b2\u0393\u03b3\u0394\u03b4\u0395\u03b5' + '\u0396\u03b6\u0397\u03b7\u0398\u03b8\u0399\u03b9\u039a\u03ba\n' + '\u0411\u0431\u0414\u0434\u0416\u0436\u041f\u043f\u0424\u0444' + '\u0427\u0447\u042a\u044a\u042d\u044d\u0460\u0464\u046c\u04dc\n' + '\n\n' + '\u05d0\u05d1\u05d2\u05d3\u05d4\u05d5\u05d6\u05d7\u05d8\u05d9' + '\u05da\u05db\u05dc\u05dd\u05de\u05df\u05e0\u05e1\u05e2\u05e3\n' + '\u0627\u0628\u062c\u062f\u0647\u0648\u0632\u062d\u0637\u064a' + '\u0660\u0661\u0662\u0663\u0664\u0665\u0666\u0667\u0668\u0669\n' + '\n\n' + '\u0966\u0967\u0968\u0969\u096a\u096b\u096c\u096d\u096e\u096f' + '\u0905\u0906\u0907\u0908\u0909\u090a\u090f\u0910\u0913\u0914\n' + '\u0be6\u0be7\u0be8\u0be9\u0bea\u0beb\u0bec\u0bed\u0bee\u0bef' + '\u0b85\u0b87\u0b89\u0b8e\n' + '\n\n' + '\u3007\u4e00\u4e8c\u4e09\u56db\u4e94\u516d\u4e03\u516b\u4e5d\n' + '\u6c49\u5b57\u6f22\u5b57\u4eba\u6728\u706b\u571f\u91d1\u6c34\n' + '\uac00\ub0d0\ub354\ub824\ubaa8\ubd64\uc218\uc720\uc988\uce58\n' + '\u3042\u3044\u3046\u3048\u304a\u30a2\u30a4\u30a6\u30a8\u30aa\n' + ) + + +class FontPage(Frame): + + def __init__(self, master, highpage): + super().__init__(master) + self.highlight_sample = highpage.highlight_sample + self.create_page_font() + self.load_font_cfg() + + def create_page_font(self): + """Return frame of widgets for Font tab. + + Fonts: Enable users to provisionally change font face, size, or + boldness and to see the consequence of proposed choices. Each + action set 3 options in changes structuree and changes the + corresponding aspect of the font sample on this page and + highlight sample on highlight page. + + Function load_font_cfg initializes font vars and widgets from + idleConf entries and tk. + + Fontlist: mouse button 1 click or up or down key invoke + on_fontlist_select(), which sets var font_name. + + Sizelist: clicking the menubutton opens the dropdown menu. A + mouse button 1 click or return key sets var font_size. + + Bold_toggle: clicking the box toggles var font_bold. + + Changing any of the font vars invokes var_changed_font, which + adds all 3 font options to changes and calls set_samples. + Set_samples applies a new font constructed from the font vars to + font_sample and to highlight_sample on the highlight page. + + Widgets for FontPage(Frame): (*) widgets bound to self + frame_font: LabelFrame + frame_font_name: Frame + font_name_title: Label + (*)fontlist: ListBox - font_name + scroll_font: Scrollbar + frame_font_param: Frame + font_size_title: Label + (*)sizelist: DynOptionMenu - font_size + (*)bold_toggle: Checkbutton - font_bold + frame_sample: LabelFrame + (*)font_sample: Label + """ + self.font_name = tracers.add(StringVar(self), self.var_changed_font) + self.font_size = tracers.add(StringVar(self), self.var_changed_font) + self.font_bold = tracers.add(BooleanVar(self), self.var_changed_font) + + # Define frames and widgets. + frame_font = LabelFrame(self, borderwidth=2, relief=GROOVE, + text=' Shell/Editor Font ') + frame_sample = LabelFrame(self, borderwidth=2, relief=GROOVE, + text=' Font Sample (Editable) ') + # frame_font. + frame_font_name = Frame(frame_font) + frame_font_param = Frame(frame_font) + font_name_title = Label( + frame_font_name, justify=LEFT, text='Font Face :') + self.fontlist = Listbox(frame_font_name, height=15, + takefocus=True, exportselection=FALSE) + self.fontlist.bind('', self.on_fontlist_select) + self.fontlist.bind('', self.on_fontlist_select) + self.fontlist.bind('', self.on_fontlist_select) + scroll_font = Scrollbar(frame_font_name) + scroll_font.config(command=self.fontlist.yview) + self.fontlist.config(yscrollcommand=scroll_font.set) + font_size_title = Label(frame_font_param, text='Size :') + self.sizelist = DynOptionMenu(frame_font_param, self.font_size, None) + self.bold_toggle = Checkbutton( + frame_font_param, variable=self.font_bold, + onvalue=1, offvalue=0, text='Bold') + # frame_sample. + font_sample_frame = ScrollableTextFrame(frame_sample) + self.font_sample = font_sample_frame.text + self.font_sample.config(wrap=NONE, width=1, height=1) + self.font_sample.insert(END, font_sample_text) + + # Grid and pack widgets: + self.columnconfigure(1, weight=1) + self.rowconfigure(2, weight=1) + frame_font.grid(row=0, column=0, padx=5, pady=5) + frame_sample.grid(row=0, column=1, rowspan=3, padx=5, pady=5, + sticky='nsew') + # frame_font. + frame_font_name.pack(side=TOP, padx=5, pady=5, fill=X) + frame_font_param.pack(side=TOP, padx=5, pady=5, fill=X) + font_name_title.pack(side=TOP, anchor=W) + self.fontlist.pack(side=LEFT, expand=TRUE, fill=X) + scroll_font.pack(side=LEFT, fill=Y) + font_size_title.pack(side=LEFT, anchor=W) + self.sizelist.pack(side=LEFT, anchor=W) + self.bold_toggle.pack(side=LEFT, anchor=W, padx=20) + # frame_sample. + font_sample_frame.pack(expand=TRUE, fill=BOTH) + + def load_font_cfg(self): + """Load current configuration settings for the font options. + + Retrieve current font with idleConf.GetFont and font families + from tk. Setup fontlist and set font_name. Setup sizelist, + which sets font_size. Set font_bold. Call set_samples. + """ + configured_font = idleConf.GetFont(self, 'main', 'EditorWindow') + font_name = configured_font[0].lower() + font_size = configured_font[1] + font_bold = configured_font[2]=='bold' + + # Set sorted no-duplicate editor font selection list and font_name. + fonts = sorted(set(tkfont.families(self))) + for font in fonts: + self.fontlist.insert(END, font) + self.font_name.set(font_name) + lc_fonts = [s.lower() for s in fonts] + try: + current_font_index = lc_fonts.index(font_name) + self.fontlist.see(current_font_index) + self.fontlist.select_set(current_font_index) + self.fontlist.select_anchor(current_font_index) + self.fontlist.activate(current_font_index) + except ValueError: + pass + # Set font size dropdown. + self.sizelist.SetMenu(('7', '8', '9', '10', '11', '12', '13', '14', + '16', '18', '20', '22', '25', '29', '34', '40'), + font_size) + # Set font weight. + self.font_bold.set(font_bold) + self.set_samples() + + def var_changed_font(self, *params): + """Store changes to font attributes. + + When one font attribute changes, save them all, as they are + not independent from each other. In particular, when we are + overriding the default font, we need to write out everything. + """ + value = self.font_name.get() + changes.add_option('main', 'EditorWindow', 'font', value) + value = self.font_size.get() + changes.add_option('main', 'EditorWindow', 'font-size', value) + value = self.font_bold.get() + changes.add_option('main', 'EditorWindow', 'font-bold', value) + self.set_samples() + + def on_fontlist_select(self, event): + """Handle selecting a font from the list. + + Event can result from either mouse click or Up or Down key. + Set font_name and example displays to selection. + """ + font = self.fontlist.get( + ACTIVE if event.type.name == 'KeyRelease' else ANCHOR) + self.font_name.set(font.lower()) + + def set_samples(self, event=None): + """Update both screen samples with the font settings. + + Called on font initialization and change events. + Accesses font_name, font_size, and font_bold Variables. + Updates font_sample and highlight page highlight_sample. + """ + font_name = self.font_name.get() + font_weight = tkfont.BOLD if self.font_bold.get() else tkfont.NORMAL + new_font = (font_name, self.font_size.get(), font_weight) + self.font_sample['font'] = new_font + self.highlight_sample['font'] = new_font + + +class HighPage(Frame): + + def __init__(self, master, extpage): + super().__init__(master) + self.extpage = extpage + self.cd = master.winfo_toplevel() + self.style = Style(master) + self.create_page_highlight() + self.load_theme_cfg() + + def create_page_highlight(self): + """Return frame of widgets for Highlights tab. + + Enable users to provisionally change foreground and background + colors applied to textual tags. Color mappings are stored in + complete listings called themes. Built-in themes in + idlelib/config-highlight.def are fixed as far as the dialog is + concerned. Any theme can be used as the base for a new custom + theme, stored in .idlerc/config-highlight.cfg. + + Function load_theme_cfg() initializes tk variables and theme + lists and calls paint_theme_sample() and set_highlight_target() + for the current theme. Radiobuttons builtin_theme_on and + custom_theme_on toggle var theme_source, which controls if the + current set of colors are from a builtin or custom theme. + DynOptionMenus builtinlist and customlist contain lists of the + builtin and custom themes, respectively, and the current item + from each list is stored in vars builtin_name and custom_name. + + Function paint_theme_sample() applies the colors from the theme + to the tags in text widget highlight_sample and then invokes + set_color_sample(). Function set_highlight_target() sets the state + of the radiobuttons fg_on and bg_on based on the tag and it also + invokes set_color_sample(). + + Function set_color_sample() sets the background color for the frame + holding the color selector. This provides a larger visual of the + color for the current tag and plane (foreground/background). + + Note: set_color_sample() is called from many places and is often + called more than once when a change is made. It is invoked when + foreground or background is selected (radiobuttons), from + paint_theme_sample() (theme is changed or load_cfg is called), and + from set_highlight_target() (target tag is changed or load_cfg called). + + Button delete_custom invokes delete_custom() to delete + a custom theme from idleConf.userCfg['highlight'] and changes. + Button save_custom invokes save_as_new_theme() which calls + get_new_theme_name() and create_new() to save a custom theme + and its colors to idleConf.userCfg['highlight']. + + Radiobuttons fg_on and bg_on toggle var fg_bg_toggle to control + if the current selected color for a tag is for the foreground or + background. + + DynOptionMenu targetlist contains a readable description of the + tags applied to Python source within IDLE. Selecting one of the + tags from this list populates highlight_target, which has a callback + function set_highlight_target(). + + Text widget highlight_sample displays a block of text (which is + mock Python code) in which is embedded the defined tags and reflects + the color attributes of the current theme and changes for those tags. + Mouse button 1 allows for selection of a tag and updates + highlight_target with that tag value. + + Note: The font in highlight_sample is set through the config in + the fonts tab. + + In other words, a tag can be selected either from targetlist or + by clicking on the sample text within highlight_sample. The + plane (foreground/background) is selected via the radiobutton. + Together, these two (tag and plane) control what color is + shown in set_color_sample() for the current theme. Button set_color + invokes get_color() which displays a ColorChooser to change the + color for the selected tag/plane. If a new color is picked, + it will be saved to changes and the highlight_sample and + frame background will be updated. + + Tk Variables: + color: Color of selected target. + builtin_name: Menu variable for built-in theme. + custom_name: Menu variable for custom theme. + fg_bg_toggle: Toggle for foreground/background color. + Note: this has no callback. + theme_source: Selector for built-in or custom theme. + highlight_target: Menu variable for the highlight tag target. + + Instance Data Attributes: + theme_elements: Dictionary of tags for text highlighting. + The key is the display name and the value is a tuple of + (tag name, display sort order). + + Methods [attachment]: + load_theme_cfg: Load current highlight colors. + get_color: Invoke colorchooser [button_set_color]. + set_color_sample_binding: Call set_color_sample [fg_bg_toggle]. + set_highlight_target: set fg_bg_toggle, set_color_sample(). + set_color_sample: Set frame background to target. + on_new_color_set: Set new color and add option. + paint_theme_sample: Recolor sample. + get_new_theme_name: Get from popup. + create_new: Combine theme with changes and save. + save_as_new_theme: Save [button_save_custom]. + set_theme_type: Command for [theme_source]. + delete_custom: Activate default [button_delete_custom]. + save_new: Save to userCfg['theme'] (is function). + + Widgets of highlights page frame: (*) widgets bound to self + frame_custom: LabelFrame + (*)highlight_sample: Text + (*)frame_color_set: Frame + (*)button_set_color: Button + (*)targetlist: DynOptionMenu - highlight_target + frame_fg_bg_toggle: Frame + (*)fg_on: Radiobutton - fg_bg_toggle + (*)bg_on: Radiobutton - fg_bg_toggle + (*)button_save_custom: Button + frame_theme: LabelFrame + theme_type_title: Label + (*)builtin_theme_on: Radiobutton - theme_source + (*)custom_theme_on: Radiobutton - theme_source + (*)builtinlist: DynOptionMenu - builtin_name + (*)customlist: DynOptionMenu - custom_name + (*)button_delete_custom: Button + (*)theme_message: Label + """ + self.theme_elements = { + # Display-name: internal-config-tag-name. + 'Normal Code or Text': 'normal', + 'Code Context': 'context', + 'Python Keywords': 'keyword', + 'Python Definitions': 'definition', + 'Python Builtins': 'builtin', + 'Python Comments': 'comment', + 'Python Strings': 'string', + 'Selected Text': 'hilite', + 'Found Text': 'hit', + 'Cursor': 'cursor', + 'Editor Breakpoint': 'break', + 'Shell Prompt': 'console', + 'Error Text': 'error', + 'Shell User Output': 'stdout', + 'Shell User Exception': 'stderr', + 'Line Number': 'linenumber', + } + self.builtin_name = tracers.add( + StringVar(self), self.var_changed_builtin_name) + self.custom_name = tracers.add( + StringVar(self), self.var_changed_custom_name) + self.fg_bg_toggle = BooleanVar(self) + self.color = tracers.add( + StringVar(self), self.var_changed_color) + self.theme_source = tracers.add( + BooleanVar(self), self.var_changed_theme_source) + self.highlight_target = tracers.add( + StringVar(self), self.var_changed_highlight_target) + + # Create widgets: + # body frame and section frames. + frame_custom = LabelFrame(self, borderwidth=2, relief=GROOVE, + text=' Custom Highlighting ') + frame_theme = LabelFrame(self, borderwidth=2, relief=GROOVE, + text=' Highlighting Theme ') + # frame_custom. + sample_frame = ScrollableTextFrame( + frame_custom, relief=SOLID, borderwidth=1) + text = self.highlight_sample = sample_frame.text + text.configure( + font=('courier', 12, ''), cursor='hand2', width=1, height=1, + takefocus=FALSE, highlightthickness=0, wrap=NONE) + # Prevent perhaps invisible selection of word or slice. + text.bind('', lambda e: 'break') + text.bind('', lambda e: 'break') + string_tags=( + ('# Click selects item.', 'comment'), ('\n', 'normal'), + ('code context section', 'context'), ('\n', 'normal'), + ('| cursor', 'cursor'), ('\n', 'normal'), + ('def', 'keyword'), (' ', 'normal'), + ('func', 'definition'), ('(param):\n ', 'normal'), + ('"Return None."', 'string'), ('\n var0 = ', 'normal'), + ("'string'", 'string'), ('\n var1 = ', 'normal'), + ("'selected'", 'hilite'), ('\n var2 = ', 'normal'), + ("'found'", 'hit'), ('\n var3 = ', 'normal'), + ('list', 'builtin'), ('(', 'normal'), + ('None', 'keyword'), (')\n', 'normal'), + (' breakpoint("line")', 'break'), ('\n\n', 'normal'), + ('>>>', 'console'), (' 3.14**2\n', 'normal'), + ('9.8596', 'stdout'), ('\n', 'normal'), + ('>>>', 'console'), (' pri ', 'normal'), + ('n', 'error'), ('t(\n', 'normal'), + ('SyntaxError', 'stderr'), ('\n', 'normal')) + for string, tag in string_tags: + text.insert(END, string, tag) + n_lines = len(text.get('1.0', END).splitlines()) + for lineno in range(1, n_lines): + text.insert(f'{lineno}.0', + f'{lineno:{len(str(n_lines))}d} ', + 'linenumber') + for element in self.theme_elements: + def tem(event, elem=element): + # event.widget.winfo_top_level().highlight_target.set(elem) + self.highlight_target.set(elem) + text.tag_bind( + self.theme_elements[element], '', tem) + text['state'] = 'disabled' + self.style.configure('frame_color_set.TFrame', borderwidth=1, + relief='solid') + self.frame_color_set = Frame(frame_custom, style='frame_color_set.TFrame') + frame_fg_bg_toggle = Frame(frame_custom) + self.button_set_color = Button( + self.frame_color_set, text='Choose Color for :', + command=self.get_color) + self.targetlist = DynOptionMenu( + self.frame_color_set, self.highlight_target, None, + highlightthickness=0) #, command=self.set_highlight_targetBinding + self.fg_on = Radiobutton( + frame_fg_bg_toggle, variable=self.fg_bg_toggle, value=1, + text='Foreground', command=self.set_color_sample_binding) + self.bg_on = Radiobutton( + frame_fg_bg_toggle, variable=self.fg_bg_toggle, value=0, + text='Background', command=self.set_color_sample_binding) + self.fg_bg_toggle.set(1) + self.button_save_custom = Button( + frame_custom, text='Save as New Custom Theme', + command=self.save_as_new_theme) + # frame_theme. + theme_type_title = Label(frame_theme, text='Select : ') + self.builtin_theme_on = Radiobutton( + frame_theme, variable=self.theme_source, value=1, + command=self.set_theme_type, text='a Built-in Theme') + self.custom_theme_on = Radiobutton( + frame_theme, variable=self.theme_source, value=0, + command=self.set_theme_type, text='a Custom Theme') + self.builtinlist = DynOptionMenu( + frame_theme, self.builtin_name, None, command=None) + self.customlist = DynOptionMenu( + frame_theme, self.custom_name, None, command=None) + self.button_delete_custom = Button( + frame_theme, text='Delete Custom Theme', + command=self.delete_custom) + self.theme_message = Label(frame_theme, borderwidth=2) + # Pack widgets: + # body. + frame_custom.pack(side=LEFT, padx=5, pady=5, expand=TRUE, fill=BOTH) + frame_theme.pack(side=TOP, padx=5, pady=5, fill=X) + # frame_custom. + self.frame_color_set.pack(side=TOP, padx=5, pady=5, fill=X) + frame_fg_bg_toggle.pack(side=TOP, padx=5, pady=0) + sample_frame.pack( + side=TOP, padx=5, pady=5, expand=TRUE, fill=BOTH) + self.button_set_color.pack(side=TOP, expand=TRUE, fill=X, padx=8, pady=4) + self.targetlist.pack(side=TOP, expand=TRUE, fill=X, padx=8, pady=3) + self.fg_on.pack(side=LEFT, anchor=E) + self.bg_on.pack(side=RIGHT, anchor=W) + self.button_save_custom.pack(side=BOTTOM, fill=X, padx=5, pady=5) + # frame_theme. + theme_type_title.pack(side=TOP, anchor=W, padx=5, pady=5) + self.builtin_theme_on.pack(side=TOP, anchor=W, padx=5) + self.custom_theme_on.pack(side=TOP, anchor=W, padx=5, pady=2) + self.builtinlist.pack(side=TOP, fill=X, padx=5, pady=5) + self.customlist.pack(side=TOP, fill=X, anchor=W, padx=5, pady=5) + self.button_delete_custom.pack(side=TOP, fill=X, padx=5, pady=5) + self.theme_message.pack(side=TOP, fill=X, pady=5) + + def load_theme_cfg(self): + """Load current configuration settings for the theme options. + + Based on the theme_source toggle, the theme is set as + either builtin or custom and the initial widget values + reflect the current settings from idleConf. + + Attributes updated: + theme_source: Set from idleConf. + builtinlist: List of default themes from idleConf. + customlist: List of custom themes from idleConf. + custom_theme_on: Disabled if there are no custom themes. + custom_theme: Message with additional information. + targetlist: Create menu from self.theme_elements. + + Methods: + set_theme_type + paint_theme_sample + set_highlight_target + """ + # Set current theme type radiobutton. + self.theme_source.set(idleConf.GetOption( + 'main', 'Theme', 'default', type='bool', default=1)) + # Set current theme. + current_option = idleConf.CurrentTheme() + # Load available theme option menus. + if self.theme_source.get(): # Default theme selected. + item_list = idleConf.GetSectionList('default', 'highlight') + item_list.sort() + self.builtinlist.SetMenu(item_list, current_option) + item_list = idleConf.GetSectionList('user', 'highlight') + item_list.sort() + if not item_list: + self.custom_theme_on.state(('disabled',)) + self.custom_name.set('- no custom themes -') + else: + self.customlist.SetMenu(item_list, item_list[0]) + else: # User theme selected. + item_list = idleConf.GetSectionList('user', 'highlight') + item_list.sort() + self.customlist.SetMenu(item_list, current_option) + item_list = idleConf.GetSectionList('default', 'highlight') + item_list.sort() + self.builtinlist.SetMenu(item_list, item_list[0]) + self.set_theme_type() + # Load theme element option menu. + theme_names = list(self.theme_elements) + self.targetlist.SetMenu(theme_names, theme_names[0]) + self.paint_theme_sample() + self.set_highlight_target() + + def var_changed_builtin_name(self, *params): + """Process new builtin theme selection. + + Add the changed theme's name to the changed_items and recreate + the sample with the values from the selected theme. + """ + old_themes = ('IDLE Classic', 'IDLE New') + value = self.builtin_name.get() + if value not in old_themes: + if idleConf.GetOption('main', 'Theme', 'name') not in old_themes: + changes.add_option('main', 'Theme', 'name', old_themes[0]) + changes.add_option('main', 'Theme', 'name2', value) + self.theme_message['text'] = 'New theme, see Help' + else: + changes.add_option('main', 'Theme', 'name', value) + changes.add_option('main', 'Theme', 'name2', '') + self.theme_message['text'] = '' + self.paint_theme_sample() + + def var_changed_custom_name(self, *params): + """Process new custom theme selection. + + If a new custom theme is selected, add the name to the + changed_items and apply the theme to the sample. + """ + value = self.custom_name.get() + if value != '- no custom themes -': + changes.add_option('main', 'Theme', 'name', value) + self.paint_theme_sample() + + def var_changed_theme_source(self, *params): + """Process toggle between builtin and custom theme. + + Update the default toggle value and apply the newly + selected theme type. + """ + value = self.theme_source.get() + changes.add_option('main', 'Theme', 'default', value) + if value: + self.var_changed_builtin_name() + else: + self.var_changed_custom_name() + + def var_changed_color(self, *params): + "Process change to color choice." + self.on_new_color_set() + + def var_changed_highlight_target(self, *params): + "Process selection of new target tag for highlighting." + self.set_highlight_target() + + def set_theme_type(self): + """Set available screen options based on builtin or custom theme. + + Attributes accessed: + theme_source + + Attributes updated: + builtinlist + customlist + button_delete_custom + custom_theme_on + + Called from: + handler for builtin_theme_on and custom_theme_on + delete_custom + create_new + load_theme_cfg + """ + if self.theme_source.get(): + self.builtinlist['state'] = 'normal' + self.customlist['state'] = 'disabled' + self.button_delete_custom.state(('disabled',)) + else: + self.builtinlist['state'] = 'disabled' + self.custom_theme_on.state(('!disabled',)) + self.customlist['state'] = 'normal' + self.button_delete_custom.state(('!disabled',)) + + def get_color(self): + """Handle button to select a new color for the target tag. + + If a new color is selected while using a builtin theme, a + name must be supplied to create a custom theme. + + Attributes accessed: + highlight_target + frame_color_set + theme_source + + Attributes updated: + color + + Methods: + get_new_theme_name + create_new + """ + target = self.highlight_target.get() + prev_color = self.style.lookup(self.frame_color_set['style'], + 'background') + rgbTuplet, color_string = colorchooser.askcolor( + parent=self, title='Pick new color for : '+target, + initialcolor=prev_color) + if color_string and (color_string != prev_color): + # User didn't cancel and they chose a new color. + if self.theme_source.get(): # Current theme is a built-in. + message = ('Your changes will be saved as a new Custom Theme. ' + 'Enter a name for your new Custom Theme below.') + new_theme = self.get_new_theme_name(message) + if not new_theme: # User cancelled custom theme creation. + return + else: # Create new custom theme based on previously active theme. + self.create_new(new_theme) + self.color.set(color_string) + else: # Current theme is user defined. + self.color.set(color_string) + + def on_new_color_set(self): + "Display sample of new color selection on the dialog." + new_color = self.color.get() + self.style.configure('frame_color_set.TFrame', background=new_color) + plane = 'foreground' if self.fg_bg_toggle.get() else 'background' + sample_element = self.theme_elements[self.highlight_target.get()] + self.highlight_sample.tag_config(sample_element, **{plane: new_color}) + theme = self.custom_name.get() + theme_element = sample_element + '-' + plane + changes.add_option('highlight', theme, theme_element, new_color) + + def get_new_theme_name(self, message): + "Return name of new theme from query popup." + used_names = (idleConf.GetSectionList('user', 'highlight') + + idleConf.GetSectionList('default', 'highlight')) + new_theme = SectionName( + self, 'New Custom Theme', message, used_names).result + return new_theme + + def save_as_new_theme(self): + """Prompt for new theme name and create the theme. + + Methods: + get_new_theme_name + create_new + """ + new_theme_name = self.get_new_theme_name('New Theme Name:') + if new_theme_name: + self.create_new(new_theme_name) + + def create_new(self, new_theme_name): + """Create a new custom theme with the given name. + + Create the new theme based on the previously active theme + with the current changes applied. Once it is saved, then + activate the new theme. + + Attributes accessed: + builtin_name + custom_name + + Attributes updated: + customlist + theme_source + + Method: + save_new + set_theme_type + """ + if self.theme_source.get(): + theme_type = 'default' + theme_name = self.builtin_name.get() + else: + theme_type = 'user' + theme_name = self.custom_name.get() + new_theme = idleConf.GetThemeDict(theme_type, theme_name) + # Apply any of the old theme's unsaved changes to the new theme. + if theme_name in changes['highlight']: + theme_changes = changes['highlight'][theme_name] + for element in theme_changes: + new_theme[element] = theme_changes[element] + # Save the new theme. + self.save_new(new_theme_name, new_theme) + # Change GUI over to the new theme. + custom_theme_list = idleConf.GetSectionList('user', 'highlight') + custom_theme_list.sort() + self.customlist.SetMenu(custom_theme_list, new_theme_name) + self.theme_source.set(0) + self.set_theme_type() + + def set_highlight_target(self): + """Set fg/bg toggle and color based on highlight tag target. + + Instance variables accessed: + highlight_target + + Attributes updated: + fg_on + bg_on + fg_bg_toggle + + Methods: + set_color_sample + + Called from: + var_changed_highlight_target + load_theme_cfg + """ + if self.highlight_target.get() == 'Cursor': # bg not possible + self.fg_on.state(('disabled',)) + self.bg_on.state(('disabled',)) + self.fg_bg_toggle.set(1) + else: # Both fg and bg can be set. + self.fg_on.state(('!disabled',)) + self.bg_on.state(('!disabled',)) + self.fg_bg_toggle.set(1) + self.set_color_sample() + + def set_color_sample_binding(self, *args): + """Change color sample based on foreground/background toggle. + + Methods: + set_color_sample + """ + self.set_color_sample() + + def set_color_sample(self): + """Set the color of the frame background to reflect the selected target. + + Instance variables accessed: + theme_elements + highlight_target + fg_bg_toggle + highlight_sample + + Attributes updated: + frame_color_set + """ + # Set the color sample area. + tag = self.theme_elements[self.highlight_target.get()] + plane = 'foreground' if self.fg_bg_toggle.get() else 'background' + color = self.highlight_sample.tag_cget(tag, plane) + self.style.configure('frame_color_set.TFrame', background=color) + + def paint_theme_sample(self): + """Apply the theme colors to each element tag in the sample text. + + Instance attributes accessed: + theme_elements + theme_source + builtin_name + custom_name + + Attributes updated: + highlight_sample: Set the tag elements to the theme. + + Methods: + set_color_sample + + Called from: + var_changed_builtin_name + var_changed_custom_name + load_theme_cfg + """ + if self.theme_source.get(): # Default theme + theme = self.builtin_name.get() + else: # User theme + theme = self.custom_name.get() + for element_title in self.theme_elements: + element = self.theme_elements[element_title] + colors = idleConf.GetHighlight(theme, element) + if element == 'cursor': # Cursor sample needs special painting. + colors['background'] = idleConf.GetHighlight( + theme, 'normal')['background'] + # Handle any unsaved changes to this theme. + if theme in changes['highlight']: + theme_dict = changes['highlight'][theme] + if element + '-foreground' in theme_dict: + colors['foreground'] = theme_dict[element + '-foreground'] + if element + '-background' in theme_dict: + colors['background'] = theme_dict[element + '-background'] + self.highlight_sample.tag_config(element, **colors) + self.set_color_sample() + + def save_new(self, theme_name, theme): + """Save a newly created theme to idleConf. + + theme_name - string, the name of the new theme + theme - dictionary containing the new theme + """ + idleConf.userCfg['highlight'].AddSection(theme_name) + for element in theme: + value = theme[element] + idleConf.userCfg['highlight'].SetOption(theme_name, element, value) + + def askyesno(self, *args, **kwargs): + # Make testing easier. Could change implementation. + return messagebox.askyesno(*args, **kwargs) + + def delete_custom(self): + """Handle event to delete custom theme. + + The current theme is deactivated and the default theme is + activated. The custom theme is permanently removed from + the config file. + + Attributes accessed: + custom_name + + Attributes updated: + custom_theme_on + customlist + theme_source + builtin_name + + Methods: + deactivate_current_config + save_all_changed_extensions + activate_config_changes + set_theme_type + """ + theme_name = self.custom_name.get() + delmsg = 'Are you sure you wish to delete the theme %r ?' + if not self.askyesno( + 'Delete Theme', delmsg % theme_name, parent=self): + return + self.cd.deactivate_current_config() + # Remove theme from changes, config, and file. + changes.delete_section('highlight', theme_name) + # Reload user theme list. + item_list = idleConf.GetSectionList('user', 'highlight') + item_list.sort() + if not item_list: + self.custom_theme_on.state(('disabled',)) + self.customlist.SetMenu(item_list, '- no custom themes -') + else: + self.customlist.SetMenu(item_list, item_list[0]) + # Revert to default theme. + self.theme_source.set(idleConf.defaultCfg['main'].Get('Theme', 'default')) + self.builtin_name.set(idleConf.defaultCfg['main'].Get('Theme', 'name')) + # User can't back out of these changes, they must be applied now. + changes.save_all() + self.extpage.save_all_changed_extensions() + self.cd.activate_config_changes() + self.set_theme_type() + + +class KeysPage(Frame): + + def __init__(self, master, extpage): + super().__init__(master) + self.extpage = extpage + self.cd = master.winfo_toplevel() + self.create_page_keys() + self.load_key_cfg() + + def create_page_keys(self): + """Return frame of widgets for Keys tab. + + Enable users to provisionally change both individual and sets of + keybindings (shortcut keys). Except for features implemented as + extensions, keybindings are stored in complete sets called + keysets. Built-in keysets in idlelib/config-keys.def are fixed + as far as the dialog is concerned. Any keyset can be used as the + base for a new custom keyset, stored in .idlerc/config-keys.cfg. + + Function load_key_cfg() initializes tk variables and keyset + lists and calls load_keys_list for the current keyset. + Radiobuttons builtin_keyset_on and custom_keyset_on toggle var + keyset_source, which controls if the current set of keybindings + are from a builtin or custom keyset. DynOptionMenus builtinlist + and customlist contain lists of the builtin and custom keysets, + respectively, and the current item from each list is stored in + vars builtin_name and custom_name. + + Button delete_custom_keys invokes delete_custom_keys() to delete + a custom keyset from idleConf.userCfg['keys'] and changes. Button + save_custom_keys invokes save_as_new_key_set() which calls + get_new_keys_name() and create_new_key_set() to save a custom keyset + and its keybindings to idleConf.userCfg['keys']. + + Listbox bindingslist contains all of the keybindings for the + selected keyset. The keybindings are loaded in load_keys_list() + and are pairs of (event, [keys]) where keys can be a list + of one or more key combinations to bind to the same event. + Mouse button 1 click invokes on_bindingslist_select(), which + allows button_new_keys to be clicked. + + So, an item is selected in listbindings, which activates + button_new_keys, and clicking button_new_keys calls function + get_new_keys(). Function get_new_keys() gets the key mappings from the + current keyset for the binding event item that was selected. The + function then displays another dialog, GetKeysDialog, with the + selected binding event and current keys and allows new key sequences + to be entered for that binding event. If the keys aren't + changed, nothing happens. If the keys are changed and the keyset + is a builtin, function get_new_keys_name() will be called + for input of a custom keyset name. If no name is given, then the + change to the keybinding will abort and no updates will be made. If + a custom name is entered in the prompt or if the current keyset was + already custom (and thus didn't require a prompt), then + idleConf.userCfg['keys'] is updated in function create_new_key_set() + with the change to the event binding. The item listing in bindingslist + is updated with the new keys. Var keybinding is also set which invokes + the callback function, var_changed_keybinding, to add the change to + the 'keys' or 'extensions' changes tracker based on the binding type. + + Tk Variables: + keybinding: Action/key bindings. + + Methods: + load_keys_list: Reload active set. + create_new_key_set: Combine active keyset and changes. + set_keys_type: Command for keyset_source. + save_new_key_set: Save to idleConf.userCfg['keys'] (is function). + deactivate_current_config: Remove keys bindings in editors. + + Widgets for KeysPage(frame): (*) widgets bound to self + frame_key_sets: LabelFrame + frames[0]: Frame + (*)builtin_keyset_on: Radiobutton - var keyset_source + (*)custom_keyset_on: Radiobutton - var keyset_source + (*)builtinlist: DynOptionMenu - var builtin_name, + func keybinding_selected + (*)customlist: DynOptionMenu - var custom_name, + func keybinding_selected + (*)keys_message: Label + frames[1]: Frame + (*)button_delete_custom_keys: Button - delete_custom_keys + (*)button_save_custom_keys: Button - save_as_new_key_set + frame_custom: LabelFrame + frame_target: Frame + target_title: Label + scroll_target_y: Scrollbar + scroll_target_x: Scrollbar + (*)bindingslist: ListBox - on_bindingslist_select + (*)button_new_keys: Button - get_new_keys & ..._name + """ + self.builtin_name = tracers.add( + StringVar(self), self.var_changed_builtin_name) + self.custom_name = tracers.add( + StringVar(self), self.var_changed_custom_name) + self.keyset_source = tracers.add( + BooleanVar(self), self.var_changed_keyset_source) + self.keybinding = tracers.add( + StringVar(self), self.var_changed_keybinding) + + # Create widgets: + # body and section frames. + frame_custom = LabelFrame( + self, borderwidth=2, relief=GROOVE, + text=' Custom Key Bindings ') + frame_key_sets = LabelFrame( + self, borderwidth=2, relief=GROOVE, text=' Key Set ') + # frame_custom. + frame_target = Frame(frame_custom) + target_title = Label(frame_target, text='Action - Key(s)') + scroll_target_y = Scrollbar(frame_target) + scroll_target_x = Scrollbar(frame_target, orient=HORIZONTAL) + self.bindingslist = Listbox( + frame_target, takefocus=FALSE, exportselection=FALSE) + self.bindingslist.bind('', + self.on_bindingslist_select) + scroll_target_y['command'] = self.bindingslist.yview + scroll_target_x['command'] = self.bindingslist.xview + self.bindingslist['yscrollcommand'] = scroll_target_y.set + self.bindingslist['xscrollcommand'] = scroll_target_x.set + self.button_new_keys = Button( + frame_custom, text='Get New Keys for Selection', + command=self.get_new_keys, state='disabled') + # frame_key_sets. + frames = [Frame(frame_key_sets, padding=2, borderwidth=0) + for i in range(2)] + self.builtin_keyset_on = Radiobutton( + frames[0], variable=self.keyset_source, value=1, + command=self.set_keys_type, text='Use a Built-in Key Set') + self.custom_keyset_on = Radiobutton( + frames[0], variable=self.keyset_source, value=0, + command=self.set_keys_type, text='Use a Custom Key Set') + self.builtinlist = DynOptionMenu( + frames[0], self.builtin_name, None, command=None) + self.customlist = DynOptionMenu( + frames[0], self.custom_name, None, command=None) + self.button_delete_custom_keys = Button( + frames[1], text='Delete Custom Key Set', + command=self.delete_custom_keys) + self.button_save_custom_keys = Button( + frames[1], text='Save as New Custom Key Set', + command=self.save_as_new_key_set) + self.keys_message = Label(frames[0], borderwidth=2) + + # Pack widgets: + # body. + frame_custom.pack(side=BOTTOM, padx=5, pady=5, expand=TRUE, fill=BOTH) + frame_key_sets.pack(side=BOTTOM, padx=5, pady=5, fill=BOTH) + # frame_custom. + self.button_new_keys.pack(side=BOTTOM, fill=X, padx=5, pady=5) + frame_target.pack(side=LEFT, padx=5, pady=5, expand=TRUE, fill=BOTH) + # frame_target. + frame_target.columnconfigure(0, weight=1) + frame_target.rowconfigure(1, weight=1) + target_title.grid(row=0, column=0, columnspan=2, sticky=W) + self.bindingslist.grid(row=1, column=0, sticky=NSEW) + scroll_target_y.grid(row=1, column=1, sticky=NS) + scroll_target_x.grid(row=2, column=0, sticky=EW) + # frame_key_sets. + self.builtin_keyset_on.grid(row=0, column=0, sticky=W+NS) + self.custom_keyset_on.grid(row=1, column=0, sticky=W+NS) + self.builtinlist.grid(row=0, column=1, sticky=NSEW) + self.customlist.grid(row=1, column=1, sticky=NSEW) + self.keys_message.grid(row=0, column=2, sticky=NSEW, padx=5, pady=5) + self.button_delete_custom_keys.pack(side=LEFT, fill=X, expand=True, padx=2) + self.button_save_custom_keys.pack(side=LEFT, fill=X, expand=True, padx=2) + frames[0].pack(side=TOP, fill=BOTH, expand=True) + frames[1].pack(side=TOP, fill=X, expand=True, pady=2) + + def load_key_cfg(self): + "Load current configuration settings for the keybinding options." + # Set current keys type radiobutton. + self.keyset_source.set(idleConf.GetOption( + 'main', 'Keys', 'default', type='bool', default=1)) + # Set current keys. + current_option = idleConf.CurrentKeys() + # Load available keyset option menus. + if self.keyset_source.get(): # Default theme selected. + item_list = idleConf.GetSectionList('default', 'keys') + item_list.sort() + self.builtinlist.SetMenu(item_list, current_option) + item_list = idleConf.GetSectionList('user', 'keys') + item_list.sort() + if not item_list: + self.custom_keyset_on.state(('disabled',)) + self.custom_name.set('- no custom keys -') + else: + self.customlist.SetMenu(item_list, item_list[0]) + else: # User key set selected. + item_list = idleConf.GetSectionList('user', 'keys') + item_list.sort() + self.customlist.SetMenu(item_list, current_option) + item_list = idleConf.GetSectionList('default', 'keys') + item_list.sort() + self.builtinlist.SetMenu(item_list, idleConf.default_keys()) + self.set_keys_type() + # Load keyset element list. + keyset_name = idleConf.CurrentKeys() + self.load_keys_list(keyset_name) + + def var_changed_builtin_name(self, *params): + "Process selection of builtin key set." + old_keys = ( + 'IDLE Classic Windows', + 'IDLE Classic Unix', + 'IDLE Classic Mac', + 'IDLE Classic OSX', + ) + value = self.builtin_name.get() + if value not in old_keys: + if idleConf.GetOption('main', 'Keys', 'name') not in old_keys: + changes.add_option('main', 'Keys', 'name', old_keys[0]) + changes.add_option('main', 'Keys', 'name2', value) + self.keys_message['text'] = 'New key set, see Help' + else: + changes.add_option('main', 'Keys', 'name', value) + changes.add_option('main', 'Keys', 'name2', '') + self.keys_message['text'] = '' + self.load_keys_list(value) + + def var_changed_custom_name(self, *params): + "Process selection of custom key set." + value = self.custom_name.get() + if value != '- no custom keys -': + changes.add_option('main', 'Keys', 'name', value) + self.load_keys_list(value) + + def var_changed_keyset_source(self, *params): + "Process toggle between builtin key set and custom key set." + value = self.keyset_source.get() + changes.add_option('main', 'Keys', 'default', value) + if value: + self.var_changed_builtin_name() + else: + self.var_changed_custom_name() + + def var_changed_keybinding(self, *params): + "Store change to a keybinding." + value = self.keybinding.get() + key_set = self.custom_name.get() + event = self.bindingslist.get(ANCHOR).split()[0] + if idleConf.IsCoreBinding(event): + changes.add_option('keys', key_set, event, value) + else: # Event is an extension binding. + ext_name = idleConf.GetExtnNameForEvent(event) + ext_keybind_section = ext_name + '_cfgBindings' + changes.add_option('extensions', ext_keybind_section, event, value) + + def set_keys_type(self): + "Set available screen options based on builtin or custom key set." + if self.keyset_source.get(): + self.builtinlist['state'] = 'normal' + self.customlist['state'] = 'disabled' + self.button_delete_custom_keys.state(('disabled',)) + else: + self.builtinlist['state'] = 'disabled' + self.custom_keyset_on.state(('!disabled',)) + self.customlist['state'] = 'normal' + self.button_delete_custom_keys.state(('!disabled',)) + + def get_new_keys(self): + """Handle event to change key binding for selected line. + + A selection of a key/binding in the list of current + bindings pops up a dialog to enter a new binding. If + the current key set is builtin and a binding has + changed, then a name for a custom key set needs to be + entered for the change to be applied. + """ + list_index = self.bindingslist.index(ANCHOR) + binding = self.bindingslist.get(list_index) + bind_name = binding.split()[0] + if self.keyset_source.get(): + current_key_set_name = self.builtin_name.get() + else: + current_key_set_name = self.custom_name.get() + current_bindings = idleConf.GetCurrentKeySet() + if current_key_set_name in changes['keys']: # unsaved changes + key_set_changes = changes['keys'][current_key_set_name] + for event in key_set_changes: + current_bindings[event] = key_set_changes[event].split() + current_key_sequences = list(current_bindings.values()) + new_keys = GetKeysWindow(self, 'Get New Keys', bind_name, + current_key_sequences).result + if new_keys: + if self.keyset_source.get(): # Current key set is a built-in. + message = ('Your changes will be saved as a new Custom Key Set.' + ' Enter a name for your new Custom Key Set below.') + new_keyset = self.get_new_keys_name(message) + if not new_keyset: # User cancelled custom key set creation. + self.bindingslist.select_set(list_index) + self.bindingslist.select_anchor(list_index) + return + else: # Create new custom key set based on previously active key set. + self.create_new_key_set(new_keyset) + self.bindingslist.delete(list_index) + self.bindingslist.insert(list_index, bind_name+' - '+new_keys) + self.bindingslist.select_set(list_index) + self.bindingslist.select_anchor(list_index) + self.keybinding.set(new_keys) + else: + self.bindingslist.select_set(list_index) + self.bindingslist.select_anchor(list_index) + + def get_new_keys_name(self, message): + "Return new key set name from query popup." + used_names = (idleConf.GetSectionList('user', 'keys') + + idleConf.GetSectionList('default', 'keys')) + new_keyset = SectionName( + self, 'New Custom Key Set', message, used_names).result + return new_keyset + + def save_as_new_key_set(self): + "Prompt for name of new key set and save changes using that name." + new_keys_name = self.get_new_keys_name('New Key Set Name:') + if new_keys_name: + self.create_new_key_set(new_keys_name) + + def on_bindingslist_select(self, event): + "Activate button to assign new keys to selected action." + self.button_new_keys.state(('!disabled',)) + + def create_new_key_set(self, new_key_set_name): + """Create a new custom key set with the given name. + + Copy the bindings/keys from the previously active keyset + to the new keyset and activate the new custom keyset. + """ + if self.keyset_source.get(): + prev_key_set_name = self.builtin_name.get() + else: + prev_key_set_name = self.custom_name.get() + prev_keys = idleConf.GetCoreKeys(prev_key_set_name) + new_keys = {} + for event in prev_keys: # Add key set to changed items. + event_name = event[2:-2] # Trim off the angle brackets. + binding = ' '.join(prev_keys[event]) + new_keys[event_name] = binding + # Handle any unsaved changes to prev key set. + if prev_key_set_name in changes['keys']: + key_set_changes = changes['keys'][prev_key_set_name] + for event in key_set_changes: + new_keys[event] = key_set_changes[event] + # Save the new key set. + self.save_new_key_set(new_key_set_name, new_keys) + # Change GUI over to the new key set. + custom_key_list = idleConf.GetSectionList('user', 'keys') + custom_key_list.sort() + self.customlist.SetMenu(custom_key_list, new_key_set_name) + self.keyset_source.set(0) + self.set_keys_type() + + def load_keys_list(self, keyset_name): + """Reload the list of action/key binding pairs for the active key set. + + An action/key binding can be selected to change the key binding. + """ + reselect = False + if self.bindingslist.curselection(): + reselect = True + list_index = self.bindingslist.index(ANCHOR) + keyset = idleConf.GetKeySet(keyset_name) + # 'set' is dict mapping virtual event to list of key events. + bind_names = list(keyset) + bind_names.sort() + self.bindingslist.delete(0, END) + for bind_name in bind_names: + key = ' '.join(keyset[bind_name]) + bind_name = bind_name[2:-2] # Trim double angle brackets. + if keyset_name in changes['keys']: + # Handle any unsaved changes to this key set. + if bind_name in changes['keys'][keyset_name]: + key = changes['keys'][keyset_name][bind_name] + self.bindingslist.insert(END, bind_name+' - '+key) + if reselect: + self.bindingslist.see(list_index) + self.bindingslist.select_set(list_index) + self.bindingslist.select_anchor(list_index) + + @staticmethod + def save_new_key_set(keyset_name, keyset): + """Save a newly created core key set. + + Add keyset to idleConf.userCfg['keys'], not to disk. + If the keyset doesn't exist, it is created. The + binding/keys are taken from the keyset argument. + + keyset_name - string, the name of the new key set + keyset - dictionary containing the new keybindings + """ + idleConf.userCfg['keys'].AddSection(keyset_name) + for event in keyset: + value = keyset[event] + idleConf.userCfg['keys'].SetOption(keyset_name, event, value) + + def askyesno(self, *args, **kwargs): + # Make testing easier. Could change implementation. + return messagebox.askyesno(*args, **kwargs) + + def delete_custom_keys(self): + """Handle event to delete a custom key set. + + Applying the delete deactivates the current configuration and + reverts to the default. The custom key set is permanently + deleted from the config file. + """ + keyset_name = self.custom_name.get() + delmsg = 'Are you sure you wish to delete the key set %r ?' + if not self.askyesno( + 'Delete Key Set', delmsg % keyset_name, parent=self): + return + self.cd.deactivate_current_config() + # Remove key set from changes, config, and file. + changes.delete_section('keys', keyset_name) + # Reload user key set list. + item_list = idleConf.GetSectionList('user', 'keys') + item_list.sort() + if not item_list: + self.custom_keyset_on.state(('disabled',)) + self.customlist.SetMenu(item_list, '- no custom keys -') + else: + self.customlist.SetMenu(item_list, item_list[0]) + # Revert to default key set. + self.keyset_source.set(idleConf.defaultCfg['main'] + .Get('Keys', 'default')) + self.builtin_name.set(idleConf.defaultCfg['main'].Get('Keys', 'name') + or idleConf.default_keys()) + # User can't back out of these changes, they must be applied now. + changes.save_all() + self.extpage.save_all_changed_extensions() + self.cd.activate_config_changes() + self.set_keys_type() + + +class WinPage(Frame): + + def __init__(self, master): + super().__init__(master) + + self.init_validators() + self.create_page_windows() + self.load_windows_cfg() + + def init_validators(self): + digits_or_empty_re = re.compile(r'[0-9]*') + def is_digits_or_empty(s): + "Return 's is blank or contains only digits'" + return digits_or_empty_re.fullmatch(s) is not None + self.digits_only = (self.register(is_digits_or_empty), '%P',) + + def create_page_windows(self): + """Return frame of widgets for Windows tab. + + Enable users to provisionally change general window options. + Function load_windows_cfg initializes tk variable idleConf. + Radiobuttons startup_shell_on and startup_editor_on set var + startup_edit. Entry boxes win_width_int and win_height_int set var + win_width and win_height. Setting var_name invokes the default + callback that adds option to changes. + + Widgets for WinPage(Frame): > vars, bound to self + frame_window: LabelFrame + frame_run: Frame + startup_title: Label + startup_editor_on: Radiobutton > startup_edit + startup_shell_on: Radiobutton > startup_edit + frame_win_size: Frame + win_size_title: Label + win_width_title: Label + win_width_int: Entry > win_width + win_height_title: Label + win_height_int: Entry > win_height + frame_cursor: Frame + indent_title: Label + indent_chooser: Spinbox > indent_spaces + blink_on: Checkbutton > cursor_blink + frame_autocomplete: Frame + auto_wait_title: Label + auto_wait_int: Entry > autocomplete_wait + frame_paren1: Frame + paren_style_title: Label + paren_style_type: OptionMenu > paren_style + frame_paren2: Frame + paren_time_title: Label + paren_flash_time: Entry > flash_delay + bell_on: Checkbutton > paren_bell + frame_format: Frame + format_width_title: Label + format_width_int: Entry > format_width + """ + # Integer values need StringVar because int('') raises. + self.startup_edit = tracers.add( + IntVar(self), ('main', 'General', 'editor-on-startup')) + self.win_width = tracers.add( + StringVar(self), ('main', 'EditorWindow', 'width')) + self.win_height = tracers.add( + StringVar(self), ('main', 'EditorWindow', 'height')) + self.indent_spaces = tracers.add( + StringVar(self), ('main', 'Indent', 'num-spaces')) + self.cursor_blink = tracers.add( + BooleanVar(self), ('main', 'EditorWindow', 'cursor-blink')) + self.autocomplete_wait = tracers.add( + StringVar(self), ('extensions', 'AutoComplete', 'popupwait')) + self.paren_style = tracers.add( + StringVar(self), ('extensions', 'ParenMatch', 'style')) + self.flash_delay = tracers.add( + StringVar(self), ('extensions', 'ParenMatch', 'flash-delay')) + self.paren_bell = tracers.add( + BooleanVar(self), ('extensions', 'ParenMatch', 'bell')) + self.format_width = tracers.add( + StringVar(self), ('extensions', 'FormatParagraph', 'max-width')) + + # Create widgets: + frame_window = LabelFrame(self, borderwidth=2, relief=GROOVE, + text=' Window Preferences') + + frame_run = Frame(frame_window, borderwidth=0) + startup_title = Label(frame_run, text='At Startup') + self.startup_editor_on = Radiobutton( + frame_run, variable=self.startup_edit, value=1, + text="Open Edit Window") + self.startup_shell_on = Radiobutton( + frame_run, variable=self.startup_edit, value=0, + text='Open Shell Window') + + frame_win_size = Frame(frame_window, borderwidth=0) + win_size_title = Label( + frame_win_size, text='Initial Window Size (in characters)') + win_width_title = Label(frame_win_size, text='Width') + self.win_width_int = Entry( + frame_win_size, textvariable=self.win_width, width=3, + validatecommand=self.digits_only, validate='key', + ) + win_height_title = Label(frame_win_size, text='Height') + self.win_height_int = Entry( + frame_win_size, textvariable=self.win_height, width=3, + validatecommand=self.digits_only, validate='key', + ) + + frame_cursor = Frame(frame_window, borderwidth=0) + indent_title = Label(frame_cursor, + text='Indent spaces (4 is standard)') + try: + self.indent_chooser = Spinbox( + frame_cursor, textvariable=self.indent_spaces, + from_=1, to=10, width=2, + validatecommand=self.digits_only, validate='key') + except TclError: + self.indent_chooser = Combobox( + frame_cursor, textvariable=self.indent_spaces, + state="readonly", values=list(range(1,11)), width=3) + cursor_blink_title = Label(frame_cursor, text='Cursor Blink') + self.cursor_blink_bool = Checkbutton(frame_cursor, text="Cursor blink", + variable=self.cursor_blink) + + frame_autocomplete = Frame(frame_window, borderwidth=0,) + auto_wait_title = Label(frame_autocomplete, + text='Completions Popup Wait (milliseconds)') + self.auto_wait_int = Entry( + frame_autocomplete, textvariable=self.autocomplete_wait, + width=6, validatecommand=self.digits_only, validate='key') + + frame_paren1 = Frame(frame_window, borderwidth=0) + paren_style_title = Label(frame_paren1, text='Paren Match Style') + self.paren_style_type = OptionMenu( + frame_paren1, self.paren_style, 'expression', + "opener","parens","expression") + frame_paren2 = Frame(frame_window, borderwidth=0) + paren_time_title = Label( + frame_paren2, text='Time Match Displayed (milliseconds)\n' + '(0 is until next input)') + self.paren_flash_time = Entry( + frame_paren2, textvariable=self.flash_delay, width=6, + validatecommand=self.digits_only, validate='key') + self.bell_on = Checkbutton( + frame_paren2, text="Bell on Mismatch", variable=self.paren_bell) + frame_format = Frame(frame_window, borderwidth=0) + format_width_title = Label(frame_format, + text='Format Paragraph Max Width') + self.format_width_int = Entry( + frame_format, textvariable=self.format_width, width=4, + validatecommand=self.digits_only, validate='key', + ) + + # Pack widgets: + frame_window.pack(side=TOP, padx=5, pady=5, expand=TRUE, fill=BOTH) + # frame_run. + frame_run.pack(side=TOP, padx=5, pady=0, fill=X) + startup_title.pack(side=LEFT, anchor=W, padx=5, pady=5) + self.startup_shell_on.pack(side=RIGHT, anchor=W, padx=5, pady=5) + self.startup_editor_on.pack(side=RIGHT, anchor=W, padx=5, pady=5) + # frame_win_size. + frame_win_size.pack(side=TOP, padx=5, pady=0, fill=X) + win_size_title.pack(side=LEFT, anchor=W, padx=5, pady=5) + self.win_height_int.pack(side=RIGHT, anchor=E, padx=10, pady=5) + win_height_title.pack(side=RIGHT, anchor=E, pady=5) + self.win_width_int.pack(side=RIGHT, anchor=E, padx=10, pady=5) + win_width_title.pack(side=RIGHT, anchor=E, pady=5) + # frame_cursor. + frame_cursor.pack(side=TOP, padx=5, pady=0, fill=X) + indent_title.pack(side=LEFT, anchor=W, padx=5) + self.indent_chooser.pack(side=LEFT, anchor=W, padx=10) + self.cursor_blink_bool.pack(side=RIGHT, anchor=E, padx=15, pady=5) + # frame_autocomplete. + frame_autocomplete.pack(side=TOP, padx=5, pady=0, fill=X) + auto_wait_title.pack(side=LEFT, anchor=W, padx=5, pady=5) + self.auto_wait_int.pack(side=TOP, padx=10, pady=5) + # frame_paren. + frame_paren1.pack(side=TOP, padx=5, pady=0, fill=X) + paren_style_title.pack(side=LEFT, anchor=W, padx=5, pady=5) + self.paren_style_type.pack(side=TOP, padx=10, pady=5) + frame_paren2.pack(side=TOP, padx=5, pady=0, fill=X) + paren_time_title.pack(side=LEFT, anchor=W, padx=5) + self.bell_on.pack(side=RIGHT, anchor=E, padx=15, pady=5) + self.paren_flash_time.pack(side=TOP, anchor=W, padx=15, pady=5) + # frame_format. + frame_format.pack(side=TOP, padx=5, pady=0, fill=X) + format_width_title.pack(side=LEFT, anchor=W, padx=5, pady=5) + self.format_width_int.pack(side=TOP, padx=10, pady=5) + + def load_windows_cfg(self): + # Set variables for all windows. + self.startup_edit.set(idleConf.GetOption( + 'main', 'General', 'editor-on-startup', type='bool')) + self.win_width.set(idleConf.GetOption( + 'main', 'EditorWindow', 'width', type='int')) + self.win_height.set(idleConf.GetOption( + 'main', 'EditorWindow', 'height', type='int')) + self.indent_spaces.set(idleConf.GetOption( + 'main', 'Indent', 'num-spaces', type='int')) + self.cursor_blink.set(idleConf.GetOption( + 'main', 'EditorWindow', 'cursor-blink', type='bool')) + self.autocomplete_wait.set(idleConf.GetOption( + 'extensions', 'AutoComplete', 'popupwait', type='int')) + self.paren_style.set(idleConf.GetOption( + 'extensions', 'ParenMatch', 'style')) + self.flash_delay.set(idleConf.GetOption( + 'extensions', 'ParenMatch', 'flash-delay', type='int')) + self.paren_bell.set(idleConf.GetOption( + 'extensions', 'ParenMatch', 'bell')) + self.format_width.set(idleConf.GetOption( + 'extensions', 'FormatParagraph', 'max-width', type='int')) + + +class ShedPage(Frame): + + def __init__(self, master): + super().__init__(master) + + self.init_validators() + self.create_page_shed() + self.load_shelled_cfg() + + def init_validators(self): + digits_or_empty_re = re.compile(r'[0-9]*') + def is_digits_or_empty(s): + "Return 's is blank or contains only digits'" + return digits_or_empty_re.fullmatch(s) is not None + self.digits_only = (self.register(is_digits_or_empty), '%P',) + + def create_page_shed(self): + """Return frame of widgets for Shell/Ed tab. + + Enable users to provisionally change shell and editor options. + Function load_shed_cfg initializes tk variables using idleConf. + Entry box auto_squeeze_min_lines_int sets + auto_squeeze_min_lines_int. Setting var_name invokes the + default callback that adds option to changes. + + Widgets for ShedPage(Frame): (*) widgets bound to self + frame_shell: LabelFrame + frame_auto_squeeze_min_lines: Frame + auto_squeeze_min_lines_title: Label + (*)auto_squeeze_min_lines_int: Entry - + auto_squeeze_min_lines + frame_editor: LabelFrame + frame_save: Frame + run_save_title: Label + (*)save_ask_on: Radiobutton - autosave + (*)save_auto_on: Radiobutton - autosave + frame_format: Frame + format_width_title: Label + (*)format_width_int: Entry - format_width + frame_line_numbers_default: Frame + line_numbers_default_title: Label + (*)line_numbers_default_bool: Checkbutton - line_numbers_default + frame_context: Frame + context_title: Label + (*)context_int: Entry - context_lines + """ + # Integer values need StringVar because int('') raises. + self.auto_squeeze_min_lines = tracers.add( + StringVar(self), ('main', 'PyShell', 'auto-squeeze-min-lines')) + + self.autosave = tracers.add( + IntVar(self), ('main', 'General', 'autosave')) + self.line_numbers_default = tracers.add( + BooleanVar(self), + ('main', 'EditorWindow', 'line-numbers-default')) + self.context_lines = tracers.add( + StringVar(self), ('extensions', 'CodeContext', 'maxlines')) + + # Create widgets: + frame_shell = LabelFrame(self, borderwidth=2, relief=GROOVE, + text=' Shell Preferences') + frame_editor = LabelFrame(self, borderwidth=2, relief=GROOVE, + text=' Editor Preferences') + # Frame_shell. + frame_auto_squeeze_min_lines = Frame(frame_shell, borderwidth=0) + auto_squeeze_min_lines_title = Label(frame_auto_squeeze_min_lines, + text='Auto-Squeeze Min. Lines:') + self.auto_squeeze_min_lines_int = Entry( + frame_auto_squeeze_min_lines, width=4, + textvariable=self.auto_squeeze_min_lines, + validatecommand=self.digits_only, validate='key', + ) + # Frame_editor. + frame_save = Frame(frame_editor, borderwidth=0) + run_save_title = Label(frame_save, text='At Start of Run (F5) ') + + self.save_ask_on = Radiobutton( + frame_save, variable=self.autosave, value=0, + text="Prompt to Save") + self.save_auto_on = Radiobutton( + frame_save, variable=self.autosave, value=1, + text='No Prompt') + + frame_line_numbers_default = Frame(frame_editor, borderwidth=0) + line_numbers_default_title = Label( + frame_line_numbers_default, text='Show line numbers in new windows') + self.line_numbers_default_bool = Checkbutton( + frame_line_numbers_default, + variable=self.line_numbers_default, + width=1) + + frame_context = Frame(frame_editor, borderwidth=0) + context_title = Label(frame_context, text='Max Context Lines :') + self.context_int = Entry( + frame_context, textvariable=self.context_lines, width=3, + validatecommand=self.digits_only, validate='key', + ) + + # Pack widgets: + frame_shell.pack(side=TOP, padx=5, pady=5, fill=BOTH) + Label(self).pack() # Spacer -- better solution? + frame_editor.pack(side=TOP, padx=5, pady=5, fill=BOTH) + # frame_auto_squeeze_min_lines + frame_auto_squeeze_min_lines.pack(side=TOP, padx=5, pady=0, fill=X) + auto_squeeze_min_lines_title.pack(side=LEFT, anchor=W, padx=5, pady=5) + self.auto_squeeze_min_lines_int.pack(side=TOP, padx=5, pady=5) + # frame_save. + frame_save.pack(side=TOP, padx=5, pady=0, fill=X) + run_save_title.pack(side=LEFT, anchor=W, padx=5, pady=5) + self.save_auto_on.pack(side=RIGHT, anchor=W, padx=5, pady=5) + self.save_ask_on.pack(side=RIGHT, anchor=W, padx=5, pady=5) + # frame_line_numbers_default. + frame_line_numbers_default.pack(side=TOP, padx=5, pady=0, fill=X) + line_numbers_default_title.pack(side=LEFT, anchor=W, padx=5, pady=5) + self.line_numbers_default_bool.pack(side=LEFT, padx=5, pady=5) + # frame_context. + frame_context.pack(side=TOP, padx=5, pady=0, fill=X) + context_title.pack(side=LEFT, anchor=W, padx=5, pady=5) + self.context_int.pack(side=TOP, padx=5, pady=5) + + def load_shelled_cfg(self): + # Set variables for shell windows. + self.auto_squeeze_min_lines.set(idleConf.GetOption( + 'main', 'PyShell', 'auto-squeeze-min-lines', type='int')) + # Set variables for editor windows. + self.autosave.set(idleConf.GetOption( + 'main', 'General', 'autosave', default=0, type='bool')) + self.line_numbers_default.set(idleConf.GetOption( + 'main', 'EditorWindow', 'line-numbers-default', type='bool')) + self.context_lines.set(idleConf.GetOption( + 'extensions', 'CodeContext', 'maxlines', type='int')) + + +class ExtPage(Frame): + def __init__(self, master): + super().__init__(master) + self.ext_defaultCfg = idleConf.defaultCfg['extensions'] + self.ext_userCfg = idleConf.userCfg['extensions'] + self.is_int = self.register(is_int) + self.load_extensions() + self.create_page_extensions() # Requires extension names. + + def create_page_extensions(self): + """Configure IDLE feature extensions and help menu extensions. + + List the feature extensions and a configuration box for the + selected extension. Help menu extensions are in a HelpFrame. + + This code reads the current configuration using idleConf, + supplies a GUI interface to change the configuration values, + and saves the changes using idleConf. + + Some changes may require restarting IDLE. This depends on each + extension's implementation. + + All values are treated as text, and it is up to the user to + supply reasonable values. The only exception to this are the + 'enable*' options, which are boolean, and can be toggled with a + True/False button. + + Methods: + extension_selected: Handle selection from list. + create_extension_frame: Hold widgets for one extension. + set_extension_value: Set in userCfg['extensions']. + save_all_changed_extensions: Call extension page Save(). + """ + self.extension_names = StringVar(self) + + frame_ext = LabelFrame(self, borderwidth=2, relief=GROOVE, + text=' Feature Extensions ') + self.frame_help = HelpFrame(self, borderwidth=2, relief=GROOVE, + text=' Help Menu Extensions ') + + frame_ext.rowconfigure(0, weight=1) + frame_ext.columnconfigure(2, weight=1) + self.extension_list = Listbox(frame_ext, listvariable=self.extension_names, + selectmode='browse') + self.extension_list.bind('<>', self.extension_selected) + scroll = Scrollbar(frame_ext, command=self.extension_list.yview) + self.extension_list.yscrollcommand=scroll.set + self.details_frame = LabelFrame(frame_ext, width=250, height=250) + self.extension_list.grid(column=0, row=0, sticky='nws') + scroll.grid(column=1, row=0, sticky='ns') + self.details_frame.grid(column=2, row=0, sticky='nsew', padx=[10, 0]) + frame_ext.configure(padding=10) + self.config_frame = {} + self.current_extension = None + + self.outerframe = self # TEMPORARY + self.tabbed_page_set = self.extension_list # TEMPORARY + + # Create the frame holding controls for each extension. + ext_names = '' + for ext_name in sorted(self.extensions): + self.create_extension_frame(ext_name) + ext_names = ext_names + '{' + ext_name + '} ' + self.extension_names.set(ext_names) + self.extension_list.selection_set(0) + self.extension_selected(None) + + + frame_ext.grid(row=0, column=0, sticky='nsew') + Label(self).grid(row=1, column=0) # Spacer. Replace with config? + self.frame_help.grid(row=2, column=0, sticky='sew') + + def load_extensions(self): + "Fill self.extensions with data from the default and user configs." + self.extensions = {} + for ext_name in idleConf.GetExtensions(active_only=False): + # Former built-in extensions are already filtered out. + self.extensions[ext_name] = [] + + for ext_name in self.extensions: + opt_list = sorted(self.ext_defaultCfg.GetOptionList(ext_name)) + + # Bring 'enable' options to the beginning of the list. + enables = [opt_name for opt_name in opt_list + if opt_name.startswith('enable')] + for opt_name in enables: + opt_list.remove(opt_name) + opt_list = enables + opt_list + + for opt_name in opt_list: + def_str = self.ext_defaultCfg.Get( + ext_name, opt_name, raw=True) + try: + def_obj = {'True':True, 'False':False}[def_str] + opt_type = 'bool' + except KeyError: + try: + def_obj = int(def_str) + opt_type = 'int' + except ValueError: + def_obj = def_str + opt_type = None + try: + value = self.ext_userCfg.Get( + ext_name, opt_name, type=opt_type, raw=True, + default=def_obj) + except ValueError: # Need this until .Get fixed. + value = def_obj # Bad values overwritten by entry. + var = StringVar(self) + var.set(str(value)) + + self.extensions[ext_name].append({'name': opt_name, + 'type': opt_type, + 'default': def_str, + 'value': value, + 'var': var, + }) + + def extension_selected(self, event): + "Handle selection of an extension from the list." + newsel = self.extension_list.curselection() + if newsel: + newsel = self.extension_list.get(newsel) + if newsel is None or newsel != self.current_extension: + if self.current_extension: + self.details_frame.config(text='') + self.config_frame[self.current_extension].grid_forget() + self.current_extension = None + if newsel: + self.details_frame.config(text=newsel) + self.config_frame[newsel].grid(column=0, row=0, sticky='nsew') + self.current_extension = newsel + + def create_extension_frame(self, ext_name): + """Create a frame holding the widgets to configure one extension""" + f = VerticalScrolledFrame(self.details_frame, height=250, width=250) + self.config_frame[ext_name] = f + entry_area = f.interior + # Create an entry for each configuration option. + for row, opt in enumerate(self.extensions[ext_name]): + # Create a row with a label and entry/checkbutton. + label = Label(entry_area, text=opt['name']) + label.grid(row=row, column=0, sticky=NW) + var = opt['var'] + if opt['type'] == 'bool': + Checkbutton(entry_area, variable=var, + onvalue='True', offvalue='False', width=8 + ).grid(row=row, column=1, sticky=W, padx=7) + elif opt['type'] == 'int': + Entry(entry_area, textvariable=var, validate='key', + validatecommand=(self.is_int, '%P'), width=10 + ).grid(row=row, column=1, sticky=NSEW, padx=7) + + else: # type == 'str' + # Limit size to fit non-expanding space with larger font. + Entry(entry_area, textvariable=var, width=15 + ).grid(row=row, column=1, sticky=NSEW, padx=7) + return + + def set_extension_value(self, section, opt): + """Return True if the configuration was added or changed. + + If the value is the same as the default, then remove it + from user config file. + """ + name = opt['name'] + default = opt['default'] + value = opt['var'].get().strip() or default + opt['var'].set(value) + # if self.defaultCfg.has_section(section): + # Currently, always true; if not, indent to return. + if (value == default): + return self.ext_userCfg.RemoveOption(section, name) + # Set the option. + return self.ext_userCfg.SetOption(section, name, value) + + def save_all_changed_extensions(self): + """Save configuration changes to the user config file. + + Attributes accessed: + extensions + + Methods: + set_extension_value + """ + has_changes = False + for ext_name in self.extensions: + options = self.extensions[ext_name] + for opt in options: + if self.set_extension_value(ext_name, opt): + has_changes = True + if has_changes: + self.ext_userCfg.Save() + + +class HelpFrame(LabelFrame): + + def __init__(self, master, **cfg): + super().__init__(master, **cfg) + self.create_frame_help() + self.load_helplist() + + def create_frame_help(self): + """Create LabelFrame for additional help menu sources. + + load_helplist loads list user_helplist with + name, position pairs and copies names to listbox helplist. + Clicking a name invokes help_source selected. Clicking + button_helplist_name invokes helplist_item_name, which also + changes user_helplist. These functions all call + set_add_delete_state. All but load call update_help_changes to + rewrite changes['main']['HelpFiles']. + + Widgets for HelpFrame(LabelFrame): (*) widgets bound to self + frame_helplist: Frame + (*)helplist: ListBox + scroll_helplist: Scrollbar + frame_buttons: Frame + (*)button_helplist_edit + (*)button_helplist_add + (*)button_helplist_remove + """ + # self = frame_help in dialog (until ExtPage class). + frame_helplist = Frame(self) + self.helplist = Listbox( + frame_helplist, height=5, takefocus=True, + exportselection=FALSE) + scroll_helplist = Scrollbar(frame_helplist) + scroll_helplist['command'] = self.helplist.yview + self.helplist['yscrollcommand'] = scroll_helplist.set + self.helplist.bind('', self.help_source_selected) + + frame_buttons = Frame(self) + self.button_helplist_edit = Button( + frame_buttons, text='Edit', state='disabled', + width=8, command=self.helplist_item_edit) + self.button_helplist_add = Button( + frame_buttons, text='Add', + width=8, command=self.helplist_item_add) + self.button_helplist_remove = Button( + frame_buttons, text='Remove', state='disabled', + width=8, command=self.helplist_item_remove) + + # Pack frame_help. + frame_helplist.pack(side=LEFT, padx=5, pady=5, expand=TRUE, fill=BOTH) + self.helplist.pack(side=LEFT, anchor=E, expand=TRUE, fill=BOTH) + scroll_helplist.pack(side=RIGHT, anchor=W, fill=Y) + frame_buttons.pack(side=RIGHT, padx=5, pady=5, fill=Y) + self.button_helplist_edit.pack(side=TOP, anchor=W, pady=5) + self.button_helplist_add.pack(side=TOP, anchor=W) + self.button_helplist_remove.pack(side=TOP, anchor=W, pady=5) + + def help_source_selected(self, event): + "Handle event for selecting additional help." + self.set_add_delete_state() + + def set_add_delete_state(self): + "Toggle the state for the help list buttons based on list entries." + if self.helplist.size() < 1: # No entries in list. + self.button_helplist_edit.state(('disabled',)) + self.button_helplist_remove.state(('disabled',)) + else: # Some entries. + if self.helplist.curselection(): # There currently is a selection. + self.button_helplist_edit.state(('!disabled',)) + self.button_helplist_remove.state(('!disabled',)) + else: # There currently is not a selection. + self.button_helplist_edit.state(('disabled',)) + self.button_helplist_remove.state(('disabled',)) + + def helplist_item_add(self): + """Handle add button for the help list. + + Query for name and location of new help sources and add + them to the list. + """ + help_source = HelpSource(self, 'New Help Source').result + if help_source: + self.user_helplist.append(help_source) + self.helplist.insert(END, help_source[0]) + self.update_help_changes() + + def helplist_item_edit(self): + """Handle edit button for the help list. + + Query with existing help source information and update + config if the values are changed. + """ + item_index = self.helplist.index(ANCHOR) + help_source = self.user_helplist[item_index] + new_help_source = HelpSource( + self, 'Edit Help Source', + menuitem=help_source[0], + filepath=help_source[1], + ).result + if new_help_source and new_help_source != help_source: + self.user_helplist[item_index] = new_help_source + self.helplist.delete(item_index) + self.helplist.insert(item_index, new_help_source[0]) + self.update_help_changes() + self.set_add_delete_state() # Selected will be un-selected + + def helplist_item_remove(self): + """Handle remove button for the help list. + + Delete the help list item from config. + """ + item_index = self.helplist.index(ANCHOR) + del(self.user_helplist[item_index]) + self.helplist.delete(item_index) + self.update_help_changes() + self.set_add_delete_state() + + def update_help_changes(self): + "Clear and rebuild the HelpFiles section in changes" + changes['main']['HelpFiles'] = {} + for num in range(1, len(self.user_helplist) + 1): + changes.add_option( + 'main', 'HelpFiles', str(num), + ';'.join(self.user_helplist[num-1][:2])) + + def load_helplist(self): + # Set additional help sources. + self.user_helplist = idleConf.GetAllExtraHelpSourcesList() + self.helplist.delete(0, 'end') + for help_item in self.user_helplist: + self.helplist.insert(END, help_item[0]) + self.set_add_delete_state() + + +class VarTrace: + """Maintain Tk variables trace state.""" + + def __init__(self): + """Store Tk variables and callbacks. + + untraced: List of tuples (var, callback) + that do not have the callback attached + to the Tk var. + traced: List of tuples (var, callback) where + that callback has been attached to the var. + """ + self.untraced = [] + self.traced = [] + + def clear(self): + "Clear lists (for tests)." + # Call after all tests in a module to avoid memory leaks. + self.untraced.clear() + self.traced.clear() + + def add(self, var, callback): + """Add (var, callback) tuple to untraced list. + + Args: + var: Tk variable instance. + callback: Either function name to be used as a callback + or a tuple with IdleConf config-type, section, and + option names used in the default callback. + + Return: + Tk variable instance. + """ + if isinstance(callback, tuple): + callback = self.make_callback(var, callback) + self.untraced.append((var, callback)) + return var + + @staticmethod + def make_callback(var, config): + "Return default callback function to add values to changes instance." + def default_callback(*params): + "Add config values to changes instance." + changes.add_option(*config, var.get()) + return default_callback + + def attach(self): + "Attach callback to all vars that are not traced." + while self.untraced: + var, callback = self.untraced.pop() + var.trace_add('write', callback) + self.traced.append((var, callback)) + + def detach(self): + "Remove callback from traced vars." + while self.traced: + var, callback = self.traced.pop() + var.trace_remove('write', var.trace_info()[0][1]) + self.untraced.append((var, callback)) + + +tracers = VarTrace() + +help_common = '''\ +When you click either the Apply or Ok buttons, settings in this +dialog that are different from IDLE's default are saved in +a .idlerc directory in your home directory. Except as noted, +these changes apply to all versions of IDLE installed on this +machine. [Cancel] only cancels changes made since the last save. +''' +help_pages = { + 'Fonts/Tabs':''' +Font sample: This shows what a selection of Basic Multilingual Plane +unicode characters look like for the current font selection. If the +selected font does not define a character, Tk attempts to find another +font that does. Substitute glyphs depend on what is available on a +particular system and will not necessarily have the same size as the +font selected. Line contains 20 characters up to Devanagari, 14 for +Tamil, and 10 for East Asia. + +Hebrew and Arabic letters should display right to left, starting with +alef, \u05d0 and \u0627. Arabic digits display left to right. The +Devanagari and Tamil lines start with digits. The East Asian lines +are Chinese digits, Chinese Hanzi, Korean Hangul, and Japanese +Hiragana and Katakana. + +You can edit the font sample. Changes remain until IDLE is closed. +''', + 'Highlights': ''' +Highlighting: +The IDLE Dark color theme is new in October 2015. It can only +be used with older IDLE releases if it is saved as a custom +theme, with a different name. +''', + 'Keys': ''' +Keys: +The IDLE Modern Unix key set is new in June 2016. It can only +be used with older IDLE releases if it is saved as a custom +key set, with a different name. +''', + 'General': ''' +General: + +AutoComplete: Popupwait is milliseconds to wait after key char, without +cursor movement, before popping up completion box. Key char is '.' after +identifier or a '/' (or '\\' on Windows) within a string. + +FormatParagraph: Max-width is max chars in lines after re-formatting. +Use with paragraphs in both strings and comment blocks. + +ParenMatch: Style indicates what is highlighted when closer is entered: +'opener' - opener '({[' corresponding to closer; 'parens' - both chars; +'expression' (default) - also everything in between. Flash-delay is how +long to highlight if cursor is not moved (0 means forever). + +CodeContext: Maxlines is the maximum number of code context lines to +display when Code Context is turned on for an editor window. + +Shell Preferences: Auto-Squeeze Min. Lines is the minimum number of lines +of output to automatically "squeeze". +''', + 'Extensions': ''' +ZzDummy: This extension is provided as an example for how to create and +use an extension. Enable indicates whether the extension is active or +not; likewise enable_editor and enable_shell indicate which windows it +will be active on. For this extension, z-text is the text that will be +inserted at or removed from the beginning of the lines of selected text, +or the current line if no selection. +''', +} + + +def is_int(s): + "Return 's is blank or represents an int'" + if not s: + return True + try: + int(s) + return True + except ValueError: + return False + + +class VerticalScrolledFrame(Frame): + """A pure Tkinter vertically scrollable frame. + + * Use the 'interior' attribute to place widgets inside the scrollable frame + * Construct and pack/place/grid normally + * This frame only allows vertical scrolling + """ + def __init__(self, parent, *args, **kw): + Frame.__init__(self, parent, *args, **kw) + + # Create a canvas object and a vertical scrollbar for scrolling it. + vscrollbar = Scrollbar(self, orient=VERTICAL) + vscrollbar.pack(fill=Y, side=RIGHT, expand=FALSE) + canvas = Canvas(self, borderwidth=0, highlightthickness=0, + yscrollcommand=vscrollbar.set, width=240) + canvas.pack(side=LEFT, fill=BOTH, expand=TRUE) + vscrollbar.config(command=canvas.yview) + + # Reset the view. + canvas.xview_moveto(0) + canvas.yview_moveto(0) + + # Create a frame inside the canvas which will be scrolled with it. + self.interior = interior = Frame(canvas) + interior_id = canvas.create_window(0, 0, window=interior, anchor=NW) + + # Track changes to the canvas and frame width and sync them, + # also updating the scrollbar. + def _configure_interior(event): + # Update the scrollbars to match the size of the inner frame. + size = (interior.winfo_reqwidth(), interior.winfo_reqheight()) + canvas.config(scrollregion="0 0 %s %s" % size) + interior.bind('', _configure_interior) + + def _configure_canvas(event): + if interior.winfo_reqwidth() != canvas.winfo_width(): + # Update the inner frame's width to fill the canvas. + canvas.itemconfigure(interior_id, width=canvas.winfo_width()) + canvas.bind('', _configure_canvas) + + return + + +if __name__ == '__main__': + from unittest import main + main('idlelib.idle_test.test_configdialog', verbosity=2, exit=False) + + from idlelib.idle_test.htest import run + run(ConfigDialog) diff --git a/Lib/idlelib/debugger.py b/Lib/idlelib/debugger.py new file mode 100644 index 000000000..1fae1d4b0 --- /dev/null +++ b/Lib/idlelib/debugger.py @@ -0,0 +1,602 @@ +"""Debug user code with a GUI interface to a subclass of bdb.Bdb. + +The Idb instance 'idb' and Debugger instance 'gui' need references to each +other or to an rpc proxy for each other. + +If IDLE is started with '-n', so that user code and idb both run in the +IDLE process, Debugger is called without an idb. Debugger.__init__ +calls Idb with its incomplete self. Idb.__init__ stores gui and gui +then stores idb. + +If IDLE is started normally, so that user code executes in a separate +process, debugger_r.start_remote_debugger is called, executing in the +IDLE process. It calls 'start the debugger' in the remote process, +which calls Idb with a gui proxy. Then Debugger is called in the IDLE +for more. +""" + +import bdb +import os + +from tkinter import * +from tkinter.ttk import Frame, Scrollbar + +from idlelib import macosx +from idlelib.scrolledlist import ScrolledList +from idlelib.window import ListedToplevel + + +class Idb(bdb.Bdb): + "Supply user_line and user_exception functions for Bdb." + + def __init__(self, gui): + self.gui = gui # An instance of Debugger or proxy thereof. + super().__init__() + + def user_line(self, frame): + """Handle a user stopping or breaking at a line. + + Convert frame to a string and send it to gui. + """ + if _in_rpc_code(frame): + self.set_step() + return + message = _frame2message(frame) + try: + self.gui.interaction(message, frame) + except TclError: # When closing debugger window with [x] in 3.x + pass + + def user_exception(self, frame, exc_info): + """Handle an the occurrence of an exception.""" + if _in_rpc_code(frame): + self.set_step() + return + message = _frame2message(frame) + self.gui.interaction(message, frame, exc_info) + +def _in_rpc_code(frame): + "Determine if debugger is within RPC code." + if frame.f_code.co_filename.count('rpc.py'): + return True # Skip this frame. + else: + prev_frame = frame.f_back + if prev_frame is None: + return False + prev_name = prev_frame.f_code.co_filename + if 'idlelib' in prev_name and 'debugger' in prev_name: + # catch both idlelib/debugger.py and idlelib/debugger_r.py + # on both Posix and Windows + return False + return _in_rpc_code(prev_frame) + +def _frame2message(frame): + """Return a message string for frame.""" + code = frame.f_code + filename = code.co_filename + lineno = frame.f_lineno + basename = os.path.basename(filename) + message = f"{basename}:{lineno}" + if code.co_name != "?": + message = f"{message}: {code.co_name}()" + return message + + +class Debugger: + """The debugger interface. + + This class handles the drawing of the debugger window and + the interactions with the underlying debugger session. + """ + vstack = None + vsource = None + vlocals = None + vglobals = None + stackviewer = None + localsviewer = None + globalsviewer = None + + def __init__(self, pyshell, idb=None): + """Instantiate and draw a debugger window. + + :param pyshell: An instance of the PyShell Window + :type pyshell: :class:`idlelib.pyshell.PyShell` + + :param idb: An instance of the IDLE debugger (optional) + :type idb: :class:`idlelib.debugger.Idb` + """ + if idb is None: + idb = Idb(self) + self.pyshell = pyshell + self.idb = idb # If passed, a proxy of remote instance. + self.frame = None + self.make_gui() + self.interacting = False + self.nesting_level = 0 + + def run(self, *args): + """Run the debugger.""" + # Deal with the scenario where we've already got a program running + # in the debugger and we want to start another. If that is the case, + # our second 'run' was invoked from an event dispatched not from + # the main event loop, but from the nested event loop in 'interaction' + # below. So our stack looks something like this: + # outer main event loop + # run() + # + # callback to debugger's interaction() + # nested event loop + # run() for second command + # + # This kind of nesting of event loops causes all kinds of problems + # (see e.g. issue #24455) especially when dealing with running as a + # subprocess, where there's all kinds of extra stuff happening in + # there - insert a traceback.print_stack() to check it out. + # + # By this point, we've already called restart_subprocess() in + # ScriptBinding. However, we also need to unwind the stack back to + # that outer event loop. To accomplish this, we: + # - return immediately from the nested run() + # - abort_loop ensures the nested event loop will terminate + # - the debugger's interaction routine completes normally + # - the restart_subprocess() will have taken care of stopping + # the running program, which will also let the outer run complete + # + # That leaves us back at the outer main event loop, at which point our + # after event can fire, and we'll come back to this routine with a + # clean stack. + if self.nesting_level > 0: + self.abort_loop() + self.root.after(100, lambda: self.run(*args)) + return + try: + self.interacting = True + return self.idb.run(*args) + finally: + self.interacting = False + + def close(self, event=None): + """Close the debugger and window.""" + try: + self.quit() + except Exception: + pass + if self.interacting: + self.top.bell() + return + if self.stackviewer: + self.stackviewer.close(); self.stackviewer = None + # Clean up pyshell if user clicked debugger control close widget. + # (Causes a harmless extra cycle through close_debugger() if user + # toggled debugger from pyshell Debug menu) + self.pyshell.close_debugger() + # Now close the debugger control window.... + self.top.destroy() + + def make_gui(self): + """Draw the debugger gui on the screen.""" + pyshell = self.pyshell + self.flist = pyshell.flist + self.root = root = pyshell.root + self.top = top = ListedToplevel(root) + self.top.wm_title("Debug Control") + self.top.wm_iconname("Debug") + top.wm_protocol("WM_DELETE_WINDOW", self.close) + self.top.bind("", self.close) + + self.bframe = bframe = Frame(top) + self.bframe.pack(anchor="w") + self.buttons = bl = [] + + self.bcont = b = Button(bframe, text="Go", command=self.cont) + bl.append(b) + self.bstep = b = Button(bframe, text="Step", command=self.step) + bl.append(b) + self.bnext = b = Button(bframe, text="Over", command=self.next) + bl.append(b) + self.bret = b = Button(bframe, text="Out", command=self.ret) + bl.append(b) + self.bret = b = Button(bframe, text="Quit", command=self.quit) + bl.append(b) + + for b in bl: + b.configure(state="disabled") + b.pack(side="left") + + self.cframe = cframe = Frame(bframe) + self.cframe.pack(side="left") + + if not self.vstack: + self.__class__.vstack = BooleanVar(top) + self.vstack.set(1) + self.bstack = Checkbutton(cframe, + text="Stack", command=self.show_stack, variable=self.vstack) + self.bstack.grid(row=0, column=0) + if not self.vsource: + self.__class__.vsource = BooleanVar(top) + self.bsource = Checkbutton(cframe, + text="Source", command=self.show_source, variable=self.vsource) + self.bsource.grid(row=0, column=1) + if not self.vlocals: + self.__class__.vlocals = BooleanVar(top) + self.vlocals.set(1) + self.blocals = Checkbutton(cframe, + text="Locals", command=self.show_locals, variable=self.vlocals) + self.blocals.grid(row=1, column=0) + if not self.vglobals: + self.__class__.vglobals = BooleanVar(top) + self.bglobals = Checkbutton(cframe, + text="Globals", command=self.show_globals, variable=self.vglobals) + self.bglobals.grid(row=1, column=1) + + self.status = Label(top, anchor="w") + self.status.pack(anchor="w") + self.error = Label(top, anchor="w") + self.error.pack(anchor="w", fill="x") + self.errorbg = self.error.cget("background") + + self.fstack = Frame(top, height=1) + self.fstack.pack(expand=1, fill="both") + self.flocals = Frame(top) + self.flocals.pack(expand=1, fill="both") + self.fglobals = Frame(top, height=1) + self.fglobals.pack(expand=1, fill="both") + + if self.vstack.get(): + self.show_stack() + if self.vlocals.get(): + self.show_locals() + if self.vglobals.get(): + self.show_globals() + + def interaction(self, message, frame, info=None): + self.frame = frame + self.status.configure(text=message) + + if info: + type, value, tb = info + try: + m1 = type.__name__ + except AttributeError: + m1 = "%s" % str(type) + if value is not None: + try: + # TODO redo entire section, tries not needed. + m1 = f"{m1}: {value}" + except: + pass + bg = "yellow" + else: + m1 = "" + tb = None + bg = self.errorbg + self.error.configure(text=m1, background=bg) + + sv = self.stackviewer + if sv: + stack, i = self.idb.get_stack(self.frame, tb) + sv.load_stack(stack, i) + + self.show_variables(1) + + if self.vsource.get(): + self.sync_source_line() + + for b in self.buttons: + b.configure(state="normal") + + self.top.wakeup() + # Nested main loop: Tkinter's main loop is not reentrant, so use + # Tcl's vwait facility, which reenters the event loop until an + # event handler sets the variable we're waiting on. + self.nesting_level += 1 + self.root.tk.call('vwait', '::idledebugwait') + self.nesting_level -= 1 + + for b in self.buttons: + b.configure(state="disabled") + self.status.configure(text="") + self.error.configure(text="", background=self.errorbg) + self.frame = None + + def sync_source_line(self): + frame = self.frame + if not frame: + return + filename, lineno = self.__frame2fileline(frame) + if filename[:1] + filename[-1:] != "<>" and os.path.exists(filename): + self.flist.gotofileline(filename, lineno) + + def __frame2fileline(self, frame): + code = frame.f_code + filename = code.co_filename + lineno = frame.f_lineno + return filename, lineno + + def cont(self): + self.idb.set_continue() + self.abort_loop() + + def step(self): + self.idb.set_step() + self.abort_loop() + + def next(self): + self.idb.set_next(self.frame) + self.abort_loop() + + def ret(self): + self.idb.set_return(self.frame) + self.abort_loop() + + def quit(self): + self.idb.set_quit() + self.abort_loop() + + def abort_loop(self): + self.root.tk.call('set', '::idledebugwait', '1') + + def show_stack(self): + if not self.stackviewer and self.vstack.get(): + self.stackviewer = sv = StackViewer(self.fstack, self.flist, self) + if self.frame: + stack, i = self.idb.get_stack(self.frame, None) + sv.load_stack(stack, i) + else: + sv = self.stackviewer + if sv and not self.vstack.get(): + self.stackviewer = None + sv.close() + self.fstack['height'] = 1 + + def show_source(self): + if self.vsource.get(): + self.sync_source_line() + + def show_frame(self, stackitem): + self.frame = stackitem[0] # lineno is stackitem[1] + self.show_variables() + + def show_locals(self): + lv = self.localsviewer + if self.vlocals.get(): + if not lv: + self.localsviewer = NamespaceViewer(self.flocals, "Locals") + else: + if lv: + self.localsviewer = None + lv.close() + self.flocals['height'] = 1 + self.show_variables() + + def show_globals(self): + gv = self.globalsviewer + if self.vglobals.get(): + if not gv: + self.globalsviewer = NamespaceViewer(self.fglobals, "Globals") + else: + if gv: + self.globalsviewer = None + gv.close() + self.fglobals['height'] = 1 + self.show_variables() + + def show_variables(self, force=0): + lv = self.localsviewer + gv = self.globalsviewer + frame = self.frame + if not frame: + ldict = gdict = None + else: + ldict = frame.f_locals + gdict = frame.f_globals + if lv and gv and ldict is gdict: + ldict = None + if lv: + lv.load_dict(ldict, force, self.pyshell.interp.rpcclt) + if gv: + gv.load_dict(gdict, force, self.pyshell.interp.rpcclt) + + def set_breakpoint(self, filename, lineno): + """Set a filename-lineno breakpoint in the debugger. + + Called from self.load_breakpoints and EW.setbreakpoint + """ + self.idb.set_break(filename, lineno) + + def clear_breakpoint(self, filename, lineno): + self.idb.clear_break(filename, lineno) + + def clear_file_breaks(self, filename): + self.idb.clear_all_file_breaks(filename) + + def load_breakpoints(self): + """Load PyShellEditorWindow breakpoints into subprocess debugger.""" + for editwin in self.pyshell.flist.inversedict: + filename = editwin.io.filename + try: + for lineno in editwin.breakpoints: + self.set_breakpoint(filename, lineno) + except AttributeError: + continue + + +class StackViewer(ScrolledList): + "Code stack viewer for debugger GUI." + + def __init__(self, master, flist, gui): + if macosx.isAquaTk(): + # At least on with the stock AquaTk version on OSX 10.4 you'll + # get a shaking GUI that eventually kills IDLE if the width + # argument is specified. + ScrolledList.__init__(self, master) + else: + ScrolledList.__init__(self, master, width=80) + self.flist = flist + self.gui = gui + self.stack = [] + + def load_stack(self, stack, index=None): + self.stack = stack + self.clear() + for i in range(len(stack)): + frame, lineno = stack[i] + try: + modname = frame.f_globals["__name__"] + except: + modname = "?" + code = frame.f_code + filename = code.co_filename + funcname = code.co_name + import linecache + sourceline = linecache.getline(filename, lineno) + sourceline = sourceline.strip() + if funcname in ("?", "", None): + item = "%s, line %d: %s" % (modname, lineno, sourceline) + else: + item = "%s.%s(), line %d: %s" % (modname, funcname, + lineno, sourceline) + if i == index: + item = "> " + item + self.append(item) + if index is not None: + self.select(index) + + def popup_event(self, event): + "Override base method." + if self.stack: + return ScrolledList.popup_event(self, event) + + def fill_menu(self): + "Override base method." + menu = self.menu + menu.add_command(label="Go to source line", + command=self.goto_source_line) + menu.add_command(label="Show stack frame", + command=self.show_stack_frame) + + def on_select(self, index): + "Override base method." + if 0 <= index < len(self.stack): + self.gui.show_frame(self.stack[index]) + + def on_double(self, index): + "Override base method." + self.show_source(index) + + def goto_source_line(self): + index = self.listbox.index("active") + self.show_source(index) + + def show_stack_frame(self): + index = self.listbox.index("active") + if 0 <= index < len(self.stack): + self.gui.show_frame(self.stack[index]) + + def show_source(self, index): + if not (0 <= index < len(self.stack)): + return + frame, lineno = self.stack[index] + code = frame.f_code + filename = code.co_filename + if os.path.isfile(filename): + edit = self.flist.open(filename) + if edit: + edit.gotoline(lineno) + + +class NamespaceViewer: + "Global/local namespace viewer for debugger GUI." + + def __init__(self, master, title, odict=None): # XXX odict never passed. + width = 0 + height = 40 + if odict: + height = 20*len(odict) # XXX 20 == observed height of Entry widget + self.master = master + self.title = title + import reprlib + self.repr = reprlib.Repr() + self.repr.maxstring = 60 + self.repr.maxother = 60 + self.frame = frame = Frame(master) + self.frame.pack(expand=1, fill="both") + self.label = Label(frame, text=title, borderwidth=2, relief="groove") + self.label.pack(fill="x") + self.vbar = vbar = Scrollbar(frame, name="vbar") + vbar.pack(side="right", fill="y") + self.canvas = canvas = Canvas(frame, + height=min(300, max(40, height)), + scrollregion=(0, 0, width, height)) + canvas.pack(side="left", fill="both", expand=1) + vbar["command"] = canvas.yview + canvas["yscrollcommand"] = vbar.set + self.subframe = subframe = Frame(canvas) + self.sfid = canvas.create_window(0, 0, window=subframe, anchor="nw") + self.load_dict(odict) + + prev_odict = -1 # Needed for initial comparison below. + + def load_dict(self, odict, force=0, rpc_client=None): + if odict is self.prev_odict and not force: + return + subframe = self.subframe + frame = self.frame + for c in list(subframe.children.values()): + c.destroy() + self.prev_odict = None + if not odict: + l = Label(subframe, text="None") + l.grid(row=0, column=0) + else: + #names = sorted(dict) + # + # Because of (temporary) limitations on the dict_keys type (not yet + # public or pickleable), have the subprocess to send a list of + # keys, not a dict_keys object. sorted() will take a dict_keys + # (no subprocess) or a list. + # + # There is also an obscure bug in sorted(dict) where the + # interpreter gets into a loop requesting non-existing dict[0], + # dict[1], dict[2], etc from the debugger_r.DictProxy. + # TODO recheck above; see debugger_r 159ff, debugobj 60. + keys_list = odict.keys() + names = sorted(keys_list) + + row = 0 + for name in names: + value = odict[name] + svalue = self.repr.repr(value) # repr(value) + # Strip extra quotes caused by calling repr on the (already) + # repr'd value sent across the RPC interface: + if rpc_client: + svalue = svalue[1:-1] + l = Label(subframe, text=name) + l.grid(row=row, column=0, sticky="nw") + l = Entry(subframe, width=0, borderwidth=0) + l.insert(0, svalue) + l.grid(row=row, column=1, sticky="nw") + row = row+1 + self.prev_odict = odict + # XXX Could we use a callback for the following? + subframe.update_idletasks() # Alas! + width = subframe.winfo_reqwidth() + height = subframe.winfo_reqheight() + canvas = self.canvas + self.canvas["scrollregion"] = (0, 0, width, height) + if height > 300: + canvas["height"] = 300 + frame.pack(expand=1) + else: + canvas["height"] = height + frame.pack(expand=0) + + def close(self): + self.frame.destroy() + + +if __name__ == "__main__": + from unittest import main + main('idlelib.idle_test.test_debugger', verbosity=2, exit=False) + +# TODO: htest? diff --git a/Lib/idlelib/debugger_r.py b/Lib/idlelib/debugger_r.py new file mode 100644 index 000000000..ad3355d9f --- /dev/null +++ b/Lib/idlelib/debugger_r.py @@ -0,0 +1,390 @@ +"""Support for remote Python debugging. + +Some ASCII art to describe the structure: + + IN PYTHON SUBPROCESS # IN IDLE PROCESS + # + # oid='gui_adapter' + +----------+ # +------------+ +-----+ + | GUIProxy |--remote#call-->| GUIAdapter |--calls-->| GUI | ++-----+--calls-->+----------+ # +------------+ +-----+ +| Idb | # / ++-----+<-calls--+------------+ # +----------+<--calls-/ + | IdbAdapter |<--remote#call--| IdbProxy | + +------------+ # +----------+ + oid='idb_adapter' # + +The purpose of the Proxy and Adapter classes is to translate certain +arguments and return values that cannot be transported through the RPC +barrier, in particular frame and traceback objects. + +""" +import reprlib +import types +from idlelib import debugger + +debugging = 0 + +idb_adap_oid = "idb_adapter" +gui_adap_oid = "gui_adapter" + +#======================================= +# +# In the PYTHON subprocess: + +frametable = {} +dicttable = {} +codetable = {} +tracebacktable = {} + +def wrap_frame(frame): + fid = id(frame) + frametable[fid] = frame + return fid + +def wrap_info(info): + "replace info[2], a traceback instance, by its ID" + if info is None: + return None + else: + traceback = info[2] + assert isinstance(traceback, types.TracebackType) + traceback_id = id(traceback) + tracebacktable[traceback_id] = traceback + modified_info = (info[0], info[1], traceback_id) + return modified_info + +class GUIProxy: + + def __init__(self, conn, gui_adap_oid): + self.conn = conn + self.oid = gui_adap_oid + + def interaction(self, message, frame, info=None): + # calls rpc.SocketIO.remotecall() via run.MyHandler instance + # pass frame and traceback object IDs instead of the objects themselves + self.conn.remotecall(self.oid, "interaction", + (message, wrap_frame(frame), wrap_info(info)), + {}) + +class IdbAdapter: + + def __init__(self, idb): + self.idb = idb + + #----------called by an IdbProxy---------- + + def set_step(self): + self.idb.set_step() + + def set_quit(self): + self.idb.set_quit() + + def set_continue(self): + self.idb.set_continue() + + def set_next(self, fid): + frame = frametable[fid] + self.idb.set_next(frame) + + def set_return(self, fid): + frame = frametable[fid] + self.idb.set_return(frame) + + def get_stack(self, fid, tbid): + frame = frametable[fid] + if tbid is None: + tb = None + else: + tb = tracebacktable[tbid] + stack, i = self.idb.get_stack(frame, tb) + stack = [(wrap_frame(frame2), k) for frame2, k in stack] + return stack, i + + def run(self, cmd): + import __main__ + self.idb.run(cmd, __main__.__dict__) + + def set_break(self, filename, lineno): + msg = self.idb.set_break(filename, lineno) + return msg + + def clear_break(self, filename, lineno): + msg = self.idb.clear_break(filename, lineno) + return msg + + def clear_all_file_breaks(self, filename): + msg = self.idb.clear_all_file_breaks(filename) + return msg + + #----------called by a FrameProxy---------- + + def frame_attr(self, fid, name): + frame = frametable[fid] + return getattr(frame, name) + + def frame_globals(self, fid): + frame = frametable[fid] + gdict = frame.f_globals + did = id(gdict) + dicttable[did] = gdict + return did + + def frame_locals(self, fid): + frame = frametable[fid] + ldict = frame.f_locals + did = id(ldict) + dicttable[did] = ldict + return did + + def frame_code(self, fid): + frame = frametable[fid] + code = frame.f_code + cid = id(code) + codetable[cid] = code + return cid + + #----------called by a CodeProxy---------- + + def code_name(self, cid): + code = codetable[cid] + return code.co_name + + def code_filename(self, cid): + code = codetable[cid] + return code.co_filename + + #----------called by a DictProxy---------- + + def dict_keys(self, did): + raise NotImplementedError("dict_keys not public or pickleable") +## return dicttable[did].keys() + + ### Needed until dict_keys type is finished and pickleable. + # xxx finished. pickleable? + ### Will probably need to extend rpc.py:SocketIO._proxify at that time. + def dict_keys_list(self, did): + return list(dicttable[did].keys()) + + def dict_item(self, did, key): + value = dicttable[did][key] + return reprlib.repr(value) # Can't pickle module 'builtins'. + +#----------end class IdbAdapter---------- + + +def start_debugger(rpchandler, gui_adap_oid): + """Start the debugger and its RPC link in the Python subprocess + + Start the subprocess side of the split debugger and set up that side of the + RPC link by instantiating the GUIProxy, Idb debugger, and IdbAdapter + objects and linking them together. Register the IdbAdapter with the + RPCServer to handle RPC requests from the split debugger GUI via the + IdbProxy. + + """ + gui_proxy = GUIProxy(rpchandler, gui_adap_oid) + idb = debugger.Idb(gui_proxy) + idb_adap = IdbAdapter(idb) + rpchandler.register(idb_adap_oid, idb_adap) + return idb_adap_oid + + +#======================================= +# +# In the IDLE process: + + +class FrameProxy: + + def __init__(self, conn, fid): + self._conn = conn + self._fid = fid + self._oid = "idb_adapter" + self._dictcache = {} + + def __getattr__(self, name): + if name[:1] == "_": + raise AttributeError(name) + if name == "f_code": + return self._get_f_code() + if name == "f_globals": + return self._get_f_globals() + if name == "f_locals": + return self._get_f_locals() + return self._conn.remotecall(self._oid, "frame_attr", + (self._fid, name), {}) + + def _get_f_code(self): + cid = self._conn.remotecall(self._oid, "frame_code", (self._fid,), {}) + return CodeProxy(self._conn, self._oid, cid) + + def _get_f_globals(self): + did = self._conn.remotecall(self._oid, "frame_globals", + (self._fid,), {}) + return self._get_dict_proxy(did) + + def _get_f_locals(self): + did = self._conn.remotecall(self._oid, "frame_locals", + (self._fid,), {}) + return self._get_dict_proxy(did) + + def _get_dict_proxy(self, did): + if did in self._dictcache: + return self._dictcache[did] + dp = DictProxy(self._conn, self._oid, did) + self._dictcache[did] = dp + return dp + + +class CodeProxy: + + def __init__(self, conn, oid, cid): + self._conn = conn + self._oid = oid + self._cid = cid + + def __getattr__(self, name): + if name == "co_name": + return self._conn.remotecall(self._oid, "code_name", + (self._cid,), {}) + if name == "co_filename": + return self._conn.remotecall(self._oid, "code_filename", + (self._cid,), {}) + + +class DictProxy: + + def __init__(self, conn, oid, did): + self._conn = conn + self._oid = oid + self._did = did + +## def keys(self): +## return self._conn.remotecall(self._oid, "dict_keys", (self._did,), {}) + + # 'temporary' until dict_keys is a pickleable built-in type + def keys(self): + return self._conn.remotecall(self._oid, + "dict_keys_list", (self._did,), {}) + + def __getitem__(self, key): + return self._conn.remotecall(self._oid, "dict_item", + (self._did, key), {}) + + def __getattr__(self, name): + ##print("*** Failed DictProxy.__getattr__:", name) + raise AttributeError(name) + + +class GUIAdapter: + + def __init__(self, conn, gui): + self.conn = conn + self.gui = gui + + def interaction(self, message, fid, modified_info): + ##print("*** Interaction: (%s, %s, %s)" % (message, fid, modified_info)) + frame = FrameProxy(self.conn, fid) + self.gui.interaction(message, frame, modified_info) + + +class IdbProxy: + + def __init__(self, conn, shell, oid): + self.oid = oid + self.conn = conn + self.shell = shell + + def call(self, methodname, /, *args, **kwargs): + ##print("*** IdbProxy.call %s %s %s" % (methodname, args, kwargs)) + value = self.conn.remotecall(self.oid, methodname, args, kwargs) + ##print("*** IdbProxy.call %s returns %r" % (methodname, value)) + return value + + def run(self, cmd, locals): + # Ignores locals on purpose! + seq = self.conn.asyncqueue(self.oid, "run", (cmd,), {}) + self.shell.interp.active_seq = seq + + def get_stack(self, frame, tbid): + # passing frame and traceback IDs, not the objects themselves + stack, i = self.call("get_stack", frame._fid, tbid) + stack = [(FrameProxy(self.conn, fid), k) for fid, k in stack] + return stack, i + + def set_continue(self): + self.call("set_continue") + + def set_step(self): + self.call("set_step") + + def set_next(self, frame): + self.call("set_next", frame._fid) + + def set_return(self, frame): + self.call("set_return", frame._fid) + + def set_quit(self): + self.call("set_quit") + + def set_break(self, filename, lineno): + msg = self.call("set_break", filename, lineno) + return msg + + def clear_break(self, filename, lineno): + msg = self.call("clear_break", filename, lineno) + return msg + + def clear_all_file_breaks(self, filename): + msg = self.call("clear_all_file_breaks", filename) + return msg + +def start_remote_debugger(rpcclt, pyshell): + """Start the subprocess debugger, initialize the debugger GUI and RPC link + + Request the RPCServer start the Python subprocess debugger and link. Set + up the Idle side of the split debugger by instantiating the IdbProxy, + debugger GUI, and debugger GUIAdapter objects and linking them together. + + Register the GUIAdapter with the RPCClient to handle debugger GUI + interaction requests coming from the subprocess debugger via the GUIProxy. + + The IdbAdapter will pass execution and environment requests coming from the + Idle debugger GUI to the subprocess debugger via the IdbProxy. + + """ + global idb_adap_oid + + idb_adap_oid = rpcclt.remotecall("exec", "start_the_debugger",\ + (gui_adap_oid,), {}) + idb_proxy = IdbProxy(rpcclt, pyshell, idb_adap_oid) + gui = debugger.Debugger(pyshell, idb_proxy) + gui_adap = GUIAdapter(rpcclt, gui) + rpcclt.register(gui_adap_oid, gui_adap) + return gui + +def close_remote_debugger(rpcclt): + """Shut down subprocess debugger and Idle side of debugger RPC link + + Request that the RPCServer shut down the subprocess debugger and link. + Unregister the GUIAdapter, which will cause a GC on the Idle process + debugger and RPC link objects. (The second reference to the debugger GUI + is deleted in pyshell.close_remote_debugger().) + + """ + close_subprocess_debugger(rpcclt) + rpcclt.unregister(gui_adap_oid) + +def close_subprocess_debugger(rpcclt): + rpcclt.remotecall("exec", "stop_the_debugger", (idb_adap_oid,), {}) + +def restart_subprocess_debugger(rpcclt): + idb_adap_oid_ret = rpcclt.remotecall("exec", "start_the_debugger",\ + (gui_adap_oid,), {}) + assert idb_adap_oid_ret == idb_adap_oid, 'Idb restarted with different oid' + + +if __name__ == "__main__": + from unittest import main + main('idlelib.idle_test.test_debugger_r', verbosity=2, exit=False) diff --git a/Lib/idlelib/debugobj.py b/Lib/idlelib/debugobj.py new file mode 100644 index 000000000..fb448ece2 --- /dev/null +++ b/Lib/idlelib/debugobj.py @@ -0,0 +1,146 @@ +"""Define tree items for debug stackviewer, which is only user. +""" +# XXX TO DO: +# - popup menu +# - support partial or total redisplay +# - more doc strings +# - tooltips + +# object browser + +# XXX TO DO: +# - for classes/modules, add "open source" to object browser +from reprlib import Repr + +from idlelib.tree import TreeItem, TreeNode, ScrolledCanvas + +myrepr = Repr() +myrepr.maxstring = 100 +myrepr.maxother = 100 + +class ObjectTreeItem(TreeItem): + def __init__(self, labeltext, object_, setfunction=None): + self.labeltext = labeltext + self.object = object_ + self.setfunction = setfunction + def GetLabelText(self): + return self.labeltext + def GetText(self): + return myrepr.repr(self.object) + def GetIconName(self): + if not self.IsExpandable(): + return "python" + def IsEditable(self): + return self.setfunction is not None + def SetText(self, text): + try: + value = eval(text) + self.setfunction(value) + except: + pass + else: + self.object = value + def IsExpandable(self): + return not not dir(self.object) + def GetSubList(self): + keys = dir(self.object) + sublist = [] + for key in keys: + try: + value = getattr(self.object, key) + except AttributeError: + continue + item = make_objecttreeitem( + str(key) + " =", + value, + lambda value, key=key, object_=self.object: + setattr(object_, key, value)) + sublist.append(item) + return sublist + +class ClassTreeItem(ObjectTreeItem): + def IsExpandable(self): + return True + def GetSubList(self): + sublist = ObjectTreeItem.GetSubList(self) + if len(self.object.__bases__) == 1: + item = make_objecttreeitem("__bases__[0] =", + self.object.__bases__[0]) + else: + item = make_objecttreeitem("__bases__ =", self.object.__bases__) + sublist.insert(0, item) + return sublist + +class AtomicObjectTreeItem(ObjectTreeItem): + def IsExpandable(self): + return False + +class SequenceTreeItem(ObjectTreeItem): + def IsExpandable(self): + return len(self.object) > 0 + def keys(self): + return range(len(self.object)) + def GetSubList(self): + sublist = [] + for key in self.keys(): + try: + value = self.object[key] + except KeyError: + continue + def setfunction(value, key=key, object_=self.object): + object_[key] = value + item = make_objecttreeitem(f"{key!r}:", value, setfunction) + sublist.append(item) + return sublist + +class DictTreeItem(SequenceTreeItem): + def keys(self): + # TODO return sorted(self.object) + keys = list(self.object) + try: + keys.sort() + except: + pass + return keys + +dispatch = { + int: AtomicObjectTreeItem, + float: AtomicObjectTreeItem, + str: AtomicObjectTreeItem, + tuple: SequenceTreeItem, + list: SequenceTreeItem, + dict: DictTreeItem, + type: ClassTreeItem, +} + +def make_objecttreeitem(labeltext, object_, setfunction=None): + t = type(object_) + if t in dispatch: + c = dispatch[t] + else: + c = ObjectTreeItem + return c(labeltext, object_, setfunction) + + +def _debug_object_browser(parent): # htest # + import sys + from tkinter import Toplevel + top = Toplevel(parent) + top.title("Test debug object browser") + x, y = map(int, parent.geometry().split('+')[1:]) + top.geometry("+%d+%d" % (x + 100, y + 175)) + top.configure(bd=0, bg="yellow") + top.focus_set() + sc = ScrolledCanvas(top, bg="white", highlightthickness=0, takefocus=1) + sc.frame.pack(expand=1, fill="both") + item = make_objecttreeitem("sys", sys) + node = TreeNode(sc.canvas, None, item) + node.update() + + +if __name__ == '__main__': + from unittest import main + main('idlelib.idle_test.test_debugobj', verbosity=2, exit=False) + + from idlelib.idle_test.htest import run + run(_debug_object_browser) diff --git a/Lib/idlelib/debugobj_r.py b/Lib/idlelib/debugobj_r.py new file mode 100644 index 000000000..75e75ebe5 --- /dev/null +++ b/Lib/idlelib/debugobj_r.py @@ -0,0 +1,41 @@ +from idlelib import rpc + +def remote_object_tree_item(item): + wrapper = WrappedObjectTreeItem(item) + oid = id(wrapper) + rpc.objecttable[oid] = wrapper + return oid + +class WrappedObjectTreeItem: + # Lives in PYTHON subprocess + + def __init__(self, item): + self.__item = item + + def __getattr__(self, name): + value = getattr(self.__item, name) + return value + + def _GetSubList(self): + sub_list = self.__item._GetSubList() + return list(map(remote_object_tree_item, sub_list)) + +class StubObjectTreeItem: + # Lives in IDLE process + + def __init__(self, sockio, oid): + self.sockio = sockio + self.oid = oid + + def __getattr__(self, name): + value = rpc.MethodProxy(self.sockio, self.oid, name) + return value + + def _GetSubList(self): + sub_list = self.sockio.remotecall(self.oid, "_GetSubList", (), {}) + return [StubObjectTreeItem(self.sockio, oid) for oid in sub_list] + + +if __name__ == '__main__': + from unittest import main + main('idlelib.idle_test.test_debugobj_r', verbosity=2) diff --git a/Lib/idlelib/delegator.py b/Lib/idlelib/delegator.py new file mode 100644 index 000000000..93ae8bbd4 --- /dev/null +++ b/Lib/idlelib/delegator.py @@ -0,0 +1,34 @@ +class Delegator: + + def __init__(self, delegate=None): + self.delegate = delegate + self.__cache = set() + # Cache is used to only remove added attributes + # when changing the delegate. + + def __getattr__(self, name): + attr = getattr(self.delegate, name) # May raise AttributeError + setattr(self, name, attr) + self.__cache.add(name) + return attr + + def resetcache(self): + "Removes added attributes while leaving original attributes." + # Function is really about resetting delegator dict + # to original state. Cache is just a means + for key in self.__cache: + try: + delattr(self, key) + except AttributeError: + pass + self.__cache.clear() + + def setdelegate(self, delegate): + "Reset attributes and change delegate." + self.resetcache() + self.delegate = delegate + + +if __name__ == '__main__': + from unittest import main + main('idlelib.idle_test.test_delegator', verbosity=2) diff --git a/Lib/idlelib/dynoption.py b/Lib/idlelib/dynoption.py new file mode 100644 index 000000000..b8937f710 --- /dev/null +++ b/Lib/idlelib/dynoption.py @@ -0,0 +1,57 @@ +""" +OptionMenu widget modified to allow dynamic menu reconfiguration +and setting of highlightthickness +""" +from tkinter import OptionMenu, _setit, StringVar, Button + +class DynOptionMenu(OptionMenu): + """Add SetMenu and highlightthickness to OptionMenu. + + Highlightthickness adds space around menu button. + """ + def __init__(self, master, variable, value, *values, **kwargs): + highlightthickness = kwargs.pop('highlightthickness', None) + OptionMenu.__init__(self, master, variable, value, *values, **kwargs) + self['highlightthickness'] = highlightthickness + self.variable = variable + self.command = kwargs.get('command') + + def SetMenu(self,valueList,value=None): + """ + clear and reload the menu with a new set of options. + valueList - list of new options + value - initial value to set the optionmenu's menubutton to + """ + self['menu'].delete(0,'end') + for item in valueList: + self['menu'].add_command(label=item, + command=_setit(self.variable,item,self.command)) + if value: + self.variable.set(value) + + +def _dyn_option_menu(parent): # htest # + from tkinter import Toplevel # + StringVar, Button + + top = Toplevel(parent) + top.title("Test dynamic option menu") + x, y = map(int, parent.geometry().split('+')[1:]) + top.geometry("200x100+%d+%d" % (x + 250, y + 175)) + top.focus_set() + + var = StringVar(top) + var.set("Old option set") #Set the default value + dyn = DynOptionMenu(top, var, "old1","old2","old3","old4", + highlightthickness=5) + dyn.pack() + + def update(): + dyn.SetMenu(["new1","new2","new3","new4"], value="new option set") + button = Button(top, text="Change option set", command=update) + button.pack() + + +if __name__ == '__main__': + # Only module without unittests because of intention to replace. + from idlelib.idle_test.htest import run + run(_dyn_option_menu) diff --git a/Lib/idlelib/editor.py b/Lib/idlelib/editor.py new file mode 100644 index 000000000..312893476 --- /dev/null +++ b/Lib/idlelib/editor.py @@ -0,0 +1,1721 @@ +import importlib.abc +import importlib.util +import os +import platform +import re +import string +import sys +import tokenize +import traceback +import webbrowser + +from tkinter import * +from tkinter.font import Font +from tkinter.ttk import Scrollbar +from tkinter import simpledialog +from tkinter import messagebox + +from idlelib.config import idleConf +from idlelib import configdialog +from idlelib import grep +from idlelib import help +from idlelib import help_about +from idlelib import macosx +from idlelib.multicall import MultiCallCreator +from idlelib import pyparse +from idlelib import query +from idlelib import replace +from idlelib import search +from idlelib.tree import wheel_event +from idlelib.util import py_extensions +from idlelib import window +from idlelib.help import _get_dochome + +# The default tab setting for a Text widget, in average-width characters. +TK_TABWIDTH_DEFAULT = 8 +darwin = sys.platform == 'darwin' + +class EditorWindow: + from idlelib.percolator import Percolator + from idlelib.colorizer import ColorDelegator, color_config + from idlelib.undo import UndoDelegator + from idlelib.iomenu import IOBinding, encoding + from idlelib import mainmenu + from idlelib.statusbar import MultiStatusBar + from idlelib.autocomplete import AutoComplete + from idlelib.autoexpand import AutoExpand + from idlelib.calltip import Calltip + from idlelib.codecontext import CodeContext + from idlelib.sidebar import LineNumbers + from idlelib.format import FormatParagraph, FormatRegion, Indents, Rstrip + from idlelib.parenmatch import ParenMatch + from idlelib.zoomheight import ZoomHeight + + filesystemencoding = sys.getfilesystemencoding() # for file names + help_url = None + + allow_code_context = True + allow_line_numbers = True + user_input_insert_tags = None + + def __init__(self, flist=None, filename=None, key=None, root=None): + # Delay import: runscript imports pyshell imports EditorWindow. + from idlelib.runscript import ScriptBinding + + if EditorWindow.help_url is None: + EditorWindow.help_url = _get_dochome() + self.flist = flist + root = root or flist.root + self.root = root + self.menubar = Menu(root) + self.top = top = window.ListedToplevel(root, menu=self.menubar) + if flist: + self.tkinter_vars = flist.vars + #self.top.instance_dict makes flist.inversedict available to + #configdialog.py so it can access all EditorWindow instances + self.top.instance_dict = flist.inversedict + else: + self.tkinter_vars = {} # keys: Tkinter event names + # values: Tkinter variable instances + self.top.instance_dict = {} + self.recent_files_path = idleConf.userdir and os.path.join( + idleConf.userdir, 'recent-files.lst') + + self.prompt_last_line = '' # Override in PyShell + self.text_frame = text_frame = Frame(top) + self.vbar = vbar = Scrollbar(text_frame, name='vbar') + width = idleConf.GetOption('main', 'EditorWindow', 'width', type='int') + text_options = { + 'name': 'text', + 'padx': 5, + 'wrap': 'none', + 'highlightthickness': 0, + 'width': width, + 'tabstyle': 'wordprocessor', # new in 8.5 + 'height': idleConf.GetOption( + 'main', 'EditorWindow', 'height', type='int'), + } + self.text = text = MultiCallCreator(Text)(text_frame, **text_options) + self.top.focused_widget = self.text + + self.createmenubar() + self.apply_bindings() + + self.top.protocol("WM_DELETE_WINDOW", self.close) + self.top.bind("<>", self.close_event) + if macosx.isAquaTk(): + # Command-W on editor windows doesn't work without this. + text.bind('<>', self.close_event) + # Some OS X systems have only one mouse button, so use + # control-click for popup context menus there. For two + # buttons, AquaTk defines <2> as the right button, not <3>. + text.bind("",self.right_menu_event) + text.bind("<2>", self.right_menu_event) + else: + # Elsewhere, use right-click for popup menus. + text.bind("<3>",self.right_menu_event) + + text.bind('', wheel_event) + if text._windowingsystem == 'x11': + text.bind('', wheel_event) + text.bind('', wheel_event) + text.bind('', self.handle_winconfig) + text.bind("<>", self.cut) + text.bind("<>", self.copy) + text.bind("<>", self.paste) + text.bind("<>", self.center_insert_event) + text.bind("<>", self.help_dialog) + text.bind("<>", self.python_docs) + text.bind("<>", self.about_dialog) + text.bind("<>", self.config_dialog) + text.bind("<>", self.open_module_event) + text.bind("<>", lambda event: "break") + text.bind("<>", self.select_all) + text.bind("<>", self.remove_selection) + text.bind("<>", self.find_event) + text.bind("<>", self.find_again_event) + text.bind("<>", self.find_in_files_event) + text.bind("<>", self.find_selection_event) + text.bind("<>", self.replace_event) + text.bind("<>", self.goto_line_event) + text.bind("<>",self.smart_backspace_event) + text.bind("<>",self.newline_and_indent_event) + text.bind("<>",self.smart_indent_event) + self.fregion = fregion = self.FormatRegion(self) + # self.fregion used in smart_indent_event to access indent_region. + text.bind("<>", fregion.indent_region_event) + text.bind("<>", fregion.dedent_region_event) + text.bind("<>", fregion.comment_region_event) + text.bind("<>", fregion.uncomment_region_event) + text.bind("<>", fregion.tabify_region_event) + text.bind("<>", fregion.untabify_region_event) + indents = self.Indents(self) + text.bind("<>", indents.toggle_tabs_event) + text.bind("<>", indents.change_indentwidth_event) + text.bind("", self.move_at_edge_if_selection(0)) + text.bind("", self.move_at_edge_if_selection(1)) + text.bind("<>", self.del_word_left) + text.bind("<>", self.del_word_right) + text.bind("<>", self.home_callback) + + if flist: + flist.inversedict[self] = key + if key: + flist.dict[key] = self + text.bind("<>", self.new_callback) + text.bind("<>", self.flist.close_all_callback) + text.bind("<>", self.open_module_browser) + text.bind("<>", self.open_path_browser) + text.bind("<>", self.open_turtle_demo) + + self.set_status_bar() + text_frame.pack(side=LEFT, fill=BOTH, expand=1) + text_frame.rowconfigure(1, weight=1) + text_frame.columnconfigure(1, weight=1) + vbar['command'] = self.handle_yview + vbar.grid(row=1, column=2, sticky=NSEW) + text['yscrollcommand'] = vbar.set + text['font'] = idleConf.GetFont(self.root, 'main', 'EditorWindow') + text.grid(row=1, column=1, sticky=NSEW) + text.focus_set() + self.set_width() + + # usetabs true -> literal tab characters are used by indent and + # dedent cmds, possibly mixed with spaces if + # indentwidth is not a multiple of tabwidth, + # which will cause Tabnanny to nag! + # false -> tab characters are converted to spaces by indent + # and dedent cmds, and ditto TAB keystrokes + # Although use-spaces=0 can be configured manually in config-main.def, + # configuration of tabs v. spaces is not supported in the configuration + # dialog. IDLE promotes the preferred Python indentation: use spaces! + usespaces = idleConf.GetOption('main', 'Indent', + 'use-spaces', type='bool') + self.usetabs = not usespaces + + # tabwidth is the display width of a literal tab character. + # CAUTION: telling Tk to use anything other than its default + # tab setting causes it to use an entirely different tabbing algorithm, + # treating tab stops as fixed distances from the left margin. + # Nobody expects this, so for now tabwidth should never be changed. + self.tabwidth = 8 # must remain 8 until Tk is fixed. + + # indentwidth is the number of screen characters per indent level. + # The recommended Python indentation is four spaces. + self.indentwidth = self.tabwidth + self.set_notabs_indentwidth() + + # Store the current value of the insertofftime now so we can restore + # it if needed. + if not hasattr(idleConf, 'blink_off_time'): + idleConf.blink_off_time = self.text['insertofftime'] + self.update_cursor_blink() + + # When searching backwards for a reliable place to begin parsing, + # first start num_context_lines[0] lines back, then + # num_context_lines[1] lines back if that didn't work, and so on. + # The last value should be huge (larger than the # of lines in a + # conceivable file). + # Making the initial values larger slows things down more often. + self.num_context_lines = 50, 500, 5000000 + self.per = per = self.Percolator(text) + self.undo = undo = self.UndoDelegator() + per.insertfilter(undo) + text.undo_block_start = undo.undo_block_start + text.undo_block_stop = undo.undo_block_stop + undo.set_saved_change_hook(self.saved_change_hook) + # IOBinding implements file I/O and printing functionality + self.io = io = self.IOBinding(self) + io.set_filename_change_hook(self.filename_change_hook) + self.good_load = False + self.set_indentation_params(False) + self.color = None # initialized below in self.ResetColorizer + self.code_context = None # optionally initialized later below + self.line_numbers = None # optionally initialized later below + if filename: + if os.path.exists(filename) and not os.path.isdir(filename): + if io.loadfile(filename): + self.good_load = True + is_py_src = self.ispythonsource(filename) + self.set_indentation_params(is_py_src) + else: + io.set_filename(filename) + self.good_load = True + + self.ResetColorizer() + self.saved_change_hook() + self.update_recent_files_list() + self.load_extensions() + menu = self.menudict.get('window') + if menu: + end = menu.index("end") + if end is None: + end = -1 + if end >= 0: + menu.add_separator() + end = end + 1 + self.wmenu_end = end + window.register_callback(self.postwindowsmenu) + + # Some abstractions so IDLE extensions are cross-IDE + self.askinteger = simpledialog.askinteger + self.askyesno = messagebox.askyesno + self.showerror = messagebox.showerror + + # Add pseudoevents for former extension fixed keys. + # (This probably needs to be done once in the process.) + text.event_add('<>', '') + text.event_add('<>', '', + '', '') + text.event_add('<>', '') + text.event_add('<>', '') + text.event_add('<>', '', + '', '') + + # Former extension bindings depends on frame.text being packed + # (called from self.ResetColorizer()). + autocomplete = self.AutoComplete(self, self.user_input_insert_tags) + text.bind("<>", autocomplete.autocomplete_event) + text.bind("<>", + autocomplete.try_open_completions_event) + text.bind("<>", + autocomplete.force_open_completions_event) + text.bind("<>", self.AutoExpand(self).expand_word_event) + text.bind("<>", + self.FormatParagraph(self).format_paragraph_event) + parenmatch = self.ParenMatch(self) + text.bind("<>", parenmatch.flash_paren_event) + text.bind("<>", parenmatch.paren_closed_event) + scriptbinding = ScriptBinding(self) + text.bind("<>", scriptbinding.check_module_event) + text.bind("<>", scriptbinding.run_module_event) + text.bind("<>", scriptbinding.run_custom_event) + text.bind("<>", self.Rstrip(self).do_rstrip) + self.ctip = ctip = self.Calltip(self) + text.bind("<>", ctip.try_open_calltip_event) + #refresh-calltip must come after paren-closed to work right + text.bind("<>", ctip.refresh_calltip_event) + text.bind("<>", ctip.force_open_calltip_event) + text.bind("<>", self.ZoomHeight(self).zoom_height_event) + if self.allow_code_context: + self.code_context = self.CodeContext(self) + text.bind("<>", + self.code_context.toggle_code_context_event) + else: + self.update_menu_state('options', '*ode*ontext', 'disabled') + if self.allow_line_numbers: + self.line_numbers = self.LineNumbers(self) + if idleConf.GetOption('main', 'EditorWindow', + 'line-numbers-default', type='bool'): + self.toggle_line_numbers_event() + text.bind("<>", self.toggle_line_numbers_event) + else: + self.update_menu_state('options', '*ine*umbers', 'disabled') + + def handle_winconfig(self, event=None): + self.set_width() + + def set_width(self): + text = self.text + inner_padding = sum(map(text.tk.getint, [text.cget('border'), + text.cget('padx')])) + pixel_width = text.winfo_width() - 2 * inner_padding + + # Divide the width of the Text widget by the font width, + # which is taken to be the width of '0' (zero). + # http://www.tcl.tk/man/tcl8.6/TkCmd/text.htm#M21 + zero_char_width = \ + Font(text, font=text.cget('font')).measure('0') + self.width = pixel_width // zero_char_width + + def new_callback(self, event): + dirname, basename = self.io.defaultfilename() + self.flist.new(dirname) + return "break" + + def home_callback(self, event): + if (event.state & 4) != 0 and event.keysym == "Home": + # state&4==Control. If , use the Tk binding. + return None + if self.text.index("iomark") and \ + self.text.compare("iomark", "<=", "insert lineend") and \ + self.text.compare("insert linestart", "<=", "iomark"): + # In Shell on input line, go to just after prompt + insertpt = int(self.text.index("iomark").split(".")[1]) + else: + line = self.text.get("insert linestart", "insert lineend") + for insertpt in range(len(line)): + if line[insertpt] not in (' ','\t'): + break + else: + insertpt=len(line) + lineat = int(self.text.index("insert").split('.')[1]) + if insertpt == lineat: + insertpt = 0 + dest = "insert linestart+"+str(insertpt)+"c" + if (event.state&1) == 0: + # shift was not pressed + self.text.tag_remove("sel", "1.0", "end") + else: + if not self.text.index("sel.first"): + # there was no previous selection + self.text.mark_set("my_anchor", "insert") + else: + if self.text.compare(self.text.index("sel.first"), "<", + self.text.index("insert")): + self.text.mark_set("my_anchor", "sel.first") # extend back + else: + self.text.mark_set("my_anchor", "sel.last") # extend forward + first = self.text.index(dest) + last = self.text.index("my_anchor") + if self.text.compare(first,">",last): + first,last = last,first + self.text.tag_remove("sel", "1.0", "end") + self.text.tag_add("sel", first, last) + self.text.mark_set("insert", dest) + self.text.see("insert") + return "break" + + def set_status_bar(self): + self.status_bar = self.MultiStatusBar(self.top) + sep = Frame(self.top, height=1, borderwidth=1, background='grey75') + if sys.platform == "darwin": + # Insert some padding to avoid obscuring some of the statusbar + # by the resize widget. + self.status_bar.set_label('_padding1', ' ', side=RIGHT) + self.status_bar.set_label('column', 'Col: ?', side=RIGHT) + self.status_bar.set_label('line', 'Ln: ?', side=RIGHT) + self.status_bar.pack(side=BOTTOM, fill=X) + sep.pack(side=BOTTOM, fill=X) + self.text.bind("<>", self.set_line_and_column) + self.text.event_add("<>", + "", "") + self.text.after_idle(self.set_line_and_column) + + def set_line_and_column(self, event=None): + line, column = self.text.index(INSERT).split('.') + self.status_bar.set_label('column', 'Col: %s' % column) + self.status_bar.set_label('line', 'Ln: %s' % line) + + + """ Menu definitions and functions. + * self.menubar - the always visible horizontal menu bar. + * mainmenu.menudefs - a list of tuples, one for each menubar item. + Each tuple pairs a lower-case name and list of dropdown items. + Each item is a name, virtual event pair or None for separator. + * mainmenu.default_keydefs - maps events to keys. + * text.keydefs - same. + * cls.menu_specs - menubar name, titlecase display form pairs + with Alt-hotkey indicator. A subset of menudefs items. + * self.menudict - map menu name to dropdown menu. + * self.recent_files_menu - 2nd level cascade in the file cascade. + * self.wmenu_end - set in __init__ (purpose unclear). + + createmenubar, postwindowsmenu, update_menu_label, update_menu_state, + ApplyKeybings (2nd part), reset_help_menu_entries, + _extra_help_callback, update_recent_files_list, + apply_bindings, fill_menus, (other functions?) + """ + + menu_specs = [ + ("file", "_File"), + ("edit", "_Edit"), + ("format", "F_ormat"), + ("run", "_Run"), + ("options", "_Options"), + ("window", "_Window"), + ("help", "_Help"), + ] + + def createmenubar(self): + """Populate the menu bar widget for the editor window. + + Each option on the menubar is itself a cascade-type Menu widget + with the menubar as the parent. The names, labels, and menu + shortcuts for the menubar items are stored in menu_specs. Each + submenu is subsequently populated in fill_menus(), except for + 'Recent Files' which is added to the File menu here. + + Instance variables: + menubar: Menu widget containing first level menu items. + menudict: Dictionary of {menuname: Menu instance} items. The keys + represent the valid menu items for this window and may be a + subset of all the menudefs available. + recent_files_menu: Menu widget contained within the 'file' menudict. + """ + mbar = self.menubar + self.menudict = menudict = {} + for name, label in self.menu_specs: + underline, label = prepstr(label) + postcommand = getattr(self, f'{name}_menu_postcommand', None) + menudict[name] = menu = Menu(mbar, name=name, tearoff=0, + postcommand=postcommand) + mbar.add_cascade(label=label, menu=menu, underline=underline) + if macosx.isCarbonTk(): + # Insert the application menu + menudict['application'] = menu = Menu(mbar, name='apple', + tearoff=0) + mbar.add_cascade(label='IDLE', menu=menu) + self.fill_menus() + self.recent_files_menu = Menu(self.menubar, tearoff=0) + self.menudict['file'].insert_cascade(3, label='Recent Files', + underline=0, + menu=self.recent_files_menu) + self.base_helpmenu_length = self.menudict['help'].index(END) + self.reset_help_menu_entries() + + def postwindowsmenu(self): + """Callback to register window. + + Only called when Window menu exists. + """ + menu = self.menudict['window'] + end = menu.index("end") + if end is None: + end = -1 + if end > self.wmenu_end: + menu.delete(self.wmenu_end+1, end) + window.add_windows_to_menu(menu) + + def update_menu_label(self, menu, index, label): + "Update label for menu item at index." + menuitem = self.menudict[menu] + menuitem.entryconfig(index, label=label) + + def update_menu_state(self, menu, index, state): + "Update state for menu item at index." + menuitem = self.menudict[menu] + menuitem.entryconfig(index, state=state) + + def handle_yview(self, event, *args): + "Handle scrollbar." + if event == 'moveto': + fraction = float(args[0]) + lines = (round(self.getlineno('end') * fraction) - + self.getlineno('@0,0')) + event = 'scroll' + args = (lines, 'units') + self.text.yview(event, *args) + return 'break' + + rmenu = None + + def right_menu_event(self, event): + text = self.text + newdex = text.index(f'@{event.x},{event.y}') + try: + in_selection = (text.compare('sel.first', '<=', newdex) and + text.compare(newdex, '<=', 'sel.last')) + except TclError: + in_selection = False + if not in_selection: + text.tag_remove("sel", "1.0", "end") + text.mark_set("insert", newdex) + if not self.rmenu: + self.make_rmenu() + rmenu = self.rmenu + self.event = event + iswin = sys.platform[:3] == 'win' + if iswin: + text.config(cursor="arrow") + + for item in self.rmenu_specs: + try: + label, eventname, verify_state = item + except ValueError: # see issue1207589 + continue + + if verify_state is None: + continue + state = getattr(self, verify_state)() + rmenu.entryconfigure(label, state=state) + + rmenu.tk_popup(event.x_root, event.y_root) + if iswin: + self.text.config(cursor="ibeam") + return "break" + + rmenu_specs = [ + # ("Label", "<>", "statefuncname"), ... + ("Close", "<>", None), # Example + ] + + def make_rmenu(self): + rmenu = Menu(self.text, tearoff=0) + for item in self.rmenu_specs: + label, eventname = item[0], item[1] + if label is not None: + def command(text=self.text, eventname=eventname): + text.event_generate(eventname) + rmenu.add_command(label=label, command=command) + else: + rmenu.add_separator() + self.rmenu = rmenu + + def rmenu_check_cut(self): + return self.rmenu_check_copy() + + def rmenu_check_copy(self): + try: + indx = self.text.index('sel.first') + except TclError: + return 'disabled' + else: + return 'normal' if indx else 'disabled' + + def rmenu_check_paste(self): + try: + self.text.tk.call('tk::GetSelection', self.text, 'CLIPBOARD') + except TclError: + return 'disabled' + else: + return 'normal' + + def about_dialog(self, event=None): + "Handle Help 'About IDLE' event." + # Synchronize with macosx.overrideRootMenu.about_dialog. + help_about.AboutDialog(self.top) + return "break" + + def config_dialog(self, event=None): + "Handle Options 'Configure IDLE' event." + # Synchronize with macosx.overrideRootMenu.config_dialog. + configdialog.ConfigDialog(self.top,'Settings') + return "break" + + def help_dialog(self, event=None): + "Handle Help 'IDLE Help' event." + # Synchronize with macosx.overrideRootMenu.help_dialog. + if self.root: + parent = self.root + else: + parent = self.top + help.show_idlehelp(parent) + return "break" + + def python_docs(self, event=None): + if sys.platform[:3] == 'win': + try: + os.startfile(self.help_url) + except OSError as why: + messagebox.showerror(title='Document Start Failure', + message=str(why), parent=self.text) + else: + webbrowser.open(self.help_url) + return "break" + + def cut(self,event): + self.text.event_generate("<>") + return "break" + + def copy(self,event): + if not self.text.tag_ranges("sel"): + # There is no selection, so do nothing and maybe interrupt. + return None + self.text.event_generate("<>") + return "break" + + def paste(self,event): + self.text.event_generate("<>") + self.text.see("insert") + return "break" + + def select_all(self, event=None): + self.text.tag_add("sel", "1.0", "end-1c") + self.text.mark_set("insert", "1.0") + self.text.see("insert") + return "break" + + def remove_selection(self, event=None): + self.text.tag_remove("sel", "1.0", "end") + self.text.see("insert") + return "break" + + def move_at_edge_if_selection(self, edge_index): + """Cursor move begins at start or end of selection + + When a left/right cursor key is pressed create and return to Tkinter a + function which causes a cursor move from the associated edge of the + selection. + + """ + self_text_index = self.text.index + self_text_mark_set = self.text.mark_set + edges_table = ("sel.first+1c", "sel.last-1c") + def move_at_edge(event): + if (event.state & 5) == 0: # no shift(==1) or control(==4) pressed + try: + self_text_index("sel.first") + self_text_mark_set("insert", edges_table[edge_index]) + except TclError: + pass + return move_at_edge + + def del_word_left(self, event): + self.text.event_generate('') + return "break" + + def del_word_right(self, event): + self.text.event_generate('') + return "break" + + def find_event(self, event): + search.find(self.text) + return "break" + + def find_again_event(self, event): + search.find_again(self.text) + return "break" + + def find_selection_event(self, event): + search.find_selection(self.text) + return "break" + + def find_in_files_event(self, event): + grep.grep(self.text, self.io, self.flist) + return "break" + + def replace_event(self, event): + replace.replace(self.text) + return "break" + + def goto_line_event(self, event): + text = self.text + lineno = query.Goto( + text, "Go To Line", + "Enter a positive integer\n" + "('big' = end of file):" + ).result + if lineno is not None: + text.tag_remove("sel", "1.0", "end") + text.mark_set("insert", f'{lineno}.0') + text.see("insert") + self.set_line_and_column() + return "break" + + def open_module(self): + """Get module name from user and open it. + + Return module path or None for calls by open_module_browser + when latter is not invoked in named editor window. + """ + # XXX This, open_module_browser, and open_path_browser + # would fit better in iomenu.IOBinding. + try: + name = self.text.get("sel.first", "sel.last").strip() + except TclError: + name = '' + file_path = query.ModuleName( + self.text, "Open Module", + "Enter the name of a Python module\n" + "to search on sys.path and open:", + name).result + if file_path is not None: + if self.flist: + self.flist.open(file_path) + else: + self.io.loadfile(file_path) + return file_path + + def open_module_event(self, event): + self.open_module() + return "break" + + def open_module_browser(self, event=None): + filename = self.io.filename + if not (self.__class__.__name__ == 'PyShellEditorWindow' + and filename): + filename = self.open_module() + if filename is None: + return "break" + from idlelib import browser + browser.ModuleBrowser(self.root, filename) + return "break" + + def open_path_browser(self, event=None): + from idlelib import pathbrowser + pathbrowser.PathBrowser(self.root) + return "break" + + def open_turtle_demo(self, event = None): + import subprocess + + cmd = [sys.executable, + '-c', + 'from turtledemo.__main__ import main; main()'] + subprocess.Popen(cmd, shell=False) + return "break" + + def gotoline(self, lineno): + if lineno is not None and lineno > 0: + self.text.mark_set("insert", "%d.0" % lineno) + self.text.tag_remove("sel", "1.0", "end") + self.text.tag_add("sel", "insert", "insert +1l") + self.center() + + def ispythonsource(self, filename): + if not filename or os.path.isdir(filename): + return True + base, ext = os.path.splitext(os.path.basename(filename)) + if os.path.normcase(ext) in py_extensions: + return True + line = self.text.get('1.0', '1.0 lineend') + return line.startswith('#!') and 'python' in line + + def close_hook(self): + if self.flist: + self.flist.unregister_maybe_terminate(self) + self.flist = None + + def set_close_hook(self, close_hook): + self.close_hook = close_hook + + def filename_change_hook(self): + if self.flist: + self.flist.filename_changed_edit(self) + self.saved_change_hook() + self.top.update_windowlist_registry(self) + self.ResetColorizer() + + def _addcolorizer(self): + if self.color: + return + if self.ispythonsource(self.io.filename): + self.color = self.ColorDelegator() + # can add more colorizers here... + if self.color: + self.per.insertfilterafter(filter=self.color, after=self.undo) + + def _rmcolorizer(self): + if not self.color: + return + self.color.removecolors() + self.per.removefilter(self.color) + self.color = None + + def ResetColorizer(self): + "Update the color theme" + # Called from self.filename_change_hook and from configdialog.py + self._rmcolorizer() + self._addcolorizer() + EditorWindow.color_config(self.text) + + if self.code_context is not None: + self.code_context.update_highlight_colors() + + if self.line_numbers is not None: + self.line_numbers.update_colors() + + IDENTCHARS = string.ascii_letters + string.digits + "_" + + def colorize_syntax_error(self, text, pos): + text.tag_add("ERROR", pos) + char = text.get(pos) + if char and char in self.IDENTCHARS: + text.tag_add("ERROR", pos + " wordstart", pos) + if '\n' == text.get(pos): # error at line end + text.mark_set("insert", pos) + else: + text.mark_set("insert", pos + "+1c") + text.see(pos) + + def update_cursor_blink(self): + "Update the cursor blink configuration." + cursorblink = idleConf.GetOption( + 'main', 'EditorWindow', 'cursor-blink', type='bool') + if not cursorblink: + self.text['insertofftime'] = 0 + else: + # Restore the original value + self.text['insertofftime'] = idleConf.blink_off_time + + def ResetFont(self): + "Update the text widgets' font if it is changed" + # Called from configdialog.py + + # Update the code context widget first, since its height affects + # the height of the text widget. This avoids double re-rendering. + if self.code_context is not None: + self.code_context.update_font() + # Next, update the line numbers widget, since its width affects + # the width of the text widget. + if self.line_numbers is not None: + self.line_numbers.update_font() + # Finally, update the main text widget. + new_font = idleConf.GetFont(self.root, 'main', 'EditorWindow') + self.text['font'] = new_font + self.set_width() + + def RemoveKeybindings(self): + """Remove the virtual, configurable keybindings. + + Leaves the default Tk Text keybindings. + """ + # Called from configdialog.deactivate_current_config. + self.mainmenu.default_keydefs = keydefs = idleConf.GetCurrentKeySet() + for event, keylist in keydefs.items(): + self.text.event_delete(event, *keylist) + for extensionName in self.get_standard_extension_names(): + xkeydefs = idleConf.GetExtensionBindings(extensionName) + if xkeydefs: + for event, keylist in xkeydefs.items(): + self.text.event_delete(event, *keylist) + + def ApplyKeybindings(self): + """Apply the virtual, configurable keybindings. + + Also update hotkeys to current keyset. + """ + # Called from configdialog.activate_config_changes. + self.mainmenu.default_keydefs = keydefs = idleConf.GetCurrentKeySet() + self.apply_bindings() + for extensionName in self.get_standard_extension_names(): + xkeydefs = idleConf.GetExtensionBindings(extensionName) + if xkeydefs: + self.apply_bindings(xkeydefs) + + # Update menu accelerators. + menuEventDict = {} + for menu in self.mainmenu.menudefs: + menuEventDict[menu[0]] = {} + for item in menu[1]: + if item: + menuEventDict[menu[0]][prepstr(item[0])[1]] = item[1] + for menubarItem in self.menudict: + menu = self.menudict[menubarItem] + end = menu.index(END) + if end is None: + # Skip empty menus + continue + end += 1 + for index in range(0, end): + if menu.type(index) == 'command': + accel = menu.entrycget(index, 'accelerator') + if accel: + itemName = menu.entrycget(index, 'label') + event = '' + if menubarItem in menuEventDict: + if itemName in menuEventDict[menubarItem]: + event = menuEventDict[menubarItem][itemName] + if event: + accel = get_accelerator(keydefs, event) + menu.entryconfig(index, accelerator=accel) + + def set_notabs_indentwidth(self): + "Update the indentwidth if changed and not using tabs in this window" + # Called from configdialog.py + if not self.usetabs: + self.indentwidth = idleConf.GetOption('main', 'Indent','num-spaces', + type='int') + + def reset_help_menu_entries(self): + """Update the additional help entries on the Help menu.""" + help_list = idleConf.GetAllExtraHelpSourcesList() + helpmenu = self.menudict['help'] + # First delete the extra help entries, if any. + helpmenu_length = helpmenu.index(END) + if helpmenu_length > self.base_helpmenu_length: + helpmenu.delete((self.base_helpmenu_length + 1), helpmenu_length) + # Then rebuild them. + if help_list: + helpmenu.add_separator() + for entry in help_list: + cmd = self._extra_help_callback(entry[1]) + helpmenu.add_command(label=entry[0], command=cmd) + # And update the menu dictionary. + self.menudict['help'] = helpmenu + + def _extra_help_callback(self, resource): + """Return a callback that loads resource (file or web page).""" + def display_extra_help(helpfile=resource): + if not helpfile.startswith(('www', 'http')): + helpfile = os.path.normpath(helpfile) + if sys.platform[:3] == 'win': + try: + os.startfile(helpfile) + except OSError as why: + messagebox.showerror(title='Document Start Failure', + message=str(why), parent=self.text) + else: + webbrowser.open(helpfile) + return display_extra_help + + def update_recent_files_list(self, new_file=None): + "Load and update the recent files list and menus" + # TODO: move to iomenu. + rf_list = [] + file_path = self.recent_files_path + if file_path and os.path.exists(file_path): + with open(file_path, + encoding='utf_8', errors='replace') as rf_list_file: + rf_list = rf_list_file.readlines() + if new_file: + new_file = os.path.abspath(new_file) + '\n' + if new_file in rf_list: + rf_list.remove(new_file) # move to top + rf_list.insert(0, new_file) + # clean and save the recent files list + bad_paths = [] + for path in rf_list: + if '\0' in path or not os.path.exists(path[0:-1]): + bad_paths.append(path) + rf_list = [path for path in rf_list if path not in bad_paths] + ulchars = "1234567890ABCDEFGHIJK" + rf_list = rf_list[0:len(ulchars)] + if file_path: + try: + with open(file_path, 'w', + encoding='utf_8', errors='replace') as rf_file: + rf_file.writelines(rf_list) + except OSError as err: + if not getattr(self.root, "recentfiles_message", False): + self.root.recentfiles_message = True + messagebox.showwarning(title='IDLE Warning', + message="Cannot save Recent Files list to disk.\n" + f" {err}\n" + "Select OK to continue.", + parent=self.text) + # for each edit window instance, construct the recent files menu + for instance in self.top.instance_dict: + menu = instance.recent_files_menu + menu.delete(0, END) # clear, and rebuild: + for i, file_name in enumerate(rf_list): + file_name = file_name.rstrip() # zap \n + callback = instance.__recent_file_callback(file_name) + menu.add_command(label=ulchars[i] + " " + file_name, + command=callback, + underline=0) + + def __recent_file_callback(self, file_name): + def open_recent_file(fn_closure=file_name): + self.io.open(editFile=fn_closure) + return open_recent_file + + def saved_change_hook(self): + short = self.short_title() + long = self.long_title() + _py_version = ' (%s)' % platform.python_version() + if short and long and not macosx.isCocoaTk(): + # Don't use both values on macOS because + # that doesn't match platform conventions. + title = short + " - " + long + _py_version + elif short: + if short == "IDLE Shell": + title = short + " " + platform.python_version() + else: + title = short + _py_version + elif long: + title = long + else: + title = "untitled" + icon = short or long or title + if not self.get_saved(): + title = "*%s*" % title + icon = "*%s" % icon + self.top.wm_title(title) + self.top.wm_iconname(icon) + + if macosx.isCocoaTk(): + # Add a proxy icon to the window title + self.top.wm_attributes("-titlepath", long) + + # Maintain the modification status for the window + self.top.wm_attributes("-modified", not self.get_saved()) + + def get_saved(self): + return self.undo.get_saved() + + def set_saved(self, flag): + self.undo.set_saved(flag) + + def reset_undo(self): + self.undo.reset_undo() + + def short_title(self): + filename = self.io.filename + return os.path.basename(filename) if filename else "untitled" + + def long_title(self): + return self.io.filename or "" + + def center_insert_event(self, event): + self.center() + return "break" + + def center(self, mark="insert"): + text = self.text + top, bot = self.getwindowlines() + lineno = self.getlineno(mark) + height = bot - top + newtop = max(1, lineno - height//2) + text.yview(float(newtop)) + + def getwindowlines(self): + text = self.text + top = self.getlineno("@0,0") + bot = self.getlineno("@0,65535") + if top == bot and text.winfo_height() == 1: + # Geometry manager hasn't run yet + height = int(text['height']) + bot = top + height - 1 + return top, bot + + def getlineno(self, mark="insert"): + text = self.text + return int(float(text.index(mark))) + + def get_geometry(self): + "Return (width, height, x, y)" + geom = self.top.wm_geometry() + m = re.match(r"(\d+)x(\d+)\+(-?\d+)\+(-?\d+)", geom) + return list(map(int, m.groups())) + + def close_event(self, event): + self.close() + return "break" + + def maybesave(self): + if self.io: + if not self.get_saved(): + if self.top.state()!='normal': + self.top.deiconify() + self.top.lower() + self.top.lift() + return self.io.maybesave() + + def close(self): + try: + reply = self.maybesave() + if str(reply) != "cancel": + self._close() + return reply + except AttributeError: # bpo-35379: close called twice + pass + + def _close(self): + if self.io.filename: + self.update_recent_files_list(new_file=self.io.filename) + window.unregister_callback(self.postwindowsmenu) + self.unload_extensions() + self.io.close() + self.io = None + self.undo = None + if self.color: + self.color.close() + self.color = None + self.text = None + self.tkinter_vars = None + self.per.close() + self.per = None + self.top.destroy() + if self.close_hook: + # unless override: unregister from flist, terminate if last window + self.close_hook() + + def load_extensions(self): + self.extensions = {} + self.load_standard_extensions() + + def unload_extensions(self): + for ins in list(self.extensions.values()): + if hasattr(ins, "close"): + ins.close() + self.extensions = {} + + def load_standard_extensions(self): + for name in self.get_standard_extension_names(): + try: + self.load_extension(name) + except: + print("Failed to load extension", repr(name)) + traceback.print_exc() + + def get_standard_extension_names(self): + return idleConf.GetExtensions(editor_only=True) + + extfiles = { # Map built-in config-extension section names to file names. + 'ZzDummy': 'zzdummy', + } + + def load_extension(self, name): + fname = self.extfiles.get(name, name) + try: + try: + mod = importlib.import_module('.' + fname, package=__package__) + except (ImportError, TypeError): + mod = importlib.import_module(fname) + except ImportError: + print("\nFailed to import extension: ", name) + raise + cls = getattr(mod, name) + keydefs = idleConf.GetExtensionBindings(name) + if hasattr(cls, "menudefs"): + self.fill_menus(cls.menudefs, keydefs) + ins = cls(self) + self.extensions[name] = ins + if keydefs: + self.apply_bindings(keydefs) + for vevent in keydefs: + methodname = vevent.replace("-", "_") + while methodname[:1] == '<': + methodname = methodname[1:] + while methodname[-1:] == '>': + methodname = methodname[:-1] + methodname = methodname + "_event" + if hasattr(ins, methodname): + self.text.bind(vevent, getattr(ins, methodname)) + + def apply_bindings(self, keydefs=None): + """Add events with keys to self.text.""" + if keydefs is None: + keydefs = self.mainmenu.default_keydefs + text = self.text + text.keydefs = keydefs + for event, keylist in keydefs.items(): + if keylist: + text.event_add(event, *keylist) + + def fill_menus(self, menudefs=None, keydefs=None): + """Fill in dropdown menus used by this window. + + Items whose name begins with '!' become checkbuttons. + Other names indicate commands. None becomes a separator. + """ + if menudefs is None: + menudefs = self.mainmenu.menudefs + if keydefs is None: + keydefs = self.mainmenu.default_keydefs + menudict = self.menudict + text = self.text + for mname, entrylist in menudefs: + menu = menudict.get(mname) + if not menu: + continue + for entry in entrylist: + if entry is None: + menu.add_separator() + else: + label, eventname = entry + checkbutton = (label[:1] == '!') + if checkbutton: + label = label[1:] + underline, label = prepstr(label) + accelerator = get_accelerator(keydefs, eventname) + def command(text=text, eventname=eventname): + text.event_generate(eventname) + if checkbutton: + var = self.get_var_obj(eventname, BooleanVar) + menu.add_checkbutton(label=label, underline=underline, + command=command, accelerator=accelerator, + variable=var) + else: + menu.add_command(label=label, underline=underline, + command=command, + accelerator=accelerator) + + def getvar(self, name): + var = self.get_var_obj(name) + if var: + value = var.get() + return value + else: + raise NameError(name) + + def setvar(self, name, value, vartype=None): + var = self.get_var_obj(name, vartype) + if var: + var.set(value) + else: + raise NameError(name) + + def get_var_obj(self, eventname, vartype=None): + """Return a tkinter variable instance for the event. + """ + var = self.tkinter_vars.get(eventname) + if not var and vartype: + # Create a Tkinter variable object. + self.tkinter_vars[eventname] = var = vartype(self.text) + return var + + # Tk implementations of "virtual text methods" -- each platform + # reusing IDLE's support code needs to define these for its GUI's + # flavor of widget. + + # Is character at text_index in a Python string? Return 0 for + # "guaranteed no", true for anything else. This info is expensive + # to compute ab initio, but is probably already known by the + # platform's colorizer. + + def is_char_in_string(self, text_index): + if self.color: + # Return true iff colorizer hasn't (re)gotten this far + # yet, or the character is tagged as being in a string + return self.text.tag_prevrange("TODO", text_index) or \ + "STRING" in self.text.tag_names(text_index) + else: + # The colorizer is missing: assume the worst + return 1 + + # If a selection is defined in the text widget, return (start, + # end) as Tkinter text indices, otherwise return (None, None) + def get_selection_indices(self): + try: + first = self.text.index("sel.first") + last = self.text.index("sel.last") + return first, last + except TclError: + return None, None + + # Return the text widget's current view of what a tab stop means + # (equivalent width in spaces). + + def get_tk_tabwidth(self): + current = self.text['tabs'] or TK_TABWIDTH_DEFAULT + return int(current) + + # Set the text widget's current view of what a tab stop means. + + def set_tk_tabwidth(self, newtabwidth): + text = self.text + if self.get_tk_tabwidth() != newtabwidth: + # Set text widget tab width + pixels = text.tk.call("font", "measure", text["font"], + "-displayof", text.master, + "n" * newtabwidth) + text.configure(tabs=pixels) + +### begin autoindent code ### (configuration was moved to beginning of class) + + def set_indentation_params(self, is_py_src, guess=True): + if is_py_src and guess: + i = self.guess_indent() + if 2 <= i <= 8: + self.indentwidth = i + if self.indentwidth != self.tabwidth: + self.usetabs = False + self.set_tk_tabwidth(self.tabwidth) + + def smart_backspace_event(self, event): + text = self.text + first, last = self.get_selection_indices() + if first and last: + text.delete(first, last) + text.mark_set("insert", first) + return "break" + # Delete whitespace left, until hitting a real char or closest + # preceding virtual tab stop. + chars = text.get("insert linestart", "insert") + if chars == '': + if text.compare("insert", ">", "1.0"): + # easy: delete preceding newline + text.delete("insert-1c") + else: + text.bell() # at start of buffer + return "break" + if chars[-1] not in " \t": + # easy: delete preceding real char + text.delete("insert-1c") + return "break" + # Ick. It may require *inserting* spaces if we back up over a + # tab character! This is written to be clear, not fast. + tabwidth = self.tabwidth + have = len(chars.expandtabs(tabwidth)) + assert have > 0 + want = ((have - 1) // self.indentwidth) * self.indentwidth + # Debug prompt is multilined.... + ncharsdeleted = 0 + while True: + chars = chars[:-1] + ncharsdeleted = ncharsdeleted + 1 + have = len(chars.expandtabs(tabwidth)) + if have <= want or chars[-1] not in " \t": + break + text.undo_block_start() + text.delete("insert-%dc" % ncharsdeleted, "insert") + if have < want: + text.insert("insert", ' ' * (want - have), + self.user_input_insert_tags) + text.undo_block_stop() + return "break" + + def smart_indent_event(self, event): + # if intraline selection: + # delete it + # elif multiline selection: + # do indent-region + # else: + # indent one level + text = self.text + first, last = self.get_selection_indices() + text.undo_block_start() + try: + if first and last: + if index2line(first) != index2line(last): + return self.fregion.indent_region_event(event) + text.delete(first, last) + text.mark_set("insert", first) + prefix = text.get("insert linestart", "insert") + raw, effective = get_line_indent(prefix, self.tabwidth) + if raw == len(prefix): + # only whitespace to the left + self.reindent_to(effective + self.indentwidth) + else: + # tab to the next 'stop' within or to right of line's text: + if self.usetabs: + pad = '\t' + else: + effective = len(prefix.expandtabs(self.tabwidth)) + n = self.indentwidth + pad = ' ' * (n - effective % n) + text.insert("insert", pad, self.user_input_insert_tags) + text.see("insert") + return "break" + finally: + text.undo_block_stop() + + def newline_and_indent_event(self, event): + """Insert a newline and indentation after Enter keypress event. + + Properly position the cursor on the new line based on information + from the current line. This takes into account if the current line + is a shell prompt, is empty, has selected text, contains a block + opener, contains a block closer, is a continuation line, or + is inside a string. + """ + text = self.text + first, last = self.get_selection_indices() + text.undo_block_start() + try: # Close undo block and expose new line in finally clause. + if first and last: + text.delete(first, last) + text.mark_set("insert", first) + line = text.get("insert linestart", "insert") + + # Count leading whitespace for indent size. + i, n = 0, len(line) + while i < n and line[i] in " \t": + i += 1 + if i == n: + # The cursor is in or at leading indentation in a continuation + # line; just inject an empty line at the start. + text.insert("insert linestart", '\n', + self.user_input_insert_tags) + return "break" + indent = line[:i] + + # Strip whitespace before insert point unless it's in the prompt. + i = 0 + while line and line[-1] in " \t": + line = line[:-1] + i += 1 + if i: + text.delete("insert - %d chars" % i, "insert") + + # Strip whitespace after insert point. + while text.get("insert") in " \t": + text.delete("insert") + + # Insert new line. + text.insert("insert", '\n', self.user_input_insert_tags) + + # Adjust indentation for continuations and block open/close. + # First need to find the last statement. + lno = index2line(text.index('insert')) + y = pyparse.Parser(self.indentwidth, self.tabwidth) + if not self.prompt_last_line: + for context in self.num_context_lines: + startat = max(lno - context, 1) + startatindex = repr(startat) + ".0" + rawtext = text.get(startatindex, "insert") + y.set_code(rawtext) + bod = y.find_good_parse_start( + self._build_char_in_string_func(startatindex)) + if bod is not None or startat == 1: + break + y.set_lo(bod or 0) + else: + r = text.tag_prevrange("console", "insert") + if r: + startatindex = r[1] + else: + startatindex = "1.0" + rawtext = text.get(startatindex, "insert") + y.set_code(rawtext) + y.set_lo(0) + + c = y.get_continuation_type() + if c != pyparse.C_NONE: + # The current statement hasn't ended yet. + if c == pyparse.C_STRING_FIRST_LINE: + # After the first line of a string do not indent at all. + pass + elif c == pyparse.C_STRING_NEXT_LINES: + # Inside a string which started before this line; + # just mimic the current indent. + text.insert("insert", indent, self.user_input_insert_tags) + elif c == pyparse.C_BRACKET: + # Line up with the first (if any) element of the + # last open bracket structure; else indent one + # level beyond the indent of the line with the + # last open bracket. + self.reindent_to(y.compute_bracket_indent()) + elif c == pyparse.C_BACKSLASH: + # If more than one line in this statement already, just + # mimic the current indent; else if initial line + # has a start on an assignment stmt, indent to + # beyond leftmost =; else to beyond first chunk of + # non-whitespace on initial line. + if y.get_num_lines_in_stmt() > 1: + text.insert("insert", indent, + self.user_input_insert_tags) + else: + self.reindent_to(y.compute_backslash_indent()) + else: + assert 0, f"bogus continuation type {c!r}" + return "break" + + # This line starts a brand new statement; indent relative to + # indentation of initial line of closest preceding + # interesting statement. + indent = y.get_base_indent_string() + text.insert("insert", indent, self.user_input_insert_tags) + if y.is_block_opener(): + self.smart_indent_event(event) + elif indent and y.is_block_closer(): + self.smart_backspace_event(event) + return "break" + finally: + text.see("insert") + text.undo_block_stop() + + # Our editwin provides an is_char_in_string function that works + # with a Tk text index, but PyParse only knows about offsets into + # a string. This builds a function for PyParse that accepts an + # offset. + + def _build_char_in_string_func(self, startindex): + def inner(offset, _startindex=startindex, + _icis=self.is_char_in_string): + return _icis(_startindex + "+%dc" % offset) + return inner + + # XXX this isn't bound to anything -- see tabwidth comments +## def change_tabwidth_event(self, event): +## new = self._asktabwidth() +## if new != self.tabwidth: +## self.tabwidth = new +## self.set_indentation_params(0, guess=0) +## return "break" + + # Make string that displays as n leading blanks. + + def _make_blanks(self, n): + if self.usetabs: + ntabs, nspaces = divmod(n, self.tabwidth) + return '\t' * ntabs + ' ' * nspaces + else: + return ' ' * n + + # Delete from beginning of line to insert point, then reinsert + # column logical (meaning use tabs if appropriate) spaces. + + def reindent_to(self, column): + text = self.text + text.undo_block_start() + if text.compare("insert linestart", "!=", "insert"): + text.delete("insert linestart", "insert") + if column: + text.insert("insert", self._make_blanks(column), + self.user_input_insert_tags) + text.undo_block_stop() + + # Guess indentwidth from text content. + # Return guessed indentwidth. This should not be believed unless + # it's in a reasonable range (e.g., it will be 0 if no indented + # blocks are found). + + def guess_indent(self): + opener, indented = IndentSearcher(self.text).run() + if opener and indented: + raw, indentsmall = get_line_indent(opener, self.tabwidth) + raw, indentlarge = get_line_indent(indented, self.tabwidth) + else: + indentsmall = indentlarge = 0 + return indentlarge - indentsmall + + def toggle_line_numbers_event(self, event=None): + if self.line_numbers is None: + return + + if self.line_numbers.is_shown: + self.line_numbers.hide_sidebar() + menu_label = "Show" + else: + self.line_numbers.show_sidebar() + menu_label = "Hide" + self.update_menu_label(menu='options', index='*ine*umbers', + label=f'{menu_label} Line Numbers') + +# "line.col" -> line, as an int +def index2line(index): + return int(float(index)) + + +_line_indent_re = re.compile(r'[ \t]*') +def get_line_indent(line, tabwidth): + """Return a line's indentation as (# chars, effective # of spaces). + + The effective # of spaces is the length after properly "expanding" + the tabs into spaces, as done by str.expandtabs(tabwidth). + """ + m = _line_indent_re.match(line) + return m.end(), len(m.group().expandtabs(tabwidth)) + + +class IndentSearcher: + "Manage initial indent guess, returned by run method." + + def __init__(self, text): + self.text = text + self.i = self.finished = 0 + self.blkopenline = self.indentedline = None + + def readline(self): + if self.finished: + return "" + i = self.i = self.i + 1 + mark = repr(i) + ".0" + if self.text.compare(mark, ">=", "end"): + return "" + return self.text.get(mark, mark + " lineend+1c") + + def tokeneater(self, type, token, start, end, line, + INDENT=tokenize.INDENT, + NAME=tokenize.NAME, + OPENERS=('class', 'def', 'for', 'if', 'match', 'try', + 'while', 'with')): + if self.finished: + pass + elif type == NAME and token in OPENERS: + self.blkopenline = line + elif type == INDENT and self.blkopenline: + self.indentedline = line + self.finished = 1 + + def run(self): + """Return 2 lines containing block opener and indent. + + Either the indent line or both may be None. + """ + try: + tokens = tokenize.generate_tokens(self.readline) + for token in tokens: + self.tokeneater(*token) + except (tokenize.TokenError, SyntaxError): + # Stopping the tokenizer early can trigger spurious errors. + pass + return self.blkopenline, self.indentedline + +### end autoindent code ### + + +def prepstr(s): + """Extract the underscore from a string. + + For example, prepstr("Co_py") returns (2, "Copy"). + + Args: + s: String with underscore. + + Returns: + Tuple of (position of underscore, string without underscore). + """ + i = s.find('_') + if i >= 0: + s = s[:i] + s[i+1:] + return i, s + + +keynames = { + 'bracketleft': '[', + 'bracketright': ']', + 'slash': '/', +} + +def get_accelerator(keydefs, eventname): + """Return a formatted string for the keybinding of an event. + + Convert the first keybinding for a given event to a form that + can be displayed as an accelerator on the menu. + + Args: + keydefs: Dictionary of valid events to keybindings. + eventname: Event to retrieve keybinding for. + + Returns: + Formatted string of the keybinding. + """ + keylist = keydefs.get(eventname) + # issue10940: temporary workaround to prevent hang with OS X Cocoa Tk 8.5 + # if not keylist: + if (not keylist) or (macosx.isCocoaTk() and eventname in { + "<>", + "<>", + "<>"}): + return "" + s = keylist[0] + # Convert strings of the form -singlelowercase to -singleuppercase. + s = re.sub(r"-[a-z]\b", lambda m: m.group().upper(), s) + # Convert certain keynames to their symbol. + s = re.sub(r"\b\w+\b", lambda m: keynames.get(m.group(), m.group()), s) + # Remove Key- from string. + s = re.sub("Key-", "", s) + # Convert Cancel to Ctrl-Break. + s = re.sub("Cancel", "Ctrl-Break", s) # dscherer@cmu.edu + # Convert Control to Ctrl-. + s = re.sub("Control-", "Ctrl-", s) + # Change - to +. + s = re.sub("-", "+", s) + # Change >< to space. + s = re.sub("><", " ", s) + # Remove <. + s = re.sub("<", "", s) + # Remove >. + s = re.sub(">", "", s) + return s + + +def fixwordbreaks(root): + # On Windows, tcl/tk breaks 'words' only on spaces, as in Command Prompt. + # We want Motif style everywhere. See #21474, msg218992 and followup. + tk = root.tk + tk.call('tcl_wordBreakAfter', 'a b', 0) # make sure word.tcl is loaded + tk.call('set', 'tcl_wordchars', r'\w') + tk.call('set', 'tcl_nonwordchars', r'\W') + + +def _editor_window(parent): # htest # + # error if close master window first - timer event, after script + root = parent + fixwordbreaks(root) + if sys.argv[1:]: + filename = sys.argv[1] + else: + filename = None + macosx.setupApp(root, None) + edit = EditorWindow(root=root, filename=filename) + text = edit.text + text['height'] = 10 + for i in range(20): + text.insert('insert', ' '*i + str(i) + '\n') + # text.bind("<>", edit.close_event) + # Does not stop error, neither does following + # edit.text.bind("<>", edit.close_event) + + +if __name__ == '__main__': + from unittest import main + main('idlelib.idle_test.test_editor', verbosity=2, exit=False) + + from idlelib.idle_test.htest import run + run(_editor_window) diff --git a/Lib/idlelib/extend.txt b/Lib/idlelib/extend.txt new file mode 100644 index 000000000..2522758ce --- /dev/null +++ b/Lib/idlelib/extend.txt @@ -0,0 +1,83 @@ +Writing an IDLE extension +========================= + +An IDLE extension can define new key bindings and menu entries for IDLE +edit windows. There is a simple mechanism to load extensions when IDLE +starts up and to attach them to each edit window. (It is also possible +to make other changes to IDLE, but this must be done by editing the IDLE +source code.) + +The list of extensions loaded at startup time is configured by editing +the file config-extensions.def. See below for details. + +An IDLE extension is defined by a class. Methods of the class define +actions that are invoked by event bindings or menu entries. Class (or +instance) variables define the bindings and menu additions; these are +automatically applied by IDLE when the extension is linked to an edit +window. + +An IDLE extension class is instantiated with a single argument, +`editwin', an EditorWindow instance. The extension cannot assume much +about this argument, but it is guaranteed to have the following instance +variables: + + text a Text instance (a widget) + io an IOBinding instance (more about this later) + flist the FileList instance (shared by all edit windows) + +(There are a few more, but they are rarely useful.) + +The extension class must not directly bind Window Manager (e.g. X) events. +Rather, it must define one or more virtual events, e.g. <>, and +corresponding methods, e.g. z_in_event(). The virtual events will be +bound to the corresponding methods, and Window Manager events can then be bound +to the virtual events. (This indirection is done so that the key bindings can +easily be changed, and so that other sources of virtual events can exist, such +as menu entries.) + +An extension can define menu entries. This is done with a class or instance +variable named menudefs; it should be a list of pairs, where each pair is a +menu name (lowercase) and a list of menu entries. Each menu entry is either +None (to insert a separator entry) or a pair of strings (menu_label, +virtual_event). Here, menu_label is the label of the menu entry, and +virtual_event is the virtual event to be generated when the entry is selected. +An underscore in the menu label is removed; the character following the +underscore is displayed underlined, to indicate the shortcut character (for +Windows). + +At the moment, extensions cannot define whole new menus; they must define +entries in existing menus. Some menus are not present on some windows; such +entry definitions are then ignored, but key bindings are still applied. (This +should probably be refined in the future.) + +Extensions are not required to define menu entries for all the events they +implement. (They are also not required to create keybindings, but in that +case there must be empty bindings in config-extensions.def) + +Here is a partial example from zzdummy.py: + +class ZzDummy: + + menudefs = [ + ('format', [ + ('Z in', '<>'), + ('Z out', '<>'), + ] ) + ] + + def __init__(self, editwin): + self.editwin = editwin + + def z_in_event(self, event=None): + "...Do what you want here..." + +The final piece of the puzzle is the file "config-extensions.def", which is +used to configure the loading of extensions and to establish key (or, more +generally, event) bindings to the virtual events defined in the extensions. + +See the comments at the top of config-extensions.def for information. It's +currently necessary to manually modify that file to change IDLE's extension +loading or extension key bindings. + +For further information on binding refer to the Tkinter Resources web page at +python.org and to the Tk Command "bind" man page. diff --git a/Lib/idlelib/filelist.py b/Lib/idlelib/filelist.py new file mode 100644 index 000000000..e27e5d32a --- /dev/null +++ b/Lib/idlelib/filelist.py @@ -0,0 +1,132 @@ +"idlelib.filelist" + +import os +from tkinter import messagebox + + +class FileList: + + # N.B. this import overridden in PyShellFileList. + from idlelib.editor import EditorWindow + + def __init__(self, root): + self.root = root + self.dict = {} + self.inversedict = {} + self.vars = {} # For EditorWindow.getrawvar (shared Tcl variables) + + def open(self, filename, action=None): + assert filename + filename = self.canonize(filename) + if os.path.isdir(filename): + # This can happen when bad filename is passed on command line: + messagebox.showerror( + "File Error", + f"{filename!r} is a directory.", + master=self.root) + return None + key = os.path.normcase(filename) + if key in self.dict: + edit = self.dict[key] + edit.top.wakeup() + return edit + if action: + # Don't create window, perform 'action', e.g. open in same window + return action(filename) + else: + edit = self.EditorWindow(self, filename, key) + if edit.good_load: + return edit + else: + edit._close() + return None + + def gotofileline(self, filename, lineno=None): + edit = self.open(filename) + if edit is not None and lineno is not None: + edit.gotoline(lineno) + + def new(self, filename=None): + return self.EditorWindow(self, filename) + + def close_all_callback(self, *args, **kwds): + for edit in list(self.inversedict): + reply = edit.close() + if reply == "cancel": + break + return "break" + + def unregister_maybe_terminate(self, edit): + try: + key = self.inversedict[edit] + except KeyError: + print("Don't know this EditorWindow object. (close)") + return + if key: + del self.dict[key] + del self.inversedict[edit] + if not self.inversedict: + self.root.quit() + + def filename_changed_edit(self, edit): + edit.saved_change_hook() + try: + key = self.inversedict[edit] + except KeyError: + print("Don't know this EditorWindow object. (rename)") + return + filename = edit.io.filename + if not filename: + if key: + del self.dict[key] + self.inversedict[edit] = None + return + filename = self.canonize(filename) + newkey = os.path.normcase(filename) + if newkey == key: + return + if newkey in self.dict: + conflict = self.dict[newkey] + self.inversedict[conflict] = None + messagebox.showerror( + "Name Conflict", + f"You now have multiple edit windows open for {filename!r}", + master=self.root) + self.dict[newkey] = edit + self.inversedict[edit] = newkey + if key: + try: + del self.dict[key] + except KeyError: + pass + + def canonize(self, filename): + if not os.path.isabs(filename): + try: + pwd = os.getcwd() + except OSError: + pass + else: + filename = os.path.join(pwd, filename) + return os.path.normpath(filename) + + +def _test(): # TODO check and convert to htest + from tkinter import Tk + from idlelib.editor import fixwordbreaks + from idlelib.run import fix_scaling + root = Tk() + fix_scaling(root) + fixwordbreaks(root) + root.withdraw() + flist = FileList(root) + flist.new() + if flist.inversedict: + root.mainloop() + + +if __name__ == '__main__': + from unittest import main + main('idlelib.idle_test.test_filelist', verbosity=2) + +# _test() diff --git a/Lib/idlelib/format.py b/Lib/idlelib/format.py new file mode 100644 index 000000000..4b57a182c --- /dev/null +++ b/Lib/idlelib/format.py @@ -0,0 +1,426 @@ +"""Format all or a selected region (line slice) of text. + +Region formatting options: paragraph, comment block, indent, deindent, +comment, uncomment, tabify, and untabify. + +File renamed from paragraph.py with functions added from editor.py. +""" +import re +from tkinter.messagebox import askyesno +from tkinter.simpledialog import askinteger +from idlelib.config import idleConf + + +class FormatParagraph: + """Format a paragraph, comment block, or selection to a max width. + + Does basic, standard text formatting, and also understands Python + comment blocks. Thus, for editing Python source code, this + extension is really only suitable for reformatting these comment + blocks or triple-quoted strings. + + Known problems with comment reformatting: + * If there is a selection marked, and the first line of the + selection is not complete, the block will probably not be detected + as comments, and will have the normal "text formatting" rules + applied. + * If a comment block has leading whitespace that mixes tabs and + spaces, they will not be considered part of the same block. + * Fancy comments, like this bulleted list, aren't handled :-) + """ + def __init__(self, editwin): + self.editwin = editwin + + @classmethod + def reload(cls): + cls.max_width = idleConf.GetOption('extensions', 'FormatParagraph', + 'max-width', type='int', default=72) + + def close(self): + self.editwin = None + + def format_paragraph_event(self, event, limit=None): + """Formats paragraph to a max width specified in idleConf. + + If text is selected, format_paragraph_event will start breaking lines + at the max width, starting from the beginning selection. + + If no text is selected, format_paragraph_event uses the current + cursor location to determine the paragraph (lines of text surrounded + by blank lines) and formats it. + + The length limit parameter is for testing with a known value. + """ + limit = self.max_width if limit is None else limit + text = self.editwin.text + first, last = self.editwin.get_selection_indices() + if first and last: + data = text.get(first, last) + comment_header = get_comment_header(data) + else: + first, last, comment_header, data = \ + find_paragraph(text, text.index("insert")) + if comment_header: + newdata = reformat_comment(data, limit, comment_header) + else: + newdata = reformat_paragraph(data, limit) + text.tag_remove("sel", "1.0", "end") + + if newdata != data: + text.mark_set("insert", first) + text.undo_block_start() + text.delete(first, last) + text.insert(first, newdata) + text.undo_block_stop() + else: + text.mark_set("insert", last) + text.see("insert") + return "break" + + +FormatParagraph.reload() + +def find_paragraph(text, mark): + """Returns the start/stop indices enclosing the paragraph that mark is in. + + Also returns the comment format string, if any, and paragraph of text + between the start/stop indices. + """ + lineno, col = map(int, mark.split(".")) + line = text.get("%d.0" % lineno, "%d.end" % lineno) + + # Look for start of next paragraph if the index passed in is a blank line + while text.compare("%d.0" % lineno, "<", "end") and is_all_white(line): + lineno = lineno + 1 + line = text.get("%d.0" % lineno, "%d.end" % lineno) + first_lineno = lineno + comment_header = get_comment_header(line) + comment_header_len = len(comment_header) + + # Once start line found, search for end of paragraph (a blank line) + while get_comment_header(line)==comment_header and \ + not is_all_white(line[comment_header_len:]): + lineno = lineno + 1 + line = text.get("%d.0" % lineno, "%d.end" % lineno) + last = "%d.0" % lineno + + # Search back to beginning of paragraph (first blank line before) + lineno = first_lineno - 1 + line = text.get("%d.0" % lineno, "%d.end" % lineno) + while lineno > 0 and \ + get_comment_header(line)==comment_header and \ + not is_all_white(line[comment_header_len:]): + lineno = lineno - 1 + line = text.get("%d.0" % lineno, "%d.end" % lineno) + first = "%d.0" % (lineno+1) + + return first, last, comment_header, text.get(first, last) + +# This should perhaps be replaced with textwrap.wrap +def reformat_paragraph(data, limit): + """Return data reformatted to specified width (limit).""" + lines = data.split("\n") + i = 0 + n = len(lines) + while i < n and is_all_white(lines[i]): + i = i+1 + if i >= n: + return data + indent1 = get_indent(lines[i]) + if i+1 < n and not is_all_white(lines[i+1]): + indent2 = get_indent(lines[i+1]) + else: + indent2 = indent1 + new = lines[:i] + partial = indent1 + while i < n and not is_all_white(lines[i]): + # XXX Should take double space after period (etc.) into account + words = re.split(r"(\s+)", lines[i]) + for j in range(0, len(words), 2): + word = words[j] + if not word: + continue # Can happen when line ends in whitespace + if len((partial + word).expandtabs()) > limit and \ + partial != indent1: + new.append(partial.rstrip()) + partial = indent2 + partial = partial + word + " " + if j+1 < len(words) and words[j+1] != " ": + partial = partial + " " + i = i+1 + new.append(partial.rstrip()) + # XXX Should reformat remaining paragraphs as well + new.extend(lines[i:]) + return "\n".join(new) + +def reformat_comment(data, limit, comment_header): + """Return data reformatted to specified width with comment header.""" + + # Remove header from the comment lines + lc = len(comment_header) + data = "\n".join(line[lc:] for line in data.split("\n")) + # Reformat to maxformatwidth chars or a 20 char width, + # whichever is greater. + format_width = max(limit - len(comment_header), 20) + newdata = reformat_paragraph(data, format_width) + # re-split and re-insert the comment header. + newdata = newdata.split("\n") + # If the block ends in a \n, we don't want the comment prefix + # inserted after it. (Im not sure it makes sense to reformat a + # comment block that is not made of complete lines, but whatever!) + # Can't think of a clean solution, so we hack away + block_suffix = "" + if not newdata[-1]: + block_suffix = "\n" + newdata = newdata[:-1] + return '\n'.join(comment_header+line for line in newdata) + block_suffix + +def is_all_white(line): + """Return True if line is empty or all whitespace.""" + + return re.match(r"^\s*$", line) is not None + +def get_indent(line): + """Return the initial space or tab indent of line.""" + return re.match(r"^([ \t]*)", line).group() + +def get_comment_header(line): + """Return string with leading whitespace and '#' from line or ''. + + A null return indicates that the line is not a comment line. A non- + null return, such as ' #', will be used to find the other lines of + a comment block with the same indent. + """ + m = re.match(r"^([ \t]*#*)", line) + if m is None: return "" + return m.group(1) + + +# Copied from editor.py; importing it would cause an import cycle. +_line_indent_re = re.compile(r'[ \t]*') + +def get_line_indent(line, tabwidth): + """Return a line's indentation as (# chars, effective # of spaces). + + The effective # of spaces is the length after properly "expanding" + the tabs into spaces, as done by str.expandtabs(tabwidth). + """ + m = _line_indent_re.match(line) + return m.end(), len(m.group().expandtabs(tabwidth)) + + +class FormatRegion: + "Format selected text (region)." + + def __init__(self, editwin): + self.editwin = editwin + + def get_region(self): + """Return line information about the selected text region. + + If text is selected, the first and last indices will be + for the selection. If there is no text selected, the + indices will be the current cursor location. + + Return a tuple containing (first index, last index, + string representation of text, list of text lines). + """ + text = self.editwin.text + first, last = self.editwin.get_selection_indices() + if first and last: + head = text.index(first + " linestart") + tail = text.index(last + "-1c lineend +1c") + else: + head = text.index("insert linestart") + tail = text.index("insert lineend +1c") + chars = text.get(head, tail) + lines = chars.split("\n") + return head, tail, chars, lines + + def set_region(self, head, tail, chars, lines): + """Replace the text between the given indices. + + Args: + head: Starting index of text to replace. + tail: Ending index of text to replace. + chars: Expected to be string of current text + between head and tail. + lines: List of new lines to insert between head + and tail. + """ + text = self.editwin.text + newchars = "\n".join(lines) + if newchars == chars: + text.bell() + return + text.tag_remove("sel", "1.0", "end") + text.mark_set("insert", head) + text.undo_block_start() + text.delete(head, tail) + text.insert(head, newchars) + text.undo_block_stop() + text.tag_add("sel", head, "insert") + + def indent_region_event(self, event=None): + "Indent region by indentwidth spaces." + head, tail, chars, lines = self.get_region() + for pos in range(len(lines)): + line = lines[pos] + if line: + raw, effective = get_line_indent(line, self.editwin.tabwidth) + effective = effective + self.editwin.indentwidth + lines[pos] = self.editwin._make_blanks(effective) + line[raw:] + self.set_region(head, tail, chars, lines) + return "break" + + def dedent_region_event(self, event=None): + "Dedent region by indentwidth spaces." + head, tail, chars, lines = self.get_region() + for pos in range(len(lines)): + line = lines[pos] + if line: + raw, effective = get_line_indent(line, self.editwin.tabwidth) + effective = max(effective - self.editwin.indentwidth, 0) + lines[pos] = self.editwin._make_blanks(effective) + line[raw:] + self.set_region(head, tail, chars, lines) + return "break" + + def comment_region_event(self, event=None): + """Comment out each line in region. + + ## is appended to the beginning of each line to comment it out. + """ + head, tail, chars, lines = self.get_region() + for pos in range(len(lines) - 1): + line = lines[pos] + lines[pos] = '##' + line + self.set_region(head, tail, chars, lines) + return "break" + + def uncomment_region_event(self, event=None): + """Uncomment each line in region. + + Remove ## or # in the first positions of a line. If the comment + is not in the beginning position, this command will have no effect. + """ + head, tail, chars, lines = self.get_region() + for pos in range(len(lines)): + line = lines[pos] + if not line: + continue + if line[:2] == '##': + line = line[2:] + elif line[:1] == '#': + line = line[1:] + lines[pos] = line + self.set_region(head, tail, chars, lines) + return "break" + + def tabify_region_event(self, event=None): + "Convert leading spaces to tabs for each line in selected region." + head, tail, chars, lines = self.get_region() + tabwidth = self._asktabwidth() + if tabwidth is None: + return + for pos in range(len(lines)): + line = lines[pos] + if line: + raw, effective = get_line_indent(line, tabwidth) + ntabs, nspaces = divmod(effective, tabwidth) + lines[pos] = '\t' * ntabs + ' ' * nspaces + line[raw:] + self.set_region(head, tail, chars, lines) + return "break" + + def untabify_region_event(self, event=None): + "Expand tabs to spaces for each line in region." + head, tail, chars, lines = self.get_region() + tabwidth = self._asktabwidth() + if tabwidth is None: + return + for pos in range(len(lines)): + lines[pos] = lines[pos].expandtabs(tabwidth) + self.set_region(head, tail, chars, lines) + return "break" + + def _asktabwidth(self): + "Return value for tab width." + return askinteger( + "Tab width", + "Columns per tab? (2-16)", + parent=self.editwin.text, + initialvalue=self.editwin.indentwidth, + minvalue=2, + maxvalue=16) + + +class Indents: + "Change future indents." + + def __init__(self, editwin): + self.editwin = editwin + + def toggle_tabs_event(self, event): + editwin = self.editwin + usetabs = editwin.usetabs + if askyesno( + "Toggle tabs", + "Turn tabs " + ("on", "off")[usetabs] + + "?\nIndent width " + + ("will be", "remains at")[usetabs] + " 8." + + "\n Note: a tab is always 8 columns", + parent=editwin.text): + editwin.usetabs = not usetabs + # Try to prevent inconsistent indentation. + # User must change indent width manually after using tabs. + editwin.indentwidth = 8 + return "break" + + def change_indentwidth_event(self, event): + editwin = self.editwin + new = askinteger( + "Indent width", + "New indent width (2-16)\n(Always use 8 when using tabs)", + parent=editwin.text, + initialvalue=editwin.indentwidth, + minvalue=2, + maxvalue=16) + if new and new != editwin.indentwidth and not editwin.usetabs: + editwin.indentwidth = new + return "break" + + +class Rstrip: # 'Strip Trailing Whitespace" on "Format" menu. + def __init__(self, editwin): + self.editwin = editwin + + def do_rstrip(self, event=None): + text = self.editwin.text + undo = self.editwin.undo + undo.undo_block_start() + + end_line = int(float(text.index('end'))) + for cur in range(1, end_line): + txt = text.get('%i.0' % cur, '%i.end' % cur) + raw = len(txt) + cut = len(txt.rstrip()) + # Since text.delete() marks file as changed, even if not, + # only call it when needed to actually delete something. + if cut < raw: + text.delete('%i.%i' % (cur, cut), '%i.end' % cur) + + if (text.get('end-2c') == '\n' # File ends with at least 1 newline; + and not hasattr(self.editwin, 'interp')): # & is not Shell. + # Delete extra user endlines. + while (text.index('end-1c') > '1.0' # Stop if file empty. + and text.get('end-3c') == '\n'): + text.delete('end-3c') + # Because tk indexes are slice indexes and never raise, + # a file with only newlines will be emptied. + # patchcheck.py does the same. + + undo.undo_block_stop() + + +if __name__ == "__main__": + from unittest import main + main('idlelib.idle_test.test_format', verbosity=2, exit=False) diff --git a/Lib/idlelib/grep.py b/Lib/idlelib/grep.py new file mode 100644 index 000000000..42048ff23 --- /dev/null +++ b/Lib/idlelib/grep.py @@ -0,0 +1,223 @@ +"""Grep dialog for Find in Files functionality. + + Inherits from SearchDialogBase for GUI and uses searchengine + to prepare search pattern. +""" +import fnmatch +import os +import sys + +from tkinter import StringVar, BooleanVar +from tkinter.ttk import Checkbutton # Frame imported in ...Base + +from idlelib.searchbase import SearchDialogBase +from idlelib import searchengine + +# Importing OutputWindow here fails due to import loop +# EditorWindow -> GrepDialog -> OutputWindow -> EditorWindow + + +def grep(text, io=None, flist=None): + """Open the Find in Files dialog. + + Module-level function to access the singleton GrepDialog + instance and open the dialog. If text is selected, it is + used as the search phrase; otherwise, the previous entry + is used. + + Args: + text: Text widget that contains the selected text for + default search phrase. + io: iomenu.IOBinding instance with default path to search. + flist: filelist.FileList instance for OutputWindow parent. + """ + root = text._root() + engine = searchengine.get(root) + if not hasattr(engine, "_grepdialog"): + engine._grepdialog = GrepDialog(root, engine, flist) + dialog = engine._grepdialog + searchphrase = text.get("sel.first", "sel.last") + dialog.open(text, searchphrase, io) + + +def walk_error(msg): + "Handle os.walk error." + print(msg) + + +def findfiles(folder, pattern, recursive): + """Generate file names in dir that match pattern. + + Args: + folder: Root directory to search. + pattern: File pattern to match. + recursive: True to include subdirectories. + """ + for dirpath, _, filenames in os.walk(folder, onerror=walk_error): + yield from (os.path.join(dirpath, name) + for name in filenames + if fnmatch.fnmatch(name, pattern)) + if not recursive: + break + + +class GrepDialog(SearchDialogBase): + "Dialog for searching multiple files." + + title = "Find in Files Dialog" + icon = "Grep" + needwrapbutton = 0 + + def __init__(self, root, engine, flist): + """Create search dialog for searching for a phrase in the file system. + + Uses SearchDialogBase as the basis for the GUI and a + searchengine instance to prepare the search. + + Attributes: + flist: filelist.Filelist instance for OutputWindow parent. + globvar: String value of Entry widget for path to search. + globent: Entry widget for globvar. Created in + create_entries(). + recvar: Boolean value of Checkbutton widget for + traversing through subdirectories. + """ + super().__init__(root, engine) + self.flist = flist + self.globvar = StringVar(root) + self.recvar = BooleanVar(root) + + def open(self, text, searchphrase, io=None): + """Make dialog visible on top of others and ready to use. + + Extend the SearchDialogBase open() to set the initial value + for globvar. + + Args: + text: Multicall object containing the text information. + searchphrase: String phrase to search. + io: iomenu.IOBinding instance containing file path. + """ + SearchDialogBase.open(self, text, searchphrase) + if io: + path = io.filename or "" + else: + path = "" + dir, base = os.path.split(path) + head, tail = os.path.splitext(base) + if not tail: + tail = ".py" + self.globvar.set(os.path.join(dir, "*" + tail)) + + def create_entries(self): + "Create base entry widgets and add widget for search path." + SearchDialogBase.create_entries(self) + self.globent = self.make_entry("In files:", self.globvar)[0] + + def create_other_buttons(self): + "Add check button to recurse down subdirectories." + btn = Checkbutton( + self.make_frame()[0], variable=self.recvar, + text="Recurse down subdirectories") + btn.pack(side="top", fill="both") + + def create_command_buttons(self): + "Create base command buttons and add button for Search Files." + SearchDialogBase.create_command_buttons(self) + self.make_button("Search Files", self.default_command, isdef=True) + + def default_command(self, event=None): + """Grep for search pattern in file path. The default command is bound + to . + + If entry values are populated, set OutputWindow as stdout + and perform search. The search dialog is closed automatically + when the search begins. + """ + prog = self.engine.getprog() + if not prog: + return + path = self.globvar.get() + if not path: + self.top.bell() + return + from idlelib.outwin import OutputWindow # leave here! + save = sys.stdout + try: + sys.stdout = OutputWindow(self.flist) + self.grep_it(prog, path) + finally: + sys.stdout = save + + def grep_it(self, prog, path): + """Search for prog within the lines of the files in path. + + For the each file in the path directory, open the file and + search each line for the matching pattern. If the pattern is + found, write the file and line information to stdout (which + is an OutputWindow). + + Args: + prog: The compiled, cooked search pattern. + path: String containing the search path. + """ + folder, filepat = os.path.split(path) + if not folder: + folder = os.curdir + filelist = sorted(findfiles(folder, filepat, self.recvar.get())) + self.close() + pat = self.engine.getpat() + print(f"Searching {pat!r} in {path} ...") + hits = 0 + try: + for fn in filelist: + try: + with open(fn, errors='replace') as f: + for lineno, line in enumerate(f, 1): + if line[-1:] == '\n': + line = line[:-1] + if prog.search(line): + sys.stdout.write(f"{fn}: {lineno}: {line}\n") + hits += 1 + except OSError as msg: + print(msg) + print(f"Hits found: {hits}\n(Hint: right-click to open locations.)" + if hits else "No hits.") + except AttributeError: + # Tk window has been closed, OutputWindow.text = None, + # so in OW.write, OW.text.insert fails. + pass + + +def _grep_dialog(parent): # htest # + from tkinter import Toplevel, Text, SEL + from tkinter.ttk import Frame, Button + from idlelib.pyshell import PyShellFileList + + top = Toplevel(parent) + top.title("Test GrepDialog") + x, y = map(int, parent.geometry().split('+')[1:]) + top.geometry(f"+{x}+{y + 175}") + + flist = PyShellFileList(top) + frame = Frame(top) + frame.pack() + text = Text(frame, height=5) + text.pack() + text.insert('1.0', 'import grep') + + def show_grep_dialog(): + text.tag_add(SEL, "1.0", '1.end') + grep(text, flist=flist) + text.tag_remove(SEL, "1.0", '1.end') + + button = Button(frame, text="Show GrepDialog", command=show_grep_dialog) + button.pack() + + +if __name__ == "__main__": + from unittest import main + main('idlelib.idle_test.test_grep', verbosity=2, exit=False) + + from idlelib.idle_test.htest import run + run(_grep_dialog) diff --git a/Lib/idlelib/help.html b/Lib/idlelib/help.html new file mode 100644 index 000000000..eda16ac5b --- /dev/null +++ b/Lib/idlelib/help.html @@ -0,0 +1,868 @@ +
+

IDLE — Python editor and shell

+

Source code: Lib/idlelib/

+
+

IDLE is Python’s Integrated Development and Learning Environment.

+

IDLE has the following features:

+
    +
  • cross-platform: works mostly the same on Windows, Unix, and macOS

  • +
  • Python shell window (interactive interpreter) with colorizing +of code input, output, and error messages

  • +
  • multi-window text editor with multiple undo, Python colorizing, +smart indent, call tips, auto completion, and other features

  • +
  • search within any window, replace within editor windows, and search +through multiple files (grep)

  • +
  • debugger with persistent breakpoints, stepping, and viewing +of global and local namespaces

  • +
  • configuration, browsers, and other dialogs

  • +
+

The IDLE application is implemented in the idlelib package.

+

This is an optional module. +If it is missing from your copy of CPython, +look for documentation from your distributor (that is, +whoever provided Python to you). +If you are the distributor, see Requirements for optional modules.

+ +
+

Editing and Navigation

+
+

Editor windows

+

IDLE may open editor windows when it starts, depending on settings +and how you start IDLE. Thereafter, use the File menu. There can be only +one open editor window for a given file.

+

The title bar contains the name of the file, the full path, and the version +of Python and IDLE running the window. The status bar contains the line +number (‘Ln’) and column number (‘Col’). Line numbers start with 1; +column numbers with 0.

+

IDLE assumes that files with a known .py* extension contain Python code +and that other files do not. Run Python code with the Run menu.

+
+
+

Key bindings

+

The IDLE insertion cursor is a thin vertical bar between character +positions. When characters are entered, the insertion cursor and +everything to its right moves right one character and +the new character is entered in the new space.

+

Several non-character keys move the cursor and possibly +delete characters. Deletion does not puts text on the clipboard, +but IDLE has an undo list. Wherever this doc discusses keys, +‘C’ refers to the Control key on Windows and +Unix and the Command key on macOS. (And all such discussions +assume that the keys have not been re-bound to something else.)

+
    +
  • Arrow keys move the cursor one character or line.

  • +
  • C-LeftArrow and C-RightArrow moves left or right one word.

  • +
  • Home and End go to the beginning or end of the line.

  • +
  • Page Up and Page Down go up or down one screen.

  • +
  • C-Home and C-End go to beginning or end of the file.

  • +
  • Backspace and Del (or C-d) delete the previous +or next character.

  • +
  • C-Backspace and C-Del delete one word left or right.

  • +
  • C-k deletes (‘kills’) everything to the right.

  • +
+

Standard keybindings (like C-c to copy and C-v to paste) +may work. Keybindings are selected in the Configure IDLE dialog.

+
+
+

Automatic indentation

+

After a block-opening statement, the next line is indented by 4 spaces (in the +Python Shell window by one tab). After certain keywords (break, return etc.) +the next line is dedented. In leading indentation, Backspace deletes up +to 4 spaces if they are there. Tab inserts spaces (in the Python +Shell window one tab), number depends on Indent width. Currently, tabs +are restricted to four spaces due to Tcl/Tk limitations.

+

See also the indent/dedent region commands on the +Format menu.

+
+
+

Search and Replace

+

Any selection becomes a search target. However, only selections within +a line work because searches are only performed within lines with the +terminal newline removed. If [x] Regular expression is checked, the +target is interpreted according to the Python re module.

+
+
+

Completions

+

Completions are supplied, when requested and available, for module +names, attributes of classes or functions, or filenames. Each request +method displays a completion box with existing names. (See tab +completions below for an exception.) For any box, change the name +being completed and the item highlighted in the box by +typing and deleting characters; by hitting Up, Down, +PageUp, PageDown, Home, and End keys; +and by a single click within the box. Close the box with Escape, +Enter, and double Tab keys or clicks outside the box. +A double click within the box selects and closes.

+

One way to open a box is to type a key character and wait for a +predefined interval. This defaults to 2 seconds; customize it +in the settings dialog. (To prevent auto popups, set the delay to a +large number of milliseconds, such as 100000000.) For imported module +names or class or function attributes, type ‘.’. +For filenames in the root directory, type os.sep or +os.altsep immediately after an opening quote. (On Windows, +one can specify a drive first.) Move into subdirectories by typing a +directory name and a separator.

+

Instead of waiting, or after a box is closed, open a completion box +immediately with Show Completions on the Edit menu. The default hot +key is C-space. If one types a prefix for the desired name +before opening the box, the first match or near miss is made visible. +The result is the same as if one enters a prefix +after the box is displayed. Show Completions after a quote completes +filenames in the current directory instead of a root directory.

+

Hitting Tab after a prefix usually has the same effect as Show +Completions. (With no prefix, it indents.) However, if there is only +one match to the prefix, that match is immediately added to the editor +text without opening a box.

+

Invoking ‘Show Completions’, or hitting Tab after a prefix, +outside of a string and without a preceding ‘.’ opens a box with +keywords, builtin names, and available module-level names.

+

When editing code in an editor (as oppose to Shell), increase the +available module-level names by running your code +and not restarting the Shell thereafter. This is especially useful +after adding imports at the top of a file. This also increases +possible attribute completions.

+

Completion boxes initially exclude names beginning with ‘_’ or, for +modules, not included in ‘__all__’. The hidden names can be accessed +by typing ‘_’ after ‘.’, either before or after the box is opened.

+
+
+

Calltips

+

A calltip is shown automatically when one types ( after the name +of an accessible function. A function name expression may include +dots and subscripts. A calltip remains until it is clicked, the cursor +is moved out of the argument area, or ) is typed. Whenever the +cursor is in the argument part of a definition, select Edit and “Show +Call Tip” on the menu or enter its shortcut to display a calltip.

+

The calltip consists of the function’s signature and docstring up to +the latter’s first blank line or the fifth non-blank line. (Some builtin +functions lack an accessible signature.) A ‘/’ or ‘*’ in the signature +indicates that the preceding or following arguments are passed by +position or name (keyword) only. Details are subject to change.

+

In Shell, the accessible functions depends on what modules have been +imported into the user process, including those imported by Idle itself, +and which definitions have been run, all since the last restart.

+

For example, restart the Shell and enter itertools.count(. A calltip +appears because Idle imports itertools into the user process for its own +use. (This could change.) Enter turtle.write( and nothing appears. +Idle does not itself import turtle. The menu entry and shortcut also do +nothing. Enter import turtle. Thereafter, turtle.write( +will display a calltip.

+

In an editor, import statements have no effect until one runs the file. +One might want to run a file after writing import statements, after +adding function definitions, or after opening an existing file.

+
+
+

Format block

+

Reformat Paragraph rewraps a block (‘paragraph’) of contiguous equally +indented non-blank comments, a similar block of text within a multiline +string, or a selected subset of either. +If needed, add a blank line to separate string from code. +Partial lines in a selection expand to complete lines. +The resulting lines have the same indent as before +but have maximum total length of N columns (characters). +Change the default N of 72 on the Window tab of IDLE Settings.

+
+
+

Code Context

+

Within an editor window containing Python code, code context can be toggled +in order to show or hide a pane at the top of the window. When shown, this +pane freezes the opening lines for block code, such as those beginning with +class, def, or if keywords, that would have otherwise scrolled +out of view. The size of the pane will be expanded and contracted as needed +to show the all current levels of context, up to the maximum number of +lines defined in the Configure IDLE dialog (which defaults to 15). If there +are no current context lines and the feature is toggled on, a single blank +line will display. Clicking on a line in the context pane will move that +line to the top of the editor.

+

The text and background colors for the context pane can be configured under +the Highlights tab in the Configure IDLE dialog.

+
+
+

Shell window

+

In IDLE’s Shell, enter, edit, and recall complete statements. (Most +consoles and terminals only work with a single physical line at a time).

+

Submit a single-line statement for execution by hitting Return +with the cursor anywhere on the line. If a line is extended with +Backslash (\), the cursor must be on the last physical line. +Submit a multi-line compound statement by entering a blank line after +the statement.

+

When one pastes code into Shell, it is not compiled and possibly executed +until one hits Return, as specified above. +One may edit pasted code first. +If one pastes more than one statement into Shell, the result will be a +SyntaxError when multiple statements are compiled as if they were one.

+

Lines containing RESTART mean that the user execution process has been +re-started. This occurs when the user execution process has crashed, +when one requests a restart on the Shell menu, or when one runs code +in an editor window.

+

The editing features described in previous subsections work when entering +code interactively. IDLE’s Shell window also responds to the following:

+
    +
  • C-c attempts to interrupt statement execution (but may fail).

  • +
  • C-d closes Shell if typed at a >>> prompt.

  • +
  • Alt-p and Alt-n (C-p and C-n on macOS) +retrieve to the current prompt the previous or next previously +entered statement that matches anything already typed.

  • +
  • Return while the cursor is on any previous statement +appends the latter to anything already typed at the prompt.

  • +
+
+
+

Text colors

+

Idle defaults to black on white text, but colors text with special meanings. +For the shell, these are shell output, shell error, user output, and +user error. For Python code, at the shell prompt or in an editor, these are +keywords, builtin class and function names, names following class and +def, strings, and comments. For any text window, these are the cursor (when +present), found text (when possible), and selected text.

+

IDLE also highlights the soft keywords match, +case, and _ in +pattern-matching statements. However, this highlighting is not perfect and +will be incorrect in some rare cases, including some _-s in case +patterns.

+

Text coloring is done in the background, so uncolorized text is occasionally +visible. To change the color scheme, use the Configure IDLE dialog +Highlighting tab. The marking of debugger breakpoint lines in the editor and +text in popups and dialogs is not user-configurable.

+
+
+
+

Startup and Code Execution

+

Upon startup with the -s option, IDLE will execute the file referenced by +the environment variables IDLESTARTUP or PYTHONSTARTUP. +IDLE first checks for IDLESTARTUP; if IDLESTARTUP is present the file +referenced is run. If IDLESTARTUP is not present, IDLE checks for +PYTHONSTARTUP. Files referenced by these environment variables are +convenient places to store functions that are used frequently from the IDLE +shell, or for executing import statements to import common modules.

+

In addition, Tk also loads a startup file if it is present. Note that the +Tk file is loaded unconditionally. This additional file is .Idle.py and is +looked for in the user’s home directory. Statements in this file will be +executed in the Tk namespace, so this file is not useful for importing +functions to be used from IDLE’s Python shell.

+
+

Command-line usage

+

IDLE can be invoked from the command line with various options. The general syntax is:

+
python -m idlelib [options] [file ...]
+
+
+

The following options are available:

+
+
+-c <command>
+

Run the specified Python command in the shell window. +For example, pass -c "print('Hello, World!')". +On Windows, the outer quotes must be double quotes as shown.

+
+ +
+
+-d
+

Enable the debugger and open the shell window.

+
+ +
+
+-e
+

Open an editor window.

+
+ +
+
+-h
+

Print a help message with legal combinations of options and exit.

+
+ +
+
+-i
+

Open a shell window.

+
+ +
+
+-r <file>
+

Run the specified file in the shell window.

+
+ +
+
+-s
+

Run the startup file (as defined by the environment variables IDLESTARTUP or PYTHONSTARTUP) before opening the shell window.

+
+ +
+
+-t <title>
+

Set the title of the shell window.

+
+ +
+
+-
+

Read and execute standard input in the shell window. This option must be the last one before any arguments.

+
+ +

If arguments are provided:

+
    +
  • If -, -c, or -r is used, all arguments are placed in sys.argv[1:], +and sys.argv[0] is set to '', '-c', or '-r' respectively. +No editor window is opened, even if that is the default set in the Options dialog.

  • +
  • Otherwise, arguments are treated as files to be opened for editing, and sys.argv reflects the arguments passed to IDLE itself.

  • +
+
+
+

Startup failure

+

IDLE uses a socket to communicate between the IDLE GUI process and the user +code execution process. A connection must be established whenever the Shell +starts or restarts. (The latter is indicated by a divider line that says +‘RESTART’). If the user process fails to connect to the GUI process, it +usually displays a Tk error box with a ‘cannot connect’ message +that directs the user here. It then exits.

+

One specific connection failure on Unix systems results from +misconfigured masquerading rules somewhere in a system’s network setup. +When IDLE is started from a terminal, one will see a message starting +with ** Invalid host:. +The valid value is 127.0.0.1 (idlelib.rpc.LOCALHOST). +One can diagnose with tcpconnect -irv 127.0.0.1 6543 in one +terminal window and tcplisten <same args> in another.

+

A common cause of failure is a user-written file with the same name as a +standard library module, such as random.py and tkinter.py. When such a +file is located in the same directory as a file that is about to be run, +IDLE cannot import the stdlib file. The current fix is to rename the +user file.

+

Though less common than in the past, an antivirus or firewall program may +stop the connection. If the program cannot be taught to allow the +connection, then it must be turned off for IDLE to work. It is safe to +allow this internal connection because no data is visible on external +ports. A similar problem is a network mis-configuration that blocks +connections.

+

Python installation issues occasionally stop IDLE: multiple versions can +clash, or a single installation might need admin access. If one undo the +clash, or cannot or does not want to run as admin, it might be easiest to +completely remove Python and start over.

+

A zombie pythonw.exe process could be a problem. On Windows, use Task +Manager to check for one and stop it if there is. Sometimes a restart +initiated by a program crash or Keyboard Interrupt (control-C) may fail +to connect. Dismissing the error box or using Restart Shell on the Shell +menu may fix a temporary problem.

+

When IDLE first starts, it attempts to read user configuration files in +~/.idlerc/ (~ is one’s home directory). If there is a problem, an error +message should be displayed. Leaving aside random disk glitches, this can +be prevented by never editing the files by hand. Instead, use the +configuration dialog, under Options. Once there is an error in a user +configuration file, the best solution may be to delete it and start over +with the settings dialog.

+

If IDLE quits with no message, and it was not started from a console, try +starting it from a console or terminal (python -m idlelib) and see if +this results in an error message.

+

On Unix-based systems with tcl/tk older than 8.6.11 (see +About IDLE) certain characters of certain fonts can cause +a tk failure with a message to the terminal. This can happen either +if one starts IDLE to edit a file with such a character or later +when entering such a character. If one cannot upgrade tcl/tk, +then re-configure IDLE to use a font that works better.

+
+
+

Running user code

+

With rare exceptions, the result of executing Python code with IDLE is +intended to be the same as executing the same code by the default method, +directly with Python in a text-mode system console or terminal window. +However, the different interface and operation occasionally affect +visible results. For instance, sys.modules starts with more entries, +and threading.active_count() returns 2 instead of 1.

+

By default, IDLE runs user code in a separate OS process rather than in +the user interface process that runs the shell and editor. In the execution +process, it replaces sys.stdin, sys.stdout, and sys.stderr +with objects that get input from and send output to the Shell window. +The original values stored in sys.__stdin__, sys.__stdout__, and +sys.__stderr__ are not touched, but may be None.

+

Sending print output from one process to a text widget in another is +slower than printing to a system terminal in the same process. +This has the most effect when printing multiple arguments, as the string +for each argument, each separator, the newline are sent separately. +For development, this is usually not a problem, but if one wants to +print faster in IDLE, format and join together everything one wants +displayed together and then print a single string. Both format strings +and str.join() can help combine fields and lines.

+

IDLE’s standard stream replacements are not inherited by subprocesses +created in the execution process, whether directly by user code or by +modules such as multiprocessing. If such subprocess use input from +sys.stdin or print or write to sys.stdout or sys.stderr, +IDLE should be started in a command line window. (On Windows, +use python or py rather than pythonw or pyw.) +The secondary subprocess +will then be attached to that window for input and output.

+

If sys is reset by user code, such as with importlib.reload(sys), +IDLE’s changes are lost and input from the keyboard and output to the screen +will not work correctly.

+

When Shell has the focus, it controls the keyboard and screen. This is +normally transparent, but functions that directly access the keyboard +and screen will not work. These include system-specific functions that +determine whether a key has been pressed and if so, which.

+

The IDLE code running in the execution process adds frames to the call stack +that would not be there otherwise. IDLE wraps sys.getrecursionlimit and +sys.setrecursionlimit to reduce the effect of the additional stack +frames.

+

When user code raises SystemExit either directly or by calling sys.exit, +IDLE returns to a Shell prompt instead of exiting.

+
+
+

User output in Shell

+

When a program outputs text, the result is determined by the +corresponding output device. When IDLE executes user code, sys.stdout +and sys.stderr are connected to the display area of IDLE’s Shell. Some of +its features are inherited from the underlying Tk Text widget. Others +are programmed additions. Where it matters, Shell is designed for development +rather than production runs.

+

For instance, Shell never throws away output. A program that sends unlimited +output to Shell will eventually fill memory, resulting in a memory error. +In contrast, some system text windows only keep the last n lines of output. +A Windows console, for instance, keeps a user-settable 1 to 9999 lines, +with 300 the default.

+

A Tk Text widget, and hence IDLE’s Shell, displays characters (codepoints) in +the BMP (Basic Multilingual Plane) subset of Unicode. Which characters are +displayed with a proper glyph and which with a replacement box depends on the +operating system and installed fonts. Tab characters cause the following text +to begin after the next tab stop. (They occur every 8 ‘characters’). Newline +characters cause following text to appear on a new line. Other control +characters are ignored or displayed as a space, box, or something else, +depending on the operating system and font. (Moving the text cursor through +such output with arrow keys may exhibit some surprising spacing behavior.)

+
>>> s = 'a\tb\a<\x02><\r>\bc\nd'  # Enter 22 chars.
+>>> len(s)
+14
+>>> s  # Display repr(s)
+'a\tb\x07<\x02><\r>\x08c\nd'
+>>> print(s, end='')  # Display s as is.
+# Result varies by OS and font.  Try it.
+
+
+

The repr function is used for interactive echo of expression +values. It returns an altered version of the input string in which +control codes, some BMP codepoints, and all non-BMP codepoints are +replaced with escape codes. As demonstrated above, it allows one to +identify the characters in a string, regardless of how they are displayed.

+

Normal and error output are generally kept separate (on separate lines) +from code input and each other. They each get different highlight colors.

+

For SyntaxError tracebacks, the normal ‘^’ marking where the error was +detected is replaced by coloring the text with an error highlight. +When code run from a file causes other exceptions, one may right click +on a traceback line to jump to the corresponding line in an IDLE editor. +The file will be opened if necessary.

+

Shell has a special facility for squeezing output lines down to a +‘Squeezed text’ label. This is done automatically +for output over N lines (N = 50 by default). +N can be changed in the PyShell section of the General +page of the Settings dialog. Output with fewer lines can be squeezed by +right clicking on the output. This can be useful lines long enough to slow +down scrolling.

+

Squeezed output is expanded in place by double-clicking the label. +It can also be sent to the clipboard or a separate view window by +right-clicking the label.

+
+
+

Developing tkinter applications

+

IDLE is intentionally different from standard Python in order to +facilitate development of tkinter programs. Enter import tkinter as tk; +root = tk.Tk() in standard Python and nothing appears. Enter the same +in IDLE and a tk window appears. In standard Python, one must also enter +root.update() to see the window. IDLE does the equivalent in the +background, about 20 times a second, which is about every 50 milliseconds. +Next enter b = tk.Button(root, text='button'); b.pack(). Again, +nothing visibly changes in standard Python until one enters root.update().

+

Most tkinter programs run root.mainloop(), which usually does not +return until the tk app is destroyed. If the program is run with +python -i or from an IDLE editor, a >>> shell prompt does not +appear until mainloop() returns, at which time there is nothing left +to interact with.

+

When running a tkinter program from an IDLE editor, one can comment out +the mainloop call. One then gets a shell prompt immediately and can +interact with the live application. One just has to remember to +re-enable the mainloop call when running in standard Python.

+
+
+

Running without a subprocess

+

By default, IDLE executes user code in a separate subprocess via a socket, +which uses the internal loopback interface. This connection is not +externally visible and no data is sent to or received from the internet. +If firewall software complains anyway, you can ignore it.

+

If the attempt to make the socket connection fails, Idle will notify you. +Such failures are sometimes transient, but if persistent, the problem +may be either a firewall blocking the connection or misconfiguration of +a particular system. Until the problem is fixed, one can run Idle with +the -n command line switch.

+

If IDLE is started with the -n command line switch it will run in a +single process and will not create the subprocess which runs the RPC +Python execution server. This can be useful if Python cannot create +the subprocess or the RPC socket interface on your platform. However, +in this mode user code is not isolated from IDLE itself. Also, the +environment is not restarted when Run/Run Module (F5) is selected. If +your code has been modified, you must reload() the affected modules and +re-import any specific items (e.g. from foo import baz) if the changes +are to take effect. For these reasons, it is preferable to run IDLE +with the default subprocess if at all possible.

+
+

Deprecated since version 3.4.

+
+
+
+
+

Help and Preferences

+
+

Help sources

+

Help menu entry “IDLE Help” displays a formatted html version of the +IDLE chapter of the Library Reference. The result, in a read-only +tkinter text window, is close to what one sees in a web browser. +Navigate through the text with a mousewheel, +the scrollbar, or up and down arrow keys held down. +Or click the TOC (Table of Contents) button and select a section +header in the opened box.

+

Help menu entry “Python Docs” opens the extensive sources of help, +including tutorials, available at docs.python.org/x.y, where ‘x.y’ +is the currently running Python version. If your system +has an off-line copy of the docs (this may be an installation option), +that will be opened instead.

+

Selected URLs can be added or removed from the help menu at any time using the +General tab of the Configure IDLE dialog.

+
+
+

Setting preferences

+

The font preferences, highlighting, keys, and general preferences can be +changed via Configure IDLE on the Option menu. +Non-default user settings are saved in a .idlerc directory in the user’s +home directory. Problems caused by bad user configuration files are solved +by editing or deleting one or more of the files in .idlerc.

+

On the Font tab, see the text sample for the effect of font face and size +on multiple characters in multiple languages. Edit the sample to add +other characters of personal interest. Use the sample to select +monospaced fonts. If particular characters have problems in Shell or an +editor, add them to the top of the sample and try changing first size +and then font.

+

On the Highlights and Keys tab, select a built-in or custom color theme +and key set. To use a newer built-in color theme or key set with older +IDLEs, save it as a new custom theme or key set and it well be accessible +to older IDLEs.

+
+
+

IDLE on macOS

+

Under System Preferences: Dock, one can set “Prefer tabs when opening +documents” to “Always”. This setting is not compatible with the tk/tkinter +GUI framework used by IDLE, and it breaks a few IDLE features.

+
+
+

Extensions

+

IDLE contains an extension facility. Preferences for extensions can be +changed with the Extensions tab of the preferences dialog. See the +beginning of config-extensions.def in the idlelib directory for further +information. The only current default extension is zzdummy, an example +also used for testing.

+
+
+
+

idlelib — implementation of IDLE application

+

Source code: Lib/idlelib

+
+

The Lib/idlelib package implements the IDLE application. See the rest +of this page for how to use IDLE.

+

The files in idlelib are described in idlelib/README.txt. Access it +either in idlelib or click Help => About IDLE on the IDLE menu. This +file also maps IDLE menu items to the code that implements the item. +Except for files listed under ‘Startup’, the idlelib code is ‘private’ in +sense that feature changes can be backported (see PEP 434).

+
+
+ + diff --git a/Lib/idlelib/help.py b/Lib/idlelib/help.py new file mode 100644 index 000000000..48e7eca28 --- /dev/null +++ b/Lib/idlelib/help.py @@ -0,0 +1,343 @@ +""" help.py: Implement the Idle help menu. +Contents are subject to revision at any time, without notice. + + +Help => About IDLE: display About Idle dialog + + + + +Help => IDLE Help: Display help.html with proper formatting. +Doc/library/idle.rst (Sphinx)=> Doc/build/html/library/idle.html +(help.copy_strip)=> Lib/idlelib/help.html + +HelpParser - Parse help.html and render to tk Text. + +HelpText - Display formatted help.html. + +HelpFrame - Contain text, scrollbar, and table-of-contents. +(This will be needed for display in a future tabbed window.) + +HelpWindow - Display HelpFrame in a standalone window. + +copy_strip - Copy the text part of idle.html to help.html while rstripping each line. + +show_idlehelp - Create HelpWindow. Called in EditorWindow.help_dialog. + +_get_dochome() - Return path to docs on user's system if present, +otherwise return link to docs.python.org. +""" +import os +import sys +from html.parser import HTMLParser +from os.path import abspath, dirname, isfile, join +from platform import python_version + +from tkinter import Toplevel, Text, Menu +from tkinter.ttk import Frame, Menubutton, Scrollbar, Style +from tkinter import font as tkfont + +from idlelib.config import idleConf +from idlelib.colorizer import color_config + +## About IDLE ## + + +## IDLE Help ## + +class HelpParser(HTMLParser): + """Render help.html into a text widget. + + The overridden handle_xyz methods handle a subset of html tags. + The supplied text should have the needed tag configurations. + The behavior for unsupported tags, such as table, is undefined. + If the tags generated by Sphinx change, this class, especially + the handle_starttag and handle_endtags methods, might have to also. + """ + def __init__(self, text): + HTMLParser.__init__(self, convert_charrefs=True) + self.text = text # Text widget we're rendering into. + self.tags = '' # Current block level text tags to apply. + self.chartags = '' # Current character level text tags. + self.hdrlink = False # Exclude html header links. + self.level = 0 # Track indentation level. + self.pre = False # Displaying preformatted text? + self.hprefix = '' # Heading prefix (like '25.5'?) to remove. + self.nested_dl = False # In a nested
? + self.simplelist = False # In a simple list (no double spacing)? + self.toc = [] # Pair headers with text indexes for toc. + self.header = '' # Text within header tags for toc. + self.prevtag = None # Previous tag info (opener?, tag). + + def indent(self, amt=1): + "Change indent (+1, 0, -1) and tags." + self.level += amt + self.tags = '' if self.level == 0 else 'l'+str(self.level) + + def handle_starttag(self, tag, attrs): + "Handle starttags in help.html." + class_ = '' + for a, v in attrs: + if a == 'class': + class_ = v + s = '' + if tag == 'p' and self.prevtag and not self.prevtag[0]: + # Begin a new block for

tags after a closed tag. + # Avoid extra lines, e.g. after

 tags.
+            lastline = self.text.get('end-1c linestart', 'end-1c')
+            s = '\n\n' if lastline and not lastline.isspace() else '\n'
+        elif tag == 'span' and class_ == 'pre':
+            self.chartags = 'pre'
+        elif tag == 'span' and class_ == 'versionmodified':
+            self.chartags = 'em'
+        elif tag == 'em':
+            self.chartags = 'em'
+        elif tag in ['ul', 'ol']:
+            if class_.find('simple') != -1:
+                s = '\n'
+                self.simplelist = True
+            else:
+                self.simplelist = False
+            self.indent()
+        elif tag == 'dl':
+            if self.level > 0:
+                self.nested_dl = True
+        elif tag == 'li':
+            s = '\n* '
+        elif tag == 'dt':
+            s = '\n\n' if not self.nested_dl else '\n'  # Avoid extra line.
+            self.nested_dl = False
+        elif tag == 'dd':
+            self.indent()
+            s = '\n'
+        elif tag == 'pre':
+            self.pre = True
+            self.text.insert('end', '\n\n')
+            self.tags = 'preblock'
+        elif tag == 'a' and class_ == 'headerlink':
+            self.hdrlink = True
+        elif tag == 'h1':
+            self.tags = tag
+        elif tag in ['h2', 'h3']:
+            self.header = ''
+            self.text.insert('end', '\n\n')
+            self.tags = tag
+        self.text.insert('end', s, (self.tags, self.chartags))
+        self.prevtag = (True, tag)
+
+    def handle_endtag(self, tag):
+        "Handle endtags in help.html."
+        if tag in ['h1', 'h2', 'h3']:
+            assert self.level == 0
+            indent = ('        ' if tag == 'h3' else
+                      '    ' if tag == 'h2' else
+                      '')
+            self.toc.append((indent+self.header, self.text.index('insert')))
+            self.tags = ''
+        elif tag in ['span', 'em']:
+            self.chartags = ''
+        elif tag == 'a':
+            self.hdrlink = False
+        elif tag == 'pre':
+            self.pre = False
+            self.tags = ''
+        elif tag in ['ul', 'dd', 'ol']:
+            self.indent(-1)
+        self.prevtag = (False, tag)
+
+    def handle_data(self, data):
+        "Handle date segments in help.html."
+        if not self.hdrlink:
+            d = data if self.pre else data.replace('\n', ' ')
+            if self.tags == 'h1':
+                try:
+                    self.hprefix = d[:d.index(' ')]
+                    if not self.hprefix.isdigit():
+                        self.hprefix = ''
+                except ValueError:
+                    self.hprefix = ''
+            if self.tags in ['h1', 'h2', 'h3']:
+                if (self.hprefix != '' and
+                    d[0:len(self.hprefix)] == self.hprefix):
+                    d = d[len(self.hprefix):]
+                self.header += d.strip()
+            self.text.insert('end', d, (self.tags, self.chartags))
+
+
+class HelpText(Text):
+    "Display help.html."
+    def __init__(self, parent, filename):
+        "Configure tags and feed file to parser."
+        uwide = idleConf.GetOption('main', 'EditorWindow', 'width', type='int')
+        uhigh = idleConf.GetOption('main', 'EditorWindow', 'height', type='int')
+        uhigh = 3 * uhigh // 4  # Lines average 4/3 of editor line height.
+        Text.__init__(self, parent, wrap='word', highlightthickness=0,
+                      padx=5, borderwidth=0, width=uwide, height=uhigh)
+
+        normalfont = self.findfont(['TkDefaultFont', 'arial', 'helvetica'])
+        fixedfont = self.findfont(['TkFixedFont', 'monaco', 'courier'])
+        color_config(self)
+        self['font'] = (normalfont, 12)
+        self.tag_configure('em', font=(normalfont, 12, 'italic'))
+        self.tag_configure('h1', font=(normalfont, 20, 'bold'))
+        self.tag_configure('h2', font=(normalfont, 18, 'bold'))
+        self.tag_configure('h3', font=(normalfont, 15, 'bold'))
+        self.tag_configure('pre', font=(fixedfont, 12))
+        preback = self['selectbackground']
+        self.tag_configure('preblock', font=(fixedfont, 10), lmargin1=25,
+                           background=preback)
+        self.tag_configure('l1', lmargin1=25, lmargin2=25)
+        self.tag_configure('l2', lmargin1=50, lmargin2=50)
+        self.tag_configure('l3', lmargin1=75, lmargin2=75)
+        self.tag_configure('l4', lmargin1=100, lmargin2=100)
+
+        self.parser = HelpParser(self)
+        with open(filename, encoding='utf-8') as f:
+            contents = f.read()
+        self.parser.feed(contents)
+        self['state'] = 'disabled'
+
+    def findfont(self, names):
+        "Return name of first font family derived from names."
+        for name in names:
+            if name.lower() in (x.lower() for x in tkfont.names(root=self)):
+                font = tkfont.Font(name=name, exists=True, root=self)
+                return font.actual()['family']
+            elif name.lower() in (x.lower()
+                                  for x in tkfont.families(root=self)):
+                return name
+
+
+class HelpFrame(Frame):
+    "Display html text, scrollbar, and toc."
+    def __init__(self, parent, filename):
+        Frame.__init__(self, parent)
+        self.text = text = HelpText(self, filename)
+        self.style = Style(parent)
+        self['style'] = 'helpframe.TFrame'
+        self.style.configure('helpframe.TFrame', background=text['background'])
+        self.toc = toc = self.toc_menu(text)
+        self.scroll = scroll = Scrollbar(self, command=text.yview)
+        text['yscrollcommand'] = scroll.set
+
+        self.rowconfigure(0, weight=1)
+        self.columnconfigure(1, weight=1)  # Only expand the text widget.
+        toc.grid(row=0, column=0, sticky='nw')
+        text.grid(row=0, column=1, sticky='nsew')
+        scroll.grid(row=0, column=2, sticky='ns')
+
+    def toc_menu(self, text):
+        "Create table of contents as drop-down menu."
+        toc = Menubutton(self, text='TOC')
+        drop = Menu(toc, tearoff=False)
+        for lbl, dex in text.parser.toc:
+            drop.add_command(label=lbl, command=lambda dex=dex:text.yview(dex))
+        toc['menu'] = drop
+        return toc
+
+
+class HelpWindow(Toplevel):
+    "Display frame with rendered html."
+    def __init__(self, parent, filename, title):
+        Toplevel.__init__(self, parent)
+        self.wm_title(title)
+        self.protocol("WM_DELETE_WINDOW", self.destroy)
+        self.frame = HelpFrame(self, filename)
+        self.frame.grid(column=0, row=0, sticky='nsew')
+        self.grid_columnconfigure(0, weight=1)
+        self.grid_rowconfigure(0, weight=1)
+
+
+def copy_strip():  # pragma: no cover
+    """Copy the text part of idle.html to idlelib/help.html while stripping trailing whitespace.
+
+    Files with trailing whitespace cannot be pushed to the git cpython
+    repository.  For 3.x (on Windows), help.html is generated, after
+    editing idle.rst on the master branch, with
+      sphinx-build -bhtml . build/html
+      python_d.exe -c "from idlelib.help import copy_strip; copy_strip()"
+    Check build/html/library/idle.html, the help.html diff, and the text
+    displayed by Help => IDLE Help.  Add a blurb and create a PR.
+
+    It can be worthwhile to occasionally generate help.html without
+    touching idle.rst.  Changes to the master version and to the doc
+    build system may result in changes that should not change
+    the displayed text, but might break HelpParser.
+
+    As long as master and maintenance versions of idle.rst remain the
+    same, help.html can be backported.  The internal Python version
+    number is not displayed.  If maintenance idle.rst diverges from
+    the master version, then instead of backporting help.html from
+    master, repeat the procedure above to generate a maintenance
+    version.
+    """
+    src = join(abspath(dirname(dirname(dirname(__file__)))),
+            'Doc', 'build', 'html', 'library', 'idle.html')
+    dst = join(abspath(dirname(__file__)), 'help.html')
+
+    with open(src, 'r', encoding="utf-8") as inn, open(dst, 'w', encoding="utf-8") as out:
+        copy = False
+        for line in inn:
+            if '
' in line: copy = True + if '
' in line: break + if copy: out.write(line.strip() + '\n') + + print(f'{src} copied to {dst}') + + +def show_idlehelp(parent): + "Create HelpWindow; called from Idle Help event handler." + filename = join(abspath(dirname(__file__)), 'help.html') + if not isfile(filename): # pragma: no cover + # Try copy_strip, present message. + return + return HelpWindow(parent, filename, 'IDLE Doc (%s)' % python_version()) + + +def _get_dochome(): + "Return path to local docs if present, otherwise link to docs.python.org." + + dochome = os.path.join(sys.base_prefix, 'Doc', 'index.html') + if sys.platform.count('linux'): + # look for html docs in a couple of standard places + pyver = 'python-docs-' + '%s.%s.%s' % sys.version_info[:3] + if os.path.isdir('/var/www/html/python/'): # rpm package manager + dochome = '/var/www/html/python/index.html' + else: + basepath = '/usr/share/doc/' # dnf/apt package managers + dochome = os.path.join(basepath, pyver, 'Doc', 'index.html') + + elif sys.platform[:3] == 'win': + import winreg # Windows only, block only executed once. + docfile = '' + KEY = (rf"Software\Python\PythonCore\{sys.winver}" + r"\Help\Main Python Documentation") + try: + docfile = winreg.QueryValue(winreg.HKEY_CURRENT_USER, KEY) + except FileNotFoundError: + try: + docfile = winreg.QueryValue(winreg.HKEY_LOCAL_MACHINE, KEY) + except FileNotFoundError: + pass + if os.path.isfile(docfile): + dochome = docfile + elif sys.platform == 'darwin': + # documentation may be stored inside a python framework + dochome = os.path.join(sys.base_prefix, + 'Resources/English.lproj/Documentation/index.html') + dochome = os.path.normpath(dochome) + if os.path.isfile(dochome): + if sys.platform == 'darwin': + # Safari requires real file:-URLs + return 'file://' + dochome + return dochome + else: + return "https://docs.python.org/%d.%d/" % sys.version_info[:2] + + +if __name__ == '__main__': + from unittest import main + main('idlelib.idle_test.test_help', verbosity=2, exit=False) + + from idlelib.idle_test.htest import run + run(show_idlehelp) diff --git a/Lib/idlelib/help_about.py b/Lib/idlelib/help_about.py new file mode 100644 index 000000000..750da3732 --- /dev/null +++ b/Lib/idlelib/help_about.py @@ -0,0 +1,211 @@ +"""About Dialog for IDLE + +""" +import os +import sys +import webbrowser +from platform import python_version, architecture + +from tkinter import Toplevel, Frame, Label, Button, PhotoImage +from tkinter import SUNKEN, TOP, BOTTOM, LEFT, X, BOTH, W, EW, NSEW, E + +from idlelib import textview + +pyver = python_version() + +if sys.platform == 'darwin': + bits = '64' if sys.maxsize > 2**32 else '32' +else: + bits = architecture()[0][:2] + + +class AboutDialog(Toplevel): + """Modal about dialog for idle + + """ + def __init__(self, parent, title=None, *, _htest=False, _utest=False): + """Create popup, do not return until tk widget destroyed. + + parent - parent of this dialog + title - string which is title of popup dialog + _htest - bool, change box location when running htest + _utest - bool, don't wait_window when running unittest + """ + Toplevel.__init__(self, parent) + self.configure(borderwidth=5) + # place dialog below parent if running htest + self.geometry("+%d+%d" % ( + parent.winfo_rootx()+30, + parent.winfo_rooty()+(30 if not _htest else 100))) + self.bg = "#bbbbbb" + self.fg = "#000000" + self.create_widgets() + self.resizable(height=False, width=False) + self.title(title or + f'About IDLE {pyver} ({bits} bit)') + self.transient(parent) + self.grab_set() + self.protocol("WM_DELETE_WINDOW", self.ok) + self.parent = parent + self.button_ok.focus_set() + self.bind('', self.ok) # dismiss dialog + self.bind('', self.ok) # dismiss dialog + self._current_textview = None + self._utest = _utest + + if not _utest: + self.deiconify() + self.wait_window() + + def create_widgets(self): + frame = Frame(self, borderwidth=2, relief=SUNKEN) + frame_buttons = Frame(self) + frame_buttons.pack(side=BOTTOM, fill=X) + frame.pack(side=TOP, expand=True, fill=BOTH) + self.button_ok = Button(frame_buttons, text='Close', + command=self.ok) + self.button_ok.pack(padx=5, pady=5) + + frame_background = Frame(frame, bg=self.bg) + frame_background.pack(expand=True, fill=BOTH) + + header = Label(frame_background, text='IDLE', fg=self.fg, + bg=self.bg, font=('courier', 24, 'bold')) + header.grid(row=0, column=0, sticky=E, padx=10, pady=10) + + tkpatch = self._root().getvar('tk_patchLevel') + ext = '.png' if tkpatch >= '8.6' else '.gif' + icon = os.path.join(os.path.abspath(os.path.dirname(__file__)), + 'Icons', f'idle_48{ext}') + self.icon_image = PhotoImage(master=self._root(), file=icon) + logo = Label(frame_background, image=self.icon_image, bg=self.bg) + logo.grid(row=0, column=0, sticky=W, rowspan=2, padx=10, pady=10) + + byline_text = "Python's Integrated Development\nand Learning Environment" + 5*'\n' + byline = Label(frame_background, text=byline_text, justify=LEFT, + fg=self.fg, bg=self.bg) + byline.grid(row=2, column=0, sticky=W, columnspan=3, padx=10, pady=5) + + forums_url = "https://discuss.python.org" + forums = Button(frame_background, text='Python (and IDLE) Discussion', width=35, + highlightbackground=self.bg, + command=lambda: webbrowser.open(forums_url)) + forums.grid(row=6, column=0, sticky=W, padx=10, pady=10) + + + docs_url = ("https://docs.python.org/%d.%d/library/idle.html" % + sys.version_info[:2]) + docs = Button(frame_background, text='IDLE Documentation', width=35, + highlightbackground=self.bg, + command=lambda: webbrowser.open(docs_url)) + docs.grid(row=7, column=0, columnspan=2, sticky=W, padx=10, pady=10) + + + Frame(frame_background, borderwidth=1, relief=SUNKEN, + height=2, bg=self.bg).grid(row=8, column=0, sticky=EW, + columnspan=3, padx=5, pady=5) + + tclver = str(self.info_patchlevel()) + tkver = ' and ' + tkpatch if tkpatch != tclver else '' + versions = f"Python {pyver} with tcl/tk {tclver}{tkver}" + vers = Label(frame_background, text=versions, fg=self.fg, bg=self.bg) + vers.grid(row=9, column=0, sticky=W, padx=10, pady=0) + py_buttons = Frame(frame_background, bg=self.bg) + py_buttons.grid(row=10, column=0, columnspan=2, sticky=NSEW) + self.py_license = Button(py_buttons, text='License', width=8, + highlightbackground=self.bg, + command=self.show_py_license) + self.py_license.pack(side=LEFT, padx=10, pady=10) + self.py_copyright = Button(py_buttons, text='Copyright', width=8, + highlightbackground=self.bg, + command=self.show_py_copyright) + self.py_copyright.pack(side=LEFT, padx=10, pady=10) + self.py_credits = Button(py_buttons, text='Credits', width=8, + highlightbackground=self.bg, + command=self.show_py_credits) + self.py_credits.pack(side=LEFT, padx=10, pady=10) + + Frame(frame_background, borderwidth=1, relief=SUNKEN, + height=2, bg=self.bg).grid(row=11, column=0, sticky=EW, + columnspan=3, padx=5, pady=5) + + idle = Label(frame_background, text='IDLE', fg=self.fg, bg=self.bg) + idle.grid(row=12, column=0, sticky=W, padx=10, pady=0) + idle_buttons = Frame(frame_background, bg=self.bg) + idle_buttons.grid(row=13, column=0, columnspan=3, sticky=NSEW) + self.readme = Button(idle_buttons, text='Readme', width=8, + highlightbackground=self.bg, + command=self.show_readme) + self.readme.pack(side=LEFT, padx=10, pady=10) + self.idle_news = Button(idle_buttons, text='News', width=8, + highlightbackground=self.bg, + command=self.show_idle_news) + self.idle_news.pack(side=LEFT, padx=10, pady=10) + self.idle_credits = Button(idle_buttons, text='Credits', width=8, + highlightbackground=self.bg, + command=self.show_idle_credits) + self.idle_credits.pack(side=LEFT, padx=10, pady=10) + + # License, copyright, and credits are of type _sitebuiltins._Printer + def show_py_license(self): + "Handle License button event." + self.display_printer_text('About - License', license) + + def show_py_copyright(self): + "Handle Copyright button event." + self.display_printer_text('About - Copyright', copyright) + + def show_py_credits(self): + "Handle Python Credits button event." + self.display_printer_text('About - Python Credits', credits) + + # Encode CREDITS.txt to utf-8 for proper version of Loewis. + # Specify others as ascii until need utf-8, so catch errors. + def show_idle_credits(self): + "Handle Idle Credits button event." + self.display_file_text('About - Credits', 'CREDITS.txt', 'utf-8') + + def show_readme(self): + "Handle Readme button event." + self.display_file_text('About - Readme', 'README.txt', 'ascii') + + def show_idle_news(self): + "Handle News button event." + self.display_file_text('About - News', 'News3.txt', 'utf-8') + + def display_printer_text(self, title, printer): + """Create textview for built-in constants. + + Built-in constants have type _sitebuiltins._Printer. The + text is extracted from the built-in and then sent to a text + viewer with self as the parent and title as the title of + the popup. + """ + printer._Printer__setup() + text = '\n'.join(printer._Printer__lines) + self._current_textview = textview.view_text( + self, title, text, _utest=self._utest) + + def display_file_text(self, title, filename, encoding=None): + """Create textview for filename. + + The filename needs to be in the current directory. The path + is sent to a text viewer with self as the parent, title as + the title of the popup, and the file encoding. + """ + fn = os.path.join(os.path.abspath(os.path.dirname(__file__)), filename) + self._current_textview = textview.view_file( + self, title, fn, encoding, _utest=self._utest) + + def ok(self, event=None): + "Dismiss help_about dialog." + self.grab_release() + self.destroy() + + +if __name__ == '__main__': + from unittest import main + main('idlelib.idle_test.test_help_about', verbosity=2, exit=False) + + from idlelib.idle_test.htest import run + run(AboutDialog) diff --git a/Lib/idlelib/history.py b/Lib/idlelib/history.py new file mode 100644 index 000000000..5a9b32aaf --- /dev/null +++ b/Lib/idlelib/history.py @@ -0,0 +1,106 @@ +"Implement Idle Shell history mechanism with History class" + +from idlelib.config import idleConf + + +class History: + ''' Implement Idle Shell history mechanism. + + store - Store source statement (called from pyshell.resetoutput). + fetch - Fetch stored statement matching prefix already entered. + history_next - Bound to <> event (default Alt-N). + history_prev - Bound to <> event (default Alt-P). + ''' + def __init__(self, text): + '''Initialize data attributes and bind event methods. + + .text - Idle wrapper of tk Text widget, with .bell(). + .history - source statements, possibly with multiple lines. + .prefix - source already entered at prompt; filters history list. + .pointer - index into history. + .cyclic - wrap around history list (or not). + ''' + self.text = text + self.history = [] + self.prefix = None + self.pointer = None + self.cyclic = idleConf.GetOption("main", "History", "cyclic", 1, "bool") + text.bind("<>", self.history_prev) + text.bind("<>", self.history_next) + + def history_next(self, event): + "Fetch later statement; start with earliest if cyclic." + self.fetch(reverse=False) + return "break" + + def history_prev(self, event): + "Fetch earlier statement; start with most recent." + self.fetch(reverse=True) + return "break" + + def fetch(self, reverse): + '''Fetch statement and replace current line in text widget. + + Set prefix and pointer as needed for successive fetches. + Reset them to None, None when returning to the start line. + Sound bell when return to start line or cannot leave a line + because cyclic is False. + ''' + nhist = len(self.history) + pointer = self.pointer + prefix = self.prefix + if pointer is not None and prefix is not None: + if self.text.compare("insert", "!=", "end-1c") or \ + self.text.get("iomark", "end-1c") != self.history[pointer]: + pointer = prefix = None + self.text.mark_set("insert", "end-1c") # != after cursor move + if pointer is None or prefix is None: + prefix = self.text.get("iomark", "end-1c") + if reverse: + pointer = nhist # will be decremented + else: + if self.cyclic: + pointer = -1 # will be incremented + else: # abort history_next + self.text.bell() + return + nprefix = len(prefix) + while True: + pointer += -1 if reverse else 1 + if pointer < 0 or pointer >= nhist: + self.text.bell() + if not self.cyclic and pointer < 0: # abort history_prev + return + else: + if self.text.get("iomark", "end-1c") != prefix: + self.text.delete("iomark", "end-1c") + self.text.insert("iomark", prefix, "stdin") + pointer = prefix = None + break + item = self.history[pointer] + if item[:nprefix] == prefix and len(item) > nprefix: + self.text.delete("iomark", "end-1c") + self.text.insert("iomark", item, "stdin") + break + self.text.see("insert") + self.text.tag_remove("sel", "1.0", "end") + self.pointer = pointer + self.prefix = prefix + + def store(self, source): + "Store Shell input statement into history list." + source = source.strip() + if len(source) > 2: + # avoid duplicates + try: + self.history.remove(source) + except ValueError: + pass + self.history.append(source) + self.pointer = None + self.prefix = None + + +if __name__ == "__main__": + from unittest import main + main('idlelib.idle_test.test_history', verbosity=2, exit=False) diff --git a/Lib/idlelib/hyperparser.py b/Lib/idlelib/hyperparser.py new file mode 100644 index 000000000..76144ee8f --- /dev/null +++ b/Lib/idlelib/hyperparser.py @@ -0,0 +1,312 @@ +"""Provide advanced parsing abilities for ParenMatch and other extensions. + +HyperParser uses PyParser. PyParser mostly gives information on the +proper indentation of code. HyperParser gives additional information on +the structure of code. +""" +from keyword import iskeyword +import string + +from idlelib import pyparse + +# all ASCII chars that may be in an identifier +_ASCII_ID_CHARS = frozenset(string.ascii_letters + string.digits + "_") +# all ASCII chars that may be the first char of an identifier +_ASCII_ID_FIRST_CHARS = frozenset(string.ascii_letters + "_") + +# lookup table for whether 7-bit ASCII chars are valid in a Python identifier +_IS_ASCII_ID_CHAR = [(chr(x) in _ASCII_ID_CHARS) for x in range(128)] +# lookup table for whether 7-bit ASCII chars are valid as the first +# char in a Python identifier +_IS_ASCII_ID_FIRST_CHAR = \ + [(chr(x) in _ASCII_ID_FIRST_CHARS) for x in range(128)] + + +class HyperParser: + def __init__(self, editwin, index): + "To initialize, analyze the surroundings of the given index." + + self.editwin = editwin + self.text = text = editwin.text + + parser = pyparse.Parser(editwin.indentwidth, editwin.tabwidth) + + def index2line(index): + return int(float(index)) + lno = index2line(text.index(index)) + + if not editwin.prompt_last_line: + for context in editwin.num_context_lines: + startat = max(lno - context, 1) + startatindex = repr(startat) + ".0" + stopatindex = "%d.end" % lno + # We add the newline because PyParse requires a newline + # at end. We add a space so that index won't be at end + # of line, so that its status will be the same as the + # char before it, if should. + parser.set_code(text.get(startatindex, stopatindex)+' \n') + bod = parser.find_good_parse_start( + editwin._build_char_in_string_func(startatindex)) + if bod is not None or startat == 1: + break + parser.set_lo(bod or 0) + else: + r = text.tag_prevrange("console", index) + if r: + startatindex = r[1] + else: + startatindex = "1.0" + stopatindex = "%d.end" % lno + # We add the newline because PyParse requires it. We add a + # space so that index won't be at end of line, so that its + # status will be the same as the char before it, if should. + parser.set_code(text.get(startatindex, stopatindex)+' \n') + parser.set_lo(0) + + # We want what the parser has, minus the last newline and space. + self.rawtext = parser.code[:-2] + # Parser.code apparently preserves the statement we are in, so + # that stopatindex can be used to synchronize the string with + # the text box indices. + self.stopatindex = stopatindex + self.bracketing = parser.get_last_stmt_bracketing() + # find which pairs of bracketing are openers. These always + # correspond to a character of rawtext. + self.isopener = [i>0 and self.bracketing[i][1] > + self.bracketing[i-1][1] + for i in range(len(self.bracketing))] + + self.set_index(index) + + def set_index(self, index): + """Set the index to which the functions relate. + + The index must be in the same statement. + """ + indexinrawtext = (len(self.rawtext) - + len(self.text.get(index, self.stopatindex))) + if indexinrawtext < 0: + raise ValueError("Index %s precedes the analyzed statement" + % index) + self.indexinrawtext = indexinrawtext + # find the rightmost bracket to which index belongs + self.indexbracket = 0 + while (self.indexbracket < len(self.bracketing)-1 and + self.bracketing[self.indexbracket+1][0] < self.indexinrawtext): + self.indexbracket += 1 + if (self.indexbracket < len(self.bracketing)-1 and + self.bracketing[self.indexbracket+1][0] == self.indexinrawtext and + not self.isopener[self.indexbracket+1]): + self.indexbracket += 1 + + def is_in_string(self): + """Is the index given to the HyperParser in a string?""" + # The bracket to which we belong should be an opener. + # If it's an opener, it has to have a character. + return (self.isopener[self.indexbracket] and + self.rawtext[self.bracketing[self.indexbracket][0]] + in ('"', "'")) + + def is_in_code(self): + """Is the index given to the HyperParser in normal code?""" + return (not self.isopener[self.indexbracket] or + self.rawtext[self.bracketing[self.indexbracket][0]] + not in ('#', '"', "'")) + + def get_surrounding_brackets(self, openers='([{', mustclose=False): + """Return bracket indexes or None. + + If the index given to the HyperParser is surrounded by a + bracket defined in openers (or at least has one before it), + return the indices of the opening bracket and the closing + bracket (or the end of line, whichever comes first). + + If it is not surrounded by brackets, or the end of line comes + before the closing bracket and mustclose is True, returns None. + """ + + bracketinglevel = self.bracketing[self.indexbracket][1] + before = self.indexbracket + while (not self.isopener[before] or + self.rawtext[self.bracketing[before][0]] not in openers or + self.bracketing[before][1] > bracketinglevel): + before -= 1 + if before < 0: + return None + bracketinglevel = min(bracketinglevel, self.bracketing[before][1]) + after = self.indexbracket + 1 + while (after < len(self.bracketing) and + self.bracketing[after][1] >= bracketinglevel): + after += 1 + + beforeindex = self.text.index("%s-%dc" % + (self.stopatindex, len(self.rawtext)-self.bracketing[before][0])) + if (after >= len(self.bracketing) or + self.bracketing[after][0] > len(self.rawtext)): + if mustclose: + return None + afterindex = self.stopatindex + else: + # We are after a real char, so it is a ')' and we give the + # index before it. + afterindex = self.text.index( + "%s-%dc" % (self.stopatindex, + len(self.rawtext)-(self.bracketing[after][0]-1))) + + return beforeindex, afterindex + + # the set of built-in identifiers which are also keywords, + # i.e. keyword.iskeyword() returns True for them + _ID_KEYWORDS = frozenset({"True", "False", "None"}) + + @classmethod + def _eat_identifier(cls, str, limit, pos): + """Given a string and pos, return the number of chars in the + identifier which ends at pos, or 0 if there is no such one. + + This ignores non-identifier eywords are not identifiers. + """ + is_ascii_id_char = _IS_ASCII_ID_CHAR + + # Start at the end (pos) and work backwards. + i = pos + + # Go backwards as long as the characters are valid ASCII + # identifier characters. This is an optimization, since it + # is faster in the common case where most of the characters + # are ASCII. + while i > limit and ( + ord(str[i - 1]) < 128 and + is_ascii_id_char[ord(str[i - 1])] + ): + i -= 1 + + # If the above loop ended due to reaching a non-ASCII + # character, continue going backwards using the most generic + # test for whether a string contains only valid identifier + # characters. + if i > limit and ord(str[i - 1]) >= 128: + while i - 4 >= limit and ('a' + str[i - 4:pos]).isidentifier(): + i -= 4 + if i - 2 >= limit and ('a' + str[i - 2:pos]).isidentifier(): + i -= 2 + if i - 1 >= limit and ('a' + str[i - 1:pos]).isidentifier(): + i -= 1 + + # The identifier candidate starts here. If it isn't a valid + # identifier, don't eat anything. At this point that is only + # possible if the first character isn't a valid first + # character for an identifier. + if not str[i:pos].isidentifier(): + return 0 + elif i < pos: + # All characters in str[i:pos] are valid ASCII identifier + # characters, so it is enough to check that the first is + # valid as the first character of an identifier. + if not _IS_ASCII_ID_FIRST_CHAR[ord(str[i])]: + return 0 + + # All keywords are valid identifiers, but should not be + # considered identifiers here, except for True, False and None. + if i < pos and ( + iskeyword(str[i:pos]) and + str[i:pos] not in cls._ID_KEYWORDS + ): + return 0 + + return pos - i + + # This string includes all chars that may be in a white space + _whitespace_chars = " \t\n\\" + + def get_expression(self): + """Return a string with the Python expression which ends at the + given index, which is empty if there is no real one. + """ + if not self.is_in_code(): + raise ValueError("get_expression should only be called " + "if index is inside a code.") + + rawtext = self.rawtext + bracketing = self.bracketing + + brck_index = self.indexbracket + brck_limit = bracketing[brck_index][0] + pos = self.indexinrawtext + + last_identifier_pos = pos + postdot_phase = True + + while True: + # Eat whitespaces, comments, and if postdot_phase is False - a dot + while True: + if pos>brck_limit and rawtext[pos-1] in self._whitespace_chars: + # Eat a whitespace + pos -= 1 + elif (not postdot_phase and + pos > brck_limit and rawtext[pos-1] == '.'): + # Eat a dot + pos -= 1 + postdot_phase = True + # The next line will fail if we are *inside* a comment, + # but we shouldn't be. + elif (pos == brck_limit and brck_index > 0 and + rawtext[bracketing[brck_index-1][0]] == '#'): + # Eat a comment + brck_index -= 2 + brck_limit = bracketing[brck_index][0] + pos = bracketing[brck_index+1][0] + else: + # If we didn't eat anything, quit. + break + + if not postdot_phase: + # We didn't find a dot, so the expression end at the + # last identifier pos. + break + + ret = self._eat_identifier(rawtext, brck_limit, pos) + if ret: + # There is an identifier to eat + pos = pos - ret + last_identifier_pos = pos + # Now, to continue the search, we must find a dot. + postdot_phase = False + # (the loop continues now) + + elif pos == brck_limit: + # We are at a bracketing limit. If it is a closing + # bracket, eat the bracket, otherwise, stop the search. + level = bracketing[brck_index][1] + while brck_index > 0 and bracketing[brck_index-1][1] > level: + brck_index -= 1 + if bracketing[brck_index][0] == brck_limit: + # We were not at the end of a closing bracket + break + pos = bracketing[brck_index][0] + brck_index -= 1 + brck_limit = bracketing[brck_index][0] + last_identifier_pos = pos + if rawtext[pos] in "([": + # [] and () may be used after an identifier, so we + # continue. postdot_phase is True, so we don't allow a dot. + pass + else: + # We can't continue after other types of brackets + if rawtext[pos] in "'\"": + # Scan a string prefix + while pos > 0 and rawtext[pos - 1] in "rRbBuU": + pos -= 1 + last_identifier_pos = pos + break + + else: + # We've found an operator or something. + break + + return rawtext[last_identifier_pos:self.indexinrawtext] + + +if __name__ == '__main__': + from unittest import main + main('idlelib.idle_test.test_hyperparser', verbosity=2) diff --git a/Lib/idlelib/idle.bat b/Lib/idlelib/idle.bat new file mode 100644 index 000000000..3d619a37e --- /dev/null +++ b/Lib/idlelib/idle.bat @@ -0,0 +1,4 @@ +@echo off +rem Start IDLE using the appropriate Python interpreter +set CURRDIR=%~dp0 +start "IDLE" "%CURRDIR%..\..\pythonw.exe" "%CURRDIR%idle.pyw" %1 %2 %3 %4 %5 %6 %7 %8 %9 diff --git a/Lib/idlelib/idle.py b/Lib/idlelib/idle.py new file mode 100644 index 000000000..485d5a75a --- /dev/null +++ b/Lib/idlelib/idle.py @@ -0,0 +1,14 @@ +import os.path +import sys + + +# Enable running IDLE with idlelib in a non-standard location. +# This was once used to run development versions of IDLE. +# Because PEP 434 declared idle.py a public interface, +# removal should require deprecation. +idlelib_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if idlelib_dir not in sys.path: + sys.path.insert(0, idlelib_dir) + +from idlelib.pyshell import main # This is subject to change +main() diff --git a/Lib/idlelib/idle.pyw b/Lib/idlelib/idle.pyw new file mode 100644 index 000000000..e73c049b7 --- /dev/null +++ b/Lib/idlelib/idle.pyw @@ -0,0 +1,17 @@ +try: + import idlelib.pyshell +except ImportError: + # IDLE is not installed, but maybe pyshell is on sys.path: + from . import pyshell + import os + idledir = os.path.dirname(os.path.abspath(pyshell.__file__)) + if idledir != os.getcwd(): + # We're not in the IDLE directory, help the subprocess find run.py + pypath = os.environ.get('PYTHONPATH', '') + if pypath: + os.environ['PYTHONPATH'] = pypath + ':' + idledir + else: + os.environ['PYTHONPATH'] = idledir + pyshell.main() +else: + idlelib.pyshell.main() diff --git a/Lib/idlelib/idle_test/README.txt b/Lib/idlelib/idle_test/README.txt new file mode 100644 index 000000000..cacd06db8 --- /dev/null +++ b/Lib/idlelib/idle_test/README.txt @@ -0,0 +1,241 @@ +README FOR IDLE TESTS IN IDLELIB.IDLE_TEST + +0. Quick Start + +Automated unit tests were added in 3.3 for Python 3.x. +To run the tests from a command line: + +python -m test.test_idle + +Human-mediated tests were added later in 3.4. + +python -m idlelib.idle_test.htest + + +1. Test Files + +The idle directory, idlelib, has over 60 xyz.py files. The idle_test +subdirectory contains test_xyz.py for each implementation file xyz.py. +To add a test for abc.py, open idle_test/template.py and immediately +Save As test_abc.py. Insert 'abc' on the first line, and replace +'zzdummy' with 'abc. + +Remove the imports of requires and tkinter if not needed. Otherwise, +add to the tkinter imports as needed. + +Add a prefix to 'Test' for the initial test class. The template class +contains code needed or possibly needed for gui tests. See the next +section if doing gui tests. If not, and not needed for further classes, +this code can be removed. + +Add the following at the end of abc.py. If an htest was added first, +insert the import and main lines before the htest lines. + +if __name__ == "__main__": + from unittest import main + main('idlelib.idle_test.test_abc', verbosity=2, exit=False) + +The ', exit=False' is only needed if an htest follows. + + + +2. GUI Tests + +When run as part of the Python test suite, Idle GUI tests need to run +test.support.requires('gui'). A test is a GUI test if it creates a +tkinter.Tk root or master object either directly or indirectly by +instantiating a tkinter or idle class. GUI tests cannot run in test +processes that either have no graphical environment available or are not +allowed to use it. + +To guard a module consisting entirely of GUI tests, start with + +from test.support import requires +requires('gui') + +To guard a test class, put "requires('gui')" in its setUpClass function. +The template.py file does this. + +To avoid interfering with other GUI tests, all GUI objects must be +destroyed and deleted by the end of the test. The Tk root created in a +setUpX function should be destroyed in the corresponding tearDownX and +the module or class attribute deleted. Others widgets should descend +from the single root and the attributes deleted BEFORE root is +destroyed. See https://bugs.python.org/issue20567. + + @classmethod + def setUpClass(cls): + requires('gui') + cls.root = tk.Tk() + cls.text = tk.Text(root) + + @classmethod + def tearDownClass(cls): + del cls.text + cls.root.update_idletasks() + cls.root.destroy() + del cls.root + +The update_idletasks call is sometimes needed to prevent the following +warning either when running a test alone or as part of the test suite +(#27196). It should not hurt if not needed. + + can't invoke "event" command: application has been destroyed + ... + "ttk::ThemeChanged" + +If a test creates instance 'e' of EditorWindow, call 'e._close()' before +or as the first part of teardown. The effect of omitting this depends +on the later shutdown. Then enable the after_cancel loop in the +template. This prevents messages like the following. + +bgerror failed to handle background error. + Original error: invalid command name "106096696timer_event" + Error in bgerror: can't invoke "tk" command: application has been destroyed + +Requires('gui') causes the test(s) it guards to be skipped if any of +these conditions are met: + + - The tests are being run by regrtest.py, and it was started without + enabling the "gui" resource with the "-u" command line option. + + - The tests are being run on Windows by a service that is not allowed + to interact with the graphical environment. + + - The tests are being run on Linux and X Windows is not available. + + - The tests are being run on Mac OSX in a process that cannot make a + window manager connection. + + - tkinter.Tk cannot be successfully instantiated for some reason. + + - test.support.use_resources has been set by something other than + regrtest.py and does not contain "gui". + +Tests of non-GUI operations should avoid creating tk widgets. Incidental +uses of tk variables and messageboxes can be replaced by the mock +classes in idle_test/mock_tk.py. The mock text handles some uses of the +tk Text widget. + + +3. Running Unit Tests + +Assume that xyz.py and test_xyz.py both end with a unittest.main() call. +Running either from an Idle editor runs all tests in the test_xyz file +with the version of Python running Idle. Test output appears in the +Shell window. The 'verbosity=2' option lists all test methods in the +file, which is appropriate when developing tests. The 'exit=False' +option is needed in xyx.py files when an htest follows. + +The following command lines also run all test methods, including +GUI tests, in test_xyz.py. (Both '-m idlelib' and '-m idlelib.idle' +start Idle and so cannot run tests.) + +python -m idlelib.xyz +python -m idlelib.idle_test.test_xyz + +The following runs all idle_test/test_*.py tests interactively. + +>>> import unittest +>>> unittest.main('idlelib.idle_test', verbosity=2) + +The following run all Idle tests at a command line. Option '-v' is the +same as 'verbosity=2'. + +python -m unittest -v idlelib.idle_test +python -m test -v -ugui test_idle +python -m test.test_idle + +IDLE tests are 'discovered' by idlelib.idle_test.__init__.load_tests +when this is imported into test.test_idle. Normally, neither file +should be changed when working on individual test modules. The third +command runs unittest indirectly through regrtest. The same happens when +the entire test suite is run with 'python -m test'. So that command must +work for buildbots to stay green. IDLE tests must not disturb the +environment in a way that makes other tests fail (GH-62281). + +To test subsets of modules, see idlelib.idle_test.__init__. This +can be used to find refleaks or possible sources of "Theme changed" +tcl messages (GH-71383). + +To run an individual Testcase or test method, extend the dotted name +given to unittest on the command line or use the test -m option. The +latter allows use of other regrtest options. When using the latter, +all components of the pattern must be present, but any can be replaced +by '*'. + +python -m unittest -v idlelib.idle_test.test_xyz.Test_case.test_meth +python -m test -m idlelib.idle_test.text_xyz.Test_case.test_meth test_idle + +The test suite can be run in an IDLE user process from Shell. +>>> import test.autotest # Issue 25588, 2017/10/13, 3.6.4, 3.7.0a2. +There are currently failures not usually present, and this does not +work when run from the editor. + + +4. Human-mediated Tests + +Human-mediated tests are widget tests that cannot be automated but need +human verification. They are contained in idlelib/idle_test/htest.py, +which has instructions. (Some modules need an auxiliary function, +identified with "# htest # on the header line.) The set is about +complete, though some tests need improvement. To run all htests, run the +htest file from an editor or from the command line with: + +python -m idlelib.idle_test.htest + + +5. Test Coverage + +Install the coverage package into your Python 3.6 site-packages +directory. (Its exact location depends on the OS). +> python3 -m pip install coverage +(On Windows, replace 'python3 with 'py -3.6' or perhaps just 'python'.) + +The problem with running coverage with repository python is that +coverage uses absolute imports for its submodules, hence it needs to be +in a directory in sys.path. One solution: copy the package to the +directory containing the cpython repository. Call it 'dev'. Then run +coverage either directly or from a script in that directory so that +'dev' is prepended to sys.path. + +Either edit or add dev/.coveragerc so it looks something like this. +--- +# .coveragerc sets coverage options. +[run] +branch = True + +[report] +# Regexes for lines to exclude from consideration +exclude_lines = + # Don't complain if non-runnable code isn't run: + if 0: + if __name__ == .__main__.: + + .*# htest # + if not _utest: + if _htest: +--- +The additions for IDLE are 'branch = True', to test coverage both ways, +and the last three exclude lines, to exclude things peculiar to IDLE +that are not executed during tests. + +A script like the following cover.bat (for Windows) is very handy. +--- +@echo off +rem Usage: cover filename [test_ suffix] # proper case required by coverage +rem filename without .py, 2nd parameter if test is not test_filename +setlocal +set py=f:\dev\3x\pcbuild\win32\python_d.exe +set src=idlelib.%1 +if "%2" EQU "" set tst=f:/dev/3x/Lib/idlelib/idle_test/test_%1.py +if "%2" NEQ "" set tst=f:/dev/ex/Lib/idlelib/idle_test/test_%2.py + +%py% -m coverage run --pylib --source=%src% %tst% +%py% -m coverage report --show-missing +%py% -m coverage html +start htmlcov\3x_Lib_idlelib_%1_py.html +rem Above opens new report; htmlcov\index.html displays report index +--- +The second parameter was added for tests of module x not named test_x. +(There were several before modules were renamed, now only one is left.) diff --git a/Lib/idlelib/idle_test/__init__.py b/Lib/idlelib/idle_test/__init__.py new file mode 100644 index 000000000..8eed2699c --- /dev/null +++ b/Lib/idlelib/idle_test/__init__.py @@ -0,0 +1,23 @@ +"""idlelib.idle_test implements test.test_idle, which tests the IDLE +application as part of the stdlib test suite. +Run IDLE tests alone with "python -m test.test_idle (-v)". + +This package and its contained modules are subject to change and +any direct use is at your own risk. +""" +from os.path import dirname + +# test_idle imports load_tests for test discovery (default all). +# To run subsets of idlelib module tests, insert '[]' after '_'. +# Example: insert '[ac]' for modules beginning with 'a' or 'c'. +# Additional .discover/.addTest pairs with separate inserts work. +# Example: pairs with 'c' and 'g' test c* files and grep. + +def load_tests(loader, standard_tests, pattern): + this_dir = dirname(__file__) + top_dir = dirname(dirname(this_dir)) + module_tests = loader.discover(start_dir=this_dir, + pattern='test_*.py', # Insert here. + top_level_dir=top_dir) + standard_tests.addTests(module_tests) + return standard_tests diff --git a/Lib/idlelib/idle_test/example_noext b/Lib/idlelib/idle_test/example_noext new file mode 100644 index 000000000..7d2510e30 --- /dev/null +++ b/Lib/idlelib/idle_test/example_noext @@ -0,0 +1,4 @@ +#!usr/bin/env python + +def example_function(some_argument): + pass diff --git a/Lib/idlelib/idle_test/example_stub.pyi b/Lib/idlelib/idle_test/example_stub.pyi new file mode 100644 index 000000000..abcdbc175 --- /dev/null +++ b/Lib/idlelib/idle_test/example_stub.pyi @@ -0,0 +1,4 @@ +# An example file to test recognition of a .pyi file as Python source code. + +class Example: + def method(self, argument1: str, argument2: list[int]) -> None: ... diff --git a/Lib/idlelib/idle_test/htest.py b/Lib/idlelib/idle_test/htest.py new file mode 100644 index 000000000..b63ff9ec2 --- /dev/null +++ b/Lib/idlelib/idle_test/htest.py @@ -0,0 +1,442 @@ +"""Run human tests of Idle's window, dialog, and popup widgets. + +run(*tests) Create a master Tk() htest window. Within that, run each +callable in tests after finding the matching test spec in this file. If +tests is empty, run an htest for each spec dict in this file after +finding the matching callable in the module named in the spec. Close +the master window to end testing. + +In a tested module, let X be a global name bound to a callable (class or +function) whose .__name__ attribute is also X (the usual situation). The +first parameter of X must be 'parent' or 'master'. When called, the +first argument will be the root window. X must create a child +Toplevel(parent/master) (or subclass thereof). The Toplevel may be a +test widget or dialog, in which case the callable is the corresponding +class. Or the Toplevel may contain the widget to be tested or set up a +context in which a test widget is invoked. In this latter case, the +callable is a wrapper function that sets up the Toplevel and other +objects. Wrapper function names, such as _editor_window', should start +with '_' and be lowercase. + + +End the module with + +if __name__ == '__main__': + + from idlelib.idle_test.htest import run + run(callable) # There could be multiple comma-separated callables. + +To have wrapper functions ignored by coverage reports, tag the def +header like so: "def _wrapper(parent): # htest #". Use the same tag +for htest lines in widget code. Make sure that the 'if __name__' line +matches the above. Then have make sure that .coveragerc includes the +following: + +[report] +exclude_lines = + .*# htest # + if __name__ == .__main__.: + +(The "." instead of "'" is intentional and necessary.) + + +To run any X, this file must contain a matching instance of the +following template, with X.__name__ prepended to '_spec'. +When all tests are run, the prefix is use to get X. + +callable_spec = { + 'file': '', + 'kwds': {'title': ''}, + 'msg': "" + } + +file (no .py): run() imports file.py. +kwds: augmented with {'parent':root} and passed to X as **kwds. +title: an example kwd; some widgets need this, delete line if not. +msg: master window hints about testing the widget. + + +TODO test these modules and classes: + autocomplete_w.AutoCompleteWindow + debugger.Debugger + outwin.OutputWindow (indirectly being tested with grep test) + pyshell.PyShellEditorWindow +""" + +import idlelib.pyshell # Set Windows DPI awareness before Tk(). +from importlib import import_module +import textwrap +import tkinter as tk +from tkinter.ttk import Scrollbar +tk.NoDefaultRoot() + +AboutDialog_spec = { + 'file': 'help_about', + 'kwds': {'title': 'help_about test', + '_htest': True, + }, + 'msg': "Click on URL to open in default browser.\n" + "Verify x.y.z versions and test each button, including Close.\n " + } + +# TODO implement ^\; adding '' to function does not work. +_calltip_window_spec = { + 'file': 'calltip_w', + 'kwds': {}, + 'msg': "Typing '(' should display a calltip.\n" + "Typing ') should hide the calltip.\n" + "So should moving cursor out of argument area.\n" + "Force-open-calltip does not work here.\n" + } + +_color_delegator_spec = { + 'file': 'colorizer', + 'kwds': {}, + 'msg': "The text is sample Python code.\n" + "Ensure components like comments, keywords, builtins,\n" + "string, definitions, and break are correctly colored.\n" + "The default color scheme is in idlelib/config-highlight.def" + } + +ConfigDialog_spec = { + 'file': 'configdialog', + 'kwds': {'title': 'ConfigDialogTest', + '_htest': True,}, + 'msg': "IDLE preferences dialog.\n" + "In the 'Fonts/Tabs' tab, changing font face, should update the " + "font face of the text in the area below it.\nIn the " + "'Highlighting' tab, try different color schemes. Clicking " + "items in the sample program should update the choices above it." + "\nIn the 'Keys', 'General' and 'Extensions' tabs, test settings " + "of interest." + "\n[Ok] to close the dialog.[Apply] to apply the settings and " + "and [Cancel] to revert all changes.\nRe-run the test to ensure " + "changes made have persisted." + } + +CustomRun_spec = { + 'file': 'query', + 'kwds': {'title': 'Customize query.py Run', + '_htest': True}, + 'msg': "Enter with or [OK]. Print valid entry to Shell\n" + "Arguments are parsed into a list\n" + "Mode is currently restart True or False\n" + "Close dialog with valid entry, , [Cancel], [X]" + } + +_debug_object_browser_spec = { + 'file': 'debugobj', + 'kwds': {}, + 'msg': "Double click on items up to the lowest level.\n" + "Attributes of the objects and related information " + "will be displayed side-by-side at each level." + } + +# TODO Improve message +_dyn_option_menu_spec = { + 'file': 'dynoption', + 'kwds': {}, + 'msg': "Select one of the many options in the 'old option set'.\n" + "Click the button to change the option set.\n" + "Select one of the many options in the 'new option set'." + } + +# TODO edit wrapper +_editor_window_spec = { + 'file': 'editor', + 'kwds': {}, + 'msg': "Test editor functions of interest.\n" + "Best to close editor first." + } + +GetKeysWindow_spec = { + 'file': 'config_key', + 'kwds': {'title': 'Test keybindings', + 'action': 'find-again', + 'current_key_sequences': [['', '', '']], + '_htest': True, + }, + 'msg': "Test for different key modifier sequences.\n" + " is invalid.\n" + "No modifier key is invalid.\n" + "Shift key with [a-z],[0-9], function key, move key, tab, space " + "is invalid.\nNo validity checking if advanced key binding " + "entry is used." + } + +_grep_dialog_spec = { + 'file': 'grep', + 'kwds': {}, + 'msg': "Click the 'Show GrepDialog' button.\n" + "Test the various 'Find-in-files' functions.\n" + "The results should be displayed in a new '*Output*' window.\n" + "'Right-click'->'Go to file/line' in the search results\n " + "should open that file in a new EditorWindow." + } + +HelpSource_spec = { + 'file': 'query', + 'kwds': {'title': 'Help name and source', + 'menuitem': 'test', + 'filepath': __file__, + 'used_names': {'abc'}, + '_htest': True}, + 'msg': "Enter menu item name and help file path\n" + "'', > than 30 chars, and 'abc' are invalid menu item names.\n" + "'' and file does not exist are invalid path items.\n" + "Any url ('www...', 'http...') is accepted.\n" + "Test Browse with and without path, as cannot unittest.\n" + "[Ok] or prints valid entry to shell\n" + ", [Cancel], or [X] prints None to shell" + } + +_io_binding_spec = { + 'file': 'iomenu', + 'kwds': {}, + 'msg': "Test the following bindings.\n" + " to open file from dialog.\n" + "Edit the file.\n" + " to print the file.\n" + " to save the file.\n" + " to save-as another file.\n" + " to save-copy-as another file.\n" + "Check that changes were saved by opening the file elsewhere." + } + +_multi_call_spec = { + 'file': 'multicall', + 'kwds': {}, + 'msg': "The following should trigger a print to console or IDLE Shell.\n" + "Entering and leaving the text area, key entry, ,\n" + ", , , \n" + ", and focusing elsewhere." + } + +_module_browser_spec = { + 'file': 'browser', + 'kwds': {}, + 'msg': textwrap.dedent(""" + "Inspect names of module, class(with superclass if applicable), + "methods and functions. Toggle nested items. Double clicking + "on items prints a traceback for an exception that is ignored.""") + } + +_multistatus_bar_spec = { + 'file': 'statusbar', + 'kwds': {}, + 'msg': "Ensure presence of multi-status bar below text area.\n" + "Click 'Update Status' to change the status text" + } + +PathBrowser_spec = { + 'file': 'pathbrowser', + 'kwds': {'_htest': True}, + 'msg': "Test for correct display of all paths in sys.path.\n" + "Toggle nested items out to the lowest level.\n" + "Double clicking on an item prints a traceback\n" + "for an exception that is ignored." + } + +_percolator_spec = { + 'file': 'percolator', + 'kwds': {}, + 'msg': "There are two tracers which can be toggled using a checkbox.\n" + "Toggling a tracer 'on' by checking it should print tracer " + "output to the console or to the IDLE shell.\n" + "If both the tracers are 'on', the output from the tracer which " + "was switched 'on' later, should be printed first\n" + "Test for actions like text entry, and removal." + } + +Query_spec = { + 'file': 'query', + 'kwds': {'title': 'Query', + 'message': 'Enter something', + 'text0': 'Go', + '_htest': True}, + 'msg': "Enter with or [Ok]. Print valid entry to Shell\n" + "Blank line, after stripping, is ignored\n" + "Close dialog with valid entry, , [Cancel], [X]" + } + + +_replace_dialog_spec = { + 'file': 'replace', + 'kwds': {}, + 'msg': "Click the 'Replace' button.\n" + "Test various replace options in the 'Replace dialog'.\n" + "Click [Close] or [X] to close the 'Replace Dialog'." + } + +_scrolled_list_spec = { + 'file': 'scrolledlist', + 'kwds': {}, + 'msg': "You should see a scrollable list of items\n" + "Selecting (clicking) or double clicking an item " + "prints the name to the console or Idle shell.\n" + "Right clicking an item will display a popup." + } + +_search_dialog_spec = { + 'file': 'search', + 'kwds': {}, + 'msg': "Click the 'Search' button.\n" + "Test various search options in the 'Search dialog'.\n" + "Click [Close] or [X] to close the 'Search Dialog'." + } + +_searchbase_spec = { + 'file': 'searchbase', + 'kwds': {}, + 'msg': "Check the appearance of the base search dialog\n" + "Its only action is to close." + } + +show_idlehelp_spec = { + 'file': 'help', + 'kwds': {}, + 'msg': "If the help text displays, this works.\n" + "Text is selectable. Window is scrollable." + } + +_sidebar_number_scrolling_spec = { + 'file': 'sidebar', + 'kwds': {}, + 'msg': textwrap.dedent("""\ + 1. Click on the line numbers and drag down below the edge of the + window, moving the mouse a bit and then leaving it there for a + while. The text and line numbers should gradually scroll down, + with the selection updated continuously. + + 2. With the lines still selected, click on a line number above + or below the selected lines. Only the line whose number was + clicked should be selected. + + 3. Repeat step #1, dragging to above the window. The text and + line numbers should gradually scroll up, with the selection + updated continuously. + + 4. Repeat step #2, clicking a line number below the selection."""), + } + +_stackbrowser_spec = { + 'file': 'stackviewer', + 'kwds': {}, + 'msg': "A stacktrace for a NameError exception.\n" + "Should have NameError and 1 traceback line." + } + +_tooltip_spec = { + 'file': 'tooltip', + 'kwds': {}, + 'msg': "Place mouse cursor over both the buttons\n" + "A tooltip should appear with some text." + } + +_tree_widget_spec = { + 'file': 'tree', + 'kwds': {}, + 'msg': "The canvas is scrollable.\n" + "Click on folders up to the lowest level." + } + +_undo_delegator_spec = { + 'file': 'undo', + 'kwds': {}, + 'msg': "Click [Undo] to undo any action.\n" + "Click [Redo] to redo any action.\n" + "Click [Dump] to dump the current state " + "by printing to the console or the IDLE shell.\n" + } + +ViewWindow_spec = { + 'file': 'textview', + 'kwds': {'title': 'Test textview', + 'contents': 'The quick brown fox jumps over the lazy dog.\n'*35, + '_htest': True}, + 'msg': "Test for read-only property of text.\n" + "Select text, scroll window, close" + } + +_widget_redirector_spec = { + 'file': 'redirector', + 'kwds': {}, + 'msg': "Every text insert should be printed to the console " + "or the IDLE shell." + } + +def run(*tests): + "Run callables in tests." + root = tk.Tk() + root.title('IDLE htest') + root.resizable(0, 0) + + # A scrollable Label-like constant width text widget. + frameLabel = tk.Frame(root, padx=10) + frameLabel.pack() + text = tk.Text(frameLabel, wrap='word') + text.configure(bg=root.cget('bg'), relief='flat', height=4, width=70) + scrollbar = Scrollbar(frameLabel, command=text.yview) + text.config(yscrollcommand=scrollbar.set) + scrollbar.pack(side='right', fill='y', expand=False) + text.pack(side='left', fill='both', expand=True) + + test_list = [] # Make list of (spec, callable) tuples. + if tests: + for test in tests: + test_spec = globals()[test.__name__ + '_spec'] + test_spec['name'] = test.__name__ + test_list.append((test_spec, test)) + else: + for key, dic in globals().items(): + if key.endswith('_spec'): + test_name = key[:-5] + test_spec = dic + test_spec['name'] = test_name + mod = import_module('idlelib.' + test_spec['file']) + test = getattr(mod, test_name) + test_list.append((test_spec, test)) + test_list.reverse() # So can pop in proper order in next_test. + + test_name = tk.StringVar(root) + callable_object = None + test_kwds = None + + def next_test(): + nonlocal test_name, callable_object, test_kwds + if len(test_list) == 1: + next_button.pack_forget() + test_spec, callable_object = test_list.pop() + test_kwds = test_spec['kwds'] + test_name.set('Test ' + test_spec['name']) + + text['state'] = 'normal' # Enable text replacement. + text.delete('1.0', 'end') + text.insert("1.0", test_spec['msg']) + text['state'] = 'disabled' # Restore read-only property. + + def run_test(_=None): + widget = callable_object(root, **test_kwds) + try: + print(widget.result) # Only true for query classes(?). + except AttributeError: + pass + + def close(_=None): + root.destroy() + + button = tk.Button(root, textvariable=test_name, + default='active', command=run_test) + next_button = tk.Button(root, text="Next", command=next_test) + button.pack() + next_button.pack() + next_button.focus_set() + root.bind('', run_test) + root.bind('', close) + + next_test() + root.mainloop() + + +if __name__ == '__main__': + run() diff --git a/Lib/idlelib/idle_test/mock_idle.py b/Lib/idlelib/idle_test/mock_idle.py new file mode 100644 index 000000000..71fa480ce --- /dev/null +++ b/Lib/idlelib/idle_test/mock_idle.py @@ -0,0 +1,61 @@ +'''Mock classes that imitate idlelib modules or classes. + +Attributes and methods will be added as needed for tests. +''' + +from idlelib.idle_test.mock_tk import Text + +class Func: + '''Record call, capture args, return/raise result set by test. + + When mock function is called, set or use attributes: + self.called - increment call number even if no args, kwds passed. + self.args - capture positional arguments. + self.kwds - capture keyword arguments. + self.result - return or raise value set in __init__. + self.return_self - return self instead, to mock query class return. + + Most common use will probably be to mock instance methods. + Given class instance, can set and delete as instance attribute. + Mock_tk.Var and Mbox_func are special variants of this. + ''' + def __init__(self, result=None, return_self=False): + self.called = 0 + self.result = result + self.return_self = return_self + self.args = None + self.kwds = None + def __call__(self, *args, **kwds): + self.called += 1 + self.args = args + self.kwds = kwds + if isinstance(self.result, BaseException): + raise self.result + elif self.return_self: + return self + else: + return self.result + + +class Editor: + '''Minimally imitate editor.EditorWindow class. + ''' + def __init__(self, flist=None, filename=None, key=None, root=None, + text=None): # Allow real Text with mock Editor. + self.text = text or Text() + self.undo = UndoDelegator() + + def get_selection_indices(self): + first = self.text.index('1.0') + last = self.text.index('end') + return first, last + + +class UndoDelegator: + '''Minimally imitate undo.UndoDelegator class. + ''' + # A real undo block is only needed for user interaction. + def undo_block_start(*args): + pass + def undo_block_stop(*args): + pass diff --git a/Lib/idlelib/idle_test/mock_tk.py b/Lib/idlelib/idle_test/mock_tk.py new file mode 100644 index 000000000..8304734b8 --- /dev/null +++ b/Lib/idlelib/idle_test/mock_tk.py @@ -0,0 +1,307 @@ +"""Classes that replace tkinter gui objects used by an object being tested. + +A gui object is anything with a master or parent parameter, which is +typically required in spite of what the doc strings say. +""" +import re +from _tkinter import TclError + + +class Event: + '''Minimal mock with attributes for testing event handlers. + + This is not a gui object, but is used as an argument for callbacks + that access attributes of the event passed. If a callback ignores + the event, other than the fact that is happened, pass 'event'. + + Keyboard, mouse, window, and other sources generate Event instances. + Event instances have the following attributes: serial (number of + event), time (of event), type (of event as number), widget (in which + event occurred), and x,y (position of mouse). There are other + attributes for specific events, such as keycode for key events. + tkinter.Event.__doc__ has more but is still not complete. + ''' + def __init__(self, **kwds): + "Create event with attributes needed for test" + self.__dict__.update(kwds) + + +class Var: + "Use for String/Int/BooleanVar: incomplete" + def __init__(self, master=None, value=None, name=None): + self.master = master + self.value = value + self.name = name + def set(self, value): + self.value = value + def get(self): + return self.value + + +class Mbox_func: + """Generic mock for messagebox functions, which all have the same signature. + + Instead of displaying a message box, the mock's call method saves the + arguments as instance attributes, which test functions can then examine. + The test can set the result returned to ask function + """ + def __init__(self, result=None): + self.result = result # Return None for all show funcs + def __call__(self, title, message, *args, **kwds): + # Save all args for possible examination by tester + self.title = title + self.message = message + self.args = args + self.kwds = kwds + return self.result # Set by tester for ask functions + + +class Mbox: + """Mock for tkinter.messagebox with an Mbox_func for each function. + + Example usage in test_module.py for testing functions in module.py: + --- +from idlelib.idle_test.mock_tk import Mbox +import module + +orig_mbox = module.messagebox +showerror = Mbox.showerror # example, for attribute access in test methods + +class Test(unittest.TestCase): + + @classmethod + def setUpClass(cls): + module.messagebox = Mbox + + @classmethod + def tearDownClass(cls): + module.messagebox = orig_mbox + --- + For 'ask' functions, set func.result return value before calling the method + that uses the message function. When messagebox functions are the + only GUI calls in a method, this replacement makes the method GUI-free, + """ + askokcancel = Mbox_func() # True or False + askquestion = Mbox_func() # 'yes' or 'no' + askretrycancel = Mbox_func() # True or False + askyesno = Mbox_func() # True or False + askyesnocancel = Mbox_func() # True, False, or None + showerror = Mbox_func() # None + showinfo = Mbox_func() # None + showwarning = Mbox_func() # None + + +class Text: + """A semi-functional non-gui replacement for tkinter.Text text editors. + + The mock's data model is that a text is a list of \n-terminated lines. + The mock adds an empty string at the beginning of the list so that the + index of actual lines start at 1, as with Tk. The methods never see this. + Tk initializes files with a terminal \n that cannot be deleted. It is + invisible in the sense that one cannot move the cursor beyond it. + + This class is only tested (and valid) with strings of ascii chars. + For testing, we are not concerned with Tk Text's treatment of, + for instance, 0-width characters or character + accent. + """ + def __init__(self, master=None, cnf={}, **kw): + '''Initialize mock, non-gui, text-only Text widget. + + At present, all args are ignored. Almost all affect visual behavior. + There are just a few Text-only options that affect text behavior. + ''' + self.data = ['', '\n'] + + def index(self, index): + "Return string version of index decoded according to current text." + return "%s.%s" % self._decode(index, endflag=1) + + def _decode(self, index, endflag=0): + """Return a (line, char) tuple of int indexes into self.data. + + This implements .index without converting the result back to a string. + The result is constrained by the number of lines and linelengths of + self.data. For many indexes, the result is initially (1, 0). + + The input index may have any of several possible forms: + * line.char float: converted to 'line.char' string; + * 'line.char' string, where line and char are decimal integers; + * 'line.char lineend', where lineend='lineend' (and char is ignored); + * 'line.end', where end='end' (same as above); + * 'insert', the positions before terminal \n; + * 'end', whose meaning depends on the endflag passed to ._endex. + * 'sel.first' or 'sel.last', where sel is a tag -- not implemented. + """ + if isinstance(index, (float, bytes)): + index = str(index) + try: + index=index.lower() + except AttributeError: + raise TclError('bad text index "%s"' % index) from None + + lastline = len(self.data) - 1 # same as number of text lines + if index == 'insert': + return lastline, len(self.data[lastline]) - 1 + elif index == 'end': + return self._endex(endflag) + + line, char = index.split('.') + line = int(line) + + # Out of bounds line becomes first or last ('end') index + if line < 1: + return 1, 0 + elif line > lastline: + return self._endex(endflag) + + linelength = len(self.data[line]) -1 # position before/at \n + if char.endswith(' lineend') or char == 'end': + return line, linelength + # Tk requires that ignored chars before ' lineend' be valid int + if m := re.fullmatch(r'end-(\d*)c', char, re.A): # Used by hyperparser. + return line, linelength - int(m.group(1)) + + # Out of bounds char becomes first or last index of line + char = int(char) + if char < 0: + char = 0 + elif char > linelength: + char = linelength + return line, char + + def _endex(self, endflag): + '''Return position for 'end' or line overflow corresponding to endflag. + + -1: position before terminal \n; for .insert(), .delete + 0: position after terminal \n; for .get, .delete index 1 + 1: same viewed as beginning of non-existent next line (for .index) + ''' + n = len(self.data) + if endflag == 1: + return n, 0 + else: + n -= 1 + return n, len(self.data[n]) + endflag + + def insert(self, index, chars): + "Insert chars before the character at index." + + if not chars: # ''.splitlines() is [], not [''] + return + chars = chars.splitlines(True) + if chars[-1][-1] == '\n': + chars.append('') + line, char = self._decode(index, -1) + before = self.data[line][:char] + after = self.data[line][char:] + self.data[line] = before + chars[0] + self.data[line+1:line+1] = chars[1:] + self.data[line+len(chars)-1] += after + + def get(self, index1, index2=None): + "Return slice from index1 to index2 (default is 'index1+1')." + + startline, startchar = self._decode(index1) + if index2 is None: + endline, endchar = startline, startchar+1 + else: + endline, endchar = self._decode(index2) + + if startline == endline: + return self.data[startline][startchar:endchar] + else: + lines = [self.data[startline][startchar:]] + for i in range(startline+1, endline): + lines.append(self.data[i]) + lines.append(self.data[endline][:endchar]) + return ''.join(lines) + + def delete(self, index1, index2=None): + '''Delete slice from index1 to index2 (default is 'index1+1'). + + Adjust default index2 ('index+1) for line ends. + Do not delete the terminal \n at the very end of self.data ([-1][-1]). + ''' + startline, startchar = self._decode(index1, -1) + if index2 is None: + if startchar < len(self.data[startline])-1: + # not deleting \n + endline, endchar = startline, startchar+1 + elif startline < len(self.data) - 1: + # deleting non-terminal \n, convert 'index1+1 to start of next line + endline, endchar = startline+1, 0 + else: + # do not delete terminal \n if index1 == 'insert' + return + else: + endline, endchar = self._decode(index2, -1) + # restricting end position to insert position excludes terminal \n + + if startline == endline and startchar < endchar: + self.data[startline] = self.data[startline][:startchar] + \ + self.data[startline][endchar:] + elif startline < endline: + self.data[startline] = self.data[startline][:startchar] + \ + self.data[endline][endchar:] + startline += 1 + for i in range(startline, endline+1): + del self.data[startline] + + def compare(self, index1, op, index2): + line1, char1 = self._decode(index1) + line2, char2 = self._decode(index2) + if op == '<': + return line1 < line2 or line1 == line2 and char1 < char2 + elif op == '<=': + return line1 < line2 or line1 == line2 and char1 <= char2 + elif op == '>': + return line1 > line2 or line1 == line2 and char1 > char2 + elif op == '>=': + return line1 > line2 or line1 == line2 and char1 >= char2 + elif op == '==': + return line1 == line2 and char1 == char2 + elif op == '!=': + return line1 != line2 or char1 != char2 + else: + raise TclError('''bad comparison operator "%s": ''' + '''must be <, <=, ==, >=, >, or !=''' % op) + + # The following Text methods normally do something and return None. + # Whether doing nothing is sufficient for a test will depend on the test. + + def mark_set(self, name, index): + "Set mark *name* before the character at index." + pass + + def mark_unset(self, *markNames): + "Delete all marks in markNames." + + def tag_remove(self, tagName, index1, index2=None): + "Remove tag tagName from all characters between index1 and index2." + pass + + # The following Text methods affect the graphics screen and return None. + # Doing nothing should always be sufficient for tests. + + def scan_dragto(self, x, y): + "Adjust the view of the text according to scan_mark" + + def scan_mark(self, x, y): + "Remember the current X, Y coordinates." + + def see(self, index): + "Scroll screen to make the character at INDEX is visible." + pass + + # The following is a Misc method inherited by Text. + # It should properly go in a Misc mock, but is included here for now. + + def bind(sequence=None, func=None, add=None): + "Bind to this widget at event sequence a call to function func." + pass + + +class Entry: + "Mock for tkinter.Entry." + def focus_set(self): + pass diff --git a/Lib/idlelib/idle_test/template.py b/Lib/idlelib/idle_test/template.py new file mode 100644 index 000000000..725a55b9c --- /dev/null +++ b/Lib/idlelib/idle_test/template.py @@ -0,0 +1,30 @@ +"Test , coverage %." + +from idlelib import zzdummy +import unittest +from test.support import requires +from tkinter import Tk + + +class Test(unittest.TestCase): + + @classmethod + def setUpClass(cls): + requires('gui') + cls.root = Tk() + cls.root.withdraw() + + @classmethod + def tearDownClass(cls): + cls.root.update_idletasks() +## for id in cls.root.tk.call('after', 'info'): +## cls.root.after_cancel(id) # Need for EditorWindow. + cls.root.destroy() + del cls.root + + def test_init(self): + self.assertTrue(True) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_autocomplete.py b/Lib/idlelib/idle_test/test_autocomplete.py new file mode 100644 index 000000000..a811363c1 --- /dev/null +++ b/Lib/idlelib/idle_test/test_autocomplete.py @@ -0,0 +1,305 @@ +"Test autocomplete, coverage 93%." + +import unittest +from unittest.mock import Mock, patch +from test.support import requires +from tkinter import Tk, Text +import os +import __main__ + +import idlelib.autocomplete as ac +import idlelib.autocomplete_w as acw +from idlelib.idle_test.mock_idle import Func +from idlelib.idle_test.mock_tk import Event + + +class DummyEditwin: + def __init__(self, root, text): + self.root = root + self.text = text + self.indentwidth = 8 + self.tabwidth = 8 + self.prompt_last_line = '>>>' # Currently not used by autocomplete. + + +class AutoCompleteTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + requires('gui') + cls.root = Tk() + cls.root.withdraw() + cls.text = Text(cls.root) + cls.editor = DummyEditwin(cls.root, cls.text) + + @classmethod + def tearDownClass(cls): + del cls.editor, cls.text + cls.root.update_idletasks() + cls.root.destroy() + del cls.root + + def setUp(self): + self.text.delete('1.0', 'end') + self.autocomplete = ac.AutoComplete(self.editor) + + def test_init(self): + self.assertEqual(self.autocomplete.editwin, self.editor) + self.assertEqual(self.autocomplete.text, self.text) + + def test_make_autocomplete_window(self): + testwin = self.autocomplete._make_autocomplete_window() + self.assertIsInstance(testwin, acw.AutoCompleteWindow) + + def test_remove_autocomplete_window(self): + acp = self.autocomplete + acp.autocompletewindow = m = Mock() + acp._remove_autocomplete_window() + m.hide_window.assert_called_once() + self.assertIsNone(acp.autocompletewindow) + + def test_force_open_completions_event(self): + # Call _open_completions and break. + acp = self.autocomplete + open_c = Func() + acp.open_completions = open_c + self.assertEqual(acp.force_open_completions_event('event'), 'break') + self.assertEqual(open_c.args[0], ac.FORCE) + + def test_autocomplete_event(self): + Equal = self.assertEqual + acp = self.autocomplete + + # Result of autocomplete event: If modified tab, None. + ev = Event(mc_state=True) + self.assertIsNone(acp.autocomplete_event(ev)) + del ev.mc_state + + # If tab after whitespace, None. + self.text.insert('1.0', ' """Docstring.\n ') + self.assertIsNone(acp.autocomplete_event(ev)) + self.text.delete('1.0', 'end') + + # If active autocomplete window, complete() and 'break'. + self.text.insert('1.0', 're.') + acp.autocompletewindow = mock = Mock() + mock.is_active = Mock(return_value=True) + Equal(acp.autocomplete_event(ev), 'break') + mock.complete.assert_called_once() + acp.autocompletewindow = None + + # If no active autocomplete window, open_completions(), None/break. + open_c = Func(result=False) + acp.open_completions = open_c + Equal(acp.autocomplete_event(ev), None) + Equal(open_c.args[0], ac.TAB) + open_c.result = True + Equal(acp.autocomplete_event(ev), 'break') + Equal(open_c.args[0], ac.TAB) + + def test_try_open_completions_event(self): + Equal = self.assertEqual + text = self.text + acp = self.autocomplete + trycompletions = acp.try_open_completions_event + after = Func(result='after1') + acp.text.after = after + + # If no text or trigger, after not called. + trycompletions() + Equal(after.called, 0) + text.insert('1.0', 're') + trycompletions() + Equal(after.called, 0) + + # Attribute needed, no existing callback. + text.insert('insert', ' re.') + acp._delayed_completion_id = None + trycompletions() + Equal(acp._delayed_completion_index, text.index('insert')) + Equal(after.args, + (acp.popupwait, acp._delayed_open_completions, ac.TRY_A)) + cb1 = acp._delayed_completion_id + Equal(cb1, 'after1') + + # File needed, existing callback cancelled. + text.insert('insert', ' "./Lib/') + after.result = 'after2' + cancel = Func() + acp.text.after_cancel = cancel + trycompletions() + Equal(acp._delayed_completion_index, text.index('insert')) + Equal(cancel.args, (cb1,)) + Equal(after.args, + (acp.popupwait, acp._delayed_open_completions, ac.TRY_F)) + Equal(acp._delayed_completion_id, 'after2') + + def test_delayed_open_completions(self): + Equal = self.assertEqual + acp = self.autocomplete + open_c = Func() + acp.open_completions = open_c + self.text.insert('1.0', '"dict.') + + # Set autocomplete._delayed_completion_id to None. + # Text index changed, don't call open_completions. + acp._delayed_completion_id = 'after' + acp._delayed_completion_index = self.text.index('insert+1c') + acp._delayed_open_completions('dummy') + self.assertIsNone(acp._delayed_completion_id) + Equal(open_c.called, 0) + + # Text index unchanged, call open_completions. + acp._delayed_completion_index = self.text.index('insert') + acp._delayed_open_completions((1, 2, 3, ac.FILES)) + self.assertEqual(open_c.args[0], (1, 2, 3, ac.FILES)) + + def test_oc_cancel_comment(self): + none = self.assertIsNone + acp = self.autocomplete + + # Comment is in neither code or string. + acp._delayed_completion_id = 'after' + after = Func(result='after') + acp.text.after_cancel = after + self.text.insert(1.0, '# comment') + none(acp.open_completions(ac.TAB)) # From 'else' after 'elif'. + none(acp._delayed_completion_id) + + def test_oc_no_list(self): + acp = self.autocomplete + fetch = Func(result=([],[])) + acp.fetch_completions = fetch + self.text.insert('1.0', 'object') + self.assertIsNone(acp.open_completions(ac.TAB)) + self.text.insert('insert', '.') + self.assertIsNone(acp.open_completions(ac.TAB)) + self.assertEqual(fetch.called, 2) + + + def test_open_completions_none(self): + # Test other two None returns. + none = self.assertIsNone + acp = self.autocomplete + + # No object for attributes or need call not allowed. + self.text.insert(1.0, '.') + none(acp.open_completions(ac.TAB)) + self.text.insert('insert', ' int().') + none(acp.open_completions(ac.TAB)) + + # Blank or quote trigger 'if complete ...'. + self.text.delete(1.0, 'end') + self.assertFalse(acp.open_completions(ac.TAB)) + self.text.insert('1.0', '"') + self.assertFalse(acp.open_completions(ac.TAB)) + self.text.delete('1.0', 'end') + + class dummy_acw: + __init__ = Func() + show_window = Func(result=False) + hide_window = Func() + + def test_open_completions(self): + # Test completions of files and attributes. + acp = self.autocomplete + fetch = Func(result=(['tem'],['tem', '_tem'])) + acp.fetch_completions = fetch + def make_acw(): return self.dummy_acw() + acp._make_autocomplete_window = make_acw + + self.text.insert('1.0', 'int.') + acp.open_completions(ac.TAB) + self.assertIsInstance(acp.autocompletewindow, self.dummy_acw) + self.text.delete('1.0', 'end') + + # Test files. + self.text.insert('1.0', '"t') + self.assertTrue(acp.open_completions(ac.TAB)) + self.text.delete('1.0', 'end') + + def test_completion_kwds(self): + self.assertIn('and', ac.completion_kwds) + self.assertIn('case', ac.completion_kwds) + self.assertNotIn('None', ac.completion_kwds) + + def test_fetch_completions(self): + # Test that fetch_completions returns 2 lists: + # For attribute completion, a large list containing all variables, and + # a small list containing non-private variables. + # For file completion, a large list containing all files in the path, + # and a small list containing files that do not start with '.'. + acp = self.autocomplete + small, large = acp.fetch_completions( + '', ac.ATTRS) + if hasattr(__main__, '__file__') and __main__.__file__ != ac.__file__: + self.assertNotIn('AutoComplete', small) # See issue 36405. + + # Test attributes + s, b = acp.fetch_completions('', ac.ATTRS) + self.assertLess(len(small), len(large)) + self.assertTrue(all(filter(lambda x: x.startswith('_'), s))) + self.assertTrue(any(filter(lambda x: x.startswith('_'), b))) + + # Test smalll should respect to __all__. + with patch.dict('__main__.__dict__', {'__all__': ['a', 'b']}): + s, b = acp.fetch_completions('', ac.ATTRS) + self.assertEqual(s, ['a', 'b']) + self.assertIn('__name__', b) # From __main__.__dict__. + self.assertIn('sum', b) # From __main__.__builtins__.__dict__. + self.assertIn('nonlocal', b) # From keyword.kwlist. + pos = b.index('False') # Test False not included twice. + self.assertNotEqual(b[pos+1], 'False') + + # Test attributes with name entity. + mock = Mock() + mock._private = Mock() + with patch.dict('__main__.__dict__', {'foo': mock}): + s, b = acp.fetch_completions('foo', ac.ATTRS) + self.assertNotIn('_private', s) + self.assertIn('_private', b) + self.assertEqual(s, [i for i in sorted(dir(mock)) if i[:1] != '_']) + self.assertEqual(b, sorted(dir(mock))) + + # Test files + def _listdir(path): + # This will be patch and used in fetch_completions. + if path == '.': + return ['foo', 'bar', '.hidden'] + return ['monty', 'python', '.hidden'] + + with patch.object(os, 'listdir', _listdir): + s, b = acp.fetch_completions('', ac.FILES) + self.assertEqual(s, ['bar', 'foo']) + self.assertEqual(b, ['.hidden', 'bar', 'foo']) + + s, b = acp.fetch_completions('~', ac.FILES) + self.assertEqual(s, ['monty', 'python']) + self.assertEqual(b, ['.hidden', 'monty', 'python']) + + def test_get_entity(self): + # Test that a name is in the namespace of sys.modules and + # __main__.__dict__. + acp = self.autocomplete + Equal = self.assertEqual + + Equal(acp.get_entity('int'), int) + + # Test name from sys.modules. + mock = Mock() + with patch.dict('sys.modules', {'tempfile': mock}): + Equal(acp.get_entity('tempfile'), mock) + + # Test name from __main__.__dict__. + di = {'foo': 10, 'bar': 20} + with patch.dict('__main__.__dict__', {'d': di}): + Equal(acp.get_entity('d'), di) + + # Test name not in namespace. + with patch.dict('__main__.__dict__', {}): + with self.assertRaises(NameError): + acp.get_entity('not_exist') + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_autocomplete_w.py b/Lib/idlelib/idle_test/test_autocomplete_w.py new file mode 100644 index 000000000..a59a375c9 --- /dev/null +++ b/Lib/idlelib/idle_test/test_autocomplete_w.py @@ -0,0 +1,32 @@ +"Test autocomplete_w, coverage 11%." + +import unittest +from test.support import requires +from tkinter import Tk, Text + +import idlelib.autocomplete_w as acw + + +class AutoCompleteWindowTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + requires('gui') + cls.root = Tk() + cls.root.withdraw() + cls.text = Text(cls.root) + cls.acw = acw.AutoCompleteWindow(cls.text, tags=None) + + @classmethod + def tearDownClass(cls): + del cls.text, cls.acw + cls.root.update_idletasks() + cls.root.destroy() + del cls.root + + def test_init(self): + self.assertEqual(self.acw.widget, self.text) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_autoexpand.py b/Lib/idlelib/idle_test/test_autoexpand.py new file mode 100644 index 000000000..e734a8be7 --- /dev/null +++ b/Lib/idlelib/idle_test/test_autoexpand.py @@ -0,0 +1,155 @@ +"Test autoexpand, coverage 100%." + +from idlelib.autoexpand import AutoExpand +import unittest +from test.support import requires +from tkinter import Text, Tk + + +class DummyEditwin: + # AutoExpand.__init__ only needs .text + def __init__(self, text): + self.text = text + +class AutoExpandTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + requires('gui') + cls.tk = Tk() + cls.text = Text(cls.tk) + cls.auto_expand = AutoExpand(DummyEditwin(cls.text)) + cls.auto_expand.bell = lambda: None + +# If mock_tk.Text._decode understood indexes 'insert' with suffixed 'linestart', +# 'wordstart', and 'lineend', used by autoexpand, we could use the following +# to run these test on non-gui machines (but check bell). +## try: +## requires('gui') +## #raise ResourceDenied() # Uncomment to test mock. +## except ResourceDenied: +## from idlelib.idle_test.mock_tk import Text +## cls.text = Text() +## cls.text.bell = lambda: None +## else: +## from tkinter import Tk, Text +## cls.tk = Tk() +## cls.text = Text(cls.tk) + + @classmethod + def tearDownClass(cls): + del cls.text, cls.auto_expand + if hasattr(cls, 'tk'): + cls.tk.destroy() + del cls.tk + + def tearDown(self): + self.text.delete('1.0', 'end') + + def test_get_prevword(self): + text = self.text + previous = self.auto_expand.getprevword + equal = self.assertEqual + + equal(previous(), '') + + text.insert('insert', 't') + equal(previous(), 't') + + text.insert('insert', 'his') + equal(previous(), 'this') + + text.insert('insert', ' ') + equal(previous(), '') + + text.insert('insert', 'is') + equal(previous(), 'is') + + text.insert('insert', '\nsample\nstring') + equal(previous(), 'string') + + text.delete('3.0', 'insert') + equal(previous(), '') + + text.delete('1.0', 'end') + equal(previous(), '') + + def test_before_only(self): + previous = self.auto_expand.getprevword + expand = self.auto_expand.expand_word_event + equal = self.assertEqual + + self.text.insert('insert', 'ab ac bx ad ab a') + equal(self.auto_expand.getwords(), ['ab', 'ad', 'ac', 'a']) + expand('event') + equal(previous(), 'ab') + expand('event') + equal(previous(), 'ad') + expand('event') + equal(previous(), 'ac') + expand('event') + equal(previous(), 'a') + + def test_after_only(self): + # Also add punctuation 'noise' that should be ignored. + text = self.text + previous = self.auto_expand.getprevword + expand = self.auto_expand.expand_word_event + equal = self.assertEqual + + text.insert('insert', 'a, [ab] ac: () bx"" cd ac= ad ya') + text.mark_set('insert', '1.1') + equal(self.auto_expand.getwords(), ['ab', 'ac', 'ad', 'a']) + expand('event') + equal(previous(), 'ab') + expand('event') + equal(previous(), 'ac') + expand('event') + equal(previous(), 'ad') + expand('event') + equal(previous(), 'a') + + def test_both_before_after(self): + text = self.text + previous = self.auto_expand.getprevword + expand = self.auto_expand.expand_word_event + equal = self.assertEqual + + text.insert('insert', 'ab xy yz\n') + text.insert('insert', 'a ac by ac') + + text.mark_set('insert', '2.1') + equal(self.auto_expand.getwords(), ['ab', 'ac', 'a']) + expand('event') + equal(previous(), 'ab') + expand('event') + equal(previous(), 'ac') + expand('event') + equal(previous(), 'a') + + def test_other_expand_cases(self): + text = self.text + expand = self.auto_expand.expand_word_event + equal = self.assertEqual + + # no expansion candidate found + equal(self.auto_expand.getwords(), []) + equal(expand('event'), 'break') + + text.insert('insert', 'bx cy dz a') + equal(self.auto_expand.getwords(), []) + + # reset state by successfully expanding once + # move cursor to another position and expand again + text.insert('insert', 'ac xy a ac ad a') + text.mark_set('insert', '1.7') + expand('event') + initial_state = self.auto_expand.state + text.mark_set('insert', '1.end') + expand('event') + new_state = self.auto_expand.state + self.assertNotEqual(initial_state, new_state) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_browser.py b/Lib/idlelib/idle_test/test_browser.py new file mode 100644 index 000000000..6cfea3888 --- /dev/null +++ b/Lib/idlelib/idle_test/test_browser.py @@ -0,0 +1,257 @@ +"Test browser, coverage 90%." + +from idlelib import browser +from test.support import requires +import unittest +from unittest import mock +from idlelib.idle_test.mock_idle import Func +from idlelib.util import py_extensions + +from collections import deque +import os.path +import pyclbr +from tkinter import Tk + +from idlelib.tree import TreeNode + + +class ModuleBrowserTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + requires('gui') + cls.root = Tk() + cls.root.withdraw() + cls.mb = browser.ModuleBrowser(cls.root, __file__, _utest=True) + + @classmethod + def tearDownClass(cls): + cls.mb.close() + cls.root.update_idletasks() + cls.root.destroy() + del cls.root, cls.mb + + def test_init(self): + mb = self.mb + eq = self.assertEqual + eq(mb.path, __file__) + eq(pyclbr._modules, {}) + self.assertIsInstance(mb.node, TreeNode) + self.assertIsNotNone(browser.file_open) + + def test_settitle(self): + mb = self.mb + self.assertIn(os.path.basename(__file__), mb.top.title()) + self.assertEqual(mb.top.iconname(), 'Module Browser') + + def test_rootnode(self): + mb = self.mb + rn = mb.rootnode() + self.assertIsInstance(rn, browser.ModuleBrowserTreeItem) + + def test_close(self): + mb = self.mb + mb.top.destroy = Func() + mb.node.destroy = Func() + mb.close() + self.assertTrue(mb.top.destroy.called) + self.assertTrue(mb.node.destroy.called) + del mb.top.destroy, mb.node.destroy + + def test_is_browseable_extension(self): + path = "/path/to/file" + for ext in py_extensions: + with self.subTest(ext=ext): + filename = f'{path}{ext}' + actual = browser.is_browseable_extension(filename) + expected = ext not in browser.browseable_extension_blocklist + self.assertEqual(actual, expected) + + +# Nested tree same as in test_pyclbr.py except for supers on C0. C1. +mb = pyclbr +module, fname = 'test', 'test.py' +C0 = mb.Class(module, 'C0', ['base'], fname, 1, end_lineno=9) +F1 = mb._nest_function(C0, 'F1', 3, 5) +C1 = mb._nest_class(C0, 'C1', 6, 9, ['']) +C2 = mb._nest_class(C1, 'C2', 7, 9) +F3 = mb._nest_function(C2, 'F3', 9, 9) +f0 = mb.Function(module, 'f0', fname, 11, end_lineno=15) +f1 = mb._nest_function(f0, 'f1', 12, 14) +f2 = mb._nest_function(f1, 'f2', 13, 13) +c1 = mb._nest_class(f0, 'c1', 15, 15) +mock_pyclbr_tree = {'C0': C0, 'f0': f0} + +# Adjust C0.name, C1.name so tests do not depend on order. +browser.transform_children(mock_pyclbr_tree, 'test') # C0(base) +browser.transform_children(C0.children) # C1() + +# The class below checks that the calls above are correct +# and that duplicate calls have no effect. + + +class TransformChildrenTest(unittest.TestCase): + + def test_transform_module_children(self): + eq = self.assertEqual + transform = browser.transform_children + # Parameter matches tree module. + tcl = list(transform(mock_pyclbr_tree, 'test')) + eq(tcl, [C0, f0]) + eq(tcl[0].name, 'C0(base)') + eq(tcl[1].name, 'f0') + # Check that second call does not change suffix. + tcl = list(transform(mock_pyclbr_tree, 'test')) + eq(tcl[0].name, 'C0(base)') + # Nothing to traverse if parameter name isn't same as tree module. + tcl = list(transform(mock_pyclbr_tree, 'different name')) + eq(tcl, []) + + def test_transform_node_children(self): + eq = self.assertEqual + transform = browser.transform_children + # Class with two children, one name altered. + tcl = list(transform(C0.children)) + eq(tcl, [F1, C1]) + eq(tcl[0].name, 'F1') + eq(tcl[1].name, 'C1()') + tcl = list(transform(C0.children)) + eq(tcl[1].name, 'C1()') + # Function with two children. + eq(list(transform(f0.children)), [f1, c1]) + + +class ModuleBrowserTreeItemTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + cls.mbt = browser.ModuleBrowserTreeItem(fname) + + def test_init(self): + self.assertEqual(self.mbt.file, fname) + + def test_gettext(self): + self.assertEqual(self.mbt.GetText(), fname) + + def test_geticonname(self): + self.assertEqual(self.mbt.GetIconName(), 'python') + + def test_isexpandable(self): + self.assertTrue(self.mbt.IsExpandable()) + + def test_listchildren(self): + save_rex = browser.pyclbr.readmodule_ex + save_tc = browser.transform_children + browser.pyclbr.readmodule_ex = Func(result=mock_pyclbr_tree) + browser.transform_children = Func(result=[f0, C0]) + try: + self.assertEqual(self.mbt.listchildren(), [f0, C0]) + finally: + browser.pyclbr.readmodule_ex = save_rex + browser.transform_children = save_tc + + def test_getsublist(self): + mbt = self.mbt + mbt.listchildren = Func(result=[f0, C0]) + sub0, sub1 = mbt.GetSubList() + del mbt.listchildren + self.assertIsInstance(sub0, browser.ChildBrowserTreeItem) + self.assertIsInstance(sub1, browser.ChildBrowserTreeItem) + self.assertEqual(sub0.name, 'f0') + self.assertEqual(sub1.name, 'C0(base)') + + @mock.patch('idlelib.browser.file_open') + def test_ondoubleclick(self, fopen): + mbt = self.mbt + + with mock.patch('os.path.exists', return_value=False): + mbt.OnDoubleClick() + fopen.assert_not_called() + + with mock.patch('os.path.exists', return_value=True): + mbt.OnDoubleClick() + fopen.assert_called_once_with(fname) + + +class ChildBrowserTreeItemTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + CBT = browser.ChildBrowserTreeItem + cls.cbt_f1 = CBT(f1) + cls.cbt_C1 = CBT(C1) + cls.cbt_F1 = CBT(F1) + + @classmethod + def tearDownClass(cls): + del cls.cbt_C1, cls.cbt_f1, cls.cbt_F1 + + def test_init(self): + eq = self.assertEqual + eq(self.cbt_C1.name, 'C1()') + self.assertFalse(self.cbt_C1.isfunction) + eq(self.cbt_f1.name, 'f1') + self.assertTrue(self.cbt_f1.isfunction) + + def test_gettext(self): + self.assertEqual(self.cbt_C1.GetText(), 'class C1()') + self.assertEqual(self.cbt_f1.GetText(), 'def f1(...)') + + def test_geticonname(self): + self.assertEqual(self.cbt_C1.GetIconName(), 'folder') + self.assertEqual(self.cbt_f1.GetIconName(), 'python') + + def test_isexpandable(self): + self.assertTrue(self.cbt_C1.IsExpandable()) + self.assertTrue(self.cbt_f1.IsExpandable()) + self.assertFalse(self.cbt_F1.IsExpandable()) + + def test_getsublist(self): + eq = self.assertEqual + CBT = browser.ChildBrowserTreeItem + + f1sublist = self.cbt_f1.GetSubList() + self.assertIsInstance(f1sublist[0], CBT) + eq(len(f1sublist), 1) + eq(f1sublist[0].name, 'f2') + + eq(self.cbt_F1.GetSubList(), []) + + @mock.patch('idlelib.browser.file_open') + def test_ondoubleclick(self, fopen): + goto = fopen.return_value.gotoline = mock.Mock() + self.cbt_F1.OnDoubleClick() + fopen.assert_called() + goto.assert_called() + goto.assert_called_with(self.cbt_F1.obj.lineno) + # Failure test would have to raise OSError or AttributeError. + + +class NestedChildrenTest(unittest.TestCase): + "Test that all the nodes in a nested tree are added to the BrowserTree." + + def test_nested(self): + queue = deque() + actual_names = [] + # The tree items are processed in breadth first order. + # Verify that processing each sublist hits every node and + # in the right order. + expected_names = ['f0', 'C0(base)', + 'f1', 'c1', 'F1', 'C1()', + 'f2', 'C2', + 'F3'] + CBT = browser.ChildBrowserTreeItem + queue.extend((CBT(f0), CBT(C0))) + while queue: + cb = queue.popleft() + sublist = cb.GetSubList() + queue.extend(sublist) + self.assertIn(cb.name, cb.GetText()) + self.assertIn(cb.GetIconName(), ('python', 'folder')) + self.assertIs(cb.IsExpandable(), sublist != []) + actual_names.append(cb.name) + self.assertEqual(actual_names, expected_names) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_calltip.py b/Lib/idlelib/idle_test/test_calltip.py new file mode 100644 index 000000000..28c196a42 --- /dev/null +++ b/Lib/idlelib/idle_test/test_calltip.py @@ -0,0 +1,372 @@ +"Test calltip, coverage 76%" + +from idlelib import calltip +import unittest +from unittest.mock import Mock +import textwrap +import types +import re +from idlelib.idle_test.mock_tk import Text +from test.support import MISSING_C_DOCSTRINGS + + +# Test Class TC is used in multiple get_argspec test methods +class TC: + 'doc' + tip = "(ai=None, *b)" + def __init__(self, ai=None, *b): 'doc' + __init__.tip = "(self, ai=None, *b)" + def t1(self): 'doc' + t1.tip = "(self)" + def t2(self, ai, b=None): 'doc' + t2.tip = "(self, ai, b=None)" + def t3(self, ai, *args): 'doc' + t3.tip = "(self, ai, *args)" + def t4(self, *args): 'doc' + t4.tip = "(self, *args)" + def t5(self, ai, b=None, *args, **kw): 'doc' + t5.tip = "(self, ai, b=None, *args, **kw)" + def t6(no, self): 'doc' + t6.tip = "(no, self)" + def __call__(self, ci): 'doc' + __call__.tip = "(self, ci)" + def nd(self): pass # No doc. + # attaching .tip to wrapped methods does not work + @classmethod + def cm(cls, a): 'doc' + @staticmethod + def sm(b): 'doc' + + +tc = TC() +default_tip = calltip._default_callable_argspec +get_spec = calltip.get_argspec + + +class Get_argspecTest(unittest.TestCase): + # The get_spec function must return a string, even if blank. + # Test a variety of objects to be sure that none cause it to raise + # (quite aside from getting as correct an answer as possible). + # The tests of builtins may break if inspect or the docstrings change, + # but a red buildbot is better than a user crash (as has happened). + # For a simple mismatch, change the expected output to the actual. + + @unittest.skipIf(MISSING_C_DOCSTRINGS, + "Signature information for builtins requires docstrings") + def test_builtins(self): + + def tiptest(obj, out): + self.assertEqual(get_spec(obj), out) + + # Python class that inherits builtin methods + class List(list): "List() doc" + + # Simulate builtin with no docstring for default tip test + class SB: __call__ = None + + if List.__doc__ is not None: + tiptest(List, + f'(iterable=(), /)' + f'\n{List.__doc__}') + tiptest(list.__new__, + '(*args, **kwargs)\n' + 'Create and return a new object. ' + 'See help(type) for accurate signature.') + tiptest(list.__init__, + '(self, /, *args, **kwargs)\n' + 'Initialize self. See help(type(self)) for accurate signature.') + append_doc = "\nAppend object to the end of the list." + tiptest(list.append, '(self, object, /)' + append_doc) + tiptest(List.append, '(self, object, /)' + append_doc) + tiptest([].append, '(object, /)' + append_doc) + # The use of 'object' above matches the signature text. + + tiptest(types.MethodType, + '(function, instance, /)\n' + 'Create a bound instance method object.') + tiptest(SB(), default_tip) + + p = re.compile('') + tiptest(re.sub, '''\ +(pattern, repl, string, count=0, flags=0) +Return the string obtained by replacing the leftmost +non-overlapping occurrences of the pattern in string by the +replacement repl. repl can be either a string or a callable; +if a string, backslash escapes in it are processed. If it is +a callable, it's passed the Match object and must return''') + tiptest(p.sub, '''\ +(repl, string, count=0) +Return the string obtained by replacing the leftmost \ +non-overlapping occurrences o...''') + + def test_signature_wrap(self): + if textwrap.TextWrapper.__doc__ is not None: + self.assertEqual(get_spec(textwrap.TextWrapper), '''\ +(width=70, initial_indent='', subsequent_indent='', expand_tabs=True, + replace_whitespace=True, fix_sentence_endings=False, break_long_words=True, + drop_whitespace=True, break_on_hyphens=True, tabsize=8, *, max_lines=None, + placeholder=' [...]') +Object for wrapping/filling text. The public interface consists of +the wrap() and fill() methods; the other methods are just there for +subclasses to override in order to tweak the default behaviour. +If you want to completely replace the main wrapping algorithm, +you\'ll probably have to override _wrap_chunks().''') + + def test_properly_formatted(self): + + def foo(s='a'*100): + pass + + def bar(s='a'*100): + """Hello Guido""" + pass + + def baz(s='a'*100, z='b'*100): + pass + + indent = calltip._INDENT + + sfoo = "(s='aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"\ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n" + indent + "aaaaaaaaa"\ + "aaaaaaaaaa')" + sbar = "(s='aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"\ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n" + indent + "aaaaaaaaa"\ + "aaaaaaaaaa')\nHello Guido" + sbaz = "(s='aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"\ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n" + indent + "aaaaaaaaa"\ + "aaaaaaaaaa', z='bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"\ + "bbbbbbbbbbbbbbbbb\n" + indent + "bbbbbbbbbbbbbbbbbbbbbb"\ + "bbbbbbbbbbbbbbbbbbbbbb')" + + for func,doc in [(foo, sfoo), (bar, sbar), (baz, sbaz)]: + with self.subTest(func=func, doc=doc): + self.assertEqual(get_spec(func), doc) + + def test_docline_truncation(self): + def f(): pass + f.__doc__ = 'a'*300 + self.assertEqual(get_spec(f), f"()\n{'a'*(calltip._MAX_COLS-3) + '...'}") + + @unittest.skipIf(MISSING_C_DOCSTRINGS, + "Signature information for builtins requires docstrings") + def test_multiline_docstring(self): + # Test fewer lines than max. + self.assertEqual(get_spec(range), + "range(stop) -> range object\n" + "range(start, stop[, step]) -> range object") + + # Test max lines + self.assertEqual(get_spec(bytes), '''\ +bytes(iterable_of_ints) -> bytes +bytes(string, encoding[, errors]) -> bytes +bytes(bytes_or_buffer) -> immutable copy of bytes_or_buffer +bytes(int) -> bytes object of size given by the parameter initialized with null bytes +bytes() -> empty bytes object''') + + def test_multiline_docstring_2(self): + # Test more than max lines + def f(): pass + f.__doc__ = 'a\n' * 15 + self.assertEqual(get_spec(f), '()' + '\na' * calltip._MAX_LINES) + + def test_functions(self): + def t1(): 'doc' + t1.tip = "()" + def t2(a, b=None): 'doc' + t2.tip = "(a, b=None)" + def t3(a, *args): 'doc' + t3.tip = "(a, *args)" + def t4(*args): 'doc' + t4.tip = "(*args)" + def t5(a, b=None, *args, **kw): 'doc' + t5.tip = "(a, b=None, *args, **kw)" + + doc = '\ndoc' if t1.__doc__ is not None else '' + for func in (t1, t2, t3, t4, t5, TC): + with self.subTest(func=func): + self.assertEqual(get_spec(func), func.tip + doc) + + def test_methods(self): + doc = '\ndoc' if TC.__doc__ is not None else '' + for meth in (TC.t1, TC.t2, TC.t3, TC.t4, TC.t5, TC.t6, TC.__call__): + with self.subTest(meth=meth): + self.assertEqual(get_spec(meth), meth.tip + doc) + self.assertEqual(get_spec(TC.cm), "(a)" + doc) + self.assertEqual(get_spec(TC.sm), "(b)" + doc) + + def test_bound_methods(self): + # test that first parameter is correctly removed from argspec + doc = '\ndoc' if TC.__doc__ is not None else '' + for meth, mtip in ((tc.t1, "()"), (tc.t4, "(*args)"), + (tc.t6, "(self)"), (tc.__call__, '(ci)'), + (tc, '(ci)'), (TC.cm, "(a)"),): + with self.subTest(meth=meth, mtip=mtip): + self.assertEqual(get_spec(meth), mtip + doc) + + def test_starred_parameter(self): + # test that starred first parameter is *not* removed from argspec + class C: + def m1(*args): pass + c = C() + for meth, mtip in ((C.m1, '(*args)'), (c.m1, "(*args)"),): + with self.subTest(meth=meth, mtip=mtip): + self.assertEqual(get_spec(meth), mtip) + + def test_invalid_method_get_spec(self): + class C: + def m2(**kwargs): pass + class Test: + def __call__(*, a): pass + + mtip = calltip._invalid_method + self.assertEqual(get_spec(C().m2), mtip) + self.assertEqual(get_spec(Test()), mtip) + + def test_non_ascii_name(self): + # test that re works to delete a first parameter name that + # includes non-ascii chars, such as various forms of A. + uni = "(A\u0391\u0410\u05d0\u0627\u0905\u1e00\u3042, a)" + assert calltip._first_param.sub('', uni) == '(a)' + + def test_no_docstring(self): + for meth, mtip in ((TC.nd, "(self)"), (tc.nd, "()")): + with self.subTest(meth=meth, mtip=mtip): + self.assertEqual(get_spec(meth), mtip) + + def test_buggy_getattr_class(self): + class NoCall: + def __getattr__(self, name): # Not invoked for class attribute. + raise IndexError # Bug. + class CallA(NoCall): + def __call__(self, ci): # Bug does not matter. + pass + class CallB(NoCall): + def __call__(oui, a, b, c): # Non-standard 'self'. + pass + + for meth, mtip in ((NoCall, default_tip), (CallA, default_tip), + (NoCall(), ''), (CallA(), '(ci)'), + (CallB(), '(a, b, c)')): + with self.subTest(meth=meth, mtip=mtip): + self.assertEqual(get_spec(meth), mtip) + + def test_metaclass_class(self): # Failure case for issue 38689. + class Type(type): # Type() requires 3 type args, returns class. + __class__ = property({}.__getitem__, {}.__setitem__) + class Object(metaclass=Type): + __slots__ = '__class__' + for meth, mtip in ((Type, get_spec(type)), (Object, default_tip), + (Object(), '')): + with self.subTest(meth=meth, mtip=mtip): + self.assertEqual(get_spec(meth), mtip) + + def test_non_callables(self): + for obj in (0, 0.0, '0', b'0', [], {}): + with self.subTest(obj=obj): + self.assertEqual(get_spec(obj), '') + + +class Get_entityTest(unittest.TestCase): + def test_bad_entity(self): + self.assertIsNone(calltip.get_entity('1/0')) + def test_good_entity(self): + self.assertIs(calltip.get_entity('int'), int) + + +# Test the 9 Calltip methods. +# open_calltip is about half the code; the others are fairly trivial. +# The default mocks are what are needed for open_calltip. + +class mock_Shell: + "Return mock sufficient to pass to hyperparser." + def __init__(self, text): + text.tag_prevrange = Mock(return_value=None) + self.text = text + self.prompt_last_line = ">>> " + self.indentwidth = 4 + self.tabwidth = 8 + + +class mock_TipWindow: + def __init__(self): + pass + + def showtip(self, text, parenleft, parenright): + self.args = parenleft, parenright + self.parenline, self.parencol = map(int, parenleft.split('.')) + + +class WrappedCalltip(calltip.Calltip): + def _make_tk_calltip_window(self): + return mock_TipWindow() + + def remove_calltip_window(self, event=None): + if self.active_calltip: # Setup to None. + self.active_calltip = None + self.tips_removed += 1 # Setup to 0. + + def fetch_tip(self, expression): + return 'tip' + + +class CalltipTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + cls.text = Text() + cls.ct = WrappedCalltip(mock_Shell(cls.text)) + + def setUp(self): + self.text.delete('1.0', 'end') # Insert and call + self.ct.active_calltip = None + # Test .active_calltip, +args + self.ct.tips_removed = 0 + + def open_close(self, testfunc): + # Open-close template with testfunc called in between. + opentip = self.ct.open_calltip + self.text.insert(1.0, 'f(') + opentip(False) + self.tip = self.ct.active_calltip + testfunc(self) ### + self.text.insert('insert', ')') + opentip(False) + self.assertIsNone(self.ct.active_calltip, None) + + def test_open_close(self): + def args(self): + self.assertEqual(self.tip.args, ('1.1', '1.end')) + self.open_close(args) + + def test_repeated_force(self): + def force(self): + for char in 'abc': + self.text.insert('insert', 'a') + self.ct.open_calltip(True) + self.ct.open_calltip(True) + self.assertIs(self.ct.active_calltip, self.tip) + self.open_close(force) + + def test_repeated_parens(self): + def parens(self): + for context in "a", "'": + with self.subTest(context=context): + self.text.insert('insert', context) + for char in '(()())': + self.text.insert('insert', char) + self.assertIs(self.ct.active_calltip, self.tip) + self.text.insert('insert', "'") + self.open_close(parens) + + def test_comment_parens(self): + def comment(self): + self.text.insert('insert', "# ") + for char in '(()())': + self.text.insert('insert', char) + self.assertIs(self.ct.active_calltip, self.tip) + self.text.insert('insert', "\n") + self.open_close(comment) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_calltip_w.py b/Lib/idlelib/idle_test/test_calltip_w.py new file mode 100644 index 000000000..a5ec76e15 --- /dev/null +++ b/Lib/idlelib/idle_test/test_calltip_w.py @@ -0,0 +1,29 @@ +"Test calltip_w, coverage 18%." + +from idlelib import calltip_w +import unittest +from test.support import requires +from tkinter import Tk, Text + + +class CallTipWindowTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + requires('gui') + cls.root = Tk() + cls.root.withdraw() + cls.text = Text(cls.root) + cls.calltip = calltip_w.CalltipWindow(cls.text) + + @classmethod + def tearDownClass(cls): + cls.root.update_idletasks() + cls.root.destroy() + del cls.text, cls.root + + def test_init(self): + self.assertEqual(self.calltip.anchor_widget, self.text) + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_codecontext.py b/Lib/idlelib/idle_test/test_codecontext.py new file mode 100644 index 000000000..6969ad73b --- /dev/null +++ b/Lib/idlelib/idle_test/test_codecontext.py @@ -0,0 +1,455 @@ +"Test codecontext, coverage 100%" + +from idlelib import codecontext +import unittest +import unittest.mock +from test.support import requires +from tkinter import NSEW, Tk, Frame, Text, TclError + +from unittest import mock +import re +from idlelib import config + + +usercfg = codecontext.idleConf.userCfg +testcfg = { + 'main': config.IdleUserConfParser(''), + 'highlight': config.IdleUserConfParser(''), + 'keys': config.IdleUserConfParser(''), + 'extensions': config.IdleUserConfParser(''), +} +code_sample = """\ + +class C1: + # Class comment. + def __init__(self, a, b): + self.a = a + self.b = b + def compare(self): + if a > b: + return a + elif a < b: + return b + else: + return None +""" + + +class DummyEditwin: + def __init__(self, root, frame, text): + self.root = root + self.top = root + self.text_frame = frame + self.text = text + self.label = '' + + def getlineno(self, index): + return int(float(self.text.index(index))) + + def update_menu_label(self, **kwargs): + self.label = kwargs['label'] + + +class CodeContextTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + requires('gui') + root = cls.root = Tk() + root.withdraw() + frame = cls.frame = Frame(root) + text = cls.text = Text(frame) + text.insert('1.0', code_sample) + # Need to pack for creation of code context text widget. + frame.pack(side='left', fill='both', expand=1) + text.grid(row=1, column=1, sticky=NSEW) + cls.editor = DummyEditwin(root, frame, text) + codecontext.idleConf.userCfg = testcfg + + @classmethod + def tearDownClass(cls): + codecontext.idleConf.userCfg = usercfg + cls.editor.text.delete('1.0', 'end') + del cls.editor, cls.frame, cls.text + cls.root.update_idletasks() + cls.root.destroy() + del cls.root + + def setUp(self): + self.text.yview(0) + self.text['font'] = 'TkFixedFont' + self.cc = codecontext.CodeContext(self.editor) + + self.highlight_cfg = {"background": '#abcdef', + "foreground": '#123456'} + orig_idleConf_GetHighlight = codecontext.idleConf.GetHighlight + def mock_idleconf_GetHighlight(theme, element): + if element == 'context': + return self.highlight_cfg + return orig_idleConf_GetHighlight(theme, element) + GetHighlight_patcher = unittest.mock.patch.object( + codecontext.idleConf, 'GetHighlight', mock_idleconf_GetHighlight) + GetHighlight_patcher.start() + self.addCleanup(GetHighlight_patcher.stop) + + self.font_override = 'TkFixedFont' + def mock_idleconf_GetFont(root, configType, section): + return self.font_override + GetFont_patcher = unittest.mock.patch.object( + codecontext.idleConf, 'GetFont', mock_idleconf_GetFont) + GetFont_patcher.start() + self.addCleanup(GetFont_patcher.stop) + + def tearDown(self): + if self.cc.context: + self.cc.context.destroy() + # Explicitly call __del__ to remove scheduled scripts. + self.cc.__del__() + del self.cc.context, self.cc + + def test_init(self): + eq = self.assertEqual + ed = self.editor + cc = self.cc + + eq(cc.editwin, ed) + eq(cc.text, ed.text) + eq(cc.text['font'], ed.text['font']) + self.assertIsNone(cc.context) + eq(cc.info, [(0, -1, '', False)]) + eq(cc.topvisible, 1) + self.assertIsNone(self.cc.t1) + + def test_del(self): + self.cc.__del__() + + def test_del_with_timer(self): + timer = self.cc.t1 = self.text.after(10000, lambda: None) + self.cc.__del__() + with self.assertRaises(TclError) as cm: + self.root.tk.call('after', 'info', timer) + self.assertIn("doesn't exist", str(cm.exception)) + + def test_reload(self): + codecontext.CodeContext.reload() + self.assertEqual(self.cc.context_depth, 15) + + def test_toggle_code_context_event(self): + eq = self.assertEqual + cc = self.cc + toggle = cc.toggle_code_context_event + + # Make sure code context is off. + if cc.context: + toggle() + + # Toggle on. + toggle() + self.assertIsNotNone(cc.context) + eq(cc.context['font'], self.text['font']) + eq(cc.context['fg'], self.highlight_cfg['foreground']) + eq(cc.context['bg'], self.highlight_cfg['background']) + eq(cc.context.get('1.0', 'end-1c'), '') + eq(cc.editwin.label, 'Hide Code Context') + eq(self.root.tk.call('after', 'info', self.cc.t1)[1], 'timer') + + # Toggle off. + toggle() + self.assertIsNone(cc.context) + eq(cc.editwin.label, 'Show Code Context') + self.assertIsNone(self.cc.t1) + + # Scroll down and toggle back on. + line11_context = '\n'.join(x[2] for x in cc.get_context(11)[0]) + cc.text.yview(11) + toggle() + eq(cc.context.get('1.0', 'end-1c'), line11_context) + + # Toggle off and on again. + toggle() + toggle() + eq(cc.context.get('1.0', 'end-1c'), line11_context) + + def test_get_context(self): + eq = self.assertEqual + gc = self.cc.get_context + + # stopline must be greater than 0. + with self.assertRaises(AssertionError): + gc(1, stopline=0) + + eq(gc(3), ([(2, 0, 'class C1:', 'class')], 0)) + + # Don't return comment. + eq(gc(4), ([(2, 0, 'class C1:', 'class')], 0)) + + # Two indentation levels and no comment. + eq(gc(5), ([(2, 0, 'class C1:', 'class'), + (4, 4, ' def __init__(self, a, b):', 'def')], 0)) + + # Only one 'def' is returned, not both at the same indent level. + eq(gc(10), ([(2, 0, 'class C1:', 'class'), + (7, 4, ' def compare(self):', 'def'), + (8, 8, ' if a > b:', 'if')], 0)) + + # With 'elif', also show the 'if' even though it's at the same level. + eq(gc(11), ([(2, 0, 'class C1:', 'class'), + (7, 4, ' def compare(self):', 'def'), + (8, 8, ' if a > b:', 'if'), + (10, 8, ' elif a < b:', 'elif')], 0)) + + # Set stop_line to not go back to first line in source code. + # Return includes stop_line. + eq(gc(11, stopline=2), ([(2, 0, 'class C1:', 'class'), + (7, 4, ' def compare(self):', 'def'), + (8, 8, ' if a > b:', 'if'), + (10, 8, ' elif a < b:', 'elif')], 0)) + eq(gc(11, stopline=3), ([(7, 4, ' def compare(self):', 'def'), + (8, 8, ' if a > b:', 'if'), + (10, 8, ' elif a < b:', 'elif')], 4)) + eq(gc(11, stopline=8), ([(8, 8, ' if a > b:', 'if'), + (10, 8, ' elif a < b:', 'elif')], 8)) + + # Set stop_indent to test indent level to stop at. + eq(gc(11, stopindent=4), ([(7, 4, ' def compare(self):', 'def'), + (8, 8, ' if a > b:', 'if'), + (10, 8, ' elif a < b:', 'elif')], 4)) + # Check that the 'if' is included. + eq(gc(11, stopindent=8), ([(8, 8, ' if a > b:', 'if'), + (10, 8, ' elif a < b:', 'elif')], 8)) + + def test_update_code_context(self): + eq = self.assertEqual + cc = self.cc + # Ensure code context is active. + if not cc.context: + cc.toggle_code_context_event() + + # Invoke update_code_context without scrolling - nothing happens. + self.assertIsNone(cc.update_code_context()) + eq(cc.info, [(0, -1, '', False)]) + eq(cc.topvisible, 1) + + # Scroll down to line 1. + cc.text.yview(1) + cc.update_code_context() + eq(cc.info, [(0, -1, '', False)]) + eq(cc.topvisible, 2) + eq(cc.context.get('1.0', 'end-1c'), '') + + # Scroll down to line 2. + cc.text.yview(2) + cc.update_code_context() + eq(cc.info, [(0, -1, '', False), (2, 0, 'class C1:', 'class')]) + eq(cc.topvisible, 3) + eq(cc.context.get('1.0', 'end-1c'), 'class C1:') + + # Scroll down to line 3. Since it's a comment, nothing changes. + cc.text.yview(3) + cc.update_code_context() + eq(cc.info, [(0, -1, '', False), (2, 0, 'class C1:', 'class')]) + eq(cc.topvisible, 4) + eq(cc.context.get('1.0', 'end-1c'), 'class C1:') + + # Scroll down to line 4. + cc.text.yview(4) + cc.update_code_context() + eq(cc.info, [(0, -1, '', False), + (2, 0, 'class C1:', 'class'), + (4, 4, ' def __init__(self, a, b):', 'def')]) + eq(cc.topvisible, 5) + eq(cc.context.get('1.0', 'end-1c'), 'class C1:\n' + ' def __init__(self, a, b):') + + # Scroll down to line 11. Last 'def' is removed. + cc.text.yview(11) + cc.update_code_context() + eq(cc.info, [(0, -1, '', False), + (2, 0, 'class C1:', 'class'), + (7, 4, ' def compare(self):', 'def'), + (8, 8, ' if a > b:', 'if'), + (10, 8, ' elif a < b:', 'elif')]) + eq(cc.topvisible, 12) + eq(cc.context.get('1.0', 'end-1c'), 'class C1:\n' + ' def compare(self):\n' + ' if a > b:\n' + ' elif a < b:') + + # No scroll. No update, even though context_depth changed. + cc.update_code_context() + cc.context_depth = 1 + eq(cc.info, [(0, -1, '', False), + (2, 0, 'class C1:', 'class'), + (7, 4, ' def compare(self):', 'def'), + (8, 8, ' if a > b:', 'if'), + (10, 8, ' elif a < b:', 'elif')]) + eq(cc.topvisible, 12) + eq(cc.context.get('1.0', 'end-1c'), 'class C1:\n' + ' def compare(self):\n' + ' if a > b:\n' + ' elif a < b:') + + # Scroll up. + cc.text.yview(5) + cc.update_code_context() + eq(cc.info, [(0, -1, '', False), + (2, 0, 'class C1:', 'class'), + (4, 4, ' def __init__(self, a, b):', 'def')]) + eq(cc.topvisible, 6) + # context_depth is 1. + eq(cc.context.get('1.0', 'end-1c'), ' def __init__(self, a, b):') + + def test_jumptoline(self): + eq = self.assertEqual + cc = self.cc + jump = cc.jumptoline + + if not cc.context: + cc.toggle_code_context_event() + + # Empty context. + cc.text.yview('2.0') + cc.update_code_context() + eq(cc.topvisible, 2) + cc.context.mark_set('insert', '1.5') + jump() + eq(cc.topvisible, 1) + + # 4 lines of context showing. + cc.text.yview('12.0') + cc.update_code_context() + eq(cc.topvisible, 12) + cc.context.mark_set('insert', '3.0') + jump() + eq(cc.topvisible, 8) + + # More context lines than limit. + cc.context_depth = 2 + cc.text.yview('12.0') + cc.update_code_context() + eq(cc.topvisible, 12) + cc.context.mark_set('insert', '1.0') + jump() + eq(cc.topvisible, 8) + + # Context selection stops jump. + cc.text.yview('5.0') + cc.update_code_context() + cc.context.tag_add('sel', '1.0', '2.0') + cc.context.mark_set('insert', '1.0') + jump() # Without selection, to line 2. + eq(cc.topvisible, 5) + + @mock.patch.object(codecontext.CodeContext, 'update_code_context') + def test_timer_event(self, mock_update): + # Ensure code context is not active. + if self.cc.context: + self.cc.toggle_code_context_event() + self.cc.timer_event() + mock_update.assert_not_called() + + # Activate code context. + self.cc.toggle_code_context_event() + self.cc.timer_event() + mock_update.assert_called() + + def test_font(self): + eq = self.assertEqual + cc = self.cc + + orig_font = cc.text['font'] + test_font = 'TkTextFont' + self.assertNotEqual(orig_font, test_font) + + # Ensure code context is not active. + if cc.context is not None: + cc.toggle_code_context_event() + + self.font_override = test_font + # Nothing breaks or changes with inactive code context. + cc.update_font() + + # Activate code context, previous font change is immediately effective. + cc.toggle_code_context_event() + eq(cc.context['font'], test_font) + + # Call the font update, change is picked up. + self.font_override = orig_font + cc.update_font() + eq(cc.context['font'], orig_font) + + def test_highlight_colors(self): + eq = self.assertEqual + cc = self.cc + + orig_colors = dict(self.highlight_cfg) + test_colors = {'background': '#222222', 'foreground': '#ffff00'} + + def assert_colors_are_equal(colors): + eq(cc.context['background'], colors['background']) + eq(cc.context['foreground'], colors['foreground']) + + # Ensure code context is not active. + if cc.context: + cc.toggle_code_context_event() + + self.highlight_cfg = test_colors + # Nothing breaks with inactive code context. + cc.update_highlight_colors() + + # Activate code context, previous colors change is immediately effective. + cc.toggle_code_context_event() + assert_colors_are_equal(test_colors) + + # Call colors update with no change to the configured colors. + cc.update_highlight_colors() + assert_colors_are_equal(test_colors) + + # Call the colors update with code context active, change is picked up. + self.highlight_cfg = orig_colors + cc.update_highlight_colors() + assert_colors_are_equal(orig_colors) + + +class HelperFunctionText(unittest.TestCase): + + def test_get_spaces_firstword(self): + get = codecontext.get_spaces_firstword + test_lines = ( + (' first word', (' ', 'first')), + ('\tfirst word', ('\t', 'first')), + (' \u19D4\u19D2: ', (' ', '\u19D4\u19D2')), + ('no spaces', ('', 'no')), + ('', ('', '')), + ('# TEST COMMENT', ('', '')), + (' (continuation)', (' ', '')) + ) + for line, expected_output in test_lines: + self.assertEqual(get(line), expected_output) + + # Send the pattern in the call. + self.assertEqual(get(' (continuation)', + c=re.compile(r'^(\s*)([^\s]*)')), + (' ', '(continuation)')) + + def test_get_line_info(self): + eq = self.assertEqual + gli = codecontext.get_line_info + lines = code_sample.splitlines() + + # Line 1 is not a BLOCKOPENER. + eq(gli(lines[0]), (codecontext.INFINITY, '', False)) + # Line 2 is a BLOCKOPENER without an indent. + eq(gli(lines[1]), (0, 'class C1:', 'class')) + # Line 3 is not a BLOCKOPENER and does not return the indent level. + eq(gli(lines[2]), (codecontext.INFINITY, ' # Class comment.', False)) + # Line 4 is a BLOCKOPENER and is indented. + eq(gli(lines[3]), (4, ' def __init__(self, a, b):', 'def')) + # Line 8 is a different BLOCKOPENER and is indented. + eq(gli(lines[7]), (8, ' if a > b:', 'if')) + # Test tab. + eq(gli('\tif a == b:'), (1, '\tif a == b:', 'if')) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_colorizer.py b/Lib/idlelib/idle_test/test_colorizer.py new file mode 100644 index 000000000..40800df97 --- /dev/null +++ b/Lib/idlelib/idle_test/test_colorizer.py @@ -0,0 +1,623 @@ +"Test colorizer, coverage 99%." +from idlelib import colorizer +from test.support import requires +import unittest +from unittest import mock +from idlelib.idle_test.tkinter_testing_utils import run_in_tk_mainloop + +from functools import partial +import textwrap +from tkinter import Tk, Text +from idlelib import config +from idlelib.percolator import Percolator + + +usercfg = colorizer.idleConf.userCfg +testcfg = { + 'main': config.IdleUserConfParser(''), + 'highlight': config.IdleUserConfParser(''), + 'keys': config.IdleUserConfParser(''), + 'extensions': config.IdleUserConfParser(''), +} + +source = textwrap.dedent("""\ + if True: int ('1') # keyword, builtin, string, comment + elif False: print(0) # 'string' in comment + else: float(None) # if in comment + if iF + If + IF: 'keyword matching must respect case' + if'': x or'' # valid keyword-string no-space combinations + async def f(): await g() + # Strings should be entirely colored, including quotes. + 'x', '''x''', "x", \"""x\""" + 'abc\\ + def' + '''abc\\ + def''' + # All valid prefixes for unicode and byte strings should be colored. + r'x', u'x', R'x', U'x', f'x', F'x' + fr'x', Fr'x', fR'x', FR'x', rf'x', rF'x', Rf'x', RF'x' + tr'x', Tr'x', tR'x', TR'x', rt'x', rT'x', Rt'x', RT'x' + b'x',B'x', br'x',Br'x',bR'x',BR'x', rb'x', rB'x',Rb'x',RB'x' + # Invalid combinations of legal characters should be half colored. + ur'x', ru'x', uf'x', fu'x', UR'x', ufr'x', rfu'x', xf'x', fx'x' + match point: + case (x, 0) as _: + print(f"X={x}") + case [_, [_], "_", + _]: + pass + case _ if ("a" if _ else set()): pass + case _: + raise ValueError("Not a point _") + ''' + case _:''' + "match x:" + """) + + +def setUpModule(): + colorizer.idleConf.userCfg = testcfg + + +def tearDownModule(): + colorizer.idleConf.userCfg = usercfg + + +class FunctionTest(unittest.TestCase): + + def test_any(self): + self.assertEqual(colorizer.any('test', ('a', 'b', 'cd')), + '(?Pa|b|cd)') + + def test_make_pat(self): + # Tested in more detail by testing prog. + self.assertTrue(colorizer.make_pat()) + + def test_prog(self): + prog = colorizer.prog + eq = self.assertEqual + line = 'def f():\n print("hello")\n' + m = prog.search(line) + eq(m.groupdict()['KEYWORD'], 'def') + m = prog.search(line, m.end()) + eq(m.groupdict()['SYNC'], '\n') + m = prog.search(line, m.end()) + eq(m.groupdict()['BUILTIN'], 'print') + m = prog.search(line, m.end()) + eq(m.groupdict()['STRING'], '"hello"') + m = prog.search(line, m.end()) + eq(m.groupdict()['SYNC'], '\n') + + def test_idprog(self): + idprog = colorizer.idprog + m = idprog.match('nospace') + self.assertIsNone(m) + m = idprog.match(' space') + self.assertEqual(m.group(0), ' space') + + +class ColorConfigTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + requires('gui') + root = cls.root = Tk() + root.withdraw() + cls.text = Text(root) + + @classmethod + def tearDownClass(cls): + del cls.text + cls.root.update_idletasks() + cls.root.destroy() + del cls.root + + def test_color_config(self): + text = self.text + eq = self.assertEqual + colorizer.color_config(text) + # Uses IDLE Classic theme as default. + eq(text['background'], '#ffffff') + eq(text['foreground'], '#000000') + eq(text['selectbackground'], 'gray') + eq(text['selectforeground'], '#000000') + eq(text['insertbackground'], 'black') + eq(text['inactiveselectbackground'], 'gray') + + +class ColorDelegatorInstantiationTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + requires('gui') + root = cls.root = Tk() + root.withdraw() + cls.text = Text(root) + + @classmethod + def tearDownClass(cls): + del cls.text + cls.root.update_idletasks() + cls.root.destroy() + del cls.root + + def setUp(self): + self.color = colorizer.ColorDelegator() + + def tearDown(self): + self.color.close() + self.text.delete('1.0', 'end') + self.color.resetcache() + del self.color + + def test_init(self): + color = self.color + self.assertIsInstance(color, colorizer.ColorDelegator) + + def test_init_state(self): + # init_state() is called during the instantiation of + # ColorDelegator in setUp(). + color = self.color + self.assertIsNone(color.after_id) + self.assertTrue(color.allow_colorizing) + self.assertFalse(color.colorizing) + self.assertFalse(color.stop_colorizing) + + +class ColorDelegatorTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + requires('gui') + root = cls.root = Tk() + root.withdraw() + text = cls.text = Text(root) + cls.percolator = Percolator(text) + # Delegator stack = [Delegator(text)] + + @classmethod + def tearDownClass(cls): + cls.percolator.close() + del cls.percolator, cls.text + cls.root.update_idletasks() + cls.root.destroy() + del cls.root + + def setUp(self): + self.color = colorizer.ColorDelegator() + self.percolator.insertfilter(self.color) + # Calls color.setdelegate(Delegator(text)). + + def tearDown(self): + self.color.close() + self.percolator.removefilter(self.color) + self.text.delete('1.0', 'end') + self.color.resetcache() + del self.color + + def test_setdelegate(self): + # Called in setUp when filter is attached to percolator. + color = self.color + self.assertIsInstance(color.delegate, colorizer.Delegator) + # It is too late to mock notify_range, so test side effect. + self.assertEqual(self.root.tk.call( + 'after', 'info', color.after_id)[1], 'timer') + + def test_LoadTagDefs(self): + highlight = partial(config.idleConf.GetHighlight, theme='IDLE Classic') + for tag, colors in self.color.tagdefs.items(): + with self.subTest(tag=tag): + self.assertIn('background', colors) + self.assertIn('foreground', colors) + if tag not in ('SYNC', 'TODO'): + self.assertEqual(colors, highlight(element=tag.lower())) + + def test_config_colors(self): + text = self.text + highlight = partial(config.idleConf.GetHighlight, theme='IDLE Classic') + for tag in self.color.tagdefs: + for plane in ('background', 'foreground'): + with self.subTest(tag=tag, plane=plane): + if tag in ('SYNC', 'TODO'): + self.assertEqual(text.tag_cget(tag, plane), '') + else: + self.assertEqual(text.tag_cget(tag, plane), + highlight(element=tag.lower())[plane]) + # 'sel' is marked as the highest priority. + self.assertEqual(text.tag_names()[-1], 'sel') + + @mock.patch.object(colorizer.ColorDelegator, 'notify_range') + def test_insert(self, mock_notify): + text = self.text + # Initial text. + text.insert('insert', 'foo') + self.assertEqual(text.get('1.0', 'end'), 'foo\n') + mock_notify.assert_called_with('1.0', '1.0+3c') + # Additional text. + text.insert('insert', 'barbaz') + self.assertEqual(text.get('1.0', 'end'), 'foobarbaz\n') + mock_notify.assert_called_with('1.3', '1.3+6c') + + @mock.patch.object(colorizer.ColorDelegator, 'notify_range') + def test_delete(self, mock_notify): + text = self.text + # Initialize text. + text.insert('insert', 'abcdefghi') + self.assertEqual(text.get('1.0', 'end'), 'abcdefghi\n') + # Delete single character. + text.delete('1.7') + self.assertEqual(text.get('1.0', 'end'), 'abcdefgi\n') + mock_notify.assert_called_with('1.7') + # Delete multiple characters. + text.delete('1.3', '1.6') + self.assertEqual(text.get('1.0', 'end'), 'abcgi\n') + mock_notify.assert_called_with('1.3') + + def test_notify_range(self): + text = self.text + color = self.color + eq = self.assertEqual + + # Colorizing already scheduled. + save_id = color.after_id + eq(self.root.tk.call('after', 'info', save_id)[1], 'timer') + self.assertFalse(color.colorizing) + self.assertFalse(color.stop_colorizing) + self.assertTrue(color.allow_colorizing) + + # Coloring scheduled and colorizing in progress. + color.colorizing = True + color.notify_range('1.0', 'end') + self.assertFalse(color.stop_colorizing) + eq(color.after_id, save_id) + + # No colorizing scheduled and colorizing in progress. + text.after_cancel(save_id) + color.after_id = None + color.notify_range('1.0', '1.0+3c') + self.assertTrue(color.stop_colorizing) + self.assertIsNotNone(color.after_id) + eq(self.root.tk.call('after', 'info', color.after_id)[1], 'timer') + # New event scheduled. + self.assertNotEqual(color.after_id, save_id) + + # No colorizing scheduled and colorizing off. + text.after_cancel(color.after_id) + color.after_id = None + color.allow_colorizing = False + color.notify_range('1.4', '1.4+10c') + # Nothing scheduled when colorizing is off. + self.assertIsNone(color.after_id) + + def test_toggle_colorize_event(self): + color = self.color + eq = self.assertEqual + + # Starts with colorizing allowed and scheduled. + self.assertFalse(color.colorizing) + self.assertFalse(color.stop_colorizing) + self.assertTrue(color.allow_colorizing) + eq(self.root.tk.call('after', 'info', color.after_id)[1], 'timer') + + # Toggle colorizing off. + color.toggle_colorize_event() + self.assertIsNone(color.after_id) + self.assertFalse(color.colorizing) + self.assertFalse(color.stop_colorizing) + self.assertFalse(color.allow_colorizing) + + # Toggle on while colorizing in progress (doesn't add timer). + color.colorizing = True + color.toggle_colorize_event() + self.assertIsNone(color.after_id) + self.assertTrue(color.colorizing) + self.assertFalse(color.stop_colorizing) + self.assertTrue(color.allow_colorizing) + + # Toggle off while colorizing in progress. + color.toggle_colorize_event() + self.assertIsNone(color.after_id) + self.assertTrue(color.colorizing) + self.assertTrue(color.stop_colorizing) + self.assertFalse(color.allow_colorizing) + + # Toggle on while colorizing not in progress. + color.colorizing = False + color.toggle_colorize_event() + eq(self.root.tk.call('after', 'info', color.after_id)[1], 'timer') + self.assertFalse(color.colorizing) + self.assertTrue(color.stop_colorizing) + self.assertTrue(color.allow_colorizing) + + @mock.patch.object(colorizer.ColorDelegator, 'recolorize_main') + def test_recolorize(self, mock_recmain): + text = self.text + color = self.color + eq = self.assertEqual + # Call recolorize manually and not scheduled. + text.after_cancel(color.after_id) + + # No delegate. + save_delegate = color.delegate + color.delegate = None + color.recolorize() + mock_recmain.assert_not_called() + color.delegate = save_delegate + + # Toggle off colorizing. + color.allow_colorizing = False + color.recolorize() + mock_recmain.assert_not_called() + color.allow_colorizing = True + + # Colorizing in progress. + color.colorizing = True + color.recolorize() + mock_recmain.assert_not_called() + color.colorizing = False + + # Colorizing is done, but not completed, so rescheduled. + color.recolorize() + self.assertFalse(color.stop_colorizing) + self.assertFalse(color.colorizing) + mock_recmain.assert_called() + eq(mock_recmain.call_count, 1) + # Rescheduled when TODO tag still exists. + eq(self.root.tk.call('after', 'info', color.after_id)[1], 'timer') + + # No changes to text, so no scheduling added. + text.tag_remove('TODO', '1.0', 'end') + color.recolorize() + self.assertFalse(color.stop_colorizing) + self.assertFalse(color.colorizing) + mock_recmain.assert_called() + eq(mock_recmain.call_count, 2) + self.assertIsNone(color.after_id) + + @mock.patch.object(colorizer.ColorDelegator, 'notify_range') + def test_recolorize_main(self, mock_notify): + text = self.text + color = self.color + eq = self.assertEqual + + text.insert('insert', source) + expected = (('1.0', ('KEYWORD',)), ('1.2', ()), ('1.3', ('KEYWORD',)), + ('1.7', ()), ('1.9', ('BUILTIN',)), ('1.14', ('STRING',)), + ('1.19', ('COMMENT',)), + ('2.1', ('KEYWORD',)), ('2.18', ()), ('2.25', ('COMMENT',)), + ('3.6', ('BUILTIN',)), ('3.12', ('KEYWORD',)), ('3.21', ('COMMENT',)), + ('4.0', ('KEYWORD',)), ('4.3', ()), ('4.6', ()), + ('5.2', ('STRING',)), ('5.8', ('KEYWORD',)), ('5.10', ('STRING',)), + ('6.0', ('KEYWORD',)), ('6.10', ('DEFINITION',)), ('6.11', ()), + ('8.0', ('STRING',)), ('8.4', ()), ('8.5', ('STRING',)), + ('8.12', ()), ('8.14', ('STRING',)), + ('20.0', ('KEYWORD',)), + ('21.4', ('KEYWORD',)), ('21.16', ('KEYWORD',)),# ('21.19', ('KEYWORD',)), + #('23.4', ('KEYWORD',)), ('23.10', ('KEYWORD',)), ('23.14', ('KEYWORD',)), ('23.19', ('STRING',)), + #('24.12', ('KEYWORD',)), + ('25.8', ('KEYWORD',)), + ('26.4', ('KEYWORD',)), ('26.9', ('KEYWORD',)), + ('26.11', ('KEYWORD',)), ('26.15', ('STRING',)), + ('26.19', ('KEYWORD',)), ('26.22', ()), + ('26.24', ('KEYWORD',)), ('26.29', ('BUILTIN',)), ('26.37', ('KEYWORD',)), + ('27.4', ('KEYWORD',)), ('27.9', ('KEYWORD',)),# ('27.11', ('KEYWORD',)), ('27.14', (),), + ('28.25', ('STRING',)), ('28.38', ('STRING',)), + ('30.0', ('STRING',)), + ('31.1', ('STRING',)), + # SYNC at the end of every line. + ('1.55', ('SYNC',)), ('2.50', ('SYNC',)), ('3.34', ('SYNC',)), + ) + + # Nothing marked to do therefore no tags in text. + text.tag_remove('TODO', '1.0', 'end') + color.recolorize_main() + for tag in text.tag_names(): + with self.subTest(tag=tag): + eq(text.tag_ranges(tag), ()) + + # Source marked for processing. + text.tag_add('TODO', '1.0', 'end') + # Check some indexes. + color.recolorize_main() + for index, expected_tags in expected: + with self.subTest(index=index): + eq(text.tag_names(index), expected_tags) + + # Check for some tags for ranges. + eq(text.tag_nextrange('TODO', '1.0'), ()) + eq(text.tag_nextrange('KEYWORD', '1.0'), ('1.0', '1.2')) + eq(text.tag_nextrange('COMMENT', '2.0'), ('2.22', '2.43')) + eq(text.tag_nextrange('SYNC', '2.0'), ('2.43', '3.0')) + eq(text.tag_nextrange('STRING', '2.0'), ('4.17', '4.53')) + eq(text.tag_nextrange('STRING', '8.0'), ('8.0', '8.3')) + eq(text.tag_nextrange('STRING', '8.3'), ('8.5', '8.12')) + eq(text.tag_nextrange('STRING', '8.12'), ('8.14', '8.17')) + eq(text.tag_nextrange('STRING', '8.17'), ('8.19', '8.26')) + eq(text.tag_nextrange('SYNC', '8.0'), ('8.26', '9.0')) + eq(text.tag_nextrange('SYNC', '31.0'), ('31.10', '33.0')) + + def _assert_highlighting(self, source, tag_ranges): + """Check highlighting of a given piece of code. + + This inserts just this code into the Text widget. It will then + check that the resulting highlighting tag ranges exactly match + those described in the given `tag_ranges` dict. + + Note that the irrelevant tags 'sel', 'TODO' and 'SYNC' are + ignored. + """ + text = self.text + + with mock.patch.object(colorizer.ColorDelegator, 'notify_range'): + text.delete('1.0', 'end-1c') + text.insert('insert', source) + text.tag_add('TODO', '1.0', 'end-1c') + self.color.recolorize_main() + + # Make a dict with highlighting tag ranges in the Text widget. + text_tag_ranges = {} + for tag in set(text.tag_names()) - {'sel', 'TODO', 'SYNC'}: + indexes = [rng.string for rng in text.tag_ranges(tag)] + for index_pair in zip(indexes[::2], indexes[1::2]): + text_tag_ranges.setdefault(tag, []).append(index_pair) + + self.assertEqual(text_tag_ranges, tag_ranges) + + with mock.patch.object(colorizer.ColorDelegator, 'notify_range'): + text.delete('1.0', 'end-1c') + + def test_def_statement(self): + # empty def + self._assert_highlighting('def', {'KEYWORD': [('1.0', '1.3')]}) + + # def followed by identifier + self._assert_highlighting('def foo:', {'KEYWORD': [('1.0', '1.3')], + 'DEFINITION': [('1.4', '1.7')]}) + + # def followed by partial identifier + self._assert_highlighting('def fo', {'KEYWORD': [('1.0', '1.3')], + 'DEFINITION': [('1.4', '1.6')]}) + + # def followed by non-keyword + self._assert_highlighting('def ++', {'KEYWORD': [('1.0', '1.3')]}) + + def test_match_soft_keyword(self): + # empty match + self._assert_highlighting('match', {'KEYWORD': [('1.0', '1.5')]}) + + # match followed by partial identifier + self._assert_highlighting('match fo', {'KEYWORD': [('1.0', '1.5')]}) + + # match followed by identifier and colon + self._assert_highlighting('match foo:', {'KEYWORD': [('1.0', '1.5')]}) + + # match followed by keyword + self._assert_highlighting('match and', {'KEYWORD': [('1.6', '1.9')]}) + + # match followed by builtin with keyword prefix + self._assert_highlighting('match int:', {'KEYWORD': [('1.0', '1.5')], + 'BUILTIN': [('1.6', '1.9')]}) + + # match followed by non-text operator + self._assert_highlighting('match^', {}) + self._assert_highlighting('match @', {}) + + # match followed by colon + self._assert_highlighting('match :', {}) + + # match followed by comma + self._assert_highlighting('match\t,', {}) + + # match followed by a lone underscore + self._assert_highlighting('match _:', {'KEYWORD': [('1.0', '1.5')]}) + + def test_case_soft_keyword(self): + # empty case + self._assert_highlighting('case', {'KEYWORD': [('1.0', '1.4')]}) + + # case followed by partial identifier + self._assert_highlighting('case fo', {'KEYWORD': [('1.0', '1.4')]}) + + # case followed by identifier and colon + self._assert_highlighting('case foo:', {'KEYWORD': [('1.0', '1.4')]}) + + # case followed by keyword + self._assert_highlighting('case and', {'KEYWORD': [('1.5', '1.8')]}) + + # case followed by builtin with keyword prefix + self._assert_highlighting('case int:', {'KEYWORD': [('1.0', '1.4')], + 'BUILTIN': [('1.5', '1.8')]}) + + # case followed by non-text operator + self._assert_highlighting('case^', {}) + self._assert_highlighting('case @', {}) + + # case followed by colon + self._assert_highlighting('case :', {}) + + # case followed by comma + self._assert_highlighting('case\t,', {}) + + # case followed by a lone underscore + self._assert_highlighting('case _:', {'KEYWORD': [('1.0', '1.4'), + ('1.5', '1.6')]}) + + def test_long_multiline_string(self): + source = textwrap.dedent('''\ + """a + b + c + d + e""" + ''') + self._assert_highlighting(source, {'STRING': [('1.0', '5.4')]}) + + @run_in_tk_mainloop(delay=50) + def test_incremental_editing(self): + text = self.text + eq = self.assertEqual + + # Simulate typing 'inte'. During this, the highlighting should + # change from normal to keyword to builtin to normal. + text.insert('insert', 'i') + yield + eq(text.tag_nextrange('BUILTIN', '1.0'), ()) + eq(text.tag_nextrange('KEYWORD', '1.0'), ()) + + text.insert('insert', 'n') + yield + eq(text.tag_nextrange('BUILTIN', '1.0'), ()) + eq(text.tag_nextrange('KEYWORD', '1.0'), ('1.0', '1.2')) + + text.insert('insert', 't') + yield + eq(text.tag_nextrange('BUILTIN', '1.0'), ('1.0', '1.3')) + eq(text.tag_nextrange('KEYWORD', '1.0'), ()) + + text.insert('insert', 'e') + yield + eq(text.tag_nextrange('BUILTIN', '1.0'), ()) + eq(text.tag_nextrange('KEYWORD', '1.0'), ()) + + # Simulate deleting three characters from the end of 'inte'. + # During this, the highlighting should change from normal to + # builtin to keyword to normal. + text.delete('insert-1c', 'insert') + yield + eq(text.tag_nextrange('BUILTIN', '1.0'), ('1.0', '1.3')) + eq(text.tag_nextrange('KEYWORD', '1.0'), ()) + + text.delete('insert-1c', 'insert') + yield + eq(text.tag_nextrange('BUILTIN', '1.0'), ()) + eq(text.tag_nextrange('KEYWORD', '1.0'), ('1.0', '1.2')) + + text.delete('insert-1c', 'insert') + yield + eq(text.tag_nextrange('BUILTIN', '1.0'), ()) + eq(text.tag_nextrange('KEYWORD', '1.0'), ()) + + @mock.patch.object(colorizer.ColorDelegator, 'recolorize') + @mock.patch.object(colorizer.ColorDelegator, 'notify_range') + def test_removecolors(self, mock_notify, mock_recolorize): + text = self.text + color = self.color + text.insert('insert', source) + + color.recolorize_main() + # recolorize_main doesn't add these tags. + text.tag_add("ERROR", "1.0") + text.tag_add("TODO", "1.0") + text.tag_add("hit", "1.0") + for tag in color.tagdefs: + with self.subTest(tag=tag): + self.assertNotEqual(text.tag_ranges(tag), ()) + + color.removecolors() + for tag in color.tagdefs: + with self.subTest(tag=tag): + self.assertEqual(text.tag_ranges(tag), ()) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_config.py b/Lib/idlelib/idle_test/test_config.py new file mode 100644 index 000000000..6d75cf7aa --- /dev/null +++ b/Lib/idlelib/idle_test/test_config.py @@ -0,0 +1,805 @@ +"""Test config, coverage 93%. +(100% for IdleConfParser, IdleUserConfParser*, ConfigChanges). +* Exception is OSError clause in Save method. +Much of IdleConf is also exercised by ConfigDialog and test_configdialog. +""" +from idlelib import config +import sys +import os +import tempfile +from test.support import captured_stderr, findfile +import unittest +from unittest import mock +import idlelib +from idlelib.idle_test.mock_idle import Func + +# Tests should not depend on fortuitous user configurations. +# They must not affect actual user .cfg files. +# Replace user parsers with empty parsers that cannot be saved +# due to getting '' as the filename when created. + +idleConf = config.idleConf +usercfg = idleConf.userCfg +testcfg = {} +usermain = testcfg['main'] = config.IdleUserConfParser('') +userhigh = testcfg['highlight'] = config.IdleUserConfParser('') +userkeys = testcfg['keys'] = config.IdleUserConfParser('') +userextn = testcfg['extensions'] = config.IdleUserConfParser('') + +def setUpModule(): + idleConf.userCfg = testcfg + idlelib.testing = True + +def tearDownModule(): + idleConf.userCfg = usercfg + idlelib.testing = False + + +class IdleConfParserTest(unittest.TestCase): + """Test that IdleConfParser works""" + + config = """ + [one] + one = false + two = true + three = 10 + + [two] + one = a string + two = true + three = false + """ + + def test_get(self): + parser = config.IdleConfParser('') + parser.read_string(self.config) + eq = self.assertEqual + + # Test with type argument. + self.assertIs(parser.Get('one', 'one', type='bool'), False) + self.assertIs(parser.Get('one', 'two', type='bool'), True) + eq(parser.Get('one', 'three', type='int'), 10) + eq(parser.Get('two', 'one'), 'a string') + self.assertIs(parser.Get('two', 'two', type='bool'), True) + self.assertIs(parser.Get('two', 'three', type='bool'), False) + + # Test without type should fallback to string. + eq(parser.Get('two', 'two'), 'true') + eq(parser.Get('two', 'three'), 'false') + + # If option not exist, should return None, or default. + self.assertIsNone(parser.Get('not', 'exist')) + eq(parser.Get('not', 'exist', default='DEFAULT'), 'DEFAULT') + + def test_get_option_list(self): + parser = config.IdleConfParser('') + parser.read_string(self.config) + get_list = parser.GetOptionList + self.assertCountEqual(get_list('one'), ['one', 'two', 'three']) + self.assertCountEqual(get_list('two'), ['one', 'two', 'three']) + self.assertEqual(get_list('not exist'), []) + + def test_load_nothing(self): + parser = config.IdleConfParser('') + parser.Load() + self.assertEqual(parser.sections(), []) + + def test_load_file(self): + # Borrow test/configdata/cfgparser.1 from test_configparser. + config_path = findfile('cfgparser.1', subdir='configdata') + parser = config.IdleConfParser(config_path) + parser.Load() + + self.assertEqual(parser.Get('Foo Bar', 'foo'), 'newbar') + self.assertEqual(parser.GetOptionList('Foo Bar'), ['foo']) + + +class IdleUserConfParserTest(unittest.TestCase): + """Test that IdleUserConfParser works""" + + def new_parser(self, path=''): + return config.IdleUserConfParser(path) + + def test_set_option(self): + parser = self.new_parser() + parser.add_section('Foo') + # Setting new option in existing section should return True. + self.assertTrue(parser.SetOption('Foo', 'bar', 'true')) + # Setting existing option with same value should return False. + self.assertFalse(parser.SetOption('Foo', 'bar', 'true')) + # Setting exiting option with new value should return True. + self.assertTrue(parser.SetOption('Foo', 'bar', 'false')) + self.assertEqual(parser.Get('Foo', 'bar'), 'false') + + # Setting option in new section should create section and return True. + self.assertTrue(parser.SetOption('Bar', 'bar', 'true')) + self.assertCountEqual(parser.sections(), ['Bar', 'Foo']) + self.assertEqual(parser.Get('Bar', 'bar'), 'true') + + def test_remove_option(self): + parser = self.new_parser() + parser.AddSection('Foo') + parser.SetOption('Foo', 'bar', 'true') + + self.assertTrue(parser.RemoveOption('Foo', 'bar')) + self.assertFalse(parser.RemoveOption('Foo', 'bar')) + self.assertFalse(parser.RemoveOption('Not', 'Exist')) + + def test_add_section(self): + parser = self.new_parser() + self.assertEqual(parser.sections(), []) + + # Should not add duplicate section. + # Configparser raises DuplicateError, IdleParser not. + parser.AddSection('Foo') + parser.AddSection('Foo') + parser.AddSection('Bar') + self.assertCountEqual(parser.sections(), ['Bar', 'Foo']) + + def test_remove_empty_sections(self): + parser = self.new_parser() + + parser.AddSection('Foo') + parser.AddSection('Bar') + parser.SetOption('Idle', 'name', 'val') + self.assertCountEqual(parser.sections(), ['Bar', 'Foo', 'Idle']) + parser.RemoveEmptySections() + self.assertEqual(parser.sections(), ['Idle']) + + def test_is_empty(self): + parser = self.new_parser() + + parser.AddSection('Foo') + parser.AddSection('Bar') + self.assertTrue(parser.IsEmpty()) + self.assertEqual(parser.sections(), []) + + parser.SetOption('Foo', 'bar', 'false') + parser.AddSection('Bar') + self.assertFalse(parser.IsEmpty()) + self.assertCountEqual(parser.sections(), ['Foo']) + + def test_save(self): + with tempfile.TemporaryDirectory() as tdir: + path = os.path.join(tdir, 'test.cfg') + parser = self.new_parser(path) + parser.AddSection('Foo') + parser.SetOption('Foo', 'bar', 'true') + + # Should save to path when config is not empty. + self.assertFalse(os.path.exists(path)) + parser.Save() + self.assertTrue(os.path.exists(path)) + + # Should remove the file from disk when config is empty. + parser.remove_section('Foo') + parser.Save() + self.assertFalse(os.path.exists(path)) + + +class IdleConfTest(unittest.TestCase): + """Test for idleConf""" + + @classmethod + def setUpClass(cls): + cls.config_string = {} + + conf = config.IdleConf(_utest=True) + if __name__ != '__main__': + idle_dir = os.path.dirname(__file__) + else: + idle_dir = os.path.abspath(sys.path[0]) + for ctype in conf.config_types: + config_path = os.path.join(idle_dir, '../config-%s.def' % ctype) + with open(config_path) as f: + cls.config_string[ctype] = f.read() + + cls.orig_warn = config._warn + config._warn = Func() + + @classmethod + def tearDownClass(cls): + config._warn = cls.orig_warn + + def new_config(self, _utest=False): + return config.IdleConf(_utest=_utest) + + def mock_config(self): + """Return a mocked idleConf + + Both default and user config used the same config-*.def + """ + conf = config.IdleConf(_utest=True) + for ctype in conf.config_types: + conf.defaultCfg[ctype] = config.IdleConfParser('') + conf.defaultCfg[ctype].read_string(self.config_string[ctype]) + conf.userCfg[ctype] = config.IdleUserConfParser('') + conf.userCfg[ctype].read_string(self.config_string[ctype]) + + return conf + + @unittest.skipIf(sys.platform.startswith('win'), 'this is test for unix system') + def test_get_user_cfg_dir_unix(self): + # Test to get user config directory under unix. + conf = self.new_config(_utest=True) + + # Check normal way should success + with mock.patch('os.path.expanduser', return_value='/home/foo'): + with mock.patch('os.path.exists', return_value=True): + self.assertEqual(conf.GetUserCfgDir(), '/home/foo/.idlerc') + + # Check os.getcwd should success + with mock.patch('os.path.expanduser', return_value='~'): + with mock.patch('os.getcwd', return_value='/home/foo/cpython'): + with mock.patch('os.mkdir'): + self.assertEqual(conf.GetUserCfgDir(), + '/home/foo/cpython/.idlerc') + + # Check user dir not exists and created failed should raise SystemExit + with mock.patch('os.path.join', return_value='/path/not/exists'): + with self.assertRaises(SystemExit): + with self.assertRaises(FileNotFoundError): + conf.GetUserCfgDir() + + @unittest.skipIf(not sys.platform.startswith('win'), 'this is test for Windows system') + def test_get_user_cfg_dir_windows(self): + # Test to get user config directory under Windows. + conf = self.new_config(_utest=True) + + # Check normal way should success + with mock.patch('os.path.expanduser', return_value='C:\\foo'): + with mock.patch('os.path.exists', return_value=True): + self.assertEqual(conf.GetUserCfgDir(), 'C:\\foo\\.idlerc') + + # Check os.getcwd should success + with mock.patch('os.path.expanduser', return_value='~'): + with mock.patch('os.getcwd', return_value='C:\\foo\\cpython'): + with mock.patch('os.mkdir'): + self.assertEqual(conf.GetUserCfgDir(), + 'C:\\foo\\cpython\\.idlerc') + + # Check user dir not exists and created failed should raise SystemExit + with mock.patch('os.path.join', return_value='/path/not/exists'): + with self.assertRaises(SystemExit): + with self.assertRaises(FileNotFoundError): + conf.GetUserCfgDir() + + def test_create_config_handlers(self): + conf = self.new_config(_utest=True) + + # Mock out idle_dir + idle_dir = '/home/foo' + with mock.patch.dict({'__name__': '__foo__'}): + with mock.patch('os.path.dirname', return_value=idle_dir): + conf.CreateConfigHandlers() + + # Check keys are equal + self.assertCountEqual(conf.defaultCfg, conf.config_types) + self.assertCountEqual(conf.userCfg, conf.config_types) + + # Check conf parser are correct type + for default_parser in conf.defaultCfg.values(): + self.assertIsInstance(default_parser, config.IdleConfParser) + for user_parser in conf.userCfg.values(): + self.assertIsInstance(user_parser, config.IdleUserConfParser) + + # Check config path are correct + for cfg_type, parser in conf.defaultCfg.items(): + self.assertEqual(parser.file, + os.path.join(idle_dir, f'config-{cfg_type}.def')) + for cfg_type, parser in conf.userCfg.items(): + self.assertEqual(parser.file, + os.path.join(conf.userdir or '#', f'config-{cfg_type}.cfg')) + + def test_load_cfg_files(self): + conf = self.new_config(_utest=True) + + # Borrow test/configdata/cfgparser.1 from test_configparser. + config_path = findfile('cfgparser.1', subdir='configdata') + conf.defaultCfg['foo'] = config.IdleConfParser(config_path) + conf.userCfg['foo'] = config.IdleUserConfParser(config_path) + + # Load all config from path + conf.LoadCfgFiles() + + eq = self.assertEqual + + # Check defaultCfg is loaded + eq(conf.defaultCfg['foo'].Get('Foo Bar', 'foo'), 'newbar') + eq(conf.defaultCfg['foo'].GetOptionList('Foo Bar'), ['foo']) + + # Check userCfg is loaded + eq(conf.userCfg['foo'].Get('Foo Bar', 'foo'), 'newbar') + eq(conf.userCfg['foo'].GetOptionList('Foo Bar'), ['foo']) + + def test_save_user_cfg_files(self): + conf = self.mock_config() + + with mock.patch('idlelib.config.IdleUserConfParser.Save') as m: + conf.SaveUserCfgFiles() + self.assertEqual(m.call_count, len(conf.userCfg)) + + def test_get_option(self): + conf = self.mock_config() + + eq = self.assertEqual + eq(conf.GetOption('main', 'EditorWindow', 'width'), '80') + eq(conf.GetOption('main', 'EditorWindow', 'width', type='int'), 80) + with mock.patch('idlelib.config._warn') as _warn: + eq(conf.GetOption('main', 'EditorWindow', 'font', type='int'), None) + eq(conf.GetOption('main', 'EditorWindow', 'NotExists'), None) + eq(conf.GetOption('main', 'EditorWindow', 'NotExists', default='NE'), 'NE') + eq(_warn.call_count, 4) + + def test_set_option(self): + conf = self.mock_config() + + conf.SetOption('main', 'Foo', 'bar', 'newbar') + self.assertEqual(conf.GetOption('main', 'Foo', 'bar'), 'newbar') + + def test_get_section_list(self): + conf = self.mock_config() + + self.assertCountEqual( + conf.GetSectionList('default', 'main'), + ['General', 'EditorWindow', 'PyShell', 'Indent', 'Theme', + 'Keys', 'History', 'HelpFiles']) + self.assertCountEqual( + conf.GetSectionList('user', 'main'), + ['General', 'EditorWindow', 'PyShell', 'Indent', 'Theme', + 'Keys', 'History', 'HelpFiles']) + + with self.assertRaises(config.InvalidConfigSet): + conf.GetSectionList('foobar', 'main') + with self.assertRaises(config.InvalidConfigType): + conf.GetSectionList('default', 'notexists') + + def test_get_highlight(self): + conf = self.mock_config() + + eq = self.assertEqual + eq(conf.GetHighlight('IDLE Classic', 'normal'), {'foreground': '#000000', + 'background': '#ffffff'}) + + # Test cursor (this background should be normal-background) + eq(conf.GetHighlight('IDLE Classic', 'cursor'), {'foreground': 'black', + 'background': '#ffffff'}) + + # Test get user themes + conf.SetOption('highlight', 'Foobar', 'normal-foreground', '#747474') + conf.SetOption('highlight', 'Foobar', 'normal-background', '#171717') + with mock.patch('idlelib.config._warn'): + eq(conf.GetHighlight('Foobar', 'normal'), {'foreground': '#747474', + 'background': '#171717'}) + + def test_get_theme_dict(self): + # TODO: finish. + conf = self.mock_config() + + # These two should be the same + self.assertEqual( + conf.GetThemeDict('default', 'IDLE Classic'), + conf.GetThemeDict('user', 'IDLE Classic')) + + with self.assertRaises(config.InvalidTheme): + conf.GetThemeDict('bad', 'IDLE Classic') + + def test_get_current_theme_and_keys(self): + conf = self.mock_config() + + self.assertEqual(conf.CurrentTheme(), conf.current_colors_and_keys('Theme')) + self.assertEqual(conf.CurrentKeys(), conf.current_colors_and_keys('Keys')) + + def test_current_colors_and_keys(self): + conf = self.mock_config() + + self.assertEqual(conf.current_colors_and_keys('Theme'), 'IDLE Classic') + + def test_default_keys(self): + current_platform = sys.platform + conf = self.new_config(_utest=True) + + sys.platform = 'win32' + self.assertEqual(conf.default_keys(), 'IDLE Classic Windows') + + sys.platform = 'darwin' + self.assertEqual(conf.default_keys(), 'IDLE Classic OSX') + + sys.platform = 'some-linux' + self.assertEqual(conf.default_keys(), 'IDLE Modern Unix') + + # Restore platform + sys.platform = current_platform + + def test_get_extensions(self): + userextn.read_string(''' + [ZzDummy] + enable = True + [DISABLE] + enable = False + ''') + eq = self.assertEqual + iGE = idleConf.GetExtensions + eq(iGE(shell_only=True), []) + eq(iGE(), ['ZzDummy']) + eq(iGE(editor_only=True), ['ZzDummy']) + eq(iGE(active_only=False), ['ZzDummy', 'DISABLE']) + eq(iGE(active_only=False, editor_only=True), ['ZzDummy', 'DISABLE']) + userextn.remove_section('ZzDummy') + userextn.remove_section('DISABLE') + + + def test_remove_key_bind_names(self): + conf = self.mock_config() + + self.assertCountEqual( + conf.RemoveKeyBindNames(conf.GetSectionList('default', 'extensions')), + ['AutoComplete', 'CodeContext', 'FormatParagraph', 'ParenMatch', 'ZzDummy']) + + def test_get_extn_name_for_event(self): + userextn.read_string(''' + [ZzDummy] + enable = True + ''') + eq = self.assertEqual + eq(idleConf.GetExtnNameForEvent('z-in'), 'ZzDummy') + eq(idleConf.GetExtnNameForEvent('z-out'), None) + userextn.remove_section('ZzDummy') + + def test_get_extension_keys(self): + userextn.read_string(''' + [ZzDummy] + enable = True + ''') + self.assertEqual(idleConf.GetExtensionKeys('ZzDummy'), + {'<>': ['']}) + userextn.remove_section('ZzDummy') +# need option key test +## key = [''] if sys.platform == 'darwin' else [''] +## eq(conf.GetExtensionKeys('ZoomHeight'), {'<>': key}) + + def test_get_extension_bindings(self): + userextn.read_string(''' + [ZzDummy] + enable = True + ''') + eq = self.assertEqual + iGEB = idleConf.GetExtensionBindings + eq(iGEB('NotExists'), {}) + expect = {'<>': [''], + '<>': ['']} + eq(iGEB('ZzDummy'), expect) + userextn.remove_section('ZzDummy') + + def test_get_keybinding(self): + conf = self.mock_config() + + eq = self.assertEqual + eq(conf.GetKeyBinding('IDLE Modern Unix', '<>'), + ['', '']) + eq(conf.GetKeyBinding('IDLE Classic Unix', '<>'), + ['', '']) + eq(conf.GetKeyBinding('IDLE Classic Windows', '<>'), + ['', '']) + eq(conf.GetKeyBinding('IDLE Classic Mac', '<>'), ['']) + eq(conf.GetKeyBinding('IDLE Classic OSX', '<>'), ['']) + + # Test keybinding not exists + eq(conf.GetKeyBinding('NOT EXISTS', '<>'), []) + eq(conf.GetKeyBinding('IDLE Modern Unix', 'NOT EXISTS'), []) + + def test_get_current_keyset(self): + current_platform = sys.platform + conf = self.mock_config() + + # Ensure that platform isn't darwin + sys.platform = 'some-linux' + self.assertEqual(conf.GetCurrentKeySet(), conf.GetKeySet(conf.CurrentKeys())) + + # This should not be the same, since replace ') + self.assertEqual(conf.GetKeySet('IDLE Modern Unix')['<>'], '') + + def test_is_core_binding(self): + # XXX: Should move out the core keys to config file or other place + conf = self.mock_config() + + self.assertTrue(conf.IsCoreBinding('copy')) + self.assertTrue(conf.IsCoreBinding('cut')) + self.assertTrue(conf.IsCoreBinding('del-word-right')) + self.assertFalse(conf.IsCoreBinding('not-exists')) + + def test_extra_help_source_list(self): + # Test GetExtraHelpSourceList and GetAllExtraHelpSourcesList in same + # place to prevent prepare input data twice. + conf = self.mock_config() + + # Test default with no extra help source + self.assertEqual(conf.GetExtraHelpSourceList('default'), []) + self.assertEqual(conf.GetExtraHelpSourceList('user'), []) + with self.assertRaises(config.InvalidConfigSet): + self.assertEqual(conf.GetExtraHelpSourceList('bad'), []) + self.assertCountEqual( + conf.GetAllExtraHelpSourcesList(), + conf.GetExtraHelpSourceList('default') + conf.GetExtraHelpSourceList('user')) + + # Add help source to user config + conf.userCfg['main'].SetOption('HelpFiles', '4', 'Python;https://python.org') # This is bad input + conf.userCfg['main'].SetOption('HelpFiles', '3', 'Python:https://python.org') # This is bad input + conf.userCfg['main'].SetOption('HelpFiles', '2', 'Pillow;https://pillow.readthedocs.io/en/latest/') + conf.userCfg['main'].SetOption('HelpFiles', '1', 'IDLE;C:/Programs/Python36/Lib/idlelib/help.html') + self.assertEqual(conf.GetExtraHelpSourceList('user'), + [('IDLE', 'C:/Programs/Python36/Lib/idlelib/help.html', '1'), + ('Pillow', 'https://pillow.readthedocs.io/en/latest/', '2'), + ('Python', 'https://python.org', '4')]) + self.assertCountEqual( + conf.GetAllExtraHelpSourcesList(), + conf.GetExtraHelpSourceList('default') + conf.GetExtraHelpSourceList('user')) + + def test_get_font(self): + from test.support import requires + from tkinter import Tk + from tkinter.font import Font + conf = self.mock_config() + + requires('gui') + root = Tk() + root.withdraw() + + f = Font.actual(Font(name='TkFixedFont', exists=True, root=root)) + self.assertEqual( + conf.GetFont(root, 'main', 'EditorWindow'), + (f['family'], 10 if f['size'] <= 0 else f['size'], f['weight'])) + + # Cleanup root + root.destroy() + del root + + def test_get_core_keys(self): + conf = self.mock_config() + + eq = self.assertEqual + eq(conf.GetCoreKeys()['<>'], ['']) + eq(conf.GetCoreKeys()['<>'], ['', '']) + eq(conf.GetCoreKeys()['<>'], ['']) + eq(conf.GetCoreKeys('IDLE Classic Windows')['<>'], + ['', '']) + eq(conf.GetCoreKeys('IDLE Classic OSX')['<>'], ['']) + eq(conf.GetCoreKeys('IDLE Classic Unix')['<>'], + ['', '']) + eq(conf.GetCoreKeys('IDLE Modern Unix')['<>'], + ['', '']) + + +class CurrentColorKeysTest(unittest.TestCase): + """ Test colorkeys function with user config [Theme] and [Keys] patterns. + + colorkeys = config.IdleConf.current_colors_and_keys + Test all patterns written by IDLE and some errors + Item 'default' should really be 'builtin' (versus 'custom). + """ + colorkeys = idleConf.current_colors_and_keys + default_theme = 'IDLE Classic' + default_keys = idleConf.default_keys() + + def test_old_builtin_theme(self): + # On initial installation, user main is blank. + self.assertEqual(self.colorkeys('Theme'), self.default_theme) + # For old default, name2 must be blank. + usermain.read_string(''' + [Theme] + default = True + ''') + # IDLE omits 'name' for default old builtin theme. + self.assertEqual(self.colorkeys('Theme'), self.default_theme) + # IDLE adds 'name' for non-default old builtin theme. + usermain['Theme']['name'] = 'IDLE New' + self.assertEqual(self.colorkeys('Theme'), 'IDLE New') + # Erroneous non-default old builtin reverts to default. + usermain['Theme']['name'] = 'non-existent' + self.assertEqual(self.colorkeys('Theme'), self.default_theme) + usermain.remove_section('Theme') + + def test_new_builtin_theme(self): + # IDLE writes name2 for new builtins. + usermain.read_string(''' + [Theme] + default = True + name2 = IDLE Dark + ''') + self.assertEqual(self.colorkeys('Theme'), 'IDLE Dark') + # Leftover 'name', not removed, is ignored. + usermain['Theme']['name'] = 'IDLE New' + self.assertEqual(self.colorkeys('Theme'), 'IDLE Dark') + # Erroneous non-default new builtin reverts to default. + usermain['Theme']['name2'] = 'non-existent' + self.assertEqual(self.colorkeys('Theme'), self.default_theme) + usermain.remove_section('Theme') + + def test_user_override_theme(self): + # Erroneous custom name (no definition) reverts to default. + usermain.read_string(''' + [Theme] + default = False + name = Custom Dark + ''') + self.assertEqual(self.colorkeys('Theme'), self.default_theme) + # Custom name is valid with matching Section name. + userhigh.read_string('[Custom Dark]\na=b') + self.assertEqual(self.colorkeys('Theme'), 'Custom Dark') + # Name2 is ignored. + usermain['Theme']['name2'] = 'non-existent' + self.assertEqual(self.colorkeys('Theme'), 'Custom Dark') + usermain.remove_section('Theme') + userhigh.remove_section('Custom Dark') + + def test_old_builtin_keys(self): + # On initial installation, user main is blank. + self.assertEqual(self.colorkeys('Keys'), self.default_keys) + # For old default, name2 must be blank, name is always used. + usermain.read_string(''' + [Keys] + default = True + name = IDLE Classic Unix + ''') + self.assertEqual(self.colorkeys('Keys'), 'IDLE Classic Unix') + # Erroneous non-default old builtin reverts to default. + usermain['Keys']['name'] = 'non-existent' + self.assertEqual(self.colorkeys('Keys'), self.default_keys) + usermain.remove_section('Keys') + + def test_new_builtin_keys(self): + # IDLE writes name2 for new builtins. + usermain.read_string(''' + [Keys] + default = True + name2 = IDLE Modern Unix + ''') + self.assertEqual(self.colorkeys('Keys'), 'IDLE Modern Unix') + # Leftover 'name', not removed, is ignored. + usermain['Keys']['name'] = 'IDLE Classic Unix' + self.assertEqual(self.colorkeys('Keys'), 'IDLE Modern Unix') + # Erroneous non-default new builtin reverts to default. + usermain['Keys']['name2'] = 'non-existent' + self.assertEqual(self.colorkeys('Keys'), self.default_keys) + usermain.remove_section('Keys') + + def test_user_override_keys(self): + # Erroneous custom name (no definition) reverts to default. + usermain.read_string(''' + [Keys] + default = False + name = Custom Keys + ''') + self.assertEqual(self.colorkeys('Keys'), self.default_keys) + # Custom name is valid with matching Section name. + userkeys.read_string('[Custom Keys]\na=b') + self.assertEqual(self.colorkeys('Keys'), 'Custom Keys') + # Name2 is ignored. + usermain['Keys']['name2'] = 'non-existent' + self.assertEqual(self.colorkeys('Keys'), 'Custom Keys') + usermain.remove_section('Keys') + userkeys.remove_section('Custom Keys') + + +class ChangesTest(unittest.TestCase): + + empty = {'main':{}, 'highlight':{}, 'keys':{}, 'extensions':{}} + + def load(self): # Test_add_option verifies that this works. + changes = self.changes + changes.add_option('main', 'Msec', 'mitem', 'mval') + changes.add_option('highlight', 'Hsec', 'hitem', 'hval') + changes.add_option('keys', 'Ksec', 'kitem', 'kval') + return changes + + loaded = {'main': {'Msec': {'mitem': 'mval'}}, + 'highlight': {'Hsec': {'hitem': 'hval'}}, + 'keys': {'Ksec': {'kitem':'kval'}}, + 'extensions': {}} + + def setUp(self): + self.changes = config.ConfigChanges() + + def test_init(self): + self.assertEqual(self.changes, self.empty) + + def test_add_option(self): + changes = self.load() + self.assertEqual(changes, self.loaded) + changes.add_option('main', 'Msec', 'mitem', 'mval') + self.assertEqual(changes, self.loaded) + + def test_save_option(self): # Static function does not touch changes. + save_option = self.changes.save_option + self.assertTrue(save_option('main', 'Indent', 'what', '0')) + self.assertFalse(save_option('main', 'Indent', 'what', '0')) + self.assertEqual(usermain['Indent']['what'], '0') + + self.assertTrue(save_option('main', 'Indent', 'use-spaces', '0')) + self.assertEqual(usermain['Indent']['use-spaces'], '0') + self.assertTrue(save_option('main', 'Indent', 'use-spaces', '1')) + self.assertFalse(usermain.has_option('Indent', 'use-spaces')) + usermain.remove_section('Indent') + + def test_save_added(self): + changes = self.load() + self.assertTrue(changes.save_all()) + self.assertEqual(usermain['Msec']['mitem'], 'mval') + self.assertEqual(userhigh['Hsec']['hitem'], 'hval') + self.assertEqual(userkeys['Ksec']['kitem'], 'kval') + changes.add_option('main', 'Msec', 'mitem', 'mval') + self.assertFalse(changes.save_all()) + usermain.remove_section('Msec') + userhigh.remove_section('Hsec') + userkeys.remove_section('Ksec') + + def test_save_help(self): + # Any change to HelpFiles overwrites entire section. + changes = self.changes + changes.save_option('main', 'HelpFiles', 'IDLE', 'idledoc') + changes.add_option('main', 'HelpFiles', 'ELDI', 'codeldi') + changes.save_all() + self.assertFalse(usermain.has_option('HelpFiles', 'IDLE')) + self.assertTrue(usermain.has_option('HelpFiles', 'ELDI')) + + def test_save_default(self): # Cover 2nd and 3rd false branches. + changes = self.changes + changes.add_option('main', 'Indent', 'use-spaces', '1') + # save_option returns False; cfg_type_changed remains False. + + # TODO: test that save_all calls usercfg Saves. + + def test_delete_section(self): + changes = self.load() + changes.delete_section('main', 'fake') # Test no exception. + self.assertEqual(changes, self.loaded) # Test nothing deleted. + for cfgtype, section in (('main', 'Msec'), ('keys', 'Ksec')): + testcfg[cfgtype].SetOption(section, 'name', 'value') + changes.delete_section(cfgtype, section) + with self.assertRaises(KeyError): + changes[cfgtype][section] # Test section gone from changes + testcfg[cfgtype][section] # and from mock userCfg. + # TODO test for save call. + + def test_clear(self): + changes = self.load() + changes.clear() + self.assertEqual(changes, self.empty) + + +class WarningTest(unittest.TestCase): + + def test_warn(self): + Equal = self.assertEqual + config._warned = set() + with captured_stderr() as stderr: + config._warn('warning', 'key') + Equal(config._warned, {('warning','key')}) + Equal(stderr.getvalue(), 'warning'+'\n') + with captured_stderr() as stderr: + config._warn('warning', 'key') + Equal(stderr.getvalue(), '') + with captured_stderr() as stderr: + config._warn('warn2', 'yek') + Equal(config._warned, {('warning','key'), ('warn2','yek')}) + Equal(stderr.getvalue(), 'warn2'+'\n') + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_config_key.py b/Lib/idlelib/idle_test/test_config_key.py new file mode 100644 index 000000000..32f878b84 --- /dev/null +++ b/Lib/idlelib/idle_test/test_config_key.py @@ -0,0 +1,356 @@ +"""Test config_key, coverage 98%. + +Coverage is effectively 100%. Tkinter dialog is mocked, Mac-only line +may be skipped, and dummy function in bind test should not be called. +Not tested: exit with 'self.advanced or self.keys_ok(keys) ...' False. +""" + +from idlelib import config_key +from test.support import requires +import unittest +from unittest import mock +from tkinter import Tk, TclError +from idlelib.idle_test.mock_idle import Func +from idlelib.idle_test.mock_tk import Mbox_func + + +class ValidationTest(unittest.TestCase): + "Test validation methods: ok, keys_ok, bind_ok." + + class Validator(config_key.GetKeysFrame): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + class list_keys_final: + get = Func() + self.list_keys_final = list_keys_final + get_modifiers = Func() + showerror = Mbox_func() + + @classmethod + def setUpClass(cls): + requires('gui') + cls.root = Tk() + cls.root.withdraw() + keylist = [[''], ['', '']] + cls.dialog = cls.Validator(cls.root, '<>', keylist) + + @classmethod + def tearDownClass(cls): + del cls.dialog + cls.root.update_idletasks() + cls.root.destroy() + del cls.root + + def setUp(self): + self.dialog.showerror.message = '' + # A test that needs a particular final key value should set it. + # A test that sets a non-blank modifier list should reset it to []. + + def test_ok_empty(self): + self.dialog.key_string.set(' ') + self.dialog.ok() + self.assertEqual(self.dialog.result, '') + self.assertEqual(self.dialog.showerror.message, 'No key specified.') + + def test_ok_good(self): + self.dialog.key_string.set('') + self.dialog.list_keys_final.get.result = 'F11' + self.dialog.ok() + self.assertEqual(self.dialog.result, '') + self.assertEqual(self.dialog.showerror.message, '') + + def test_keys_no_ending(self): + self.assertFalse(self.dialog.keys_ok('')) + self.assertIn('No modifier', self.dialog.showerror.message) + + def test_keys_no_modifier_ok(self): + self.dialog.list_keys_final.get.result = 'F11' + self.assertTrue(self.dialog.keys_ok('')) + self.assertEqual(self.dialog.showerror.message, '') + + def test_keys_shift_bad(self): + self.dialog.list_keys_final.get.result = 'a' + self.dialog.get_modifiers.result = ['Shift'] + self.assertFalse(self.dialog.keys_ok('')) + self.assertIn('shift modifier', self.dialog.showerror.message) + self.dialog.get_modifiers.result = [] + + def test_keys_dup(self): + for mods, final, seq in (([], 'F12', ''), + (['Control'], 'x', ''), + (['Control'], 'X', '')): + with self.subTest(m=mods, f=final, s=seq): + self.dialog.list_keys_final.get.result = final + self.dialog.get_modifiers.result = mods + self.assertFalse(self.dialog.keys_ok(seq)) + self.assertIn('already in use', self.dialog.showerror.message) + self.dialog.get_modifiers.result = [] + + def test_bind_ok(self): + self.assertTrue(self.dialog.bind_ok('')) + self.assertEqual(self.dialog.showerror.message, '') + + def test_bind_not_ok(self): + self.assertFalse(self.dialog.bind_ok('')) + self.assertIn('not accepted', self.dialog.showerror.message) + + +class ToggleLevelTest(unittest.TestCase): + "Test toggle between Basic and Advanced frames." + + @classmethod + def setUpClass(cls): + requires('gui') + cls.root = Tk() + cls.root.withdraw() + cls.dialog = config_key.GetKeysFrame(cls.root, '<>', []) + + @classmethod + def tearDownClass(cls): + del cls.dialog + cls.root.update_idletasks() + cls.root.destroy() + del cls.root + + def test_toggle_level(self): + dialog = self.dialog + + def stackorder(): + """Get the stack order of the children of the frame. + + winfo_children() stores the children in stack order, so + this can be used to check whether a frame is above or + below another one. + """ + for index, child in enumerate(dialog.winfo_children()): + if child._name == 'keyseq_basic': + basic = index + if child._name == 'keyseq_advanced': + advanced = index + return basic, advanced + + # New window starts at basic level. + self.assertFalse(dialog.advanced) + self.assertIn('Advanced', dialog.button_level['text']) + basic, advanced = stackorder() + self.assertGreater(basic, advanced) + + # Toggle to advanced. + dialog.toggle_level() + self.assertTrue(dialog.advanced) + self.assertIn('Basic', dialog.button_level['text']) + basic, advanced = stackorder() + self.assertGreater(advanced, basic) + + # Toggle to basic. + dialog.button_level.invoke() + self.assertFalse(dialog.advanced) + self.assertIn('Advanced', dialog.button_level['text']) + basic, advanced = stackorder() + self.assertGreater(basic, advanced) + + +class KeySelectionTest(unittest.TestCase): + "Test selecting key on Basic frames." + + class Basic(config_key.GetKeysFrame): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + class list_keys_final: + get = Func() + select_clear = Func() + yview = Func() + self.list_keys_final = list_keys_final + def set_modifiers_for_platform(self): + self.modifiers = ['foo', 'bar', 'BAZ'] + self.modifier_label = {'BAZ': 'ZZZ'} + showerror = Mbox_func() + + @classmethod + def setUpClass(cls): + requires('gui') + cls.root = Tk() + cls.root.withdraw() + cls.dialog = cls.Basic(cls.root, '<>', []) + + @classmethod + def tearDownClass(cls): + del cls.dialog + cls.root.update_idletasks() + cls.root.destroy() + del cls.root + + def setUp(self): + self.dialog.clear_key_seq() + + def test_get_modifiers(self): + dialog = self.dialog + gm = dialog.get_modifiers + eq = self.assertEqual + + # Modifiers are set on/off by invoking the checkbutton. + dialog.modifier_checkbuttons['foo'].invoke() + eq(gm(), ['foo']) + + dialog.modifier_checkbuttons['BAZ'].invoke() + eq(gm(), ['foo', 'BAZ']) + + dialog.modifier_checkbuttons['foo'].invoke() + eq(gm(), ['BAZ']) + + @mock.patch.object(config_key.GetKeysFrame, 'get_modifiers') + def test_build_key_string(self, mock_modifiers): + dialog = self.dialog + key = dialog.list_keys_final + string = dialog.key_string.get + eq = self.assertEqual + + key.get.result = 'a' + mock_modifiers.return_value = [] + dialog.build_key_string() + eq(string(), '') + + mock_modifiers.return_value = ['mymod'] + dialog.build_key_string() + eq(string(), '') + + key.get.result = '' + mock_modifiers.return_value = ['mymod', 'test'] + dialog.build_key_string() + eq(string(), '') + + @mock.patch.object(config_key.GetKeysFrame, 'get_modifiers') + def test_final_key_selected(self, mock_modifiers): + dialog = self.dialog + key = dialog.list_keys_final + string = dialog.key_string.get + eq = self.assertEqual + + mock_modifiers.return_value = ['Shift'] + key.get.result = '{' + dialog.final_key_selected() + eq(string(), '') + + +class CancelWindowTest(unittest.TestCase): + "Simulate user clicking [Cancel] button." + + @classmethod + def setUpClass(cls): + requires('gui') + cls.root = Tk() + cls.root.withdraw() + cls.dialog = config_key.GetKeysWindow( + cls.root, 'Title', '<>', [], _utest=True) + + @classmethod + def tearDownClass(cls): + cls.dialog.cancel() + del cls.dialog + cls.root.update_idletasks() + cls.root.destroy() + del cls.root + + @mock.patch.object(config_key.GetKeysFrame, 'ok') + def test_cancel(self, mock_frame_ok): + self.assertEqual(self.dialog.winfo_class(), 'Toplevel') + self.dialog.button_cancel.invoke() + with self.assertRaises(TclError): + self.dialog.winfo_class() + self.assertEqual(self.dialog.result, '') + mock_frame_ok.assert_not_called() + + +class OKWindowTest(unittest.TestCase): + "Simulate user clicking [OK] button." + + @classmethod + def setUpClass(cls): + requires('gui') + cls.root = Tk() + cls.root.withdraw() + cls.dialog = config_key.GetKeysWindow( + cls.root, 'Title', '<>', [], _utest=True) + + @classmethod + def tearDownClass(cls): + cls.dialog.cancel() + del cls.dialog + cls.root.update_idletasks() + cls.root.destroy() + del cls.root + + @mock.patch.object(config_key.GetKeysFrame, 'ok') + def test_ok(self, mock_frame_ok): + self.assertEqual(self.dialog.winfo_class(), 'Toplevel') + self.dialog.button_ok.invoke() + with self.assertRaises(TclError): + self.dialog.winfo_class() + mock_frame_ok.assert_called() + + +class WindowResultTest(unittest.TestCase): + "Test window result get and set." + + @classmethod + def setUpClass(cls): + requires('gui') + cls.root = Tk() + cls.root.withdraw() + cls.dialog = config_key.GetKeysWindow( + cls.root, 'Title', '<>', [], _utest=True) + + @classmethod + def tearDownClass(cls): + cls.dialog.cancel() + del cls.dialog + cls.root.update_idletasks() + cls.root.destroy() + del cls.root + + def test_result(self): + dialog = self.dialog + eq = self.assertEqual + + dialog.result = '' + eq(dialog.result, '') + eq(dialog.frame.result,'') + + dialog.result = 'bar' + eq(dialog.result,'bar') + eq(dialog.frame.result,'bar') + + dialog.frame.result = 'foo' + eq(dialog.result, 'foo') + eq(dialog.frame.result,'foo') + + +class HelperTest(unittest.TestCase): + "Test module level helper functions." + + def test_translate_key(self): + tr = config_key.translate_key + eq = self.assertEqual + + # Letters return unchanged with no 'Shift'. + eq(tr('q', []), 'Key-q') + eq(tr('q', ['Control', 'Alt']), 'Key-q') + + # 'Shift' uppercases single lowercase letters. + eq(tr('q', ['Shift']), 'Key-Q') + eq(tr('q', ['Control', 'Shift']), 'Key-Q') + eq(tr('q', ['Control', 'Alt', 'Shift']), 'Key-Q') + + # Convert key name to keysym. + eq(tr('Page Up', []), 'Key-Prior') + # 'Shift' doesn't change case when it's not a single char. + eq(tr('*', ['Shift']), 'Key-asterisk') + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_configdialog.py b/Lib/idlelib/idle_test/test_configdialog.py new file mode 100644 index 000000000..2773ed7ce --- /dev/null +++ b/Lib/idlelib/idle_test/test_configdialog.py @@ -0,0 +1,1580 @@ +"""Test configdialog, coverage 94%. + +Half the class creates dialog, half works with user customizations. +""" +from idlelib import configdialog +from test.support import requires +requires('gui') +import unittest +from unittest import mock +from idlelib.idle_test.mock_idle import Func +from tkinter import (Tk, StringVar, IntVar, BooleanVar, DISABLED, NORMAL) +from idlelib import config +from idlelib.configdialog import idleConf, changes, tracers + +# Tests should not depend on fortuitous user configurations. +# They must not affect actual user .cfg files. +# Use solution from test_config: empty parsers with no filename. +usercfg = idleConf.userCfg +testcfg = { + 'main': config.IdleUserConfParser(''), + 'highlight': config.IdleUserConfParser(''), + 'keys': config.IdleUserConfParser(''), + 'extensions': config.IdleUserConfParser(''), +} + +root = None +dialog = None +mainpage = changes['main'] +highpage = changes['highlight'] +keyspage = changes['keys'] +extpage = changes['extensions'] + + +def setUpModule(): + global root, dialog + idleConf.userCfg = testcfg + root = Tk() + # root.withdraw() # Comment out, see issue 30870 + dialog = configdialog.ConfigDialog(root, 'Test', _utest=True) + + +def tearDownModule(): + global root, dialog + idleConf.userCfg = usercfg + tracers.detach() + tracers.clear() + changes.clear() + root.update_idletasks() + root.destroy() + root = dialog = None + + +class ConfigDialogTest(unittest.TestCase): + + def test_deactivate_current_config(self): + pass + + def activate_config_changes(self): + pass + + +class ButtonTest(unittest.TestCase): + + def test_click_ok(self): + d = dialog + apply = d.apply = mock.Mock() + destroy = d.destroy = mock.Mock() + d.buttons['Ok'].invoke() + apply.assert_called_once() + destroy.assert_called_once() + del d.destroy, d.apply + + def test_click_apply(self): + d = dialog + deactivate = d.deactivate_current_config = mock.Mock() + save_ext = d.extpage.save_all_changed_extensions = mock.Mock() + activate = d.activate_config_changes = mock.Mock() + d.buttons['Apply'].invoke() + deactivate.assert_called_once() + save_ext.assert_called_once() + activate.assert_called_once() + del d.extpage.save_all_changed_extensions + del d.activate_config_changes, d.deactivate_current_config + + def test_click_cancel(self): + d = dialog + d.destroy = Func() + changes['main']['something'] = 1 + d.buttons['Cancel'].invoke() + self.assertEqual(changes['main'], {}) + self.assertEqual(d.destroy.called, 1) + del d.destroy + + def test_click_help(self): + dialog.note.select(dialog.keyspage) + with mock.patch.object(configdialog, 'view_text', + new_callable=Func) as view: + dialog.buttons['Help'].invoke() + title, contents = view.kwds['title'], view.kwds['contents'] + self.assertEqual(title, 'Help for IDLE preferences') + self.assertStartsWith(contents, 'When you click') + self.assertEndsWith(contents,'a different name.\n') + + +class FontPageTest(unittest.TestCase): + """Test that font widgets enable users to make font changes. + + Test that widget actions set vars, that var changes add three + options to changes and call set_samples, and that set_samples + changes the font of both sample boxes. + """ + @classmethod + def setUpClass(cls): + page = cls.page = dialog.fontpage + dialog.note.select(page) + page.set_samples = Func() # Mask instance method. + page.update() + + @classmethod + def tearDownClass(cls): + del cls.page.set_samples # Unmask instance method. + + def setUp(self): + changes.clear() + + def test_load_font_cfg(self): + # Leave widget load test to human visual check. + # TODO Improve checks when add IdleConf.get_font_values. + tracers.detach() + d = self.page + d.font_name.set('Fake') + d.font_size.set('1') + d.font_bold.set(True) + d.set_samples.called = 0 + d.load_font_cfg() + self.assertNotEqual(d.font_name.get(), 'Fake') + self.assertNotEqual(d.font_size.get(), '1') + self.assertFalse(d.font_bold.get()) + self.assertEqual(d.set_samples.called, 1) + tracers.attach() + + def test_fontlist_key(self): + # Up and Down keys should select a new font. + d = self.page + if d.fontlist.size() < 2: + self.skipTest('need at least 2 fonts') + fontlist = d.fontlist + fontlist.activate(0) + font = d.fontlist.get('active') + + # Test Down key. + fontlist.focus_force() + fontlist.update() + fontlist.event_generate('') + fontlist.event_generate('') + + down_font = fontlist.get('active') + self.assertNotEqual(down_font, font) + self.assertIn(d.font_name.get(), down_font.lower()) + + # Test Up key. + fontlist.focus_force() + fontlist.update() + fontlist.event_generate('') + fontlist.event_generate('') + + up_font = fontlist.get('active') + self.assertEqual(up_font, font) + self.assertIn(d.font_name.get(), up_font.lower()) + + def test_fontlist_mouse(self): + # Click on item should select that item. + d = self.page + if d.fontlist.size() < 2: + self.skipTest('need at least 2 fonts') + fontlist = d.fontlist + fontlist.activate(0) + + # Select next item in listbox + fontlist.focus_force() + fontlist.see(1) + fontlist.update() + x, y, dx, dy = fontlist.bbox(1) + x += dx // 2 + y += dy // 2 + fontlist.event_generate('', x=x, y=y) + fontlist.event_generate('', x=x, y=y) + + font1 = fontlist.get(1) + select_font = fontlist.get('anchor') + self.assertEqual(select_font, font1) + self.assertIn(d.font_name.get(), font1.lower()) + + def test_sizelist(self): + # Click on number should select that number + d = self.page + d.sizelist.variable.set(40) + self.assertEqual(d.font_size.get(), '40') + + def test_bold_toggle(self): + # Click on checkbutton should invert it. + d = self.page + d.font_bold.set(False) + d.bold_toggle.invoke() + self.assertTrue(d.font_bold.get()) + d.bold_toggle.invoke() + self.assertFalse(d.font_bold.get()) + + def test_font_set(self): + # Test that setting a font Variable results in 3 provisional + # change entries and a call to set_samples. Use values sure to + # not be defaults. + + default_font = idleConf.GetFont(root, 'main', 'EditorWindow') + default_size = str(default_font[1]) + default_bold = default_font[2] == 'bold' + d = self.page + d.font_size.set(default_size) + d.font_bold.set(default_bold) + d.set_samples.called = 0 + + d.font_name.set('Test Font') + expected = {'EditorWindow': {'font': 'Test Font', + 'font-size': default_size, + 'font-bold': str(default_bold)}} + self.assertEqual(mainpage, expected) + self.assertEqual(d.set_samples.called, 1) + changes.clear() + + d.font_size.set('20') + expected = {'EditorWindow': {'font': 'Test Font', + 'font-size': '20', + 'font-bold': str(default_bold)}} + self.assertEqual(mainpage, expected) + self.assertEqual(d.set_samples.called, 2) + changes.clear() + + d.font_bold.set(not default_bold) + expected = {'EditorWindow': {'font': 'Test Font', + 'font-size': '20', + 'font-bold': str(not default_bold)}} + self.assertEqual(mainpage, expected) + self.assertEqual(d.set_samples.called, 3) + + def test_set_samples(self): + d = self.page + del d.set_samples # Unmask method for test + orig_samples = d.font_sample, d.highlight_sample + d.font_sample, d.highlight_sample = {}, {} + d.font_name.set('test') + d.font_size.set('5') + d.font_bold.set(1) + expected = {'font': ('test', '5', 'bold')} + + # Test set_samples. + d.set_samples() + self.assertTrue(d.font_sample == d.highlight_sample == expected) + + d.font_sample, d.highlight_sample = orig_samples + d.set_samples = Func() # Re-mask for other tests. + + +class HighPageTest(unittest.TestCase): + """Test that highlight tab widgets enable users to make changes. + + Test that widget actions set vars, that var changes add + options to changes and that themes work correctly. + """ + + @classmethod + def setUpClass(cls): + page = cls.page = dialog.highpage + dialog.note.select(page) + page.set_theme_type = Func() + page.paint_theme_sample = Func() + page.set_highlight_target = Func() + page.set_color_sample = Func() + page.update() + + @classmethod + def tearDownClass(cls): + d = cls.page + del d.set_theme_type, d.paint_theme_sample + del d.set_highlight_target, d.set_color_sample + + def setUp(self): + d = self.page + # The following is needed for test_load_key_cfg, _delete_custom_keys. + # This may indicate a defect in some test or function. + for section in idleConf.GetSectionList('user', 'highlight'): + idleConf.userCfg['highlight'].remove_section(section) + changes.clear() + d.set_theme_type.called = 0 + d.paint_theme_sample.called = 0 + d.set_highlight_target.called = 0 + d.set_color_sample.called = 0 + + def test_load_theme_cfg(self): + tracers.detach() + d = self.page + eq = self.assertEqual + + # Use builtin theme with no user themes created. + idleConf.CurrentTheme = mock.Mock(return_value='IDLE Classic') + d.load_theme_cfg() + self.assertTrue(d.theme_source.get()) + # builtinlist sets variable builtin_name to the CurrentTheme default. + eq(d.builtin_name.get(), 'IDLE Classic') + eq(d.custom_name.get(), '- no custom themes -') + eq(d.custom_theme_on.state(), ('disabled',)) + eq(d.set_theme_type.called, 1) + eq(d.paint_theme_sample.called, 1) + eq(d.set_highlight_target.called, 1) + + # Builtin theme with non-empty user theme list. + idleConf.SetOption('highlight', 'test1', 'option', 'value') + idleConf.SetOption('highlight', 'test2', 'option2', 'value2') + d.load_theme_cfg() + eq(d.builtin_name.get(), 'IDLE Classic') + eq(d.custom_name.get(), 'test1') + eq(d.set_theme_type.called, 2) + eq(d.paint_theme_sample.called, 2) + eq(d.set_highlight_target.called, 2) + + # Use custom theme. + idleConf.CurrentTheme = mock.Mock(return_value='test2') + idleConf.SetOption('main', 'Theme', 'default', '0') + d.load_theme_cfg() + self.assertFalse(d.theme_source.get()) + eq(d.builtin_name.get(), 'IDLE Classic') + eq(d.custom_name.get(), 'test2') + eq(d.set_theme_type.called, 3) + eq(d.paint_theme_sample.called, 3) + eq(d.set_highlight_target.called, 3) + + del idleConf.CurrentTheme + tracers.attach() + + def test_theme_source(self): + eq = self.assertEqual + d = self.page + # Test these separately. + d.var_changed_builtin_name = Func() + d.var_changed_custom_name = Func() + # Builtin selected. + d.builtin_theme_on.invoke() + eq(mainpage, {'Theme': {'default': 'True'}}) + eq(d.var_changed_builtin_name.called, 1) + eq(d.var_changed_custom_name.called, 0) + changes.clear() + + # Custom selected. + d.custom_theme_on.state(('!disabled',)) + d.custom_theme_on.invoke() + self.assertEqual(mainpage, {'Theme': {'default': 'False'}}) + eq(d.var_changed_builtin_name.called, 1) + eq(d.var_changed_custom_name.called, 1) + del d.var_changed_builtin_name, d.var_changed_custom_name + + def test_builtin_name(self): + eq = self.assertEqual + d = self.page + item_list = ['IDLE Classic', 'IDLE Dark', 'IDLE New'] + + # Not in old_themes, defaults name to first item. + idleConf.SetOption('main', 'Theme', 'name', 'spam') + d.builtinlist.SetMenu(item_list, 'IDLE Dark') + eq(mainpage, {'Theme': {'name': 'IDLE Classic', + 'name2': 'IDLE Dark'}}) + eq(d.theme_message['text'], 'New theme, see Help') + eq(d.paint_theme_sample.called, 1) + + # Not in old themes - uses name2. + changes.clear() + idleConf.SetOption('main', 'Theme', 'name', 'IDLE New') + d.builtinlist.SetMenu(item_list, 'IDLE Dark') + eq(mainpage, {'Theme': {'name2': 'IDLE Dark'}}) + eq(d.theme_message['text'], 'New theme, see Help') + eq(d.paint_theme_sample.called, 2) + + # Builtin name in old_themes. + changes.clear() + d.builtinlist.SetMenu(item_list, 'IDLE Classic') + eq(mainpage, {'Theme': {'name': 'IDLE Classic', 'name2': ''}}) + eq(d.theme_message['text'], '') + eq(d.paint_theme_sample.called, 3) + + def test_custom_name(self): + d = self.page + + # If no selections, doesn't get added. + d.customlist.SetMenu([], '- no custom themes -') + self.assertNotIn('Theme', mainpage) + self.assertEqual(d.paint_theme_sample.called, 0) + + # Custom name selected. + changes.clear() + d.customlist.SetMenu(['a', 'b', 'c'], 'c') + self.assertEqual(mainpage, {'Theme': {'name': 'c'}}) + self.assertEqual(d.paint_theme_sample.called, 1) + + def test_color(self): + d = self.page + d.on_new_color_set = Func() + # self.color is only set in get_color through colorchooser. + d.color.set('green') + self.assertEqual(d.on_new_color_set.called, 1) + del d.on_new_color_set + + def test_highlight_target_list_mouse(self): + # Set highlight_target through targetlist. + eq = self.assertEqual + d = self.page + + d.targetlist.SetMenu(['a', 'b', 'c'], 'c') + eq(d.highlight_target.get(), 'c') + eq(d.set_highlight_target.called, 1) + + def test_highlight_target_text_mouse(self): + # Set highlight_target through clicking highlight_sample. + eq = self.assertEqual + d = self.page + hs = d.highlight_sample + hs.focus_force() + + def click_char(index): + "Simulate click on character at *index*." + hs.see(index) + hs.update_idletasks() + x, y, dx, dy = hs.bbox(index) + x += dx // 2 + y += dy // 2 + hs.event_generate('', x=0, y=0) + hs.event_generate('', x=x, y=y) + hs.event_generate('', x=x, y=y) + hs.event_generate('', x=x, y=y) + + # Reverse theme_elements to make the tag the key. + elem = {tag: element for element, tag in d.theme_elements.items()} + + # If highlight_sample has a tag that isn't in theme_elements, there + # will be a KeyError in the test run. + count = 0 + for tag in hs.tag_names(): + try: + click_char(hs.tag_nextrange(tag, "1.0")[0]) + eq(d.highlight_target.get(), elem[tag]) + count += 1 + eq(d.set_highlight_target.called, count) + except IndexError: + pass # Skip unused theme_elements tag, like 'sel'. + + def test_highlight_sample_double_click(self): + # Test double click on highlight_sample. + eq = self.assertEqual + d = self.page + + hs = d.highlight_sample + hs.focus_force() + hs.see(1.0) + hs.update_idletasks() + + # Test binding from configdialog. + hs.event_generate('', x=0, y=0) + hs.event_generate('', x=0, y=0) + # Double click is a sequence of two clicks in a row. + for _ in range(2): + hs.event_generate('', x=0, y=0) + hs.event_generate('', x=0, y=0) + + eq(hs.tag_ranges('sel'), ()) + + def test_highlight_sample_b1_motion(self): + # Test button motion on highlight_sample. + eq = self.assertEqual + d = self.page + + hs = d.highlight_sample + hs.focus_force() + hs.see(1.0) + hs.update_idletasks() + + x, y, dx, dy, offset = hs.dlineinfo('1.0') + + # Test binding from configdialog. + hs.event_generate('') + hs.event_generate('') + hs.event_generate('', x=x, y=y) + hs.event_generate('', x=x, y=y) + hs.event_generate('', x=dx, y=dy) + hs.event_generate('', x=dx, y=dy) + + eq(hs.tag_ranges('sel'), ()) + + def test_set_theme_type(self): + eq = self.assertEqual + d = self.page + del d.set_theme_type + + # Builtin theme selected. + d.theme_source.set(True) + d.set_theme_type() + eq(d.builtinlist['state'], NORMAL) + eq(d.customlist['state'], DISABLED) + eq(d.button_delete_custom.state(), ('disabled',)) + + # Custom theme selected. + d.theme_source.set(False) + d.set_theme_type() + eq(d.builtinlist['state'], DISABLED) + eq(d.custom_theme_on.state(), ('selected',)) + eq(d.customlist['state'], NORMAL) + eq(d.button_delete_custom.state(), ()) + d.set_theme_type = Func() + + def test_get_color(self): + eq = self.assertEqual + d = self.page + orig_chooser = configdialog.colorchooser.askcolor + chooser = configdialog.colorchooser.askcolor = Func() + gntn = d.get_new_theme_name = Func() + + d.highlight_target.set('Editor Breakpoint') + d.color.set('#ffffff') + + # Nothing selected. + chooser.result = (None, None) + d.button_set_color.invoke() + eq(d.color.get(), '#ffffff') + + # Selection same as previous color. + chooser.result = ('', d.style.lookup(d.frame_color_set['style'], 'background')) + d.button_set_color.invoke() + eq(d.color.get(), '#ffffff') + + # Select different color. + chooser.result = ((222.8671875, 0.0, 0.0), '#de0000') + + # Default theme. + d.color.set('#ffffff') + d.theme_source.set(True) + + # No theme name selected therefore color not saved. + gntn.result = '' + d.button_set_color.invoke() + eq(gntn.called, 1) + eq(d.color.get(), '#ffffff') + # Theme name selected. + gntn.result = 'My New Theme' + d.button_set_color.invoke() + eq(d.custom_name.get(), gntn.result) + eq(d.color.get(), '#de0000') + + # Custom theme. + d.color.set('#ffffff') + d.theme_source.set(False) + d.button_set_color.invoke() + eq(d.color.get(), '#de0000') + + del d.get_new_theme_name + configdialog.colorchooser.askcolor = orig_chooser + + def test_on_new_color_set(self): + d = self.page + color = '#3f7cae' + d.custom_name.set('Python') + d.highlight_target.set('Selected Text') + d.fg_bg_toggle.set(True) + + d.color.set(color) + self.assertEqual(d.style.lookup(d.frame_color_set['style'], 'background'), color) + self.assertEqual(d.highlight_sample.tag_cget('hilite', 'foreground'), color) + self.assertEqual(highpage, + {'Python': {'hilite-foreground': color}}) + + def test_get_new_theme_name(self): + orig_sectionname = configdialog.SectionName + sn = configdialog.SectionName = Func(return_self=True) + d = self.page + + sn.result = 'New Theme' + self.assertEqual(d.get_new_theme_name(''), 'New Theme') + + configdialog.SectionName = orig_sectionname + + def test_save_as_new_theme(self): + d = self.page + gntn = d.get_new_theme_name = Func() + d.theme_source.set(True) + + # No name entered. + gntn.result = '' + d.button_save_custom.invoke() + self.assertNotIn(gntn.result, idleConf.userCfg['highlight']) + + # Name entered. + gntn.result = 'my new theme' + gntn.called = 0 + self.assertNotIn(gntn.result, idleConf.userCfg['highlight']) + d.button_save_custom.invoke() + self.assertIn(gntn.result, idleConf.userCfg['highlight']) + + del d.get_new_theme_name + + def test_create_new_and_save_new(self): + eq = self.assertEqual + d = self.page + + # Use default as previously active theme. + d.theme_source.set(True) + d.builtin_name.set('IDLE Classic') + first_new = 'my new custom theme' + second_new = 'my second custom theme' + + # No changes, so themes are an exact copy. + self.assertNotIn(first_new, idleConf.userCfg) + d.create_new(first_new) + eq(idleConf.GetSectionList('user', 'highlight'), [first_new]) + eq(idleConf.GetThemeDict('default', 'IDLE Classic'), + idleConf.GetThemeDict('user', first_new)) + eq(d.custom_name.get(), first_new) + self.assertFalse(d.theme_source.get()) # Use custom set. + eq(d.set_theme_type.called, 1) + + # Test that changed targets are in new theme. + changes.add_option('highlight', first_new, 'hit-background', 'yellow') + self.assertNotIn(second_new, idleConf.userCfg) + d.create_new(second_new) + eq(idleConf.GetSectionList('user', 'highlight'), [first_new, second_new]) + self.assertNotEqual(idleConf.GetThemeDict('user', first_new), + idleConf.GetThemeDict('user', second_new)) + # Check that difference in themes was in `hit-background` from `changes`. + idleConf.SetOption('highlight', first_new, 'hit-background', 'yellow') + eq(idleConf.GetThemeDict('user', first_new), + idleConf.GetThemeDict('user', second_new)) + + def test_set_highlight_target(self): + eq = self.assertEqual + d = self.page + del d.set_highlight_target + + # Target is cursor. + d.highlight_target.set('Cursor') + eq(d.fg_on.state(), ('disabled', 'selected')) + eq(d.bg_on.state(), ('disabled',)) + self.assertTrue(d.fg_bg_toggle) + eq(d.set_color_sample.called, 1) + + # Target is not cursor. + d.highlight_target.set('Comment') + eq(d.fg_on.state(), ('selected',)) + eq(d.bg_on.state(), ()) + self.assertTrue(d.fg_bg_toggle) + eq(d.set_color_sample.called, 2) + + d.set_highlight_target = Func() + + def test_set_color_sample_binding(self): + d = self.page + scs = d.set_color_sample + + d.fg_on.invoke() + self.assertEqual(scs.called, 1) + + d.bg_on.invoke() + self.assertEqual(scs.called, 2) + + def test_set_color_sample(self): + d = self.page + del d.set_color_sample + d.highlight_target.set('Selected Text') + d.fg_bg_toggle.set(True) + d.set_color_sample() + self.assertEqual( + d.style.lookup(d.frame_color_set['style'], 'background'), + d.highlight_sample.tag_cget('hilite', 'foreground')) + d.set_color_sample = Func() + + def test_paint_theme_sample(self): + eq = self.assertEqual + page = self.page + del page.paint_theme_sample # Delete masking mock. + hs_tag = page.highlight_sample.tag_cget + gh = idleConf.GetHighlight + + # Create custom theme based on IDLE Dark. + page.theme_source.set(True) + page.builtin_name.set('IDLE Dark') + theme = 'IDLE Test' + page.create_new(theme) + page.set_color_sample.called = 0 + + # Base theme with nothing in `changes`. + page.paint_theme_sample() + new_console = {'foreground': 'blue', + 'background': 'yellow',} + for key, value in new_console.items(): + self.assertNotEqual(hs_tag('console', key), value) + eq(page.set_color_sample.called, 1) + + # Apply changes. + for key, value in new_console.items(): + changes.add_option('highlight', theme, 'console-'+key, value) + page.paint_theme_sample() + for key, value in new_console.items(): + eq(hs_tag('console', key), value) + eq(page.set_color_sample.called, 2) + + page.paint_theme_sample = Func() + + def test_delete_custom(self): + eq = self.assertEqual + d = self.page + d.button_delete_custom.state(('!disabled',)) + yesno = d.askyesno = Func() + dialog.deactivate_current_config = Func() + dialog.activate_config_changes = Func() + + theme_name = 'spam theme' + idleConf.userCfg['highlight'].SetOption(theme_name, 'name', 'value') + highpage[theme_name] = {'option': 'True'} + + theme_name2 = 'other theme' + idleConf.userCfg['highlight'].SetOption(theme_name2, 'name', 'value') + highpage[theme_name2] = {'option': 'False'} + + # Force custom theme. + d.custom_theme_on.state(('!disabled',)) + d.custom_theme_on.invoke() + d.custom_name.set(theme_name) + + # Cancel deletion. + yesno.result = False + d.button_delete_custom.invoke() + eq(yesno.called, 1) + eq(highpage[theme_name], {'option': 'True'}) + eq(idleConf.GetSectionList('user', 'highlight'), [theme_name, theme_name2]) + eq(dialog.deactivate_current_config.called, 0) + eq(dialog.activate_config_changes.called, 0) + eq(d.set_theme_type.called, 0) + + # Confirm deletion. + yesno.result = True + d.button_delete_custom.invoke() + eq(yesno.called, 2) + self.assertNotIn(theme_name, highpage) + eq(idleConf.GetSectionList('user', 'highlight'), [theme_name2]) + eq(d.custom_theme_on.state(), ()) + eq(d.custom_name.get(), theme_name2) + eq(dialog.deactivate_current_config.called, 1) + eq(dialog.activate_config_changes.called, 1) + eq(d.set_theme_type.called, 1) + + # Confirm deletion of second theme - empties list. + d.custom_name.set(theme_name2) + yesno.result = True + d.button_delete_custom.invoke() + eq(yesno.called, 3) + self.assertNotIn(theme_name, highpage) + eq(idleConf.GetSectionList('user', 'highlight'), []) + eq(d.custom_theme_on.state(), ('disabled',)) + eq(d.custom_name.get(), '- no custom themes -') + eq(dialog.deactivate_current_config.called, 2) + eq(dialog.activate_config_changes.called, 2) + eq(d.set_theme_type.called, 2) + + del dialog.activate_config_changes, dialog.deactivate_current_config + del d.askyesno + + +class KeysPageTest(unittest.TestCase): + """Test that keys tab widgets enable users to make changes. + + Test that widget actions set vars, that var changes add + options to changes and that key sets works correctly. + """ + + @classmethod + def setUpClass(cls): + page = cls.page = dialog.keyspage + dialog.note.select(page) + page.set_keys_type = Func() + page.load_keys_list = Func() + + @classmethod + def tearDownClass(cls): + page = cls.page + del page.set_keys_type, page.load_keys_list + + def setUp(self): + d = self.page + # The following is needed for test_load_key_cfg, _delete_custom_keys. + # This may indicate a defect in some test or function. + for section in idleConf.GetSectionList('user', 'keys'): + idleConf.userCfg['keys'].remove_section(section) + changes.clear() + d.set_keys_type.called = 0 + d.load_keys_list.called = 0 + + def test_load_key_cfg(self): + tracers.detach() + d = self.page + eq = self.assertEqual + + # Use builtin keyset with no user keysets created. + idleConf.CurrentKeys = mock.Mock(return_value='IDLE Classic OSX') + d.load_key_cfg() + self.assertTrue(d.keyset_source.get()) + # builtinlist sets variable builtin_name to the CurrentKeys default. + eq(d.builtin_name.get(), 'IDLE Classic OSX') + eq(d.custom_name.get(), '- no custom keys -') + eq(d.custom_keyset_on.state(), ('disabled',)) + eq(d.set_keys_type.called, 1) + eq(d.load_keys_list.called, 1) + eq(d.load_keys_list.args, ('IDLE Classic OSX', )) + + # Builtin keyset with non-empty user keyset list. + idleConf.SetOption('keys', 'test1', 'option', 'value') + idleConf.SetOption('keys', 'test2', 'option2', 'value2') + d.load_key_cfg() + eq(d.builtin_name.get(), 'IDLE Classic OSX') + eq(d.custom_name.get(), 'test1') + eq(d.set_keys_type.called, 2) + eq(d.load_keys_list.called, 2) + eq(d.load_keys_list.args, ('IDLE Classic OSX', )) + + # Use custom keyset. + idleConf.CurrentKeys = mock.Mock(return_value='test2') + idleConf.default_keys = mock.Mock(return_value='IDLE Modern Unix') + idleConf.SetOption('main', 'Keys', 'default', '0') + d.load_key_cfg() + self.assertFalse(d.keyset_source.get()) + eq(d.builtin_name.get(), 'IDLE Modern Unix') + eq(d.custom_name.get(), 'test2') + eq(d.set_keys_type.called, 3) + eq(d.load_keys_list.called, 3) + eq(d.load_keys_list.args, ('test2', )) + + del idleConf.CurrentKeys, idleConf.default_keys + tracers.attach() + + def test_keyset_source(self): + eq = self.assertEqual + d = self.page + # Test these separately. + d.var_changed_builtin_name = Func() + d.var_changed_custom_name = Func() + # Builtin selected. + d.builtin_keyset_on.invoke() + eq(mainpage, {'Keys': {'default': 'True'}}) + eq(d.var_changed_builtin_name.called, 1) + eq(d.var_changed_custom_name.called, 0) + changes.clear() + + # Custom selected. + d.custom_keyset_on.state(('!disabled',)) + d.custom_keyset_on.invoke() + self.assertEqual(mainpage, {'Keys': {'default': 'False'}}) + eq(d.var_changed_builtin_name.called, 1) + eq(d.var_changed_custom_name.called, 1) + del d.var_changed_builtin_name, d.var_changed_custom_name + + def test_builtin_name(self): + eq = self.assertEqual + d = self.page + idleConf.userCfg['main'].remove_section('Keys') + item_list = ['IDLE Classic Windows', 'IDLE Classic OSX', + 'IDLE Modern UNIX'] + + # Not in old_keys, defaults name to first item. + d.builtinlist.SetMenu(item_list, 'IDLE Modern UNIX') + eq(mainpage, {'Keys': {'name': 'IDLE Classic Windows', + 'name2': 'IDLE Modern UNIX'}}) + eq(d.keys_message['text'], 'New key set, see Help') + eq(d.load_keys_list.called, 1) + eq(d.load_keys_list.args, ('IDLE Modern UNIX', )) + + # Not in old keys - uses name2. + changes.clear() + idleConf.SetOption('main', 'Keys', 'name', 'IDLE Classic Unix') + d.builtinlist.SetMenu(item_list, 'IDLE Modern UNIX') + eq(mainpage, {'Keys': {'name2': 'IDLE Modern UNIX'}}) + eq(d.keys_message['text'], 'New key set, see Help') + eq(d.load_keys_list.called, 2) + eq(d.load_keys_list.args, ('IDLE Modern UNIX', )) + + # Builtin name in old_keys. + changes.clear() + d.builtinlist.SetMenu(item_list, 'IDLE Classic OSX') + eq(mainpage, {'Keys': {'name': 'IDLE Classic OSX', 'name2': ''}}) + eq(d.keys_message['text'], '') + eq(d.load_keys_list.called, 3) + eq(d.load_keys_list.args, ('IDLE Classic OSX', )) + + def test_custom_name(self): + d = self.page + + # If no selections, doesn't get added. + d.customlist.SetMenu([], '- no custom keys -') + self.assertNotIn('Keys', mainpage) + self.assertEqual(d.load_keys_list.called, 0) + + # Custom name selected. + changes.clear() + d.customlist.SetMenu(['a', 'b', 'c'], 'c') + self.assertEqual(mainpage, {'Keys': {'name': 'c'}}) + self.assertEqual(d.load_keys_list.called, 1) + + def test_keybinding(self): + idleConf.SetOption('extensions', 'ZzDummy', 'enable', 'True') + d = self.page + d.custom_name.set('my custom keys') + d.bindingslist.delete(0, 'end') + d.bindingslist.insert(0, 'copy') + d.bindingslist.insert(1, 'z-in') + d.bindingslist.selection_set(0) + d.bindingslist.selection_anchor(0) + # Core binding - adds to keys. + d.keybinding.set('') + self.assertEqual(keyspage, + {'my custom keys': {'copy': ''}}) + + # Not a core binding - adds to extensions. + d.bindingslist.selection_set(1) + d.bindingslist.selection_anchor(1) + d.keybinding.set('') + self.assertEqual(extpage, + {'ZzDummy_cfgBindings': {'z-in': ''}}) + + def test_set_keys_type(self): + eq = self.assertEqual + d = self.page + del d.set_keys_type + + # Builtin keyset selected. + d.keyset_source.set(True) + d.set_keys_type() + eq(d.builtinlist['state'], NORMAL) + eq(d.customlist['state'], DISABLED) + eq(d.button_delete_custom_keys.state(), ('disabled',)) + + # Custom keyset selected. + d.keyset_source.set(False) + d.set_keys_type() + eq(d.builtinlist['state'], DISABLED) + eq(d.custom_keyset_on.state(), ('selected',)) + eq(d.customlist['state'], NORMAL) + eq(d.button_delete_custom_keys.state(), ()) + d.set_keys_type = Func() + + def test_get_new_keys(self): + eq = self.assertEqual + d = self.page + orig_getkeysdialog = configdialog.GetKeysWindow + gkd = configdialog.GetKeysWindow = Func(return_self=True) + gnkn = d.get_new_keys_name = Func() + + d.button_new_keys.state(('!disabled',)) + d.bindingslist.delete(0, 'end') + d.bindingslist.insert(0, 'copy - ') + d.bindingslist.selection_set(0) + d.bindingslist.selection_anchor(0) + d.keybinding.set('Key-a') + d.keyset_source.set(True) # Default keyset. + + # Default keyset; no change to binding. + gkd.result = '' + d.button_new_keys.invoke() + eq(d.bindingslist.get('anchor'), 'copy - ') + # Keybinding isn't changed when there isn't a change entered. + eq(d.keybinding.get(), 'Key-a') + + # Default keyset; binding changed. + gkd.result = '' + # No keyset name selected therefore binding not saved. + gnkn.result = '' + d.button_new_keys.invoke() + eq(gnkn.called, 1) + eq(d.bindingslist.get('anchor'), 'copy - ') + # Keyset name selected. + gnkn.result = 'My New Key Set' + d.button_new_keys.invoke() + eq(d.custom_name.get(), gnkn.result) + eq(d.bindingslist.get('anchor'), 'copy - ') + eq(d.keybinding.get(), '') + + # User keyset; binding changed. + d.keyset_source.set(False) # Custom keyset. + gnkn.called = 0 + gkd.result = '' + d.button_new_keys.invoke() + eq(gnkn.called, 0) + eq(d.bindingslist.get('anchor'), 'copy - ') + eq(d.keybinding.get(), '') + + del d.get_new_keys_name + configdialog.GetKeysWindow = orig_getkeysdialog + + def test_get_new_keys_name(self): + orig_sectionname = configdialog.SectionName + sn = configdialog.SectionName = Func(return_self=True) + d = self.page + + sn.result = 'New Keys' + self.assertEqual(d.get_new_keys_name(''), 'New Keys') + + configdialog.SectionName = orig_sectionname + + def test_save_as_new_key_set(self): + d = self.page + gnkn = d.get_new_keys_name = Func() + d.keyset_source.set(True) + + # No name entered. + gnkn.result = '' + d.button_save_custom_keys.invoke() + + # Name entered. + gnkn.result = 'my new key set' + gnkn.called = 0 + self.assertNotIn(gnkn.result, idleConf.userCfg['keys']) + d.button_save_custom_keys.invoke() + self.assertIn(gnkn.result, idleConf.userCfg['keys']) + + del d.get_new_keys_name + + def test_on_bindingslist_select(self): + d = self.page + b = d.bindingslist + b.delete(0, 'end') + b.insert(0, 'copy') + b.insert(1, 'find') + b.activate(0) + + b.focus_force() + b.see(1) + b.update() + x, y, dx, dy = b.bbox(1) + x += dx // 2 + y += dy // 2 + b.event_generate('', x=0, y=0) + b.event_generate('', x=x, y=y) + b.event_generate('', x=x, y=y) + b.event_generate('', x=x, y=y) + self.assertEqual(b.get('anchor'), 'find') + self.assertEqual(d.button_new_keys.state(), ()) + + def test_create_new_key_set_and_save_new_key_set(self): + eq = self.assertEqual + d = self.page + + # Use default as previously active keyset. + d.keyset_source.set(True) + d.builtin_name.set('IDLE Classic Windows') + first_new = 'my new custom key set' + second_new = 'my second custom keyset' + + # No changes, so keysets are an exact copy. + self.assertNotIn(first_new, idleConf.userCfg) + d.create_new_key_set(first_new) + eq(idleConf.GetSectionList('user', 'keys'), [first_new]) + eq(idleConf.GetKeySet('IDLE Classic Windows'), + idleConf.GetKeySet(first_new)) + eq(d.custom_name.get(), first_new) + self.assertFalse(d.keyset_source.get()) # Use custom set. + eq(d.set_keys_type.called, 1) + + # Test that changed keybindings are in new keyset. + changes.add_option('keys', first_new, 'copy', '') + self.assertNotIn(second_new, idleConf.userCfg) + d.create_new_key_set(second_new) + eq(idleConf.GetSectionList('user', 'keys'), [first_new, second_new]) + self.assertNotEqual(idleConf.GetKeySet(first_new), + idleConf.GetKeySet(second_new)) + # Check that difference in keysets was in option `copy` from `changes`. + idleConf.SetOption('keys', first_new, 'copy', '') + eq(idleConf.GetKeySet(first_new), idleConf.GetKeySet(second_new)) + + def test_load_keys_list(self): + eq = self.assertEqual + d = self.page + gks = idleConf.GetKeySet = Func() + del d.load_keys_list + b = d.bindingslist + + b.delete(0, 'end') + b.insert(0, '<>') + b.insert(1, '<>') + gks.result = {'<>': ['', ''], + '<>': [''], + '<>': ['']} + changes.add_option('keys', 'my keys', 'spam', '') + expected = ('copy - ', + 'force-open-completions - ', + 'spam - ') + + # No current selection. + d.load_keys_list('my keys') + eq(b.get(0, 'end'), expected) + eq(b.get('anchor'), '') + eq(b.curselection(), ()) + + # Check selection. + b.selection_set(1) + b.selection_anchor(1) + d.load_keys_list('my keys') + eq(b.get(0, 'end'), expected) + eq(b.get('anchor'), 'force-open-completions - ') + eq(b.curselection(), (1, )) + + # Change selection. + b.selection_set(2) + b.selection_anchor(2) + d.load_keys_list('my keys') + eq(b.get(0, 'end'), expected) + eq(b.get('anchor'), 'spam - ') + eq(b.curselection(), (2, )) + d.load_keys_list = Func() + + del idleConf.GetKeySet + + def test_delete_custom_keys(self): + eq = self.assertEqual + d = self.page + d.button_delete_custom_keys.state(('!disabled',)) + yesno = d.askyesno = Func() + dialog.deactivate_current_config = Func() + dialog.activate_config_changes = Func() + + keyset_name = 'spam key set' + idleConf.userCfg['keys'].SetOption(keyset_name, 'name', 'value') + keyspage[keyset_name] = {'option': 'True'} + + keyset_name2 = 'other key set' + idleConf.userCfg['keys'].SetOption(keyset_name2, 'name', 'value') + keyspage[keyset_name2] = {'option': 'False'} + + # Force custom keyset. + d.custom_keyset_on.state(('!disabled',)) + d.custom_keyset_on.invoke() + d.custom_name.set(keyset_name) + + # Cancel deletion. + yesno.result = False + d.button_delete_custom_keys.invoke() + eq(yesno.called, 1) + eq(keyspage[keyset_name], {'option': 'True'}) + eq(idleConf.GetSectionList('user', 'keys'), [keyset_name, keyset_name2]) + eq(dialog.deactivate_current_config.called, 0) + eq(dialog.activate_config_changes.called, 0) + eq(d.set_keys_type.called, 0) + + # Confirm deletion. + yesno.result = True + d.button_delete_custom_keys.invoke() + eq(yesno.called, 2) + self.assertNotIn(keyset_name, keyspage) + eq(idleConf.GetSectionList('user', 'keys'), [keyset_name2]) + eq(d.custom_keyset_on.state(), ()) + eq(d.custom_name.get(), keyset_name2) + eq(dialog.deactivate_current_config.called, 1) + eq(dialog.activate_config_changes.called, 1) + eq(d.set_keys_type.called, 1) + + # Confirm deletion of second keyset - empties list. + d.custom_name.set(keyset_name2) + yesno.result = True + d.button_delete_custom_keys.invoke() + eq(yesno.called, 3) + self.assertNotIn(keyset_name, keyspage) + eq(idleConf.GetSectionList('user', 'keys'), []) + eq(d.custom_keyset_on.state(), ('disabled',)) + eq(d.custom_name.get(), '- no custom keys -') + eq(dialog.deactivate_current_config.called, 2) + eq(dialog.activate_config_changes.called, 2) + eq(d.set_keys_type.called, 2) + + del dialog.activate_config_changes, dialog.deactivate_current_config + del d.askyesno + + +class WinPageTest(unittest.TestCase): + """Test that general tab widgets enable users to make changes. + + Test that widget actions set vars, that var changes add + options to changes. + """ + @classmethod + def setUpClass(cls): + page = cls.page = dialog.winpage + dialog.note.select(page) + page.update() + + def setUp(self): + changes.clear() + + def test_load_windows_cfg(self): + # Set to wrong values, load, check right values. + eq = self.assertEqual + d = self.page + d.startup_edit.set(1) + d.win_width.set(1) + d.win_height.set(1) + d.load_windows_cfg() + eq(d.startup_edit.get(), 0) + eq(d.win_width.get(), '80') + eq(d.win_height.get(), '40') + + def test_startup(self): + d = self.page + d.startup_editor_on.invoke() + self.assertEqual(mainpage, + {'General': {'editor-on-startup': '1'}}) + changes.clear() + d.startup_shell_on.invoke() + self.assertEqual(mainpage, + {'General': {'editor-on-startup': '0'}}) + + def test_editor_size(self): + d = self.page + d.win_height_int.delete(0, 'end') + d.win_height_int.insert(0, '11') + self.assertEqual(mainpage, {'EditorWindow': {'height': '11'}}) + changes.clear() + d.win_width_int.delete(0, 'end') + d.win_width_int.insert(0, '11') + self.assertEqual(mainpage, {'EditorWindow': {'width': '11'}}) + + def test_indent_spaces(self): + d = self.page + d.indent_chooser.set(6) + self.assertEqual(d.indent_spaces.get(), '6') + self.assertEqual(mainpage, {'Indent': {'num-spaces': '6'}}) + + def test_cursor_blink(self): + self.page.cursor_blink_bool.invoke() + self.assertEqual(mainpage, {'EditorWindow': {'cursor-blink': 'False'}}) + + def test_autocomplete_wait(self): + self.page.auto_wait_int.delete(0, 'end') + self.page.auto_wait_int.insert(0, '11') + self.assertEqual(extpage, {'AutoComplete': {'popupwait': '11'}}) + + def test_parenmatch(self): + d = self.page + eq = self.assertEqual + d.paren_style_type['menu'].invoke(0) + eq(extpage, {'ParenMatch': {'style': 'opener'}}) + changes.clear() + d.paren_flash_time.delete(0, 'end') + d.paren_flash_time.insert(0, '11') + eq(extpage, {'ParenMatch': {'flash-delay': '11'}}) + changes.clear() + d.bell_on.invoke() + eq(extpage, {'ParenMatch': {'bell': 'False'}}) + + def test_paragraph(self): + self.page.format_width_int.delete(0, 'end') + self.page.format_width_int.insert(0, '11') + self.assertEqual(extpage, {'FormatParagraph': {'max-width': '11'}}) + + +class ShedPageTest(unittest.TestCase): + """Test that shed tab widgets enable users to make changes. + + Test that widget actions set vars, that var changes add + options to changes. + """ + @classmethod + def setUpClass(cls): + page = cls.page = dialog.shedpage + dialog.note.select(page) + page.update() + + def setUp(self): + changes.clear() + + def test_load_shelled_cfg(self): + # Set to wrong values, load, check right values. + eq = self.assertEqual + d = self.page + d.autosave.set(1) + d.load_shelled_cfg() + eq(d.autosave.get(), 0) + + def test_autosave(self): + d = self.page + d.save_auto_on.invoke() + self.assertEqual(mainpage, {'General': {'autosave': '1'}}) + d.save_ask_on.invoke() + self.assertEqual(mainpage, {'General': {'autosave': '0'}}) + + def test_context(self): + self.page.context_int.delete(0, 'end') + self.page.context_int.insert(0, '1') + self.assertEqual(extpage, {'CodeContext': {'maxlines': '1'}}) + + +#unittest.skip("Nothing here yet TODO") +class ExtPageTest(unittest.TestCase): + """Test that the help source list works correctly.""" + @classmethod + def setUpClass(cls): + page = dialog.extpage + dialog.note.select(page) + + +class HelpSourceTest(unittest.TestCase): + """Test that the help source list works correctly.""" + @classmethod + def setUpClass(cls): + page = dialog.extpage + dialog.note.select(page) + frame = cls.frame = page.frame_help + frame.set = frame.set_add_delete_state = Func() + frame.upc = frame.update_help_changes = Func() + frame.update() + + @classmethod + def tearDownClass(cls): + frame = cls.frame + del frame.set, frame.set_add_delete_state + del frame.upc, frame.update_help_changes + frame.helplist.delete(0, 'end') + frame.user_helplist.clear() + + def setUp(self): + changes.clear() + + def test_load_helplist(self): + eq = self.assertEqual + fr = self.frame + fr.helplist.insert('end', 'bad') + fr.user_helplist = ['bad', 'worse'] + idleConf.SetOption('main', 'HelpFiles', '1', 'name;file') + fr.load_helplist() + eq(fr.helplist.get(0, 'end'), ('name',)) + eq(fr.user_helplist, [('name', 'file', '1')]) + + def test_source_selected(self): + fr = self.frame + fr.set = fr.set_add_delete_state + fr.upc = fr.update_help_changes + helplist = fr.helplist + dex = 'end' + helplist.insert(dex, 'source') + helplist.activate(dex) + + helplist.focus_force() + helplist.see(dex) + helplist.update() + x, y, dx, dy = helplist.bbox(dex) + x += dx // 2 + y += dy // 2 + fr.set.called = fr.upc.called = 0 + helplist.event_generate('', x=0, y=0) + helplist.event_generate('', x=x, y=y) + helplist.event_generate('', x=x, y=y) + helplist.event_generate('', x=x, y=y) + self.assertEqual(helplist.get('anchor'), 'source') + self.assertTrue(fr.set.called) + self.assertFalse(fr.upc.called) + + def test_set_add_delete_state(self): + # Call with 0 items, 1 unselected item, 1 selected item. + eq = self.assertEqual + fr = self.frame + del fr.set_add_delete_state # Unmask method. + sad = fr.set_add_delete_state + h = fr.helplist + + h.delete(0, 'end') + sad() + eq(fr.button_helplist_edit.state(), ('disabled',)) + eq(fr.button_helplist_remove.state(), ('disabled',)) + + h.insert(0, 'source') + sad() + eq(fr.button_helplist_edit.state(), ('disabled',)) + eq(fr.button_helplist_remove.state(), ('disabled',)) + + h.selection_set(0) + sad() + eq(fr.button_helplist_edit.state(), ()) + eq(fr.button_helplist_remove.state(), ()) + fr.set_add_delete_state = Func() # Mask method. + + def test_helplist_item_add(self): + # Call without and twice with HelpSource result. + # Double call enables check on order. + eq = self.assertEqual + orig_helpsource = configdialog.HelpSource + hs = configdialog.HelpSource = Func(return_self=True) + fr = self.frame + fr.helplist.delete(0, 'end') + fr.user_helplist.clear() + fr.set.called = fr.upc.called = 0 + + hs.result = '' + fr.helplist_item_add() + self.assertTrue(list(fr.helplist.get(0, 'end')) == + fr.user_helplist == []) + self.assertFalse(fr.upc.called) + + hs.result = ('name1', 'file1') + fr.helplist_item_add() + hs.result = ('name2', 'file2') + fr.helplist_item_add() + eq(fr.helplist.get(0, 'end'), ('name1', 'name2')) + eq(fr.user_helplist, [('name1', 'file1'), ('name2', 'file2')]) + eq(fr.upc.called, 2) + self.assertFalse(fr.set.called) + + configdialog.HelpSource = orig_helpsource + + def test_helplist_item_edit(self): + # Call without and with HelpSource change. + eq = self.assertEqual + orig_helpsource = configdialog.HelpSource + hs = configdialog.HelpSource = Func(return_self=True) + fr = self.frame + fr.helplist.delete(0, 'end') + fr.helplist.insert(0, 'name1') + fr.helplist.selection_set(0) + fr.helplist.selection_anchor(0) + fr.user_helplist.clear() + fr.user_helplist.append(('name1', 'file1')) + fr.set.called = fr.upc.called = 0 + + hs.result = '' + fr.helplist_item_edit() + hs.result = ('name1', 'file1') + fr.helplist_item_edit() + eq(fr.helplist.get(0, 'end'), ('name1',)) + eq(fr.user_helplist, [('name1', 'file1')]) + self.assertFalse(fr.upc.called) + + hs.result = ('name2', 'file2') + fr.helplist_item_edit() + eq(fr.helplist.get(0, 'end'), ('name2',)) + eq(fr.user_helplist, [('name2', 'file2')]) + self.assertTrue(fr.upc.called == fr.set.called == 1) + + configdialog.HelpSource = orig_helpsource + + def test_helplist_item_remove(self): + eq = self.assertEqual + fr = self.frame + fr.helplist.delete(0, 'end') + fr.helplist.insert(0, 'name1') + fr.helplist.selection_set(0) + fr.helplist.selection_anchor(0) + fr.user_helplist.clear() + fr.user_helplist.append(('name1', 'file1')) + fr.set.called = fr.upc.called = 0 + + fr.helplist_item_remove() + eq(fr.helplist.get(0, 'end'), ()) + eq(fr.user_helplist, []) + self.assertTrue(fr.upc.called == fr.set.called == 1) + + def test_update_help_changes(self): + fr = self.frame + del fr.update_help_changes + fr.user_helplist.clear() + fr.user_helplist.append(('name1', 'file1')) + fr.user_helplist.append(('name2', 'file2')) + + fr.update_help_changes() + self.assertEqual(mainpage['HelpFiles'], + {'1': 'name1;file1', '2': 'name2;file2'}) + fr.update_help_changes = Func() + + +class VarTraceTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + cls.tracers = configdialog.VarTrace() + cls.iv = IntVar(root) + cls.bv = BooleanVar(root) + + @classmethod + def tearDownClass(cls): + del cls.tracers, cls.iv, cls.bv + + def setUp(self): + self.tracers.clear() + self.called = 0 + + def var_changed_increment(self, *params): + self.called += 13 + + def var_changed_boolean(self, *params): + pass + + def test_init(self): + tr = self.tracers + tr.__init__() + self.assertEqual(tr.untraced, []) + self.assertEqual(tr.traced, []) + + def test_clear(self): + tr = self.tracers + tr.untraced.append(0) + tr.traced.append(1) + tr.clear() + self.assertEqual(tr.untraced, []) + self.assertEqual(tr.traced, []) + + def test_add(self): + tr = self.tracers + func = Func() + cb = tr.make_callback = mock.Mock(return_value=func) + + iv = tr.add(self.iv, self.var_changed_increment) + self.assertIs(iv, self.iv) + bv = tr.add(self.bv, self.var_changed_boolean) + self.assertIs(bv, self.bv) + + sv = StringVar(root) + sv2 = tr.add(sv, ('main', 'section', 'option')) + self.assertIs(sv2, sv) + cb.assert_called_once() + cb.assert_called_with(sv, ('main', 'section', 'option')) + + expected = [(iv, self.var_changed_increment), + (bv, self.var_changed_boolean), + (sv, func)] + self.assertEqual(tr.traced, []) + self.assertEqual(tr.untraced, expected) + + del tr.make_callback + + def test_make_callback(self): + cb = self.tracers.make_callback(self.iv, ('main', 'section', 'option')) + self.assertTrue(callable(cb)) + self.iv.set(42) + # Not attached, so set didn't invoke the callback. + self.assertNotIn('section', changes['main']) + # Invoke callback manually. + cb() + self.assertIn('section', changes['main']) + self.assertEqual(changes['main']['section']['option'], '42') + changes.clear() + + def test_attach_detach(self): + tr = self.tracers + iv = tr.add(self.iv, self.var_changed_increment) + bv = tr.add(self.bv, self.var_changed_boolean) + expected = [(iv, self.var_changed_increment), + (bv, self.var_changed_boolean)] + + # Attach callbacks and test call increment. + tr.attach() + self.assertEqual(tr.untraced, []) + self.assertCountEqual(tr.traced, expected) + iv.set(1) + self.assertEqual(iv.get(), 1) + self.assertEqual(self.called, 13) + + # Check that only one callback is attached to a variable. + # If more than one callback were attached, then var_changed_increment + # would be called twice and the counter would be 2. + self.called = 0 + tr.attach() + iv.set(1) + self.assertEqual(self.called, 13) + + # Detach callbacks. + self.called = 0 + tr.detach() + self.assertEqual(tr.traced, []) + self.assertCountEqual(tr.untraced, expected) + iv.set(1) + self.assertEqual(self.called, 0) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_debugger.py b/Lib/idlelib/idle_test/test_debugger.py new file mode 100644 index 000000000..9ca3b3326 --- /dev/null +++ b/Lib/idlelib/idle_test/test_debugger.py @@ -0,0 +1,297 @@ +"""Test debugger, coverage 66% + +Try to make tests pass with draft bdbx, which may replace bdb in 3.13+. +""" + +from idlelib import debugger +from collections import namedtuple +from textwrap import dedent +from tkinter import Tk + +from test.support import requires +import unittest +from unittest import mock +from unittest.mock import Mock, patch + +"""A test python script for the debug tests.""" +TEST_CODE = dedent(""" + i = 1 + i += 2 + if i == 3: + print(i) + """) + + +class MockFrame: + "Minimal mock frame." + + def __init__(self, code, lineno): + self.f_code = code + self.f_lineno = lineno + + +class IdbTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + cls.gui = Mock() + cls.idb = debugger.Idb(cls.gui) + + # Create test and code objects to simulate a debug session. + code_obj = compile(TEST_CODE, 'idlelib/file.py', mode='exec') + frame1 = MockFrame(code_obj, 1) + frame1.f_back = None + frame2 = MockFrame(code_obj, 2) + frame2.f_back = frame1 + cls.frame = frame2 + cls.msg = 'file.py:2: ()' + + def test_init(self): + self.assertIs(self.idb.gui, self.gui) + # Won't test super call since two Bdbs are very different. + + def test_user_line(self): + # Test that .user_line() creates a string message for a frame. + self.gui.interaction = Mock() + self.idb.user_line(self.frame) + self.gui.interaction.assert_called_once_with(self.msg, self.frame) + + def test_user_exception(self): + # Test that .user_exception() creates a string message for a frame. + exc_info = (type(ValueError), ValueError(), None) + self.gui.interaction = Mock() + self.idb.user_exception(self.frame, exc_info) + self.gui.interaction.assert_called_once_with( + self.msg, self.frame, exc_info) + + +class FunctionTest(unittest.TestCase): + # Test module functions together. + + def test_functions(self): + rpc_obj = compile(TEST_CODE,'rpc.py', mode='exec') + rpc_frame = MockFrame(rpc_obj, 2) + rpc_frame.f_back = rpc_frame + self.assertTrue(debugger._in_rpc_code(rpc_frame)) + self.assertEqual(debugger._frame2message(rpc_frame), + 'rpc.py:2: ()') + + code_obj = compile(TEST_CODE, 'idlelib/debugger.py', mode='exec') + code_frame = MockFrame(code_obj, 1) + code_frame.f_back = None + self.assertFalse(debugger._in_rpc_code(code_frame)) + self.assertEqual(debugger._frame2message(code_frame), + 'debugger.py:1: ()') + + code_frame.f_back = code_frame + self.assertFalse(debugger._in_rpc_code(code_frame)) + code_frame.f_back = rpc_frame + self.assertTrue(debugger._in_rpc_code(code_frame)) + + +class DebuggerTest(unittest.TestCase): + "Tests for Debugger that do not need a real root." + + @classmethod + def setUpClass(cls): + cls.pyshell = Mock() + cls.pyshell.root = Mock() + cls.idb = Mock() + with patch.object(debugger.Debugger, 'make_gui'): + cls.debugger = debugger.Debugger(cls.pyshell, cls.idb) + cls.debugger.root = Mock() + + def test_cont(self): + self.debugger.cont() + self.idb.set_continue.assert_called_once() + + def test_step(self): + self.debugger.step() + self.idb.set_step.assert_called_once() + + def test_quit(self): + self.debugger.quit() + self.idb.set_quit.assert_called_once() + + def test_next(self): + with patch.object(self.debugger, 'frame') as frame: + self.debugger.next() + self.idb.set_next.assert_called_once_with(frame) + + def test_ret(self): + with patch.object(self.debugger, 'frame') as frame: + self.debugger.ret() + self.idb.set_return.assert_called_once_with(frame) + + def test_clear_breakpoint(self): + self.debugger.clear_breakpoint('test.py', 4) + self.idb.clear_break.assert_called_once_with('test.py', 4) + + def test_clear_file_breaks(self): + self.debugger.clear_file_breaks('test.py') + self.idb.clear_all_file_breaks.assert_called_once_with('test.py') + + def test_set_load_breakpoints(self): + # Test the .load_breakpoints() method calls idb. + FileIO = namedtuple('FileIO', 'filename') + + class MockEditWindow(object): + def __init__(self, fn, breakpoints): + self.io = FileIO(fn) + self.breakpoints = breakpoints + + self.pyshell.flist = Mock() + self.pyshell.flist.inversedict = ( + MockEditWindow('test1.py', [4, 4]), + MockEditWindow('test2.py', [13, 44, 45]), + ) + self.debugger.set_breakpoint('test0.py', 1) + self.idb.set_break.assert_called_once_with('test0.py', 1) + self.debugger.load_breakpoints() # Call set_breakpoint 5 times. + self.idb.set_break.assert_has_calls( + [mock.call('test0.py', 1), + mock.call('test1.py', 4), + mock.call('test1.py', 4), + mock.call('test2.py', 13), + mock.call('test2.py', 44), + mock.call('test2.py', 45)]) + + def test_sync_source_line(self): + # Test that .sync_source_line() will set the flist.gotofileline with fixed frame. + test_code = compile(TEST_CODE, 'test_sync.py', 'exec') + test_frame = MockFrame(test_code, 1) + self.debugger.frame = test_frame + + self.debugger.flist = Mock() + with patch('idlelib.debugger.os.path.exists', return_value=True): + self.debugger.sync_source_line() + self.debugger.flist.gotofileline.assert_called_once_with('test_sync.py', 1) + + +class DebuggerGuiTest(unittest.TestCase): + """Tests for debugger.Debugger that need tk root. + + close needs debugger.top set in make_gui. + """ + + @classmethod + def setUpClass(cls): + requires('gui') + cls.root = root = Tk() + root.withdraw() + cls.pyshell = Mock() + cls.pyshell.root = root + cls.idb = Mock() +# stack tests fail with debugger here. +## cls.debugger = debugger.Debugger(cls.pyshell, cls.idb) +## cls.debugger.root = root +## # real root needed for real make_gui +## # run, interacting, abort_loop + + @classmethod + def tearDownClass(cls): + cls.root.destroy() + del cls.root + + def setUp(self): + self.debugger = debugger.Debugger(self.pyshell, self.idb) + self.debugger.root = self.root + # real root needed for real make_gui + # run, interacting, abort_loop + + def test_run_debugger(self): + self.debugger.run(1, 'two') + self.idb.run.assert_called_once_with(1, 'two') + self.assertEqual(self.debugger.interacting, 0) + + def test_close(self): + # Test closing the window in an idle state. + self.debugger.close() + self.pyshell.close_debugger.assert_called_once() + + def test_show_stack(self): + self.debugger.show_stack() + self.assertEqual(self.debugger.stackviewer.gui, self.debugger) + + def test_show_stack_with_frame(self): + test_frame = MockFrame(None, None) + self.debugger.frame = test_frame + + # Reset the stackviewer to force it to be recreated. + self.debugger.stackviewer = None + self.idb.get_stack.return_value = ([], 0) + self.debugger.show_stack() + + # Check that the newly created stackviewer has the test gui as a field. + self.assertEqual(self.debugger.stackviewer.gui, self.debugger) + self.idb.get_stack.assert_called_once_with(test_frame, None) + + +class StackViewerTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + requires('gui') + cls.root = Tk() + cls.root.withdraw() + + @classmethod + def tearDownClass(cls): + cls.root.destroy() + del cls.root + + def setUp(self): + self.code = compile(TEST_CODE, 'test_stackviewer.py', 'exec') + self.stack = [ + (MockFrame(self.code, 1), 1), + (MockFrame(self.code, 2), 2) + ] + # Create a stackviewer and load the test stack. + self.sv = debugger.StackViewer(self.root, None, None) + self.sv.load_stack(self.stack) + + def test_init(self): + # Test creation of StackViewer. + gui = None + flist = None + master_window = self.root + sv = debugger.StackViewer(master_window, flist, gui) + self.assertHasAttr(sv, 'stack') + + def test_load_stack(self): + # Test the .load_stack() method against a fixed test stack. + # Check the test stack is assigned and the list contains the repr of them. + self.assertEqual(self.sv.stack, self.stack) + self.assertTrue('?.(), line 1:' in self.sv.get(0)) + self.assertEqual(self.sv.get(1), '?.(), line 2: ') + + def test_show_source(self): + # Test the .show_source() method against a fixed test stack. + # Patch out the file list to monitor it + self.sv.flist = Mock() + # Patch out isfile to pretend file exists. + with patch('idlelib.debugger.os.path.isfile', return_value=True) as isfile: + self.sv.show_source(1) + isfile.assert_called_once_with('test_stackviewer.py') + self.sv.flist.open.assert_called_once_with('test_stackviewer.py') + + +class NameSpaceTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + requires('gui') + cls.root = Tk() + cls.root.withdraw() + + @classmethod + def tearDownClass(cls): + cls.root.destroy() + del cls.root + + def test_init(self): + debugger.NamespaceViewer(self.root, 'Test') + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_debugger_r.py b/Lib/idlelib/idle_test/test_debugger_r.py new file mode 100644 index 000000000..cf8af05fe --- /dev/null +++ b/Lib/idlelib/idle_test/test_debugger_r.py @@ -0,0 +1,36 @@ +"Test debugger_r, coverage 30%." + +from idlelib import debugger_r +import unittest + +# Boilerplate likely to be needed for future test classes. +##from test.support import requires +##from tkinter import Tk +##class Test(unittest.TestCase): +## @classmethod +## def setUpClass(cls): +## requires('gui') +## cls.root = Tk() +## @classmethod +## def tearDownClass(cls): +## cls.root.destroy() + +# GUIProxy, IdbAdapter, FrameProxy, CodeProxy, DictProxy, +# GUIAdapter, IdbProxy, and 7 functions still need tests. + +class IdbAdapterTest(unittest.TestCase): + + def test_dict_item_noattr(self): # Issue 33065. + + class BinData: + def __repr__(self): + return self.length + + debugger_r.dicttable[0] = {'BinData': BinData()} + idb = debugger_r.IdbAdapter(None) + self.assertTrue(idb.dict_item(0, 'BinData')) + debugger_r.dicttable.clear() + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_debugobj.py b/Lib/idlelib/idle_test/test_debugobj.py new file mode 100644 index 000000000..90ace4e1b --- /dev/null +++ b/Lib/idlelib/idle_test/test_debugobj.py @@ -0,0 +1,57 @@ +"Test debugobj, coverage 40%." + +from idlelib import debugobj +import unittest + + +class ObjectTreeItemTest(unittest.TestCase): + + def test_init(self): + ti = debugobj.ObjectTreeItem('label', 22) + self.assertEqual(ti.labeltext, 'label') + self.assertEqual(ti.object, 22) + self.assertEqual(ti.setfunction, None) + + +class ClassTreeItemTest(unittest.TestCase): + + def test_isexpandable(self): + ti = debugobj.ClassTreeItem('label', 0) + self.assertTrue(ti.IsExpandable()) + + +class AtomicObjectTreeItemTest(unittest.TestCase): + + def test_isexpandable(self): + ti = debugobj.AtomicObjectTreeItem('label', 0) + self.assertFalse(ti.IsExpandable()) + + +class SequenceTreeItemTest(unittest.TestCase): + + def test_isexpandable(self): + ti = debugobj.SequenceTreeItem('label', ()) + self.assertFalse(ti.IsExpandable()) + ti = debugobj.SequenceTreeItem('label', (1,)) + self.assertTrue(ti.IsExpandable()) + + def test_keys(self): + ti = debugobj.SequenceTreeItem('label', 'abc') + self.assertEqual(list(ti.keys()), [0, 1, 2]) # keys() is a range. + + +class DictTreeItemTest(unittest.TestCase): + + def test_isexpandable(self): + ti = debugobj.DictTreeItem('label', {}) + self.assertFalse(ti.IsExpandable()) + ti = debugobj.DictTreeItem('label', {1:1}) + self.assertTrue(ti.IsExpandable()) + + def test_keys(self): + ti = debugobj.DictTreeItem('label', {1:1, 0:0, 2:2}) + self.assertEqual(ti.keys(), [0, 1, 2]) # keys() is a sorted list. + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_debugobj_r.py b/Lib/idlelib/idle_test/test_debugobj_r.py new file mode 100644 index 000000000..86e51b6cb --- /dev/null +++ b/Lib/idlelib/idle_test/test_debugobj_r.py @@ -0,0 +1,22 @@ +"Test debugobj_r, coverage 56%." + +from idlelib import debugobj_r +import unittest + + +class WrappedObjectTreeItemTest(unittest.TestCase): + + def test_getattr(self): + ti = debugobj_r.WrappedObjectTreeItem(list) + self.assertEqual(ti.append, list.append) + +class StubObjectTreeItemTest(unittest.TestCase): + + def test_init(self): + ti = debugobj_r.StubObjectTreeItem('socket', 1111) + self.assertEqual(ti.sockio, 'socket') + self.assertEqual(ti.oid, 1111) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_delegator.py b/Lib/idlelib/idle_test/test_delegator.py new file mode 100644 index 000000000..922416297 --- /dev/null +++ b/Lib/idlelib/idle_test/test_delegator.py @@ -0,0 +1,44 @@ +"Test delegator, coverage 100%." + +from idlelib.delegator import Delegator +import unittest + + +class DelegatorTest(unittest.TestCase): + + def test_mydel(self): + # Test a simple use scenario. + + # Initialize an int delegator. + mydel = Delegator(int) + self.assertIs(mydel.delegate, int) + self.assertEqual(mydel._Delegator__cache, set()) + # Trying to access a non-attribute of int fails. + self.assertRaises(AttributeError, mydel.__getattr__, 'xyz') + + # Add real int attribute 'bit_length' by accessing it. + bl = mydel.bit_length + self.assertIs(bl, int.bit_length) + self.assertIs(mydel.__dict__['bit_length'], int.bit_length) + self.assertEqual(mydel._Delegator__cache, {'bit_length'}) + + # Add attribute 'numerator'. + mydel.numerator + self.assertEqual(mydel._Delegator__cache, {'bit_length', 'numerator'}) + + # Delete 'numerator'. + del mydel.numerator + self.assertNotIn('numerator', mydel.__dict__) + # The current implementation leaves it in the name cache. + # self.assertIn('numerator', mydel._Delegator__cache) + # However, this is not required and not part of the specification + + # Change delegate to float, first resetting the attributes. + mydel.setdelegate(float) # calls resetcache + self.assertNotIn('bit_length', mydel.__dict__) + self.assertEqual(mydel._Delegator__cache, set()) + self.assertIs(mydel.delegate, float) + + +if __name__ == '__main__': + unittest.main(verbosity=2, exit=2) diff --git a/Lib/idlelib/idle_test/test_editmenu.py b/Lib/idlelib/idle_test/test_editmenu.py new file mode 100644 index 000000000..17478473a --- /dev/null +++ b/Lib/idlelib/idle_test/test_editmenu.py @@ -0,0 +1,74 @@ +'''Test (selected) IDLE Edit menu items. + +Edit modules have their own test files +''' +from test.support import requires +requires('gui') +import tkinter as tk +from tkinter import ttk +import unittest +from idlelib import pyshell + +class PasteTest(unittest.TestCase): + '''Test pasting into widgets that allow pasting. + + On X11, replacing selections requires tk fix. + ''' + @classmethod + def setUpClass(cls): + cls.root = root = tk.Tk() + cls.root.withdraw() + pyshell.fix_x11_paste(root) + cls.text = tk.Text(root) + cls.entry = tk.Entry(root) + cls.tentry = ttk.Entry(root) + cls.spin = tk.Spinbox(root) + root.clipboard_clear() + root.clipboard_append('two') + + @classmethod + def tearDownClass(cls): + del cls.text, cls.entry, cls.tentry + cls.root.clipboard_clear() + cls.root.update_idletasks() + cls.root.destroy() + del cls.root + + def test_paste_text(self): + "Test pasting into text with and without a selection." + text = self.text + for tag, ans in ('', 'onetwo\n'), ('sel', 'two\n'): + with self.subTest(tag=tag, ans=ans): + text.delete('1.0', 'end') + text.insert('1.0', 'one', tag) + text.event_generate('<>') + self.assertEqual(text.get('1.0', 'end'), ans) + + def test_paste_entry(self): + "Test pasting into an entry with and without a selection." + # Generated <> fails for tk entry without empty select + # range for 'no selection'. Live widget works fine. + for entry in self.entry, self.tentry: + for end, ans in (0, 'onetwo'), ('end', 'two'): + with self.subTest(entry=entry, end=end, ans=ans): + entry.delete(0, 'end') + entry.insert(0, 'one') + entry.select_range(0, end) + entry.event_generate('<>') + self.assertEqual(entry.get(), ans) + + def test_paste_spin(self): + "Test pasting into a spinbox with and without a selection." + # See note above for entry. + spin = self.spin + for end, ans in (0, 'onetwo'), ('end', 'two'): + with self.subTest(end=end, ans=ans): + spin.delete(0, 'end') + spin.insert(0, 'one') + spin.selection('range', 0, end) # see note + spin.event_generate('<>') + self.assertEqual(spin.get(), ans) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_editor.py b/Lib/idlelib/idle_test/test_editor.py new file mode 100644 index 000000000..0dfe2f3c5 --- /dev/null +++ b/Lib/idlelib/idle_test/test_editor.py @@ -0,0 +1,241 @@ +"Test editor, coverage 53%." + +from idlelib import editor +import unittest +from collections import namedtuple +from test.support import requires +from tkinter import Tk, Text + +Editor = editor.EditorWindow + + +class EditorWindowTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + requires('gui') + cls.root = Tk() + cls.root.withdraw() + + @classmethod + def tearDownClass(cls): + cls.root.update_idletasks() + for id in cls.root.tk.call('after', 'info'): + cls.root.after_cancel(id) + cls.root.destroy() + del cls.root + + def test_init(self): + e = Editor(root=self.root) + self.assertEqual(e.root, self.root) + e._close() + + +class GetLineIndentTest(unittest.TestCase): + def test_empty_lines(self): + for tabwidth in [1, 2, 4, 6, 8]: + for line in ['', '\n']: + with self.subTest(line=line, tabwidth=tabwidth): + self.assertEqual( + editor.get_line_indent(line, tabwidth=tabwidth), + (0, 0), + ) + + def test_tabwidth_4(self): + # (line, (raw, effective)) + tests = (('no spaces', (0, 0)), + # Internal space isn't counted. + (' space test', (4, 4)), + ('\ttab test', (1, 4)), + ('\t\tdouble tabs test', (2, 8)), + # Different results when mixing tabs and spaces. + (' \tmixed test', (5, 8)), + (' \t mixed test', (5, 6)), + ('\t mixed test', (5, 8)), + # Spaces not divisible by tabwidth. + (' \tmixed test', (3, 4)), + (' \t mixed test', (3, 5)), + ('\t mixed test', (3, 6)), + # Only checks spaces and tabs. + ('\nnewline test', (0, 0))) + + for line, expected in tests: + with self.subTest(line=line): + self.assertEqual( + editor.get_line_indent(line, tabwidth=4), + expected, + ) + + def test_tabwidth_8(self): + # (line, (raw, effective)) + tests = (('no spaces', (0, 0)), + # Internal space isn't counted. + (' space test', (8, 8)), + ('\ttab test', (1, 8)), + ('\t\tdouble tabs test', (2, 16)), + # Different results when mixing tabs and spaces. + (' \tmixed test', (9, 16)), + (' \t mixed test', (9, 10)), + ('\t mixed test', (9, 16)), + # Spaces not divisible by tabwidth. + (' \tmixed test', (3, 8)), + (' \t mixed test', (3, 9)), + ('\t mixed test', (3, 10)), + # Only checks spaces and tabs. + ('\nnewline test', (0, 0))) + + for line, expected in tests: + with self.subTest(line=line): + self.assertEqual( + editor.get_line_indent(line, tabwidth=8), + expected, + ) + + +def insert(text, string): + text.delete('1.0', 'end') + text.insert('end', string) + text.update_idletasks() # Force update for colorizer to finish. + + +class IndentAndNewlineTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + requires('gui') + cls.root = Tk() + cls.root.withdraw() + cls.window = Editor(root=cls.root) + cls.window.indentwidth = 2 + cls.window.tabwidth = 2 + + @classmethod + def tearDownClass(cls): + cls.window._close() + del cls.window + cls.root.update_idletasks() + for id in cls.root.tk.call('after', 'info'): + cls.root.after_cancel(id) + cls.root.destroy() + del cls.root + + def test_indent_and_newline_event(self): + eq = self.assertEqual + w = self.window + text = w.text + get = text.get + nl = w.newline_and_indent_event + + TestInfo = namedtuple('Tests', ['label', 'text', 'expected', 'mark']) + + tests = (TestInfo('Empty line inserts with no indent.', + ' \n def __init__(self):', + '\n \n def __init__(self):\n', + '1.end'), + TestInfo('Inside bracket before space, deletes space.', + ' def f1(self, a, b):', + ' def f1(self,\n a, b):\n', + '1.14'), + TestInfo('Inside bracket after space, deletes space.', + ' def f1(self, a, b):', + ' def f1(self,\n a, b):\n', + '1.15'), + TestInfo('Inside string with one line - no indent.', + ' """Docstring."""', + ' """Docstring.\n"""\n', + '1.15'), + TestInfo('Inside string with more than one line.', + ' """Docstring.\n Docstring Line 2"""', + ' """Docstring.\n Docstring Line 2\n """\n', + '2.18'), + TestInfo('Backslash with one line.', + 'a =\\', + 'a =\\\n \n', + '1.end'), + TestInfo('Backslash with more than one line.', + 'a =\\\n multiline\\', + 'a =\\\n multiline\\\n \n', + '2.end'), + TestInfo('Block opener - indents +1 level.', + ' def f1(self):\n pass', + ' def f1(self):\n \n pass\n', + '1.end'), + TestInfo('Block closer - dedents -1 level.', + ' def f1(self):\n pass', + ' def f1(self):\n pass\n \n', + '2.end'), + ) + + for test in tests: + with self.subTest(label=test.label): + insert(text, test.text) + text.mark_set('insert', test.mark) + nl(event=None) + eq(get('1.0', 'end'), test.expected) + + # Selected text. + insert(text, ' def f1(self, a, b):\n return a + b') + text.tag_add('sel', '1.17', '1.end') + nl(None) + # Deletes selected text before adding new line. + eq(get('1.0', 'end'), ' def f1(self, a,\n \n return a + b\n') + + +class IndentSearcherTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + requires('gui') + cls.root = Tk() + cls.root.withdraw() + cls.text = Text(cls.root) + + @classmethod + def tearDownClass(cls): + cls.root.destroy() + del cls.root + + def test_searcher(self): + text = self.text + searcher = (self.text) + test_info = (# text, (block, indent)) + ("", (None, None)), + ("[1,", (None, None)), # TokenError + ("if 1:\n", ('if 1:\n', None)), + ("if 1:\n 2\n 3\n", ('if 1:\n', ' 2\n')), + ) + for code, expected_pair in test_info: + with self.subTest(code=code): + insert(text, code) + actual_pair = editor.IndentSearcher(text).run() + self.assertEqual(actual_pair, expected_pair) + + +class RMenuTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + requires('gui') + cls.root = Tk() + cls.root.withdraw() + cls.window = Editor(root=cls.root) + + @classmethod + def tearDownClass(cls): + cls.window._close() + del cls.window + cls.root.update_idletasks() + for id in cls.root.tk.call('after', 'info'): + cls.root.after_cancel(id) + cls.root.destroy() + del cls.root + + class DummyRMenu: + def tk_popup(x, y): pass + + def test_rclick(self): + pass + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_filelist.py b/Lib/idlelib/idle_test/test_filelist.py new file mode 100644 index 000000000..731f1975e --- /dev/null +++ b/Lib/idlelib/idle_test/test_filelist.py @@ -0,0 +1,33 @@ +"Test filelist, coverage 19%." + +from idlelib import filelist +import unittest +from test.support import requires +from tkinter import Tk + +class FileListTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + requires('gui') + cls.root = Tk() + cls.root.withdraw() + + @classmethod + def tearDownClass(cls): + cls.root.update_idletasks() + for id in cls.root.tk.call('after', 'info'): + cls.root.after_cancel(id) + cls.root.destroy() + del cls.root + + def test_new_empty(self): + flist = filelist.FileList(self.root) + self.assertEqual(flist.root, self.root) + e = flist.new() + self.assertEqual(type(e), flist.EditorWindow) + e._close() + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_format.py b/Lib/idlelib/idle_test/test_format.py new file mode 100644 index 000000000..e5e903688 --- /dev/null +++ b/Lib/idlelib/idle_test/test_format.py @@ -0,0 +1,668 @@ +"Test format, coverage 99%." + +from idlelib import format as ft +import unittest +from unittest import mock +from test.support import requires +from tkinter import Tk, Text +from idlelib.editor import EditorWindow +from idlelib.idle_test.mock_idle import Editor as MockEditor + + +class Is_Get_Test(unittest.TestCase): + """Test the is_ and get_ functions""" + test_comment = '# This is a comment' + test_nocomment = 'This is not a comment' + trailingws_comment = '# This is a comment ' + leadingws_comment = ' # This is a comment' + leadingws_nocomment = ' This is not a comment' + + def test_is_all_white(self): + self.assertTrue(ft.is_all_white('')) + self.assertTrue(ft.is_all_white('\t\n\r\f\v')) + self.assertFalse(ft.is_all_white(self.test_comment)) + + def test_get_indent(self): + Equal = self.assertEqual + Equal(ft.get_indent(self.test_comment), '') + Equal(ft.get_indent(self.trailingws_comment), '') + Equal(ft.get_indent(self.leadingws_comment), ' ') + Equal(ft.get_indent(self.leadingws_nocomment), ' ') + + def test_get_comment_header(self): + Equal = self.assertEqual + # Test comment strings + Equal(ft.get_comment_header(self.test_comment), '#') + Equal(ft.get_comment_header(self.trailingws_comment), '#') + Equal(ft.get_comment_header(self.leadingws_comment), ' #') + # Test non-comment strings + Equal(ft.get_comment_header(self.leadingws_nocomment), ' ') + Equal(ft.get_comment_header(self.test_nocomment), '') + + +class FindTest(unittest.TestCase): + """Test the find_paragraph function in paragraph module. + + Using the runcase() function, find_paragraph() is called with 'mark' set at + multiple indexes before and inside the test paragraph. + + It appears that code with the same indentation as a quoted string is grouped + as part of the same paragraph, which is probably incorrect behavior. + """ + + @classmethod + def setUpClass(cls): + from idlelib.idle_test.mock_tk import Text + cls.text = Text() + + def runcase(self, inserttext, stopline, expected): + # Check that find_paragraph returns the expected paragraph when + # the mark index is set to beginning, middle, end of each line + # up to but not including the stop line + text = self.text + text.insert('1.0', inserttext) + for line in range(1, stopline): + linelength = int(text.index("%d.end" % line).split('.')[1]) + for col in (0, linelength//2, linelength): + tempindex = "%d.%d" % (line, col) + self.assertEqual(ft.find_paragraph(text, tempindex), expected) + text.delete('1.0', 'end') + + def test_find_comment(self): + comment = ( + "# Comment block with no blank lines before\n" + "# Comment line\n" + "\n") + self.runcase(comment, 3, ('1.0', '3.0', '#', comment[0:58])) + + comment = ( + "\n" + "# Comment block with whitespace line before and after\n" + "# Comment line\n" + "\n") + self.runcase(comment, 4, ('2.0', '4.0', '#', comment[1:70])) + + comment = ( + "\n" + " # Indented comment block with whitespace before and after\n" + " # Comment line\n" + "\n") + self.runcase(comment, 4, ('2.0', '4.0', ' #', comment[1:82])) + + comment = ( + "\n" + "# Single line comment\n" + "\n") + self.runcase(comment, 3, ('2.0', '3.0', '#', comment[1:23])) + + comment = ( + "\n" + " # Single line comment with leading whitespace\n" + "\n") + self.runcase(comment, 3, ('2.0', '3.0', ' #', comment[1:51])) + + comment = ( + "\n" + "# Comment immediately followed by code\n" + "x = 42\n" + "\n") + self.runcase(comment, 3, ('2.0', '3.0', '#', comment[1:40])) + + comment = ( + "\n" + " # Indented comment immediately followed by code\n" + "x = 42\n" + "\n") + self.runcase(comment, 3, ('2.0', '3.0', ' #', comment[1:53])) + + comment = ( + "\n" + "# Comment immediately followed by indented code\n" + " x = 42\n" + "\n") + self.runcase(comment, 3, ('2.0', '3.0', '#', comment[1:49])) + + def test_find_paragraph(self): + teststring = ( + '"""String with no blank lines before\n' + 'String line\n' + '"""\n' + '\n') + self.runcase(teststring, 4, ('1.0', '4.0', '', teststring[0:53])) + + teststring = ( + "\n" + '"""String with whitespace line before and after\n' + 'String line.\n' + '"""\n' + '\n') + self.runcase(teststring, 5, ('2.0', '5.0', '', teststring[1:66])) + + teststring = ( + '\n' + ' """Indented string with whitespace before and after\n' + ' Comment string.\n' + ' """\n' + '\n') + self.runcase(teststring, 5, ('2.0', '5.0', ' ', teststring[1:85])) + + teststring = ( + '\n' + '"""Single line string."""\n' + '\n') + self.runcase(teststring, 3, ('2.0', '3.0', '', teststring[1:27])) + + teststring = ( + '\n' + ' """Single line string with leading whitespace."""\n' + '\n') + self.runcase(teststring, 3, ('2.0', '3.0', ' ', teststring[1:55])) + + +class ReformatFunctionTest(unittest.TestCase): + """Test the reformat_paragraph function without the editor window.""" + + def test_reformat_paragraph(self): + Equal = self.assertEqual + reform = ft.reformat_paragraph + hw = "O hello world" + Equal(reform(' ', 1), ' ') + Equal(reform("Hello world", 20), "Hello world") + + # Test without leading newline + Equal(reform(hw, 1), "O\nhello\nworld") + Equal(reform(hw, 6), "O\nhello\nworld") + Equal(reform(hw, 7), "O hello\nworld") + Equal(reform(hw, 12), "O hello\nworld") + Equal(reform(hw, 13), "O hello world") + + # Test with leading newline + hw = "\nO hello world" + Equal(reform(hw, 1), "\nO\nhello\nworld") + Equal(reform(hw, 6), "\nO\nhello\nworld") + Equal(reform(hw, 7), "\nO hello\nworld") + Equal(reform(hw, 12), "\nO hello\nworld") + Equal(reform(hw, 13), "\nO hello world") + + +class ReformatCommentTest(unittest.TestCase): + """Test the reformat_comment function without the editor window.""" + + def test_reformat_comment(self): + Equal = self.assertEqual + + # reformat_comment formats to a minimum of 20 characters + test_string = ( + " \"\"\"this is a test of a reformat for a triple quoted string" + " will it reformat to less than 70 characters for me?\"\"\"") + result = ft.reformat_comment(test_string, 70, " ") + expected = ( + " \"\"\"this is a test of a reformat for a triple quoted string will it\n" + " reformat to less than 70 characters for me?\"\"\"") + Equal(result, expected) + + test_comment = ( + "# this is a test of a reformat for a triple quoted string will " + "it reformat to less than 70 characters for me?") + result = ft.reformat_comment(test_comment, 70, "#") + expected = ( + "# this is a test of a reformat for a triple quoted string will it\n" + "# reformat to less than 70 characters for me?") + Equal(result, expected) + + +class FormatClassTest(unittest.TestCase): + def test_init_close(self): + instance = ft.FormatParagraph('editor') + self.assertEqual(instance.editwin, 'editor') + instance.close() + self.assertEqual(instance.editwin, None) + + +# For testing format_paragraph_event, Initialize FormatParagraph with +# a mock Editor with .text and .get_selection_indices. The text must +# be a Text wrapper that adds two methods + +# A real EditorWindow creates unneeded, time-consuming baggage and +# sometimes emits shutdown warnings like this: +# "warning: callback failed in WindowList +# : invalid command name ".55131368.windows". +# Calling EditorWindow._close in tearDownClass prevents this but causes +# other problems (windows left open). + +class TextWrapper: + def __init__(self, master): + self.text = Text(master=master) + def __getattr__(self, name): + return getattr(self.text, name) + def undo_block_start(self): pass + def undo_block_stop(self): pass + +class Editor: + def __init__(self, root): + self.text = TextWrapper(root) + get_selection_indices = EditorWindow. get_selection_indices + +class FormatEventTest(unittest.TestCase): + """Test the formatting of text inside a Text widget. + + This is done with FormatParagraph.format.paragraph_event, + which calls functions in the module as appropriate. + """ + test_string = ( + " '''this is a test of a reformat for a triple " + "quoted string will it reformat to less than 70 " + "characters for me?'''\n") + multiline_test_string = ( + " '''The first line is under the max width.\n" + " The second line's length is way over the max width. It goes " + "on and on until it is over 100 characters long.\n" + " Same thing with the third line. It is also way over the max " + "width, but FormatParagraph will fix it.\n" + " '''\n") + multiline_test_comment = ( + "# The first line is under the max width.\n" + "# The second line's length is way over the max width. It goes on " + "and on until it is over 100 characters long.\n" + "# Same thing with the third line. It is also way over the max " + "width, but FormatParagraph will fix it.\n" + "# The fourth line is short like the first line.") + + @classmethod + def setUpClass(cls): + requires('gui') + cls.root = Tk() + cls.root.withdraw() + editor = Editor(root=cls.root) + cls.text = editor.text.text # Test code does not need the wrapper. + cls.formatter = ft.FormatParagraph(editor).format_paragraph_event + # Sets the insert mark just after the re-wrapped and inserted text. + + @classmethod + def tearDownClass(cls): + del cls.text, cls.formatter + cls.root.update_idletasks() + cls.root.destroy() + del cls.root + + def test_short_line(self): + self.text.insert('1.0', "Short line\n") + self.formatter("Dummy") + self.assertEqual(self.text.get('1.0', 'insert'), "Short line\n" ) + self.text.delete('1.0', 'end') + + def test_long_line(self): + text = self.text + + # Set cursor ('insert' mark) to '1.0', within text. + text.insert('1.0', self.test_string) + text.mark_set('insert', '1.0') + self.formatter('ParameterDoesNothing', limit=70) + result = text.get('1.0', 'insert') + # find function includes \n + expected = ( +" '''this is a test of a reformat for a triple quoted string will it\n" +" reformat to less than 70 characters for me?'''\n") # yes + self.assertEqual(result, expected) + text.delete('1.0', 'end') + + # Select from 1.11 to line end. + text.insert('1.0', self.test_string) + text.tag_add('sel', '1.11', '1.end') + self.formatter('ParameterDoesNothing', limit=70) + result = text.get('1.0', 'insert') + # selection excludes \n + expected = ( +" '''this is a test of a reformat for a triple quoted string will it reformat\n" +" to less than 70 characters for me?'''") # no + self.assertEqual(result, expected) + text.delete('1.0', 'end') + + def test_multiple_lines(self): + text = self.text + # Select 2 long lines. + text.insert('1.0', self.multiline_test_string) + text.tag_add('sel', '2.0', '4.0') + self.formatter('ParameterDoesNothing', limit=70) + result = text.get('2.0', 'insert') + expected = ( +" The second line's length is way over the max width. It goes on and\n" +" on until it is over 100 characters long. Same thing with the third\n" +" line. It is also way over the max width, but FormatParagraph will\n" +" fix it.\n") + self.assertEqual(result, expected) + text.delete('1.0', 'end') + + def test_comment_block(self): + text = self.text + + # Set cursor ('insert') to '1.0', within block. + text.insert('1.0', self.multiline_test_comment) + self.formatter('ParameterDoesNothing', limit=70) + result = text.get('1.0', 'insert') + expected = ( +"# The first line is under the max width. The second line's length is\n" +"# way over the max width. It goes on and on until it is over 100\n" +"# characters long. Same thing with the third line. It is also way over\n" +"# the max width, but FormatParagraph will fix it. The fourth line is\n" +"# short like the first line.\n") + self.assertEqual(result, expected) + text.delete('1.0', 'end') + + # Select line 2, verify line 1 unaffected. + text.insert('1.0', self.multiline_test_comment) + text.tag_add('sel', '2.0', '3.0') + self.formatter('ParameterDoesNothing', limit=70) + result = text.get('1.0', 'insert') + expected = ( +"# The first line is under the max width.\n" +"# The second line's length is way over the max width. It goes on and\n" +"# on until it is over 100 characters long.\n") + self.assertEqual(result, expected) + text.delete('1.0', 'end') + +# The following block worked with EditorWindow but fails with the mock. +# Lines 2 and 3 get pasted together even though the previous block left +# the previous line alone. More investigation is needed. +## # Select lines 3 and 4 +## text.insert('1.0', self.multiline_test_comment) +## text.tag_add('sel', '3.0', '5.0') +## self.formatter('ParameterDoesNothing') +## result = text.get('3.0', 'insert') +## expected = ( +##"# Same thing with the third line. It is also way over the max width,\n" +##"# but FormatParagraph will fix it. The fourth line is short like the\n" +##"# first line.\n") +## self.assertEqual(result, expected) +## text.delete('1.0', 'end') + + +class DummyEditwin: + def __init__(self, root, text): + self.root = root + self.text = text + self.indentwidth = 4 + self.tabwidth = 4 + self.usetabs = False + self.context_use_ps1 = True + + _make_blanks = EditorWindow._make_blanks + get_selection_indices = EditorWindow.get_selection_indices + + +class FormatRegionTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + requires('gui') + cls.root = Tk() + cls.root.withdraw() + cls.text = Text(cls.root) + cls.text.undo_block_start = mock.Mock() + cls.text.undo_block_stop = mock.Mock() + cls.editor = DummyEditwin(cls.root, cls.text) + cls.formatter = ft.FormatRegion(cls.editor) + + @classmethod + def tearDownClass(cls): + del cls.text, cls.formatter, cls.editor + cls.root.update_idletasks() + cls.root.destroy() + del cls.root + + def setUp(self): + self.text.insert('1.0', self.code_sample) + + def tearDown(self): + self.text.delete('1.0', 'end') + + code_sample = """\ +# WS line needed for test. +class C1: + # Class comment. + def __init__(self, a, b): + self.a = a + self.b = b + + def compare(self): + if a > b: + return a + elif a < b: + return b + else: + return None +""" + + def test_get_region(self): + get = self.formatter.get_region + text = self.text + eq = self.assertEqual + + # Add selection. + text.tag_add('sel', '7.0', '10.0') + expected_lines = ['', + ' def compare(self):', + ' if a > b:', + ''] + eq(get(), ('7.0', '10.0', '\n'.join(expected_lines), expected_lines)) + + # Remove selection. + text.tag_remove('sel', '1.0', 'end') + eq(get(), ('15.0', '16.0', '\n', ['', ''])) + + def test_set_region(self): + set_ = self.formatter.set_region + text = self.text + eq = self.assertEqual + + save_bell = text.bell + text.bell = mock.Mock() + line6 = self.code_sample.splitlines()[5] + line10 = self.code_sample.splitlines()[9] + + text.tag_add('sel', '6.0', '11.0') + head, tail, chars, lines = self.formatter.get_region() + + # No changes. + set_(head, tail, chars, lines) + text.bell.assert_called_once() + eq(text.get('6.0', '11.0'), chars) + eq(text.get('sel.first', 'sel.last'), chars) + text.tag_remove('sel', '1.0', 'end') + + # Alter selected lines by changing lines and adding a newline. + newstring = 'added line 1\n\n\n\n' + newlines = newstring.split('\n') + set_('7.0', '10.0', chars, newlines) + # Selection changed. + eq(text.get('sel.first', 'sel.last'), newstring) + # Additional line added, so last index is changed. + eq(text.get('7.0', '11.0'), newstring) + # Before and after lines unchanged. + eq(text.get('6.0', '7.0-1c'), line6) + eq(text.get('11.0', '12.0-1c'), line10) + text.tag_remove('sel', '1.0', 'end') + + text.bell = save_bell + + def test_indent_region_event(self): + indent = self.formatter.indent_region_event + text = self.text + eq = self.assertEqual + + text.tag_add('sel', '7.0', '10.0') + indent() + # Blank lines aren't affected by indent. + eq(text.get('7.0', '10.0'), ('\n def compare(self):\n if a > b:\n')) + + def test_dedent_region_event(self): + dedent = self.formatter.dedent_region_event + text = self.text + eq = self.assertEqual + + text.tag_add('sel', '7.0', '10.0') + dedent() + # Blank lines aren't affected by dedent. + eq(text.get('7.0', '10.0'), ('\ndef compare(self):\n if a > b:\n')) + + def test_comment_region_event(self): + comment = self.formatter.comment_region_event + text = self.text + eq = self.assertEqual + + text.tag_add('sel', '7.0', '10.0') + comment() + eq(text.get('7.0', '10.0'), ('##\n## def compare(self):\n## if a > b:\n')) + + def test_uncomment_region_event(self): + comment = self.formatter.comment_region_event + uncomment = self.formatter.uncomment_region_event + text = self.text + eq = self.assertEqual + + text.tag_add('sel', '7.0', '10.0') + comment() + uncomment() + eq(text.get('7.0', '10.0'), ('\n def compare(self):\n if a > b:\n')) + + # Only remove comments at the beginning of a line. + text.tag_remove('sel', '1.0', 'end') + text.tag_add('sel', '3.0', '4.0') + uncomment() + eq(text.get('3.0', '3.end'), (' # Class comment.')) + + self.formatter.set_region('3.0', '4.0', '', ['# Class comment.', '']) + uncomment() + eq(text.get('3.0', '3.end'), (' Class comment.')) + + @mock.patch.object(ft.FormatRegion, "_asktabwidth") + def test_tabify_region_event(self, _asktabwidth): + tabify = self.formatter.tabify_region_event + text = self.text + eq = self.assertEqual + + text.tag_add('sel', '7.0', '10.0') + # No tabwidth selected. + _asktabwidth.return_value = None + self.assertIsNone(tabify()) + + _asktabwidth.return_value = 3 + self.assertIsNotNone(tabify()) + eq(text.get('7.0', '10.0'), ('\n\t def compare(self):\n\t\t if a > b:\n')) + + @mock.patch.object(ft.FormatRegion, "_asktabwidth") + def test_untabify_region_event(self, _asktabwidth): + untabify = self.formatter.untabify_region_event + text = self.text + eq = self.assertEqual + + text.tag_add('sel', '7.0', '10.0') + # No tabwidth selected. + _asktabwidth.return_value = None + self.assertIsNone(untabify()) + + _asktabwidth.return_value = 2 + self.formatter.tabify_region_event() + _asktabwidth.return_value = 3 + self.assertIsNotNone(untabify()) + eq(text.get('7.0', '10.0'), ('\n def compare(self):\n if a > b:\n')) + + @mock.patch.object(ft, "askinteger") + def test_ask_tabwidth(self, askinteger): + ask = self.formatter._asktabwidth + askinteger.return_value = 10 + self.assertEqual(ask(), 10) + + +class IndentsTest(unittest.TestCase): + + @mock.patch.object(ft, "askyesno") + def test_toggle_tabs(self, askyesno): + editor = DummyEditwin(None, None) # usetabs == False. + indents = ft.Indents(editor) + askyesno.return_value = True + + indents.toggle_tabs_event(None) + self.assertEqual(editor.usetabs, True) + self.assertEqual(editor.indentwidth, 8) + + indents.toggle_tabs_event(None) + self.assertEqual(editor.usetabs, False) + self.assertEqual(editor.indentwidth, 8) + + @mock.patch.object(ft, "askinteger") + def test_change_indentwidth(self, askinteger): + editor = DummyEditwin(None, None) # indentwidth == 4. + indents = ft.Indents(editor) + + askinteger.return_value = None + indents.change_indentwidth_event(None) + self.assertEqual(editor.indentwidth, 4) + + askinteger.return_value = 3 + indents.change_indentwidth_event(None) + self.assertEqual(editor.indentwidth, 3) + + askinteger.return_value = 5 + editor.usetabs = True + indents.change_indentwidth_event(None) + self.assertEqual(editor.indentwidth, 3) + + +class RstripTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + requires('gui') + cls.root = Tk() + cls.root.withdraw() + cls.text = Text(cls.root) + cls.editor = MockEditor(text=cls.text) + cls.do_rstrip = ft.Rstrip(cls.editor).do_rstrip + + @classmethod + def tearDownClass(cls): + del cls.text, cls.do_rstrip, cls.editor + cls.root.update_idletasks() + cls.root.destroy() + del cls.root + + def tearDown(self): + self.text.delete('1.0', 'end-1c') + + def test_rstrip_lines(self): + original = ( + "Line with an ending tab \n" + "Line ending in 5 spaces \n" + "Linewithnospaces\n" + " indented line\n" + " indented line with trailing space \n" + " \n") + stripped = ( + "Line with an ending tab\n" + "Line ending in 5 spaces\n" + "Linewithnospaces\n" + " indented line\n" + " indented line with trailing space\n") + + self.text.insert('1.0', original) + self.do_rstrip() + self.assertEqual(self.text.get('1.0', 'insert'), stripped) + + def test_rstrip_end(self): + text = self.text + for code in ('', '\n', '\n\n\n'): + with self.subTest(code=code): + text.insert('1.0', code) + self.do_rstrip() + self.assertEqual(text.get('1.0','end-1c'), '') + for code in ('a\n', 'a\n\n', 'a\n\n\n'): + with self.subTest(code=code): + text.delete('1.0', 'end-1c') + text.insert('1.0', code) + self.do_rstrip() + self.assertEqual(text.get('1.0','end-1c'), 'a\n') + + +if __name__ == '__main__': + unittest.main(verbosity=2, exit=2) diff --git a/Lib/idlelib/idle_test/test_grep.py b/Lib/idlelib/idle_test/test_grep.py new file mode 100644 index 000000000..d67dba769 --- /dev/null +++ b/Lib/idlelib/idle_test/test_grep.py @@ -0,0 +1,156 @@ +""" !Changing this line will break Test_findfile.test_found! +Non-gui unit tests for grep.GrepDialog methods. +dummy_command calls grep_it calls findfiles. +An exception raised in one method will fail callers. +Otherwise, tests are mostly independent. +Currently only test grep_it, coverage 51%. +""" +from idlelib import grep +import unittest +from test.support import captured_stdout +from idlelib.idle_test.mock_tk import Var +import os +import re + + +class Dummy_searchengine: + '''GrepDialog.__init__ calls parent SearchDiabolBase which attaches the + passed in SearchEngine instance as attribute 'engine'. Only a few of the + many possible self.engine.x attributes are needed here. + ''' + def getpat(self): + return self._pat + +searchengine = Dummy_searchengine() + + +class Dummy_grep: + # Methods tested + #default_command = GrepDialog.default_command + grep_it = grep.GrepDialog.grep_it + # Other stuff needed + recvar = Var(False) + engine = searchengine + def close(self): # gui method + pass + +_grep = Dummy_grep() + + +class FindfilesTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + cls.realpath = os.path.realpath(__file__) + cls.path = os.path.dirname(cls.realpath) + + @classmethod + def tearDownClass(cls): + del cls.realpath, cls.path + + def test_invaliddir(self): + with captured_stdout() as s: + filelist = list(grep.findfiles('invaliddir', '*.*', False)) + self.assertEqual(filelist, []) + self.assertIn('invalid', s.getvalue()) + + def test_curdir(self): + # Test os.curdir. + ff = grep.findfiles + save_cwd = os.getcwd() + os.chdir(self.path) + filename = 'test_grep.py' + filelist = list(ff(os.curdir, filename, False)) + self.assertIn(os.path.join(os.curdir, filename), filelist) + os.chdir(save_cwd) + + def test_base(self): + ff = grep.findfiles + readme = os.path.join(self.path, 'README.txt') + + # Check for Python files in path where this file lives. + filelist = list(ff(self.path, '*.py', False)) + # This directory has many Python files. + self.assertGreater(len(filelist), 10) + self.assertIn(self.realpath, filelist) + self.assertNotIn(readme, filelist) + + # Look for .txt files in path where this file lives. + filelist = list(ff(self.path, '*.txt', False)) + self.assertNotEqual(len(filelist), 0) + self.assertNotIn(self.realpath, filelist) + self.assertIn(readme, filelist) + + # Look for non-matching pattern. + filelist = list(ff(self.path, 'grep.*', False)) + self.assertEqual(len(filelist), 0) + self.assertNotIn(self.realpath, filelist) + + def test_recurse(self): + ff = grep.findfiles + parent = os.path.dirname(self.path) + grepfile = os.path.join(parent, 'grep.py') + pat = '*.py' + + # Get Python files only in parent directory. + filelist = list(ff(parent, pat, False)) + parent_size = len(filelist) + # Lots of Python files in idlelib. + self.assertGreater(parent_size, 20) + self.assertIn(grepfile, filelist) + # Without subdirectories, this file isn't returned. + self.assertNotIn(self.realpath, filelist) + + # Include subdirectories. + filelist = list(ff(parent, pat, True)) + # More files found now. + self.assertGreater(len(filelist), parent_size) + self.assertIn(grepfile, filelist) + # This file exists in list now. + self.assertIn(self.realpath, filelist) + + # Check another level up the tree. + parent = os.path.dirname(parent) + filelist = list(ff(parent, '*.py', True)) + self.assertIn(self.realpath, filelist) + + +class Grep_itTest(unittest.TestCase): + # Test captured reports with 0 and some hits. + # Should test file names, but Windows reports have mixed / and \ separators + # from incomplete replacement, so 'later'. + + def report(self, pat): + _grep.engine._pat = pat + with captured_stdout() as s: + _grep.grep_it(re.compile(pat), __file__) + lines = s.getvalue().split('\n') + lines.pop() # remove bogus '' after last \n + return lines + + def test_unfound(self): + pat = 'xyz*'*7 + lines = self.report(pat) + self.assertEqual(len(lines), 2) + self.assertIn(pat, lines[0]) + self.assertEqual(lines[1], 'No hits.') + + def test_found(self): + + pat = '""" !Changing this line will break Test_findfile.test_found!' + lines = self.report(pat) + self.assertEqual(len(lines), 5) + self.assertIn(pat, lines[0]) + self.assertIn('py: 1:', lines[1]) # line number 1 + self.assertIn('2', lines[3]) # hits found 2 + self.assertStartsWith(lines[4], '(Hint:') + + +class Default_commandTest(unittest.TestCase): + # To write this, move outwin import to top of GrepDialog + # so it can be replaced by captured_stdout in class setup/teardown. + pass + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_help.py b/Lib/idlelib/idle_test/test_help.py new file mode 100644 index 000000000..ebb02b5c0 --- /dev/null +++ b/Lib/idlelib/idle_test/test_help.py @@ -0,0 +1,36 @@ +"Test help, coverage 94%." + +from idlelib import help +import unittest +from test.support import requires +requires('gui') +from os.path import abspath, dirname, join +from tkinter import Tk + + +class IdleDocTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + "By itself, this tests that file parsed without exception." + cls.root = root = Tk() + root.withdraw() + cls.window = help.show_idlehelp(root) + + @classmethod + def tearDownClass(cls): + del cls.window + cls.root.update_idletasks() + cls.root.destroy() + del cls.root + + def test_1window(self): + self.assertIn('IDLE Doc', self.window.wm_title()) + + def test_4text(self): + text = self.window.frame.text + self.assertEqual(text.get('1.0', '1.end'), ' IDLE — Python editor and shell ') + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_help_about.py b/Lib/idlelib/idle_test/test_help_about.py new file mode 100644 index 000000000..7e16abdb7 --- /dev/null +++ b/Lib/idlelib/idle_test/test_help_about.py @@ -0,0 +1,182 @@ +"""Test help_about, coverage 100%. +help_about.build_bits branches on sys.platform='darwin'. +'100% combines coverage on Mac and others. +""" + +from idlelib import help_about +import unittest +from test.support import requires, findfile +from tkinter import Tk, TclError +from idlelib.idle_test.mock_idle import Func +from idlelib.idle_test.mock_tk import Mbox_func +from idlelib import textview +import os.path +from platform import python_version + +About = help_about.AboutDialog + + +class LiveDialogTest(unittest.TestCase): + """Simulate user clicking buttons other than [Close]. + + Test that invoked textview has text from source. + """ + @classmethod + def setUpClass(cls): + requires('gui') + cls.root = Tk() + cls.root.withdraw() + cls.dialog = About(cls.root, 'About IDLE', _utest=True) + + @classmethod + def tearDownClass(cls): + del cls.dialog + cls.root.update_idletasks() + cls.root.destroy() + del cls.root + + def test_build_bits(self): + self.assertIn(help_about.bits, ('32', '64')) + + def test_dialog_title(self): + """Test about dialog title""" + self.assertEqual(self.dialog.title(), 'About IDLE') + + def test_dialog_logo(self): + """Test about dialog logo.""" + path, file = os.path.split(self.dialog.icon_image['file']) + fn, ext = os.path.splitext(file) + self.assertEqual(fn, 'idle_48') + + def test_printer_buttons(self): + """Test buttons whose commands use printer function.""" + dialog = self.dialog + button_sources = [(dialog.py_license, license, 'license'), + (dialog.py_copyright, copyright, 'copyright'), + (dialog.py_credits, credits, 'credits')] + + for button, printer, name in button_sources: + with self.subTest(name=name): + printer._Printer__setup() + button.invoke() + get = dialog._current_textview.viewframe.textframe.text.get + lines = printer._Printer__lines + if len(lines) < 2: + self.fail(name + ' full text was not found') + self.assertEqual(lines[0], get('1.0', '1.end')) + self.assertEqual(lines[1], get('2.0', '2.end')) + dialog._current_textview.destroy() + + def test_file_buttons(self): + """Test buttons that display files.""" + dialog = self.dialog + button_sources = [(self.dialog.readme, 'README.txt', 'readme'), + (self.dialog.idle_news, 'News3.txt', 'news'), + (self.dialog.idle_credits, 'CREDITS.txt', 'credits')] + + for button, filename, name in button_sources: + with self.subTest(name=name): + button.invoke() + fn = findfile(filename, subdir='idlelib') + get = dialog._current_textview.viewframe.textframe.text.get + with open(fn, encoding='utf-8') as f: + self.assertEqual(f.readline().strip(), get('1.0', '1.end')) + f.readline() + self.assertEqual(f.readline().strip(), get('3.0', '3.end')) + dialog._current_textview.destroy() + + +class DefaultTitleTest(unittest.TestCase): + "Test default title." + + @classmethod + def setUpClass(cls): + requires('gui') + cls.root = Tk() + cls.root.withdraw() + cls.dialog = About(cls.root, _utest=True) + + @classmethod + def tearDownClass(cls): + del cls.dialog + cls.root.update_idletasks() + cls.root.destroy() + del cls.root + + def test_dialog_title(self): + """Test about dialog title""" + self.assertEqual(self.dialog.title(), + f'About IDLE {python_version()}' + f' ({help_about.bits} bit)') + + +class CloseTest(unittest.TestCase): + """Simulate user clicking [Close] button""" + + @classmethod + def setUpClass(cls): + requires('gui') + cls.root = Tk() + cls.root.withdraw() + cls.dialog = About(cls.root, 'About IDLE', _utest=True) + + @classmethod + def tearDownClass(cls): + del cls.dialog + cls.root.update_idletasks() + cls.root.destroy() + del cls.root + + def test_close(self): + self.assertEqual(self.dialog.winfo_class(), 'Toplevel') + self.dialog.button_ok.invoke() + with self.assertRaises(TclError): + self.dialog.winfo_class() + + +class Dummy_about_dialog: + # Dummy class for testing file display functions. + idle_credits = About.show_idle_credits + idle_readme = About.show_readme + idle_news = About.show_idle_news + # Called by the above + display_file_text = About.display_file_text + _utest = True + + +class DisplayFileTest(unittest.TestCase): + """Test functions that display files. + + While somewhat redundant with gui-based test_file_dialog, + these unit tests run on all buildbots, not just a few. + """ + dialog = Dummy_about_dialog() + + @classmethod + def setUpClass(cls): + cls.orig_error = textview.showerror + cls.orig_view = textview.view_text + cls.error = Mbox_func() + cls.view = Func() + textview.showerror = cls.error + textview.view_text = cls.view + + @classmethod + def tearDownClass(cls): + textview.showerror = cls.orig_error + textview.view_text = cls.orig_view + + def test_file_display(self): + for handler in (self.dialog.idle_credits, + self.dialog.idle_readme, + self.dialog.idle_news): + self.error.message = '' + self.view.called = False + with self.subTest(handler=handler): + handler() + self.assertEqual(self.error.message, '') + self.assertEqual(self.view.called, True) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_history.py b/Lib/idlelib/idle_test/test_history.py new file mode 100644 index 000000000..675396514 --- /dev/null +++ b/Lib/idlelib/idle_test/test_history.py @@ -0,0 +1,172 @@ +" Test history, coverage 100%." + +from idlelib.history import History +import unittest +from test.support import requires + +import tkinter as tk +from tkinter import Text as tkText +from idlelib.idle_test.mock_tk import Text as mkText +from idlelib.config import idleConf + +line1 = 'a = 7' +line2 = 'b = a' + + +class StoreTest(unittest.TestCase): + '''Tests History.__init__ and History.store with mock Text''' + + @classmethod + def setUpClass(cls): + cls.text = mkText() + cls.history = History(cls.text) + + def tearDown(self): + self.text.delete('1.0', 'end') + self.history.history = [] + + def test_init(self): + self.assertIs(self.history.text, self.text) + self.assertEqual(self.history.history, []) + self.assertIsNone(self.history.prefix) + self.assertIsNone(self.history.pointer) + self.assertEqual(self.history.cyclic, + idleConf.GetOption("main", "History", "cyclic", 1, "bool")) + + def test_store_short(self): + self.history.store('a') + self.assertEqual(self.history.history, []) + self.history.store(' a ') + self.assertEqual(self.history.history, []) + + def test_store_dup(self): + self.history.store(line1) + self.assertEqual(self.history.history, [line1]) + self.history.store(line2) + self.assertEqual(self.history.history, [line1, line2]) + self.history.store(line1) + self.assertEqual(self.history.history, [line2, line1]) + + def test_store_reset(self): + self.history.prefix = line1 + self.history.pointer = 0 + self.history.store(line2) + self.assertIsNone(self.history.prefix) + self.assertIsNone(self.history.pointer) + + +class TextWrapper: + def __init__(self, master): + self.text = tkText(master=master) + self._bell = False + def __getattr__(self, name): + return getattr(self.text, name) + def bell(self): + self._bell = True + + +class FetchTest(unittest.TestCase): + '''Test History.fetch with wrapped tk.Text. + ''' + @classmethod + def setUpClass(cls): + requires('gui') + cls.root = tk.Tk() + cls.root.withdraw() + + def setUp(self): + self.text = text = TextWrapper(self.root) + text.insert('1.0', ">>> ") + text.mark_set('iomark', '1.4') + text.mark_gravity('iomark', 'left') + self.history = History(text) + self.history.history = [line1, line2] + + @classmethod + def tearDownClass(cls): + cls.root.destroy() + del cls.root + + def fetch_test(self, reverse, line, prefix, index, *, bell=False): + # Perform one fetch as invoked by Alt-N or Alt-P + # Test the result. The line test is the most important. + # The last two are diagnostic of fetch internals. + History = self.history + History.fetch(reverse) + + Equal = self.assertEqual + Equal(self.text.get('iomark', 'end-1c'), line) + Equal(self.text._bell, bell) + if bell: + self.text._bell = False + Equal(History.prefix, prefix) + Equal(History.pointer, index) + Equal(self.text.compare("insert", '==', "end-1c"), 1) + + def test_fetch_prev_cyclic(self): + prefix = '' + test = self.fetch_test + test(True, line2, prefix, 1) + test(True, line1, prefix, 0) + test(True, prefix, None, None, bell=True) + + def test_fetch_next_cyclic(self): + prefix = '' + test = self.fetch_test + test(False, line1, prefix, 0) + test(False, line2, prefix, 1) + test(False, prefix, None, None, bell=True) + + # Prefix 'a' tests skip line2, which starts with 'b' + def test_fetch_prev_prefix(self): + prefix = 'a' + self.text.insert('iomark', prefix) + self.fetch_test(True, line1, prefix, 0) + self.fetch_test(True, prefix, None, None, bell=True) + + def test_fetch_next_prefix(self): + prefix = 'a' + self.text.insert('iomark', prefix) + self.fetch_test(False, line1, prefix, 0) + self.fetch_test(False, prefix, None, None, bell=True) + + def test_fetch_prev_noncyclic(self): + prefix = '' + self.history.cyclic = False + test = self.fetch_test + test(True, line2, prefix, 1) + test(True, line1, prefix, 0) + test(True, line1, prefix, 0, bell=True) + + def test_fetch_next_noncyclic(self): + prefix = '' + self.history.cyclic = False + test = self.fetch_test + test(False, prefix, None, None, bell=True) + test(True, line2, prefix, 1) + test(False, prefix, None, None, bell=True) + test(False, prefix, None, None, bell=True) + + def test_fetch_cursor_move(self): + # Move cursor after fetch + self.history.fetch(reverse=True) # initialization + self.text.mark_set('insert', 'iomark') + self.fetch_test(True, line2, None, None, bell=True) + + def test_fetch_edit(self): + # Edit after fetch + self.history.fetch(reverse=True) # initialization + self.text.delete('iomark', 'insert', ) + self.text.insert('iomark', 'a =') + self.fetch_test(True, line1, 'a =', 0) # prefix is reset + + def test_history_prev_next(self): + # Minimally test functions bound to events + self.history.history_prev('dummy event') + self.assertEqual(self.history.pointer, 1) + self.history.history_next('dummy event') + self.assertEqual(self.history.pointer, None) + + +if __name__ == '__main__': + unittest.main(verbosity=2, exit=2) diff --git a/Lib/idlelib/idle_test/test_hyperparser.py b/Lib/idlelib/idle_test/test_hyperparser.py new file mode 100644 index 000000000..343843c41 --- /dev/null +++ b/Lib/idlelib/idle_test/test_hyperparser.py @@ -0,0 +1,276 @@ +"Test hyperparser, coverage 98%." + +from idlelib.hyperparser import HyperParser +import unittest +from test.support import requires +from tkinter import Tk, Text +from idlelib.editor import EditorWindow + +class DummyEditwin: + def __init__(self, text): + self.text = text + self.indentwidth = 8 + self.tabwidth = 8 + self.prompt_last_line = '>>>' + self.num_context_lines = 50, 500, 1000 + + _build_char_in_string_func = EditorWindow._build_char_in_string_func + is_char_in_string = EditorWindow.is_char_in_string + + +class HyperParserTest(unittest.TestCase): + code = ( + '"""This is a module docstring"""\n' + '# this line is a comment\n' + 'x = "this is a string"\n' + "y = 'this is also a string'\n" + 'l = [i for i in range(10)]\n' + 'm = [py*py for # comment\n' + ' py in l]\n' + 'x.__len__\n' + "z = ((r'asdf')+('a')))\n" + '[x for x in\n' + 'for = False\n' + 'cliché = "this is a string with unicode, what a cliché"' + ) + + @classmethod + def setUpClass(cls): + requires('gui') + cls.root = Tk() + cls.root.withdraw() + cls.text = Text(cls.root) + cls.editwin = DummyEditwin(cls.text) + + @classmethod + def tearDownClass(cls): + del cls.text, cls.editwin + cls.root.destroy() + del cls.root + + def setUp(self): + self.text.insert('insert', self.code) + + def tearDown(self): + self.text.delete('1.0', 'end') + self.editwin.prompt_last_line = '>>>' + + def get_parser(self, index): + """ + Return a parser object with index at 'index' + """ + return HyperParser(self.editwin, index) + + def test_init(self): + """ + test corner cases in the init method + """ + with self.assertRaises(ValueError) as ve: + self.text.tag_add('console', '1.0', '1.end') + p = self.get_parser('1.5') + self.assertIn('precedes', str(ve.exception)) + + # test without ps1 + self.editwin.prompt_last_line = '' + + # number of lines lesser than 50 + p = self.get_parser('end') + self.assertEqual(p.rawtext, self.text.get('1.0', 'end')) + + # number of lines greater than 50 + self.text.insert('end', self.text.get('1.0', 'end')*4) + p = self.get_parser('54.5') + + def test_is_in_string(self): + get = self.get_parser + + p = get('1.0') + self.assertFalse(p.is_in_string()) + p = get('1.4') + self.assertTrue(p.is_in_string()) + p = get('2.3') + self.assertFalse(p.is_in_string()) + p = get('3.3') + self.assertFalse(p.is_in_string()) + p = get('3.7') + self.assertTrue(p.is_in_string()) + p = get('4.6') + self.assertTrue(p.is_in_string()) + p = get('12.54') + self.assertTrue(p.is_in_string()) + + def test_is_in_code(self): + get = self.get_parser + + p = get('1.0') + self.assertTrue(p.is_in_code()) + p = get('1.1') + self.assertFalse(p.is_in_code()) + p = get('2.5') + self.assertFalse(p.is_in_code()) + p = get('3.4') + self.assertTrue(p.is_in_code()) + p = get('3.6') + self.assertFalse(p.is_in_code()) + p = get('4.14') + self.assertFalse(p.is_in_code()) + + def test_get_surrounding_bracket(self): + get = self.get_parser + + def without_mustclose(parser): + # a utility function to get surrounding bracket + # with mustclose=False + return parser.get_surrounding_brackets(mustclose=False) + + def with_mustclose(parser): + # a utility function to get surrounding bracket + # with mustclose=True + return parser.get_surrounding_brackets(mustclose=True) + + p = get('3.2') + self.assertIsNone(with_mustclose(p)) + self.assertIsNone(without_mustclose(p)) + + p = get('5.6') + self.assertTupleEqual(without_mustclose(p), ('5.4', '5.25')) + self.assertTupleEqual(without_mustclose(p), with_mustclose(p)) + + p = get('5.23') + self.assertTupleEqual(without_mustclose(p), ('5.21', '5.24')) + self.assertTupleEqual(without_mustclose(p), with_mustclose(p)) + + p = get('6.15') + self.assertTupleEqual(without_mustclose(p), ('6.4', '6.end')) + self.assertIsNone(with_mustclose(p)) + + p = get('9.end') + self.assertIsNone(with_mustclose(p)) + self.assertIsNone(without_mustclose(p)) + + def test_get_expression(self): + get = self.get_parser + + p = get('4.2') + self.assertEqual(p.get_expression(), 'y ') + + p = get('4.7') + with self.assertRaises(ValueError) as ve: + p.get_expression() + self.assertIn('is inside a code', str(ve.exception)) + + p = get('5.25') + self.assertEqual(p.get_expression(), 'range(10)') + + p = get('6.7') + self.assertEqual(p.get_expression(), 'py') + + p = get('6.8') + self.assertEqual(p.get_expression(), '') + + p = get('7.9') + self.assertEqual(p.get_expression(), 'py') + + p = get('8.end') + self.assertEqual(p.get_expression(), 'x.__len__') + + p = get('9.13') + self.assertEqual(p.get_expression(), "r'asdf'") + + p = get('9.17') + with self.assertRaises(ValueError) as ve: + p.get_expression() + self.assertIn('is inside a code', str(ve.exception)) + + p = get('10.0') + self.assertEqual(p.get_expression(), '') + + p = get('10.6') + self.assertEqual(p.get_expression(), '') + + p = get('10.11') + self.assertEqual(p.get_expression(), '') + + p = get('11.3') + self.assertEqual(p.get_expression(), '') + + p = get('11.11') + self.assertEqual(p.get_expression(), 'False') + + p = get('12.6') + self.assertEqual(p.get_expression(), 'cliché') + + def test_eat_identifier(self): + def is_valid_id(candidate): + result = HyperParser._eat_identifier(candidate, 0, len(candidate)) + if result == len(candidate): + return True + elif result == 0: + return False + else: + err_msg = "Unexpected result: {} (expected 0 or {}".format( + result, len(candidate) + ) + raise Exception(err_msg) + + # invalid first character which is valid elsewhere in an identifier + self.assertFalse(is_valid_id('2notid')) + + # ASCII-only valid identifiers + self.assertTrue(is_valid_id('valid_id')) + self.assertTrue(is_valid_id('_valid_id')) + self.assertTrue(is_valid_id('valid_id_')) + self.assertTrue(is_valid_id('_2valid_id')) + + # keywords which should be "eaten" + self.assertTrue(is_valid_id('True')) + self.assertTrue(is_valid_id('False')) + self.assertTrue(is_valid_id('None')) + + # keywords which should not be "eaten" + self.assertFalse(is_valid_id('for')) + self.assertFalse(is_valid_id('import')) + self.assertFalse(is_valid_id('return')) + + # valid unicode identifiers + self.assertTrue(is_valid_id('cliche')) + self.assertTrue(is_valid_id('cliché')) + self.assertTrue(is_valid_id('a٢')) + + # invalid unicode identifiers + self.assertFalse(is_valid_id('2a')) + self.assertFalse(is_valid_id('٢a')) + self.assertFalse(is_valid_id('a²')) + + # valid identifier after "punctuation" + self.assertEqual(HyperParser._eat_identifier('+ var', 0, 5), len('var')) + self.assertEqual(HyperParser._eat_identifier('+var', 0, 4), len('var')) + self.assertEqual(HyperParser._eat_identifier('.var', 0, 4), len('var')) + + # invalid identifiers + self.assertFalse(is_valid_id('+')) + self.assertFalse(is_valid_id(' ')) + self.assertFalse(is_valid_id(':')) + self.assertFalse(is_valid_id('?')) + self.assertFalse(is_valid_id('^')) + self.assertFalse(is_valid_id('\\')) + self.assertFalse(is_valid_id('"')) + self.assertFalse(is_valid_id('"a string"')) + + def test_eat_identifier_various_lengths(self): + eat_id = HyperParser._eat_identifier + + for length in range(1, 21): + self.assertEqual(eat_id('a' * length, 0, length), length) + self.assertEqual(eat_id('é' * length, 0, length), length) + self.assertEqual(eat_id('a' + '2' * (length - 1), 0, length), length) + self.assertEqual(eat_id('é' + '2' * (length - 1), 0, length), length) + self.assertEqual(eat_id('é' + 'a' * (length - 1), 0, length), length) + self.assertEqual(eat_id('é' * (length - 1) + 'a', 0, length), length) + self.assertEqual(eat_id('+' * length, 0, length), 0) + self.assertEqual(eat_id('2' + 'a' * (length - 1), 0, length), 0) + self.assertEqual(eat_id('2' + 'é' * (length - 1), 0, length), 0) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_iomenu.py b/Lib/idlelib/idle_test/test_iomenu.py new file mode 100644 index 000000000..e0642cf0c --- /dev/null +++ b/Lib/idlelib/idle_test/test_iomenu.py @@ -0,0 +1,84 @@ +"Test , coverage 17%." + +from idlelib import iomenu +import unittest +from test.support import requires +from tkinter import Tk +from idlelib.editor import EditorWindow +from idlelib import util +from idlelib.idle_test.mock_idle import Func + +# Fail if either tokenize.open and t.detect_encoding does not exist. +# These are used in loadfile and encode. +# Also used in pyshell.MI.execfile and runscript.tabnanny. +from tokenize import open, detect_encoding +# Remove when we have proper tests that use both. + + +class IOBindingTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + requires('gui') + cls.root = Tk() + cls.root.withdraw() + cls.editwin = EditorWindow(root=cls.root) + cls.io = iomenu.IOBinding(cls.editwin) + + @classmethod + def tearDownClass(cls): + cls.io.close() + cls.editwin._close() + del cls.editwin + cls.root.update_idletasks() + for id in cls.root.tk.call('after', 'info'): + cls.root.after_cancel(id) # Need for EditorWindow. + cls.root.destroy() + del cls.root + + def test_init(self): + self.assertIs(self.io.editwin, self.editwin) + + def test_fixnewlines_end(self): + eq = self.assertEqual + io = self.io + fix = io.fixnewlines + text = io.editwin.text + + # Make the editor temporarily look like Shell. + self.editwin.interp = None + shelltext = '>>> if 1' + self.editwin.get_prompt_text = Func(result=shelltext) + eq(fix(), shelltext) # Get... call and '\n' not added. + del self.editwin.interp, self.editwin.get_prompt_text + + text.insert(1.0, 'a') + eq(fix(), 'a'+io.eol_convention) + eq(text.get('1.0', 'end-1c'), 'a\n') + eq(fix(), 'a'+io.eol_convention) + + +def _extension_in_filetypes(extension): + return any( + f'*{extension}' in filetype_tuple[1] + for filetype_tuple in iomenu.IOBinding.filetypes + ) + + +class FiletypesTest(unittest.TestCase): + def test_python_source_files(self): + for extension in util.py_extensions: + with self.subTest(extension=extension): + self.assertTrue( + _extension_in_filetypes(extension) + ) + + def test_text_files(self): + self.assertTrue(_extension_in_filetypes('.txt')) + + def test_all_files(self): + self.assertTrue(_extension_in_filetypes('')) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_macosx.py b/Lib/idlelib/idle_test/test_macosx.py new file mode 100644 index 000000000..86da8849e --- /dev/null +++ b/Lib/idlelib/idle_test/test_macosx.py @@ -0,0 +1,113 @@ +"Test macosx, coverage 45% on Windows." + +from idlelib import macosx +import unittest +from test.support import requires +import tkinter as tk +import unittest.mock as mock +from idlelib.filelist import FileList + +mactypes = {'carbon', 'cocoa', 'xquartz'} +nontypes = {'other'} +alltypes = mactypes | nontypes + + +def setUpModule(): + global orig_tktype + orig_tktype = macosx._tk_type + + +def tearDownModule(): + macosx._tk_type = orig_tktype + + +class InitTktypeTest(unittest.TestCase): + "Test _init_tk_type." + + @classmethod + def setUpClass(cls): + requires('gui') + cls.root = tk.Tk() + cls.root.withdraw() + cls.orig_platform = macosx.platform + + @classmethod + def tearDownClass(cls): + cls.root.update_idletasks() + cls.root.destroy() + del cls.root + macosx.platform = cls.orig_platform + + def test_init_sets_tktype(self): + "Test that _init_tk_type sets _tk_type according to platform." + for platform, types in ('darwin', alltypes), ('other', nontypes): + with self.subTest(platform=platform): + macosx.platform = platform + macosx._tk_type = None + macosx._init_tk_type() + self.assertIn(macosx._tk_type, types) + + +class IsTypeTkTest(unittest.TestCase): + "Test each of the four isTypeTk predecates." + isfuncs = ((macosx.isAquaTk, ('carbon', 'cocoa')), + (macosx.isCarbonTk, ('carbon')), + (macosx.isCocoaTk, ('cocoa')), + (macosx.isXQuartz, ('xquartz')), + ) + + @mock.patch('idlelib.macosx._init_tk_type') + def test_is_calls_init(self, mockinit): + "Test that each isTypeTk calls _init_tk_type when _tk_type is None." + macosx._tk_type = None + for func, whentrue in self.isfuncs: + with self.subTest(func=func): + func() + self.assertTrue(mockinit.called) + mockinit.reset_mock() + + def test_isfuncs(self): + "Test that each isTypeTk return correct bool." + for func, whentrue in self.isfuncs: + for tktype in alltypes: + with self.subTest(func=func, whentrue=whentrue, tktype=tktype): + macosx._tk_type = tktype + (self.assertTrue if tktype in whentrue else self.assertFalse)\ + (func()) + + +class SetupTest(unittest.TestCase): + "Test setupApp." + + @classmethod + def setUpClass(cls): + requires('gui') + cls.root = tk.Tk() + cls.root.withdraw() + def cmd(tkpath, func): + assert isinstance(tkpath, str) + assert isinstance(func, type(cmd)) + cls.root.createcommand = cmd + + @classmethod + def tearDownClass(cls): + cls.root.update_idletasks() + cls.root.destroy() + del cls.root + + @mock.patch('idlelib.macosx.overrideRootMenu') #27312 + def test_setupapp(self, overrideRootMenu): + "Call setupApp with each possible graphics type." + root = self.root + flist = FileList(root) + for tktype in alltypes: + with self.subTest(tktype=tktype): + macosx._tk_type = tktype + macosx.setupApp(root, flist) + if tktype in ('carbon', 'cocoa'): + self.assertTrue(overrideRootMenu.called) + overrideRootMenu.reset_mock() + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_mainmenu.py b/Lib/idlelib/idle_test/test_mainmenu.py new file mode 100644 index 000000000..51d2accfe --- /dev/null +++ b/Lib/idlelib/idle_test/test_mainmenu.py @@ -0,0 +1,42 @@ +"Test mainmenu, coverage 100%." +# Reported as 88%; mocking turtledemo absence would have no point. + +from idlelib import mainmenu +import re +import unittest + + +class MainMenuTest(unittest.TestCase): + + def test_menudefs(self): + actual = [item[0] for item in mainmenu.menudefs] + expect = ['file', 'edit', 'format', 'run', 'shell', + 'debug', 'options', 'window', 'help'] + self.assertEqual(actual, expect) + + def test_default_keydefs(self): + self.assertGreaterEqual(len(mainmenu.default_keydefs), 50) + + def test_tcl_indexes(self): + # Test tcl patterns used to find menuitem to alter. + # On failure, change pattern here and in function(s). + # Patterns here have '.*' for re instead of '*' for tcl. + for menu, pattern in ( + ('debug', '.*tack.*iewer'), # PyShell.debug_menu_postcommand + ('options', '.*ode.*ontext'), # EW.__init__, CodeContext.toggle... + ('options', '.*ine.*umbers'), # EW.__init__, EW.toggle...event. + ): + with self.subTest(menu=menu, pattern=pattern): + for menutup in mainmenu.menudefs: + if menutup[0] == menu: + break + else: + self.assertTrue(0, f"{menu} not in menudefs") + self.assertTrue(any(re.search(pattern, menuitem[0]) + for menuitem in menutup[1] + if menuitem is not None), # Separator. + f"{pattern} not in {menu}") + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_multicall.py b/Lib/idlelib/idle_test/test_multicall.py new file mode 100644 index 000000000..67f28db6b --- /dev/null +++ b/Lib/idlelib/idle_test/test_multicall.py @@ -0,0 +1,48 @@ +"Test multicall, coverage 33%." + +from idlelib import multicall +import unittest +from test.support import requires +from tkinter import Tk, Text + + +class MultiCallTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + requires('gui') + cls.root = Tk() + cls.root.withdraw() + cls.mc = multicall.MultiCallCreator(Text) + + @classmethod + def tearDownClass(cls): + del cls.mc + cls.root.update_idletasks() +## for id in cls.root.tk.call('after', 'info'): +## cls.root.after_cancel(id) # Need for EditorWindow. + cls.root.destroy() + del cls.root + + def test_creator(self): + mc = self.mc + self.assertIs(multicall._multicall_dict[Text], mc) + self.assertIsSubclass(mc, Text) + mc2 = multicall.MultiCallCreator(Text) + self.assertIs(mc, mc2) + + def test_init(self): + mctext = self.mc(self.root) + self.assertIsInstance(mctext._MultiCall__binders, list) + + def test_yview(self): + # Added for tree.wheel_event + # (it depends on yview to not be overridden) + mc = self.mc + self.assertIs(mc.yview, Text.yview) + mctext = self.mc(self.root) + self.assertIs(mctext.yview.__func__, Text.yview) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_outwin.py b/Lib/idlelib/idle_test/test_outwin.py new file mode 100644 index 000000000..0f13363f8 --- /dev/null +++ b/Lib/idlelib/idle_test/test_outwin.py @@ -0,0 +1,172 @@ +"Test outwin, coverage 76%." + +from idlelib import outwin +import platform +import sys +import unittest +from test.support import requires +from tkinter import Tk, Text +from idlelib.idle_test.mock_tk import Mbox_func +from idlelib.idle_test.mock_idle import Func +from unittest import mock + + +class OutputWindowTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + requires('gui') + root = cls.root = Tk() + root.withdraw() + w = cls.window = outwin.OutputWindow(None, None, None, root) + cls.text = w.text = Text(root) + if sys.platform == 'darwin': # Issue 112938 + cls.text.update = cls.text.update_idletasks + # Without this, test write, writelines, and goto... fail. + # The reasons and why macOS-specific are unclear. + + @classmethod + def tearDownClass(cls): + cls.window.close() + del cls.text, cls.window + cls.root.destroy() + del cls.root + + def setUp(self): + self.text.delete('1.0', 'end') + + def test_ispythonsource(self): + # OutputWindow overrides ispythonsource to always return False. + w = self.window + self.assertFalse(w.ispythonsource('test.txt')) + self.assertFalse(w.ispythonsource(__file__)) + + def test_window_title(self): + self.assertEqual(self.window.top.title(), 'Output' + ' (%s)' % platform.python_version()) + + def test_maybesave(self): + w = self.window + eq = self.assertEqual + w.get_saved = Func() + + w.get_saved.result = False + eq(w.maybesave(), 'no') + eq(w.get_saved.called, 1) + + w.get_saved.result = True + eq(w.maybesave(), 'yes') + eq(w.get_saved.called, 2) + del w.get_saved + + def test_write(self): + eq = self.assertEqual + delete = self.text.delete + get = self.text.get + write = self.window.write + + # No new line - insert stays on same line. + delete('1.0', 'end') + test_text = 'test text' + eq(write(test_text), len(test_text)) + eq(get('1.0', '1.end'), 'test text') + eq(get('insert linestart', 'insert lineend'), 'test text') + + # New line - insert moves to next line. + delete('1.0', 'end') + test_text = 'test text\n' + eq(write(test_text), len(test_text)) + eq(get('1.0', '1.end'), 'test text') + eq(get('insert linestart', 'insert lineend'), '') + + # Text after new line is tagged for second line of Text widget. + delete('1.0', 'end') + test_text = 'test text\nLine 2' + eq(write(test_text), len(test_text)) + eq(get('1.0', '1.end'), 'test text') + eq(get('2.0', '2.end'), 'Line 2') + eq(get('insert linestart', 'insert lineend'), 'Line 2') + + # Test tags. + delete('1.0', 'end') + test_text = 'test text\n' + test_text2 = 'Line 2\n' + eq(write(test_text, tags='mytag'), len(test_text)) + eq(write(test_text2, tags='secondtag'), len(test_text2)) + eq(get('mytag.first', 'mytag.last'), test_text) + eq(get('secondtag.first', 'secondtag.last'), test_text2) + eq(get('1.0', '1.end'), test_text.rstrip('\n')) + eq(get('2.0', '2.end'), test_text2.rstrip('\n')) + + def test_writelines(self): + eq = self.assertEqual + get = self.text.get + writelines = self.window.writelines + + writelines(('Line 1\n', 'Line 2\n', 'Line 3\n')) + eq(get('1.0', '1.end'), 'Line 1') + eq(get('2.0', '2.end'), 'Line 2') + eq(get('3.0', '3.end'), 'Line 3') + eq(get('insert linestart', 'insert lineend'), '') + + def test_goto_file_line(self): + eq = self.assertEqual + w = self.window + text = self.text + + w.flist = mock.Mock() + gfl = w.flist.gotofileline = Func() + showerror = w.showerror = Mbox_func() + + # No file/line number. + w.write('Not a file line') + self.assertIsNone(w.goto_file_line()) + eq(gfl.called, 0) + eq(showerror.title, 'No special line') + + # Current file/line number. + w.write(f'{str(__file__)}: 42: spam\n') + w.write(f'{str(__file__)}: 21: spam') + self.assertIsNone(w.goto_file_line()) + eq(gfl.args, (str(__file__), 21)) + + # Previous line has file/line number. + text.delete('1.0', 'end') + w.write(f'{str(__file__)}: 42: spam\n') + w.write('Not a file line') + self.assertIsNone(w.goto_file_line()) + eq(gfl.args, (str(__file__), 42)) + + del w.flist.gotofileline, w.showerror + + +class ModuleFunctionTest(unittest.TestCase): + + @classmethod + def setUp(cls): + outwin.file_line_progs = None + + def test_compile_progs(self): + outwin.compile_progs() + for pat, regex in zip(outwin.file_line_pats, outwin.file_line_progs): + self.assertEqual(regex.pattern, pat) + + @mock.patch('builtins.open') + def test_file_line_helper(self, mock_open): + flh = outwin.file_line_helper + test_lines = ( + (r'foo file "testfile1", line 42, bar', ('testfile1', 42)), + (r'foo testfile2(21) bar', ('testfile2', 21)), + (r' testfile3 : 42: foo bar\n', (' testfile3 ', 42)), + (r'foo testfile4.py :1: ', ('foo testfile4.py ', 1)), + ('testfile5: \u19D4\u19D2: ', ('testfile5', 42)), + (r'testfile6: 42', None), # only one `:` + (r'testfile7 42 text', None) # no separators + ) + for line, expected_output in test_lines: + self.assertEqual(flh(line), expected_output) + if expected_output: + mock_open.assert_called_with(expected_output[0]) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_parenmatch.py b/Lib/idlelib/idle_test/test_parenmatch.py new file mode 100644 index 000000000..2e10d7cd3 --- /dev/null +++ b/Lib/idlelib/idle_test/test_parenmatch.py @@ -0,0 +1,112 @@ +"""Test parenmatch, coverage 91%. + +This must currently be a gui test because ParenMatch methods use +several text methods not defined on idlelib.idle_test.mock_tk.Text. +""" +from idlelib.parenmatch import ParenMatch +from test.support import requires +requires('gui') + +import unittest +from unittest.mock import Mock +from tkinter import Tk, Text + + +class DummyEditwin: + def __init__(self, text): + self.text = text + self.indentwidth = 8 + self.tabwidth = 8 + self.prompt_last_line = '>>>' # Currently not used by parenmatch. + + +class ParenMatchTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + cls.root = Tk() + cls.root.withdraw() + cls.text = Text(cls.root) + cls.editwin = DummyEditwin(cls.text) + cls.editwin.text_frame = Mock() + + @classmethod + def tearDownClass(cls): + del cls.text, cls.editwin + cls.root.update_idletasks() + cls.root.destroy() + del cls.root + + def tearDown(self): + self.text.delete('1.0', 'end') + + def get_parenmatch(self): + pm = ParenMatch(self.editwin) + pm.bell = lambda: None + return pm + + def test_paren_styles(self): + """ + Test ParenMatch with each style. + """ + text = self.text + pm = self.get_parenmatch() + for style, range1, range2 in ( + ('opener', ('1.10', '1.11'), ('1.10', '1.11')), + ('default',('1.10', '1.11'),('1.10', '1.11')), + ('parens', ('1.14', '1.15'), ('1.15', '1.16')), + ('expression', ('1.10', '1.15'), ('1.10', '1.16'))): + with self.subTest(style=style): + text.delete('1.0', 'end') + pm.STYLE = style + text.insert('insert', 'def foobar(a, b') + + pm.flash_paren_event('event') + self.assertIn('<>', text.event_info()) + if style == 'parens': + self.assertTupleEqual(text.tag_nextrange('paren', '1.0'), + ('1.10', '1.11')) + self.assertTupleEqual( + text.tag_prevrange('paren', 'end'), range1) + + text.insert('insert', ')') + pm.restore_event() + self.assertNotIn('<>', + text.event_info()) + self.assertEqual(text.tag_prevrange('paren', 'end'), ()) + + pm.paren_closed_event('event') + self.assertTupleEqual( + text.tag_prevrange('paren', 'end'), range2) + + def test_paren_corner(self): + """ + Test corner cases in flash_paren_event and paren_closed_event. + + Force execution of conditional expressions and alternate paths. + """ + text = self.text + pm = self.get_parenmatch() + + text.insert('insert', '# Comment.)') + pm.paren_closed_event('event') + + text.insert('insert', '\ndef') + pm.flash_paren_event('event') + pm.paren_closed_event('event') + + text.insert('insert', ' a, *arg)') + pm.paren_closed_event('event') + + def test_handle_restore_timer(self): + pm = self.get_parenmatch() + pm.restore_event = Mock() + pm.handle_restore_timer(0) + self.assertTrue(pm.restore_event.called) + pm.restore_event.reset_mock() + pm.handle_restore_timer(1) + self.assertFalse(pm.restore_event.called) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_pathbrowser.py b/Lib/idlelib/idle_test/test_pathbrowser.py new file mode 100644 index 000000000..13d8b9e1b --- /dev/null +++ b/Lib/idlelib/idle_test/test_pathbrowser.py @@ -0,0 +1,86 @@ +"Test pathbrowser, coverage 95%." + +from idlelib import pathbrowser +import unittest +from test.support import requires +from tkinter import Tk + +import os.path +import pyclbr # for _modules +import sys # for sys.path + +from idlelib.idle_test.mock_idle import Func +import idlelib # for __file__ +from idlelib import browser +from idlelib.tree import TreeNode + + +class PathBrowserTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + requires('gui') + cls.root = Tk() + cls.root.withdraw() + cls.pb = pathbrowser.PathBrowser(cls.root, _utest=True) + + @classmethod + def tearDownClass(cls): + cls.pb.close() + cls.root.update_idletasks() + cls.root.destroy() + del cls.root, cls.pb + + def test_init(self): + pb = self.pb + eq = self.assertEqual + eq(pb.master, self.root) + eq(pyclbr._modules, {}) + self.assertIsInstance(pb.node, TreeNode) + self.assertIsNotNone(browser.file_open) + + def test_settitle(self): + pb = self.pb + self.assertEqual(pb.top.title(), 'Path Browser') + self.assertEqual(pb.top.iconname(), 'Path Browser') + + def test_rootnode(self): + pb = self.pb + rn = pb.rootnode() + self.assertIsInstance(rn, pathbrowser.PathBrowserTreeItem) + + def test_close(self): + pb = self.pb + pb.top.destroy = Func() + pb.node.destroy = Func() + pb.close() + self.assertTrue(pb.top.destroy.called) + self.assertTrue(pb.node.destroy.called) + del pb.top.destroy, pb.node.destroy + + +class DirBrowserTreeItemTest(unittest.TestCase): + + def test_DirBrowserTreeItem(self): + # Issue16226 - make sure that getting a sublist works + d = pathbrowser.DirBrowserTreeItem('') + d.GetSubList() + self.assertEqual('', d.GetText()) + + dir = os.path.split(os.path.abspath(idlelib.__file__))[0] + self.assertEqual(d.ispackagedir(dir), True) + self.assertEqual(d.ispackagedir(dir + '/Icons'), False) + + +class PathBrowserTreeItemTest(unittest.TestCase): + + def test_PathBrowserTreeItem(self): + p = pathbrowser.PathBrowserTreeItem() + self.assertEqual(p.GetText(), 'sys.path') + sub = p.GetSubList() + self.assertEqual(len(sub), len(sys.path)) + self.assertEqual(type(sub[0]), pathbrowser.DirBrowserTreeItem) + + +if __name__ == '__main__': + unittest.main(verbosity=2, exit=False) diff --git a/Lib/idlelib/idle_test/test_percolator.py b/Lib/idlelib/idle_test/test_percolator.py new file mode 100644 index 000000000..17668ccd1 --- /dev/null +++ b/Lib/idlelib/idle_test/test_percolator.py @@ -0,0 +1,118 @@ +"Test percolator, coverage 100%." + +from idlelib.percolator import Percolator, Delegator +import unittest +from test.support import requires +requires('gui') +from tkinter import Text, Tk, END + + +class MyFilter(Delegator): + def __init__(self): + Delegator.__init__(self, None) + + def insert(self, *args): + self.insert_called_with = args + self.delegate.insert(*args) + + def delete(self, *args): + self.delete_called_with = args + self.delegate.delete(*args) + + def uppercase_insert(self, index, chars, tags=None): + chars = chars.upper() + self.delegate.insert(index, chars) + + def lowercase_insert(self, index, chars, tags=None): + chars = chars.lower() + self.delegate.insert(index, chars) + + def dont_insert(self, index, chars, tags=None): + pass + + +class PercolatorTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + cls.root = Tk() + cls.text = Text(cls.root) + + @classmethod + def tearDownClass(cls): + del cls.text + cls.root.destroy() + del cls.root + + def setUp(self): + self.percolator = Percolator(self.text) + self.filter_one = MyFilter() + self.filter_two = MyFilter() + self.percolator.insertfilter(self.filter_one) + self.percolator.insertfilter(self.filter_two) + + def tearDown(self): + self.percolator.close() + self.text.delete('1.0', END) + + def test_insertfilter(self): + self.assertIsNotNone(self.filter_one.delegate) + self.assertEqual(self.percolator.top, self.filter_two) + self.assertEqual(self.filter_two.delegate, self.filter_one) + self.assertEqual(self.filter_one.delegate, self.percolator.bottom) + + def test_removefilter(self): + filter_three = MyFilter() + self.percolator.removefilter(self.filter_two) + self.assertEqual(self.percolator.top, self.filter_one) + self.assertIsNone(self.filter_two.delegate) + + filter_three = MyFilter() + self.percolator.insertfilter(self.filter_two) + self.percolator.insertfilter(filter_three) + self.percolator.removefilter(self.filter_one) + self.assertEqual(self.percolator.top, filter_three) + self.assertEqual(filter_three.delegate, self.filter_two) + self.assertEqual(self.filter_two.delegate, self.percolator.bottom) + self.assertIsNone(self.filter_one.delegate) + + def test_insert(self): + self.text.insert('insert', 'foo') + self.assertEqual(self.text.get('1.0', END), 'foo\n') + self.assertTupleEqual(self.filter_one.insert_called_with, + ('insert', 'foo', None)) + + def test_modify_insert(self): + self.filter_one.insert = self.filter_one.uppercase_insert + self.text.insert('insert', 'bAr') + self.assertEqual(self.text.get('1.0', END), 'BAR\n') + + def test_modify_chain_insert(self): + filter_three = MyFilter() + self.percolator.insertfilter(filter_three) + self.filter_two.insert = self.filter_two.uppercase_insert + self.filter_one.insert = self.filter_one.lowercase_insert + self.text.insert('insert', 'BaR') + self.assertEqual(self.text.get('1.0', END), 'bar\n') + + def test_dont_insert(self): + self.filter_one.insert = self.filter_one.dont_insert + self.text.insert('insert', 'foo bar') + self.assertEqual(self.text.get('1.0', END), '\n') + self.filter_one.insert = self.filter_one.dont_insert + self.text.insert('insert', 'foo bar') + self.assertEqual(self.text.get('1.0', END), '\n') + + def test_without_filter(self): + self.text.insert('insert', 'hello') + self.assertEqual(self.text.get('1.0', 'end'), 'hello\n') + + def test_delete(self): + self.text.insert('insert', 'foo') + self.text.delete('1.0', '1.2') + self.assertEqual(self.text.get('1.0', END), 'o\n') + self.assertTupleEqual(self.filter_one.delete_called_with, + ('1.0', '1.2')) + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_pyparse.py b/Lib/idlelib/idle_test/test_pyparse.py new file mode 100644 index 000000000..384db566a --- /dev/null +++ b/Lib/idlelib/idle_test/test_pyparse.py @@ -0,0 +1,483 @@ +"Test pyparse, coverage 96%." + +from idlelib import pyparse +import unittest +from collections import namedtuple + + +class ParseMapTest(unittest.TestCase): + + def test_parsemap(self): + keepwhite = {ord(c): ord(c) for c in ' \t\n\r'} + mapping = pyparse.ParseMap(keepwhite) + self.assertEqual(mapping[ord('\t')], ord('\t')) + self.assertEqual(mapping[ord('a')], ord('x')) + self.assertEqual(mapping[1000], ord('x')) + + def test_trans(self): + # trans is the production instance of ParseMap, used in _study1 + parser = pyparse.Parser(4, 4) + self.assertEqual('\t a([{b}])b"c\'d\n'.translate(pyparse.trans), + 'xxx(((x)))x"x\'x\n') + + +class PyParseTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + cls.parser = pyparse.Parser(indentwidth=4, tabwidth=4) + + @classmethod + def tearDownClass(cls): + del cls.parser + + def test_init(self): + self.assertEqual(self.parser.indentwidth, 4) + self.assertEqual(self.parser.tabwidth, 4) + + def test_set_code(self): + eq = self.assertEqual + p = self.parser + setcode = p.set_code + + # Not empty and doesn't end with newline. + with self.assertRaises(AssertionError): + setcode('a') + + tests = ('', + 'a\n') + + for string in tests: + with self.subTest(string=string): + setcode(string) + eq(p.code, string) + eq(p.study_level, 0) + + def test_find_good_parse_start(self): + eq = self.assertEqual + p = self.parser + setcode = p.set_code + start = p.find_good_parse_start + def char_in_string_false(index): return False + + # First line starts with 'def' and ends with ':', then 0 is the pos. + setcode('def spam():\n') + eq(start(char_in_string_false), 0) + + # First line begins with a keyword in the list and ends + # with an open brace, then 0 is the pos. This is how + # hyperparser calls this function as the newline is not added + # in the editor, but rather on the call to setcode. + setcode('class spam( ' + ' \n') + eq(start(char_in_string_false), 0) + + # Split def across lines. + setcode('"""This is a module docstring"""\n' + 'class C:\n' + ' def __init__(self, a,\n' + ' b=True):\n' + ' pass\n' + ) + pos0, pos = 33, 42 # Start of 'class...', ' def' lines. + + # Passing no value or non-callable should fail (issue 32989). + with self.assertRaises(TypeError): + start() + with self.assertRaises(TypeError): + start(False) + + # Make text look like a string. This returns pos as the start + # position, but it's set to None. + self.assertIsNone(start(is_char_in_string=lambda index: True)) + + # Make all text look like it's not in a string. This means that it + # found a good start position. + eq(start(char_in_string_false), pos) + + # If the beginning of the def line is not in a string, then it + # returns that as the index. + eq(start(is_char_in_string=lambda index: index > pos), pos) + # If the beginning of the def line is in a string, then it + # looks for a previous index. + eq(start(is_char_in_string=lambda index: index >= pos), pos0) + # If everything before the 'def' is in a string, then returns None. + # The non-continuation def line returns 44 (see below). + eq(start(is_char_in_string=lambda index: index < pos), None) + + # Code without extra line break in def line - mostly returns the same + # values. + setcode('"""This is a module docstring"""\n' + 'class C:\n' + ' def __init__(self, a, b=True):\n' + ' pass\n' + ) # Does not affect class, def positions. + eq(start(char_in_string_false), pos) + eq(start(is_char_in_string=lambda index: index > pos), pos) + eq(start(is_char_in_string=lambda index: index >= pos), pos0) + # When the def line isn't split, this returns which doesn't match the + # split line test. + eq(start(is_char_in_string=lambda index: index < pos), pos) + + def test_set_lo(self): + code = ( + '"""This is a module docstring"""\n' + 'class C:\n' + ' def __init__(self, a,\n' + ' b=True):\n' + ' pass\n' + ) + pos = 42 + p = self.parser + p.set_code(code) + + # Previous character is not a newline. + with self.assertRaises(AssertionError): + p.set_lo(5) + + # A value of 0 doesn't change self.code. + p.set_lo(0) + self.assertEqual(p.code, code) + + # An index that is preceded by a newline. + p.set_lo(pos) + self.assertEqual(p.code, code[pos:]) + + def test_study1(self): + eq = self.assertEqual + p = self.parser + setcode = p.set_code + study = p._study1 + + (NONE, BACKSLASH, FIRST, NEXT, BRACKET) = range(5) + TestInfo = namedtuple('TestInfo', ['string', 'goodlines', + 'continuation']) + tests = ( + TestInfo('', [0], NONE), + # Docstrings. + TestInfo('"""This is a complete docstring."""\n', [0, 1], NONE), + TestInfo("'''This is a complete docstring.'''\n", [0, 1], NONE), + TestInfo('"""This is a continued docstring.\n', [0, 1], FIRST), + TestInfo("'''This is a continued docstring.\n", [0, 1], FIRST), + TestInfo('"""Closing quote does not match."\n', [0, 1], FIRST), + TestInfo('"""Bracket in docstring [\n', [0, 1], FIRST), + TestInfo("'''Incomplete two line docstring.\n\n", [0, 2], NEXT), + # Single-quoted strings. + TestInfo('"This is a complete string."\n', [0, 1], NONE), + TestInfo('"This is an incomplete string.\n', [0, 1], NONE), + TestInfo("'This is more incomplete.\n\n", [0, 1, 2], NONE), + # Comment (backslash does not continue comments). + TestInfo('# Comment\\\n', [0, 1], NONE), + # Brackets. + TestInfo('("""Complete string in bracket"""\n', [0, 1], BRACKET), + TestInfo('("""Open string in bracket\n', [0, 1], FIRST), + TestInfo('a = (1 + 2) - 5 *\\\n', [0, 1], BACKSLASH), # No bracket. + TestInfo('\n def function1(self, a,\n b):\n', + [0, 1, 3], NONE), + TestInfo('\n def function1(self, a,\\\n', [0, 1, 2], BRACKET), + TestInfo('\n def function1(self, a,\n', [0, 1, 2], BRACKET), + TestInfo('())\n', [0, 1], NONE), # Extra closer. + TestInfo(')(\n', [0, 1], BRACKET), # Extra closer. + # For the mismatched example, it doesn't look like continuation. + TestInfo('{)(]\n', [0, 1], NONE), # Mismatched. + ) + + for test in tests: + with self.subTest(string=test.string): + setcode(test.string) # resets study_level + study() + eq(p.study_level, 1) + eq(p.goodlines, test.goodlines) + eq(p.continuation, test.continuation) + + # Called again, just returns without reprocessing. + self.assertIsNone(study()) + + def test_get_continuation_type(self): + eq = self.assertEqual + p = self.parser + setcode = p.set_code + gettype = p.get_continuation_type + + (NONE, BACKSLASH, FIRST, NEXT, BRACKET) = range(5) + TestInfo = namedtuple('TestInfo', ['string', 'continuation']) + tests = ( + TestInfo('', NONE), + TestInfo('"""This is a continuation docstring.\n', FIRST), + TestInfo("'''This is a multiline-continued docstring.\n\n", NEXT), + TestInfo('a = (1 + 2) - 5 *\\\n', BACKSLASH), + TestInfo('\n def function1(self, a,\\\n', BRACKET) + ) + + for test in tests: + with self.subTest(string=test.string): + setcode(test.string) + eq(gettype(), test.continuation) + + def test_study2(self): + eq = self.assertEqual + p = self.parser + setcode = p.set_code + study = p._study2 + + TestInfo = namedtuple('TestInfo', ['string', 'start', 'end', 'lastch', + 'openbracket', 'bracketing']) + tests = ( + TestInfo('', 0, 0, '', None, ((0, 0),)), + TestInfo("'''This is a multiline continuation docstring.\n\n", + 0, 48, "'", None, ((0, 0), (0, 1), (48, 0))), + TestInfo(' # Comment\\\n', + 0, 12, '', None, ((0, 0), (1, 1), (12, 0))), + # A comment without a space is a special case + TestInfo(' #Comment\\\n', + 0, 0, '', None, ((0, 0),)), + # Backslash continuation. + TestInfo('a = (1 + 2) - 5 *\\\n', + 0, 19, '*', None, ((0, 0), (4, 1), (11, 0))), + # Bracket continuation with close. + TestInfo('\n def function1(self, a,\n b):\n', + 1, 48, ':', None, ((1, 0), (17, 1), (46, 0))), + # Bracket continuation with unneeded backslash. + TestInfo('\n def function1(self, a,\\\n', + 1, 28, ',', 17, ((1, 0), (17, 1))), + # Bracket continuation. + TestInfo('\n def function1(self, a,\n', + 1, 27, ',', 17, ((1, 0), (17, 1))), + # Bracket continuation with comment at end of line with text. + TestInfo('\n def function1(self, a, # End of line comment.\n', + 1, 51, ',', 17, ((1, 0), (17, 1), (28, 2), (51, 1))), + # Multi-line statement with comment line in between code lines. + TestInfo(' a = ["first item",\n # Comment line\n "next item",\n', + 0, 55, ',', 6, ((0, 0), (6, 1), (7, 2), (19, 1), + (23, 2), (38, 1), (42, 2), (53, 1))), + TestInfo('())\n', + 0, 4, ')', None, ((0, 0), (0, 1), (2, 0), (3, 0))), + TestInfo(')(\n', 0, 3, '(', 1, ((0, 0), (1, 0), (1, 1))), + # Wrong closers still decrement stack level. + TestInfo('{)(]\n', + 0, 5, ']', None, ((0, 0), (0, 1), (2, 0), (2, 1), (4, 0))), + # Character after backslash. + TestInfo(':\\a\n', 0, 4, '\\a', None, ((0, 0),)), + TestInfo('\n', 0, 0, '', None, ((0, 0),)), + ) + + for test in tests: + with self.subTest(string=test.string): + setcode(test.string) + study() + eq(p.study_level, 2) + eq(p.stmt_start, test.start) + eq(p.stmt_end, test.end) + eq(p.lastch, test.lastch) + eq(p.lastopenbracketpos, test.openbracket) + eq(p.stmt_bracketing, test.bracketing) + + # Called again, just returns without reprocessing. + self.assertIsNone(study()) + + def test_get_num_lines_in_stmt(self): + eq = self.assertEqual + p = self.parser + setcode = p.set_code + getlines = p.get_num_lines_in_stmt + + TestInfo = namedtuple('TestInfo', ['string', 'lines']) + tests = ( + TestInfo('[x for x in a]\n', 1), # Closed on one line. + TestInfo('[x\nfor x in a\n', 2), # Not closed. + TestInfo('[x\\\nfor x in a\\\n', 2), # "", unneeded backslashes. + TestInfo('[x\nfor x in a\n]\n', 3), # Closed on multi-line. + TestInfo('\n"""Docstring comment L1"""\nL2\nL3\nL4\n', 1), + TestInfo('\n"""Docstring comment L1\nL2"""\nL3\nL4\n', 1), + TestInfo('\n"""Docstring comment L1\\\nL2\\\nL3\\\nL4\\\n', 4), + TestInfo('\n\n"""Docstring comment L1\\\nL2\\\nL3\\\nL4\\\n"""\n', 5) + ) + + # Blank string doesn't have enough elements in goodlines. + setcode('') + with self.assertRaises(IndexError): + getlines() + + for test in tests: + with self.subTest(string=test.string): + setcode(test.string) + eq(getlines(), test.lines) + + def test_compute_bracket_indent(self): + eq = self.assertEqual + p = self.parser + setcode = p.set_code + indent = p.compute_bracket_indent + + TestInfo = namedtuple('TestInfo', ['string', 'spaces']) + tests = ( + TestInfo('def function1(self, a,\n', 14), + # Characters after bracket. + TestInfo('\n def function1(self, a,\n', 18), + TestInfo('\n\tdef function1(self, a,\n', 18), + # No characters after bracket. + TestInfo('\n def function1(\n', 8), + TestInfo('\n\tdef function1(\n', 8), + TestInfo('\n def function1( \n', 8), # Ignore extra spaces. + TestInfo('[\n"first item",\n # Comment line\n "next item",\n', 0), + TestInfo('[\n "first item",\n # Comment line\n "next item",\n', 2), + TestInfo('["first item",\n # Comment line\n "next item",\n', 1), + TestInfo('(\n', 4), + TestInfo('(a\n', 1), + ) + + # Must be C_BRACKET continuation type. + setcode('def function1(self, a, b):\n') + with self.assertRaises(AssertionError): + indent() + + for test in tests: + setcode(test.string) + eq(indent(), test.spaces) + + def test_compute_backslash_indent(self): + eq = self.assertEqual + p = self.parser + setcode = p.set_code + indent = p.compute_backslash_indent + + # Must be C_BACKSLASH continuation type. + errors = (('def function1(self, a, b\\\n'), # Bracket. + (' """ (\\\n'), # Docstring. + ('a = #\\\n'), # Inline comment. + ) + for string in errors: + with self.subTest(string=string): + setcode(string) + with self.assertRaises(AssertionError): + indent() + + TestInfo = namedtuple('TestInfo', ('string', 'spaces')) + tests = (TestInfo('a = (1 + 2) - 5 *\\\n', 4), + TestInfo('a = 1 + 2 - 5 *\\\n', 4), + TestInfo(' a = 1 + 2 - 5 *\\\n', 8), + TestInfo(' a = "spam"\\\n', 6), + TestInfo(' a = \\\n"a"\\\n', 4), + TestInfo(' a = #\\\n"a"\\\n', 5), + TestInfo('a == \\\n', 2), + TestInfo('a != \\\n', 2), + # Difference between containing = and those not. + TestInfo('\\\n', 2), + TestInfo(' \\\n', 6), + TestInfo('\t\\\n', 6), + TestInfo('a\\\n', 3), + TestInfo('{}\\\n', 4), + TestInfo('(1 + 2) - 5 *\\\n', 3), + ) + for test in tests: + with self.subTest(string=test.string): + setcode(test.string) + eq(indent(), test.spaces) + + def test_get_base_indent_string(self): + eq = self.assertEqual + p = self.parser + setcode = p.set_code + baseindent = p.get_base_indent_string + + TestInfo = namedtuple('TestInfo', ['string', 'indent']) + tests = (TestInfo('', ''), + TestInfo('def a():\n', ''), + TestInfo('\tdef a():\n', '\t'), + TestInfo(' def a():\n', ' '), + TestInfo(' def a(\n', ' '), + TestInfo('\t\n def a(\n', ' '), + TestInfo('\t\n # Comment.\n', ' '), + ) + + for test in tests: + with self.subTest(string=test.string): + setcode(test.string) + eq(baseindent(), test.indent) + + def test_is_block_opener(self): + yes = self.assertTrue + no = self.assertFalse + p = self.parser + setcode = p.set_code + opener = p.is_block_opener + + TestInfo = namedtuple('TestInfo', ['string', 'assert_']) + tests = ( + TestInfo('def a():\n', yes), + TestInfo('\n def function1(self, a,\n b):\n', yes), + TestInfo(':\n', yes), + TestInfo('a:\n', yes), + TestInfo('):\n', yes), + TestInfo('(:\n', yes), + TestInfo('":\n', no), + TestInfo('\n def function1(self, a,\n', no), + TestInfo('def function1(self, a):\n pass\n', no), + TestInfo('# A comment:\n', no), + TestInfo('"""A docstring:\n', no), + TestInfo('"""A docstring:\n', no), + ) + + for test in tests: + with self.subTest(string=test.string): + setcode(test.string) + test.assert_(opener()) + + def test_is_block_closer(self): + yes = self.assertTrue + no = self.assertFalse + p = self.parser + setcode = p.set_code + closer = p.is_block_closer + + TestInfo = namedtuple('TestInfo', ['string', 'assert_']) + tests = ( + TestInfo('return\n', yes), + TestInfo('\tbreak\n', yes), + TestInfo(' continue\n', yes), + TestInfo(' raise\n', yes), + TestInfo('pass \n', yes), + TestInfo('pass\t\n', yes), + TestInfo('return #\n', yes), + TestInfo('raised\n', no), + TestInfo('returning\n', no), + TestInfo('# return\n', no), + TestInfo('"""break\n', no), + TestInfo('"continue\n', no), + TestInfo('def function1(self, a):\n pass\n', yes), + ) + + for test in tests: + with self.subTest(string=test.string): + setcode(test.string) + test.assert_(closer()) + + def test_get_last_stmt_bracketing(self): + eq = self.assertEqual + p = self.parser + setcode = p.set_code + bracketing = p.get_last_stmt_bracketing + + TestInfo = namedtuple('TestInfo', ['string', 'bracket']) + tests = ( + TestInfo('', ((0, 0),)), + TestInfo('a\n', ((0, 0),)), + TestInfo('()()\n', ((0, 0), (0, 1), (2, 0), (2, 1), (4, 0))), + TestInfo('(\n)()\n', ((0, 0), (0, 1), (3, 0), (3, 1), (5, 0))), + TestInfo('()\n()\n', ((3, 0), (3, 1), (5, 0))), + TestInfo('()(\n)\n', ((0, 0), (0, 1), (2, 0), (2, 1), (5, 0))), + TestInfo('(())\n', ((0, 0), (0, 1), (1, 2), (3, 1), (4, 0))), + TestInfo('(\n())\n', ((0, 0), (0, 1), (2, 2), (4, 1), (5, 0))), + # Same as matched test. + TestInfo('{)(]\n', ((0, 0), (0, 1), (2, 0), (2, 1), (4, 0))), + TestInfo('(((())\n', + ((0, 0), (0, 1), (1, 2), (2, 3), (3, 4), (5, 3), (6, 2))), + ) + + for test in tests: + with self.subTest(string=test.string): + setcode(test.string) + eq(bracketing(), test.bracket) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_pyshell.py b/Lib/idlelib/idle_test/test_pyshell.py new file mode 100644 index 000000000..706703965 --- /dev/null +++ b/Lib/idlelib/idle_test/test_pyshell.py @@ -0,0 +1,148 @@ +"Test pyshell, coverage 12%." +# Plus coverage of test_warning. Was 20% with test_openshell. + +from idlelib import pyshell +import unittest +from test.support import requires +from tkinter import Tk + + +class FunctionTest(unittest.TestCase): + # Test stand-alone module level non-gui functions. + + def test_restart_line_wide(self): + eq = self.assertEqual + for file, mul, extra in (('', 22, ''), ('finame', 21, '=')): + width = 60 + bar = mul * '=' + with self.subTest(file=file, bar=bar): + file = file or 'Shell' + line = pyshell.restart_line(width, file) + eq(len(line), width) + eq(line, f"{bar+extra} RESTART: {file} {bar}") + + def test_restart_line_narrow(self): + expect, taglen = "= RESTART: Shell", 16 + for width in (taglen-1, taglen, taglen+1): + with self.subTest(width=width): + self.assertEqual(pyshell.restart_line(width, ''), expect) + self.assertEqual(pyshell.restart_line(taglen+2, ''), expect+' =') + + +class PyShellFileListTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + requires('gui') + cls.root = Tk() + cls.root.withdraw() + + @classmethod + def tearDownClass(cls): + #cls.root.update_idletasks() +## for id in cls.root.tk.call('after', 'info'): +## cls.root.after_cancel(id) # Need for EditorWindow. + cls.root.destroy() + del cls.root + + def test_init(self): + psfl = pyshell.PyShellFileList(self.root) + self.assertEqual(psfl.EditorWindow, pyshell.PyShellEditorWindow) + self.assertIsNone(psfl.pyshell) + +# The following sometimes causes 'invalid command name "109734456recolorize"'. +# Uncommenting after_cancel above prevents this, but results in +# TclError: bad window path name ".!listedtoplevel.!frame.text" +# which is normally prevented by after_cancel. +## def test_openshell(self): +## pyshell.use_subprocess = False +## ps = pyshell.PyShellFileList(self.root).open_shell() +## self.assertIsInstance(ps, pyshell.PyShell) + + +class PyShellRemoveLastNewlineAndSurroundingWhitespaceTest(unittest.TestCase): + regexp = pyshell.PyShell._last_newline_re + + def all_removed(self, text): + self.assertEqual('', self.regexp.sub('', text)) + + def none_removed(self, text): + self.assertEqual(text, self.regexp.sub('', text)) + + def check_result(self, text, expected): + self.assertEqual(expected, self.regexp.sub('', text)) + + def test_empty(self): + self.all_removed('') + + def test_newline(self): + self.all_removed('\n') + + def test_whitespace_no_newline(self): + self.all_removed(' ') + self.all_removed(' ') + self.all_removed(' ') + self.all_removed(' ' * 20) + self.all_removed('\t') + self.all_removed('\t\t') + self.all_removed('\t\t\t') + self.all_removed('\t' * 20) + self.all_removed('\t ') + self.all_removed(' \t') + self.all_removed(' \t \t ') + self.all_removed('\t \t \t') + + def test_newline_with_whitespace(self): + self.all_removed(' \n') + self.all_removed('\t\n') + self.all_removed(' \t\n') + self.all_removed('\t \n') + self.all_removed('\n ') + self.all_removed('\n\t') + self.all_removed('\n \t') + self.all_removed('\n\t ') + self.all_removed(' \n ') + self.all_removed('\t\n ') + self.all_removed(' \n\t') + self.all_removed('\t\n\t') + self.all_removed('\t \t \t\n') + self.all_removed(' \t \t \n') + self.all_removed('\n\t \t \t') + self.all_removed('\n \t \t ') + + def test_multiple_newlines(self): + self.check_result('\n\n', '\n') + self.check_result('\n' * 5, '\n' * 4) + self.check_result('\n' * 5 + '\t', '\n' * 4) + self.check_result('\n' * 20, '\n' * 19) + self.check_result('\n' * 20 + ' ', '\n' * 19) + self.check_result(' \n \n ', ' \n') + self.check_result(' \n\n ', ' \n') + self.check_result(' \n\n', ' \n') + self.check_result('\t\n\n', '\t\n') + self.check_result('\n\n ', '\n') + self.check_result('\n\n\t', '\n') + self.check_result(' \n \n ', ' \n') + self.check_result('\t\n\t\n\t', '\t\n') + + def test_non_whitespace(self): + self.none_removed('a') + self.check_result('a\n', 'a') + self.check_result('a\n ', 'a') + self.check_result('a \n ', 'a') + self.check_result('a \n\t', 'a') + self.none_removed('-') + self.check_result('-\n', '-') + self.none_removed('.') + self.check_result('.\n', '.') + + def test_unsupported_whitespace(self): + self.none_removed('\v') + self.none_removed('\n\v') + self.check_result('\v\n', '\v') + self.none_removed(' \n\v') + self.check_result('\v\n ', '\v') + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_query.py b/Lib/idlelib/idle_test/test_query.py new file mode 100644 index 000000000..a6ef858a8 --- /dev/null +++ b/Lib/idlelib/idle_test/test_query.py @@ -0,0 +1,451 @@ +"""Test query, coverage 93%. + +Non-gui tests for Query, SectionName, ModuleName, and HelpSource use +dummy versions that extract the non-gui methods and add other needed +attributes. GUI tests create an instance of each class and simulate +entries and button clicks. Subclass tests only target the new code in +the subclass definition. + +The appearance of the widgets is checked by the Query and +HelpSource htests. These are run by running query.py. +""" +from idlelib import query +import unittest +from test.support import requires +from tkinter import Tk, END + +import sys +from unittest import mock +from idlelib.idle_test.mock_tk import Var + + +# NON-GUI TESTS + +class QueryTest(unittest.TestCase): + "Test Query base class." + + class Dummy_Query: + # Test the following Query methods. + entry_ok = query.Query.entry_ok + ok = query.Query.ok + cancel = query.Query.cancel + # Add attributes and initialization needed for tests. + def __init__(self, dummy_entry): + self.entry = Var(value=dummy_entry) + self.entry_error = {'text': ''} + self.result = None + self.destroyed = False + def showerror(self, message): + self.entry_error['text'] = message + def destroy(self): + self.destroyed = True + + def test_entry_ok_blank(self): + dialog = self.Dummy_Query(' ') + self.assertEqual(dialog.entry_ok(), None) + self.assertEqual((dialog.result, dialog.destroyed), (None, False)) + self.assertIn('blank line', dialog.entry_error['text']) + + def test_entry_ok_good(self): + dialog = self.Dummy_Query(' good ') + Equal = self.assertEqual + Equal(dialog.entry_ok(), 'good') + Equal((dialog.result, dialog.destroyed), (None, False)) + Equal(dialog.entry_error['text'], '') + + def test_ok_blank(self): + dialog = self.Dummy_Query('') + dialog.entry.focus_set = mock.Mock() + self.assertEqual(dialog.ok(), None) + self.assertTrue(dialog.entry.focus_set.called) + del dialog.entry.focus_set + self.assertEqual((dialog.result, dialog.destroyed), (None, False)) + + def test_ok_good(self): + dialog = self.Dummy_Query('good') + self.assertEqual(dialog.ok(), None) + self.assertEqual((dialog.result, dialog.destroyed), ('good', True)) + + def test_cancel(self): + dialog = self.Dummy_Query('does not matter') + self.assertEqual(dialog.cancel(), None) + self.assertEqual((dialog.result, dialog.destroyed), (None, True)) + + +class SectionNameTest(unittest.TestCase): + "Test SectionName subclass of Query." + + class Dummy_SectionName: + entry_ok = query.SectionName.entry_ok # Function being tested. + used_names = ['used'] + def __init__(self, dummy_entry): + self.entry = Var(value=dummy_entry) + self.entry_error = {'text': ''} + def showerror(self, message): + self.entry_error['text'] = message + + def test_blank_section_name(self): + dialog = self.Dummy_SectionName(' ') + self.assertEqual(dialog.entry_ok(), None) + self.assertIn('no name', dialog.entry_error['text']) + + def test_used_section_name(self): + dialog = self.Dummy_SectionName('used') + self.assertEqual(dialog.entry_ok(), None) + self.assertIn('use', dialog.entry_error['text']) + + def test_long_section_name(self): + dialog = self.Dummy_SectionName('good'*8) + self.assertEqual(dialog.entry_ok(), None) + self.assertIn('longer than 30', dialog.entry_error['text']) + + def test_good_section_name(self): + dialog = self.Dummy_SectionName(' good ') + self.assertEqual(dialog.entry_ok(), 'good') + self.assertEqual(dialog.entry_error['text'], '') + + +class ModuleNameTest(unittest.TestCase): + "Test ModuleName subclass of Query." + + class Dummy_ModuleName: + entry_ok = query.ModuleName.entry_ok # Function being tested. + text0 = '' + def __init__(self, dummy_entry): + self.entry = Var(value=dummy_entry) + self.entry_error = {'text': ''} + def showerror(self, message): + self.entry_error['text'] = message + + def test_blank_module_name(self): + dialog = self.Dummy_ModuleName(' ') + self.assertEqual(dialog.entry_ok(), None) + self.assertIn('no name', dialog.entry_error['text']) + + def test_bogus_module_name(self): + dialog = self.Dummy_ModuleName('__name_xyz123_should_not_exist__') + self.assertEqual(dialog.entry_ok(), None) + self.assertIn('not found', dialog.entry_error['text']) + + def test_c_source_name(self): + dialog = self.Dummy_ModuleName('itertools') + self.assertEqual(dialog.entry_ok(), None) + self.assertIn('source-based', dialog.entry_error['text']) + + def test_good_module_name(self): + dialog = self.Dummy_ModuleName('idlelib') + self.assertEndsWith(dialog.entry_ok(), '__init__.py') + self.assertEqual(dialog.entry_error['text'], '') + dialog = self.Dummy_ModuleName('idlelib.idle') + self.assertEndsWith(dialog.entry_ok(), 'idle.py') + self.assertEqual(dialog.entry_error['text'], '') + + +class GotoTest(unittest.TestCase): + "Test Goto subclass of Query." + + class Dummy_ModuleName: + entry_ok = query.Goto.entry_ok # Function being tested. + def __init__(self, dummy_entry): + self.entry = Var(value=dummy_entry) + self.entry_error = {'text': ''} + def showerror(self, message): + self.entry_error['text'] = message + + def test_bogus_goto(self): + dialog = self.Dummy_ModuleName('a') + self.assertEqual(dialog.entry_ok(), None) + self.assertIn('not a base 10 integer', dialog.entry_error['text']) + + def test_bad_goto(self): + dialog = self.Dummy_ModuleName('0') + self.assertEqual(dialog.entry_ok(), None) + self.assertIn('not a positive integer', dialog.entry_error['text']) + + def test_good_goto(self): + dialog = self.Dummy_ModuleName('1') + self.assertEqual(dialog.entry_ok(), 1) + self.assertEqual(dialog.entry_error['text'], '') + + +# 3 HelpSource test classes each test one method. + +class HelpsourceBrowsefileTest(unittest.TestCase): + "Test browse_file method of ModuleName subclass of Query." + + class Dummy_HelpSource: + browse_file = query.HelpSource.browse_file + pathvar = Var() + + def test_file_replaces_path(self): + dialog = self.Dummy_HelpSource() + # Path is widget entry, either '' or something. + # Func return is file dialog return, either '' or something. + # Func return should override widget entry. + # We need all 4 combinations to test all (most) code paths. + for path, func, result in ( + ('', lambda a,b,c:'', ''), + ('', lambda a,b,c: __file__, __file__), + ('htest', lambda a,b,c:'', 'htest'), + ('htest', lambda a,b,c: __file__, __file__)): + with self.subTest(): + dialog.pathvar.set(path) + dialog.askfilename = func + dialog.browse_file() + self.assertEqual(dialog.pathvar.get(), result) + + +class HelpsourcePathokTest(unittest.TestCase): + "Test path_ok method of HelpSource subclass of Query." + + class Dummy_HelpSource: + path_ok = query.HelpSource.path_ok + def __init__(self, dummy_path): + self.path = Var(value=dummy_path) + self.path_error = {'text': ''} + def showerror(self, message, widget=None): + self.path_error['text'] = message + + orig_platform = query.platform # Set in test_path_ok_file. + @classmethod + def tearDownClass(cls): + query.platform = cls.orig_platform + + def test_path_ok_blank(self): + dialog = self.Dummy_HelpSource(' ') + self.assertEqual(dialog.path_ok(), None) + self.assertIn('no help file', dialog.path_error['text']) + + def test_path_ok_bad(self): + dialog = self.Dummy_HelpSource(__file__ + 'bad-bad-bad') + self.assertEqual(dialog.path_ok(), None) + self.assertIn('not exist', dialog.path_error['text']) + + def test_path_ok_web(self): + dialog = self.Dummy_HelpSource('') + Equal = self.assertEqual + for url in 'www.py.org', 'http://py.org': + with self.subTest(): + dialog.path.set(url) + self.assertEqual(dialog.path_ok(), url) + self.assertEqual(dialog.path_error['text'], '') + + def test_path_ok_file(self): + dialog = self.Dummy_HelpSource('') + for platform, prefix in ('darwin', 'file://'), ('other', ''): + with self.subTest(): + query.platform = platform + dialog.path.set(__file__) + self.assertEqual(dialog.path_ok(), prefix + __file__) + self.assertEqual(dialog.path_error['text'], '') + + +class HelpsourceEntryokTest(unittest.TestCase): + "Test entry_ok method of HelpSource subclass of Query." + + class Dummy_HelpSource: + entry_ok = query.HelpSource.entry_ok + entry_error = {} + path_error = {} + def item_ok(self): + return self.name + def path_ok(self): + return self.path + + def test_entry_ok_helpsource(self): + dialog = self.Dummy_HelpSource() + for name, path, result in ((None, None, None), + (None, 'doc.txt', None), + ('doc', None, None), + ('doc', 'doc.txt', ('doc', 'doc.txt'))): + with self.subTest(): + dialog.name, dialog.path = name, path + self.assertEqual(dialog.entry_ok(), result) + + +# 2 CustomRun test classes each test one method. + +class CustomRunCLIargsokTest(unittest.TestCase): + "Test cli_ok method of the CustomRun subclass of Query." + + class Dummy_CustomRun: + cli_args_ok = query.CustomRun.cli_args_ok + def __init__(self, dummy_entry): + self.entry = Var(value=dummy_entry) + self.entry_error = {'text': ''} + def showerror(self, message): + self.entry_error['text'] = message + + def test_blank_args(self): + dialog = self.Dummy_CustomRun(' ') + self.assertEqual(dialog.cli_args_ok(), []) + + def test_invalid_args(self): + dialog = self.Dummy_CustomRun("'no-closing-quote") + self.assertEqual(dialog.cli_args_ok(), None) + self.assertIn('No closing', dialog.entry_error['text']) + + def test_good_args(self): + args = ['-n', '10', '--verbose', '-p', '/path', '--name'] + dialog = self.Dummy_CustomRun(' '.join(args) + ' "my name"') + self.assertEqual(dialog.cli_args_ok(), args + ["my name"]) + self.assertEqual(dialog.entry_error['text'], '') + + +class CustomRunEntryokTest(unittest.TestCase): + "Test entry_ok method of the CustomRun subclass of Query." + + class Dummy_CustomRun: + entry_ok = query.CustomRun.entry_ok + entry_error = {} + restartvar = Var() + def cli_args_ok(self): + return self.cli_args + + def test_entry_ok_customrun(self): + dialog = self.Dummy_CustomRun() + for restart in {True, False}: + dialog.restartvar.set(restart) + for cli_args, result in ((None, None), + (['my arg'], (['my arg'], restart))): + with self.subTest(restart=restart, cli_args=cli_args): + dialog.cli_args = cli_args + self.assertEqual(dialog.entry_ok(), result) + + +# GUI TESTS + +class QueryGuiTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + requires('gui') + cls.root = root = Tk() + cls.root.withdraw() + cls.dialog = query.Query(root, 'TEST', 'test', _utest=True) + cls.dialog.destroy = mock.Mock() + + @classmethod + def tearDownClass(cls): + del cls.dialog.destroy + del cls.dialog + cls.root.destroy() + del cls.root + + def setUp(self): + self.dialog.entry.delete(0, 'end') + self.dialog.result = None + self.dialog.destroy.reset_mock() + + def test_click_ok(self): + dialog = self.dialog + dialog.entry.insert(0, 'abc') + dialog.button_ok.invoke() + self.assertEqual(dialog.result, 'abc') + self.assertTrue(dialog.destroy.called) + + def test_click_blank(self): + dialog = self.dialog + dialog.button_ok.invoke() + self.assertEqual(dialog.result, None) + self.assertFalse(dialog.destroy.called) + + def test_click_cancel(self): + dialog = self.dialog + dialog.entry.insert(0, 'abc') + dialog.button_cancel.invoke() + self.assertEqual(dialog.result, None) + self.assertTrue(dialog.destroy.called) + + +class SectionnameGuiTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + requires('gui') + + def test_click_section_name(self): + root = Tk() + root.withdraw() + dialog = query.SectionName(root, 'T', 't', {'abc'}, _utest=True) + Equal = self.assertEqual + self.assertEqual(dialog.used_names, {'abc'}) + dialog.entry.insert(0, 'okay') + dialog.button_ok.invoke() + self.assertEqual(dialog.result, 'okay') + root.destroy() + + +class ModulenameGuiTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + requires('gui') + + def test_click_module_name(self): + root = Tk() + root.withdraw() + dialog = query.ModuleName(root, 'T', 't', 'idlelib', _utest=True) + self.assertEqual(dialog.text0, 'idlelib') + self.assertEqual(dialog.entry.get(), 'idlelib') + dialog.button_ok.invoke() + self.assertEndsWith(dialog.result, '__init__.py') + root.destroy() + + +class GotoGuiTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + requires('gui') + + def test_click_module_name(self): + root = Tk() + root.withdraw() + dialog = query.Goto(root, 'T', 't', _utest=True) + dialog.entry.insert(0, '22') + dialog.button_ok.invoke() + self.assertEqual(dialog.result, 22) + root.destroy() + + +class HelpsourceGuiTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + requires('gui') + + def test_click_help_source(self): + root = Tk() + root.withdraw() + dialog = query.HelpSource(root, 'T', menuitem='__test__', + filepath=__file__, _utest=True) + Equal = self.assertEqual + Equal(dialog.entry.get(), '__test__') + Equal(dialog.path.get(), __file__) + dialog.button_ok.invoke() + prefix = "file://" if sys.platform == 'darwin' else '' + Equal(dialog.result, ('__test__', prefix + __file__)) + root.destroy() + + +class CustomRunGuiTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + requires('gui') + + def test_click_args(self): + root = Tk() + root.withdraw() + dialog = query.CustomRun(root, 'Title', + cli_args=['a', 'b=1'], _utest=True) + self.assertEqual(dialog.entry.get(), 'a b=1') + dialog.entry.insert(END, ' c') + dialog.button_ok.invoke() + self.assertEqual(dialog.result, (['a', 'b=1', 'c'], True)) + root.destroy() + + +if __name__ == '__main__': + unittest.main(verbosity=2, exit=False) diff --git a/Lib/idlelib/idle_test/test_redirector.py b/Lib/idlelib/idle_test/test_redirector.py new file mode 100644 index 000000000..bd486d7da --- /dev/null +++ b/Lib/idlelib/idle_test/test_redirector.py @@ -0,0 +1,122 @@ +"Test redirector, coverage 100%." + +from idlelib.redirector import WidgetRedirector +import unittest +from test.support import requires +from tkinter import Tk, Text, TclError +from idlelib.idle_test.mock_idle import Func + + +class InitCloseTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + requires('gui') + cls.root = Tk() + cls.root.withdraw() + cls.text = Text(cls.root) + + @classmethod + def tearDownClass(cls): + del cls.text + cls.root.destroy() + del cls.root + + def test_init(self): + redir = WidgetRedirector(self.text) + self.assertEqual(redir.widget, self.text) + self.assertEqual(redir.tk, self.text.tk) + self.assertRaises(TclError, WidgetRedirector, self.text) + redir.close() # restore self.tk, self.text + + def test_close(self): + redir = WidgetRedirector(self.text) + redir.register('insert', Func) + redir.close() + self.assertEqual(redir._operations, {}) + self.assertNotHasAttr(self.text, 'widget') + + +class WidgetRedirectorTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + requires('gui') + cls.root = Tk() + cls.root.withdraw() + cls.text = Text(cls.root) + + @classmethod + def tearDownClass(cls): + del cls.text + cls.root.update_idletasks() + cls.root.destroy() + del cls.root + + def setUp(self): + self.redir = WidgetRedirector(self.text) + self.func = Func() + self.orig_insert = self.redir.register('insert', self.func) + self.text.insert('insert', 'asdf') # leaves self.text empty + + def tearDown(self): + self.text.delete('1.0', 'end') + self.redir.close() + + def test_repr(self): # partly for 100% coverage + self.assertIn('Redirector', repr(self.redir)) + self.assertIn('Original', repr(self.orig_insert)) + + def test_register(self): + self.assertEqual(self.text.get('1.0', 'end'), '\n') + self.assertEqual(self.func.args, ('insert', 'asdf')) + self.assertIn('insert', self.redir._operations) + self.assertIn('insert', self.text.__dict__) + self.assertEqual(self.text.insert, self.func) + + def test_original_command(self): + self.assertEqual(self.orig_insert.operation, 'insert') + self.assertEqual(self.orig_insert.tk_call, self.text.tk.call) + self.orig_insert('insert', 'asdf') + self.assertEqual(self.text.get('1.0', 'end'), 'asdf\n') + + def test_unregister(self): + self.assertIsNone(self.redir.unregister('invalid operation name')) + self.assertEqual(self.redir.unregister('insert'), self.func) + self.assertNotIn('insert', self.redir._operations) + self.assertNotIn('insert', self.text.__dict__) + + def test_unregister_no_attribute(self): + del self.text.insert + self.assertEqual(self.redir.unregister('insert'), self.func) + + def test_dispatch_intercept(self): + self.func.__init__(True) + self.assertTrue(self.redir.dispatch('insert', False)) + self.assertFalse(self.func.args[0]) + + def test_dispatch_bypass(self): + self.orig_insert('insert', 'asdf') + # tk.call returns '' where Python would return None + self.assertEqual(self.redir.dispatch('delete', '1.0', 'end'), '') + self.assertEqual(self.text.get('1.0', 'end'), '\n') + + def test_dispatch_error(self): + self.func.__init__(TclError()) + self.assertEqual(self.redir.dispatch('insert', False), '') + self.assertEqual(self.redir.dispatch('invalid'), '') + + def test_command_dispatch(self): + # Test that .__init__ causes redirection of tk calls + # through redir.dispatch + self.root.call(self.text._w, 'insert', 'hello') + self.assertEqual(self.func.args, ('hello',)) + self.assertEqual(self.text.get('1.0', 'end'), '\n') + # Ensure that called through redir .dispatch and not through + # self.text.insert by having mock raise TclError. + self.func.__init__(TclError()) + self.assertEqual(self.root.call(self.text._w, 'insert', 'boo'), '') + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_replace.py b/Lib/idlelib/idle_test/test_replace.py new file mode 100644 index 000000000..6c07389b2 --- /dev/null +++ b/Lib/idlelib/idle_test/test_replace.py @@ -0,0 +1,294 @@ +"Test replace, coverage 78%." + +from idlelib.replace import ReplaceDialog +import unittest +from test.support import requires +requires('gui') +from tkinter import Tk, Text + +from unittest.mock import Mock +from idlelib.idle_test.mock_tk import Mbox +import idlelib.searchengine as se + +orig_mbox = se.messagebox +showerror = Mbox.showerror + + +class ReplaceDialogTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + cls.root = Tk() + cls.root.withdraw() + se.messagebox = Mbox + cls.engine = se.SearchEngine(cls.root) + cls.dialog = ReplaceDialog(cls.root, cls.engine) + cls.dialog.bell = lambda: None + cls.dialog.ok = Mock() + cls.text = Text(cls.root) + cls.text.undo_block_start = Mock() + cls.text.undo_block_stop = Mock() + cls.dialog.text = cls.text + + @classmethod + def tearDownClass(cls): + se.messagebox = orig_mbox + del cls.text, cls.dialog, cls.engine + cls.root.destroy() + del cls.root + + def setUp(self): + self.text.insert('insert', 'This is a sample sTring') + + def tearDown(self): + self.engine.patvar.set('') + self.dialog.replvar.set('') + self.engine.wordvar.set(False) + self.engine.casevar.set(False) + self.engine.revar.set(False) + self.engine.wrapvar.set(True) + self.engine.backvar.set(False) + showerror.title = '' + showerror.message = '' + self.text.delete('1.0', 'end') + + def test_replace_simple(self): + # Test replace function with all options at default setting. + # Wrap around - True + # Regular Expression - False + # Match case - False + # Match word - False + # Direction - Forwards + text = self.text + equal = self.assertEqual + pv = self.engine.patvar + rv = self.dialog.replvar + replace = self.dialog.replace_it + + # test accessor method + self.engine.setpat('asdf') + equal(self.engine.getpat(), pv.get()) + + # text found and replaced + pv.set('a') + rv.set('asdf') + replace() + equal(text.get('1.8', '1.12'), 'asdf') + + # don't "match word" case + text.mark_set('insert', '1.0') + pv.set('is') + rv.set('hello') + replace() + equal(text.get('1.2', '1.7'), 'hello') + + # don't "match case" case + pv.set('string') + rv.set('world') + replace() + equal(text.get('1.23', '1.28'), 'world') + + # without "regular expression" case + text.mark_set('insert', 'end') + text.insert('insert', '\nline42:') + before_text = text.get('1.0', 'end') + pv.set(r'[a-z][\d]+') + replace() + after_text = text.get('1.0', 'end') + equal(before_text, after_text) + + # test with wrap around selected and complete a cycle + text.mark_set('insert', '1.9') + pv.set('i') + rv.set('j') + replace() + equal(text.get('1.8'), 'i') + equal(text.get('2.1'), 'j') + replace() + equal(text.get('2.1'), 'j') + equal(text.get('1.8'), 'j') + before_text = text.get('1.0', 'end') + replace() + after_text = text.get('1.0', 'end') + equal(before_text, after_text) + + # text not found + before_text = text.get('1.0', 'end') + pv.set('foobar') + replace() + after_text = text.get('1.0', 'end') + equal(before_text, after_text) + + # test access method + self.dialog.find_it(0) + + def test_replace_wrap_around(self): + text = self.text + equal = self.assertEqual + pv = self.engine.patvar + rv = self.dialog.replvar + replace = self.dialog.replace_it + self.engine.wrapvar.set(False) + + # replace candidate found both after and before 'insert' + text.mark_set('insert', '1.4') + pv.set('i') + rv.set('j') + replace() + equal(text.get('1.2'), 'i') + equal(text.get('1.5'), 'j') + replace() + equal(text.get('1.2'), 'i') + equal(text.get('1.20'), 'j') + replace() + equal(text.get('1.2'), 'i') + + # replace candidate found only before 'insert' + text.mark_set('insert', '1.8') + pv.set('is') + before_text = text.get('1.0', 'end') + replace() + after_text = text.get('1.0', 'end') + equal(before_text, after_text) + + def test_replace_whole_word(self): + text = self.text + equal = self.assertEqual + pv = self.engine.patvar + rv = self.dialog.replvar + replace = self.dialog.replace_it + self.engine.wordvar.set(True) + + pv.set('is') + rv.set('hello') + replace() + equal(text.get('1.0', '1.4'), 'This') + equal(text.get('1.5', '1.10'), 'hello') + + def test_replace_match_case(self): + equal = self.assertEqual + text = self.text + pv = self.engine.patvar + rv = self.dialog.replvar + replace = self.dialog.replace_it + self.engine.casevar.set(True) + + before_text = self.text.get('1.0', 'end') + pv.set('this') + rv.set('that') + replace() + after_text = self.text.get('1.0', 'end') + equal(before_text, after_text) + + pv.set('This') + replace() + equal(text.get('1.0', '1.4'), 'that') + + def test_replace_regex(self): + equal = self.assertEqual + text = self.text + pv = self.engine.patvar + rv = self.dialog.replvar + replace = self.dialog.replace_it + self.engine.revar.set(True) + + before_text = text.get('1.0', 'end') + pv.set(r'[a-z][\d]+') + rv.set('hello') + replace() + after_text = text.get('1.0', 'end') + equal(before_text, after_text) + + text.insert('insert', '\nline42') + replace() + equal(text.get('2.0', '2.8'), 'linhello') + + pv.set('') + replace() + self.assertIn('error', showerror.title) + self.assertIn('Empty', showerror.message) + + pv.set(r'[\d') + replace() + self.assertIn('error', showerror.title) + self.assertIn('Pattern', showerror.message) + + showerror.title = '' + showerror.message = '' + pv.set('[a]') + rv.set('test\\') + replace() + self.assertIn('error', showerror.title) + self.assertIn('Invalid Replace Expression', showerror.message) + + # test access method + self.engine.setcookedpat("?") + equal(pv.get(), "\\?") + + def test_replace_backwards(self): + equal = self.assertEqual + text = self.text + pv = self.engine.patvar + rv = self.dialog.replvar + replace = self.dialog.replace_it + self.engine.backvar.set(True) + + text.insert('insert', '\nis as ') + + pv.set('is') + rv.set('was') + replace() + equal(text.get('1.2', '1.4'), 'is') + equal(text.get('2.0', '2.3'), 'was') + replace() + equal(text.get('1.5', '1.8'), 'was') + replace() + equal(text.get('1.2', '1.5'), 'was') + + def test_replace_all(self): + text = self.text + pv = self.engine.patvar + rv = self.dialog.replvar + replace_all = self.dialog.replace_all + + text.insert('insert', '\n') + text.insert('insert', text.get('1.0', 'end')*100) + pv.set('is') + rv.set('was') + replace_all() + self.assertNotIn('is', text.get('1.0', 'end')) + + self.engine.revar.set(True) + pv.set('') + replace_all() + self.assertIn('error', showerror.title) + self.assertIn('Empty', showerror.message) + + pv.set('[s][T]') + rv.set('\\') + replace_all() + + self.engine.revar.set(False) + pv.set('text which is not present') + rv.set('foobar') + replace_all() + + def test_default_command(self): + text = self.text + pv = self.engine.patvar + rv = self.dialog.replvar + replace_find = self.dialog.default_command + equal = self.assertEqual + + pv.set('This') + rv.set('was') + replace_find() + equal(text.get('sel.first', 'sel.last'), 'was') + + self.engine.revar.set(True) + pv.set('') + replace_find() + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_rpc.py b/Lib/idlelib/idle_test/test_rpc.py new file mode 100644 index 000000000..81eff398c --- /dev/null +++ b/Lib/idlelib/idle_test/test_rpc.py @@ -0,0 +1,29 @@ +"Test rpc, coverage 20%." + +from idlelib import rpc +import unittest + + + +class CodePicklerTest(unittest.TestCase): + + def test_pickle_unpickle(self): + def f(): return a + b + c + func, (cbytes,) = rpc.pickle_code(f.__code__) + self.assertIs(func, rpc.unpickle_code) + self.assertIn(b'test_rpc.py', cbytes) + code = rpc.unpickle_code(cbytes) + self.assertEqual(code.co_names, ('a', 'b', 'c')) + + def test_code_pickler(self): + self.assertIn(type((lambda:None).__code__), + rpc.CodePickler.dispatch_table) + + def test_dumps(self): + def f(): pass + # The main test here is that pickling code does not raise. + self.assertIn(b'test_rpc.py', rpc.dumps(f.__code__)) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_run.py b/Lib/idlelib/idle_test/test_run.py new file mode 100644 index 000000000..83ecbffa2 --- /dev/null +++ b/Lib/idlelib/idle_test/test_run.py @@ -0,0 +1,433 @@ +"Test run, coverage 54%." + +from idlelib import run +import io +import sys +from test.support import captured_output, captured_stderr +import unittest +from unittest import mock +import idlelib +from idlelib.idle_test.mock_idle import Func +from test.support import force_not_colorized + +idlelib.testing = True # Use {} for executing test user code. + + +class ExceptionTest(unittest.TestCase): + + def test_print_exception_unhashable(self): + class UnhashableException(Exception): + def __eq__(self, other): + return True + + ex1 = UnhashableException('ex1') + ex2 = UnhashableException('ex2') + try: + raise ex2 from ex1 + except UnhashableException: + try: + raise ex1 + except UnhashableException: + with captured_stderr() as output: + with mock.patch.object(run, 'cleanup_traceback') as ct: + ct.side_effect = lambda t, e: t + run.print_exception() + + tb = output.getvalue().strip().splitlines() + self.assertEqual(11, len(tb)) + self.assertIn('UnhashableException: ex2', tb[3]) + self.assertIn('UnhashableException: ex1', tb[10]) + + data = (('1/0', ZeroDivisionError, "division by zero\n"), + ('abc', NameError, "name 'abc' is not defined. " + "Did you mean: 'abs'? " + "Or did you forget to import 'abc'?\n"), + ('int.reel', AttributeError, + "type object 'int' has no attribute 'reel'. " + "Did you mean: 'real'?\n"), + ) + + @force_not_colorized + def test_get_message(self): + for code, exc, msg in self.data: + with self.subTest(code=code): + try: + eval(compile(code, '', 'eval')) + except exc: + typ, val, tb = sys.exc_info() + actual = run.get_message_lines(typ, val, tb)[0] + expect = f'{exc.__name__}: {msg}' + self.assertEqual(actual, expect) + + @force_not_colorized + @mock.patch.object(run, 'cleanup_traceback', + new_callable=lambda: (lambda t, e: None)) + def test_get_multiple_message(self, mock): + d = self.data + data2 = ((d[0], d[1]), (d[1], d[2]), (d[2], d[0])) + subtests = 0 + for (code1, exc1, msg1), (code2, exc2, msg2) in data2: + with self.subTest(codes=(code1,code2)): + try: + eval(compile(code1, '', 'eval')) + except exc1: + try: + eval(compile(code2, '', 'eval')) + except exc2: + with captured_stderr() as output: + run.print_exception() + actual = output.getvalue() + self.assertIn(msg1, actual) + self.assertIn(msg2, actual) + subtests += 1 + self.assertEqual(subtests, len(data2)) # All subtests ran? + +# StdioFile tests. + +class S(str): + def __str__(self): + return '%s:str' % type(self).__name__ + def __unicode__(self): + return '%s:unicode' % type(self).__name__ + def __len__(self): + return 3 + def __iter__(self): + return iter('abc') + def __getitem__(self, *args): + return '%s:item' % type(self).__name__ + def __getslice__(self, *args): + return '%s:slice' % type(self).__name__ + + +class MockShell: + def __init__(self): + self.reset() + def write(self, *args): + self.written.append(args) + def readline(self): + return self.lines.pop() + def close(self): + pass + def reset(self): + self.written = [] + def push(self, lines): + self.lines = list(lines)[::-1] + + +class StdInputFilesTest(unittest.TestCase): + + def test_misc(self): + shell = MockShell() + f = run.StdInputFile(shell, 'stdin') + self.assertIsInstance(f, io.TextIOBase) + self.assertEqual(f.encoding, 'utf-8') + self.assertEqual(f.errors, 'strict') + self.assertIsNone(f.newlines) + self.assertEqual(f.name, '') + self.assertFalse(f.closed) + self.assertTrue(f.isatty()) + self.assertTrue(f.readable()) + self.assertFalse(f.writable()) + self.assertFalse(f.seekable()) + + def test_unsupported(self): + shell = MockShell() + f = run.StdInputFile(shell, 'stdin') + self.assertRaises(OSError, f.fileno) + self.assertRaises(OSError, f.tell) + self.assertRaises(OSError, f.seek, 0) + self.assertRaises(OSError, f.write, 'x') + self.assertRaises(OSError, f.writelines, ['x']) + + def test_read(self): + shell = MockShell() + f = run.StdInputFile(shell, 'stdin') + shell.push(['one\n', 'two\n', '']) + self.assertEqual(f.read(), 'one\ntwo\n') + shell.push(['one\n', 'two\n', '']) + self.assertEqual(f.read(-1), 'one\ntwo\n') + shell.push(['one\n', 'two\n', '']) + self.assertEqual(f.read(None), 'one\ntwo\n') + shell.push(['one\n', 'two\n', 'three\n', '']) + self.assertEqual(f.read(2), 'on') + self.assertEqual(f.read(3), 'e\nt') + self.assertEqual(f.read(10), 'wo\nthree\n') + + shell.push(['one\n', 'two\n']) + self.assertEqual(f.read(0), '') + self.assertRaises(TypeError, f.read, 1.5) + self.assertRaises(TypeError, f.read, '1') + self.assertRaises(TypeError, f.read, 1, 1) + + def test_readline(self): + shell = MockShell() + f = run.StdInputFile(shell, 'stdin') + shell.push(['one\n', 'two\n', 'three\n', 'four\n']) + self.assertEqual(f.readline(), 'one\n') + self.assertEqual(f.readline(-1), 'two\n') + self.assertEqual(f.readline(None), 'three\n') + shell.push(['one\ntwo\n']) + self.assertEqual(f.readline(), 'one\n') + self.assertEqual(f.readline(), 'two\n') + shell.push(['one', 'two', 'three']) + self.assertEqual(f.readline(), 'one') + self.assertEqual(f.readline(), 'two') + shell.push(['one\n', 'two\n', 'three\n']) + self.assertEqual(f.readline(2), 'on') + self.assertEqual(f.readline(1), 'e') + self.assertEqual(f.readline(1), '\n') + self.assertEqual(f.readline(10), 'two\n') + + shell.push(['one\n', 'two\n']) + self.assertEqual(f.readline(0), '') + self.assertRaises(TypeError, f.readlines, 1.5) + self.assertRaises(TypeError, f.readlines, '1') + self.assertRaises(TypeError, f.readlines, 1, 1) + + def test_readlines(self): + shell = MockShell() + f = run.StdInputFile(shell, 'stdin') + shell.push(['one\n', 'two\n', '']) + self.assertEqual(f.readlines(), ['one\n', 'two\n']) + shell.push(['one\n', 'two\n', '']) + self.assertEqual(f.readlines(-1), ['one\n', 'two\n']) + shell.push(['one\n', 'two\n', '']) + self.assertEqual(f.readlines(None), ['one\n', 'two\n']) + shell.push(['one\n', 'two\n', '']) + self.assertEqual(f.readlines(0), ['one\n', 'two\n']) + shell.push(['one\n', 'two\n', '']) + self.assertEqual(f.readlines(3), ['one\n']) + shell.push(['one\n', 'two\n', '']) + self.assertEqual(f.readlines(4), ['one\n', 'two\n']) + + shell.push(['one\n', 'two\n', '']) + self.assertRaises(TypeError, f.readlines, 1.5) + self.assertRaises(TypeError, f.readlines, '1') + self.assertRaises(TypeError, f.readlines, 1, 1) + + def test_close(self): + shell = MockShell() + f = run.StdInputFile(shell, 'stdin') + shell.push(['one\n', 'two\n', '']) + self.assertFalse(f.closed) + self.assertEqual(f.readline(), 'one\n') + f.close() + self.assertFalse(f.closed) + self.assertEqual(f.readline(), 'two\n') + self.assertRaises(TypeError, f.close, 1) + + +class StdOutputFilesTest(unittest.TestCase): + + def test_misc(self): + shell = MockShell() + f = run.StdOutputFile(shell, 'stdout') + self.assertIsInstance(f, io.TextIOBase) + self.assertEqual(f.encoding, 'utf-8') + self.assertEqual(f.errors, 'strict') + self.assertIsNone(f.newlines) + self.assertEqual(f.name, '') + self.assertFalse(f.closed) + self.assertTrue(f.isatty()) + self.assertFalse(f.readable()) + self.assertTrue(f.writable()) + self.assertFalse(f.seekable()) + + def test_unsupported(self): + shell = MockShell() + f = run.StdOutputFile(shell, 'stdout') + self.assertRaises(OSError, f.fileno) + self.assertRaises(OSError, f.tell) + self.assertRaises(OSError, f.seek, 0) + self.assertRaises(OSError, f.read, 0) + self.assertRaises(OSError, f.readline, 0) + + def test_write(self): + shell = MockShell() + f = run.StdOutputFile(shell, 'stdout') + f.write('test') + self.assertEqual(shell.written, [('test', 'stdout')]) + shell.reset() + f.write('t\xe8\u015b\U0001d599') + self.assertEqual(shell.written, [('t\xe8\u015b\U0001d599', 'stdout')]) + shell.reset() + + f.write(S('t\xe8\u015b\U0001d599')) + self.assertEqual(shell.written, [('t\xe8\u015b\U0001d599', 'stdout')]) + self.assertEqual(type(shell.written[0][0]), str) + shell.reset() + + self.assertRaises(TypeError, f.write) + self.assertEqual(shell.written, []) + self.assertRaises(TypeError, f.write, b'test') + self.assertRaises(TypeError, f.write, 123) + self.assertEqual(shell.written, []) + self.assertRaises(TypeError, f.write, 'test', 'spam') + self.assertEqual(shell.written, []) + + def test_write_stderr_nonencodable(self): + shell = MockShell() + f = run.StdOutputFile(shell, 'stderr', 'iso-8859-15', 'backslashreplace') + f.write('t\xe8\u015b\U0001d599\xa4') + self.assertEqual(shell.written, [('t\xe8\\u015b\\U0001d599\\xa4', 'stderr')]) + shell.reset() + + f.write(S('t\xe8\u015b\U0001d599\xa4')) + self.assertEqual(shell.written, [('t\xe8\\u015b\\U0001d599\\xa4', 'stderr')]) + self.assertEqual(type(shell.written[0][0]), str) + shell.reset() + + self.assertRaises(TypeError, f.write) + self.assertEqual(shell.written, []) + self.assertRaises(TypeError, f.write, b'test') + self.assertRaises(TypeError, f.write, 123) + self.assertEqual(shell.written, []) + self.assertRaises(TypeError, f.write, 'test', 'spam') + self.assertEqual(shell.written, []) + + def test_writelines(self): + shell = MockShell() + f = run.StdOutputFile(shell, 'stdout') + f.writelines([]) + self.assertEqual(shell.written, []) + shell.reset() + f.writelines(['one\n', 'two']) + self.assertEqual(shell.written, + [('one\n', 'stdout'), ('two', 'stdout')]) + shell.reset() + f.writelines(['on\xe8\n', 'tw\xf2']) + self.assertEqual(shell.written, + [('on\xe8\n', 'stdout'), ('tw\xf2', 'stdout')]) + shell.reset() + + f.writelines([S('t\xe8st')]) + self.assertEqual(shell.written, [('t\xe8st', 'stdout')]) + self.assertEqual(type(shell.written[0][0]), str) + shell.reset() + + self.assertRaises(TypeError, f.writelines) + self.assertEqual(shell.written, []) + self.assertRaises(TypeError, f.writelines, 123) + self.assertEqual(shell.written, []) + self.assertRaises(TypeError, f.writelines, [b'test']) + self.assertRaises(TypeError, f.writelines, [123]) + self.assertEqual(shell.written, []) + self.assertRaises(TypeError, f.writelines, [], []) + self.assertEqual(shell.written, []) + + def test_close(self): + shell = MockShell() + f = run.StdOutputFile(shell, 'stdout') + self.assertFalse(f.closed) + f.write('test') + f.close() + self.assertTrue(f.closed) + self.assertRaises(ValueError, f.write, 'x') + self.assertEqual(shell.written, [('test', 'stdout')]) + f.close() + self.assertRaises(TypeError, f.close, 1) + + +class RecursionLimitTest(unittest.TestCase): + # Test (un)install_recursionlimit_wrappers and fixdoc. + + def test_bad_setrecursionlimit_calls(self): + run.install_recursionlimit_wrappers() + self.addCleanup(run.uninstall_recursionlimit_wrappers) + f = sys.setrecursionlimit + self.assertRaises(TypeError, f, limit=100) + self.assertRaises(TypeError, f, 100, 1000) + self.assertRaises(ValueError, f, 0) + + def test_roundtrip(self): + run.install_recursionlimit_wrappers() + self.addCleanup(run.uninstall_recursionlimit_wrappers) + + # Check that setting the recursion limit works. + orig_reclimit = sys.getrecursionlimit() + self.addCleanup(sys.setrecursionlimit, orig_reclimit) + sys.setrecursionlimit(orig_reclimit + 3) + + # Check that the new limit is returned by sys.getrecursionlimit(). + new_reclimit = sys.getrecursionlimit() + self.assertEqual(new_reclimit, orig_reclimit + 3) + + def test_default_recursion_limit_preserved(self): + orig_reclimit = sys.getrecursionlimit() + run.install_recursionlimit_wrappers() + self.addCleanup(run.uninstall_recursionlimit_wrappers) + new_reclimit = sys.getrecursionlimit() + self.assertEqual(new_reclimit, orig_reclimit) + + def test_fixdoc(self): + # Put here until better place for miscellaneous test. + def func(): "docstring" + run.fixdoc(func, "more") + self.assertEqual(func.__doc__, "docstring\n\nmore") + func.__doc__ = None + run.fixdoc(func, "more") + self.assertEqual(func.__doc__, "more") + + +class HandleErrorTest(unittest.TestCase): + # Method of MyRPCServer + def test_fatal_error(self): + eq = self.assertEqual + with captured_output('__stderr__') as err,\ + mock.patch('idlelib.run.thread.interrupt_main', + new_callable=Func) as func: + try: + raise EOFError + except EOFError: + run.MyRPCServer.handle_error(None, 'abc', '123') + eq(run.exit_now, True) + run.exit_now = False + eq(err.getvalue(), '') + + try: + raise IndexError + except IndexError: + run.MyRPCServer.handle_error(None, 'abc', '123') + eq(run.quitting, True) + run.quitting = False + msg = err.getvalue() + self.assertIn('abc', msg) + self.assertIn('123', msg) + self.assertIn('IndexError', msg) + eq(func.called, 2) + + +class ExecRuncodeTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + cls.addClassCleanup(setattr,run,'print_exception',run.print_exception) + cls.prt = Func() # Need reference. + run.print_exception = cls.prt + mockrpc = mock.Mock() + mockrpc.console.getvar = Func(result=False) + cls.ex = run.Executive(mockrpc) + + @classmethod + def tearDownClass(cls): + assert sys.excepthook == sys.__excepthook__ + + def test_exceptions(self): + ex = self.ex + ex.runcode('1/0') + self.assertIs(ex.user_exc_info[0], ZeroDivisionError) + + self.addCleanup(setattr, sys, 'excepthook', sys.__excepthook__) + sys.excepthook = lambda t, e, tb: run.print_exception(t) + ex.runcode('1/0') + self.assertIs(self.prt.args[0], ZeroDivisionError) + + sys.excepthook = lambda: None + ex.runcode('1/0') + t, e, tb = ex.user_exc_info + self.assertIs(t, TypeError) + self.assertTrue(isinstance(e.__context__, ZeroDivisionError)) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_runscript.py b/Lib/idlelib/idle_test/test_runscript.py new file mode 100644 index 000000000..5fc60185a --- /dev/null +++ b/Lib/idlelib/idle_test/test_runscript.py @@ -0,0 +1,33 @@ +"Test runscript, coverage 16%." + +from idlelib import runscript +import unittest +from test.support import requires +from tkinter import Tk +from idlelib.editor import EditorWindow + + +class ScriptBindingTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + requires('gui') + cls.root = Tk() + cls.root.withdraw() + + @classmethod + def tearDownClass(cls): + cls.root.update_idletasks() + for id in cls.root.tk.call('after', 'info'): + cls.root.after_cancel(id) # Need for EditorWindow. + cls.root.destroy() + del cls.root + + def test_init(self): + ew = EditorWindow(root=self.root) + sb = runscript.ScriptBinding(ew) + ew._close() + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_scrolledlist.py b/Lib/idlelib/idle_test/test_scrolledlist.py new file mode 100644 index 000000000..2f819fda0 --- /dev/null +++ b/Lib/idlelib/idle_test/test_scrolledlist.py @@ -0,0 +1,27 @@ +"Test scrolledlist, coverage 38%." + +from idlelib.scrolledlist import ScrolledList +import unittest +from test.support import requires +requires('gui') +from tkinter import Tk + + +class ScrolledListTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + cls.root = Tk() + + @classmethod + def tearDownClass(cls): + cls.root.destroy() + del cls.root + + + def test_init(self): + ScrolledList(self.root) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_search.py b/Lib/idlelib/idle_test/test_search.py new file mode 100644 index 000000000..de703c195 --- /dev/null +++ b/Lib/idlelib/idle_test/test_search.py @@ -0,0 +1,80 @@ +"Test search, coverage 69%." + +from idlelib import search +import unittest +from test.support import requires +requires('gui') +from tkinter import Tk, Text, BooleanVar +from idlelib import searchengine + +# Does not currently test the event handler wrappers. +# A usage test should simulate clicks and check highlighting. +# Tests need to be coordinated with SearchDialogBase tests +# to avoid duplication. + + +class SearchDialogTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + cls.root = Tk() + + @classmethod + def tearDownClass(cls): + cls.root.destroy() + del cls.root + + def setUp(self): + self.engine = searchengine.SearchEngine(self.root) + self.dialog = search.SearchDialog(self.root, self.engine) + self.dialog.bell = lambda: None + self.text = Text(self.root) + self.text.insert('1.0', 'Hello World!') + + def test_find_again(self): + # Search for various expressions + text = self.text + + self.engine.setpat('') + self.assertFalse(self.dialog.find_again(text)) + self.dialog.bell = lambda: None + + self.engine.setpat('Hello') + self.assertTrue(self.dialog.find_again(text)) + + self.engine.setpat('Goodbye') + self.assertFalse(self.dialog.find_again(text)) + + self.engine.setpat('World!') + self.assertTrue(self.dialog.find_again(text)) + + self.engine.setpat('Hello World!') + self.assertTrue(self.dialog.find_again(text)) + + # Regular expression + self.engine.revar = BooleanVar(self.root, True) + self.engine.setpat('W[aeiouy]r') + self.assertTrue(self.dialog.find_again(text)) + + def test_find_selection(self): + # Select some text and make sure it's found + text = self.text + # Add additional line to find + self.text.insert('2.0', 'Hello World!') + + text.tag_add('sel', '1.0', '1.4') # Select 'Hello' + self.assertTrue(self.dialog.find_selection(text)) + + text.tag_remove('sel', '1.0', 'end') + text.tag_add('sel', '1.6', '1.11') # Select 'World!' + self.assertTrue(self.dialog.find_selection(text)) + + text.tag_remove('sel', '1.0', 'end') + text.tag_add('sel', '1.0', '1.11') # Select 'Hello World!' + self.assertTrue(self.dialog.find_selection(text)) + + # Remove additional line + text.delete('2.0', 'end') + +if __name__ == '__main__': + unittest.main(verbosity=2, exit=2) diff --git a/Lib/idlelib/idle_test/test_searchbase.py b/Lib/idlelib/idle_test/test_searchbase.py new file mode 100644 index 000000000..8c9c410eb --- /dev/null +++ b/Lib/idlelib/idle_test/test_searchbase.py @@ -0,0 +1,160 @@ +"Test searchbase, coverage 98%." +# The only thing not covered is inconsequential -- +# testing skipping of suite when self.needwrapbutton is false. + +import unittest +from test.support import requires +from tkinter import Text, Tk, Toplevel +from tkinter.ttk import Frame +from idlelib import searchengine as se +from idlelib import searchbase as sdb +from idlelib.idle_test.mock_idle import Func +## from idlelib.idle_test.mock_tk import Var + +# The ## imports above & following could help make some tests gui-free. +# However, they currently make radiobutton tests fail. +##def setUpModule(): +## # Replace tk objects used to initialize se.SearchEngine. +## se.BooleanVar = Var +## se.StringVar = Var +## +##def tearDownModule(): +## se.BooleanVar = BooleanVar +## se.StringVar = StringVar + + +class SearchDialogBaseTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + requires('gui') + cls.root = Tk() + + @classmethod + def tearDownClass(cls): + cls.root.update_idletasks() + cls.root.destroy() + del cls.root + + def setUp(self): + self.engine = se.SearchEngine(self.root) # None also seems to work + self.dialog = sdb.SearchDialogBase(root=self.root, engine=self.engine) + + def tearDown(self): + self.dialog.close() + + def test_open_and_close(self): + # open calls create_widgets, which needs default_command + self.dialog.default_command = None + + toplevel = Toplevel(self.root) + text = Text(toplevel) + self.dialog.open(text) + self.assertEqual(self.dialog.top.state(), 'normal') + self.dialog.close() + self.assertEqual(self.dialog.top.state(), 'withdrawn') + + self.dialog.open(text, searchphrase="hello") + self.assertEqual(self.dialog.ent.get(), 'hello') + toplevel.update_idletasks() + toplevel.destroy() + + def test_create_widgets(self): + self.dialog.create_entries = Func() + self.dialog.create_option_buttons = Func() + self.dialog.create_other_buttons = Func() + self.dialog.create_command_buttons = Func() + + self.dialog.default_command = None + self.dialog.create_widgets() + + self.assertTrue(self.dialog.create_entries.called) + self.assertTrue(self.dialog.create_option_buttons.called) + self.assertTrue(self.dialog.create_other_buttons.called) + self.assertTrue(self.dialog.create_command_buttons.called) + + def test_make_entry(self): + equal = self.assertEqual + self.dialog.row = 0 + self.dialog.frame = Frame(self.root) + entry, label = self.dialog.make_entry("Test:", 'hello') + equal(label['text'], 'Test:') + + self.assertIn(entry.get(), 'hello') + egi = entry.grid_info() + equal(int(egi['row']), 0) + equal(int(egi['column']), 1) + equal(int(egi['rowspan']), 1) + equal(int(egi['columnspan']), 1) + equal(self.dialog.row, 1) + + def test_create_entries(self): + self.dialog.frame = Frame(self.root) + self.dialog.row = 0 + self.engine.setpat('hello') + self.dialog.create_entries() + self.assertIn(self.dialog.ent.get(), 'hello') + + def test_make_frame(self): + self.dialog.row = 0 + self.dialog.frame = Frame(self.root) + frame, label = self.dialog.make_frame() + self.assertEqual(label, '') + self.assertEqual(str(type(frame)), "") + # self.assertIsInstance(frame, Frame) fails when test is run by + # test_idle not run from IDLE editor. See issue 33987 PR. + + frame, label = self.dialog.make_frame('testlabel') + self.assertEqual(label['text'], 'testlabel') + + def btn_test_setup(self, meth): + self.dialog.frame = Frame(self.root) + self.dialog.row = 0 + return meth() + + def test_create_option_buttons(self): + e = self.engine + for state in (0, 1): + for var in (e.revar, e.casevar, e.wordvar, e.wrapvar): + var.set(state) + frame, options = self.btn_test_setup( + self.dialog.create_option_buttons) + for spec, button in zip (options, frame.pack_slaves()): + var, label = spec + self.assertEqual(button['text'], label) + self.assertEqual(var.get(), state) + + def test_create_other_buttons(self): + for state in (False, True): + var = self.engine.backvar + var.set(state) + frame, others = self.btn_test_setup( + self.dialog.create_other_buttons) + buttons = frame.pack_slaves() + for spec, button in zip(others, buttons): + val, label = spec + self.assertEqual(button['text'], label) + if val == state: + # hit other button, then this one + # indexes depend on button order + self.assertEqual(var.get(), state) + + def test_make_button(self): + self.dialog.frame = Frame(self.root) + self.dialog.buttonframe = Frame(self.dialog.frame) + btn = self.dialog.make_button('Test', self.dialog.close) + self.assertEqual(btn['text'], 'Test') + + def test_create_command_buttons(self): + self.dialog.frame = Frame(self.root) + self.dialog.create_command_buttons() + # Look for close button command in buttonframe + closebuttoncommand = '' + for child in self.dialog.buttonframe.winfo_children(): + if child['text'] == 'Close': + closebuttoncommand = child['command'] + self.assertIn('close', closebuttoncommand) + + +if __name__ == '__main__': + unittest.main(verbosity=2, exit=2) diff --git a/Lib/idlelib/idle_test/test_searchengine.py b/Lib/idlelib/idle_test/test_searchengine.py new file mode 100644 index 000000000..9d9798394 --- /dev/null +++ b/Lib/idlelib/idle_test/test_searchengine.py @@ -0,0 +1,332 @@ +"Test searchengine, coverage 99%." + +from idlelib import searchengine as se +import unittest +# from test.support import requires +from tkinter import BooleanVar, StringVar, TclError # ,Tk, Text +from tkinter import messagebox +from idlelib.idle_test.mock_tk import Var, Mbox +from idlelib.idle_test.mock_tk import Text as mockText +import re + +# With mock replacements, the module does not use any gui widgets. +# The use of tk.Text is avoided (for now, until mock Text is improved) +# by patching instances with an index function returning what is needed. +# This works because mock Text.get does not use .index. +# The tkinter imports are used to restore searchengine. + +def setUpModule(): + # Replace s-e module tkinter imports other than non-gui TclError. + se.BooleanVar = Var + se.StringVar = Var + se.messagebox = Mbox + +def tearDownModule(): + # Restore 'just in case', though other tests should also replace. + se.BooleanVar = BooleanVar + se.StringVar = StringVar + se.messagebox = messagebox + + +class Mock: + def __init__(self, *args, **kwargs): pass + +class GetTest(unittest.TestCase): + # SearchEngine.get returns singleton created & saved on first call. + def test_get(self): + saved_Engine = se.SearchEngine + se.SearchEngine = Mock # monkey-patch class + try: + root = Mock() + engine = se.get(root) + self.assertIsInstance(engine, se.SearchEngine) + self.assertIs(root._searchengine, engine) + self.assertIs(se.get(root), engine) + finally: + se.SearchEngine = saved_Engine # restore class to module + +class GetLineColTest(unittest.TestCase): + # Test simple text-independent helper function + def test_get_line_col(self): + self.assertEqual(se.get_line_col('1.0'), (1, 0)) + self.assertEqual(se.get_line_col('1.11'), (1, 11)) + + self.assertRaises(ValueError, se.get_line_col, ('1.0 lineend')) + self.assertRaises(ValueError, se.get_line_col, ('end')) + +class GetSelectionTest(unittest.TestCase): + # Test text-dependent helper function. +## # Need gui for text.index('sel.first/sel.last/insert'). +## @classmethod +## def setUpClass(cls): +## requires('gui') +## cls.root = Tk() +## +## @classmethod +## def tearDownClass(cls): +## cls.root.destroy() +## del cls.root + + def test_get_selection(self): + # text = Text(master=self.root) + text = mockText() + text.insert('1.0', 'Hello World!') + + # fix text.index result when called in get_selection + def sel(s): + # select entire text, cursor irrelevant + if s == 'sel.first': return '1.0' + if s == 'sel.last': return '1.12' + raise TclError + text.index = sel # replaces .tag_add('sel', '1.0, '1.12') + self.assertEqual(se.get_selection(text), ('1.0', '1.12')) + + def mark(s): + # no selection, cursor after 'Hello' + if s == 'insert': return '1.5' + raise TclError + text.index = mark # replaces .mark_set('insert', '1.5') + self.assertEqual(se.get_selection(text), ('1.5', '1.5')) + + +class ReverseSearchTest(unittest.TestCase): + # Test helper function that searches backwards within a line. + def test_search_reverse(self): + Equal = self.assertEqual + line = "Here is an 'is' test text." + prog = re.compile('is') + Equal(se.search_reverse(prog, line, len(line)).span(), (12, 14)) + Equal(se.search_reverse(prog, line, 14).span(), (12, 14)) + Equal(se.search_reverse(prog, line, 13).span(), (5, 7)) + Equal(se.search_reverse(prog, line, 7).span(), (5, 7)) + Equal(se.search_reverse(prog, line, 6), None) + + +class SearchEngineTest(unittest.TestCase): + # Test class methods that do not use Text widget. + + def setUp(self): + self.engine = se.SearchEngine(root=None) + # Engine.root is only used to create error message boxes. + # The mock replacement ignores the root argument. + + def test_is_get(self): + engine = self.engine + Equal = self.assertEqual + + Equal(engine.getpat(), '') + engine.setpat('hello') + Equal(engine.getpat(), 'hello') + + Equal(engine.isre(), False) + engine.revar.set(1) + Equal(engine.isre(), True) + + Equal(engine.iscase(), False) + engine.casevar.set(1) + Equal(engine.iscase(), True) + + Equal(engine.isword(), False) + engine.wordvar.set(1) + Equal(engine.isword(), True) + + Equal(engine.iswrap(), True) + engine.wrapvar.set(0) + Equal(engine.iswrap(), False) + + Equal(engine.isback(), False) + engine.backvar.set(1) + Equal(engine.isback(), True) + + def test_setcookedpat(self): + engine = self.engine + engine.setcookedpat(r'\s') + self.assertEqual(engine.getpat(), r'\s') + engine.revar.set(1) + engine.setcookedpat(r'\s') + self.assertEqual(engine.getpat(), r'\\s') + + def test_getcookedpat(self): + engine = self.engine + Equal = self.assertEqual + + Equal(engine.getcookedpat(), '') + engine.setpat('hello') + Equal(engine.getcookedpat(), 'hello') + engine.wordvar.set(True) + Equal(engine.getcookedpat(), r'\bhello\b') + engine.wordvar.set(False) + + engine.setpat(r'\s') + Equal(engine.getcookedpat(), r'\\s') + engine.revar.set(True) + Equal(engine.getcookedpat(), r'\s') + + def test_getprog(self): + engine = self.engine + Equal = self.assertEqual + + engine.setpat('Hello') + temppat = engine.getprog() + Equal(temppat.pattern, re.compile('Hello', re.IGNORECASE).pattern) + engine.casevar.set(1) + temppat = engine.getprog() + Equal(temppat.pattern, re.compile('Hello').pattern, 0) + + engine.setpat('') + Equal(engine.getprog(), None) + Equal(Mbox.showerror.message, + 'Error: Empty regular expression') + engine.setpat('+') + engine.revar.set(1) + Equal(engine.getprog(), None) + Equal(Mbox.showerror.message, + 'Error: nothing to repeat\nPattern: +\nOffset: 0') + + def test_report_error(self): + showerror = Mbox.showerror + Equal = self.assertEqual + pat = '[a-z' + msg = 'unexpected end of regular expression' + + Equal(self.engine.report_error(pat, msg), None) + Equal(showerror.title, 'Regular expression error') + expected_message = ("Error: " + msg + "\nPattern: [a-z") + Equal(showerror.message, expected_message) + + Equal(self.engine.report_error(pat, msg, 5), None) + Equal(showerror.title, 'Regular expression error') + expected_message += "\nOffset: 5" + Equal(showerror.message, expected_message) + + +class SearchTest(unittest.TestCase): + # Test that search_text makes right call to right method. + + @classmethod + def setUpClass(cls): +## requires('gui') +## cls.root = Tk() +## cls.text = Text(master=cls.root) + cls.text = mockText() + test_text = ( + 'First line\n' + 'Line with target\n' + 'Last line\n') + cls.text.insert('1.0', test_text) + cls.pat = re.compile('target') + + cls.engine = se.SearchEngine(None) + cls.engine.search_forward = lambda *args: ('f', args) + cls.engine.search_backward = lambda *args: ('b', args) + +## @classmethod +## def tearDownClass(cls): +## cls.root.destroy() +## del cls.root + + def test_search(self): + Equal = self.assertEqual + engine = self.engine + search = engine.search_text + text = self.text + pat = self.pat + + engine.patvar.set(None) + #engine.revar.set(pat) + Equal(search(text), None) + + def mark(s): + # no selection, cursor after 'Hello' + if s == 'insert': return '1.5' + raise TclError + text.index = mark + Equal(search(text, pat), ('f', (text, pat, 1, 5, True, False))) + engine.wrapvar.set(False) + Equal(search(text, pat), ('f', (text, pat, 1, 5, False, False))) + engine.wrapvar.set(True) + engine.backvar.set(True) + Equal(search(text, pat), ('b', (text, pat, 1, 5, True, False))) + engine.backvar.set(False) + + def sel(s): + if s == 'sel.first': return '2.10' + if s == 'sel.last': return '2.16' + raise TclError + text.index = sel + Equal(search(text, pat), ('f', (text, pat, 2, 16, True, False))) + Equal(search(text, pat, True), ('f', (text, pat, 2, 10, True, True))) + engine.backvar.set(True) + Equal(search(text, pat), ('b', (text, pat, 2, 10, True, False))) + Equal(search(text, pat, True), ('b', (text, pat, 2, 16, True, True))) + + +class ForwardBackwardTest(unittest.TestCase): + # Test that search_forward method finds the target. +## @classmethod +## def tearDownClass(cls): +## cls.root.destroy() +## del cls.root + + @classmethod + def setUpClass(cls): + cls.engine = se.SearchEngine(None) +## requires('gui') +## cls.root = Tk() +## cls.text = Text(master=cls.root) + cls.text = mockText() + # search_backward calls index('end-1c') + cls.text.index = lambda index: '4.0' + test_text = ( + 'First line\n' + 'Line with target\n' + 'Last line\n') + cls.text.insert('1.0', test_text) + cls.pat = re.compile('target') + cls.res = (2, (10, 16)) # line, slice indexes of 'target' + cls.failpat = re.compile('xyz') # not in text + cls.emptypat = re.compile(r'\w*') # empty match possible + + def make_search(self, func): + def search(pat, line, col, wrap, ok=0): + res = func(self.text, pat, line, col, wrap, ok) + # res is (line, matchobject) or None + return (res[0], res[1].span()) if res else res + return search + + def test_search_forward(self): + # search for non-empty match + Equal = self.assertEqual + forward = self.make_search(self.engine.search_forward) + pat = self.pat + Equal(forward(pat, 1, 0, True), self.res) + Equal(forward(pat, 3, 0, True), self.res) # wrap + Equal(forward(pat, 3, 0, False), None) # no wrap + Equal(forward(pat, 2, 10, False), self.res) + + Equal(forward(self.failpat, 1, 0, True), None) + Equal(forward(self.emptypat, 2, 9, True, ok=True), (2, (9, 9))) + #Equal(forward(self.emptypat, 2, 9, True), self.res) + # While the initial empty match is correctly ignored, skipping + # the rest of the line and returning (3, (0,4)) seems buggy - tjr. + Equal(forward(self.emptypat, 2, 10, True), self.res) + + def test_search_backward(self): + # search for non-empty match + Equal = self.assertEqual + backward = self.make_search(self.engine.search_backward) + pat = self.pat + Equal(backward(pat, 3, 5, True), self.res) + Equal(backward(pat, 2, 0, True), self.res) # wrap + Equal(backward(pat, 2, 0, False), None) # no wrap + Equal(backward(pat, 2, 16, False), self.res) + + Equal(backward(self.failpat, 3, 9, True), None) + Equal(backward(self.emptypat, 2, 10, True, ok=True), (2, (9,9))) + # Accepted because 9 < 10, not because ok=True. + # It is not clear that ok=True is useful going back - tjr + Equal(backward(self.emptypat, 2, 9, True), (2, (5, 9))) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_sidebar.py b/Lib/idlelib/idle_test/test_sidebar.py new file mode 100644 index 000000000..4157a4b4d --- /dev/null +++ b/Lib/idlelib/idle_test/test_sidebar.py @@ -0,0 +1,775 @@ +"""Test sidebar, coverage 85%""" +from textwrap import dedent +import sys + +from itertools import chain +import unittest +import unittest.mock +from test.support import requires, swap_attr +from test import support +import tkinter as tk +from idlelib.idle_test.tkinter_testing_utils import run_in_tk_mainloop + +from idlelib.delegator import Delegator +from idlelib.editor import fixwordbreaks +from idlelib.percolator import Percolator +import idlelib.pyshell +from idlelib.pyshell import fix_x11_paste, PyShell, PyShellFileList +from idlelib.run import fix_scaling +import idlelib.sidebar +from idlelib.sidebar import get_end_linenumber, get_lineno + + +class Dummy_editwin: + def __init__(self, text): + self.text = text + self.text_frame = self.text.master + self.per = Percolator(text) + self.undo = Delegator() + self.per.insertfilter(self.undo) + + def setvar(self, name, value): + pass + + def getlineno(self, index): + return int(float(self.text.index(index))) + + +class LineNumbersTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + requires('gui') + cls.root = tk.Tk() + cls.root.withdraw() + + cls.text_frame = tk.Frame(cls.root) + cls.text_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + cls.text_frame.rowconfigure(1, weight=1) + cls.text_frame.columnconfigure(1, weight=1) + + cls.text = tk.Text(cls.text_frame, width=80, height=24, wrap=tk.NONE) + cls.text.grid(row=1, column=1, sticky=tk.NSEW) + + cls.editwin = Dummy_editwin(cls.text) + cls.editwin.vbar = tk.Scrollbar(cls.text_frame) + + @classmethod + def tearDownClass(cls): + cls.editwin.per.close() + cls.root.update_idletasks() + cls.root.destroy() + del cls.text, cls.text_frame, cls.editwin, cls.root + + def setUp(self): + self.linenumber = idlelib.sidebar.LineNumbers(self.editwin) + + self.highlight_cfg = {"background": '#abcdef', + "foreground": '#123456'} + orig_idleConf_GetHighlight = idlelib.sidebar.idleConf.GetHighlight + def mock_idleconf_GetHighlight(theme, element): + if element == 'linenumber': + return self.highlight_cfg + return orig_idleConf_GetHighlight(theme, element) + GetHighlight_patcher = unittest.mock.patch.object( + idlelib.sidebar.idleConf, 'GetHighlight', mock_idleconf_GetHighlight) + GetHighlight_patcher.start() + self.addCleanup(GetHighlight_patcher.stop) + + self.font_override = 'TkFixedFont' + def mock_idleconf_GetFont(root, configType, section): + return self.font_override + GetFont_patcher = unittest.mock.patch.object( + idlelib.sidebar.idleConf, 'GetFont', mock_idleconf_GetFont) + GetFont_patcher.start() + self.addCleanup(GetFont_patcher.stop) + + def tearDown(self): + self.text.delete('1.0', 'end') + + def get_selection(self): + return tuple(map(str, self.text.tag_ranges('sel'))) + + def get_line_screen_position(self, line): + bbox = self.linenumber.sidebar_text.bbox(f'{line}.end -1c') + x = bbox[0] + 2 + y = bbox[1] + 2 + return x, y + + def assert_state_disabled(self): + state = self.linenumber.sidebar_text.config()['state'] + self.assertEqual(state[-1], tk.DISABLED) + + def get_sidebar_text_contents(self): + return self.linenumber.sidebar_text.get('1.0', tk.END) + + def assert_sidebar_n_lines(self, n_lines): + expected = '\n'.join(chain(map(str, range(1, n_lines + 1)), [''])) + self.assertEqual(self.get_sidebar_text_contents(), expected) + + def assert_text_equals(self, expected): + return self.assertEqual(self.text.get('1.0', 'end'), expected) + + def test_init_empty(self): + self.assert_sidebar_n_lines(1) + + def test_init_not_empty(self): + self.text.insert('insert', 'foo bar\n'*3) + self.assert_text_equals('foo bar\n'*3 + '\n') + self.assert_sidebar_n_lines(4) + + def test_toggle_linenumbering(self): + self.assertEqual(self.linenumber.is_shown, False) + self.linenumber.show_sidebar() + self.assertEqual(self.linenumber.is_shown, True) + self.linenumber.hide_sidebar() + self.assertEqual(self.linenumber.is_shown, False) + self.linenumber.hide_sidebar() + self.assertEqual(self.linenumber.is_shown, False) + self.linenumber.show_sidebar() + self.assertEqual(self.linenumber.is_shown, True) + self.linenumber.show_sidebar() + self.assertEqual(self.linenumber.is_shown, True) + + def test_insert(self): + self.text.insert('insert', 'foobar') + self.assert_text_equals('foobar\n') + self.assert_sidebar_n_lines(1) + self.assert_state_disabled() + + self.text.insert('insert', '\nfoo') + self.assert_text_equals('foobar\nfoo\n') + self.assert_sidebar_n_lines(2) + self.assert_state_disabled() + + self.text.insert('insert', 'hello\n'*2) + self.assert_text_equals('foobar\nfoohello\nhello\n\n') + self.assert_sidebar_n_lines(4) + self.assert_state_disabled() + + self.text.insert('insert', '\nworld') + self.assert_text_equals('foobar\nfoohello\nhello\n\nworld\n') + self.assert_sidebar_n_lines(5) + self.assert_state_disabled() + + def test_delete(self): + self.text.insert('insert', 'foobar') + self.assert_text_equals('foobar\n') + self.text.delete('1.1', '1.3') + self.assert_text_equals('fbar\n') + self.assert_sidebar_n_lines(1) + self.assert_state_disabled() + + self.text.insert('insert', 'foo\n'*2) + self.assert_text_equals('fbarfoo\nfoo\n\n') + self.assert_sidebar_n_lines(3) + self.assert_state_disabled() + + # Deleting up to "2.end" doesn't delete the final newline. + self.text.delete('2.0', '2.end') + self.assert_text_equals('fbarfoo\n\n\n') + self.assert_sidebar_n_lines(3) + self.assert_state_disabled() + + self.text.delete('1.3', 'end') + self.assert_text_equals('fba\n') + self.assert_sidebar_n_lines(1) + self.assert_state_disabled() + + # Text widgets always keep a single '\n' character at the end. + self.text.delete('1.0', 'end') + self.assert_text_equals('\n') + self.assert_sidebar_n_lines(1) + self.assert_state_disabled() + + def test_sidebar_text_width(self): + """ + Test that linenumber text widget is always at the minimum + width + """ + def get_width(): + return self.linenumber.sidebar_text.config()['width'][-1] + + self.assert_sidebar_n_lines(1) + self.assertEqual(get_width(), 1) + + self.text.insert('insert', 'foo') + self.assert_sidebar_n_lines(1) + self.assertEqual(get_width(), 1) + + self.text.insert('insert', 'foo\n'*8) + self.assert_sidebar_n_lines(9) + self.assertEqual(get_width(), 1) + + self.text.insert('insert', 'foo\n') + self.assert_sidebar_n_lines(10) + self.assertEqual(get_width(), 2) + + self.text.insert('insert', 'foo\n') + self.assert_sidebar_n_lines(11) + self.assertEqual(get_width(), 2) + + self.text.delete('insert -1l linestart', 'insert linestart') + self.assert_sidebar_n_lines(10) + self.assertEqual(get_width(), 2) + + self.text.delete('insert -1l linestart', 'insert linestart') + self.assert_sidebar_n_lines(9) + self.assertEqual(get_width(), 1) + + self.text.insert('insert', 'foo\n'*90) + self.assert_sidebar_n_lines(99) + self.assertEqual(get_width(), 2) + + self.text.insert('insert', 'foo\n') + self.assert_sidebar_n_lines(100) + self.assertEqual(get_width(), 3) + + self.text.insert('insert', 'foo\n') + self.assert_sidebar_n_lines(101) + self.assertEqual(get_width(), 3) + + self.text.delete('insert -1l linestart', 'insert linestart') + self.assert_sidebar_n_lines(100) + self.assertEqual(get_width(), 3) + + self.text.delete('insert -1l linestart', 'insert linestart') + self.assert_sidebar_n_lines(99) + self.assertEqual(get_width(), 2) + + self.text.delete('50.0 -1c', 'end -1c') + self.assert_sidebar_n_lines(49) + self.assertEqual(get_width(), 2) + + self.text.delete('5.0 -1c', 'end -1c') + self.assert_sidebar_n_lines(4) + self.assertEqual(get_width(), 1) + + # Text widgets always keep a single '\n' character at the end. + self.text.delete('1.0', 'end -1c') + self.assert_sidebar_n_lines(1) + self.assertEqual(get_width(), 1) + + # The following tests are temporarily disabled due to relying on + # simulated user input and inspecting which text is selected, which + # are fragile and can fail when several GUI tests are run in parallel + # or when the windows created by the test lose focus. + # + # TODO: Re-work these tests or remove them from the test suite. + + @unittest.skip('test disabled') + def test_click_selection(self): + self.linenumber.show_sidebar() + self.text.insert('1.0', 'one\ntwo\nthree\nfour\n') + self.root.update() + + # Click on the second line. + x, y = self.get_line_screen_position(2) + self.linenumber.sidebar_text.event_generate('', x=x, y=y) + self.linenumber.sidebar_text.update() + self.root.update() + + self.assertEqual(self.get_selection(), ('2.0', '3.0')) + + def simulate_drag(self, start_line, end_line): + start_x, start_y = self.get_line_screen_position(start_line) + end_x, end_y = self.get_line_screen_position(end_line) + + self.linenumber.sidebar_text.event_generate('', + x=start_x, y=start_y) + self.root.update() + + def lerp(a, b, steps): + """linearly interpolate from a to b (inclusive) in equal steps""" + last_step = steps - 1 + for i in range(steps): + yield ((last_step - i) / last_step) * a + (i / last_step) * b + + for x, y in zip( + map(int, lerp(start_x, end_x, steps=11)), + map(int, lerp(start_y, end_y, steps=11)), + ): + self.linenumber.sidebar_text.event_generate('', x=x, y=y) + self.root.update() + + self.linenumber.sidebar_text.event_generate('', + x=end_x, y=end_y) + self.root.update() + + @unittest.skip('test disabled') + def test_drag_selection_down(self): + self.linenumber.show_sidebar() + self.text.insert('1.0', 'one\ntwo\nthree\nfour\nfive\n') + self.root.update() + + # Drag from the second line to the fourth line. + self.simulate_drag(2, 4) + self.assertEqual(self.get_selection(), ('2.0', '5.0')) + + @unittest.skip('test disabled') + def test_drag_selection_up(self): + self.linenumber.show_sidebar() + self.text.insert('1.0', 'one\ntwo\nthree\nfour\nfive\n') + self.root.update() + + # Drag from the fourth line to the second line. + self.simulate_drag(4, 2) + self.assertEqual(self.get_selection(), ('2.0', '5.0')) + + def test_scroll(self): + self.linenumber.show_sidebar() + self.text.insert('1.0', 'line\n' * 100) + self.root.update() + + # Scroll down 10 lines. + self.text.yview_scroll(10, 'unit') + self.root.update() + self.assertEqual(self.text.index('@0,0'), '11.0') + self.assertEqual(self.linenumber.sidebar_text.index('@0,0'), '11.0') + + # Generate a mouse-wheel event and make sure it scrolled up or down. + # The meaning of the "delta" is OS-dependent, so this just checks for + # any change. + self.linenumber.sidebar_text.event_generate('', + x=0, y=0, + delta=10) + self.root.update() + self.assertNotEqual(self.text.index('@0,0'), '11.0') + self.assertNotEqual(self.linenumber.sidebar_text.index('@0,0'), '11.0') + + def test_font(self): + ln = self.linenumber + + orig_font = ln.sidebar_text['font'] + test_font = 'TkTextFont' + self.assertNotEqual(orig_font, test_font) + + # Ensure line numbers aren't shown. + ln.hide_sidebar() + + self.font_override = test_font + # Nothing breaks when line numbers aren't shown. + ln.update_font() + + # Activate line numbers, previous font change is immediately effective. + ln.show_sidebar() + self.assertEqual(ln.sidebar_text['font'], test_font) + + # Call the font update with line numbers shown, change is picked up. + self.font_override = orig_font + ln.update_font() + self.assertEqual(ln.sidebar_text['font'], orig_font) + + def test_highlight_colors(self): + ln = self.linenumber + + orig_colors = dict(self.highlight_cfg) + test_colors = {'background': '#222222', 'foreground': '#ffff00'} + + def assert_colors_are_equal(colors): + self.assertEqual(ln.sidebar_text['background'], colors['background']) + self.assertEqual(ln.sidebar_text['foreground'], colors['foreground']) + + # Ensure line numbers aren't shown. + ln.hide_sidebar() + + self.highlight_cfg = test_colors + # Nothing breaks with inactive line numbers. + ln.update_colors() + + # Show line numbers, previous colors change is immediately effective. + ln.show_sidebar() + assert_colors_are_equal(test_colors) + + # Call colors update with no change to the configured colors. + ln.update_colors() + assert_colors_are_equal(test_colors) + + # Call the colors update with line numbers shown, change is picked up. + self.highlight_cfg = orig_colors + ln.update_colors() + assert_colors_are_equal(orig_colors) + + +class ShellSidebarTest(unittest.TestCase): + root: tk.Tk = None + shell: PyShell = None + + @classmethod + def setUpClass(cls): + requires('gui') + + cls.root = root = tk.Tk() + root.withdraw() + + fix_scaling(root) + fixwordbreaks(root) + fix_x11_paste(root) + + cls.flist = flist = PyShellFileList(root) + # See #43981 about macosx.setupApp(root, flist) causing failure. + root.update_idletasks() + + cls.init_shell() + + @classmethod + def tearDownClass(cls): + if cls.shell is not None: + cls.shell.executing = False + cls.shell.close() + cls.shell = None + cls.flist = None + cls.root.update_idletasks() + cls.root.destroy() + cls.root = None + + @classmethod + def init_shell(cls): + cls.shell = cls.flist.open_shell() + cls.shell.pollinterval = 10 + cls.root.update() + cls.n_preface_lines = get_lineno(cls.shell.text, 'end-1c') - 1 + + @classmethod + def reset_shell(cls): + cls.shell.per.bottom.delete(f'{cls.n_preface_lines+1}.0', 'end-1c') + cls.shell.shell_sidebar.update_sidebar() + cls.root.update() + + def setUp(self): + # In some test environments, e.g. Azure Pipelines (as of + # Apr. 2021), sys.stdout is changed between tests. However, + # PyShell relies on overriding sys.stdout when run without a + # sub-process (as done here; see setUpClass). + self._saved_stdout = None + if sys.stdout != self.shell.stdout: + self._saved_stdout = sys.stdout + sys.stdout = self.shell.stdout + + self.reset_shell() + + def tearDown(self): + if self._saved_stdout is not None: + sys.stdout = self._saved_stdout + + def get_sidebar_lines(self): + canvas = self.shell.shell_sidebar.canvas + texts = list(canvas.find(tk.ALL)) + texts_by_y_coords = { + canvas.bbox(text)[1]: canvas.itemcget(text, 'text') + for text in texts + } + line_y_coords = self.get_shell_line_y_coords() + return [texts_by_y_coords.get(y, None) for y in line_y_coords] + + def assert_sidebar_lines_end_with(self, expected_lines): + self.shell.shell_sidebar.update_sidebar() + self.assertEqual( + self.get_sidebar_lines()[-len(expected_lines):], + expected_lines, + ) + + def get_shell_line_y_coords(self): + text = self.shell.text + y_coords = [] + index = text.index("@0,0") + if index.split('.', 1)[1] != '0': + index = text.index(f"{index} +1line linestart") + while (lineinfo := text.dlineinfo(index)) is not None: + y_coords.append(lineinfo[1]) + index = text.index(f"{index} +1line") + return y_coords + + def get_sidebar_line_y_coords(self): + canvas = self.shell.shell_sidebar.canvas + texts = list(canvas.find(tk.ALL)) + texts.sort(key=lambda text: canvas.bbox(text)[1]) + return [canvas.bbox(text)[1] for text in texts] + + def assert_sidebar_lines_synced(self): + self.assertLessEqual( + set(self.get_sidebar_line_y_coords()), + set(self.get_shell_line_y_coords()), + ) + + def do_input(self, input): + shell = self.shell + text = shell.text + for line_index, line in enumerate(input.split('\n')): + if line_index > 0: + text.event_generate('<>') + text.insert('insert', line, 'stdin') + + def test_initial_state(self): + sidebar_lines = self.get_sidebar_lines() + self.assertEqual( + sidebar_lines, + [None] * (len(sidebar_lines) - 1) + ['>>>'], + ) + self.assert_sidebar_lines_synced() + + @run_in_tk_mainloop() + def test_single_empty_input(self): + self.do_input('\n') + yield + self.assert_sidebar_lines_end_with(['>>>', '>>>']) + + @run_in_tk_mainloop() + def test_single_line_statement(self): + self.do_input('1\n') + yield + self.assert_sidebar_lines_end_with(['>>>', None, '>>>']) + + @run_in_tk_mainloop() + def test_multi_line_statement(self): + # Block statements are not indented because IDLE auto-indents. + self.do_input(dedent('''\ + if True: + print(1) + + ''')) + yield + self.assert_sidebar_lines_end_with([ + '>>>', + '...', + '...', + '...', + None, + '>>>', + ]) + + @run_in_tk_mainloop() + def test_single_long_line_wraps(self): + self.do_input('1' * 200 + '\n') + yield + self.assert_sidebar_lines_end_with(['>>>', None, '>>>']) + self.assert_sidebar_lines_synced() + + @run_in_tk_mainloop() + def test_squeeze_multi_line_output(self): + shell = self.shell + text = shell.text + + self.do_input('print("a\\nb\\nc")\n') + yield + self.assert_sidebar_lines_end_with(['>>>', None, None, None, '>>>']) + + text.mark_set('insert', f'insert -1line linestart') + text.event_generate('<>') + yield + self.assert_sidebar_lines_end_with(['>>>', None, '>>>']) + self.assert_sidebar_lines_synced() + + shell.squeezer.expandingbuttons[0].expand() + yield + self.assert_sidebar_lines_end_with(['>>>', None, None, None, '>>>']) + self.assert_sidebar_lines_synced() + + @run_in_tk_mainloop() + def test_interrupt_recall_undo_redo(self): + text = self.shell.text + # Block statements are not indented because IDLE auto-indents. + initial_sidebar_lines = self.get_sidebar_lines() + + self.do_input(dedent('''\ + if True: + print(1) + ''')) + yield + self.assert_sidebar_lines_end_with(['>>>', '...', '...']) + with_block_sidebar_lines = self.get_sidebar_lines() + self.assertNotEqual(with_block_sidebar_lines, initial_sidebar_lines) + + # Control-C + text.event_generate('<>') + yield + self.assert_sidebar_lines_end_with(['>>>', '...', '...', None, '>>>']) + + # Recall previous via history + text.event_generate('<>') + text.event_generate('<>') + yield + self.assert_sidebar_lines_end_with(['>>>', '...', None, '>>>']) + + # Recall previous via recall + text.mark_set('insert', text.index('insert -2l')) + text.event_generate('<>') + yield + + text.event_generate('<>') + yield + self.assert_sidebar_lines_end_with(['>>>']) + + text.event_generate('<>') + yield + self.assert_sidebar_lines_end_with(['>>>', '...']) + + text.event_generate('<>') + text.event_generate('<>') + yield + self.assert_sidebar_lines_end_with( + ['>>>', '...', '...', '...', None, '>>>'] + ) + + @run_in_tk_mainloop() + def test_very_long_wrapped_line(self): + with support.adjust_int_max_str_digits(11_111), \ + swap_attr(self.shell, 'squeezer', None): + self.do_input('x = ' + '1'*10_000 + '\n') + yield + self.assertEqual(self.get_sidebar_lines(), ['>>>']) + + def test_font(self): + sidebar = self.shell.shell_sidebar + + test_font = 'TkTextFont' + + def mock_idleconf_GetFont(root, configType, section): + return test_font + GetFont_patcher = unittest.mock.patch.object( + idlelib.sidebar.idleConf, 'GetFont', mock_idleconf_GetFont) + GetFont_patcher.start() + def cleanup(): + GetFont_patcher.stop() + sidebar.update_font() + self.addCleanup(cleanup) + + def get_sidebar_font(): + canvas = sidebar.canvas + texts = list(canvas.find(tk.ALL)) + fonts = {canvas.itemcget(text, 'font') for text in texts} + self.assertEqual(len(fonts), 1) + return next(iter(fonts)) + + self.assertNotEqual(get_sidebar_font(), test_font) + sidebar.update_font() + self.assertEqual(get_sidebar_font(), test_font) + + def test_highlight_colors(self): + sidebar = self.shell.shell_sidebar + + test_colors = {"background": '#abcdef', "foreground": '#123456'} + + orig_idleConf_GetHighlight = idlelib.sidebar.idleConf.GetHighlight + def mock_idleconf_GetHighlight(theme, element): + if element in ['linenumber', 'console']: + return test_colors + return orig_idleConf_GetHighlight(theme, element) + GetHighlight_patcher = unittest.mock.patch.object( + idlelib.sidebar.idleConf, 'GetHighlight', + mock_idleconf_GetHighlight) + GetHighlight_patcher.start() + def cleanup(): + GetHighlight_patcher.stop() + sidebar.update_colors() + self.addCleanup(cleanup) + + def get_sidebar_colors(): + canvas = sidebar.canvas + texts = list(canvas.find(tk.ALL)) + fgs = {canvas.itemcget(text, 'fill') for text in texts} + self.assertEqual(len(fgs), 1) + fg = next(iter(fgs)) + bg = canvas.cget('background') + return {"background": bg, "foreground": fg} + + self.assertNotEqual(get_sidebar_colors(), test_colors) + sidebar.update_colors() + self.assertEqual(get_sidebar_colors(), test_colors) + + @run_in_tk_mainloop() + def test_mousewheel(self): + sidebar = self.shell.shell_sidebar + text = self.shell.text + + # Enter a 100-line string to scroll the shell screen down. + self.do_input('x = """' + '\n'*100 + '"""\n') + yield + self.assertGreater(get_lineno(text, '@0,0'), 1) + + last_lineno = get_end_linenumber(text) + self.assertIsNotNone(text.dlineinfo(text.index(f'{last_lineno}.0'))) + + # Delta for , whose meaning is platform-dependent. + delta = 1 if sidebar.canvas._windowingsystem == 'aqua' else 120 + + # Scroll up. + if sidebar.canvas._windowingsystem == 'x11': + sidebar.canvas.event_generate('', x=0, y=0) + else: + sidebar.canvas.event_generate('', x=0, y=0, delta=delta) + yield + self.assertIsNone(text.dlineinfo(text.index(f'{last_lineno}.0'))) + + # Scroll back down. + if sidebar.canvas._windowingsystem == 'x11': + sidebar.canvas.event_generate('', x=0, y=0) + else: + sidebar.canvas.event_generate('', x=0, y=0, delta=-delta) + yield + self.assertIsNotNone(text.dlineinfo(text.index(f'{last_lineno}.0'))) + + @run_in_tk_mainloop() + def test_copy(self): + sidebar = self.shell.shell_sidebar + text = self.shell.text + + first_line = get_end_linenumber(text) + + self.do_input(dedent('''\ + if True: + print(1) + + ''')) + yield + + text.tag_add('sel', f'{first_line}.0', 'end-1c') + selected_text = text.get('sel.first', 'sel.last') + self.assertStartsWith(selected_text, 'if True:\n') + self.assertIn('\n1\n', selected_text) + + text.event_generate('<>') + self.addCleanup(text.clipboard_clear) + + copied_text = text.clipboard_get() + self.assertEqual(copied_text, selected_text) + + @run_in_tk_mainloop() + def test_copy_with_prompts(self): + sidebar = self.shell.shell_sidebar + text = self.shell.text + + first_line = get_end_linenumber(text) + self.do_input(dedent('''\ + if True: + print(1) + + ''')) + yield + + text.tag_add('sel', f'{first_line}.3', 'end-1c') + selected_text = text.get('sel.first', 'sel.last') + self.assertStartsWith(selected_text, 'True:\n') + + selected_lines_text = text.get('sel.first linestart', 'sel.last') + selected_lines = selected_lines_text.split('\n') + selected_lines.pop() # Final '' is a split artifact, not a line. + # Expect a block of input and a single output line. + expected_prompts = \ + ['>>>'] + ['...'] * (len(selected_lines) - 2) + [None] + selected_text_with_prompts = '\n'.join( + line if prompt is None else prompt + ' ' + line + for prompt, line in zip(expected_prompts, + selected_lines, + strict=True) + ) + '\n' + + text.event_generate('<>') + self.addCleanup(text.clipboard_clear) + + copied_text = text.clipboard_get() + self.assertEqual(copied_text, selected_text_with_prompts) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_squeezer.py b/Lib/idlelib/idle_test/test_squeezer.py new file mode 100644 index 000000000..86c21f00b --- /dev/null +++ b/Lib/idlelib/idle_test/test_squeezer.py @@ -0,0 +1,467 @@ +"Test squeezer, coverage 95%" + +from textwrap import dedent +from tkinter import Text, Tk +import unittest +from unittest.mock import Mock, NonCallableMagicMock, patch, sentinel, ANY +from test.support import requires + +from idlelib.config import idleConf +from idlelib.percolator import Percolator +from idlelib.squeezer import count_lines_with_wrapping, ExpandingButton, \ + Squeezer +from idlelib import macosx +from idlelib.textview import view_text +from idlelib.tooltip import Hovertip + +SENTINEL_VALUE = sentinel.SENTINEL_VALUE + + +def get_test_tk_root(test_instance): + """Helper for tests: Create a root Tk object.""" + requires('gui') + root = Tk() + root.withdraw() + + def cleanup_root(): + root.update_idletasks() + root.destroy() + test_instance.addCleanup(cleanup_root) + + return root + + +class CountLinesTest(unittest.TestCase): + """Tests for the count_lines_with_wrapping function.""" + def check(self, expected, text, linewidth): + return self.assertEqual( + expected, + count_lines_with_wrapping(text, linewidth), + ) + + def test_count_empty(self): + """Test with an empty string.""" + self.assertEqual(count_lines_with_wrapping(""), 0) + + def test_count_begins_with_empty_line(self): + """Test with a string which begins with a newline.""" + self.assertEqual(count_lines_with_wrapping("\ntext"), 2) + + def test_count_ends_with_empty_line(self): + """Test with a string which ends with a newline.""" + self.assertEqual(count_lines_with_wrapping("text\n"), 1) + + def test_count_several_lines(self): + """Test with several lines of text.""" + self.assertEqual(count_lines_with_wrapping("1\n2\n3\n"), 3) + + def test_empty_lines(self): + self.check(expected=1, text='\n', linewidth=80) + self.check(expected=2, text='\n\n', linewidth=80) + self.check(expected=10, text='\n' * 10, linewidth=80) + + def test_long_line(self): + self.check(expected=3, text='a' * 200, linewidth=80) + self.check(expected=3, text='a' * 200 + '\n', linewidth=80) + + def test_several_lines_different_lengths(self): + text = dedent("""\ + 13 characters + 43 is the number of characters on this line + + 7 chars + 13 characters""") + self.check(expected=5, text=text, linewidth=80) + self.check(expected=5, text=text + '\n', linewidth=80) + self.check(expected=6, text=text, linewidth=40) + self.check(expected=7, text=text, linewidth=20) + self.check(expected=11, text=text, linewidth=10) + + +class SqueezerTest(unittest.TestCase): + """Tests for the Squeezer class.""" + def make_mock_editor_window(self, with_text_widget=False): + """Create a mock EditorWindow instance.""" + editwin = NonCallableMagicMock() + editwin.width = 80 + + if with_text_widget: + editwin.root = get_test_tk_root(self) + text_widget = self.make_text_widget(root=editwin.root) + editwin.text = editwin.per.bottom = text_widget + + return editwin + + def make_squeezer_instance(self, editor_window=None): + """Create an actual Squeezer instance with a mock EditorWindow.""" + if editor_window is None: + editor_window = self.make_mock_editor_window() + squeezer = Squeezer(editor_window) + return squeezer + + def make_text_widget(self, root=None): + if root is None: + root = get_test_tk_root(self) + text_widget = Text(root) + text_widget["font"] = ('Courier', 10) + text_widget.mark_set("iomark", "1.0") + return text_widget + + def set_idleconf_option_with_cleanup(self, configType, section, option, value): + prev_val = idleConf.GetOption(configType, section, option) + idleConf.SetOption(configType, section, option, value) + self.addCleanup(idleConf.SetOption, + configType, section, option, prev_val) + + def test_count_lines(self): + """Test Squeezer.count_lines() with various inputs.""" + editwin = self.make_mock_editor_window() + squeezer = self.make_squeezer_instance(editwin) + + for text_code, line_width, expected in [ + (r"'\n'", 80, 1), + (r"'\n' * 3", 80, 3), + (r"'a' * 40 + '\n'", 80, 1), + (r"'a' * 80 + '\n'", 80, 1), + (r"'a' * 200 + '\n'", 80, 3), + (r"'aa\t' * 20", 80, 2), + (r"'aa\t' * 21", 80, 3), + (r"'aa\t' * 20", 40, 4), + ]: + with self.subTest(text_code=text_code, + line_width=line_width, + expected=expected): + text = eval(text_code) + with patch.object(editwin, 'width', line_width): + self.assertEqual(squeezer.count_lines(text), expected) + + def test_init(self): + """Test the creation of Squeezer instances.""" + editwin = self.make_mock_editor_window() + squeezer = self.make_squeezer_instance(editwin) + self.assertIs(squeezer.editwin, editwin) + self.assertEqual(squeezer.expandingbuttons, []) + + def test_write_no_tags(self): + """Test Squeezer's overriding of the EditorWindow's write() method.""" + editwin = self.make_mock_editor_window() + for text in ['', 'TEXT', 'LONG TEXT' * 1000, 'MANY_LINES\n' * 100]: + editwin.write = orig_write = Mock(return_value=SENTINEL_VALUE) + squeezer = self.make_squeezer_instance(editwin) + + self.assertEqual(squeezer.editwin.write(text, ()), SENTINEL_VALUE) + self.assertEqual(orig_write.call_count, 1) + orig_write.assert_called_with(text, ()) + self.assertEqual(len(squeezer.expandingbuttons), 0) + + def test_write_not_stdout(self): + """Test Squeezer's overriding of the EditorWindow's write() method.""" + for text in ['', 'TEXT', 'LONG TEXT' * 1000, 'MANY_LINES\n' * 100]: + editwin = self.make_mock_editor_window() + editwin.write.return_value = SENTINEL_VALUE + orig_write = editwin.write + squeezer = self.make_squeezer_instance(editwin) + + self.assertEqual(squeezer.editwin.write(text, "stderr"), + SENTINEL_VALUE) + self.assertEqual(orig_write.call_count, 1) + orig_write.assert_called_with(text, "stderr") + self.assertEqual(len(squeezer.expandingbuttons), 0) + + def test_write_stdout(self): + """Test Squeezer's overriding of the EditorWindow's write() method.""" + requires('gui') + editwin = self.make_mock_editor_window() + + for text in ['', 'TEXT']: + editwin.write = orig_write = Mock(return_value=SENTINEL_VALUE) + squeezer = self.make_squeezer_instance(editwin) + squeezer.auto_squeeze_min_lines = 50 + + self.assertEqual(squeezer.editwin.write(text, "stdout"), + SENTINEL_VALUE) + self.assertEqual(orig_write.call_count, 1) + orig_write.assert_called_with(text, "stdout") + self.assertEqual(len(squeezer.expandingbuttons), 0) + + for text in ['LONG TEXT' * 1000, 'MANY_LINES\n' * 100]: + editwin.write = orig_write = Mock(return_value=SENTINEL_VALUE) + squeezer = self.make_squeezer_instance(editwin) + squeezer.auto_squeeze_min_lines = 50 + + self.assertEqual(squeezer.editwin.write(text, "stdout"), None) + self.assertEqual(orig_write.call_count, 0) + self.assertEqual(len(squeezer.expandingbuttons), 1) + + def test_auto_squeeze(self): + """Test that the auto-squeezing creates an ExpandingButton properly.""" + editwin = self.make_mock_editor_window(with_text_widget=True) + text_widget = editwin.text + squeezer = self.make_squeezer_instance(editwin) + squeezer.auto_squeeze_min_lines = 5 + squeezer.count_lines = Mock(return_value=6) + + editwin.write('TEXT\n'*6, "stdout") + self.assertEqual(text_widget.get('1.0', 'end'), '\n') + self.assertEqual(len(squeezer.expandingbuttons), 1) + + def test_squeeze_current_text(self): + """Test the squeeze_current_text method.""" + # Squeezing text should work for both stdout and stderr. + for tag_name in ["stdout", "stderr"]: + editwin = self.make_mock_editor_window(with_text_widget=True) + text_widget = editwin.text + squeezer = self.make_squeezer_instance(editwin) + squeezer.count_lines = Mock(return_value=6) + + # Prepare some text in the Text widget. + text_widget.insert("1.0", "SOME\nTEXT\n", tag_name) + text_widget.mark_set("insert", "1.0") + self.assertEqual(text_widget.get('1.0', 'end'), 'SOME\nTEXT\n\n') + + self.assertEqual(len(squeezer.expandingbuttons), 0) + + # Test squeezing the current text. + retval = squeezer.squeeze_current_text() + self.assertEqual(retval, "break") + self.assertEqual(text_widget.get('1.0', 'end'), '\n\n') + self.assertEqual(len(squeezer.expandingbuttons), 1) + self.assertEqual(squeezer.expandingbuttons[0].s, 'SOME\nTEXT') + + # Test that expanding the squeezed text works and afterwards + # the Text widget contains the original text. + squeezer.expandingbuttons[0].expand() + self.assertEqual(text_widget.get('1.0', 'end'), 'SOME\nTEXT\n\n') + self.assertEqual(len(squeezer.expandingbuttons), 0) + + def test_squeeze_current_text_no_allowed_tags(self): + """Test that the event doesn't squeeze text without a relevant tag.""" + editwin = self.make_mock_editor_window(with_text_widget=True) + text_widget = editwin.text + squeezer = self.make_squeezer_instance(editwin) + squeezer.count_lines = Mock(return_value=6) + + # Prepare some text in the Text widget. + text_widget.insert("1.0", "SOME\nTEXT\n", "TAG") + text_widget.mark_set("insert", "1.0") + self.assertEqual(text_widget.get('1.0', 'end'), 'SOME\nTEXT\n\n') + + self.assertEqual(len(squeezer.expandingbuttons), 0) + + # Test squeezing the current text. + retval = squeezer.squeeze_current_text() + self.assertEqual(retval, "break") + self.assertEqual(text_widget.get('1.0', 'end'), 'SOME\nTEXT\n\n') + self.assertEqual(len(squeezer.expandingbuttons), 0) + + def test_squeeze_text_before_existing_squeezed_text(self): + """Test squeezing text before existing squeezed text.""" + editwin = self.make_mock_editor_window(with_text_widget=True) + text_widget = editwin.text + squeezer = self.make_squeezer_instance(editwin) + squeezer.count_lines = Mock(return_value=6) + + # Prepare some text in the Text widget and squeeze it. + text_widget.insert("1.0", "SOME\nTEXT\n", "stdout") + text_widget.mark_set("insert", "1.0") + squeezer.squeeze_current_text() + self.assertEqual(len(squeezer.expandingbuttons), 1) + + # Test squeezing the current text. + text_widget.insert("1.0", "MORE\nSTUFF\n", "stdout") + text_widget.mark_set("insert", "1.0") + retval = squeezer.squeeze_current_text() + self.assertEqual(retval, "break") + self.assertEqual(text_widget.get('1.0', 'end'), '\n\n\n') + self.assertEqual(len(squeezer.expandingbuttons), 2) + self.assertTrue(text_widget.compare( + squeezer.expandingbuttons[0], + '<', + squeezer.expandingbuttons[1], + )) + + def test_reload(self): + """Test the reload() class-method.""" + editwin = self.make_mock_editor_window(with_text_widget=True) + squeezer = self.make_squeezer_instance(editwin) + + orig_auto_squeeze_min_lines = squeezer.auto_squeeze_min_lines + + # Increase auto-squeeze-min-lines. + new_auto_squeeze_min_lines = orig_auto_squeeze_min_lines + 10 + self.set_idleconf_option_with_cleanup( + 'main', 'PyShell', 'auto-squeeze-min-lines', + str(new_auto_squeeze_min_lines)) + + Squeezer.reload() + self.assertEqual(squeezer.auto_squeeze_min_lines, + new_auto_squeeze_min_lines) + + def test_reload_no_squeezer_instances(self): + """Test that Squeezer.reload() runs without any instances existing.""" + Squeezer.reload() + + +class ExpandingButtonTest(unittest.TestCase): + """Tests for the ExpandingButton class.""" + # In these tests the squeezer instance is a mock, but actual tkinter + # Text and Button instances are created. + def make_mock_squeezer(self): + """Helper for tests: Create a mock Squeezer object.""" + root = get_test_tk_root(self) + squeezer = Mock() + squeezer.editwin.text = Text(root) + squeezer.editwin.per = Percolator(squeezer.editwin.text) + self.addCleanup(squeezer.editwin.per.close) + + # Set default values for the configuration settings. + squeezer.auto_squeeze_min_lines = 50 + return squeezer + + @patch('idlelib.squeezer.Hovertip', autospec=Hovertip) + def test_init(self, MockHovertip): + """Test the simplest creation of an ExpandingButton.""" + squeezer = self.make_mock_squeezer() + text_widget = squeezer.editwin.text + + expandingbutton = ExpandingButton('TEXT', 'TAGS', 50, squeezer) + self.assertEqual(expandingbutton.s, 'TEXT') + + # Check that the underlying tkinter.Button is properly configured. + self.assertEqual(expandingbutton.master, text_widget) + self.assertTrue('50 lines' in expandingbutton.cget('text')) + + # Check that the text widget still contains no text. + self.assertEqual(text_widget.get('1.0', 'end'), '\n') + + # Check that the mouse events are bound. + self.assertIn('', expandingbutton.bind()) + right_button_code = '' % ('2' if macosx.isAquaTk() else '3') + self.assertIn(right_button_code, expandingbutton.bind()) + + # Check that ToolTip was called once, with appropriate values. + self.assertEqual(MockHovertip.call_count, 1) + MockHovertip.assert_called_with(expandingbutton, ANY, hover_delay=ANY) + + # Check that 'right-click' appears in the tooltip text. + tooltip_text = MockHovertip.call_args[0][1] + self.assertIn('right-click', tooltip_text.lower()) + + def test_expand(self): + """Test the expand event.""" + squeezer = self.make_mock_squeezer() + expandingbutton = ExpandingButton('TEXT', 'TAGS', 50, squeezer) + + # Insert the button into the text widget + # (this is normally done by the Squeezer class). + text_widget = squeezer.editwin.text + text_widget.window_create("1.0", window=expandingbutton) + + # trigger the expand event + retval = expandingbutton.expand(event=Mock()) + self.assertEqual(retval, None) + + # Check that the text was inserted into the text widget. + self.assertEqual(text_widget.get('1.0', 'end'), 'TEXT\n') + + # Check that the 'TAGS' tag was set on the inserted text. + text_end_index = text_widget.index('end-1c') + self.assertEqual(text_widget.get('1.0', text_end_index), 'TEXT') + self.assertEqual(text_widget.tag_nextrange('TAGS', '1.0'), + ('1.0', text_end_index)) + + # Check that the button removed itself from squeezer.expandingbuttons. + self.assertEqual(squeezer.expandingbuttons.remove.call_count, 1) + squeezer.expandingbuttons.remove.assert_called_with(expandingbutton) + + def test_expand_dangerous_oupput(self): + """Test that expanding very long output asks user for confirmation.""" + squeezer = self.make_mock_squeezer() + text = 'a' * 10**5 + expandingbutton = ExpandingButton(text, 'TAGS', 50, squeezer) + expandingbutton.set_is_dangerous() + self.assertTrue(expandingbutton.is_dangerous) + + # Insert the button into the text widget + # (this is normally done by the Squeezer class). + text_widget = expandingbutton.text + text_widget.window_create("1.0", window=expandingbutton) + + # Patch the message box module to always return False. + with patch('idlelib.squeezer.messagebox') as mock_msgbox: + mock_msgbox.askokcancel.return_value = False + mock_msgbox.askyesno.return_value = False + # Trigger the expand event. + retval = expandingbutton.expand(event=Mock()) + + # Check that the event chain was broken and no text was inserted. + self.assertEqual(retval, 'break') + self.assertEqual(expandingbutton.text.get('1.0', 'end-1c'), '') + + # Patch the message box module to always return True. + with patch('idlelib.squeezer.messagebox') as mock_msgbox: + mock_msgbox.askokcancel.return_value = True + mock_msgbox.askyesno.return_value = True + # Trigger the expand event. + retval = expandingbutton.expand(event=Mock()) + + # Check that the event chain wasn't broken and the text was inserted. + self.assertEqual(retval, None) + self.assertEqual(expandingbutton.text.get('1.0', 'end-1c'), text) + + def test_copy(self): + """Test the copy event.""" + # Testing with the actual clipboard proved problematic, so this + # test replaces the clipboard manipulation functions with mocks + # and checks that they are called appropriately. + squeezer = self.make_mock_squeezer() + expandingbutton = ExpandingButton('TEXT', 'TAGS', 50, squeezer) + expandingbutton.clipboard_clear = Mock() + expandingbutton.clipboard_append = Mock() + + # Trigger the copy event. + retval = expandingbutton.copy(event=Mock()) + self.assertEqual(retval, None) + + # Vheck that the expanding button called clipboard_clear() and + # clipboard_append('TEXT') once each. + self.assertEqual(expandingbutton.clipboard_clear.call_count, 1) + self.assertEqual(expandingbutton.clipboard_append.call_count, 1) + expandingbutton.clipboard_append.assert_called_with('TEXT') + + def test_view(self): + """Test the view event.""" + squeezer = self.make_mock_squeezer() + expandingbutton = ExpandingButton('TEXT', 'TAGS', 50, squeezer) + expandingbutton.selection_own = Mock() + + with patch('idlelib.squeezer.view_text', autospec=view_text)\ + as mock_view_text: + # Trigger the view event. + expandingbutton.view(event=Mock()) + + # Check that the expanding button called view_text. + self.assertEqual(mock_view_text.call_count, 1) + + # Check that the proper text was passed. + self.assertEqual(mock_view_text.call_args[0][2], 'TEXT') + + def test_rmenu(self): + """Test the context menu.""" + squeezer = self.make_mock_squeezer() + expandingbutton = ExpandingButton('TEXT', 'TAGS', 50, squeezer) + with patch('tkinter.Menu') as mock_Menu: + mock_menu = Mock() + mock_Menu.return_value = mock_menu + mock_event = Mock() + mock_event.x = 10 + mock_event.y = 10 + expandingbutton.context_menu_event(event=mock_event) + self.assertEqual(mock_menu.add_command.call_count, + len(expandingbutton.rmenu_specs)) + for label, *data in expandingbutton.rmenu_specs: + mock_menu.add_command.assert_any_call(label=label, command=ANY) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_stackviewer.py b/Lib/idlelib/idle_test/test_stackviewer.py new file mode 100644 index 000000000..55f510382 --- /dev/null +++ b/Lib/idlelib/idle_test/test_stackviewer.py @@ -0,0 +1,41 @@ +"Test stackviewer, coverage 63%." + +from idlelib import stackviewer +import unittest +from test.support import requires +from tkinter import Tk + +from idlelib.tree import TreeNode, ScrolledCanvas + + +class StackBrowserTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + + requires('gui') + cls.root = Tk() + cls.root.withdraw() + + @classmethod + def tearDownClass(cls): + + cls.root.update_idletasks() +## for id in cls.root.tk.call('after', 'info'): +## cls.root.after_cancel(id) # Need for EditorWindow. + cls.root.destroy() + del cls.root + + def test_init(self): + try: + abc + except NameError as exc: + sb = stackviewer.StackBrowser(self.root, exc) + isi = self.assertIsInstance + isi(stackviewer.sc, ScrolledCanvas) + isi(stackviewer.item, stackviewer.StackTreeItem) + isi(stackviewer.node, TreeNode) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_statusbar.py b/Lib/idlelib/idle_test/test_statusbar.py new file mode 100644 index 000000000..203a57db8 --- /dev/null +++ b/Lib/idlelib/idle_test/test_statusbar.py @@ -0,0 +1,41 @@ +"Test statusbar, coverage 100%." + +from idlelib import statusbar +import unittest +from test.support import requires +from tkinter import Tk + + +class Test(unittest.TestCase): + + @classmethod + def setUpClass(cls): + requires('gui') + cls.root = Tk() + cls.root.withdraw() + + @classmethod + def tearDownClass(cls): + cls.root.update_idletasks() + cls.root.destroy() + del cls.root + + def test_init(self): + bar = statusbar.MultiStatusBar(self.root) + self.assertEqual(bar.labels, {}) + + def test_set_label(self): + bar = statusbar.MultiStatusBar(self.root) + bar.set_label('left', text='sometext', width=10) + self.assertIn('left', bar.labels) + left = bar.labels['left'] + self.assertEqual(left['text'], 'sometext') + self.assertEqual(left['width'], 10) + bar.set_label('left', text='revised text') + self.assertEqual(left['text'], 'revised text') + bar.set_label('right', text='correct text') + self.assertEqual(bar.labels['right']['text'], 'correct text') + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_text.py b/Lib/idlelib/idle_test/test_text.py new file mode 100644 index 000000000..43a9ba02c --- /dev/null +++ b/Lib/idlelib/idle_test/test_text.py @@ -0,0 +1,236 @@ +''' Test mock_tk.Text class against tkinter.Text class + +Run same tests with both by creating a mixin class. +''' +import unittest +from test.support import requires +from _tkinter import TclError + +class TextTest: + "Define items common to both sets of tests." + + hw = 'hello\nworld' # Several tests insert this after initialization. + hwn = hw+'\n' # \n present at initialization, before insert + + # setUpClass defines cls.Text and maybe cls.root. + # setUp defines self.text from Text and maybe root. + + def test_init(self): + self.assertEqual(self.text.get('1.0'), '\n') + self.assertEqual(self.text.get('end'), '') + + def test_index_empty(self): + index = self.text.index + + for dex in (-1.0, 0.3, '1.-1', '1.0', '1.0 lineend', '1.end', '1.33', + 'insert'): + self.assertEqual(index(dex), '1.0') + + for dex in 'end', 2.0, '2.1', '33.44': + self.assertEqual(index(dex), '2.0') + + def test_index_data(self): + index = self.text.index + self.text.insert('1.0', self.hw) + + for dex in -1.0, 0.3, '1.-1', '1.0': + self.assertEqual(index(dex), '1.0') + + for dex in '1.0 lineend', '1.end', '1.33': + self.assertEqual(index(dex), '1.5') + + for dex in 'end', '33.44': + self.assertEqual(index(dex), '3.0') + + def test_get(self): + get = self.text.get + Equal = self.assertEqual + self.text.insert('1.0', self.hw) + + Equal(get('end'), '') + Equal(get('end', 'end'), '') + Equal(get('1.0'), 'h') + Equal(get('1.0', '1.1'), 'h') + Equal(get('1.0', '1.3'), 'hel') + Equal(get('1.1', '1.3'), 'el') + Equal(get('1.0', '1.0 lineend'), 'hello') + Equal(get('1.0', '1.10'), 'hello') + Equal(get('1.0 lineend'), '\n') + Equal(get('1.1', '2.3'), 'ello\nwor') + Equal(get('1.0', '2.5'), self.hw) + Equal(get('1.0', 'end'), self.hwn) + Equal(get('0.0', '5.0'), self.hwn) + + def test_insert(self): + insert = self.text.insert + get = self.text.get + Equal = self.assertEqual + + insert('1.0', self.hw) + Equal(get('1.0', 'end'), self.hwn) + + insert('1.0', '') # nothing + Equal(get('1.0', 'end'), self.hwn) + + insert('1.0', '*') + Equal(get('1.0', 'end'), '*hello\nworld\n') + + insert('1.0 lineend', '*') + Equal(get('1.0', 'end'), '*hello*\nworld\n') + + insert('2.3', '*') + Equal(get('1.0', 'end'), '*hello*\nwor*ld\n') + + insert('end', 'x') + Equal(get('1.0', 'end'), '*hello*\nwor*ldx\n') + + insert('1.4', 'x\n') + Equal(get('1.0', 'end'), '*helx\nlo*\nwor*ldx\n') + + def test_no_delete(self): + # if index1 == 'insert' or 'end' or >= end, there is no deletion + delete = self.text.delete + get = self.text.get + Equal = self.assertEqual + self.text.insert('1.0', self.hw) + + delete('insert') + Equal(get('1.0', 'end'), self.hwn) + + delete('end') + Equal(get('1.0', 'end'), self.hwn) + + delete('insert', 'end') + Equal(get('1.0', 'end'), self.hwn) + + delete('insert', '5.5') + Equal(get('1.0', 'end'), self.hwn) + + delete('1.4', '1.0') + Equal(get('1.0', 'end'), self.hwn) + + delete('1.4', '1.4') + Equal(get('1.0', 'end'), self.hwn) + + def test_delete_char(self): + delete = self.text.delete + get = self.text.get + Equal = self.assertEqual + self.text.insert('1.0', self.hw) + + delete('1.0') + Equal(get('1.0', '1.end'), 'ello') + + delete('1.0', '1.1') + Equal(get('1.0', '1.end'), 'llo') + + # delete \n and combine 2 lines into 1 + delete('1.end') + Equal(get('1.0', '1.end'), 'lloworld') + + self.text.insert('1.3', '\n') + delete('1.10') + Equal(get('1.0', '1.end'), 'lloworld') + + self.text.insert('1.3', '\n') + delete('1.3', '2.0') + Equal(get('1.0', '1.end'), 'lloworld') + + def test_delete_slice(self): + delete = self.text.delete + get = self.text.get + Equal = self.assertEqual + self.text.insert('1.0', self.hw) + + delete('1.0', '1.0 lineend') + Equal(get('1.0', 'end'), '\nworld\n') + + delete('1.0', 'end') + Equal(get('1.0', 'end'), '\n') + + self.text.insert('1.0', self.hw) + delete('1.0', '2.0') + Equal(get('1.0', 'end'), 'world\n') + + delete('1.0', 'end') + Equal(get('1.0', 'end'), '\n') + + self.text.insert('1.0', self.hw) + delete('1.2', '2.3') + Equal(get('1.0', 'end'), 'held\n') + + def test_multiple_lines(self): # insert and delete + self.text.insert('1.0', 'hello') + + self.text.insert('1.3', '1\n2\n3\n4\n5') + self.assertEqual(self.text.get('1.0', 'end'), 'hel1\n2\n3\n4\n5lo\n') + + self.text.delete('1.3', '5.1') + self.assertEqual(self.text.get('1.0', 'end'), 'hello\n') + + def test_compare(self): + compare = self.text.compare + Equal = self.assertEqual + # need data so indexes not squished to 1,0 + self.text.insert('1.0', 'First\nSecond\nThird\n') + + self.assertRaises(TclError, compare, '2.2', 'op', '2.2') + + for op, less1, less0, equal, greater0, greater1 in ( + ('<', True, True, False, False, False), + ('<=', True, True, True, False, False), + ('>', False, False, False, True, True), + ('>=', False, False, True, True, True), + ('==', False, False, True, False, False), + ('!=', True, True, False, True, True), + ): + Equal(compare('1.1', op, '2.2'), less1, op) + Equal(compare('2.1', op, '2.2'), less0, op) + Equal(compare('2.2', op, '2.2'), equal, op) + Equal(compare('2.3', op, '2.2'), greater0, op) + Equal(compare('3.3', op, '2.2'), greater1, op) + + +class MockTextTest(TextTest, unittest.TestCase): + + @classmethod + def setUpClass(cls): + from idlelib.idle_test.mock_tk import Text + cls.Text = Text + + def setUp(self): + self.text = self.Text() + + + def test_decode(self): + # test endflags (-1, 0) not tested by test_index (which uses +1) + decode = self.text._decode + Equal = self.assertEqual + self.text.insert('1.0', self.hw) + + Equal(decode('end', -1), (2, 5)) + Equal(decode('3.1', -1), (2, 5)) + Equal(decode('end', 0), (2, 6)) + Equal(decode('3.1', 0), (2, 6)) + + +class TkTextTest(TextTest, unittest.TestCase): + + @classmethod + def setUpClass(cls): + requires('gui') + from tkinter import Tk, Text + cls.Text = Text + cls.root = Tk() + + @classmethod + def tearDownClass(cls): + cls.root.destroy() + del cls.root + + def setUp(self): + self.text = self.Text(self.root) + + +if __name__ == '__main__': + unittest.main(verbosity=2, exit=False) diff --git a/Lib/idlelib/idle_test/test_textview.py b/Lib/idlelib/idle_test/test_textview.py new file mode 100644 index 000000000..7189378ab --- /dev/null +++ b/Lib/idlelib/idle_test/test_textview.py @@ -0,0 +1,233 @@ +"""Test textview, coverage 100%. + +Since all methods and functions create (or destroy) a ViewWindow, which +is a widget containing a widget, etcetera, all tests must be gui tests. +Using mock Text would not change this. Other mocks are used to retrieve +information about calls. +""" +from idlelib import textview as tv +from test.support import requires +requires('gui') + +import os +import unittest +from tkinter import Tk, TclError, CHAR, NONE, WORD +from tkinter.ttk import Button +from idlelib.idle_test.mock_idle import Func +from idlelib.idle_test.mock_tk import Mbox_func + +def setUpModule(): + global root + root = Tk() + root.withdraw() + +def tearDownModule(): + global root + root.update_idletasks() + root.destroy() + del root + +# If we call ViewWindow or wrapper functions with defaults +# modal=True, _utest=False, test hangs on call to wait_window. +# Have also gotten tk error 'can't invoke "event" command'. + + +class VW(tv.ViewWindow): # Used in ViewWindowTest. + transient = Func() + grab_set = Func() + wait_window = Func() + + +# Call wrapper class VW with mock wait_window. +class ViewWindowTest(unittest.TestCase): + + def setUp(self): + VW.transient.__init__() + VW.grab_set.__init__() + VW.wait_window.__init__() + + def test_init_modal(self): + view = VW(root, 'Title', 'test text') + self.assertTrue(VW.transient.called) + self.assertTrue(VW.grab_set.called) + self.assertTrue(VW.wait_window.called) + view.ok() + + def test_init_nonmodal(self): + view = VW(root, 'Title', 'test text', modal=False) + self.assertFalse(VW.transient.called) + self.assertFalse(VW.grab_set.called) + self.assertFalse(VW.wait_window.called) + view.ok() + + def test_ok(self): + view = VW(root, 'Title', 'test text', modal=False) + view.destroy = Func() + view.ok() + self.assertTrue(view.destroy.called) + del view.destroy # Unmask real function. + view.destroy() + + +class AutoHideScrollbarTest(unittest.TestCase): + # Method set is tested in ScrollableTextFrameTest + def test_forbidden_geometry(self): + scroll = tv.AutoHideScrollbar(root) + self.assertRaises(TclError, scroll.pack) + self.assertRaises(TclError, scroll.place) + + +class ScrollableTextFrameTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + cls.root = root = Tk() + root.withdraw() + + @classmethod + def tearDownClass(cls): + cls.root.update_idletasks() + cls.root.destroy() + del cls.root + + def make_frame(self, wrap=NONE, **kwargs): + frame = tv.ScrollableTextFrame(self.root, wrap=wrap, **kwargs) + def cleanup_frame(): + frame.update_idletasks() + frame.destroy() + self.addCleanup(cleanup_frame) + return frame + + def test_line1(self): + frame = self.make_frame() + frame.text.insert('1.0', 'test text') + self.assertEqual(frame.text.get('1.0', '1.end'), 'test text') + + def test_horiz_scrollbar(self): + # The horizontal scrollbar should be shown/hidden according to + # the 'wrap' setting: It should only be shown when 'wrap' is + # set to NONE. + + # wrap = NONE -> with horizontal scrolling + frame = self.make_frame(wrap=NONE) + self.assertEqual(frame.text.cget('wrap'), NONE) + self.assertIsNotNone(frame.xscroll) + + # wrap != NONE -> no horizontal scrolling + for wrap in [CHAR, WORD]: + with self.subTest(wrap=wrap): + frame = self.make_frame(wrap=wrap) + self.assertEqual(frame.text.cget('wrap'), wrap) + self.assertIsNone(frame.xscroll) + + +class ViewFrameTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + cls.root = root = Tk() + root.withdraw() + cls.frame = tv.ViewFrame(root, 'test text') + + @classmethod + def tearDownClass(cls): + del cls.frame + cls.root.update_idletasks() + cls.root.destroy() + del cls.root + + def test_line1(self): + get = self.frame.text.get + self.assertEqual(get('1.0', '1.end'), 'test text') + + +# Call ViewWindow with modal=False. +class ViewFunctionTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + cls.orig_error = tv.showerror + tv.showerror = Mbox_func() + + @classmethod + def tearDownClass(cls): + tv.showerror = cls.orig_error + del cls.orig_error + + def test_view_text(self): + view = tv.view_text(root, 'Title', 'test text', modal=False) + self.assertIsInstance(view, tv.ViewWindow) + self.assertIsInstance(view.viewframe, tv.ViewFrame) + view.viewframe.ok() + + def test_view_file(self): + view = tv.view_file(root, 'Title', __file__, 'ascii', modal=False) + self.assertIsInstance(view, tv.ViewWindow) + self.assertIsInstance(view.viewframe, tv.ViewFrame) + get = view.viewframe.textframe.text.get + self.assertIn('Test', get('1.0', '1.end')) + view.ok() + + def test_bad_file(self): + # Mock showerror will be used; view_file will return None. + view = tv.view_file(root, 'Title', 'abc.xyz', 'ascii', modal=False) + self.assertIsNone(view) + self.assertEqual(tv.showerror.title, 'File Load Error') + + def test_bad_encoding(self): + p = os.path + fn = p.abspath(p.join(p.dirname(__file__), '..', 'CREDITS.txt')) + view = tv.view_file(root, 'Title', fn, 'ascii', modal=False) + self.assertIsNone(view) + self.assertEqual(tv.showerror.title, 'Unicode Decode Error') + + def test_nowrap(self): + view = tv.view_text(root, 'Title', 'test', modal=False, wrap='none') + text_widget = view.viewframe.textframe.text + self.assertEqual(text_widget.cget('wrap'), 'none') + + +# Call ViewWindow with _utest=True. +class ButtonClickTest(unittest.TestCase): + + def setUp(self): + self.view = None + self.called = False + + def tearDown(self): + if self.view: + self.view.destroy() + + def test_view_text_bind_with_button(self): + def _command(): + self.called = True + self.view = tv.view_text(root, 'TITLE_TEXT', 'COMMAND', _utest=True) + button = Button(root, text='BUTTON', command=_command) + button.invoke() + self.addCleanup(button.destroy) + + self.assertEqual(self.called, True) + self.assertEqual(self.view.title(), 'TITLE_TEXT') + self.assertEqual(self.view.viewframe.textframe.text.get('1.0', '1.end'), + 'COMMAND') + + def test_view_file_bind_with_button(self): + def _command(): + self.called = True + self.view = tv.view_file(root, 'TITLE_FILE', __file__, + encoding='ascii', _utest=True) + button = Button(root, text='BUTTON', command=_command) + button.invoke() + self.addCleanup(button.destroy) + + self.assertEqual(self.called, True) + self.assertEqual(self.view.title(), 'TITLE_FILE') + get = self.view.viewframe.textframe.text.get + with open(__file__) as f: + self.assertEqual(get('1.0', '1.end'), f.readline().strip()) + f.readline() + self.assertEqual(get('3.0', '3.end'), f.readline().strip()) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_tooltip.py b/Lib/idlelib/idle_test/test_tooltip.py new file mode 100644 index 000000000..c616d4fde --- /dev/null +++ b/Lib/idlelib/idle_test/test_tooltip.py @@ -0,0 +1,161 @@ +"""Test tooltip, coverage 100%. + +Coverage is 100% after excluding 6 lines with "# pragma: no cover". +They involve TclErrors that either should or should not happen in a +particular situation, and which are 'pass'ed if they do. +""" + +from idlelib.tooltip import TooltipBase, Hovertip +from test.support import requires +requires('gui') + +from functools import wraps +import time +from tkinter import Button, Tk, Toplevel +import unittest + + +def setUpModule(): + global root + root = Tk() + +def tearDownModule(): + global root + root.update_idletasks() + root.destroy() + del root + + +def add_call_counting(func): + @wraps(func) + def wrapped_func(*args, **kwargs): + wrapped_func.call_args_list.append((args, kwargs)) + return func(*args, **kwargs) + wrapped_func.call_args_list = [] + return wrapped_func + + +def _make_top_and_button(testobj): + global root + top = Toplevel(root) + testobj.addCleanup(top.destroy) + top.title("Test tooltip") + button = Button(top, text='ToolTip test button') + button.pack() + testobj.addCleanup(button.destroy) + top.lift() + return top, button + + +class ToolTipBaseTest(unittest.TestCase): + def setUp(self): + self.top, self.button = _make_top_and_button(self) + + def test_base_class_is_unusable(self): + global root + top = Toplevel(root) + self.addCleanup(top.destroy) + + button = Button(top, text='ToolTip test button') + button.pack() + self.addCleanup(button.destroy) + + with self.assertRaises(NotImplementedError): + tooltip = TooltipBase(button) + tooltip.showtip() + + +class HovertipTest(unittest.TestCase): + def setUp(self): + self.top, self.button = _make_top_and_button(self) + + def is_tipwindow_shown(self, tooltip): + return tooltip.tipwindow and tooltip.tipwindow.winfo_viewable() + + def test_showtip(self): + tooltip = Hovertip(self.button, 'ToolTip text') + self.addCleanup(tooltip.hidetip) + self.assertFalse(self.is_tipwindow_shown(tooltip)) + tooltip.showtip() + self.assertTrue(self.is_tipwindow_shown(tooltip)) + + def test_showtip_twice(self): + tooltip = Hovertip(self.button, 'ToolTip text') + self.addCleanup(tooltip.hidetip) + self.assertFalse(self.is_tipwindow_shown(tooltip)) + tooltip.showtip() + self.assertTrue(self.is_tipwindow_shown(tooltip)) + orig_tipwindow = tooltip.tipwindow + tooltip.showtip() + self.assertTrue(self.is_tipwindow_shown(tooltip)) + self.assertIs(tooltip.tipwindow, orig_tipwindow) + + def test_hidetip(self): + tooltip = Hovertip(self.button, 'ToolTip text') + self.addCleanup(tooltip.hidetip) + tooltip.showtip() + tooltip.hidetip() + self.assertFalse(self.is_tipwindow_shown(tooltip)) + + def test_showtip_on_mouse_enter_no_delay(self): + tooltip = Hovertip(self.button, 'ToolTip text', hover_delay=None) + self.addCleanup(tooltip.hidetip) + tooltip.showtip = add_call_counting(tooltip.showtip) + root.update() + self.assertFalse(self.is_tipwindow_shown(tooltip)) + self.button.event_generate('', x=0, y=0) + root.update() + self.assertTrue(self.is_tipwindow_shown(tooltip)) + self.assertGreater(len(tooltip.showtip.call_args_list), 0) + + def test_hover_with_delay(self): + # Run multiple tests requiring an actual delay simultaneously. + + # Test #1: A hover tip with a non-zero delay appears after the delay. + tooltip1 = Hovertip(self.button, 'ToolTip text', hover_delay=100) + self.addCleanup(tooltip1.hidetip) + tooltip1.showtip = add_call_counting(tooltip1.showtip) + root.update() + self.assertFalse(self.is_tipwindow_shown(tooltip1)) + self.button.event_generate('', x=0, y=0) + root.update() + self.assertFalse(self.is_tipwindow_shown(tooltip1)) + + # Test #2: A hover tip with a non-zero delay doesn't appear when + # the mouse stops hovering over the base widget before the delay + # expires. + tooltip2 = Hovertip(self.button, 'ToolTip text', hover_delay=100) + self.addCleanup(tooltip2.hidetip) + tooltip2.showtip = add_call_counting(tooltip2.showtip) + root.update() + self.button.event_generate('', x=0, y=0) + root.update() + self.button.event_generate('', x=0, y=0) + root.update() + + time.sleep(0.15) + root.update() + + # Test #1 assertions. + self.assertTrue(self.is_tipwindow_shown(tooltip1)) + self.assertGreater(len(tooltip1.showtip.call_args_list), 0) + + # Test #2 assertions. + self.assertFalse(self.is_tipwindow_shown(tooltip2)) + self.assertEqual(tooltip2.showtip.call_args_list, []) + + def test_hidetip_on_mouse_leave(self): + tooltip = Hovertip(self.button, 'ToolTip text', hover_delay=None) + self.addCleanup(tooltip.hidetip) + tooltip.showtip = add_call_counting(tooltip.showtip) + root.update() + self.button.event_generate('', x=0, y=0) + root.update() + self.button.event_generate('', x=0, y=0) + root.update() + self.assertFalse(self.is_tipwindow_shown(tooltip)) + self.assertGreater(len(tooltip.showtip.call_args_list), 0) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_tree.py b/Lib/idlelib/idle_test/test_tree.py new file mode 100644 index 000000000..b3e4c10cf --- /dev/null +++ b/Lib/idlelib/idle_test/test_tree.py @@ -0,0 +1,60 @@ +"Test tree. coverage 56%." + +from idlelib import tree +import unittest +from test.support import requires +requires('gui') +from tkinter import Tk, EventType, SCROLL + + +class TreeTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + cls.root = Tk() + cls.root.withdraw() + + @classmethod + def tearDownClass(cls): + cls.root.destroy() + del cls.root + + def test_init(self): + # Start with code slightly adapted from htest. + sc = tree.ScrolledCanvas( + self.root, bg="white", highlightthickness=0, takefocus=1) + sc.frame.pack(expand=1, fill="both", side='left') + item = tree.FileTreeItem(tree.ICONDIR) + node = tree.TreeNode(sc.canvas, None, item) + node.expand() + + +class TestScrollEvent(unittest.TestCase): + + def test_wheel_event(self): + # Fake widget class containing `yview` only. + class _Widget: + def __init__(widget, *expected): + widget.expected = expected + def yview(widget, *args): + self.assertTupleEqual(widget.expected, args) + # Fake event class + class _Event: + pass + # (type, delta, num, amount) + tests = ((EventType.MouseWheel, 120, -1, -5), + (EventType.MouseWheel, -120, -1, 5), + (EventType.ButtonPress, -1, 4, -5), + (EventType.ButtonPress, -1, 5, 5)) + + event = _Event() + for ty, delta, num, amount in tests: + event.type = ty + event.delta = delta + event.num = num + res = tree.wheel_event(event, _Widget(SCROLL, amount, "units")) + self.assertEqual(res, "break") + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_undo.py b/Lib/idlelib/idle_test/test_undo.py new file mode 100644 index 000000000..beb5b5820 --- /dev/null +++ b/Lib/idlelib/idle_test/test_undo.py @@ -0,0 +1,135 @@ +"Test undo, coverage 77%." +# Only test UndoDelegator so far. + +from idlelib.undo import UndoDelegator +import unittest +from test.support import requires +requires('gui') + +from unittest.mock import Mock +from tkinter import Text, Tk +from idlelib.percolator import Percolator + + +class UndoDelegatorTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + cls.root = Tk() + cls.text = Text(cls.root) + cls.percolator = Percolator(cls.text) + + @classmethod + def tearDownClass(cls): + cls.percolator.redir.close() + del cls.percolator, cls.text + cls.root.destroy() + del cls.root + + def setUp(self): + self.delegator = UndoDelegator() + self.delegator.bell = Mock() + self.percolator.insertfilter(self.delegator) + + def tearDown(self): + self.percolator.removefilter(self.delegator) + self.text.delete('1.0', 'end') + self.delegator.resetcache() + + def test_undo_event(self): + text = self.text + + text.insert('insert', 'foobar') + text.insert('insert', 'h') + text.event_generate('<>') + self.assertEqual(text.get('1.0', 'end'), '\n') + + text.insert('insert', 'foo') + text.insert('insert', 'bar') + text.delete('1.2', '1.4') + text.insert('insert', 'hello') + text.event_generate('<>') + self.assertEqual(text.get('1.0', '1.4'), 'foar') + text.event_generate('<>') + self.assertEqual(text.get('1.0', '1.6'), 'foobar') + text.event_generate('<>') + self.assertEqual(text.get('1.0', '1.3'), 'foo') + text.event_generate('<>') + self.delegator.undo_event('event') + self.assertTrue(self.delegator.bell.called) + + def test_redo_event(self): + text = self.text + + text.insert('insert', 'foo') + text.insert('insert', 'bar') + text.delete('1.0', '1.3') + text.event_generate('<>') + text.event_generate('<>') + self.assertEqual(text.get('1.0', '1.3'), 'bar') + text.event_generate('<>') + self.assertTrue(self.delegator.bell.called) + + def test_dump_event(self): + """ + Dump_event cannot be tested directly without changing + environment variables. So, test statements in dump_event + indirectly + """ + text = self.text + d = self.delegator + + text.insert('insert', 'foo') + text.insert('insert', 'bar') + text.delete('1.2', '1.4') + self.assertTupleEqual((d.pointer, d.can_merge), (3, True)) + text.event_generate('<>') + self.assertTupleEqual((d.pointer, d.can_merge), (2, False)) + + def test_get_set_saved(self): + # test the getter method get_saved + # test the setter method set_saved + # indirectly test check_saved + d = self.delegator + + self.assertTrue(d.get_saved()) + self.text.insert('insert', 'a') + self.assertFalse(d.get_saved()) + d.saved_change_hook = Mock() + + d.set_saved(True) + self.assertEqual(d.pointer, d.saved) + self.assertTrue(d.saved_change_hook.called) + + d.set_saved(False) + self.assertEqual(d.saved, -1) + self.assertTrue(d.saved_change_hook.called) + + def test_undo_start_stop(self): + # test the undo_block_start and undo_block_stop methods + text = self.text + + text.insert('insert', 'foo') + self.delegator.undo_block_start() + text.insert('insert', 'bar') + text.insert('insert', 'bar') + self.delegator.undo_block_stop() + self.assertEqual(text.get('1.0', '1.3'), 'foo') + + # test another code path + self.delegator.undo_block_start() + text.insert('insert', 'bar') + self.delegator.undo_block_stop() + self.assertEqual(text.get('1.0', '1.3'), 'foo') + + def test_addcmd(self): + text = self.text + # when number of undo operations exceeds max_undo + self.delegator.max_undo = max_undo = 10 + for i in range(max_undo + 10): + text.insert('insert', 'foo') + self.assertLessEqual(len(self.delegator.undolist), max_undo) + + +if __name__ == '__main__': + unittest.main(verbosity=2, exit=False) diff --git a/Lib/idlelib/idle_test/test_util.py b/Lib/idlelib/idle_test/test_util.py new file mode 100644 index 000000000..20721fe98 --- /dev/null +++ b/Lib/idlelib/idle_test/test_util.py @@ -0,0 +1,14 @@ +"""Test util, coverage 100%""" + +import unittest +from idlelib import util + + +class UtilTest(unittest.TestCase): + def test_extensions(self): + for extension in {'.pyi', '.py', '.pyw'}: + self.assertIn(extension, util.py_extensions) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_warning.py b/Lib/idlelib/idle_test/test_warning.py new file mode 100644 index 000000000..221068c58 --- /dev/null +++ b/Lib/idlelib/idle_test/test_warning.py @@ -0,0 +1,73 @@ +'''Test warnings replacement in pyshell.py and run.py. + +This file could be expanded to include traceback overrides +(in same two modules). If so, change name. +Revise if output destination changes (http://bugs.python.org/issue18318). +Make sure warnings module is left unaltered (http://bugs.python.org/issue18081). +''' +from idlelib import run +from idlelib import pyshell as shell +import unittest +from test.support import captured_stderr +import warnings + +# Try to capture default showwarning before Idle modules are imported. +showwarning = warnings.showwarning +# But if we run this file within idle, we are in the middle of the run.main loop +# and default showwarnings has already been replaced. +running_in_idle = 'idle' in showwarning.__name__ + +# The following was generated from pyshell.idle_formatwarning +# and checked as matching expectation. +idlemsg = ''' +Warning (from warnings module): + File "test_warning.py", line 99 + Line of code +UserWarning: Test +''' +shellmsg = idlemsg + ">>> " + + +class RunWarnTest(unittest.TestCase): + + @unittest.skipIf(running_in_idle, "Does not work when run within Idle.") + def test_showwarnings(self): + self.assertIs(warnings.showwarning, showwarning) + run.capture_warnings(True) + self.assertIs(warnings.showwarning, run.idle_showwarning_subproc) + run.capture_warnings(False) + self.assertIs(warnings.showwarning, showwarning) + + def test_run_show(self): + with captured_stderr() as f: + run.idle_showwarning_subproc( + 'Test', UserWarning, 'test_warning.py', 99, f, 'Line of code') + # The following uses .splitlines to erase line-ending differences + self.assertEqual(idlemsg.splitlines(), f.getvalue().splitlines()) + + +class ShellWarnTest(unittest.TestCase): + + @unittest.skipIf(running_in_idle, "Does not work when run within Idle.") + def test_showwarnings(self): + self.assertIs(warnings.showwarning, showwarning) + shell.capture_warnings(True) + self.assertIs(warnings.showwarning, shell.idle_showwarning) + shell.capture_warnings(False) + self.assertIs(warnings.showwarning, showwarning) + + def test_idle_formatter(self): + # Will fail if format changed without regenerating idlemsg + s = shell.idle_formatwarning( + 'Test', UserWarning, 'test_warning.py', 99, 'Line of code') + self.assertEqual(idlemsg, s) + + def test_shell_show(self): + with captured_stderr() as f: + shell.idle_showwarning( + 'Test', UserWarning, 'test_warning.py', 99, f, 'Line of code') + self.assertEqual(shellmsg.splitlines(), f.getvalue().splitlines()) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_window.py b/Lib/idlelib/idle_test/test_window.py new file mode 100644 index 000000000..5a2645b9c --- /dev/null +++ b/Lib/idlelib/idle_test/test_window.py @@ -0,0 +1,45 @@ +"Test window, coverage 47%." + +from idlelib import window +import unittest +from test.support import requires +from tkinter import Tk + + +class WindowListTest(unittest.TestCase): + + def test_init(self): + wl = window.WindowList() + self.assertEqual(wl.dict, {}) + self.assertEqual(wl.callbacks, []) + + # Further tests need mock Window. + + +class ListedToplevelTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + window.registry = set() + requires('gui') + cls.root = Tk() + cls.root.withdraw() + + @classmethod + def tearDownClass(cls): + window.registry = window.WindowList() + cls.root.update_idletasks() +## for id in cls.root.tk.call('after', 'info'): +## cls.root.after_cancel(id) # Need for EditorWindow. + cls.root.destroy() + del cls.root + + def test_init(self): + + win = window.ListedToplevel(self.root) + self.assertIn(win, window.registry) + self.assertEqual(win.focused_widget, win) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_zoomheight.py b/Lib/idlelib/idle_test/test_zoomheight.py new file mode 100644 index 000000000..aa5bdfb4f --- /dev/null +++ b/Lib/idlelib/idle_test/test_zoomheight.py @@ -0,0 +1,39 @@ +"Test zoomheight, coverage 66%." +# Some code is system dependent. + +from idlelib import zoomheight +import unittest +from test.support import requires +from tkinter import Tk +from idlelib.editor import EditorWindow + + +class Test(unittest.TestCase): + + @classmethod + def setUpClass(cls): + requires('gui') + cls.root = Tk() + cls.root.withdraw() + cls.editwin = EditorWindow(root=cls.root) + + @classmethod + def tearDownClass(cls): + cls.editwin._close() + cls.root.update_idletasks() + for id in cls.root.tk.call('after', 'info'): + cls.root.after_cancel(id) # Need for EditorWindow. + cls.root.destroy() + del cls.root + + def test_init(self): + zoom = zoomheight.ZoomHeight(self.editwin) + self.assertIs(zoom.editwin, self.editwin) + + def test_zoom_height_event(self): + zoom = zoomheight.ZoomHeight(self.editwin) + zoom.zoom_height_event() + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/test_zzdummy.py b/Lib/idlelib/idle_test/test_zzdummy.py new file mode 100644 index 000000000..209d8564d --- /dev/null +++ b/Lib/idlelib/idle_test/test_zzdummy.py @@ -0,0 +1,152 @@ +"Test zzdummy, coverage 100%." + +from idlelib import zzdummy +import unittest +from test.support import requires +from tkinter import Tk, Text +from unittest import mock +from idlelib import config +from idlelib import editor +from idlelib import format + + +usercfg = zzdummy.idleConf.userCfg +testcfg = { + 'main': config.IdleUserConfParser(''), + 'highlight': config.IdleUserConfParser(''), + 'keys': config.IdleUserConfParser(''), + 'extensions': config.IdleUserConfParser(''), +} +code_sample = """\ + +class C1: + # Class comment. + def __init__(self, a, b): + self.a = a + self.b = b +""" + + +class DummyEditwin: + get_selection_indices = editor.EditorWindow.get_selection_indices + def __init__(self, root, text): + self.root = root + self.top = root + self.text = text + self.fregion = format.FormatRegion(self) + self.text.undo_block_start = mock.Mock() + self.text.undo_block_stop = mock.Mock() + + +class ZZDummyTest(unittest.TestCase): + + @classmethod + def setUpClass(cls): + requires('gui') + root = cls.root = Tk() + root.withdraw() + text = cls.text = Text(cls.root) + cls.editor = DummyEditwin(root, text) + zzdummy.idleConf.userCfg = testcfg + + @classmethod + def tearDownClass(cls): + zzdummy.idleConf.userCfg = usercfg + del cls.editor, cls.text + cls.root.update_idletasks() + for id in cls.root.tk.call('after', 'info'): + cls.root.after_cancel(id) # Need for EditorWindow. + cls.root.destroy() + del cls.root + + def setUp(self): + text = self.text + text.insert('1.0', code_sample) + text.undo_block_start.reset_mock() + text.undo_block_stop.reset_mock() + zz = self.zz = zzdummy.ZzDummy(self.editor) + zzdummy.ZzDummy.ztext = '# ignore #' + + def tearDown(self): + self.text.delete('1.0', 'end') + del self.zz + + def checklines(self, text, value): + # Verify that there are lines being checked. + end_line = int(float(text.index('end'))) + + # Check each line for the starting text. + actual = [] + for line in range(1, end_line): + txt = text.get(f'{line}.0', f'{line}.end') + actual.append(txt.startswith(value)) + return actual + + def test_init(self): + zz = self.zz + self.assertEqual(zz.editwin, self.editor) + self.assertEqual(zz.text, self.editor.text) + + def test_reload(self): + self.assertEqual(self.zz.ztext, '# ignore #') + testcfg['extensions'].SetOption('ZzDummy', 'z-text', 'spam') + zzdummy.ZzDummy.reload() + self.assertEqual(self.zz.ztext, 'spam') + + def test_z_in_event(self): + eq = self.assertEqual + zz = self.zz + text = zz.text + eq(self.zz.ztext, '# ignore #') + + # No lines have the leading text. + expected = [False, False, False, False, False, False, False] + actual = self.checklines(text, zz.ztext) + eq(expected, actual) + + text.tag_add('sel', '2.0', '4.end') + eq(zz.z_in_event(), 'break') + expected = [False, True, True, True, False, False, False] + actual = self.checklines(text, zz.ztext) + eq(expected, actual) + + text.undo_block_start.assert_called_once() + text.undo_block_stop.assert_called_once() + + def test_z_out_event(self): + eq = self.assertEqual + zz = self.zz + text = zz.text + eq(self.zz.ztext, '# ignore #') + + # Prepend text. + text.tag_add('sel', '2.0', '5.end') + zz.z_in_event() + text.undo_block_start.reset_mock() + text.undo_block_stop.reset_mock() + + # Select a few lines to remove text. + text.tag_remove('sel', '1.0', 'end') + text.tag_add('sel', '3.0', '4.end') + eq(zz.z_out_event(), 'break') + expected = [False, True, False, False, True, False, False] + actual = self.checklines(text, zz.ztext) + eq(expected, actual) + + text.undo_block_start.assert_called_once() + text.undo_block_stop.assert_called_once() + + def test_roundtrip(self): + # Insert and remove to all code should give back original text. + zz = self.zz + text = zz.text + + text.tag_add('sel', '1.0', 'end-1c') + zz.z_in_event() + zz.z_out_event() + + self.assertEqual(text.get('1.0', 'end-1c'), code_sample) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/Lib/idlelib/idle_test/tkinter_testing_utils.py b/Lib/idlelib/idle_test/tkinter_testing_utils.py new file mode 100644 index 000000000..a89839bbe --- /dev/null +++ b/Lib/idlelib/idle_test/tkinter_testing_utils.py @@ -0,0 +1,62 @@ +"""Utilities for testing with Tkinter""" +import functools + + +def run_in_tk_mainloop(delay=1): + """Decorator for running a test method with a real Tk mainloop. + + This starts a Tk mainloop before running the test, and stops it + at the end. This is faster and more robust than the common + alternative method of calling .update() and/or .update_idletasks(). + + Test methods using this must be written as generator functions, + using "yield" to allow the mainloop to process events and "after" + callbacks, and then continue the test from that point. + + The delay argument is passed into root.after(...) calls as the number + of ms to wait before passing execution back to the generator function. + + This also assumes that the test class has a .root attribute, + which is a tkinter.Tk object. + + For example (from test_sidebar.py): + + @run_test_with_tk_mainloop() + def test_single_empty_input(self): + self.do_input('\n') + yield + self.assert_sidebar_lines_end_with(['>>>', '>>>']) + """ + def decorator(test_method): + @functools.wraps(test_method) + def new_test_method(self): + test_generator = test_method(self) + root = self.root + # Exceptions raised by self.assert...() need to be raised + # outside of the after() callback in order for the test + # harness to capture them. + exception = None + def after_callback(): + nonlocal exception + try: + next(test_generator) + except StopIteration: + root.quit() + except Exception as exc: + exception = exc + root.quit() + else: + # Schedule the Tk mainloop to call this function again, + # using a robust method of ensuring that it gets a + # chance to process queued events before doing so. + # See: https://stackoverflow.com/q/18499082#comment65004099_38817470 + root.after(delay, root.after_idle, after_callback) + root.after(0, root.after_idle, after_callback) + root.mainloop() + + if exception: + raise exception + + return new_test_method + + return decorator diff --git a/Lib/idlelib/iomenu.py b/Lib/idlelib/iomenu.py new file mode 100644 index 000000000..fc502f7fd --- /dev/null +++ b/Lib/idlelib/iomenu.py @@ -0,0 +1,468 @@ +import io +import os +import shlex +import sys +import tempfile +import tokenize + +from tkinter import filedialog +from tkinter import messagebox +from tkinter.simpledialog import askstring # loadfile encoding. + +from idlelib.config import idleConf +from idlelib.util import py_extensions + +py_extensions = ' '.join("*"+ext for ext in py_extensions) +encoding = 'utf-8' +errors = 'surrogatepass' if sys.platform == 'win32' else 'surrogateescape' + + +class IOBinding: +# One instance per editor Window so methods know which to save, close. +# Open returns focus to self.editwin if aborted. +# EditorWindow.open_module, others, belong here. + + def __init__(self, editwin): + self.editwin = editwin + self.text = editwin.text + self.__id_open = self.text.bind("<>", self.open) + self.__id_save = self.text.bind("<>", self.save) + self.__id_saveas = self.text.bind("<>", + self.save_as) + self.__id_savecopy = self.text.bind("<>", + self.save_a_copy) + self.fileencoding = 'utf-8' + self.__id_print = self.text.bind("<>", self.print_window) + + def close(self): + # Undo command bindings + self.text.unbind("<>", self.__id_open) + self.text.unbind("<>", self.__id_save) + self.text.unbind("<>",self.__id_saveas) + self.text.unbind("<>", self.__id_savecopy) + self.text.unbind("<>", self.__id_print) + # Break cycles + self.editwin = None + self.text = None + self.filename_change_hook = None + + def get_saved(self): + return self.editwin.get_saved() + + def set_saved(self, flag): + self.editwin.set_saved(flag) + + def reset_undo(self): + self.editwin.reset_undo() + + filename_change_hook = None + + def set_filename_change_hook(self, hook): + self.filename_change_hook = hook + + filename = None + file_timestamp = None + dirname = None + + def set_filename(self, filename): + if filename and os.path.isdir(filename): + self.filename = None + self.dirname = filename + else: + self.filename = filename + self.dirname = None + self.set_saved(1) + if self.filename_change_hook: + self.filename_change_hook() + + def open(self, event=None, editFile=None): + flist = self.editwin.flist + # Save in case parent window is closed (ie, during askopenfile()). + if flist: + if not editFile: + filename = self.askopenfile() + else: + filename=editFile + if filename: + # If editFile is valid and already open, flist.open will + # shift focus to its existing window. + # If the current window exists and is a fresh unnamed, + # unmodified editor window (not an interpreter shell), + # pass self.loadfile to flist.open so it will load the file + # in the current window (if the file is not already open) + # instead of a new window. + if (self.editwin and + not getattr(self.editwin, 'interp', None) and + not self.filename and + self.get_saved()): + flist.open(filename, self.loadfile) + else: + flist.open(filename) + else: + if self.text: + self.text.focus_set() + return "break" + + # Code for use outside IDLE: + if self.get_saved(): + reply = self.maybesave() + if reply == "cancel": + self.text.focus_set() + return "break" + if not editFile: + filename = self.askopenfile() + else: + filename=editFile + if filename: + self.loadfile(filename) + else: + self.text.focus_set() + return "break" + + eol_convention = os.linesep # default + + def loadfile(self, filename): + try: + try: + with tokenize.open(filename) as f: + chars = f.read() + fileencoding = f.encoding + eol_convention = f.newlines + file_timestamp = self.getmtime(filename) + converted = False + except (UnicodeDecodeError, SyntaxError): + # Wait for the editor window to appear + self.editwin.text.update() + enc = askstring( + "Specify file encoding", + "The file's encoding is invalid for Python 3.x.\n" + "IDLE will convert it to UTF-8.\n" + "What is the current encoding of the file?", + initialvalue='utf-8', + parent=self.editwin.text) + with open(filename, encoding=enc) as f: + chars = f.read() + fileencoding = f.encoding + eol_convention = f.newlines + file_timestamp = self.getmtime(filename) + converted = True + except OSError as err: + messagebox.showerror("I/O Error", str(err), parent=self.text) + return False + except UnicodeDecodeError: + messagebox.showerror("Decoding Error", + "File %s\nFailed to Decode" % filename, + parent=self.text) + return False + + if not isinstance(eol_convention, str): + # If the file does not contain line separators, it is None. + # If the file contains mixed line separators, it is a tuple. + if eol_convention is not None: + messagebox.showwarning("Mixed Newlines", + "Mixed newlines detected.\n" + "The file will be changed on save.", + parent=self.text) + converted = True + eol_convention = os.linesep # default + + self.text.delete("1.0", "end") + self.set_filename(None) + self.fileencoding = fileencoding + self.eol_convention = eol_convention + self.text.insert("1.0", chars) + self.reset_undo() + self.set_filename(filename) + self.file_timestamp = file_timestamp + if converted: + # We need to save the conversion results first + # before being able to execute the code + self.set_saved(False) + self.text.mark_set("insert", "1.0") + self.text.yview("insert") + self.updaterecentfileslist(filename) + return True + + def maybesave(self): + """Return 'yes', 'no', 'cancel' as appropriate. + + Tkinter messagebox.askyesnocancel converts these tk responses + to True, False, None. Convert back, as now expected elsewhere. + """ + if self.get_saved(): + return "yes" + message = ("Do you want to save " + f"{self.filename or 'this untitled document'}" + " before closing?") + confirm = messagebox.askyesnocancel( + title="Save On Close", + message=message, + default=messagebox.YES, + parent=self.text) + if confirm: + self.save(None) + reply = "yes" if self.get_saved() else "cancel" + else: reply = "cancel" if confirm is None else "no" + self.text.focus_set() + return reply + + def save(self, event): + if not self.filename: + self.save_as(event) + else: + # Check the time of most recent content modification so the + # user doesn't accidentally overwrite a newer version of the file. + try: + file_timestamp = self.getmtime(self.filename) + except OSError: + pass + else: + if self.file_timestamp != file_timestamp: + confirm = messagebox.askokcancel( + title="File has changed", + message=( + "The file has changed on disk since reading it!\n\n" + "Do you really want to overwrite it?"), + default=messagebox.CANCEL, + parent=self.text) + if not confirm: + return "break" + + if self.writefile(self.filename): + self.file_timestamp = self.getmtime(self.filename) + self.set_saved(True) + try: + self.editwin.store_file_breaks() + except AttributeError: # may be a PyShell + pass + self.text.focus_set() + return "break" + + def save_as(self, event): + filename = self.asksavefile() + if filename: + if self.writefile(filename): + self.file_timestamp = self.getmtime(filename) + self.set_filename(filename) + self.set_saved(1) + try: + self.editwin.store_file_breaks() + except AttributeError: + pass + self.text.focus_set() + self.updaterecentfileslist(filename) + return "break" + + def save_a_copy(self, event): + filename = self.asksavefile() + if filename: + self.writefile(filename) + self.text.focus_set() + self.updaterecentfileslist(filename) + return "break" + + def writefile(self, filename): + text = self.fixnewlines() + chars = self.encode(text) + try: + with open(filename, "wb") as f: + f.write(chars) + f.flush() + os.fsync(f.fileno()) + return True + except OSError as msg: + messagebox.showerror("I/O Error", str(msg), + parent=self.text) + return False + + def getmtime(self, filename): + return os.stat(filename).st_mtime + + def fixnewlines(self): + """Return text with os eols. + + Add prompts if shell else final \n if missing. + """ + + if hasattr(self.editwin, "interp"): # Saving shell. + text = self.editwin.get_prompt_text('1.0', self.text.index('end-1c')) + else: + if self.text.get("end-2c") != '\n': + self.text.insert("end-1c", "\n") # Changes 'end-1c' value. + text = self.text.get('1.0', "end-1c") + if self.eol_convention != "\n": + text = text.replace("\n", self.eol_convention) + return text + + def encode(self, chars): + if isinstance(chars, bytes): + # This is either plain ASCII, or Tk was returning mixed-encoding + # text to us. Don't try to guess further. + return chars + # Preserve a BOM that might have been present on opening + if self.fileencoding == 'utf-8-sig': + return chars.encode('utf-8-sig') + # See whether there is anything non-ASCII in it. + # If not, no need to figure out the encoding. + try: + return chars.encode('ascii') + except UnicodeEncodeError: + pass + # Check if there is an encoding declared + try: + encoded = chars.encode('ascii', 'replace') + enc, _ = tokenize.detect_encoding(io.BytesIO(encoded).readline) + return chars.encode(enc) + except SyntaxError as err: + failed = str(err) + except UnicodeEncodeError: + failed = "Invalid encoding '%s'" % enc + messagebox.showerror( + "I/O Error", + "%s.\nSaving as UTF-8" % failed, + parent=self.text) + # Fallback: save as UTF-8, with BOM - ignoring the incorrect + # declared encoding + return chars.encode('utf-8-sig') + + def print_window(self, event): + confirm = messagebox.askokcancel( + title="Print", + message="Print to Default Printer", + default=messagebox.OK, + parent=self.text) + if not confirm: + self.text.focus_set() + return "break" + tempfilename = None + saved = self.get_saved() + if saved: + filename = self.filename + # shell undo is reset after every prompt, looks saved, probably isn't + if not saved or filename is None: + (tfd, tempfilename) = tempfile.mkstemp(prefix='IDLE_tmp_') + filename = tempfilename + os.close(tfd) + if not self.writefile(tempfilename): + os.unlink(tempfilename) + return "break" + platform = os.name + printPlatform = True + if platform == 'posix': #posix platform + command = idleConf.GetOption('main','General', + 'print-command-posix') + command = command + " 2>&1" + elif platform == 'nt': #win32 platform + command = idleConf.GetOption('main','General','print-command-win') + else: #no printing for this platform + printPlatform = False + if printPlatform: #we can try to print for this platform + command = command % shlex.quote(filename) + pipe = os.popen(command, "r") + # things can get ugly on NT if there is no printer available. + output = pipe.read().strip() + status = pipe.close() + if status: + output = "Printing failed (exit status 0x%x)\n" % \ + status + output + if output: + output = "Printing command: %s\n" % repr(command) + output + messagebox.showerror("Print status", output, parent=self.text) + else: #no printing for this platform + message = "Printing is not enabled for this platform: %s" % platform + messagebox.showinfo("Print status", message, parent=self.text) + if tempfilename: + os.unlink(tempfilename) + return "break" + + opendialog = None + savedialog = None + + filetypes = ( + ("Python files", py_extensions, "TEXT"), + ("Text files", "*.txt", "TEXT"), + ("All files", "*"), + ) + + defaultextension = '.py' if sys.platform == 'darwin' else '' + + def askopenfile(self): + dir, base = self.defaultfilename("open") + if not self.opendialog: + self.opendialog = filedialog.Open(parent=self.text, + filetypes=self.filetypes) + filename = self.opendialog.show(initialdir=dir, initialfile=base) + return filename + + def defaultfilename(self, mode="open"): + if self.filename: + return os.path.split(self.filename) + elif self.dirname: + return self.dirname, "" + else: + try: + pwd = os.getcwd() + except OSError: + pwd = "" + return pwd, "" + + def asksavefile(self): + dir, base = self.defaultfilename("save") + if not self.savedialog: + self.savedialog = filedialog.SaveAs( + parent=self.text, + filetypes=self.filetypes, + defaultextension=self.defaultextension) + filename = self.savedialog.show(initialdir=dir, initialfile=base) + return filename + + def updaterecentfileslist(self,filename): + "Update recent file list on all editor windows" + if self.editwin.flist: + self.editwin.update_recent_files_list(filename) + + +def _io_binding(parent): # htest # + from tkinter import Toplevel, Text + + top = Toplevel(parent) + top.title("Test IOBinding") + x, y = map(int, parent.geometry().split('+')[1:]) + top.geometry("+%d+%d" % (x, y + 175)) + + class MyEditWin: + def __init__(self, text): + self.text = text + self.flist = None + self.text.bind("", self.open) + self.text.bind('', self.print) + self.text.bind("", self.save) + self.text.bind("", self.saveas) + self.text.bind('', self.savecopy) + def get_saved(self): return 0 + def set_saved(self, flag): pass + def reset_undo(self): pass + def open(self, event): + self.text.event_generate("<>") + def print(self, event): + self.text.event_generate("<>") + def save(self, event): + self.text.event_generate("<>") + def saveas(self, event): + self.text.event_generate("<>") + def savecopy(self, event): + self.text.event_generate("<>") + + text = Text(top) + text.pack() + text.focus_set() + editwin = MyEditWin(text) + IOBinding(editwin) + + +if __name__ == "__main__": + from unittest import main + main('idlelib.idle_test.test_iomenu', verbosity=2, exit=False) + + from idlelib.idle_test.htest import run + run(_io_binding) diff --git a/Lib/idlelib/macosx.py b/Lib/idlelib/macosx.py new file mode 100644 index 000000000..332952f45 --- /dev/null +++ b/Lib/idlelib/macosx.py @@ -0,0 +1,278 @@ +""" +A number of functions that enhance IDLE on macOS. +""" +from os.path import expanduser +import plistlib +from sys import platform # Used in _init_tk_type, changed by test. + +import tkinter + + +## Define functions that query the Mac graphics type. +## _tk_type and its initializer are private to this section. + +_tk_type = None + +def _init_tk_type(): + """ Initialize _tk_type for isXyzTk functions. + + This function is only called once, when _tk_type is still None. + """ + global _tk_type + if platform == 'darwin': + + # When running IDLE, GUI is present, test/* may not be. + # When running tests, test/* is present, GUI may not be. + # If not, guess most common. Does not matter for testing. + from idlelib.__init__ import testing + if testing: + from test.support import requires, ResourceDenied + try: + requires('gui') + except ResourceDenied: + _tk_type = "cocoa" + return + + root = tkinter.Tk() + ws = root.tk.call('tk', 'windowingsystem') + if 'x11' in ws: + _tk_type = "xquartz" + elif 'aqua' not in ws: + _tk_type = "other" + elif 'AppKit' in root.tk.call('winfo', 'server', '.'): + _tk_type = "cocoa" + else: + _tk_type = "carbon" + root.destroy() + else: + _tk_type = "other" + return + +def isAquaTk(): + """ + Returns True if IDLE is using a native OS X Tk (Cocoa or Carbon). + """ + if not _tk_type: + _init_tk_type() + return _tk_type == "cocoa" or _tk_type == "carbon" + +def isCarbonTk(): + """ + Returns True if IDLE is using a Carbon Aqua Tk (instead of the + newer Cocoa Aqua Tk). + """ + if not _tk_type: + _init_tk_type() + return _tk_type == "carbon" + +def isCocoaTk(): + """ + Returns True if IDLE is using a Cocoa Aqua Tk. + """ + if not _tk_type: + _init_tk_type() + return _tk_type == "cocoa" + +def isXQuartz(): + """ + Returns True if IDLE is using an OS X X11 Tk. + """ + if not _tk_type: + _init_tk_type() + return _tk_type == "xquartz" + + +def readSystemPreferences(): + """ + Fetch the macOS system preferences. + """ + if platform != 'darwin': + return None + + plist_path = expanduser('~/Library/Preferences/.GlobalPreferences.plist') + try: + with open(plist_path, 'rb') as plist_file: + return plistlib.load(plist_file) + except OSError: + return None + + +def preferTabsPreferenceWarning(): + """ + Warn if "Prefer tabs when opening documents" is set to "Always". + """ + if platform != 'darwin': + return None + + prefs = readSystemPreferences() + if prefs and prefs.get('AppleWindowTabbingMode') == 'always': + return ( + 'WARNING: The system preference "Prefer tabs when opening' + ' documents" is set to "Always". This will cause various problems' + ' with IDLE. For the best experience, change this setting when' + ' running IDLE (via System Preferences -> Dock).' + ) + return None + + +## Fix the menu and related functions. + +def addOpenEventSupport(root, flist): + """ + This ensures that the application will respond to open AppleEvents, which + makes is feasible to use IDLE as the default application for python files. + """ + def doOpenFile(*args): + for fn in args: + flist.open(fn) + + # The command below is a hook in aquatk that is called whenever the app + # receives a file open event. The callback can have multiple arguments, + # one for every file that should be opened. + root.createcommand("::tk::mac::OpenDocument", doOpenFile) + +def hideTkConsole(root): + try: + root.tk.call('console', 'hide') + except tkinter.TclError: + # Some versions of the Tk framework don't have a console object + pass + +def overrideRootMenu(root, flist): + """ + Replace the Tk root menu by something that is more appropriate for + IDLE with an Aqua Tk. + """ + # The menu that is attached to the Tk root (".") is also used by AquaTk for + # all windows that don't specify a menu of their own. The default menubar + # contains a number of menus, none of which are appropriate for IDLE. The + # Most annoying of those is an 'About Tck/Tk...' menu in the application + # menu. + # + # This function replaces the default menubar by a mostly empty one, it + # should only contain the correct application menu and the window menu. + # + # Due to a (mis-)feature of TkAqua the user will also see an empty Help + # menu. + from tkinter import Menu + from idlelib import mainmenu + from idlelib import window + + closeItem = mainmenu.menudefs[0][1][-2] + + # Remove the last 3 items of the file menu: a separator, close window and + # quit. Close window will be reinserted just above the save item, where + # it should be according to the HIG. Quit is in the application menu. + del mainmenu.menudefs[0][1][-3:] + mainmenu.menudefs[0][1].insert(6, closeItem) + + # Remove the 'About' entry from the help menu, it is in the application + # menu + del mainmenu.menudefs[-1][1][0:2] + # Remove the 'Configure Idle' entry from the options menu, it is in the + # application menu as 'Preferences' + del mainmenu.menudefs[-3][1][0:2] + menubar = Menu(root) + root.configure(menu=menubar) + + menu = Menu(menubar, name='window', tearoff=0) + menubar.add_cascade(label='Window', menu=menu, underline=0) + + def postwindowsmenu(menu=menu): + end = menu.index('end') + if end is None: + end = -1 + + if end > 0: + menu.delete(0, end) + window.add_windows_to_menu(menu) + window.register_callback(postwindowsmenu) + + def about_dialog(event=None): + "Handle Help 'About IDLE' event." + # Synchronize with editor.EditorWindow.about_dialog. + from idlelib import help_about + help_about.AboutDialog(root) + + def config_dialog(event=None): + "Handle Options 'Configure IDLE' event." + # Synchronize with editor.EditorWindow.config_dialog. + from idlelib import configdialog + + # Ensure that the root object has an instance_dict attribute, + # mirrors code in EditorWindow (although that sets the attribute + # on an EditorWindow instance that is then passed as the first + # argument to ConfigDialog) + root.instance_dict = flist.inversedict + configdialog.ConfigDialog(root, 'Settings') + + def help_dialog(event=None): + "Handle Help 'IDLE Help' event." + # Synchronize with editor.EditorWindow.help_dialog. + from idlelib import help + help.show_idlehelp(root) + + root.bind('<>', about_dialog) + root.bind('<>', config_dialog) + root.createcommand('::tk::mac::ShowPreferences', config_dialog) + if flist: + root.bind('<>', flist.close_all_callback) + + # The binding above doesn't reliably work on all versions of Tk + # on macOS. Adding command definition below does seem to do the + # right thing for now. + root.createcommand('::tk::mac::Quit', flist.close_all_callback) + + if isCarbonTk(): + # for Carbon AquaTk, replace the default Tk apple menu + menu = Menu(menubar, name='apple', tearoff=0) + menubar.add_cascade(label='IDLE', menu=menu) + mainmenu.menudefs.insert(0, + ('application', [ + ('About IDLE', '<>'), + None, + ])) + if isCocoaTk(): + # replace default About dialog with About IDLE one + root.createcommand('tkAboutDialog', about_dialog) + # replace default "Help" item in Help menu + root.createcommand('::tk::mac::ShowHelp', help_dialog) + # remove redundant "IDLE Help" from menu + del mainmenu.menudefs[-1][1][0] + +def fixb2context(root): + '''Removed bad AquaTk Button-2 (right) and Paste bindings. + + They prevent context menu access and seem to be gone in AquaTk8.6. + See issue #24801. + ''' + root.unbind_class('Text', '') + root.unbind_class('Text', '') + root.unbind_class('Text', '<>') + +def setupApp(root, flist): + """ + Perform initial OS X customizations if needed. + Called from pyshell.main() after initial calls to Tk() + + There are currently three major versions of Tk in use on OS X: + 1. Aqua Cocoa Tk (native default since OS X 10.6) + 2. Aqua Carbon Tk (original native, 32-bit only, deprecated) + 3. X11 (supported by some third-party distributors, deprecated) + There are various differences among the three that affect IDLE + behavior, primarily with menus, mouse key events, and accelerators. + Some one-time customizations are performed here. + Others are dynamically tested throughout idlelib by calls to the + isAquaTk(), isCarbonTk(), isCocoaTk(), isXQuartz() functions which + are initialized here as well. + """ + if isAquaTk(): + hideTkConsole(root) + overrideRootMenu(root, flist) + addOpenEventSupport(root, flist) + fixb2context(root) + + +if __name__ == '__main__': + from unittest import main + main('idlelib.idle_test.test_macosx', verbosity=2) diff --git a/Lib/idlelib/mainmenu.py b/Lib/idlelib/mainmenu.py new file mode 100644 index 000000000..91a32cebb --- /dev/null +++ b/Lib/idlelib/mainmenu.py @@ -0,0 +1,126 @@ +"""Define the menu contents, hotkeys, and event bindings. + +There is additional configuration information in the EditorWindow class (and +subclasses): the menus are created there based on the menu_specs (class) +variable, and menus not created are silently skipped in the code here. This +makes it possible, for example, to define a Debug menu which is only present in +the PythonShell window, and a Format menu which is only present in the Editor +windows. + +""" +from importlib.util import find_spec + +from idlelib.config import idleConf + +# Warning: menudefs is altered in macosx.overrideRootMenu() +# after it is determined that an OS X Aqua Tk is in use, +# which cannot be done until after Tk() is first called. +# Do not alter the 'file', 'options', or 'help' cascades here +# without altering overrideRootMenu() as well. +# TODO: Make this more robust + +menudefs = [ + # underscore prefixes character to underscore + ('file', [ + ('_New File', '<>'), + ('_Open...', '<>'), + ('Open _Module...', '<>'), + ('Module _Browser', '<>'), + ('_Path Browser', '<>'), + None, + ('_Save', '<>'), + ('Save _As...', '<>'), + ('Save Cop_y As...', '<>'), + None, + ('Prin_t Window', '<>'), + None, + ('_Close Window', '<>'), + ('E_xit IDLE', '<>'), + ]), + + ('edit', [ + ('_Undo', '<>'), + ('_Redo', '<>'), + None, + ('Select _All', '<>'), + ('Cu_t', '<>'), + ('_Copy', '<>'), + ('_Paste', '<>'), + None, + ('_Find...', '<>'), + ('Find A_gain', '<>'), + ('Find _Selection', '<>'), + ('Find in Files...', '<>'), + ('R_eplace...', '<>'), + None, + ('Go to _Line', '<>'), + ('S_how Completions', '<>'), + ('E_xpand Word', '<>'), + ('Show C_all Tip', '<>'), + ('Show Surrounding P_arens', '<>'), + ]), + + ('format', [ + ('F_ormat Paragraph', '<>'), + ('_Indent Region', '<>'), + ('_Dedent Region', '<>'), + ('Comment _Out Region', '<>'), + ('U_ncomment Region', '<>'), + ('Tabify Region', '<>'), + ('Untabify Region', '<>'), + ('Toggle Tabs', '<>'), + ('New Indent Width', '<>'), + ('S_trip Trailing Whitespace', '<>'), + ]), + + ('run', [ + ('R_un Module', '<>'), + ('Run... _Customized', '<>'), + ('C_heck Module', '<>'), + ('Python Shell', '<>'), + ]), + + ('shell', [ + ('_View Last Restart', '<>'), + ('_Restart Shell', '<>'), + None, + ('_Previous History', '<>'), + ('_Next History', '<>'), + None, + ('_Interrupt Execution', '<>'), + ]), + + ('debug', [ + ('_Go to File/Line', '<>'), + ('!_Debugger', '<>'), + ('_Stack Viewer', '<>'), + ('!_Auto-open Stack Viewer', '<>'), + ]), + + ('options', [ + ('Configure _IDLE', '<>'), + None, + ('Show _Code Context', '<>'), + ('Show _Line Numbers', '<>'), + ('_Zoom Height', '<>'), + ]), + + ('window', [ + ]), + + ('help', [ + ('_About IDLE', '<>'), + None, + ('_IDLE Doc', '<>'), + ('Python _Docs', '<>'), + ]), +] + +if find_spec('turtledemo'): + menudefs[-1][1].append(('Turtle Demo', '<>')) + +default_keydefs = idleConf.GetCurrentKeySet() + +if __name__ == '__main__': + from unittest import main + main('idlelib.idle_test.test_mainmenu', verbosity=2) diff --git a/Lib/idlelib/multicall.py b/Lib/idlelib/multicall.py new file mode 100644 index 000000000..41f818131 --- /dev/null +++ b/Lib/idlelib/multicall.py @@ -0,0 +1,451 @@ +""" +MultiCall - a class which inherits its methods from a Tkinter widget (Text, for +example), but enables multiple calls of functions per virtual event - all +matching events will be called, not only the most specific one. This is done +by wrapping the event functions - event_add, event_delete and event_info. +MultiCall recognizes only a subset of legal event sequences. Sequences which +are not recognized are treated by the original Tk handling mechanism. A +more-specific event will be called before a less-specific event. + +The recognized sequences are complete one-event sequences (no emacs-style +Ctrl-X Ctrl-C, no shortcuts like <3>), for all types of events. +Key/Button Press/Release events can have modifiers. +The recognized modifiers are Shift, Control, Option and Command for Mac, and +Control, Alt, Shift, Meta/M for other platforms. + +For all events which were handled by MultiCall, a new member is added to the +event instance passed to the binded functions - mc_type. This is one of the +event type constants defined in this module (such as MC_KEYPRESS). +For Key/Button events (which are handled by MultiCall and may receive +modifiers), another member is added - mc_state. This member gives the state +of the recognized modifiers, as a combination of the modifier constants +also defined in this module (for example, MC_SHIFT). +Using these members is absolutely portable. + +The order by which events are called is defined by these rules: +1. A more-specific event will be called before a less-specific event. +2. A recently-binded event will be called before a previously-binded event, + unless this conflicts with the first rule. +Each function will be called at most once for each event. +""" +import re +import sys + +import tkinter + +# the event type constants, which define the meaning of mc_type +MC_KEYPRESS=0; MC_KEYRELEASE=1; MC_BUTTONPRESS=2; MC_BUTTONRELEASE=3; +MC_ACTIVATE=4; MC_CIRCULATE=5; MC_COLORMAP=6; MC_CONFIGURE=7; +MC_DEACTIVATE=8; MC_DESTROY=9; MC_ENTER=10; MC_EXPOSE=11; MC_FOCUSIN=12; +MC_FOCUSOUT=13; MC_GRAVITY=14; MC_LEAVE=15; MC_MAP=16; MC_MOTION=17; +MC_MOUSEWHEEL=18; MC_PROPERTY=19; MC_REPARENT=20; MC_UNMAP=21; MC_VISIBILITY=22; +# the modifier state constants, which define the meaning of mc_state +MC_SHIFT = 1<<0; MC_CONTROL = 1<<2; MC_ALT = 1<<3; MC_META = 1<<5 +MC_OPTION = 1<<6; MC_COMMAND = 1<<7 + +# define the list of modifiers, to be used in complex event types. +if sys.platform == "darwin": + _modifiers = (("Shift",), ("Control",), ("Option",), ("Command",)) + _modifier_masks = (MC_SHIFT, MC_CONTROL, MC_OPTION, MC_COMMAND) +else: + _modifiers = (("Control",), ("Alt",), ("Shift",), ("Meta", "M")) + _modifier_masks = (MC_CONTROL, MC_ALT, MC_SHIFT, MC_META) + +# a dictionary to map a modifier name into its number +_modifier_names = {name: number + for number in range(len(_modifiers)) + for name in _modifiers[number]} + +# In 3.4, if no shell window is ever open, the underlying Tk widget is +# destroyed before .__del__ methods here are called. The following +# is used to selectively ignore shutdown exceptions to avoid +# 'Exception ignored' messages. See http://bugs.python.org/issue20167 +APPLICATION_GONE = "application has been destroyed" + +# A binder is a class which binds functions to one type of event. It has two +# methods: bind and unbind, which get a function and a parsed sequence, as +# returned by _parse_sequence(). There are two types of binders: +# _SimpleBinder handles event types with no modifiers and no detail. +# No Python functions are called when no events are binded. +# _ComplexBinder handles event types with modifiers and a detail. +# A Python function is called each time an event is generated. + +class _SimpleBinder: + def __init__(self, type, widget, widgetinst): + self.type = type + self.sequence = '<'+_types[type][0]+'>' + self.widget = widget + self.widgetinst = widgetinst + self.bindedfuncs = [] + self.handlerid = None + + def bind(self, triplet, func): + if not self.handlerid: + def handler(event, l = self.bindedfuncs, mc_type = self.type): + event.mc_type = mc_type + wascalled = {} + for i in range(len(l)-1, -1, -1): + func = l[i] + if func not in wascalled: + wascalled[func] = True + r = func(event) + if r: + return r + self.handlerid = self.widget.bind(self.widgetinst, + self.sequence, handler) + self.bindedfuncs.append(func) + + def unbind(self, triplet, func): + self.bindedfuncs.remove(func) + if not self.bindedfuncs: + self.widget.unbind(self.widgetinst, self.sequence, self.handlerid) + self.handlerid = None + + def __del__(self): + if self.handlerid: + try: + self.widget.unbind(self.widgetinst, self.sequence, + self.handlerid) + except tkinter.TclError as e: + if not APPLICATION_GONE in e.args[0]: + raise + +# An int in range(1 << len(_modifiers)) represents a combination of modifiers +# (if the least significant bit is on, _modifiers[0] is on, and so on). +# _state_subsets gives for each combination of modifiers, or *state*, +# a list of the states which are a subset of it. This list is ordered by the +# number of modifiers is the state - the most specific state comes first. +_states = range(1 << len(_modifiers)) +_state_names = [''.join(m[0]+'-' + for i, m in enumerate(_modifiers) + if (1 << i) & s) + for s in _states] + +def expand_substates(states): + '''For each item of states return a list containing all combinations of + that item with individual bits reset, sorted by the number of set bits. + ''' + def nbits(n): + "number of bits set in n base 2" + nb = 0 + while n: + n, rem = divmod(n, 2) + nb += rem + return nb + statelist = [] + for state in states: + substates = list({state & x for x in states}) + substates.sort(key=nbits, reverse=True) + statelist.append(substates) + return statelist + +_state_subsets = expand_substates(_states) + +# _state_codes gives for each state, the portable code to be passed as mc_state +_state_codes = [] +for s in _states: + r = 0 + for i in range(len(_modifiers)): + if (1 << i) & s: + r |= _modifier_masks[i] + _state_codes.append(r) + +class _ComplexBinder: + # This class binds many functions, and only unbinds them when it is deleted. + # self.handlerids is the list of seqs and ids of binded handler functions. + # The binded functions sit in a dictionary of lists of lists, which maps + # a detail (or None) and a state into a list of functions. + # When a new detail is discovered, handlers for all the possible states + # are binded. + + def __create_handler(self, lists, mc_type, mc_state): + def handler(event, lists = lists, + mc_type = mc_type, mc_state = mc_state, + ishandlerrunning = self.ishandlerrunning, + doafterhandler = self.doafterhandler): + ishandlerrunning[:] = [True] + event.mc_type = mc_type + event.mc_state = mc_state + wascalled = {} + r = None + for l in lists: + for i in range(len(l)-1, -1, -1): + func = l[i] + if func not in wascalled: + wascalled[func] = True + r = l[i](event) + if r: + break + if r: + break + ishandlerrunning[:] = [] + # Call all functions in doafterhandler and remove them from list + for f in doafterhandler: + f() + doafterhandler[:] = [] + if r: + return r + return handler + + def __init__(self, type, widget, widgetinst): + self.type = type + self.typename = _types[type][0] + self.widget = widget + self.widgetinst = widgetinst + self.bindedfuncs = {None: [[] for s in _states]} + self.handlerids = [] + # we don't want to change the lists of functions while a handler is + # running - it will mess up the loop and anyway, we usually want the + # change to happen from the next event. So we have a list of functions + # for the handler to run after it finishes calling the binded functions. + # It calls them only once. + # ishandlerrunning is a list. An empty one means no, otherwise - yes. + # this is done so that it would be mutable. + self.ishandlerrunning = [] + self.doafterhandler = [] + for s in _states: + lists = [self.bindedfuncs[None][i] for i in _state_subsets[s]] + handler = self.__create_handler(lists, type, _state_codes[s]) + seq = '<'+_state_names[s]+self.typename+'>' + self.handlerids.append((seq, self.widget.bind(self.widgetinst, + seq, handler))) + + def bind(self, triplet, func): + if triplet[2] not in self.bindedfuncs: + self.bindedfuncs[triplet[2]] = [[] for s in _states] + for s in _states: + lists = [ self.bindedfuncs[detail][i] + for detail in (triplet[2], None) + for i in _state_subsets[s] ] + handler = self.__create_handler(lists, self.type, + _state_codes[s]) + seq = "<%s%s-%s>"% (_state_names[s], self.typename, triplet[2]) + self.handlerids.append((seq, self.widget.bind(self.widgetinst, + seq, handler))) + doit = lambda: self.bindedfuncs[triplet[2]][triplet[0]].append(func) + if not self.ishandlerrunning: + doit() + else: + self.doafterhandler.append(doit) + + def unbind(self, triplet, func): + doit = lambda: self.bindedfuncs[triplet[2]][triplet[0]].remove(func) + if not self.ishandlerrunning: + doit() + else: + self.doafterhandler.append(doit) + + def __del__(self): + for seq, id in self.handlerids: + try: + self.widget.unbind(self.widgetinst, seq, id) + except tkinter.TclError as e: + if not APPLICATION_GONE in e.args[0]: + raise + +# define the list of event types to be handled by MultiEvent. the order is +# compatible with the definition of event type constants. +_types = ( + ("KeyPress", "Key"), ("KeyRelease",), ("ButtonPress", "Button"), + ("ButtonRelease",), ("Activate",), ("Circulate",), ("Colormap",), + ("Configure",), ("Deactivate",), ("Destroy",), ("Enter",), ("Expose",), + ("FocusIn",), ("FocusOut",), ("Gravity",), ("Leave",), ("Map",), + ("Motion",), ("MouseWheel",), ("Property",), ("Reparent",), ("Unmap",), + ("Visibility",), +) + +# which binder should be used for every event type? +_binder_classes = (_ComplexBinder,) * 4 + (_SimpleBinder,) * (len(_types)-4) + +# A dictionary to map a type name into its number +_type_names = {name: number + for number in range(len(_types)) + for name in _types[number]} + +_keysym_re = re.compile(r"^\w+$") +_button_re = re.compile(r"^[1-5]$") +def _parse_sequence(sequence): + """Get a string which should describe an event sequence. If it is + successfully parsed as one, return a tuple containing the state (as an int), + the event type (as an index of _types), and the detail - None if none, or a + string if there is one. If the parsing is unsuccessful, return None. + """ + if not sequence or sequence[0] != '<' or sequence[-1] != '>': + return None + words = sequence[1:-1].split('-') + modifiers = 0 + while words and words[0] in _modifier_names: + modifiers |= 1 << _modifier_names[words[0]] + del words[0] + if words and words[0] in _type_names: + type = _type_names[words[0]] + del words[0] + else: + return None + if _binder_classes[type] is _SimpleBinder: + if modifiers or words: + return None + else: + detail = None + else: + # _ComplexBinder + if type in [_type_names[s] for s in ("KeyPress", "KeyRelease")]: + type_re = _keysym_re + else: + type_re = _button_re + + if not words: + detail = None + elif len(words) == 1 and type_re.match(words[0]): + detail = words[0] + else: + return None + + return modifiers, type, detail + +def _triplet_to_sequence(triplet): + if triplet[2]: + return '<'+_state_names[triplet[0]]+_types[triplet[1]][0]+'-'+ \ + triplet[2]+'>' + else: + return '<'+_state_names[triplet[0]]+_types[triplet[1]][0]+'>' + +_multicall_dict = {} +def MultiCallCreator(widget): + """Return a MultiCall class which inherits its methods from the + given widget class (for example, Tkinter.Text). This is used + instead of a templating mechanism. + """ + if widget in _multicall_dict: + return _multicall_dict[widget] + + class MultiCall (widget): + assert issubclass(widget, tkinter.Misc) + + def __init__(self, *args, **kwargs): + widget.__init__(self, *args, **kwargs) + # a dictionary which maps a virtual event to a tuple with: + # 0. the function binded + # 1. a list of triplets - the sequences it is binded to + self.__eventinfo = {} + self.__binders = [_binder_classes[i](i, widget, self) + for i in range(len(_types))] + + def bind(self, sequence=None, func=None, add=None): + #print("bind(%s, %s, %s)" % (sequence, func, add), + # file=sys.__stderr__) + if type(sequence) is str and len(sequence) > 2 and \ + sequence[:2] == "<<" and sequence[-2:] == ">>": + if sequence in self.__eventinfo: + ei = self.__eventinfo[sequence] + if ei[0] is not None: + for triplet in ei[1]: + self.__binders[triplet[1]].unbind(triplet, ei[0]) + ei[0] = func + if ei[0] is not None: + for triplet in ei[1]: + self.__binders[triplet[1]].bind(triplet, func) + else: + self.__eventinfo[sequence] = [func, []] + return widget.bind(self, sequence, func, add) + + def unbind(self, sequence, funcid=None): + if type(sequence) is str and len(sequence) > 2 and \ + sequence[:2] == "<<" and sequence[-2:] == ">>" and \ + sequence in self.__eventinfo: + func, triplets = self.__eventinfo[sequence] + if func is not None: + for triplet in triplets: + self.__binders[triplet[1]].unbind(triplet, func) + self.__eventinfo[sequence][0] = None + return widget.unbind(self, sequence, funcid) + + def event_add(self, virtual, *sequences): + #print("event_add(%s, %s)" % (repr(virtual), repr(sequences)), + # file=sys.__stderr__) + if virtual not in self.__eventinfo: + self.__eventinfo[virtual] = [None, []] + + func, triplets = self.__eventinfo[virtual] + for seq in sequences: + triplet = _parse_sequence(seq) + if triplet is None: + #print("Tkinter event_add(%s)" % seq, file=sys.__stderr__) + widget.event_add(self, virtual, seq) + else: + if func is not None: + self.__binders[triplet[1]].bind(triplet, func) + triplets.append(triplet) + + def event_delete(self, virtual, *sequences): + if virtual not in self.__eventinfo: + return + func, triplets = self.__eventinfo[virtual] + for seq in sequences: + triplet = _parse_sequence(seq) + if triplet is None: + #print("Tkinter event_delete: %s" % seq, file=sys.__stderr__) + widget.event_delete(self, virtual, seq) + else: + if func is not None: + self.__binders[triplet[1]].unbind(triplet, func) + triplets.remove(triplet) + + def event_info(self, virtual=None): + if virtual is None or virtual not in self.__eventinfo: + return widget.event_info(self, virtual) + else: + return tuple(map(_triplet_to_sequence, + self.__eventinfo[virtual][1])) + \ + widget.event_info(self, virtual) + + def __del__(self): + for virtual in self.__eventinfo: + func, triplets = self.__eventinfo[virtual] + if func: + for triplet in triplets: + try: + self.__binders[triplet[1]].unbind(triplet, func) + except tkinter.TclError as e: + if not APPLICATION_GONE in e.args[0]: + raise + + _multicall_dict[widget] = MultiCall + return MultiCall + + +def _multi_call(parent): # htest # + top = tkinter.Toplevel(parent) + top.title("Test MultiCall") + x, y = map(int, parent.geometry().split('+')[1:]) + top.geometry("+%d+%d" % (x, y + 175)) + text = MultiCallCreator(tkinter.Text)(top) + text.pack() + text.focus_set() + + def bindseq(seq, n=[0]): + def handler(event): + print(seq) + text.bind("<>"%n[0], handler) + text.event_add("<>"%n[0], seq) + n[0] += 1 + bindseq("") + bindseq("") + bindseq("") + bindseq("") + bindseq("") + bindseq("") + bindseq("") + bindseq("") + bindseq("") + bindseq("") + bindseq("") + bindseq("") + + +if __name__ == "__main__": + from unittest import main + main('idlelib.idle_test.test_mainmenu', verbosity=2, exit=False) + + from idlelib.idle_test.htest import run + run(_multi_call) diff --git a/Lib/idlelib/outwin.py b/Lib/idlelib/outwin.py new file mode 100644 index 000000000..8baa65755 --- /dev/null +++ b/Lib/idlelib/outwin.py @@ -0,0 +1,188 @@ +"""Editor window that can serve as an output file. +""" + +import re + +from tkinter import messagebox + +from idlelib.editor import EditorWindow + + +file_line_pats = [ + # order of patterns matters + r'file "([^"]*)", line (\d+)', + r'([^\s]+)\((\d+)\)', + r'^(\s*\S.*?):\s*(\d+):', # Win filename, maybe starting with spaces + r'([^\s]+):\s*(\d+):', # filename or path, ltrim + r'^\s*(\S.*?):\s*(\d+):', # Win abs path with embedded spaces, ltrim +] + +file_line_progs = None + + +def compile_progs(): + "Compile the patterns for matching to file name and line number." + global file_line_progs + file_line_progs = [re.compile(pat, re.IGNORECASE) + for pat in file_line_pats] + + +def file_line_helper(line): + """Extract file name and line number from line of text. + + Check if line of text contains one of the file/line patterns. + If it does and if the file and line are valid, return + a tuple of the file name and line number. If it doesn't match + or if the file or line is invalid, return None. + """ + if not file_line_progs: + compile_progs() + for prog in file_line_progs: + match = prog.search(line) + if match: + filename, lineno = match.group(1, 2) + try: + f = open(filename) + f.close() + break + except OSError: + continue + else: + return None + try: + return filename, int(lineno) + except TypeError: + return None + + +class OutputWindow(EditorWindow): + """An editor window that can serve as an output file. + + Also the future base class for the Python shell window. + This class has no input facilities. + + Adds binding to open a file at a line to the text widget. + """ + + # Our own right-button menu + rmenu_specs = [ + ("Cut", "<>", "rmenu_check_cut"), + ("Copy", "<>", "rmenu_check_copy"), + ("Paste", "<>", "rmenu_check_paste"), + (None, None, None), + ("Go to file/line", "<>", None), + ] + + allow_code_context = False + + def __init__(self, *args): + EditorWindow.__init__(self, *args) + self.text.bind("<>", self.goto_file_line) + + # Customize EditorWindow + def ispythonsource(self, filename): + "Python source is only part of output: do not colorize." + return False + + def short_title(self): + "Customize EditorWindow title." + return "Output" + + def maybesave(self): + "Customize EditorWindow to not display save file messagebox." + return 'yes' if self.get_saved() else 'no' + + # Act as output file + def write(self, s, tags=(), mark="insert"): + """Write text to text widget. + + The text is inserted at the given index with the provided + tags. The text widget is then scrolled to make it visible + and updated to display it, giving the effect of seeing each + line as it is added. + + Args: + s: Text to insert into text widget. + tags: Tuple of tag strings to apply on the insert. + mark: Index for the insert. + + Return: + Length of text inserted. + """ + assert isinstance(s, str) + self.text.insert(mark, s, tags) + self.text.see(mark) + self.text.update() + return len(s) + + def writelines(self, lines): + "Write each item in lines iterable." + for line in lines: + self.write(line) + + def flush(self): + "No flushing needed as write() directly writes to widget." + pass + + def showerror(self, *args, **kwargs): + messagebox.showerror(*args, **kwargs) + + def goto_file_line(self, event=None): + """Handle request to open file/line. + + If the selected or previous line in the output window + contains a file name and line number, then open that file + name in a new window and position on the line number. + + Otherwise, display an error messagebox. + """ + line = self.text.get("insert linestart", "insert lineend") + result = file_line_helper(line) + if not result: + # Try the previous line. This is handy e.g. in tracebacks, + # where you tend to right-click on the displayed source line + line = self.text.get("insert -1line linestart", + "insert -1line lineend") + result = file_line_helper(line) + if not result: + self.showerror( + "No special line", + "The line you point at doesn't look like " + "a valid file name followed by a line number.", + parent=self.text) + return + filename, lineno = result + self.flist.gotofileline(filename, lineno) + + +# These classes are currently not used but might come in handy +class OnDemandOutputWindow: + + tagdefs = { + # XXX Should use IdlePrefs.ColorPrefs + "stdout": {"foreground": "blue"}, + "stderr": {"foreground": "#007700"}, + } + + def __init__(self, flist): + self.flist = flist + self.owin = None + + def write(self, s, tags, mark): + if not self.owin: + self.setup() + self.owin.write(s, tags, mark) + + def setup(self): + self.owin = owin = OutputWindow(self.flist) + text = owin.text + for tag, cnf in self.tagdefs.items(): + if cnf: + text.tag_configure(tag, **cnf) + text.tag_raise('sel') + self.write = self.owin.write + + +if __name__ == '__main__': + from unittest import main + main('idlelib.idle_test.test_outwin', verbosity=2, exit=False) diff --git a/Lib/idlelib/parenmatch.py b/Lib/idlelib/parenmatch.py new file mode 100644 index 000000000..3fd7aadb2 --- /dev/null +++ b/Lib/idlelib/parenmatch.py @@ -0,0 +1,183 @@ +"""ParenMatch -- for parenthesis matching. + +When you hit a right paren, the cursor should move briefly to the left +paren. Paren here is used generically; the matching applies to +parentheses, square brackets, and curly braces. +""" +from idlelib.hyperparser import HyperParser +from idlelib.config import idleConf + +_openers = {')':'(',']':'[','}':'{'} +CHECK_DELAY = 100 # milliseconds + +class ParenMatch: + """Highlight matching openers and closers, (), [], and {}. + + There are three supported styles of paren matching. When a right + paren (opener) is typed: + + opener -- highlight the matching left paren (closer); + parens -- highlight the left and right parens (opener and closer); + expression -- highlight the entire expression from opener to closer. + (For back compatibility, 'default' is a synonym for 'opener'). + + Flash-delay is the maximum milliseconds the highlighting remains. + Any cursor movement (key press or click) before that removes the + highlight. If flash-delay is 0, there is no maximum. + + TODO: + - Augment bell() with mismatch warning in status window. + - Highlight when cursor is moved to the right of a closer. + This might be too expensive to check. + """ + + RESTORE_VIRTUAL_EVENT_NAME = "<>" + # We want the restore event be called before the usual return and + # backspace events. + RESTORE_SEQUENCES = ("", "", + "", "") + + def __init__(self, editwin): + self.editwin = editwin + self.text = editwin.text + # Bind the check-restore event to the function restore_event, + # so that we can then use activate_restore (which calls event_add) + # and deactivate_restore (which calls event_delete). + editwin.text.bind(self.RESTORE_VIRTUAL_EVENT_NAME, + self.restore_event) + self.counter = 0 + self.is_restore_active = 0 + + @classmethod + def reload(cls): + cls.STYLE = idleConf.GetOption( + 'extensions','ParenMatch','style', default='opener') + cls.FLASH_DELAY = idleConf.GetOption( + 'extensions','ParenMatch','flash-delay', type='int',default=500) + cls.BELL = idleConf.GetOption( + 'extensions','ParenMatch','bell', type='bool', default=1) + cls.HILITE_CONFIG = idleConf.GetHighlight(idleConf.CurrentTheme(), + 'hilite') + + def activate_restore(self): + "Activate mechanism to restore text from highlighting." + if not self.is_restore_active: + for seq in self.RESTORE_SEQUENCES: + self.text.event_add(self.RESTORE_VIRTUAL_EVENT_NAME, seq) + self.is_restore_active = True + + def deactivate_restore(self): + "Remove restore event bindings." + if self.is_restore_active: + for seq in self.RESTORE_SEQUENCES: + self.text.event_delete(self.RESTORE_VIRTUAL_EVENT_NAME, seq) + self.is_restore_active = False + + def flash_paren_event(self, event): + "Handle editor 'show surrounding parens' event (menu or shortcut)." + indices = (HyperParser(self.editwin, "insert") + .get_surrounding_brackets()) + self.finish_paren_event(indices) + return "break" + + def paren_closed_event(self, event): + "Handle user input of closer." + # If user bound non-closer to <>, quit. + closer = self.text.get("insert-1c") + if closer not in _openers: + return + hp = HyperParser(self.editwin, "insert-1c") + if not hp.is_in_code(): + return + indices = hp.get_surrounding_brackets(_openers[closer], True) + self.finish_paren_event(indices) + return # Allow calltips to see ')' + + def finish_paren_event(self, indices): + if indices is None and self.BELL: + self.text.bell() + return + self.activate_restore() + # self.create_tag(indices) + self.tagfuncs.get(self.STYLE, self.create_tag_expression)(self, indices) + # self.set_timeout() + (self.set_timeout_last if self.FLASH_DELAY else + self.set_timeout_none)() + + def restore_event(self, event=None): + "Remove effect of doing match." + self.text.tag_delete("paren") + self.deactivate_restore() + self.counter += 1 # disable the last timer, if there is one. + + def handle_restore_timer(self, timer_count): + if timer_count == self.counter: + self.restore_event() + + # any one of the create_tag_XXX methods can be used depending on + # the style + + def create_tag_opener(self, indices): + """Highlight the single paren that matches""" + self.text.tag_add("paren", indices[0]) + self.text.tag_config("paren", self.HILITE_CONFIG) + + def create_tag_parens(self, indices): + """Highlight the left and right parens""" + if self.text.get(indices[1]) in (')', ']', '}'): + rightindex = indices[1]+"+1c" + else: + rightindex = indices[1] + self.text.tag_add("paren", indices[0], indices[0]+"+1c", rightindex+"-1c", rightindex) + self.text.tag_config("paren", self.HILITE_CONFIG) + + def create_tag_expression(self, indices): + """Highlight the entire expression""" + if self.text.get(indices[1]) in (')', ']', '}'): + rightindex = indices[1]+"+1c" + else: + rightindex = indices[1] + self.text.tag_add("paren", indices[0], rightindex) + self.text.tag_config("paren", self.HILITE_CONFIG) + + tagfuncs = { + 'opener': create_tag_opener, + 'default': create_tag_opener, + 'parens': create_tag_parens, + 'expression': create_tag_expression, + } + + # any one of the set_timeout_XXX methods can be used depending on + # the style + + def set_timeout_none(self): + """Highlight will remain until user input turns it off + or the insert has moved""" + # After CHECK_DELAY, call a function which disables the "paren" tag + # if the event is for the most recent timer and the insert has changed, + # or schedules another call for itself. + self.counter += 1 + def callme(callme, self=self, c=self.counter, + index=self.text.index("insert")): + if index != self.text.index("insert"): + self.handle_restore_timer(c) + else: + self.editwin.text_frame.after(CHECK_DELAY, callme, callme) + self.editwin.text_frame.after(CHECK_DELAY, callme, callme) + + def set_timeout_last(self): + """The last highlight created will be removed after FLASH_DELAY millisecs""" + # associate a counter with an event; only disable the "paren" + # tag if the event is for the most recent timer. + self.counter += 1 + self.editwin.text_frame.after( + self.FLASH_DELAY, + lambda self=self, c=self.counter: self.handle_restore_timer(c)) + + +ParenMatch.reload() + + +if __name__ == '__main__': + from unittest import main + main('idlelib.idle_test.test_parenmatch', verbosity=2) diff --git a/Lib/idlelib/pathbrowser.py b/Lib/idlelib/pathbrowser.py new file mode 100644 index 000000000..48a77875b --- /dev/null +++ b/Lib/idlelib/pathbrowser.py @@ -0,0 +1,107 @@ +import importlib.machinery +import os +import sys + +from idlelib.browser import ModuleBrowser, ModuleBrowserTreeItem +from idlelib.tree import TreeItem + + +class PathBrowser(ModuleBrowser): + + def __init__(self, master, *, _htest=False, _utest=False): + """ + _htest - bool, change box location when running htest + """ + self.master = master + self._htest = _htest + self._utest = _utest + self.init() + + def settitle(self): + "Set window titles." + self.top.wm_title("Path Browser") + self.top.wm_iconname("Path Browser") + + def rootnode(self): + return PathBrowserTreeItem() + + +class PathBrowserTreeItem(TreeItem): + + def GetText(self): + return "sys.path" + + def GetSubList(self): + sublist = [] + for dir in sys.path: + item = DirBrowserTreeItem(dir) + sublist.append(item) + return sublist + + +class DirBrowserTreeItem(TreeItem): + + def __init__(self, dir, packages=[]): + self.dir = dir + self.packages = packages + + def GetText(self): + if not self.packages: + return self.dir + else: + return self.packages[-1] + ": package" + + def GetSubList(self): + try: + names = os.listdir(self.dir or os.curdir) + except OSError: + return [] + packages = [] + for name in names: + file = os.path.join(self.dir, name) + if self.ispackagedir(file): + nn = os.path.normcase(name) + packages.append((nn, name, file)) + packages.sort() + sublist = [] + for nn, name, file in packages: + item = DirBrowserTreeItem(file, self.packages + [name]) + sublist.append(item) + for nn, name in self.listmodules(names): + item = ModuleBrowserTreeItem(os.path.join(self.dir, name)) + sublist.append(item) + return sublist + + def ispackagedir(self, file): + " Return true for directories that are packages." + if not os.path.isdir(file): + return False + init = os.path.join(file, "__init__.py") + return os.path.exists(init) + + def listmodules(self, allnames): + modules = {} + suffixes = importlib.machinery.EXTENSION_SUFFIXES[:] + suffixes += importlib.machinery.SOURCE_SUFFIXES + suffixes += importlib.machinery.BYTECODE_SUFFIXES + sorted = [] + for suff in suffixes: + i = -len(suff) + for name in allnames[:]: + normed_name = os.path.normcase(name) + if normed_name[i:] == suff: + mod_name = name[:i] + if mod_name not in modules: + modules[mod_name] = None + sorted.append((normed_name, name)) + allnames.remove(name) + sorted.sort() + return sorted + + +if __name__ == "__main__": + from unittest import main + main('idlelib.idle_test.test_pathbrowser', verbosity=2, exit=False) + + from idlelib.idle_test.htest import run + run(PathBrowser) diff --git a/Lib/idlelib/percolator.py b/Lib/idlelib/percolator.py new file mode 100644 index 000000000..aa73427c4 --- /dev/null +++ b/Lib/idlelib/percolator.py @@ -0,0 +1,120 @@ +from idlelib.delegator import Delegator +from idlelib.redirector import WidgetRedirector + + +class Percolator: + + def __init__(self, text): + # XXX would be nice to inherit from Delegator + self.text = text + self.redir = WidgetRedirector(text) + self.top = self.bottom = Delegator(text) + self.bottom.insert = self.redir.register("insert", self.insert) + self.bottom.delete = self.redir.register("delete", self.delete) + self.filters = [] + + def close(self): + while self.top is not self.bottom: + self.removefilter(self.top) + self.top = None + self.bottom.setdelegate(None) + self.bottom = None + self.redir.close() + self.redir = None + self.text = None + + def insert(self, index, chars, tags=None): + # Could go away if inheriting from Delegator + self.top.insert(index, chars, tags) + + def delete(self, index1, index2=None): + # Could go away if inheriting from Delegator + self.top.delete(index1, index2) + + def insertfilter(self, filter): + # Perhaps rename to pushfilter()? + assert isinstance(filter, Delegator) + assert filter.delegate is None + filter.setdelegate(self.top) + self.top = filter + + def insertfilterafter(self, filter, after): + assert isinstance(filter, Delegator) + assert isinstance(after, Delegator) + assert filter.delegate is None + + f = self.top + f.resetcache() + while f is not after: + assert f is not self.bottom + f = f.delegate + f.resetcache() + + filter.setdelegate(f.delegate) + f.setdelegate(filter) + + def removefilter(self, filter): + # XXX Perhaps should only support popfilter()? + assert isinstance(filter, Delegator) + assert filter.delegate is not None + f = self.top + if f is filter: + self.top = filter.delegate + filter.setdelegate(None) + else: + while f.delegate is not filter: + assert f is not self.bottom + f.resetcache() + f = f.delegate + f.setdelegate(filter.delegate) + filter.setdelegate(None) + + +def _percolator(parent): # htest # + import tkinter as tk + + class Tracer(Delegator): + def __init__(self, name): + self.name = name + Delegator.__init__(self, None) + + def insert(self, *args): + print(self.name, ": insert", args) + self.delegate.insert(*args) + + def delete(self, *args): + print(self.name, ": delete", args) + self.delegate.delete(*args) + + top = tk.Toplevel(parent) + top.title("Test Percolator") + x, y = map(int, parent.geometry().split('+')[1:]) + top.geometry("+%d+%d" % (x, y + 175)) + text = tk.Text(top) + p = Percolator(text) + pin = p.insertfilter + pout = p.removefilter + t1 = Tracer("t1") + t2 = Tracer("t2") + + def toggle1(): + (pin if var1.get() else pout)(t1) + def toggle2(): + (pin if var2.get() else pout)(t2) + + text.pack() + text.focus_set() + var1 = tk.IntVar(parent) + cb1 = tk.Checkbutton(top, text="Tracer1", command=toggle1, variable=var1) + cb1.pack() + var2 = tk.IntVar(parent) + cb2 = tk.Checkbutton(top, text="Tracer2", command=toggle2, variable=var2) + cb2.pack() + + +if __name__ == "__main__": + from unittest import main + main('idlelib.idle_test.test_percolator', verbosity=2, exit=False) + + from idlelib.idle_test.htest import run + run(_percolator) diff --git a/Lib/idlelib/pyparse.py b/Lib/idlelib/pyparse.py new file mode 100644 index 000000000..8545c63e1 --- /dev/null +++ b/Lib/idlelib/pyparse.py @@ -0,0 +1,589 @@ +"""Define partial Python code Parser used by editor and hyperparser. + +Instances of ParseMap are used with str.translate. + +The following bound search and match functions are defined: +_synchre - start of popular statement; +_junkre - whitespace or comment line; +_match_stringre: string, possibly without closer; +_itemre - line that may have bracket structure start; +_closere - line that must be followed by dedent. +_chew_ordinaryre - non-special characters. +""" +import re + +# Reason last statement is continued (or C_NONE if it's not). +(C_NONE, C_BACKSLASH, C_STRING_FIRST_LINE, + C_STRING_NEXT_LINES, C_BRACKET) = range(5) + +# Find what looks like the start of a popular statement. + +_synchre = re.compile(r""" + ^ + [ \t]* + (?: while + | else + | def + | return + | assert + | break + | class + | continue + | elif + | try + | except + | raise + | import + | yield + ) + \b +""", re.VERBOSE | re.MULTILINE).search + +# Match blank line or non-indenting comment line. + +_junkre = re.compile(r""" + [ \t]* + (?: \# \S .* )? + \n +""", re.VERBOSE).match + +# Match any flavor of string; the terminating quote is optional +# so that we're robust in the face of incomplete program text. + +_match_stringre = re.compile(r""" + \""" [^"\\]* (?: + (?: \\. | "(?!"") ) + [^"\\]* + )* + (?: \""" )? + +| " [^"\\\n]* (?: \\. [^"\\\n]* )* "? + +| ''' [^'\\]* (?: + (?: \\. | '(?!'') ) + [^'\\]* + )* + (?: ''' )? + +| ' [^'\\\n]* (?: \\. [^'\\\n]* )* '? +""", re.VERBOSE | re.DOTALL).match + +# Match a line that starts with something interesting; +# used to find the first item of a bracket structure. + +_itemre = re.compile(r""" + [ \t]* + [^\s#\\] # if we match, m.end()-1 is the interesting char +""", re.VERBOSE).match + +# Match start of statements that should be followed by a dedent. + +_closere = re.compile(r""" + \s* + (?: return + | break + | continue + | raise + | pass + ) + \b +""", re.VERBOSE).match + +# Chew up non-special chars as quickly as possible. If match is +# successful, m.end() less 1 is the index of the last boring char +# matched. If match is unsuccessful, the string starts with an +# interesting char. + +_chew_ordinaryre = re.compile(r""" + [^[\](){}#'"\\]+ +""", re.VERBOSE).match + + +class ParseMap(dict): + r"""Dict subclass that maps anything not in dict to 'x'. + + This is designed to be used with str.translate in study1. + Anything not specifically mapped otherwise becomes 'x'. + Example: replace everything except whitespace with 'x'. + + >>> keepwhite = ParseMap((ord(c), ord(c)) for c in ' \t\n\r') + >>> "a + b\tc\nd".translate(keepwhite) + 'x x x\tx\nx' + """ + # Calling this triples access time; see bpo-32940 + def __missing__(self, key): + return 120 # ord('x') + + +# Map all ascii to 120 to avoid __missing__ call, then replace some. +trans = ParseMap.fromkeys(range(128), 120) +trans.update((ord(c), ord('(')) for c in "({[") # open brackets => '('; +trans.update((ord(c), ord(')')) for c in ")}]") # close brackets => ')'. +trans.update((ord(c), ord(c)) for c in "\"'\\\n#") # Keep these. + + +class Parser: + + def __init__(self, indentwidth, tabwidth): + self.indentwidth = indentwidth + self.tabwidth = tabwidth + + def set_code(self, s): + assert len(s) == 0 or s[-1] == '\n' + self.code = s + self.study_level = 0 + + def find_good_parse_start(self, is_char_in_string): + """ + Return index of a good place to begin parsing, as close to the + end of the string as possible. This will be the start of some + popular stmt like "if" or "def". Return None if none found: + the caller should pass more prior context then, if possible, or + if not (the entire program text up until the point of interest + has already been tried) pass 0 to set_lo(). + + This will be reliable iff given a reliable is_char_in_string() + function, meaning that when it says "no", it's absolutely + guaranteed that the char is not in a string. + """ + code, pos = self.code, None + + # Peek back from the end for a good place to start, + # but don't try too often; pos will be left None, or + # bumped to a legitimate synch point. + limit = len(code) + for tries in range(5): + i = code.rfind(":\n", 0, limit) + if i < 0: + break + i = code.rfind('\n', 0, i) + 1 # start of colon line (-1+1=0) + m = _synchre(code, i, limit) + if m and not is_char_in_string(m.start()): + pos = m.start() + break + limit = i + if pos is None: + # Nothing looks like a block-opener, or stuff does + # but is_char_in_string keeps returning true; most likely + # we're in or near a giant string, the colorizer hasn't + # caught up enough to be helpful, or there simply *aren't* + # any interesting stmts. In any of these cases we're + # going to have to parse the whole thing to be sure, so + # give it one last try from the start, but stop wasting + # time here regardless of the outcome. + m = _synchre(code) + if m and not is_char_in_string(m.start()): + pos = m.start() + return pos + + # Peeking back worked; look forward until _synchre no longer + # matches. + i = pos + 1 + while m := _synchre(code, i): + s, i = m.span() + if not is_char_in_string(s): + pos = s + return pos + + def set_lo(self, lo): + """ Throw away the start of the string. + + Intended to be called with the result of find_good_parse_start(). + """ + assert lo == 0 or self.code[lo-1] == '\n' + if lo > 0: + self.code = self.code[lo:] + + def _study1(self): + """Find the line numbers of non-continuation lines. + + As quickly as humanly possible , find the line numbers (0- + based) of the non-continuation lines. + Creates self.{goodlines, continuation}. + """ + if self.study_level >= 1: + return + self.study_level = 1 + + # Map all uninteresting characters to "x", all open brackets + # to "(", all close brackets to ")", then collapse runs of + # uninteresting characters. This can cut the number of chars + # by a factor of 10-40, and so greatly speed the following loop. + code = self.code + code = code.translate(trans) + code = code.replace('xxxxxxxx', 'x') + code = code.replace('xxxx', 'x') + code = code.replace('xx', 'x') + code = code.replace('xx', 'x') + code = code.replace('\nx', '\n') + # Replacing x\n with \n would be incorrect because + # x may be preceded by a backslash. + + # March over the squashed version of the program, accumulating + # the line numbers of non-continued stmts, and determining + # whether & why the last stmt is a continuation. + continuation = C_NONE + level = lno = 0 # level is nesting level; lno is line number + self.goodlines = goodlines = [0] + push_good = goodlines.append + i, n = 0, len(code) + while i < n: + ch = code[i] + i = i+1 + + # cases are checked in decreasing order of frequency + if ch == 'x': + continue + + if ch == '\n': + lno = lno + 1 + if level == 0: + push_good(lno) + # else we're in an unclosed bracket structure + continue + + if ch == '(': + level = level + 1 + continue + + if ch == ')': + if level: + level = level - 1 + # else the program is invalid, but we can't complain + continue + + if ch == '"' or ch == "'": + # consume the string + quote = ch + if code[i-1:i+2] == quote * 3: + quote = quote * 3 + firstlno = lno + w = len(quote) - 1 + i = i+w + while i < n: + ch = code[i] + i = i+1 + + if ch == 'x': + continue + + if code[i-1:i+w] == quote: + i = i+w + break + + if ch == '\n': + lno = lno + 1 + if w == 0: + # unterminated single-quoted string + if level == 0: + push_good(lno) + break + continue + + if ch == '\\': + assert i < n + if code[i] == '\n': + lno = lno + 1 + i = i+1 + continue + + # else comment char or paren inside string + + else: + # didn't break out of the loop, so we're still + # inside a string + if (lno - 1) == firstlno: + # before the previous \n in code, we were in the first + # line of the string + continuation = C_STRING_FIRST_LINE + else: + continuation = C_STRING_NEXT_LINES + continue # with outer loop + + if ch == '#': + # consume the comment + i = code.find('\n', i) + assert i >= 0 + continue + + assert ch == '\\' + assert i < n + if code[i] == '\n': + lno = lno + 1 + if i+1 == n: + continuation = C_BACKSLASH + i = i+1 + + # The last stmt may be continued for all 3 reasons. + # String continuation takes precedence over bracket + # continuation, which beats backslash continuation. + if (continuation != C_STRING_FIRST_LINE + and continuation != C_STRING_NEXT_LINES and level > 0): + continuation = C_BRACKET + self.continuation = continuation + + # Push the final line number as a sentinel value, regardless of + # whether it's continued. + assert (continuation == C_NONE) == (goodlines[-1] == lno) + if goodlines[-1] != lno: + push_good(lno) + + def get_continuation_type(self): + self._study1() + return self.continuation + + def _study2(self): + """ + study1 was sufficient to determine the continuation status, + but doing more requires looking at every character. study2 + does this for the last interesting statement in the block. + Creates: + self.stmt_start, stmt_end + slice indices of last interesting stmt + self.stmt_bracketing + the bracketing structure of the last interesting stmt; for + example, for the statement "say(boo) or die", + stmt_bracketing will be ((0, 0), (0, 1), (2, 0), (2, 1), + (4, 0)). Strings and comments are treated as brackets, for + the matter. + self.lastch + last interesting character before optional trailing comment + self.lastopenbracketpos + if continuation is C_BRACKET, index of last open bracket + """ + if self.study_level >= 2: + return + self._study1() + self.study_level = 2 + + # Set p and q to slice indices of last interesting stmt. + code, goodlines = self.code, self.goodlines + i = len(goodlines) - 1 # Index of newest line. + p = len(code) # End of goodlines[i] + while i: + assert p + # Make p be the index of the stmt at line number goodlines[i]. + # Move p back to the stmt at line number goodlines[i-1]. + q = p + for nothing in range(goodlines[i-1], goodlines[i]): + # tricky: sets p to 0 if no preceding newline + p = code.rfind('\n', 0, p-1) + 1 + # The stmt code[p:q] isn't a continuation, but may be blank + # or a non-indenting comment line. + if _junkre(code, p): + i = i-1 + else: + break + if i == 0: + # nothing but junk! + assert p == 0 + q = p + self.stmt_start, self.stmt_end = p, q + + # Analyze this stmt, to find the last open bracket (if any) + # and last interesting character (if any). + lastch = "" + stack = [] # stack of open bracket indices + push_stack = stack.append + bracketing = [(p, 0)] + while p < q: + # suck up all except ()[]{}'"#\\ + m = _chew_ordinaryre(code, p, q) + if m: + # we skipped at least one boring char + newp = m.end() + # back up over totally boring whitespace + i = newp - 1 # index of last boring char + while i >= p and code[i] in " \t\n": + i = i-1 + if i >= p: + lastch = code[i] + p = newp + if p >= q: + break + + ch = code[p] + + if ch in "([{": + push_stack(p) + bracketing.append((p, len(stack))) + lastch = ch + p = p+1 + continue + + if ch in ")]}": + if stack: + del stack[-1] + lastch = ch + p = p+1 + bracketing.append((p, len(stack))) + continue + + if ch == '"' or ch == "'": + # consume string + # Note that study1 did this with a Python loop, but + # we use a regexp here; the reason is speed in both + # cases; the string may be huge, but study1 pre-squashed + # strings to a couple of characters per line. study1 + # also needed to keep track of newlines, and we don't + # have to. + bracketing.append((p, len(stack)+1)) + lastch = ch + p = _match_stringre(code, p, q).end() + bracketing.append((p, len(stack))) + continue + + if ch == '#': + # consume comment and trailing newline + bracketing.append((p, len(stack)+1)) + p = code.find('\n', p, q) + 1 + assert p > 0 + bracketing.append((p, len(stack))) + continue + + assert ch == '\\' + p = p+1 # beyond backslash + assert p < q + if code[p] != '\n': + # the program is invalid, but can't complain + lastch = ch + code[p] + p = p+1 # beyond escaped char + + # end while p < q: + + self.lastch = lastch + self.lastopenbracketpos = stack[-1] if stack else None + self.stmt_bracketing = tuple(bracketing) + + def compute_bracket_indent(self): + """Return number of spaces the next line should be indented. + + Line continuation must be C_BRACKET. + """ + self._study2() + assert self.continuation == C_BRACKET + j = self.lastopenbracketpos + code = self.code + n = len(code) + origi = i = code.rfind('\n', 0, j) + 1 + j = j+1 # one beyond open bracket + # find first list item; set i to start of its line + while j < n: + m = _itemre(code, j) + if m: + j = m.end() - 1 # index of first interesting char + extra = 0 + break + else: + # this line is junk; advance to next line + i = j = code.find('\n', j) + 1 + else: + # nothing interesting follows the bracket; + # reproduce the bracket line's indentation + a level + j = i = origi + while code[j] in " \t": + j = j+1 + extra = self.indentwidth + return len(code[i:j].expandtabs(self.tabwidth)) + extra + + def get_num_lines_in_stmt(self): + """Return number of physical lines in last stmt. + + The statement doesn't have to be an interesting statement. This is + intended to be called when continuation is C_BACKSLASH. + """ + self._study1() + goodlines = self.goodlines + return goodlines[-1] - goodlines[-2] + + def compute_backslash_indent(self): + """Return number of spaces the next line should be indented. + + Line continuation must be C_BACKSLASH. Also assume that the new + line is the first one following the initial line of the stmt. + """ + self._study2() + assert self.continuation == C_BACKSLASH + code = self.code + i = self.stmt_start + while code[i] in " \t": + i = i+1 + startpos = i + + # See whether the initial line starts an assignment stmt; i.e., + # look for an = operator + endpos = code.find('\n', startpos) + 1 + found = level = 0 + while i < endpos: + ch = code[i] + if ch in "([{": + level = level + 1 + i = i+1 + elif ch in ")]}": + if level: + level = level - 1 + i = i+1 + elif ch == '"' or ch == "'": + i = _match_stringre(code, i, endpos).end() + elif ch == '#': + # This line is unreachable because the # makes a comment of + # everything after it. + break + elif level == 0 and ch == '=' and \ + (i == 0 or code[i-1] not in "=<>!") and \ + code[i+1] != '=': + found = 1 + break + else: + i = i+1 + + if found: + # found a legit =, but it may be the last interesting + # thing on the line + i = i+1 # move beyond the = + found = re.match(r"\s*\\", code[i:endpos]) is None + + if not found: + # oh well ... settle for moving beyond the first chunk + # of non-whitespace chars + i = startpos + while code[i] not in " \t\n": + i = i+1 + + return len(code[self.stmt_start:i].expandtabs(\ + self.tabwidth)) + 1 + + def get_base_indent_string(self): + """Return the leading whitespace on the initial line of the last + interesting stmt. + """ + self._study2() + i, n = self.stmt_start, self.stmt_end + j = i + code = self.code + while j < n and code[j] in " \t": + j = j + 1 + return code[i:j] + + def is_block_opener(self): + "Return True if the last interesting statement opens a block." + self._study2() + return self.lastch == ':' + + def is_block_closer(self): + "Return True if the last interesting statement closes a block." + self._study2() + return _closere(self.code, self.stmt_start) is not None + + def get_last_stmt_bracketing(self): + """Return bracketing structure of the last interesting statement. + + The returned tuple is in the format defined in _study2(). + """ + self._study2() + return self.stmt_bracketing + + +if __name__ == '__main__': + from unittest import main + main('idlelib.idle_test.test_pyparse', verbosity=2) diff --git a/Lib/idlelib/pyshell.py b/Lib/idlelib/pyshell.py new file mode 100755 index 000000000..1b7c2af1a --- /dev/null +++ b/Lib/idlelib/pyshell.py @@ -0,0 +1,1699 @@ +#! /usr/bin/env python3 + +import sys +if __name__ == "__main__": + sys.modules['idlelib.pyshell'] = sys.modules['__main__'] + +try: + from tkinter import * +except ImportError: + print("** IDLE can't import Tkinter.\n" + "Your Python may not be configured for Tk. **", file=sys.__stderr__) + raise SystemExit(1) + +if sys.platform == 'win32': + from idlelib.util import fix_win_hidpi + fix_win_hidpi() + +from tkinter import messagebox + +from code import InteractiveInterpreter +import itertools +import linecache +import os +import os.path +import re +import socket +import subprocess +from textwrap import TextWrapper +import threading +import time +import tokenize +import warnings + +from idlelib.colorizer import ColorDelegator +from idlelib.config import idleConf +from idlelib.delegator import Delegator +from idlelib import debugger +from idlelib import debugger_r +from idlelib.editor import EditorWindow, fixwordbreaks +from idlelib.filelist import FileList +from idlelib.outwin import OutputWindow +from idlelib import replace +from idlelib import rpc +from idlelib.run import idle_formatwarning, StdInputFile, StdOutputFile +from idlelib.undo import UndoDelegator + +# Default for testing; defaults to True in main() for running. +use_subprocess = False + +HOST = '127.0.0.1' # python execution server on localhost loopback +PORT = 0 # someday pass in host, port for remote debug capability + +try: # In case IDLE started with -n. + eof = 'Ctrl-D (end-of-file)' + exit.eof = eof + quit.eof = eof +except NameError: # In case python started with -S. + pass + +# Override warnings module to write to warning_stream. Initialize to send IDLE +# internal warnings to the console. ScriptBinding.check_syntax() will +# temporarily redirect the stream to the shell window to display warnings when +# checking user's code. +warning_stream = sys.__stderr__ # None, at least on Windows, if no console. + +def idle_showwarning( + message, category, filename, lineno, file=None, line=None): + """Show Idle-format warning (after replacing warnings.showwarning). + + The differences are the formatter called, the file=None replacement, + which can be None, the capture of the consequence AttributeError, + and the output of a hard-coded prompt. + """ + if file is None: + file = warning_stream + try: + file.write(idle_formatwarning( + message, category, filename, lineno, line=line)) + file.write(">>> ") + except (AttributeError, OSError): + pass # if file (probably __stderr__) is invalid, skip warning. + +_warnings_showwarning = None + +def capture_warnings(capture): + "Replace warning.showwarning with idle_showwarning, or reverse." + + global _warnings_showwarning + if capture: + if _warnings_showwarning is None: + _warnings_showwarning = warnings.showwarning + warnings.showwarning = idle_showwarning + else: + if _warnings_showwarning is not None: + warnings.showwarning = _warnings_showwarning + _warnings_showwarning = None + +capture_warnings(True) + +def extended_linecache_checkcache(filename=None, + orig_checkcache=linecache.checkcache): + """Extend linecache.checkcache to preserve the entries + + Rather than repeating the linecache code, patch it to save the + entries, call the original linecache.checkcache() + (skipping them), and then restore the saved entries. + + orig_checkcache is bound at definition time to the original + method, allowing it to be patched. + """ + cache = linecache.cache + save = {} + for key in list(cache): + if key[:1] + key[-1:] == '<>': + save[key] = cache.pop(key) + orig_checkcache(filename) + cache.update(save) + +# Patch linecache.checkcache(): +linecache.checkcache = extended_linecache_checkcache + + +class PyShellEditorWindow(EditorWindow): + "Regular text edit window in IDLE, supports breakpoints" + + def __init__(self, *args): + self.breakpoints = [] + EditorWindow.__init__(self, *args) + self.text.bind("<>", self.set_breakpoint_event) + self.text.bind("<>", self.clear_breakpoint_event) + self.text.bind("<>", self.flist.open_shell) + + #TODO: don't read/write this from/to .idlerc when testing + self.breakpointPath = os.path.join( + idleConf.userdir, 'breakpoints.lst') + # whenever a file is changed, restore breakpoints + def filename_changed_hook(old_hook=self.io.filename_change_hook, + self=self): + self.restore_file_breaks() + old_hook() + self.io.set_filename_change_hook(filename_changed_hook) + if self.io.filename: + self.restore_file_breaks() + self.color_breakpoint_text() + + rmenu_specs = [ + ("Cut", "<>", "rmenu_check_cut"), + ("Copy", "<>", "rmenu_check_copy"), + ("Paste", "<>", "rmenu_check_paste"), + (None, None, None), + ("Set Breakpoint", "<>", None), + ("Clear Breakpoint", "<>", None) + ] + + def color_breakpoint_text(self, color=True): + "Turn colorizing of breakpoint text on or off" + if self.io is None: + # possible due to update in restore_file_breaks + return + if color: + theme = idleConf.CurrentTheme() + cfg = idleConf.GetHighlight(theme, "break") + else: + cfg = {'foreground': '', 'background': ''} + self.text.tag_config('BREAK', cfg) + + def set_breakpoint(self, lineno): + text = self.text + filename = self.io.filename + text.tag_add("BREAK", "%d.0" % lineno, "%d.0" % (lineno+1)) + try: + self.breakpoints.index(lineno) + except ValueError: # only add if missing, i.e. do once + self.breakpoints.append(lineno) + try: # update the subprocess debugger + debug = self.flist.pyshell.interp.debugger + debug.set_breakpoint(filename, lineno) + except: # but debugger may not be active right now.... + pass + + def set_breakpoint_event(self, event=None): + text = self.text + filename = self.io.filename + if not filename: + text.bell() + return + lineno = int(float(text.index("insert"))) + self.set_breakpoint(lineno) + + def clear_breakpoint_event(self, event=None): + text = self.text + filename = self.io.filename + if not filename: + text.bell() + return + lineno = int(float(text.index("insert"))) + try: + self.breakpoints.remove(lineno) + except: + pass + text.tag_remove("BREAK", "insert linestart",\ + "insert lineend +1char") + try: + debug = self.flist.pyshell.interp.debugger + debug.clear_breakpoint(filename, lineno) + except: + pass + + def clear_file_breaks(self): + if self.breakpoints: + text = self.text + filename = self.io.filename + if not filename: + text.bell() + return + self.breakpoints = [] + text.tag_remove("BREAK", "1.0", END) + try: + debug = self.flist.pyshell.interp.debugger + debug.clear_file_breaks(filename) + except: + pass + + def store_file_breaks(self): + "Save breakpoints when file is saved" + # XXX 13 Dec 2002 KBK Currently the file must be saved before it can + # be run. The breaks are saved at that time. If we introduce + # a temporary file save feature the save breaks functionality + # needs to be re-verified, since the breaks at the time the + # temp file is created may differ from the breaks at the last + # permanent save of the file. Currently, a break introduced + # after a save will be effective, but not persistent. + # This is necessary to keep the saved breaks synched with the + # saved file. + # + # Breakpoints are set as tagged ranges in the text. + # Since a modified file has to be saved before it is + # run, and since self.breakpoints (from which the subprocess + # debugger is loaded) is updated during the save, the visible + # breaks stay synched with the subprocess even if one of these + # unexpected breakpoint deletions occurs. + breaks = self.breakpoints + filename = self.io.filename + try: + with open(self.breakpointPath) as fp: + lines = fp.readlines() + except OSError: + lines = [] + try: + with open(self.breakpointPath, "w") as new_file: + for line in lines: + if not line.startswith(filename + '='): + new_file.write(line) + self.update_breakpoints() + breaks = self.breakpoints + if breaks: + new_file.write(filename + '=' + str(breaks) + '\n') + except OSError as err: + if not getattr(self.root, "breakpoint_error_displayed", False): + self.root.breakpoint_error_displayed = True + messagebox.showerror(title='IDLE Error', + message='Unable to update breakpoint list:\n%s' + % str(err), + parent=self.text) + + def restore_file_breaks(self): + self.text.update() # this enables setting "BREAK" tags to be visible + if self.io is None: + # can happen if IDLE closes due to the .update() call + return + filename = self.io.filename + if filename is None: + return + if os.path.isfile(self.breakpointPath): + with open(self.breakpointPath) as fp: + lines = fp.readlines() + for line in lines: + if line.startswith(filename + '='): + breakpoint_linenumbers = eval(line[len(filename)+1:]) + for breakpoint_linenumber in breakpoint_linenumbers: + self.set_breakpoint(breakpoint_linenumber) + + def update_breakpoints(self): + "Retrieves all the breakpoints in the current window" + text = self.text + ranges = text.tag_ranges("BREAK") + linenumber_list = self.ranges_to_linenumbers(ranges) + self.breakpoints = linenumber_list + + def ranges_to_linenumbers(self, ranges): + lines = [] + for index in range(0, len(ranges), 2): + lineno = int(float(ranges[index].string)) + end = int(float(ranges[index+1].string)) + while lineno < end: + lines.append(lineno) + lineno += 1 + return lines + +# XXX 13 Dec 2002 KBK Not used currently +# def saved_change_hook(self): +# "Extend base method - clear breaks if module is modified" +# if not self.get_saved(): +# self.clear_file_breaks() +# EditorWindow.saved_change_hook(self) + + def _close(self): + "Extend base method - clear breaks when module is closed" + self.clear_file_breaks() + EditorWindow._close(self) + + +class PyShellFileList(FileList): + "Extend base class: IDLE supports a shell and breakpoints" + + # override FileList's class variable, instances return PyShellEditorWindow + # instead of EditorWindow when new edit windows are created. + EditorWindow = PyShellEditorWindow + + pyshell = None + + def open_shell(self, event=None): + if self.pyshell: + self.pyshell.top.wakeup() + else: + self.pyshell = PyShell(self) + if self.pyshell: + if not self.pyshell.begin(): + return None + return self.pyshell + + +class ModifiedColorDelegator(ColorDelegator): + "Extend base class: colorizer for the shell window itself" + def recolorize_main(self): + self.tag_remove("TODO", "1.0", "iomark") + self.tag_add("SYNC", "1.0", "iomark") + ColorDelegator.recolorize_main(self) + + def removecolors(self): + # Don't remove shell color tags before "iomark" + for tag in self.tagdefs: + self.tag_remove(tag, "iomark", "end") + + +class ModifiedUndoDelegator(UndoDelegator): + "Extend base class: forbid insert/delete before the I/O mark" + def insert(self, index, chars, tags=None): + try: + if self.delegate.compare(index, "<", "iomark"): + self.delegate.bell() + return + except TclError: + pass + UndoDelegator.insert(self, index, chars, tags) + + def delete(self, index1, index2=None): + try: + if self.delegate.compare(index1, "<", "iomark"): + self.delegate.bell() + return + except TclError: + pass + UndoDelegator.delete(self, index1, index2) + + def undo_event(self, event): + # Temporarily monkey-patch the delegate's .insert() method to + # always use the "stdin" tag. This is needed for undo-ing + # deletions to preserve the "stdin" tag, because UndoDelegator + # doesn't preserve tags for deleted text. + orig_insert = self.delegate.insert + self.delegate.insert = \ + lambda index, chars: orig_insert(index, chars, "stdin") + try: + super().undo_event(event) + finally: + self.delegate.insert = orig_insert + + +class UserInputTaggingDelegator(Delegator): + """Delegator used to tag user input with "stdin".""" + def insert(self, index, chars, tags=None): + if tags is None: + tags = "stdin" + self.delegate.insert(index, chars, tags) + + +class MyRPCClient(rpc.RPCClient): + + def handle_EOF(self): + "Override the base class - just re-raise EOFError" + raise EOFError + +def restart_line(width, filename): # See bpo-38141. + """Return width long restart line formatted with filename. + + Fill line with balanced '='s, with any extras and at least one at + the beginning. Do not end with a trailing space. + """ + tag = f"= RESTART: {filename or 'Shell'} =" + if width >= len(tag): + div, mod = divmod((width -len(tag)), 2) + return f"{(div+mod)*'='}{tag}{div*'='}" + else: + return tag[:-2] # Remove ' ='. + + +class ModifiedInterpreter(InteractiveInterpreter): + + def __init__(self, tkconsole): + self.tkconsole = tkconsole + locals = sys.modules['__main__'].__dict__ + InteractiveInterpreter.__init__(self, locals=locals) + self.restarting = False + self.subprocess_arglist = None + self.port = PORT + self.original_compiler_flags = self.compile.compiler.flags + + _afterid = None + rpcclt = None + rpcsubproc = None + + def spawn_subprocess(self): + if self.subprocess_arglist is None: + self.subprocess_arglist = self.build_subprocess_arglist() + # gh-127060: Disable traceback colors + env = dict(os.environ, TERM='dumb') + self.rpcsubproc = subprocess.Popen(self.subprocess_arglist, env=env) + + def build_subprocess_arglist(self): + assert (self.port!=0), ( + "Socket should have been assigned a port number.") + w = ['-W' + s for s in sys.warnoptions] + # Maybe IDLE is installed and is being accessed via sys.path, + # or maybe it's not installed and the idle.py script is being + # run from the IDLE source directory. + del_exitf = idleConf.GetOption('main', 'General', 'delete-exitfunc', + default=False, type='bool') + command = f"__import__('idlelib.run').run.main({del_exitf!r})" + return [sys.executable] + w + ["-c", command, str(self.port)] + + def start_subprocess(self): + addr = (HOST, self.port) + # GUI makes several attempts to acquire socket, listens for connection + for i in range(3): + time.sleep(i) + try: + self.rpcclt = MyRPCClient(addr) + break + except OSError: + pass + else: + self.display_port_binding_error() + return None + # if PORT was 0, system will assign an 'ephemeral' port. Find it out: + self.port = self.rpcclt.listening_sock.getsockname()[1] + # if PORT was not 0, probably working with a remote execution server + if PORT != 0: + # To allow reconnection within the 2MSL wait (cf. Stevens TCP + # V1, 18.6), set SO_REUSEADDR. Note that this can be problematic + # on Windows since the implementation allows two active sockets on + # the same address! + self.rpcclt.listening_sock.setsockopt(socket.SOL_SOCKET, + socket.SO_REUSEADDR, 1) + self.spawn_subprocess() + #time.sleep(20) # test to simulate GUI not accepting connection + # Accept the connection from the Python execution server + self.rpcclt.listening_sock.settimeout(10) + try: + self.rpcclt.accept() + except TimeoutError: + self.display_no_subprocess_error() + return None + self.rpcclt.register("console", self.tkconsole) + self.rpcclt.register("stdin", self.tkconsole.stdin) + self.rpcclt.register("stdout", self.tkconsole.stdout) + self.rpcclt.register("stderr", self.tkconsole.stderr) + self.rpcclt.register("flist", self.tkconsole.flist) + self.rpcclt.register("linecache", linecache) + self.rpcclt.register("interp", self) + self.transfer_path(with_cwd=True) + self.poll_subprocess() + return self.rpcclt + + def restart_subprocess(self, with_cwd=False, filename=''): + if self.restarting: + return self.rpcclt + self.restarting = True + # close only the subprocess debugger + debug = self.getdebugger() + if debug: + try: + # Only close subprocess debugger, don't unregister gui_adap! + debugger_r.close_subprocess_debugger(self.rpcclt) + except: + pass + # Kill subprocess, spawn a new one, accept connection. + self.rpcclt.close() + self.terminate_subprocess() + console = self.tkconsole + was_executing = console.executing + console.executing = False + self.spawn_subprocess() + try: + self.rpcclt.accept() + except TimeoutError: + self.display_no_subprocess_error() + return None + self.transfer_path(with_cwd=with_cwd) + console.stop_readline() + # annotate restart in shell window and mark it + console.text.delete("iomark", "end-1c") + console.write('\n') + console.write(restart_line(console.width, filename)) + console.text.mark_set("restart", "end-1c") + console.text.mark_gravity("restart", "left") + if not filename: + console.showprompt() + # restart subprocess debugger + if debug: + # Restarted debugger connects to current instance of debug GUI + debugger_r.restart_subprocess_debugger(self.rpcclt) + # reload remote debugger breakpoints for all PyShellEditWindows + debug.load_breakpoints() + self.compile.compiler.flags = self.original_compiler_flags + self.restarting = False + return self.rpcclt + + def __request_interrupt(self): + self.rpcclt.remotecall("exec", "interrupt_the_server", (), {}) + + def interrupt_subprocess(self): + threading.Thread(target=self.__request_interrupt).start() + + def kill_subprocess(self): + if self._afterid is not None: + self.tkconsole.text.after_cancel(self._afterid) + try: + self.rpcclt.listening_sock.close() + except AttributeError: # no socket + pass + try: + self.rpcclt.close() + except AttributeError: # no socket + pass + self.terminate_subprocess() + self.tkconsole.executing = False + self.rpcclt = None + + def terminate_subprocess(self): + "Make sure subprocess is terminated" + try: + self.rpcsubproc.kill() + except OSError: + # process already terminated + return + else: + try: + self.rpcsubproc.wait() + except OSError: + return + + def transfer_path(self, with_cwd=False): + if with_cwd: # Issue 13506 + path = [''] # include Current Working Directory + path.extend(sys.path) + else: + path = sys.path + + self.runcommand("""if 1: + import sys as _sys + _sys.path = {!r} + del _sys + \n""".format(path)) + + active_seq = None + + def poll_subprocess(self): + clt = self.rpcclt + if clt is None: + return + try: + response = clt.pollresponse(self.active_seq, wait=0.05) + except (EOFError, OSError, KeyboardInterrupt): + # lost connection or subprocess terminated itself, restart + # [the KBI is from rpc.SocketIO.handle_EOF()] + if self.tkconsole.closing: + return + response = None + self.restart_subprocess() + if response: + self.tkconsole.resetoutput() + self.active_seq = None + how, what = response + console = self.tkconsole.console + if how == "OK": + if what is not None: + print(repr(what), file=console) + elif how == "EXCEPTION": + if self.tkconsole.getvar("<>"): + self.remote_stack_viewer() + elif how == "ERROR": + errmsg = "pyshell.ModifiedInterpreter: Subprocess ERROR:\n" + print(errmsg, what, file=sys.__stderr__) + print(errmsg, what, file=console) + # we received a response to the currently active seq number: + try: + self.tkconsole.endexecuting() + except AttributeError: # shell may have closed + pass + # Reschedule myself + if not self.tkconsole.closing: + self._afterid = self.tkconsole.text.after( + self.tkconsole.pollinterval, self.poll_subprocess) + + debugger = None + + def setdebugger(self, debugger): + self.debugger = debugger + + def getdebugger(self): + return self.debugger + + def open_remote_stack_viewer(self): + """Initiate the remote stack viewer from a separate thread. + + This method is called from the subprocess, and by returning from this + method we allow the subprocess to unblock. After a bit the shell + requests the subprocess to open the remote stack viewer which returns a + static object looking at the last exception. It is queried through + the RPC mechanism. + + """ + self.tkconsole.text.after(300, self.remote_stack_viewer) + return + + def remote_stack_viewer(self): + from idlelib import debugobj_r + oid = self.rpcclt.remotequeue("exec", "stackviewer", ("flist",), {}) + if oid is None: + self.tkconsole.root.bell() + return + item = debugobj_r.StubObjectTreeItem(self.rpcclt, oid) + from idlelib.tree import ScrolledCanvas, TreeNode + top = Toplevel(self.tkconsole.root) + theme = idleConf.CurrentTheme() + background = idleConf.GetHighlight(theme, 'normal')['background'] + sc = ScrolledCanvas(top, bg=background, highlightthickness=0) + sc.frame.pack(expand=1, fill="both") + node = TreeNode(sc.canvas, None, item) + node.expand() + # XXX Should GC the remote tree when closing the window + + gid = 0 + + def execsource(self, source): + "Like runsource() but assumes complete exec source" + filename = self.stuffsource(source) + self.execfile(filename, source) + + def execfile(self, filename, source=None): + "Execute an existing file" + if source is None: + with tokenize.open(filename) as fp: + source = fp.read() + if use_subprocess: + source = (f"__file__ = r'''{os.path.abspath(filename)}'''\n" + + source + "\ndel __file__") + try: + code = compile(source, filename, "exec") + except (OverflowError, SyntaxError): + self.tkconsole.resetoutput() + print('*** Error in script or command!\n' + 'Traceback (most recent call last):', + file=self.tkconsole.stderr) + InteractiveInterpreter.showsyntaxerror(self, filename) + self.tkconsole.showprompt() + else: + self.runcode(code) + + def runsource(self, source): + "Extend base class method: Stuff the source in the line cache first" + filename = self.stuffsource(source) + # at the moment, InteractiveInterpreter expects str + assert isinstance(source, str) + # InteractiveInterpreter.runsource() calls its runcode() method, + # which is overridden (see below) + return InteractiveInterpreter.runsource(self, source, filename) + + def stuffsource(self, source): + "Stuff source in the filename cache" + filename = "" % self.gid + self.gid = self.gid + 1 + lines = source.split("\n") + linecache.cache[filename] = len(source)+1, 0, lines, filename + return filename + + def prepend_syspath(self, filename): + "Prepend sys.path with file's directory if not already included" + self.runcommand("""if 1: + _filename = {!r} + import sys as _sys + from os.path import dirname as _dirname + _dir = _dirname(_filename) + if not _dir in _sys.path: + _sys.path.insert(0, _dir) + del _filename, _sys, _dirname, _dir + \n""".format(filename)) + + def showsyntaxerror(self, filename=None, **kwargs): + """Override Interactive Interpreter method: Use Colorizing + + Color the offending position instead of printing it and pointing at it + with a caret. + + """ + tkconsole = self.tkconsole + text = tkconsole.text + text.tag_remove("ERROR", "1.0", "end") + type, value, tb = sys.exc_info() + msg = getattr(value, 'msg', '') or value or "" + lineno = getattr(value, 'lineno', '') or 1 + offset = getattr(value, 'offset', '') or 0 + if offset == 0: + lineno += 1 #mark end of offending line + if lineno == 1: + pos = "iomark + %d chars" % (offset-1) + else: + pos = "iomark linestart + %d lines + %d chars" % \ + (lineno-1, offset-1) + tkconsole.colorize_syntax_error(text, pos) + tkconsole.resetoutput() + self.write("SyntaxError: %s\n" % msg) + tkconsole.showprompt() + + def showtraceback(self): + "Extend base class method to reset output properly" + self.tkconsole.resetoutput() + self.checklinecache() + InteractiveInterpreter.showtraceback(self) + if self.tkconsole.getvar("<>"): + self.tkconsole.open_stack_viewer() + + def checklinecache(self): + "Remove keys other than ''." + cache = linecache.cache + for key in list(cache): # Iterate list because mutate cache. + if key[:1] + key[-1:] != "<>": + del cache[key] + + def runcommand(self, code): + "Run the code without invoking the debugger" + # The code better not raise an exception! + if self.tkconsole.executing: + self.display_executing_dialog() + return 0 + if self.rpcclt: + self.rpcclt.remotequeue("exec", "runcode", (code,), {}) + else: + exec(code, self.locals) + return 1 + + def runcode(self, code): + "Override base class method" + if self.tkconsole.executing: + self.restart_subprocess() + self.checklinecache() + debugger = self.debugger + try: + self.tkconsole.beginexecuting() + if not debugger and self.rpcclt is not None: + self.active_seq = self.rpcclt.asyncqueue("exec", "runcode", + (code,), {}) + elif debugger: + debugger.run(code, self.locals) + else: + exec(code, self.locals) + except SystemExit: + if not self.tkconsole.closing: + if messagebox.askyesno( + "Exit?", + "Do you want to exit altogether?", + default="yes", + parent=self.tkconsole.text): + raise + else: + self.showtraceback() + else: + raise + except: + if use_subprocess: + print("IDLE internal error in runcode()", + file=self.tkconsole.stderr) + self.showtraceback() + self.tkconsole.endexecuting() + else: + if self.tkconsole.canceled: + self.tkconsole.canceled = False + print("KeyboardInterrupt", file=self.tkconsole.stderr) + else: + self.showtraceback() + finally: + if not use_subprocess: + try: + self.tkconsole.endexecuting() + except AttributeError: # shell may have closed + pass + + def write(self, s): + "Override base class method" + return self.tkconsole.stderr.write(s) + + def display_port_binding_error(self): + messagebox.showerror( + "Port Binding Error", + "IDLE can't bind to a TCP/IP port, which is necessary to " + "communicate with its Python execution server. This might be " + "because no networking is installed on this computer. " + "Run IDLE with the -n command line switch to start without a " + "subprocess and refer to Help/IDLE Help 'Running without a " + "subprocess' for further details.", + parent=self.tkconsole.text) + + def display_no_subprocess_error(self): + messagebox.showerror( + "Subprocess Connection Error", + "IDLE's subprocess didn't make connection.\n" + "See the 'Startup failure' section of the IDLE doc, online at\n" + "https://docs.python.org/3/library/idle.html#startup-failure", + parent=self.tkconsole.text) + + def display_executing_dialog(self): + messagebox.showerror( + "Already executing", + "The Python Shell window is already executing a command; " + "please wait until it is finished.", + parent=self.tkconsole.text) + + +class PyShell(OutputWindow): + from idlelib.squeezer import Squeezer + + shell_title = "IDLE Shell" + + # Override classes + ColorDelegator = ModifiedColorDelegator + UndoDelegator = ModifiedUndoDelegator + + # Override menus + menu_specs = [ + ("file", "_File"), + ("edit", "_Edit"), + ("debug", "_Debug"), + ("options", "_Options"), + ("window", "_Window"), + ("help", "_Help"), + ] + + # Extend right-click context menu + rmenu_specs = OutputWindow.rmenu_specs + [ + ("Squeeze", "<>"), + ] + _idx = 1 + len(list(itertools.takewhile( + lambda rmenu_item: rmenu_item[0] != "Copy", rmenu_specs) + )) + rmenu_specs.insert(_idx, ("Copy with prompts", + "<>", + "rmenu_check_copy")) + del _idx + + allow_line_numbers = False + user_input_insert_tags = "stdin" + + # New classes + from idlelib.history import History + from idlelib.sidebar import ShellSidebar + + def __init__(self, flist=None): + ms = self.menu_specs + if ms[2][0] != "shell": + ms.insert(2, ("shell", "She_ll")) + self.interp = ModifiedInterpreter(self) + if flist is None: + root = Tk() + fixwordbreaks(root) + root.withdraw() + flist = PyShellFileList(root) + + self.shell_sidebar = None # initialized below + + OutputWindow.__init__(self, flist, None, None) + + self.usetabs = False + # indentwidth must be 8 when using tabs. See note in EditorWindow: + self.indentwidth = 4 + + self.sys_ps1 = sys.ps1 if hasattr(sys, 'ps1') else '>>>\n' + self.prompt_last_line = self.sys_ps1.split('\n')[-1] + self.prompt = self.sys_ps1 # Changes when debug active + + text = self.text + text.configure(wrap="char") + text.bind("<>", self.enter_callback) + text.bind("<>", self.linefeed_callback) + text.bind("<>", self.cancel_callback) + text.bind("<>", self.eof_callback) + text.bind("<>", self.open_stack_viewer) + text.bind("<>", self.toggle_debugger) + text.bind("<>", self.toggle_jit_stack_viewer) + text.bind("<>", self.copy_with_prompts_callback) + if use_subprocess: + text.bind("<>", self.view_restart_mark) + text.bind("<>", self.restart_shell) + self.squeezer = self.Squeezer(self) + text.bind("<>", + self.squeeze_current_text_event) + + self.save_stdout = sys.stdout + self.save_stderr = sys.stderr + self.save_stdin = sys.stdin + from idlelib import iomenu + self.stdin = StdInputFile(self, "stdin", + iomenu.encoding, iomenu.errors) + self.stdout = StdOutputFile(self, "stdout", + iomenu.encoding, iomenu.errors) + self.stderr = StdOutputFile(self, "stderr", + iomenu.encoding, "backslashreplace") + self.console = StdOutputFile(self, "console", + iomenu.encoding, iomenu.errors) + if not use_subprocess: + sys.stdout = self.stdout + sys.stderr = self.stderr + sys.stdin = self.stdin + try: + # page help() text to shell. + import pydoc # import must be done here to capture i/o rebinding. + # XXX KBK 27Dec07 use text viewer someday, but must work w/o subproc + pydoc.pager = pydoc.plainpager + except: + sys.stderr = sys.__stderr__ + raise + # + self.history = self.History(self.text) + # + self.pollinterval = 50 # millisec + + self.shell_sidebar = self.ShellSidebar(self) + + # Insert UserInputTaggingDelegator at the top of the percolator, + # but make calls to text.insert() skip it. This causes only insert + # events generated in Tcl/Tk to go through this delegator. + self.text.insert = self.per.top.insert + self.per.insertfilter(UserInputTaggingDelegator()) + + if not use_subprocess: + # Menu options "View Last Restart" and "Restart Shell" are disabled + self.update_menu_state("shell", 0, "disabled") + self.update_menu_state("shell", 1, "disabled") + + def ResetFont(self): + super().ResetFont() + + if self.shell_sidebar is not None: + self.shell_sidebar.update_font() + + def ResetColorizer(self): + super().ResetColorizer() + + theme = idleConf.CurrentTheme() + tag_colors = { + "stdin": {'background': None, 'foreground': None}, + "stdout": idleConf.GetHighlight(theme, "stdout"), + "stderr": idleConf.GetHighlight(theme, "stderr"), + "console": idleConf.GetHighlight(theme, "normal"), + } + for tag, tag_colors_config in tag_colors.items(): + self.text.tag_configure(tag, **tag_colors_config) + + if self.shell_sidebar is not None: + self.shell_sidebar.update_colors() + + def replace_event(self, event): + replace.replace(self.text, insert_tags="stdin") + return "break" + + def get_standard_extension_names(self): + return idleConf.GetExtensions(shell_only=True) + + def get_prompt_text(self, first, last): + """Return text between first and last with prompts added.""" + text = self.text.get(first, last) + lineno_range = range( + int(float(first)), + int(float(last)) + ) + prompts = [ + self.shell_sidebar.line_prompts.get(lineno) + for lineno in lineno_range + ] + return "\n".join( + line if prompt is None else f"{prompt} {line}" + for prompt, line in zip(prompts, text.splitlines()) + ) + "\n" + + + def copy_with_prompts_callback(self, event=None): + """Copy selected lines to the clipboard, with prompts. + + This makes the copied text useful for doc-tests and interactive + shell code examples. + + This always copies entire lines, even if only part of the first + and/or last lines is selected. + """ + text = self.text + selfirst = text.index('sel.first linestart') + if selfirst is None: # Should not be possible. + return # No selection, do nothing. + sellast = text.index('sel.last') + if sellast[-1] != '0': + sellast = text.index("sel.last+1line linestart") + text.clipboard_clear() + prompt_text = self.get_prompt_text(selfirst, sellast) + text.clipboard_append(prompt_text) + + reading = False + executing = False + canceled = False + endoffile = False + closing = False + _stop_readline_flag = False + + def set_warning_stream(self, stream): + global warning_stream + warning_stream = stream + + def get_warning_stream(self): + return warning_stream + + def toggle_debugger(self, event=None): + if self.executing: + messagebox.showerror("Don't debug now", + "You can only toggle the debugger when idle", + parent=self.text) + self.set_debugger_indicator() + return "break" + else: + db = self.interp.getdebugger() + if db: + self.close_debugger() + else: + self.open_debugger() + + def set_debugger_indicator(self): + db = self.interp.getdebugger() + self.setvar("<>", not not db) + + def toggle_jit_stack_viewer(self, event=None): + pass # All we need is the variable + + def close_debugger(self): + db = self.interp.getdebugger() + if db: + self.interp.setdebugger(None) + db.close() + if self.interp.rpcclt: + debugger_r.close_remote_debugger(self.interp.rpcclt) + self.resetoutput() + self.console.write("[DEBUG OFF]\n") + self.prompt = self.sys_ps1 + self.showprompt() + self.set_debugger_indicator() + + def open_debugger(self): + if self.interp.rpcclt: + dbg_gui = debugger_r.start_remote_debugger(self.interp.rpcclt, + self) + else: + dbg_gui = debugger.Debugger(self) + self.interp.setdebugger(dbg_gui) + dbg_gui.load_breakpoints() + self.prompt = "[DEBUG ON]\n" + self.sys_ps1 + self.showprompt() + self.set_debugger_indicator() + + def debug_menu_postcommand(self): + state = 'disabled' if self.executing else 'normal' + self.update_menu_state('debug', '*tack*iewer', state) + + def beginexecuting(self): + "Helper for ModifiedInterpreter" + self.resetoutput() + self.executing = True + + def endexecuting(self): + "Helper for ModifiedInterpreter" + self.executing = False + self.canceled = False + self.showprompt() + + def close(self): + "Extend EditorWindow.close()" + if self.executing: + response = messagebox.askokcancel( + "Kill?", + "Your program is still running!\n Do you want to kill it?", + default="ok", + parent=self.text) + if response is False: + return "cancel" + self.stop_readline() + self.canceled = True + self.closing = True + return EditorWindow.close(self) + + def _close(self): + "Extend EditorWindow._close(), shut down debugger and execution server" + self.close_debugger() + if use_subprocess: + self.interp.kill_subprocess() + # Restore std streams + sys.stdout = self.save_stdout + sys.stderr = self.save_stderr + sys.stdin = self.save_stdin + # Break cycles + self.interp = None + self.console = None + self.flist.pyshell = None + self.history = None + EditorWindow._close(self) + + def ispythonsource(self, filename): + "Override EditorWindow method: never remove the colorizer" + return True + + def short_title(self): + return self.shell_title + + SPLASHLINE = 'Enter "help" below or click "Help" above for more information.' + + def begin(self): + self.text.mark_set("iomark", "insert") + self.resetoutput() + if use_subprocess: + nosub = '' + client = self.interp.start_subprocess() + if not client: + self.close() + return False + else: + nosub = ("==== No Subprocess ====\n\n" + + "WARNING: Running IDLE without a Subprocess is deprecated\n" + + "and will be removed in a later version. See Help/IDLE Help\n" + + "for details.\n\n") + sys.displayhook = rpc.displayhook + + self.write("Python %s on %s\n%s\n%s" % + (sys.version, sys.platform, self.SPLASHLINE, nosub)) + self.text.focus_force() + self.showprompt() + # User code should use separate default Tk root window + import tkinter + tkinter._support_default_root = True + tkinter._default_root = None + return True + + def stop_readline(self): + if not self.reading: # no nested mainloop to exit. + return + self._stop_readline_flag = True + self.top.quit() + + def readline(self): + save = self.reading + try: + self.reading = True + self.top.mainloop() # nested mainloop() + finally: + self.reading = save + if self._stop_readline_flag: + self._stop_readline_flag = False + return "" + line = self.text.get("iomark", "end-1c") + if len(line) == 0: # may be EOF if we quit our mainloop with Ctrl-C + line = "\n" + self.resetoutput() + if self.canceled: + self.canceled = False + if not use_subprocess: + raise KeyboardInterrupt + if self.endoffile: + self.endoffile = False + line = "" + return line + + def isatty(self): + return True + + def cancel_callback(self, event=None): + try: + if self.text.compare("sel.first", "!=", "sel.last"): + return # Active selection -- always use default binding + except: + pass + if not (self.executing or self.reading): + self.resetoutput() + self.interp.write("KeyboardInterrupt\n") + self.showprompt() + return "break" + self.endoffile = False + self.canceled = True + if (self.executing and self.interp.rpcclt): + if self.interp.getdebugger(): + self.interp.restart_subprocess() + else: + self.interp.interrupt_subprocess() + if self.reading: + self.top.quit() # exit the nested mainloop() in readline() + return "break" + + def eof_callback(self, event): + if self.executing and not self.reading: + return # Let the default binding (delete next char) take over + if not (self.text.compare("iomark", "==", "insert") and + self.text.compare("insert", "==", "end-1c")): + return # Let the default binding (delete next char) take over + if not self.executing: + self.resetoutput() + self.close() + else: + self.canceled = False + self.endoffile = True + self.top.quit() + return "break" + + def linefeed_callback(self, event): + # Insert a linefeed without entering anything (still autoindented) + if self.reading: + self.text.insert("insert", "\n") + self.text.see("insert") + else: + self.newline_and_indent_event(event) + return "break" + + def enter_callback(self, event): + if self.executing and not self.reading: + return # Let the default binding (insert '\n') take over + # If some text is selected, recall the selection + # (but only if this before the I/O mark) + try: + sel = self.text.get("sel.first", "sel.last") + if sel: + if self.text.compare("sel.last", "<=", "iomark"): + self.recall(sel, event) + return "break" + except: + pass + # If we're strictly before the line containing iomark, recall + # the current line, less a leading prompt, less leading or + # trailing whitespace + if self.text.compare("insert", "<", "iomark linestart"): + # Check if there's a relevant stdin range -- if so, use it. + # Note: "stdin" blocks may include several successive statements, + # so look for "console" tags on the newline before each statement + # (and possibly on prompts). + prev = self.text.tag_prevrange("stdin", "insert") + if ( + prev and + self.text.compare("insert", "<", prev[1]) and + # The following is needed to handle empty statements. + "console" not in self.text.tag_names("insert") + ): + prev_cons = self.text.tag_prevrange("console", "insert") + if prev_cons and self.text.compare(prev_cons[1], ">=", prev[0]): + prev = (prev_cons[1], prev[1]) + next_cons = self.text.tag_nextrange("console", "insert") + if next_cons and self.text.compare(next_cons[0], "<", prev[1]): + prev = (prev[0], self.text.index(next_cons[0] + "+1c")) + self.recall(self.text.get(prev[0], prev[1]), event) + return "break" + next = self.text.tag_nextrange("stdin", "insert") + if next and self.text.compare("insert lineend", ">=", next[0]): + next_cons = self.text.tag_nextrange("console", "insert lineend") + if next_cons and self.text.compare(next_cons[0], "<", next[1]): + next = (next[0], self.text.index(next_cons[0] + "+1c")) + self.recall(self.text.get(next[0], next[1]), event) + return "break" + # No stdin mark -- just get the current line, less any prompt + indices = self.text.tag_nextrange("console", "insert linestart") + if indices and \ + self.text.compare(indices[0], "<=", "insert linestart"): + self.recall(self.text.get(indices[1], "insert lineend"), event) + else: + self.recall(self.text.get("insert linestart", "insert lineend"), event) + return "break" + # If we're between the beginning of the line and the iomark, i.e. + # in the prompt area, move to the end of the prompt + if self.text.compare("insert", "<", "iomark"): + self.text.mark_set("insert", "iomark") + # If we're in the current input and there's only whitespace + # beyond the cursor, erase that whitespace first + s = self.text.get("insert", "end-1c") + if s and not s.strip(): + self.text.delete("insert", "end-1c") + # If we're in the current input before its last line, + # insert a newline right at the insert point + if self.text.compare("insert", "<", "end-1c linestart"): + self.newline_and_indent_event(event) + return "break" + # We're in the last line; append a newline and submit it + self.text.mark_set("insert", "end-1c") + if self.reading: + self.text.insert("insert", "\n") + self.text.see("insert") + else: + self.newline_and_indent_event(event) + self.text.update_idletasks() + if self.reading: + self.top.quit() # Break out of recursive mainloop() + else: + self.runit() + return "break" + + def recall(self, s, event): + # remove leading and trailing empty or whitespace lines + s = re.sub(r'^\s*\n', '', s) + s = re.sub(r'\n\s*$', '', s) + lines = s.split('\n') + self.text.undo_block_start() + try: + self.text.tag_remove("sel", "1.0", "end") + self.text.mark_set("insert", "end-1c") + prefix = self.text.get("insert linestart", "insert") + if prefix.rstrip().endswith(':'): + self.newline_and_indent_event(event) + prefix = self.text.get("insert linestart", "insert") + self.text.insert("insert", lines[0].strip(), + self.user_input_insert_tags) + if len(lines) > 1: + orig_base_indent = re.search(r'^([ \t]*)', lines[0]).group(0) + new_base_indent = re.search(r'^([ \t]*)', prefix).group(0) + for line in lines[1:]: + if line.startswith(orig_base_indent): + # replace orig base indentation with new indentation + line = new_base_indent + line[len(orig_base_indent):] + self.text.insert('insert', '\n' + line.rstrip(), + self.user_input_insert_tags) + finally: + self.text.see("insert") + self.text.undo_block_stop() + + _last_newline_re = re.compile(r"[ \t]*(\n[ \t]*)?\z") + def runit(self): + index_before = self.text.index("end-2c") + line = self.text.get("iomark", "end-1c") + # Strip off last newline and surrounding whitespace. + # (To allow you to hit return twice to end a statement.) + line = self._last_newline_re.sub("", line) + input_is_complete = self.interp.runsource(line) + if not input_is_complete: + if self.text.get(index_before) == '\n': + self.text.tag_remove(self.user_input_insert_tags, index_before) + self.shell_sidebar.update_sidebar() + + def open_stack_viewer(self, event=None): # -n mode only + if self.interp.rpcclt: + return self.interp.remote_stack_viewer() + + from idlelib.stackviewer import StackBrowser + try: + StackBrowser(self.root, sys.last_exc, self.flist) + except: + messagebox.showerror("No stack trace", + "There is no stack trace yet.\n" + "(sys.last_exc is not defined)", + parent=self.text) + return None + + def view_restart_mark(self, event=None): + self.text.see("iomark") + self.text.see("restart") + + def restart_shell(self, event=None): + "Callback for Run/Restart Shell Cntl-F6" + self.interp.restart_subprocess(with_cwd=True) + + def showprompt(self): + self.resetoutput() + + prompt = self.prompt + if self.sys_ps1 and prompt.endswith(self.sys_ps1): + prompt = prompt[:-len(self.sys_ps1)] + self.text.tag_add("console", "iomark-1c") + self.console.write(prompt) + + self.shell_sidebar.update_sidebar() + self.text.mark_set("insert", "end-1c") + self.set_line_and_column() + self.io.reset_undo() + + def show_warning(self, msg): + width = self.interp.tkconsole.width + wrapper = TextWrapper(width=width, tabsize=8, expand_tabs=True) + wrapped_msg = '\n'.join(wrapper.wrap(msg)) + if not wrapped_msg.endswith('\n'): + wrapped_msg += '\n' + self.per.bottom.insert("iomark linestart", wrapped_msg, "stderr") + + def resetoutput(self): + source = self.text.get("iomark", "end-1c") + if self.history: + self.history.store(source) + if self.text.get("end-2c") != "\n": + self.text.insert("end-1c", "\n") + self.text.mark_set("iomark", "end-1c") + self.set_line_and_column() + self.ctip.remove_calltip_window() + + def write(self, s, tags=()): + try: + self.text.mark_gravity("iomark", "right") + count = OutputWindow.write(self, s, tags, "iomark") + self.text.mark_gravity("iomark", "left") + except: + raise ###pass # ### 11Aug07 KBK if we are expecting exceptions + # let's find out what they are and be specific. + if self.canceled: + self.canceled = False + if not use_subprocess: + raise KeyboardInterrupt + return count + + def rmenu_check_cut(self): + try: + if self.text.compare('sel.first', '<', 'iomark'): + return 'disabled' + except TclError: # no selection, so the index 'sel.first' doesn't exist + return 'disabled' + return super().rmenu_check_cut() + + def rmenu_check_paste(self): + if self.text.compare('insert','<','iomark'): + return 'disabled' + return super().rmenu_check_paste() + + def squeeze_current_text_event(self, event=None): + self.squeezer.squeeze_current_text() + self.shell_sidebar.update_sidebar() + + def on_squeezed_expand(self, index, text, tags): + self.shell_sidebar.update_sidebar() + + +def fix_x11_paste(root): + "Make paste replace selection on x11. See issue #5124." + if root._windowingsystem == 'x11': + for cls in 'Text', 'Entry', 'Spinbox': + root.bind_class( + cls, + '<>', + 'catch {%W delete sel.first sel.last}\n' + + root.bind_class(cls, '<>')) + + +usage_msg = """\ + +USAGE: idle [-deins] [-t title] [file]* + idle [-dns] [-t title] (-c cmd | -r file) [arg]* + idle [-dns] [-t title] - [arg]* + + -h print this help message and exit + -n run IDLE without a subprocess (DEPRECATED, + see Help/IDLE Help for details) + +The following options will override the IDLE 'settings' configuration: + + -e open an edit window + -i open a shell window + +The following options imply -i and will open a shell: + + -c cmd run the command in a shell, or + -r file run script from file + + -d enable the debugger + -s run $IDLESTARTUP or $PYTHONSTARTUP before anything else + -t title set title of shell window + +A default edit window will be bypassed when -c, -r, or - are used. + +[arg]* are passed to the command (-c) or script (-r) in sys.argv[1:]. + +Examples: + +idle + Open an edit window or shell depending on IDLE's configuration. + +idle foo.py foobar.py + Edit the files, also open a shell if configured to start with shell. + +idle -est "Baz" foo.py + Run $IDLESTARTUP or $PYTHONSTARTUP, edit foo.py, and open a shell + window with the title "Baz". + +idle -c "import sys; print(sys.argv)" "foo" + Open a shell window and run the command, passing "-c" in sys.argv[0] + and "foo" in sys.argv[1]. + +idle -d -s -r foo.py "Hello World" + Open a shell window, run a startup script, enable the debugger, and + run foo.py, passing "foo.py" in sys.argv[0] and "Hello World" in + sys.argv[1]. + +echo "import sys; print(sys.argv)" | idle - "foobar" + Open a shell window, run the script piped in, passing '' in sys.argv[0] + and "foobar" in sys.argv[1]. +""" + +def main(): + import getopt + from platform import system + from idlelib import testing # bool value + from idlelib import macosx + + global flist, root, use_subprocess + + capture_warnings(True) + use_subprocess = True + enable_shell = False + enable_edit = False + debug = False + cmd = None + script = None + startup = False + try: + opts, args = getopt.getopt(sys.argv[1:], "c:deihnr:st:") + except getopt.error as msg: + print(f"Error: {msg}\n{usage_msg}", file=sys.stderr) + sys.exit(2) + for o, a in opts: + if o == '-c': + cmd = a + enable_shell = True + if o == '-d': + debug = True + enable_shell = True + if o == '-e': + enable_edit = True + if o == '-h': + sys.stdout.write(usage_msg) + sys.exit() + if o == '-i': + enable_shell = True + if o == '-n': + print(" Warning: running IDLE without a subprocess is deprecated.", + file=sys.stderr) + use_subprocess = False + if o == '-r': + script = a + if os.path.isfile(script): + pass + else: + print("No script file: ", script) + sys.exit() + enable_shell = True + if o == '-s': + startup = True + enable_shell = True + if o == '-t': + PyShell.shell_title = a + enable_shell = True + if args and args[0] == '-': + cmd = sys.stdin.read() + enable_shell = True + # process sys.argv and sys.path: + for i in range(len(sys.path)): + sys.path[i] = os.path.abspath(sys.path[i]) + if args and args[0] == '-': + sys.argv = [''] + args[1:] + elif cmd: + sys.argv = ['-c'] + args + elif script: + sys.argv = [script] + args + elif args: + enable_edit = True + pathx = [] + for filename in args: + pathx.append(os.path.dirname(filename)) + for dir in pathx: + dir = os.path.abspath(dir) + if not dir in sys.path: + sys.path.insert(0, dir) + else: + dir = os.getcwd() + if dir not in sys.path: + sys.path.insert(0, dir) + # check the IDLE settings configuration (but command line overrides) + edit_start = idleConf.GetOption('main', 'General', + 'editor-on-startup', type='bool') + enable_edit = enable_edit or edit_start + enable_shell = enable_shell or not enable_edit + + # Setup root. Don't break user code run in IDLE process. + # Don't change environment when testing. + if use_subprocess and not testing: + NoDefaultRoot() + root = Tk(className="Idle") + root.withdraw() + from idlelib.run import fix_scaling + fix_scaling(root) + + # set application icon + icondir = os.path.join(os.path.dirname(__file__), 'Icons') + if system() == 'Windows': + iconfile = os.path.join(icondir, 'idle.ico') + root.wm_iconbitmap(default=iconfile) + elif not macosx.isAquaTk(): + if TkVersion >= 8.6: + ext = '.png' + sizes = (16, 32, 48, 256) + else: + ext = '.gif' + sizes = (16, 32, 48) + iconfiles = [os.path.join(icondir, 'idle_%d%s' % (size, ext)) + for size in sizes] + icons = [PhotoImage(master=root, file=iconfile) + for iconfile in iconfiles] + root.wm_iconphoto(True, *icons) + + # start editor and/or shell windows: + fixwordbreaks(root) + fix_x11_paste(root) + flist = PyShellFileList(root) + macosx.setupApp(root, flist) + + if enable_edit: + if not (cmd or script): + for filename in args[:]: + if flist.open(filename) is None: + # filename is a directory actually, disconsider it + args.remove(filename) + if not args: + flist.new() + + if enable_shell: + shell = flist.open_shell() + if not shell: + return # couldn't open shell + if macosx.isAquaTk() and flist.dict: + # On OSX: when the user has double-clicked on a file that causes + # IDLE to be launched the shell window will open just in front of + # the file she wants to see. Lower the interpreter window when + # there are open files. + shell.top.lower() + else: + shell = flist.pyshell + + # Handle remaining options. If any of these are set, enable_shell + # was set also, so shell must be true to reach here. + if debug: + shell.open_debugger() + if startup: + filename = os.environ.get("IDLESTARTUP") or \ + os.environ.get("PYTHONSTARTUP") + if filename and os.path.isfile(filename): + shell.interp.execfile(filename) + if cmd or script: + shell.interp.runcommand("""if 1: + import sys as _sys + _sys.argv = {!r} + del _sys + \n""".format(sys.argv)) + if cmd: + shell.interp.execsource(cmd) + elif script: + shell.interp.prepend_syspath(script) + shell.interp.execfile(script) + elif shell: + # If there is a shell window and no cmd or script in progress, + # check for problematic issues and print warning message(s) in + # the IDLE shell window; this is less intrusive than always + # opening a separate window. + + # Warn if the "Prefer tabs when opening documents" system + # preference is set to "Always". + prefer_tabs_preference_warning = macosx.preferTabsPreferenceWarning() + if prefer_tabs_preference_warning: + shell.show_warning(prefer_tabs_preference_warning) + + while flist.inversedict: # keep IDLE running while files are open. + root.mainloop() + root.destroy() + capture_warnings(False) + + +if __name__ == "__main__": + main() + +capture_warnings(False) # Make sure turned off; see issue 18081 diff --git a/Lib/idlelib/query.py b/Lib/idlelib/query.py new file mode 100644 index 000000000..5f9bdc031 --- /dev/null +++ b/Lib/idlelib/query.py @@ -0,0 +1,390 @@ +""" +Dialogs that query users and verify the answer before accepting. + +Query is the generic base class for a popup dialog. +The user must either enter a valid answer or close the dialog. +Entries are validated when is entered or [Ok] is clicked. +Entries are ignored when [Cancel] or [X] are clicked. +The 'return value' is .result set to either a valid answer or None. + +Subclass SectionName gets a name for a new config file section. +Configdialog uses it for new highlight theme and keybinding set names. +Subclass ModuleName gets a name for File => Open Module. +Subclass HelpSource gets menu item and path for additions to Help menu. +""" +# Query and Section name result from splitting GetCfgSectionNameDialog +# of configSectionNameDialog.py (temporarily config_sec.py) into +# generic and specific parts. 3.6 only, July 2016. +# ModuleName.entry_ok came from editor.EditorWindow.load_module. +# HelpSource was extracted from configHelpSourceEdit.py (temporarily +# config_help.py), with darwin code moved from ok to path_ok. + +import importlib.util, importlib.abc +import os +import shlex +from sys import executable, platform # Platform is set for one test. + +from tkinter import Toplevel, StringVar, BooleanVar, W, E, S +from tkinter.ttk import Frame, Button, Entry, Label, Checkbutton +from tkinter import filedialog +from tkinter.font import Font +from tkinter.simpledialog import _setup_dialog + +class Query(Toplevel): + """Base class for getting verified answer from a user. + + For this base class, accept any non-blank string. + """ + def __init__(self, parent, title, message, *, text0='', used_names={}, + _htest=False, _utest=False): + """Create modal popup, return when destroyed. + + Additional subclass init must be done before this unless + _utest=True is passed to suppress wait_window(). + + title - string, title of popup dialog + message - string, informational message to display + text0 - initial value for entry + used_names - names already in use + _htest - bool, change box location when running htest + _utest - bool, leave window hidden and not modal + """ + self.parent = parent # Needed for Font call. + self.message = message + self.text0 = text0 + self.used_names = used_names + + Toplevel.__init__(self, parent) + self.withdraw() # Hide while configuring, especially geometry. + self.title(title) + self.transient(parent) + if not _utest: # Otherwise fail when directly run unittest. + self.grab_set() + + _setup_dialog(self) + if self._windowingsystem == 'aqua': + self.bind("", self.cancel) + self.bind('', self.cancel) + self.protocol("WM_DELETE_WINDOW", self.cancel) + self.bind('', self.ok) + self.bind("", self.ok) + + self.create_widgets() + self.update_idletasks() # Need here for winfo_reqwidth below. + self.geometry( # Center dialog over parent (or below htest box). + "+%d+%d" % ( + parent.winfo_rootx() + + (parent.winfo_width()/2 - self.winfo_reqwidth()/2), + parent.winfo_rooty() + + ((parent.winfo_height()/2 - self.winfo_reqheight()/2) + if not _htest else 150) + ) ) + self.resizable(height=False, width=False) + + if not _utest: + self.deiconify() # Unhide now that geometry set. + self.entry.focus_set() + self.wait_window() + + def create_widgets(self, ok_text='OK'): # Do not replace. + """Create entry (rows, extras, buttons. + + Entry stuff on rows 0-2, spanning cols 0-2. + Buttons on row 99, cols 1, 2. + """ + # Bind to self the widgets needed for entry_ok or unittest. + self.frame = frame = Frame(self, padding=10) + frame.grid(column=0, row=0, sticky='news') + frame.grid_columnconfigure(0, weight=1) + + entrylabel = Label(frame, anchor='w', justify='left', + text=self.message) + self.entryvar = StringVar(self, self.text0) + self.entry = Entry(frame, width=30, textvariable=self.entryvar) + self.error_font = Font(name='TkCaptionFont', + exists=True, root=self.parent) + self.entry_error = Label(frame, text=' ', foreground='red', + font=self.error_font) + # Display or blank error by setting ['text'] =. + entrylabel.grid(column=0, row=0, columnspan=3, padx=5, sticky=W) + self.entry.grid(column=0, row=1, columnspan=3, padx=5, sticky=W+E, + pady=[10,0]) + self.entry_error.grid(column=0, row=2, columnspan=3, padx=5, + sticky=W+E) + + self.create_extra() + + self.button_ok = Button( + frame, text=ok_text, default='active', command=self.ok) + self.button_cancel = Button( + frame, text='Cancel', command=self.cancel) + + self.button_ok.grid(column=1, row=99, padx=5) + self.button_cancel.grid(column=2, row=99, padx=5) + + def create_extra(self): pass # Override to add widgets. + + def showerror(self, message, widget=None): + #self.bell(displayof=self) + (widget or self.entry_error)['text'] = 'ERROR: ' + message + + def entry_ok(self): # Example: usually replace. + "Return non-blank entry or None." + entry = self.entry.get().strip() + if not entry: + self.showerror('blank line.') + return None + return entry + + def ok(self, event=None): # Do not replace. + '''If entry is valid, bind it to 'result' and destroy tk widget. + + Otherwise leave dialog open for user to correct entry or cancel. + ''' + self.entry_error['text'] = '' + entry = self.entry_ok() + if entry is not None: + self.result = entry + self.destroy() + else: + # [Ok] moves focus. ( does not.) Move it back. + self.entry.focus_set() + + def cancel(self, event=None): # Do not replace. + "Set dialog result to None and destroy tk widget." + self.result = None + self.destroy() + + def destroy(self): + self.grab_release() + super().destroy() + + +class SectionName(Query): + "Get a name for a config file section name." + # Used in ConfigDialog.GetNewKeysName, .GetNewThemeName (837) + + def __init__(self, parent, title, message, used_names, + *, _htest=False, _utest=False): + super().__init__(parent, title, message, used_names=used_names, + _htest=_htest, _utest=_utest) + + def entry_ok(self): + "Return sensible ConfigParser section name or None." + name = self.entry.get().strip() + if not name: + self.showerror('no name specified.') + return None + elif len(name)>30: + self.showerror('name is longer than 30 characters.') + return None + elif name in self.used_names: + self.showerror('name is already in use.') + return None + return name + + +class ModuleName(Query): + "Get a module name for Open Module menu entry." + # Used in open_module (editor.EditorWindow until move to iobinding). + + def __init__(self, parent, title, message, text0, + *, _htest=False, _utest=False): + super().__init__(parent, title, message, text0=text0, + _htest=_htest, _utest=_utest) + + def entry_ok(self): + "Return entered module name as file path or None." + name = self.entry.get().strip() + if not name: + self.showerror('no name specified.') + return None + # XXX Ought to insert current file's directory in front of path. + try: + spec = importlib.util.find_spec(name) + except (ValueError, ImportError) as msg: + self.showerror(str(msg)) + return None + if spec is None: + self.showerror("module not found.") + return None + if not isinstance(spec.loader, importlib.abc.SourceLoader): + self.showerror("not a source-based module.") + return None + try: + file_path = spec.loader.get_filename(name) + except AttributeError: + self.showerror("loader does not support get_filename.") + return None + except ImportError: + # Some special modules require this (e.g. os.path) + try: + file_path = spec.loader.get_filename() + except TypeError: + self.showerror("loader failed to get filename.") + return None + return file_path + + +class Goto(Query): + "Get a positive line number for editor Go To Line." + # Used in editor.EditorWindow.goto_line_event. + + def entry_ok(self): + try: + lineno = int(self.entry.get()) + except ValueError: + self.showerror('not a base 10 integer.') + return None + if lineno <= 0: + self.showerror('not a positive integer.') + return None + return lineno + + +class HelpSource(Query): + "Get menu name and help source for Help menu." + # Used in ConfigDialog.HelpListItemAdd/Edit, (941/9) + + def __init__(self, parent, title, *, menuitem='', filepath='', + used_names={}, _htest=False, _utest=False): + """Get menu entry and url/local file for Additional Help. + + User enters a name for the Help resource and a web url or file + name. The user can browse for the file. + """ + self.filepath = filepath + message = 'Name for item on Help menu:' + super().__init__( + parent, title, message, text0=menuitem, + used_names=used_names, _htest=_htest, _utest=_utest) + + def create_extra(self): + "Add path widjets to rows 10-12." + frame = self.frame + pathlabel = Label(frame, anchor='w', justify='left', + text='Help File Path: Enter URL or browse for file') + self.pathvar = StringVar(self, self.filepath) + self.path = Entry(frame, textvariable=self.pathvar, width=40) + browse = Button(frame, text='Browse', width=8, + command=self.browse_file) + self.path_error = Label(frame, text=' ', foreground='red', + font=self.error_font) + + pathlabel.grid(column=0, row=10, columnspan=3, padx=5, pady=[10,0], + sticky=W) + self.path.grid(column=0, row=11, columnspan=2, padx=5, sticky=W+E, + pady=[10,0]) + browse.grid(column=2, row=11, padx=5, sticky=W+S) + self.path_error.grid(column=0, row=12, columnspan=3, padx=5, + sticky=W+E) + + def askfilename(self, filetypes, initdir, initfile): # htest # + # Extracted from browse_file so can mock for unittests. + # Cannot unittest as cannot simulate button clicks. + # Test by running htest, such as by running this file. + return filedialog.Open(parent=self, filetypes=filetypes)\ + .show(initialdir=initdir, initialfile=initfile) + + def browse_file(self): + filetypes = [ + ("HTML Files", "*.htm *.html", "TEXT"), + ("Text Files", "*.txt", "TEXT"), + ("All Files", "*")] + path = self.pathvar.get() + if path: + dir, base = os.path.split(path) + else: + base = None + if platform[:3] == 'win': + dir = os.path.join(os.path.dirname(executable), 'Doc') + if not os.path.isdir(dir): + dir = os.getcwd() + else: + dir = os.getcwd() + file = self.askfilename(filetypes, dir, base) + if file: + self.pathvar.set(file) + + item_ok = SectionName.entry_ok # localize for test override + + def path_ok(self): + "Simple validity check for menu file path" + path = self.path.get().strip() + if not path: #no path specified + self.showerror('no help file path specified.', self.path_error) + return None + elif not path.startswith(('www.', 'http')): + if path[:5] == 'file:': + path = path[5:] + if not os.path.exists(path): + self.showerror('help file path does not exist.', + self.path_error) + return None + if platform == 'darwin': # for Mac Safari + path = "file://" + path + return path + + def entry_ok(self): + "Return apparently valid (name, path) or None" + self.path_error['text'] = '' + name = self.item_ok() + path = self.path_ok() + return None if name is None or path is None else (name, path) + +class CustomRun(Query): + """Get settings for custom run of module. + + 1. Command line arguments to extend sys.argv. + 2. Whether to restart Shell or not. + """ + # Used in runscript.run_custom_event + + def __init__(self, parent, title, *, cli_args=[], + _htest=False, _utest=False): + """cli_args is a list of strings. + + The list is assigned to the default Entry StringVar. + The strings are displayed joined by ' ' for display. + """ + message = 'Command Line Arguments for sys.argv:' + super().__init__( + parent, title, message, text0=cli_args, + _htest=_htest, _utest=_utest) + + def create_extra(self): + "Add run mode on rows 10-12." + frame = self.frame + self.restartvar = BooleanVar(self, value=True) + restart = Checkbutton(frame, variable=self.restartvar, onvalue=True, + offvalue=False, text='Restart shell') + self.args_error = Label(frame, text=' ', foreground='red', + font=self.error_font) + + restart.grid(column=0, row=10, columnspan=3, padx=5, sticky='w') + self.args_error.grid(column=0, row=12, columnspan=3, padx=5, + sticky='we') + + def cli_args_ok(self): + "Return command line arg list or None if error." + cli_string = self.entry.get().strip() + try: + cli_args = shlex.split(cli_string, posix=True) + except ValueError as err: + self.showerror(str(err)) + return None + return cli_args + + def entry_ok(self): + "Return apparently valid (cli_args, restart) or None." + cli_args = self.cli_args_ok() + restart = self.restartvar.get() + return None if cli_args is None else (cli_args, restart) + + +if __name__ == '__main__': + from unittest import main + main('idlelib.idle_test.test_query', verbosity=2, exit=False) + + from idlelib.idle_test.htest import run + run(Query, HelpSource, CustomRun) diff --git a/Lib/idlelib/redirector.py b/Lib/idlelib/redirector.py new file mode 100644 index 000000000..8e2ba68d3 --- /dev/null +++ b/Lib/idlelib/redirector.py @@ -0,0 +1,174 @@ +from tkinter import TclError + +class WidgetRedirector: + """Support for redirecting arbitrary widget subcommands. + + Some Tk operations don't normally pass through tkinter. For example, if a + character is inserted into a Text widget by pressing a key, a default Tk + binding to the widget's 'insert' operation is activated, and the Tk library + processes the insert without calling back into tkinter. + + Although a binding to could be made via tkinter, what we really want + to do is to hook the Tk 'insert' operation itself. For one thing, we want + a text.insert call in idle code to have the same effect as a key press. + + When a widget is instantiated, a Tcl command is created whose name is the + same as the pathname widget._w. This command is used to invoke the various + widget operations, e.g. insert (for a Text widget). We are going to hook + this command and provide a facility ('register') to intercept the widget + operation. We will also intercept method calls on the tkinter class + instance that represents the tk widget. + + In IDLE, WidgetRedirector is used in Percolator to intercept Text + commands. The function being registered provides access to the top + of a Percolator chain. At the bottom of the chain is a call to the + original Tk widget operation. + """ + def __init__(self, widget): + '''Initialize attributes and setup redirection. + + _operations: dict mapping operation name to new function. + widget: the widget whose tcl command is to be intercepted. + tk: widget.tk, a convenience attribute, probably not needed. + orig: new name of the original tcl command. + + Since renaming to orig fails with TclError when orig already + exists, only one WidgetDirector can exist for a given widget. + ''' + self._operations = {} + self.widget = widget # widget instance + self.tk = tk = widget.tk # widget's root + w = widget._w # widget's (full) Tk pathname + self.orig = w + "_orig" + # Rename the Tcl command within Tcl: + tk.call("rename", w, self.orig) + # Create a new Tcl command whose name is the widget's pathname, and + # whose action is to dispatch on the operation passed to the widget: + tk.createcommand(w, self.dispatch) + + def __repr__(self): + w = self.widget + return f"{self.__class__.__name__,}({w.__class__.__name__}<{w._w}>)" + + def close(self): + "Unregister operations and revert redirection created by .__init__." + for operation in list(self._operations): + self.unregister(operation) + widget = self.widget + tk = widget.tk + w = widget._w + # Restore the original widget Tcl command. + tk.deletecommand(w) + tk.call("rename", self.orig, w) + del self.widget, self.tk # Should not be needed + # if instance is deleted after close, as in Percolator. + + def register(self, operation, function): + '''Return OriginalCommand(operation) after registering function. + + Registration adds an operation: function pair to ._operations. + It also adds a widget function attribute that masks the tkinter + class instance method. Method masking operates independently + from command dispatch. + + If a second function is registered for the same operation, the + first function is replaced in both places. + ''' + self._operations[operation] = function + setattr(self.widget, operation, function) + return OriginalCommand(self, operation) + + def unregister(self, operation): + '''Return the function for the operation, or None. + + Deleting the instance attribute unmasks the class attribute. + ''' + if operation in self._operations: + function = self._operations[operation] + del self._operations[operation] + try: + delattr(self.widget, operation) + except AttributeError: + pass + return function + else: + return None + + def dispatch(self, operation, *args): + '''Callback from Tcl which runs when the widget is referenced. + + If an operation has been registered in self._operations, apply the + associated function to the args passed into Tcl. Otherwise, pass the + operation through to Tk via the original Tcl function. + + Note that if a registered function is called, the operation is not + passed through to Tk. Apply the function returned by self.register() + to *args to accomplish that. For an example, see colorizer.py. + + ''' + operation = str(operation) # can be a Tcl_Obj + m = self._operations.get(operation) + try: + if m: + return m(*args) + else: + return self.tk.call((self.orig, operation) + args) + except TclError: + return "" + + +class OriginalCommand: + '''Callable for original tk command that has been redirected. + + Returned by .register; can be used in the function registered. + redir = WidgetRedirector(text) + def my_insert(*args): + print("insert", args) + original_insert(*args) + original_insert = redir.register("insert", my_insert) + ''' + + def __init__(self, redir, operation): + '''Create .tk_call and .orig_and_operation for .__call__ method. + + .redir and .operation store the input args for __repr__. + .tk and .orig copy attributes of .redir (probably not needed). + ''' + self.redir = redir + self.operation = operation + self.tk = redir.tk # redundant with self.redir + self.orig = redir.orig # redundant with self.redir + # These two could be deleted after checking recipient code. + self.tk_call = redir.tk.call + self.orig_and_operation = (redir.orig, operation) + + def __repr__(self): + return f"{self.__class__.__name__,}({self.redir!r}, {self.operation!r})" + + def __call__(self, *args): + return self.tk_call(self.orig_and_operation + args) + + +def _widget_redirector(parent): # htest # + from tkinter import Toplevel, Text + + top = Toplevel(parent) + top.title("Test WidgetRedirector") + x, y = map(int, parent.geometry().split('+')[1:]) + top.geometry("+%d+%d" % (x, y + 175)) + text = Text(top) + text.pack() + text.focus_set() + redir = WidgetRedirector(text) + def my_insert(*args): + print("insert", args) + original_insert(*args) + original_insert = redir.register("insert", my_insert) + + +if __name__ == "__main__": + from unittest import main + main('idlelib.idle_test.test_redirector', verbosity=2, exit=False) + + from idlelib.idle_test.htest import run + run(_widget_redirector) diff --git a/Lib/idlelib/replace.py b/Lib/idlelib/replace.py new file mode 100644 index 000000000..3716d8415 --- /dev/null +++ b/Lib/idlelib/replace.py @@ -0,0 +1,299 @@ +"""Replace dialog for IDLE. Inherits SearchDialogBase for GUI. +Uses idlelib.searchengine.SearchEngine for search capability. +Defines various replace related functions like replace, replace all, +and replace+find. +""" +import re + +from tkinter import StringVar, TclError + +from idlelib.searchbase import SearchDialogBase +from idlelib import searchengine + + +def replace(text, insert_tags=None): + """Create or reuse a singleton ReplaceDialog instance. + + The singleton dialog saves user entries and preferences + across instances. + + Args: + text: Text widget containing the text to be searched. + """ + root = text._root() + engine = searchengine.get(root) + if not hasattr(engine, "_replacedialog"): + engine._replacedialog = ReplaceDialog(root, engine) + dialog = engine._replacedialog + searchphrase = text.get("sel.first", "sel.last") + dialog.open(text, searchphrase, insert_tags=insert_tags) + + +class ReplaceDialog(SearchDialogBase): + "Dialog for finding and replacing a pattern in text." + + title = "Replace Dialog" + icon = "Replace" + + def __init__(self, root, engine): + """Create search dialog for finding and replacing text. + + Uses SearchDialogBase as the basis for the GUI and a + searchengine instance to prepare the search. + + Attributes: + replvar: StringVar containing 'Replace with:' value. + replent: Entry widget for replvar. Created in + create_entries(). + ok: Boolean used in searchengine.search_text to indicate + whether the search includes the selection. + """ + super().__init__(root, engine) + self.replvar = StringVar(root) + self.insert_tags = None + + def open(self, text, searchphrase=None, *, insert_tags=None): + """Make dialog visible on top of others and ready to use. + + Also, set the search to include the current selection + (self.ok). + + Args: + text: Text widget being searched. + searchphrase: String phrase to search. + """ + SearchDialogBase.open(self, text, searchphrase) + self.ok = True + self.insert_tags = insert_tags + + def create_entries(self): + "Create base and additional label and text entry widgets." + SearchDialogBase.create_entries(self) + self.replent = self.make_entry("Replace with:", self.replvar)[0] + + def create_command_buttons(self): + """Create base and additional command buttons. + + The additional buttons are for Find, Replace, + Replace+Find, and Replace All. + """ + SearchDialogBase.create_command_buttons(self) + self.make_button("Find", self.find_it) + self.make_button("Replace", self.replace_it) + self.make_button("Replace+Find", self.default_command, isdef=True) + self.make_button("Replace All", self.replace_all) + + def find_it(self, event=None): + "Handle the Find button." + self.do_find(False) + + def replace_it(self, event=None): + """Handle the Replace button. + + If the find is successful, then perform replace. + """ + if self.do_find(self.ok): + self.do_replace() + + def default_command(self, event=None): + """Handle the Replace+Find button as the default command. + + First performs a replace and then, if the replace was + successful, a find next. + """ + if self.do_find(self.ok): + if self.do_replace(): # Only find next match if replace succeeded. + # A bad re can cause it to fail. + self.do_find(False) + + def _replace_expand(self, m, repl): + "Expand replacement text if regular expression." + if self.engine.isre(): + try: + new = m.expand(repl) + except re.PatternError: + self.engine.report_error(repl, 'Invalid Replace Expression') + new = None + else: + new = repl + + return new + + def replace_all(self, event=None): + """Handle the Replace All button. + + Search text for occurrences of the Find value and replace + each of them. The 'wrap around' value controls the start + point for searching. If wrap isn't set, then the searching + starts at the first occurrence after the current selection; + if wrap is set, the replacement starts at the first line. + The replacement is always done top-to-bottom in the text. + """ + prog = self.engine.getprog() + if not prog: + return + repl = self.replvar.get() + text = self.text + res = self.engine.search_text(text, prog) + if not res: + self.bell() + return + text.tag_remove("sel", "1.0", "end") + text.tag_remove("hit", "1.0", "end") + line = res[0] + col = res[1].start() + if self.engine.iswrap(): + line = 1 + col = 0 + ok = True + first = last = None + # XXX ought to replace circular instead of top-to-bottom when wrapping + text.undo_block_start() + while res := self.engine.search_forward( + text, prog, line, col, wrap=False, ok=ok): + line, m = res + chars = text.get("%d.0" % line, "%d.0" % (line+1)) + orig = m.group() + new = self._replace_expand(m, repl) + if new is None: + break + i, j = m.span() + first = "%d.%d" % (line, i) + last = "%d.%d" % (line, j) + if new == orig: + text.mark_set("insert", last) + else: + text.mark_set("insert", first) + if first != last: + text.delete(first, last) + if new: + text.insert(first, new, self.insert_tags) + col = i + len(new) + ok = False + text.undo_block_stop() + if first and last: + self.show_hit(first, last) + self.close() + + def do_find(self, ok=False): + """Search for and highlight next occurrence of pattern in text. + + No text replacement is done with this option. + """ + if not self.engine.getprog(): + return False + text = self.text + res = self.engine.search_text(text, None, ok) + if not res: + self.bell() + return False + line, m = res + i, j = m.span() + first = "%d.%d" % (line, i) + last = "%d.%d" % (line, j) + self.show_hit(first, last) + self.ok = True + return True + + def do_replace(self): + "Replace search pattern in text with replacement value." + prog = self.engine.getprog() + if not prog: + return False + text = self.text + try: + first = pos = text.index("sel.first") + last = text.index("sel.last") + except TclError: + pos = None + if not pos: + first = last = pos = text.index("insert") + line, col = searchengine.get_line_col(pos) + chars = text.get("%d.0" % line, "%d.0" % (line+1)) + m = prog.match(chars, col) + if not prog: + return False + new = self._replace_expand(m, self.replvar.get()) + if new is None: + return False + text.mark_set("insert", first) + text.undo_block_start() + if m.group(): + text.delete(first, last) + if new: + text.insert(first, new, self.insert_tags) + text.undo_block_stop() + self.show_hit(first, text.index("insert")) + self.ok = False + return True + + def show_hit(self, first, last): + """Highlight text between first and last indices. + + Text is highlighted via the 'hit' tag and the marked + section is brought into view. + + The colors from the 'hit' tag aren't currently shown + when the text is displayed. This is due to the 'sel' + tag being added first, so the colors in the 'sel' + config are seen instead of the colors for 'hit'. + """ + text = self.text + text.mark_set("insert", first) + text.tag_remove("sel", "1.0", "end") + text.tag_add("sel", first, last) + text.tag_remove("hit", "1.0", "end") + if first == last: + text.tag_add("hit", first) + else: + text.tag_add("hit", first, last) + text.see("insert") + text.update_idletasks() + + def close(self, event=None): + "Close the dialog and remove hit tags." + SearchDialogBase.close(self, event) + self.text.tag_remove("hit", "1.0", "end") + self.insert_tags = None + + +def _replace_dialog(parent): # htest # + from tkinter import Toplevel, Text, END, SEL + from tkinter.ttk import Frame, Button + + top = Toplevel(parent) + top.title("Test ReplaceDialog") + x, y = map(int, parent.geometry().split('+')[1:]) + top.geometry("+%d+%d" % (x, y + 175)) + + # mock undo delegator methods + def undo_block_start(): + pass + + def undo_block_stop(): + pass + + frame = Frame(top) + frame.pack() + text = Text(frame, inactiveselectbackground='gray') + text.undo_block_start = undo_block_start + text.undo_block_stop = undo_block_stop + text.pack() + text.insert("insert","This is a sample sTring\nPlus MORE.") + text.focus_set() + + def show_replace(): + text.tag_add(SEL, "1.0", END) + replace(text) + text.tag_remove(SEL, "1.0", END) + + button = Button(frame, text="Replace", command=show_replace) + button.pack() + + +if __name__ == '__main__': + from unittest import main + main('idlelib.idle_test.test_replace', verbosity=2, exit=False) + + from idlelib.idle_test.htest import run + run(_replace_dialog) diff --git a/Lib/idlelib/rpc.py b/Lib/idlelib/rpc.py new file mode 100644 index 000000000..3f0b2230d --- /dev/null +++ b/Lib/idlelib/rpc.py @@ -0,0 +1,635 @@ +"""RPC Implementation, originally written for the Python Idle IDE + +For security reasons, GvR requested that Idle's Python execution server process +connect to the Idle process, which listens for the connection. Since Idle has +only one client per server, this was not a limitation. + + +---------------------------------+ +-------------+ + | socketserver.BaseRequestHandler | | SocketIO | + +---------------------------------+ +-------------+ + ^ | register() | + | | unregister()| + | +-------------+ + | ^ ^ + | | | + | + -------------------+ | + | | | + +-------------------------+ +-----------------+ + | RPCHandler | | RPCClient | + | [attribute of RPCServer]| | | + +-------------------------+ +-----------------+ + +The RPCServer handler class is expected to provide register/unregister methods. +RPCHandler inherits the mix-in class SocketIO, which provides these methods. + +See the Idle run.main() docstring for further information on how this was +accomplished in Idle. + +""" +import builtins +import copyreg +import io +import marshal +import os +import pickle +import queue +import select +import socket +import socketserver +import struct +import sys +import threading +import traceback +import types + +def unpickle_code(ms): + "Return code object from marshal string ms." + co = marshal.loads(ms) + assert isinstance(co, types.CodeType) + return co + +def pickle_code(co): + "Return unpickle function and tuple with marshalled co code object." + assert isinstance(co, types.CodeType) + ms = marshal.dumps(co) + return unpickle_code, (ms,) + +def dumps(obj, protocol=None): + "Return pickled (or marshalled) string for obj." + # IDLE passes 'None' to select pickle.DEFAULT_PROTOCOL. + f = io.BytesIO() + p = CodePickler(f, protocol) + p.dump(obj) + return f.getvalue() + + +class CodePickler(pickle.Pickler): + dispatch_table = {types.CodeType: pickle_code, **copyreg.dispatch_table} + + +BUFSIZE = 8*1024 +LOCALHOST = '127.0.0.1' + +class RPCServer(socketserver.TCPServer): + + def __init__(self, addr, handlerclass=None): + if handlerclass is None: + handlerclass = RPCHandler + socketserver.TCPServer.__init__(self, addr, handlerclass) + + def server_bind(self): + "Override TCPServer method, no bind() phase for connecting entity" + pass + + def server_activate(self): + """Override TCPServer method, connect() instead of listen() + + Due to the reversed connection, self.server_address is actually the + address of the Idle Client to which we are connecting. + + """ + self.socket.connect(self.server_address) + + def get_request(self): + "Override TCPServer method, return already connected socket" + return self.socket, self.server_address + + def handle_error(self, request, client_address): + """Override TCPServer method + + Error message goes to __stderr__. No error message if exiting + normally or socket raised EOF. Other exceptions not handled in + server code will cause os._exit. + + """ + try: + raise + except SystemExit: + raise + except: + erf = sys.__stderr__ + print('\n' + '-'*40, file=erf) + print('Unhandled server exception!', file=erf) + print('Thread: %s' % threading.current_thread().name, file=erf) + print('Client Address: ', client_address, file=erf) + print('Request: ', repr(request), file=erf) + traceback.print_exc(file=erf) + print('\n*** Unrecoverable, server exiting!', file=erf) + print('-'*40, file=erf) + os._exit(0) + +#----------------- end class RPCServer -------------------- + +objecttable = {} +request_queue = queue.Queue(0) +response_queue = queue.Queue(0) + + +class SocketIO: + + nextseq = 0 + + def __init__(self, sock, objtable=None, debugging=None): + self.sockthread = threading.current_thread() + if debugging is not None: + self.debugging = debugging + self.sock = sock + if objtable is None: + objtable = objecttable + self.objtable = objtable + self.responses = {} + self.cvars = {} + + def close(self): + sock = self.sock + self.sock = None + if sock is not None: + sock.close() + + def exithook(self): + "override for specific exit action" + os._exit(0) + + def debug(self, *args): + if not self.debugging: + return + s = self.location + " " + str(threading.current_thread().name) + for a in args: + s = s + " " + str(a) + print(s, file=sys.__stderr__) + + def register(self, oid, object_): + self.objtable[oid] = object_ + + def unregister(self, oid): + try: + del self.objtable[oid] + except KeyError: + pass + + def localcall(self, seq, request): + self.debug("localcall:", request) + try: + how, (oid, methodname, args, kwargs) = request + except TypeError: + return ("ERROR", "Bad request format") + if oid not in self.objtable: + return ("ERROR", f"Unknown object id: {oid!r}") + obj = self.objtable[oid] + if methodname == "__methods__": + methods = {} + _getmethods(obj, methods) + return ("OK", methods) + if methodname == "__attributes__": + attributes = {} + _getattributes(obj, attributes) + return ("OK", attributes) + if not hasattr(obj, methodname): + return ("ERROR", f"Unsupported method name: {methodname!r}") + method = getattr(obj, methodname) + try: + if how == 'CALL': + ret = method(*args, **kwargs) + if isinstance(ret, RemoteObject): + ret = remoteref(ret) + return ("OK", ret) + elif how == 'QUEUE': + request_queue.put((seq, (method, args, kwargs))) + return("QUEUED", None) + else: + return ("ERROR", "Unsupported message type: %s" % how) + except SystemExit: + raise + except KeyboardInterrupt: + raise + except OSError: + raise + except Exception as ex: + return ("CALLEXC", ex) + except: + msg = "*** Internal Error: rpc.py:SocketIO.localcall()\n\n"\ + " Object: %s \n Method: %s \n Args: %s\n" + print(msg % (oid, method, args), file=sys.__stderr__) + traceback.print_exc(file=sys.__stderr__) + return ("EXCEPTION", None) + + def remotecall(self, oid, methodname, args, kwargs): + self.debug("remotecall:asynccall: ", oid, methodname) + seq = self.asynccall(oid, methodname, args, kwargs) + return self.asyncreturn(seq) + + def remotequeue(self, oid, methodname, args, kwargs): + self.debug("remotequeue:asyncqueue: ", oid, methodname) + seq = self.asyncqueue(oid, methodname, args, kwargs) + return self.asyncreturn(seq) + + def asynccall(self, oid, methodname, args, kwargs): + request = ("CALL", (oid, methodname, args, kwargs)) + seq = self.newseq() + if threading.current_thread() != self.sockthread: + cvar = threading.Condition() + self.cvars[seq] = cvar + self.debug(("asynccall:%d:" % seq), oid, methodname, args, kwargs) + self.putmessage((seq, request)) + return seq + + def asyncqueue(self, oid, methodname, args, kwargs): + request = ("QUEUE", (oid, methodname, args, kwargs)) + seq = self.newseq() + if threading.current_thread() != self.sockthread: + cvar = threading.Condition() + self.cvars[seq] = cvar + self.debug(("asyncqueue:%d:" % seq), oid, methodname, args, kwargs) + self.putmessage((seq, request)) + return seq + + def asyncreturn(self, seq): + self.debug("asyncreturn:%d:call getresponse(): " % seq) + response = self.getresponse(seq, wait=0.05) + self.debug(("asyncreturn:%d:response: " % seq), response) + return self.decoderesponse(response) + + def decoderesponse(self, response): + how, what = response + if how == "OK": + return what + if how == "QUEUED": + return None + if how == "EXCEPTION": + self.debug("decoderesponse: EXCEPTION") + return None + if how == "EOF": + self.debug("decoderesponse: EOF") + self.decode_interrupthook() + return None + if how == "ERROR": + self.debug("decoderesponse: Internal ERROR:", what) + raise RuntimeError(what) + if how == "CALLEXC": + self.debug("decoderesponse: Call Exception:", what) + raise what + raise SystemError(how, what) + + def decode_interrupthook(self): + "" + raise EOFError + + def mainloop(self): + """Listen on socket until I/O not ready or EOF + + pollresponse() will loop looking for seq number None, which + never comes, and exit on EOFError. + + """ + try: + self.getresponse(myseq=None, wait=0.05) + except EOFError: + self.debug("mainloop:return") + return + + def getresponse(self, myseq, wait): + response = self._getresponse(myseq, wait) + if response is not None: + how, what = response + if how == "OK": + response = how, self._proxify(what) + return response + + def _proxify(self, obj): + if isinstance(obj, RemoteProxy): + return RPCProxy(self, obj.oid) + if isinstance(obj, list): + return list(map(self._proxify, obj)) + # XXX Check for other types -- not currently needed + return obj + + def _getresponse(self, myseq, wait): + self.debug("_getresponse:myseq:", myseq) + if threading.current_thread() is self.sockthread: + # this thread does all reading of requests or responses + while True: + response = self.pollresponse(myseq, wait) + if response is not None: + return response + else: + # wait for notification from socket handling thread + cvar = self.cvars[myseq] + cvar.acquire() + while myseq not in self.responses: + cvar.wait() + response = self.responses[myseq] + self.debug("_getresponse:%s: thread woke up: response: %s" % + (myseq, response)) + del self.responses[myseq] + del self.cvars[myseq] + cvar.release() + return response + + def newseq(self): + self.nextseq = seq = self.nextseq + 2 + return seq + + def putmessage(self, message): + self.debug("putmessage:%d:" % message[0]) + try: + s = dumps(message) + except pickle.PicklingError: + print("Cannot pickle:", repr(message), file=sys.__stderr__) + raise + s = struct.pack(" 0: + try: + r, w, x = select.select([], [self.sock], []) + n = self.sock.send(s[:BUFSIZE]) + except (AttributeError, TypeError): + raise OSError("socket no longer exists") + s = s[n:] + + buff = b'' + bufneed = 4 + bufstate = 0 # meaning: 0 => reading count; 1 => reading data + + def pollpacket(self, wait): + self._stage0() + if len(self.buff) < self.bufneed: + r, w, x = select.select([self.sock.fileno()], [], [], wait) + if len(r) == 0: + return None + try: + s = self.sock.recv(BUFSIZE) + except OSError: + raise EOFError + if len(s) == 0: + raise EOFError + self.buff += s + self._stage0() + return self._stage1() + + def _stage0(self): + if self.bufstate == 0 and len(self.buff) >= 4: + s = self.buff[:4] + self.buff = self.buff[4:] + self.bufneed = struct.unpack("= self.bufneed: + packet = self.buff[:self.bufneed] + self.buff = self.buff[self.bufneed:] + self.bufneed = 4 + self.bufstate = 0 + return packet + + def pollmessage(self, wait): + packet = self.pollpacket(wait) + if packet is None: + return None + try: + message = pickle.loads(packet) + except pickle.UnpicklingError: + print("-----------------------", file=sys.__stderr__) + print("cannot unpickle packet:", repr(packet), file=sys.__stderr__) + traceback.print_stack(file=sys.__stderr__) + print("-----------------------", file=sys.__stderr__) + raise + return message + + def pollresponse(self, myseq, wait): + """Handle messages received on the socket. + + Some messages received may be asynchronous 'call' or 'queue' requests, + and some may be responses for other threads. + + 'call' requests are passed to self.localcall() with the expectation of + immediate execution, during which time the socket is not serviced. + + 'queue' requests are used for tasks (which may block or hang) to be + processed in a different thread. These requests are fed into + request_queue by self.localcall(). Responses to queued requests are + taken from response_queue and sent across the link with the associated + sequence numbers. Messages in the queues are (sequence_number, + request/response) tuples and code using this module removing messages + from the request_queue is responsible for returning the correct + sequence number in the response_queue. + + pollresponse() will loop until a response message with the myseq + sequence number is received, and will save other responses in + self.responses and notify the owning thread. + + """ + while True: + # send queued response if there is one available + try: + qmsg = response_queue.get(0) + except queue.Empty: + pass + else: + seq, response = qmsg + message = (seq, ('OK', response)) + self.putmessage(message) + # poll for message on link + try: + message = self.pollmessage(wait) + if message is None: # socket not ready + return None + except EOFError: + self.handle_EOF() + return None + except AttributeError: + return None + seq, resq = message + how = resq[0] + self.debug("pollresponse:%d:myseq:%s" % (seq, myseq)) + # process or queue a request + if how in ("CALL", "QUEUE"): + self.debug("pollresponse:%d:localcall:call:" % seq) + response = self.localcall(seq, resq) + self.debug("pollresponse:%d:localcall:response:%s" + % (seq, response)) + if how == "CALL": + self.putmessage((seq, response)) + elif how == "QUEUE": + # don't acknowledge the 'queue' request! + pass + continue + # return if completed message transaction + elif seq == myseq: + return resq + # must be a response for a different thread: + else: + cv = self.cvars.get(seq, None) + # response involving unknown sequence number is discarded, + # probably intended for prior incarnation of server + if cv is not None: + cv.acquire() + self.responses[seq] = resq + cv.notify() + cv.release() + continue + + def handle_EOF(self): + "action taken upon link being closed by peer" + self.EOFhook() + self.debug("handle_EOF") + for key in self.cvars: + cv = self.cvars[key] + cv.acquire() + self.responses[key] = ('EOF', None) + cv.notify() + cv.release() + # call our (possibly overridden) exit function + self.exithook() + + def EOFhook(self): + "Classes using rpc client/server can override to augment EOF action" + pass + +#----------------- end class SocketIO -------------------- + +class RemoteObject: + # Token mix-in class + pass + + +def remoteref(obj): + oid = id(obj) + objecttable[oid] = obj + return RemoteProxy(oid) + + +class RemoteProxy: + + def __init__(self, oid): + self.oid = oid + + +class RPCHandler(socketserver.BaseRequestHandler, SocketIO): + + debugging = False + location = "#S" # Server + + def __init__(self, sock, addr, svr): + svr.current_handler = self ## cgt xxx + SocketIO.__init__(self, sock) + socketserver.BaseRequestHandler.__init__(self, sock, addr, svr) + + def handle(self): + "handle() method required by socketserver" + self.mainloop() + + def get_remote_proxy(self, oid): + return RPCProxy(self, oid) + + +class RPCClient(SocketIO): + + debugging = False + location = "#C" # Client + + nextseq = 1 # Requests coming from the client are odd numbered + + def __init__(self, address, family=socket.AF_INET, type=socket.SOCK_STREAM): + self.listening_sock = socket.socket(family, type) + self.listening_sock.bind(address) + self.listening_sock.listen(1) + + def accept(self): + working_sock, address = self.listening_sock.accept() + if self.debugging: + print("****** Connection request from ", address, file=sys.__stderr__) + if address[0] == LOCALHOST: + SocketIO.__init__(self, working_sock) + else: + print("** Invalid host: ", address, file=sys.__stderr__) + raise OSError + + def get_remote_proxy(self, oid): + return RPCProxy(self, oid) + + +class RPCProxy: + + __methods = None + __attributes = None + + def __init__(self, sockio, oid): + self.sockio = sockio + self.oid = oid + + def __getattr__(self, name): + if self.__methods is None: + self.__getmethods() + if self.__methods.get(name): + return MethodProxy(self.sockio, self.oid, name) + if self.__attributes is None: + self.__getattributes() + if name in self.__attributes: + value = self.sockio.remotecall(self.oid, '__getattribute__', + (name,), {}) + return value + else: + raise AttributeError(name) + + def __getattributes(self): + self.__attributes = self.sockio.remotecall(self.oid, + "__attributes__", (), {}) + + def __getmethods(self): + self.__methods = self.sockio.remotecall(self.oid, + "__methods__", (), {}) + +def _getmethods(obj, methods): + # Helper to get a list of methods from an object + # Adds names to dictionary argument 'methods' + for name in dir(obj): + attr = getattr(obj, name) + if callable(attr): + methods[name] = 1 + if isinstance(obj, type): + for super in obj.__bases__: + _getmethods(super, methods) + +def _getattributes(obj, attributes): + for name in dir(obj): + attr = getattr(obj, name) + if not callable(attr): + attributes[name] = 1 + + +class MethodProxy: + + def __init__(self, sockio, oid, name): + self.sockio = sockio + self.oid = oid + self.name = name + + def __call__(self, /, *args, **kwargs): + value = self.sockio.remotecall(self.oid, self.name, args, kwargs) + return value + + +# XXX KBK 09Sep03 We need a proper unit test for this module. Previously +# existing test code was removed at Rev 1.27 (r34098). + +def displayhook(value): + """Override standard display hook to use non-locale encoding""" + if value is None: + return + # Set '_' to None to avoid recursion + builtins._ = None + text = repr(value) + try: + sys.stdout.write(text) + except UnicodeEncodeError: + # let's use ascii while utf8-bmp codec doesn't present + encoding = 'ascii' + bytes = text.encode(encoding, 'backslashreplace') + text = bytes.decode(encoding, 'strict') + sys.stdout.write(text) + sys.stdout.write("\n") + builtins._ = value + + +if __name__ == '__main__': + from unittest import main + main('idlelib.idle_test.test_rpc', verbosity=2,) diff --git a/Lib/idlelib/run.py b/Lib/idlelib/run.py new file mode 100644 index 000000000..a30db99a6 --- /dev/null +++ b/Lib/idlelib/run.py @@ -0,0 +1,653 @@ +""" idlelib.run + +Simplified, pyshell.ModifiedInterpreter spawns a subprocess with +f'''{sys.executable} -c "__import__('idlelib.run').run.main()"''' +'.run' is needed because __import__ returns idlelib, not idlelib.run. +""" +import contextlib +import functools +import io +import linecache +import queue +import sys +import textwrap +import time +import traceback +import _thread as thread +import threading +import warnings + +import idlelib # testing +from idlelib import autocomplete # AutoComplete, fetch_encodings +from idlelib import calltip # Calltip +from idlelib import debugger_r # start_debugger +from idlelib import debugobj_r # remote_object_tree_item +from idlelib import iomenu # encoding +from idlelib import rpc # multiple objects +from idlelib import stackviewer # StackTreeItem +import __main__ + +import tkinter # Use tcl and, if startup fails, messagebox. +if not hasattr(sys.modules['idlelib.run'], 'firstrun'): + # Undo modifications of tkinter by idlelib imports; see bpo-25507. + for mod in ('simpledialog', 'messagebox', 'font', + 'dialog', 'filedialog', 'commondialog', + 'ttk'): + delattr(tkinter, mod) + del sys.modules['tkinter.' + mod] + # Avoid AttributeError if run again; see bpo-37038. + sys.modules['idlelib.run'].firstrun = False + +LOCALHOST = '127.0.0.1' + +try: + eof = 'Ctrl-D (end-of-file)' + exit.eof = eof + quit.eof = eof +except NameError: # In case subprocess started with -S (maybe in future). + pass + + +def idle_formatwarning(message, category, filename, lineno, line=None): + """Format warnings the IDLE way.""" + + s = "\nWarning (from warnings module):\n" + s += f' File \"{filename}\", line {lineno}\n' + if line is None: + line = linecache.getline(filename, lineno) + line = line.strip() + if line: + s += " %s\n" % line + s += f"{category.__name__}: {message}\n" + return s + +def idle_showwarning_subproc( + message, category, filename, lineno, file=None, line=None): + """Show Idle-format warning after replacing warnings.showwarning. + + The only difference is the formatter called. + """ + if file is None: + file = sys.stderr + try: + file.write(idle_formatwarning( + message, category, filename, lineno, line)) + except OSError: + pass # the file (probably stderr) is invalid - this warning gets lost. + +_warnings_showwarning = None + +def capture_warnings(capture): + "Replace warning.showwarning with idle_showwarning_subproc, or reverse." + + global _warnings_showwarning + if capture: + if _warnings_showwarning is None: + _warnings_showwarning = warnings.showwarning + warnings.showwarning = idle_showwarning_subproc + else: + if _warnings_showwarning is not None: + warnings.showwarning = _warnings_showwarning + _warnings_showwarning = None + +capture_warnings(True) + +if idlelib.testing: + # gh-121008: When testing IDLE, don't create a Tk object to avoid side + # effects such as installing a PyOS_InputHook hook. + def handle_tk_events(): + pass +else: + tcl = tkinter.Tcl() + + def handle_tk_events(tcl=tcl): + """Process any tk events that are ready to be dispatched if tkinter + has been imported, a tcl interpreter has been created and tk has been + loaded.""" + tcl.eval("update") + +# Thread shared globals: Establish a queue between a subthread (which handles +# the socket) and the main thread (which runs user code), plus global +# completion, exit and interruptible (the main thread) flags: + +exit_now = False +quitting = False +interruptible = False + +def main(del_exitfunc=False): + """Start the Python execution server in a subprocess + + In the Python subprocess, RPCServer is instantiated with handlerclass + MyHandler, which inherits register/unregister methods from RPCHandler via + the mix-in class SocketIO. + + When the RPCServer 'server' is instantiated, the TCPServer initialization + creates an instance of run.MyHandler and calls its handle() method. + handle() instantiates a run.Executive object, passing it a reference to the + MyHandler object. That reference is saved as attribute rpchandler of the + Executive instance. The Executive methods have access to the reference and + can pass it on to entities that they command + (e.g. debugger_r.Debugger.start_debugger()). The latter, in turn, can + call MyHandler(SocketIO) register/unregister methods via the reference to + register and unregister themselves. + + """ + global exit_now + global quitting + global no_exitfunc + no_exitfunc = del_exitfunc + #time.sleep(15) # test subprocess not responding + try: + assert(len(sys.argv) > 1) + port = int(sys.argv[-1]) + except: + print("IDLE Subprocess: no IP port passed in sys.argv.", + file=sys.__stderr__) + return + + capture_warnings(True) + sys.argv[:] = [""] + threading.Thread(target=manage_socket, + name='SockThread', + args=((LOCALHOST, port),), + daemon=True, + ).start() + + while True: + try: + if exit_now: + try: + exit() + except KeyboardInterrupt: + # exiting but got an extra KBI? Try again! + continue + try: + request = rpc.request_queue.get(block=True, timeout=0.05) + except queue.Empty: + request = None + # Issue 32207: calling handle_tk_events here adds spurious + # queue.Empty traceback to event handling exceptions. + if request: + seq, (method, args, kwargs) = request + ret = method(*args, **kwargs) + rpc.response_queue.put((seq, ret)) + else: + handle_tk_events() + except KeyboardInterrupt: + if quitting: + exit_now = True + continue + except SystemExit: + capture_warnings(False) + raise + except: + type, value, tb = sys.exc_info() + try: + print_exception() + rpc.response_queue.put((seq, None)) + except: + # Link didn't work, print same exception to __stderr__ + traceback.print_exception(type, value, tb, file=sys.__stderr__) + exit() + else: + continue + +def manage_socket(address): + for i in range(3): + time.sleep(i) + try: + server = MyRPCServer(address, MyHandler) + break + except OSError as err: + print("IDLE Subprocess: OSError: " + err.args[1] + + ", retrying....", file=sys.__stderr__) + socket_error = err + else: + print("IDLE Subprocess: Connection to " + "IDLE GUI failed, exiting.", file=sys.__stderr__) + show_socket_error(socket_error, address) + global exit_now + exit_now = True + return + server.handle_request() # A single request only + +def show_socket_error(err, address): + "Display socket error from manage_socket." + import tkinter + from tkinter.messagebox import showerror + root = tkinter.Tk() + fix_scaling(root) + root.withdraw() + showerror( + "Subprocess Connection Error", + f"IDLE's subprocess can't connect to {address[0]}:{address[1]}.\n" + f"Fatal OSError #{err.errno}: {err.strerror}.\n" + "See the 'Startup failure' section of the IDLE doc, online at\n" + "https://docs.python.org/3/library/idle.html#startup-failure", + parent=root) + root.destroy() + + +def get_message_lines(typ, exc, tb): + "Return line composing the exception message." + if typ in (AttributeError, NameError): + # 3.10+ hints are not directly accessible from python (#44026). + err = io.StringIO() + with contextlib.redirect_stderr(err): + sys.__excepthook__(typ, exc, tb) + return [err.getvalue().split("\n")[-2] + "\n"] + else: + return traceback.format_exception_only(typ, exc) + + +def print_exception(): + import linecache + linecache.checkcache() + flush_stdout() + efile = sys.stderr + typ, val, tb = excinfo = sys.exc_info() + sys.last_type, sys.last_value, sys.last_traceback = excinfo + sys.last_exc = val + seen = set() + + def print_exc(typ, exc, tb): + seen.add(id(exc)) + context = exc.__context__ + cause = exc.__cause__ + if cause is not None and id(cause) not in seen: + print_exc(type(cause), cause, cause.__traceback__) + print("\nThe above exception was the direct cause " + "of the following exception:\n", file=efile) + elif (context is not None and + not exc.__suppress_context__ and + id(context) not in seen): + print_exc(type(context), context, context.__traceback__) + print("\nDuring handling of the above exception, " + "another exception occurred:\n", file=efile) + if tb: + tbe = traceback.extract_tb(tb) + print('Traceback (most recent call last):', file=efile) + exclude = ("run.py", "rpc.py", "threading.py", "queue.py", + "debugger_r.py", "bdb.py") + cleanup_traceback(tbe, exclude) + traceback.print_list(tbe, file=efile) + lines = get_message_lines(typ, exc, tb) + for line in lines: + print(line, end='', file=efile) + + print_exc(typ, val, tb) + +def cleanup_traceback(tb, exclude): + "Remove excluded traces from beginning/end of tb; get cached lines" + orig_tb = tb[:] + while tb: + for rpcfile in exclude: + if tb[0][0].count(rpcfile): + break # found an exclude, break for: and delete tb[0] + else: + break # no excludes, have left RPC code, break while: + del tb[0] + while tb: + for rpcfile in exclude: + if tb[-1][0].count(rpcfile): + break + else: + break + del tb[-1] + if len(tb) == 0: + # exception was in IDLE internals, don't prune! + tb[:] = orig_tb[:] + print("** IDLE Internal Exception: ", file=sys.stderr) + rpchandler = rpc.objecttable['exec'].rpchandler + for i in range(len(tb)): + fn, ln, nm, line = tb[i] + if nm == '?': + nm = "-toplevel-" + if not line and fn.startswith(" 1.4: + for name in tkinter.font.names(root): + font = tkinter.font.Font(root=root, name=name, exists=True) + size = int(font['size']) + if size < 0: + font['size'] = round(-0.75*size) + + +def fixdoc(fun, text): + tem = (fun.__doc__ + '\n\n') if fun.__doc__ is not None else '' + fun.__doc__ = tem + textwrap.fill(textwrap.dedent(text)) + +RECURSIONLIMIT_DELTA = 30 + +def install_recursionlimit_wrappers(): + """Install wrappers to always add 30 to the recursion limit.""" + # see: bpo-26806 + + @functools.wraps(sys.setrecursionlimit) + def setrecursionlimit(*args, **kwargs): + # mimic the original sys.setrecursionlimit()'s input handling + if kwargs: + raise TypeError( + "setrecursionlimit() takes no keyword arguments") + try: + limit, = args + except ValueError: + raise TypeError(f"setrecursionlimit() takes exactly one " + f"argument ({len(args)} given)") + if not limit > 0: + raise ValueError( + "recursion limit must be greater or equal than 1") + + return setrecursionlimit.__wrapped__(limit + RECURSIONLIMIT_DELTA) + + fixdoc(setrecursionlimit, f"""\ + This IDLE wrapper adds {RECURSIONLIMIT_DELTA} to prevent possible + uninterruptible loops.""") + + @functools.wraps(sys.getrecursionlimit) + def getrecursionlimit(): + return getrecursionlimit.__wrapped__() - RECURSIONLIMIT_DELTA + + fixdoc(getrecursionlimit, f"""\ + This IDLE wrapper subtracts {RECURSIONLIMIT_DELTA} to compensate + for the {RECURSIONLIMIT_DELTA} IDLE adds when setting the limit.""") + + # add the delta to the default recursion limit, to compensate + sys.setrecursionlimit(sys.getrecursionlimit() + RECURSIONLIMIT_DELTA) + + sys.setrecursionlimit = setrecursionlimit + sys.getrecursionlimit = getrecursionlimit + + +def uninstall_recursionlimit_wrappers(): + """Uninstall the recursion limit wrappers from the sys module. + + IDLE only uses this for tests. Users can import run and call + this to remove the wrapping. + """ + if ( + getattr(sys.setrecursionlimit, '__wrapped__', None) and + getattr(sys.getrecursionlimit, '__wrapped__', None) + ): + sys.setrecursionlimit = sys.setrecursionlimit.__wrapped__ + sys.getrecursionlimit = sys.getrecursionlimit.__wrapped__ + sys.setrecursionlimit(sys.getrecursionlimit() - RECURSIONLIMIT_DELTA) + + +class MyRPCServer(rpc.RPCServer): + + def handle_error(self, request, client_address): + """Override RPCServer method for IDLE + + Interrupt the MainThread and exit server if link is dropped. + + """ + global quitting + try: + raise + except SystemExit: + raise + except EOFError: + global exit_now + exit_now = True + thread.interrupt_main() + except: + erf = sys.__stderr__ + print(textwrap.dedent(f""" + {'-'*40} + Unhandled exception in user code execution server!' + Thread: {threading.current_thread().name} + IDLE Client Address: {client_address} + Request: {request!r} + """), file=erf) + traceback.print_exc(limit=-20, file=erf) + print(textwrap.dedent(f""" + *** Unrecoverable, server exiting! + + Users should never see this message; it is likely transient. + If this recurs, report this with a copy of the message + and an explanation of how to make it repeat. + {'-'*40}"""), file=erf) + quitting = True + thread.interrupt_main() + + +# Pseudofiles for shell-remote communication (also used in pyshell) + +class StdioFile(io.TextIOBase): + + def __init__(self, shell, tags, encoding='utf-8', errors='strict'): + self.shell = shell + # GH-78889: accessing unpickleable attributes freezes Shell. + # IDLE only needs methods; allow 'width' for possible use. + self.shell._RPCProxy__attributes = {'width': 1} + self.tags = tags + self._encoding = encoding + self._errors = errors + + @property + def encoding(self): + return self._encoding + + @property + def errors(self): + return self._errors + + @property + def name(self): + return '<%s>' % self.tags + + def isatty(self): + return True + + +class StdOutputFile(StdioFile): + + def writable(self): + return True + + def write(self, s): + if self.closed: + raise ValueError("write to closed file") + s = str.encode(s, self.encoding, self.errors).decode(self.encoding, self.errors) + return self.shell.write(s, self.tags) + + +class StdInputFile(StdioFile): + _line_buffer = '' + + def readable(self): + return True + + def read(self, size=-1): + if self.closed: + raise ValueError("read from closed file") + if size is None: + size = -1 + elif not isinstance(size, int): + raise TypeError('must be int, not ' + type(size).__name__) + result = self._line_buffer + self._line_buffer = '' + if size < 0: + while line := self.shell.readline(): + result += line + else: + while len(result) < size: + line = self.shell.readline() + if not line: break + result += line + self._line_buffer = result[size:] + result = result[:size] + return result + + def readline(self, size=-1): + if self.closed: + raise ValueError("read from closed file") + if size is None: + size = -1 + elif not isinstance(size, int): + raise TypeError('must be int, not ' + type(size).__name__) + line = self._line_buffer or self.shell.readline() + if size < 0: + size = len(line) + eol = line.find('\n', 0, size) + if eol >= 0: + size = eol + 1 + self._line_buffer = line[size:] + return line[:size] + + def close(self): + self.shell.close() + + +class MyHandler(rpc.RPCHandler): + + def handle(self): + """Override base method""" + executive = Executive(self) + self.register("exec", executive) + self.console = self.get_remote_proxy("console") + sys.stdin = StdInputFile(self.console, "stdin", + iomenu.encoding, iomenu.errors) + sys.stdout = StdOutputFile(self.console, "stdout", + iomenu.encoding, iomenu.errors) + sys.stderr = StdOutputFile(self.console, "stderr", + iomenu.encoding, "backslashreplace") + + sys.displayhook = rpc.displayhook + # page help() text to shell. + import pydoc # import must be done here to capture i/o binding + pydoc.pager = pydoc.plainpager + + # Keep a reference to stdin so that it won't try to exit IDLE if + # sys.stdin gets changed from within IDLE's shell. See issue17838. + self._keep_stdin = sys.stdin + + install_recursionlimit_wrappers() + + self.interp = self.get_remote_proxy("interp") + rpc.RPCHandler.getresponse(self, myseq=None, wait=0.05) + + def exithook(self): + "override SocketIO method - wait for MainThread to shut us down" + time.sleep(10) + + def EOFhook(self): + "Override SocketIO method - terminate wait on callback and exit thread" + global quitting + quitting = True + thread.interrupt_main() + + def decode_interrupthook(self): + "interrupt awakened thread" + global quitting + quitting = True + thread.interrupt_main() + + +class Executive: + + def __init__(self, rpchandler): + self.rpchandler = rpchandler + if idlelib.testing is False: + self.locals = __main__.__dict__ + self.calltip = calltip.Calltip() + self.autocomplete = autocomplete.AutoComplete() + else: + self.locals = {} + + def runcode(self, code): + global interruptible + try: + self.user_exc_info = None + interruptible = True + try: + exec(code, self.locals) + finally: + interruptible = False + except SystemExit as e: + if e.args: # SystemExit called with an argument. + ob = e.args[0] + if not isinstance(ob, (type(None), int)): + print('SystemExit: ' + str(ob), file=sys.stderr) + # Return to the interactive prompt. + except: + self.user_exc_info = sys.exc_info() # For testing, hook, viewer. + if quitting: + exit() + if sys.excepthook is sys.__excepthook__: + print_exception() + else: + try: + sys.excepthook(*self.user_exc_info) + except: + self.user_exc_info = sys.exc_info() # For testing. + print_exception() + jit = self.rpchandler.console.getvar("<>") + if jit: + self.rpchandler.interp.open_remote_stack_viewer() + else: + flush_stdout() + + def interrupt_the_server(self): + if interruptible: + thread.interrupt_main() + + def start_the_debugger(self, gui_adap_oid): + return debugger_r.start_debugger(self.rpchandler, gui_adap_oid) + + def stop_the_debugger(self, idb_adap_oid): + "Unregister the Idb Adapter. Link objects and Idb then subject to GC" + self.rpchandler.unregister(idb_adap_oid) + + def get_the_calltip(self, name): + return self.calltip.fetch_tip(name) + + def get_the_completion_list(self, what, mode): + return self.autocomplete.fetch_completions(what, mode) + + def stackviewer(self, flist_oid=None): + if self.user_exc_info: + _, exc, tb = self.user_exc_info + else: + return None + flist = None + if flist_oid is not None: + flist = self.rpchandler.get_remote_proxy(flist_oid) + while tb and tb.tb_frame.f_globals["__name__"] in ["rpc", "run"]: + tb = tb.tb_next + exc.__traceback__ = tb + item = stackviewer.StackTreeItem(exc, flist) + return debugobj_r.remote_object_tree_item(item) + + +if __name__ == '__main__': + from unittest import main + main('idlelib.idle_test.test_run', verbosity=2) + +capture_warnings(False) # Make sure turned off; see bpo-18081. diff --git a/Lib/idlelib/runscript.py b/Lib/idlelib/runscript.py new file mode 100644 index 000000000..55712e904 --- /dev/null +++ b/Lib/idlelib/runscript.py @@ -0,0 +1,213 @@ +"""Execute code from an editor. + +Check module: do a full syntax check of the current module. +Also run the tabnanny to catch any inconsistent tabs. + +Run module: also execute the module's code in the __main__ namespace. +The window must have been saved previously. The module is added to +sys.modules, and is also added to the __main__ namespace. + +TODO: Specify command line arguments in a dialog box. +""" +import os +import tabnanny +import time +import tokenize + +from tkinter import messagebox + +from idlelib.config import idleConf +from idlelib import macosx +from idlelib import pyshell +from idlelib.query import CustomRun +from idlelib import outwin + +indent_message = """Error: Inconsistent indentation detected! + +1) Your indentation is outright incorrect (easy to fix), OR + +2) Your indentation mixes tabs and spaces. + +To fix case 2, change all tabs to spaces by using Edit->Select All followed \ +by Format->Untabify Region and specify the number of columns used by each tab. +""" + + +class ScriptBinding: + + def __init__(self, editwin): + self.editwin = editwin + # Provide instance variables referenced by debugger + # XXX This should be done differently + self.flist = self.editwin.flist + self.root = self.editwin.root + # cli_args is list of strings that extends sys.argv + self.cli_args = [] + self.perf = 0.0 # Workaround for macOS 11 Uni2; see bpo-42508. + + def check_module_event(self, event): + if isinstance(self.editwin, outwin.OutputWindow): + self.editwin.text.bell() + return 'break' + filename = self.getfilename() + if not filename: + return 'break' + if not self.checksyntax(filename): + return 'break' + if not self.tabnanny(filename): + return 'break' + return "break" + + def tabnanny(self, filename): + # XXX: tabnanny should work on binary files as well + with tokenize.open(filename) as f: + try: + tabnanny.process_tokens(tokenize.generate_tokens(f.readline)) + except tokenize.TokenError as msg: + msgtxt, (lineno, start) = msg.args + self.editwin.gotoline(lineno) + self.errorbox("Tabnanny Tokenizing Error", + "Token Error: %s" % msgtxt) + return False + except tabnanny.NannyNag as nag: + # The error messages from tabnanny are too confusing... + self.editwin.gotoline(nag.get_lineno()) + self.errorbox("Tab/space error", indent_message) + return False + return True + + def checksyntax(self, filename): + self.shell = shell = self.flist.open_shell() + saved_stream = shell.get_warning_stream() + shell.set_warning_stream(shell.stderr) + with open(filename, 'rb') as f: + source = f.read() + if b'\r' in source: + source = source.replace(b'\r\n', b'\n') + source = source.replace(b'\r', b'\n') + if source and source[-1] != ord(b'\n'): + source = source + b'\n' + editwin = self.editwin + text = editwin.text + text.tag_remove("ERROR", "1.0", "end") + try: + # If successful, return the compiled code + return compile(source, filename, "exec") + except (SyntaxError, OverflowError, ValueError) as value: + msg = getattr(value, 'msg', '') or value or "" + lineno = getattr(value, 'lineno', '') or 1 + offset = getattr(value, 'offset', '') or 0 + if offset == 0: + lineno += 1 #mark end of offending line + pos = "0.0 + %d lines + %d chars" % (lineno-1, offset-1) + editwin.colorize_syntax_error(text, pos) + self.errorbox("SyntaxError", "%-20s" % msg) + return False + finally: + shell.set_warning_stream(saved_stream) + + def run_custom_event(self, event): + return self.run_module_event(event, customize=True) + + def run_module_event(self, event, *, customize=False): + """Run the module after setting up the environment. + + First check the syntax. Next get customization. If OK, make + sure the shell is active and then transfer the arguments, set + the run environment's working directory to the directory of the + module being executed and also add that directory to its + sys.path if not already included. + """ + if macosx.isCocoaTk() and (time.perf_counter() - self.perf < .05): + return 'break' + if isinstance(self.editwin, outwin.OutputWindow): + self.editwin.text.bell() + return 'break' + filename = self.getfilename() + if not filename: + return 'break' + code = self.checksyntax(filename) + if not code: + return 'break' + if not self.tabnanny(filename): + return 'break' + if customize: + title = f"Customize {self.editwin.short_title()} Run" + run_args = CustomRun(self.shell.text, title, + cli_args=self.cli_args).result + if not run_args: # User cancelled. + return 'break' + self.cli_args, restart = run_args if customize else ([], True) + interp = self.shell.interp + if pyshell.use_subprocess and restart: + interp.restart_subprocess( + with_cwd=False, filename=filename) + dirname = os.path.dirname(filename) + argv = [filename] + if self.cli_args: + argv += self.cli_args + interp.runcommand(f"""if 1: + __file__ = {filename!r} + import sys as _sys + from os.path import basename as _basename + argv = {argv!r} + if (not _sys.argv or + _basename(_sys.argv[0]) != _basename(__file__) or + len(argv) > 1): + _sys.argv = argv + import os as _os + _os.chdir({dirname!r}) + del _sys, argv, _basename, _os + \n""") + interp.prepend_syspath(filename) + # XXX KBK 03Jul04 When run w/o subprocess, runtime warnings still + # go to __stderr__. With subprocess, they go to the shell. + # Need to change streams in pyshell.ModifiedInterpreter. + interp.runcode(code) + return 'break' + + def getfilename(self): + """Get source filename. If not saved, offer to save (or create) file + + The debugger requires a source file. Make sure there is one, and that + the current version of the source buffer has been saved. If the user + declines to save or cancels the Save As dialog, return None. + + If the user has configured IDLE for Autosave, the file will be + silently saved if it already exists and is dirty. + + """ + filename = self.editwin.io.filename + if not self.editwin.get_saved(): + autosave = idleConf.GetOption('main', 'General', + 'autosave', type='bool') + if autosave and filename: + self.editwin.io.save(None) + else: + confirm = self.ask_save_dialog() + self.editwin.text.focus_set() + if confirm: + self.editwin.io.save(None) + filename = self.editwin.io.filename + else: + filename = None + return filename + + def ask_save_dialog(self): + msg = "Source Must Be Saved\n" + 5*' ' + "OK to Save?" + confirm = messagebox.askokcancel(title="Save Before Run or Check", + message=msg, + default=messagebox.OK, + parent=self.editwin.text) + return confirm + + def errorbox(self, title, message): + # XXX This should really be a function of EditorWindow... + messagebox.showerror(title, message, parent=self.editwin.text) + self.editwin.text.focus_set() + self.perf = time.perf_counter() + + +if __name__ == "__main__": + from unittest import main + main('idlelib.idle_test.test_runscript', verbosity=2,) diff --git a/Lib/idlelib/scrolledlist.py b/Lib/idlelib/scrolledlist.py new file mode 100644 index 000000000..4fb418db3 --- /dev/null +++ b/Lib/idlelib/scrolledlist.py @@ -0,0 +1,151 @@ +from tkinter import * +from tkinter.ttk import Frame, Scrollbar + +from idlelib import macosx + + +class ScrolledList: + + default = "(None)" + + def __init__(self, master, **options): + # Create top frame, with scrollbar and listbox + self.master = master + self.frame = frame = Frame(master) + self.frame.pack(fill="both", expand=1) + self.vbar = vbar = Scrollbar(frame, name="vbar") + self.vbar.pack(side="right", fill="y") + self.listbox = listbox = Listbox(frame, exportselection=0, + background="white") + if options: + listbox.configure(options) + listbox.pack(expand=1, fill="both") + # Tie listbox and scrollbar together + vbar["command"] = listbox.yview + listbox["yscrollcommand"] = vbar.set + # Bind events to the list box + listbox.bind("", self.click_event) + listbox.bind("", self.double_click_event) + if macosx.isAquaTk(): + listbox.bind("", self.popup_event) + listbox.bind("", self.popup_event) + else: + listbox.bind("", self.popup_event) + listbox.bind("", self.up_event) + listbox.bind("", self.down_event) + # Mark as empty + self.clear() + + def close(self): + self.frame.destroy() + + def clear(self): + self.listbox.delete(0, "end") + self.empty = 1 + self.listbox.insert("end", self.default) + + def append(self, item): + if self.empty: + self.listbox.delete(0, "end") + self.empty = 0 + self.listbox.insert("end", str(item)) + + def get(self, index): + return self.listbox.get(index) + + def click_event(self, event): + self.listbox.activate("@%d,%d" % (event.x, event.y)) + index = self.listbox.index("active") + self.select(index) + self.on_select(index) + return "break" + + def double_click_event(self, event): + index = self.listbox.index("active") + self.select(index) + self.on_double(index) + return "break" + + menu = None + + def popup_event(self, event): + if not self.menu: + self.make_menu() + menu = self.menu + self.listbox.activate("@%d,%d" % (event.x, event.y)) + index = self.listbox.index("active") + self.select(index) + menu.tk_popup(event.x_root, event.y_root) + return "break" + + def make_menu(self): + menu = Menu(self.listbox, tearoff=0) + self.menu = menu + self.fill_menu() + + def up_event(self, event): + index = self.listbox.index("active") + if self.listbox.selection_includes(index): + index = index - 1 + else: + index = self.listbox.size() - 1 + if index < 0: + self.listbox.bell() + else: + self.select(index) + self.on_select(index) + return "break" + + def down_event(self, event): + index = self.listbox.index("active") + if self.listbox.selection_includes(index): + index = index + 1 + else: + index = 0 + if index >= self.listbox.size(): + self.listbox.bell() + else: + self.select(index) + self.on_select(index) + return "break" + + def select(self, index): + self.listbox.focus_set() + self.listbox.activate(index) + self.listbox.selection_clear(0, "end") + self.listbox.selection_set(index) + self.listbox.see(index) + + # Methods to override for specific actions + + def fill_menu(self): + pass + + def on_select(self, index): + pass + + def on_double(self, index): + pass + + +def _scrolled_list(parent): # htest # + top = Toplevel(parent) + x, y = map(int, parent.geometry().split('+')[1:]) + top.geometry("+%d+%d" % (x+200, y + 175)) + + class MyScrolledList(ScrolledList): + def fill_menu(self): self.menu.add_command(label="right click") + def on_select(self, index): print("select", self.get(index)) + def on_double(self, index): print("double", self.get(index)) + + scrolled_list = MyScrolledList(top) + for i in range(30): + scrolled_list.append("Item %02d" % i) + + +if __name__ == '__main__': + from unittest import main + main('idlelib.idle_test.test_scrolledlist', verbosity=2, exit=False) + + from idlelib.idle_test.htest import run + run(_scrolled_list) diff --git a/Lib/idlelib/search.py b/Lib/idlelib/search.py new file mode 100644 index 000000000..935a48322 --- /dev/null +++ b/Lib/idlelib/search.py @@ -0,0 +1,165 @@ +"""Search dialog for Find, Find Again, and Find Selection + functionality. + + Inherits from SearchDialogBase for GUI and uses searchengine + to prepare search pattern. +""" +from tkinter import TclError + +from idlelib import searchengine +from idlelib.searchbase import SearchDialogBase + +def _setup(text): + """Return the new or existing singleton SearchDialog instance. + + The singleton dialog saves user entries and preferences + across instances. + + Args: + text: Text widget containing the text to be searched. + """ + root = text._root() + engine = searchengine.get(root) + if not hasattr(engine, "_searchdialog"): + engine._searchdialog = SearchDialog(root, engine) + return engine._searchdialog + +def find(text): + """Open the search dialog. + + Module-level function to access the singleton SearchDialog + instance and open the dialog. If text is selected, it is + used as the search phrase; otherwise, the previous entry + is used. No search is done with this command. + """ + pat = text.get("sel.first", "sel.last") + return _setup(text).open(text, pat) # Open is inherited from SDBase. + +def find_again(text): + """Repeat the search for the last pattern and preferences. + + Module-level function to access the singleton SearchDialog + instance to search again using the user entries and preferences + from the last dialog. If there was no prior search, open the + search dialog; otherwise, perform the search without showing the + dialog. + """ + return _setup(text).find_again(text) + +def find_selection(text): + """Search for the selected pattern in the text. + + Module-level function to access the singleton SearchDialog + instance to search using the selected text. With a text + selection, perform the search without displaying the dialog. + Without a selection, use the prior entry as the search phrase + and don't display the dialog. If there has been no prior + search, open the search dialog. + """ + return _setup(text).find_selection(text) + + +class SearchDialog(SearchDialogBase): + "Dialog for finding a pattern in text." + + def create_widgets(self): + "Create the base search dialog and add a button for Find Next." + SearchDialogBase.create_widgets(self) + # TODO - why is this here and not in a create_command_buttons? + self.make_button("Find Next", self.default_command, isdef=True) + + def default_command(self, event=None): + "Handle the Find Next button as the default command." + if not self.engine.getprog(): + return + self.find_again(self.text) + + def find_again(self, text): + """Repeat the last search. + + If no search was previously run, open a new search dialog. In + this case, no search is done. + + If a search was previously run, the search dialog won't be + shown and the options from the previous search (including the + search pattern) will be used to find the next occurrence + of the pattern. Next is relative based on direction. + + Position the window to display the located occurrence in the + text. + + Return True if the search was successful and False otherwise. + """ + if not self.engine.getpat(): + self.open(text) + return False + if not self.engine.getprog(): + return False + res = self.engine.search_text(text) + if res: + line, m = res + i, j = m.span() + first = "%d.%d" % (line, i) + last = "%d.%d" % (line, j) + try: + selfirst = text.index("sel.first") + sellast = text.index("sel.last") + if selfirst == first and sellast == last: + self.bell() + return False + except TclError: + pass + text.tag_remove("sel", "1.0", "end") + text.tag_add("sel", first, last) + text.mark_set("insert", self.engine.isback() and first or last) + text.see("insert") + return True + else: + self.bell() + return False + + def find_selection(self, text): + """Search for selected text with previous dialog preferences. + + Instead of using the same pattern for searching (as Find + Again does), this first resets the pattern to the currently + selected text. If the selected text isn't changed, then use + the prior search phrase. + """ + pat = text.get("sel.first", "sel.last") + if pat: + self.engine.setcookedpat(pat) + return self.find_again(text) + + +def _search_dialog(parent): # htest # + "Display search test box." + from tkinter import Toplevel, Text + from tkinter.ttk import Frame, Button + + top = Toplevel(parent) + top.title("Test SearchDialog") + x, y = map(int, parent.geometry().split('+')[1:]) + top.geometry("+%d+%d" % (x, y + 175)) + + frame = Frame(top) + frame.pack() + text = Text(frame, inactiveselectbackground='gray') + text.pack() + text.insert("insert","This is a sample string.\n"*5) + + def show_find(): + text.tag_add('sel', '1.0', 'end') + _setup(text).open(text) + text.tag_remove('sel', '1.0', 'end') + + button = Button(frame, text="Search (selection ignored)", command=show_find) + button.pack() + + +if __name__ == '__main__': + from unittest import main + main('idlelib.idle_test.test_search', verbosity=2, exit=False) + + from idlelib.idle_test.htest import run + run(_search_dialog) diff --git a/Lib/idlelib/searchbase.py b/Lib/idlelib/searchbase.py new file mode 100644 index 000000000..c68a6ca33 --- /dev/null +++ b/Lib/idlelib/searchbase.py @@ -0,0 +1,210 @@ +'''Define SearchDialogBase used by Search, Replace, and Grep dialogs.''' + +from tkinter import Toplevel +from tkinter.ttk import Frame, Entry, Label, Button, Checkbutton, Radiobutton +from tkinter.simpledialog import _setup_dialog + + +class SearchDialogBase: + '''Create most of a 3 or 4 row, 3 column search dialog. + + The left and wide middle column contain: + 1 or 2 labeled text entry lines (make_entry, create_entries); + a row of standard Checkbuttons (make_frame, create_option_buttons), + each of which corresponds to a search engine Variable; + a row of dialog-specific Check/Radiobuttons (create_other_buttons). + + The narrow right column contains command buttons + (make_button, create_command_buttons). + These are bound to functions that execute the command. + + Except for command buttons, this base class is not limited to items + common to all three subclasses. Rather, it is the Find dialog minus + the "Find Next" command, its execution function, and the + default_command attribute needed in create_widgets. The other + dialogs override attributes and methods, the latter to replace and + add widgets. + ''' + + title = "Search Dialog" # replace in subclasses + icon = "Search" + needwrapbutton = 1 # not in Find in Files + + def __init__(self, root, engine): + '''Initialize root, engine, and top attributes. + + top (level widget): set in create_widgets() called from open(). + frame: container for all widgets in dialog. + text (Text searched): set in open(), only used in subclasses(). + ent (ry): created in make_entry() called from create_entry(). + row (of grid): 0 in create_widgets(), +1 in make_entry/frame(). + default_command: set in subclasses, used in create_widgets(). + + title (of dialog): class attribute, override in subclasses. + icon (of dialog): ditto, use unclear if cannot minimize dialog. + ''' + self.root = root + self.bell = root.bell + self.engine = engine + self.top = None + + def open(self, text, searchphrase=None): + "Make dialog visible on top of others and ready to use." + self.text = text + if not self.top: + self.create_widgets() + else: + self.top.deiconify() + self.top.tkraise() + self.top.transient(text.winfo_toplevel()) + if searchphrase: + self.ent.delete(0,"end") + self.ent.insert("end",searchphrase) + self.ent.focus_set() + self.ent.selection_range(0, "end") + self.ent.icursor(0) + self.top.grab_set() + + def close(self, event=None): + "Put dialog away for later use." + if self.top: + self.top.grab_release() + self.top.transient('') + self.top.withdraw() + + def create_widgets(self): + '''Create basic 3 row x 3 col search (find) dialog. + + Other dialogs override subsidiary create_x methods as needed. + Replace and Find-in-Files add another entry row. + ''' + top = Toplevel(self.root) + top.bind("", self.default_command) + top.bind("", self.close) + top.protocol("WM_DELETE_WINDOW", self.close) + top.wm_title(self.title) + top.wm_iconname(self.icon) + _setup_dialog(top) + self.top = top + self.frame = Frame(top, padding=5) + self.frame.grid(sticky="nwes") + top.grid_columnconfigure(0, weight=100) + top.grid_rowconfigure(0, weight=100) + + self.row = 0 + self.frame.grid_columnconfigure(0, pad=2, weight=0) + self.frame.grid_columnconfigure(1, pad=2, minsize=100, weight=100) + + self.create_entries() # row 0 (and maybe 1), cols 0, 1 + self.create_option_buttons() # next row, cols 0, 1 + self.create_other_buttons() # next row, cols 0, 1 + self.create_command_buttons() # col 2, all rows + + def make_entry(self, label_text, var): + '''Return (entry, label), . + + entry - gridded labeled Entry for text entry. + label - Label widget, returned for testing. + ''' + label = Label(self.frame, text=label_text) + label.grid(row=self.row, column=0, sticky="nw") + entry = Entry(self.frame, textvariable=var, exportselection=0) + entry.grid(row=self.row, column=1, sticky="nwe") + self.row = self.row + 1 + return entry, label + + def create_entries(self): + "Create one or more entry lines with make_entry." + self.ent = self.make_entry("Find:", self.engine.patvar)[0] + + def make_frame(self,labeltext=None): + '''Return (frame, label). + + frame - gridded labeled Frame for option or other buttons. + label - Label widget, returned for testing. + ''' + if labeltext: + label = Label(self.frame, text=labeltext) + label.grid(row=self.row, column=0, sticky="nw") + else: + label = '' + frame = Frame(self.frame) + frame.grid(row=self.row, column=1, columnspan=1, sticky="nwe") + self.row = self.row + 1 + return frame, label + + def create_option_buttons(self): + '''Return (filled frame, options) for testing. + + Options is a list of searchengine booleanvar, label pairs. + A gridded frame from make_frame is filled with a Checkbutton + for each pair, bound to the var, with the corresponding label. + ''' + frame = self.make_frame("Options")[0] + engine = self.engine + options = [(engine.revar, "Regular expression"), + (engine.casevar, "Match case"), + (engine.wordvar, "Whole word")] + if self.needwrapbutton: + options.append((engine.wrapvar, "Wrap around")) + for var, label in options: + btn = Checkbutton(frame, variable=var, text=label) + btn.pack(side="left", fill="both") + return frame, options + + def create_other_buttons(self): + '''Return (frame, others) for testing. + + Others is a list of value, label pairs. + A gridded frame from make_frame is filled with radio buttons. + ''' + frame = self.make_frame("Direction")[0] + var = self.engine.backvar + others = [(1, 'Up'), (0, 'Down')] + for val, label in others: + btn = Radiobutton(frame, variable=var, value=val, text=label) + btn.pack(side="left", fill="both") + return frame, others + + def make_button(self, label, command, isdef=0): + "Return command button gridded in command frame." + b = Button(self.buttonframe, + text=label, command=command, + default=isdef and "active" or "normal") + cols,rows=self.buttonframe.grid_size() + b.grid(pady=1,row=rows,column=0,sticky="ew") + self.buttonframe.grid(rowspan=rows+1) + return b + + def create_command_buttons(self): + "Place buttons in vertical command frame gridded on right." + f = self.buttonframe = Frame(self.frame) + f.grid(row=0,column=2,padx=2,pady=2,ipadx=2,ipady=2) + + b = self.make_button("Close", self.close) + b.lower() + + +class _searchbase(SearchDialogBase): # htest # + "Create auto-opening dialog with no text connection." + + def __init__(self, parent): + import re + from idlelib import searchengine + + self.root = parent + self.engine = searchengine.get(parent) + self.create_widgets() + print(parent.geometry()) + width,height, x,y = list(map(int, re.split('[x+]', parent.geometry()))) + self.top.geometry("+%d+%d" % (x + 40, y + 175)) + + def default_command(self, dummy): pass + + +if __name__ == '__main__': + from unittest import main + main('idlelib.idle_test.test_searchbase', verbosity=2, exit=False) + + from idlelib.idle_test.htest import run + run(_searchbase) diff --git a/Lib/idlelib/searchengine.py b/Lib/idlelib/searchengine.py new file mode 100644 index 000000000..ceb38cfae --- /dev/null +++ b/Lib/idlelib/searchengine.py @@ -0,0 +1,234 @@ +'''Define SearchEngine for search dialogs.''' +import re + +from tkinter import StringVar, BooleanVar, TclError +from tkinter import messagebox + +def get(root): + '''Return the singleton SearchEngine instance for the process. + + The single SearchEngine saves settings between dialog instances. + If there is not a SearchEngine already, make one. + ''' + if not hasattr(root, "_searchengine"): + root._searchengine = SearchEngine(root) + # This creates a cycle that persists until root is deleted. + return root._searchengine + + +class SearchEngine: + """Handles searching a text widget for Find, Replace, and Grep.""" + + def __init__(self, root): + '''Initialize Variables that save search state. + + The dialogs bind these to the UI elements present in the dialogs. + ''' + self.root = root # need for report_error() + self.patvar = StringVar(root, '') # search pattern + self.revar = BooleanVar(root, False) # regular expression? + self.casevar = BooleanVar(root, False) # match case? + self.wordvar = BooleanVar(root, False) # match whole word? + self.wrapvar = BooleanVar(root, True) # wrap around buffer? + self.backvar = BooleanVar(root, False) # search backwards? + + # Access methods + + def getpat(self): + return self.patvar.get() + + def setpat(self, pat): + self.patvar.set(pat) + + def isre(self): + return self.revar.get() + + def iscase(self): + return self.casevar.get() + + def isword(self): + return self.wordvar.get() + + def iswrap(self): + return self.wrapvar.get() + + def isback(self): + return self.backvar.get() + + # Higher level access methods + + def setcookedpat(self, pat): + "Set pattern after escaping if re." + # called only in search.py: 66 + if self.isre(): + pat = re.escape(pat) + self.setpat(pat) + + def getcookedpat(self): + pat = self.getpat() + if not self.isre(): # if True, see setcookedpat + pat = re.escape(pat) + if self.isword(): + pat = r"\b%s\b" % pat + return pat + + def getprog(self): + "Return compiled cooked search pattern." + pat = self.getpat() + if not pat: + self.report_error(pat, "Empty regular expression") + return None + pat = self.getcookedpat() + flags = 0 + if not self.iscase(): + flags = flags | re.IGNORECASE + try: + prog = re.compile(pat, flags) + except re.PatternError as e: + self.report_error(pat, e.msg, e.pos) + return None + return prog + + def report_error(self, pat, msg, col=None): + # Derived class could override this with something fancier + msg = "Error: " + str(msg) + if pat: + msg = msg + "\nPattern: " + str(pat) + if col is not None: + msg = msg + "\nOffset: " + str(col) + messagebox.showerror("Regular expression error", + msg, master=self.root) + + def search_text(self, text, prog=None, ok=0): + '''Return (lineno, matchobj) or None for forward/backward search. + + This function calls the right function with the right arguments. + It directly return the result of that call. + + Text is a text widget. Prog is a precompiled pattern. + The ok parameter is a bit complicated as it has two effects. + + If there is a selection, the search begin at either end, + depending on the direction setting and ok, with ok meaning that + the search starts with the selection. Otherwise, search begins + at the insert mark. + + To aid progress, the search functions do not return an empty + match at the starting position unless ok is True. + ''' + + if not prog: + prog = self.getprog() + if not prog: + return None # Compilation failed -- stop + wrap = self.wrapvar.get() + first, last = get_selection(text) + if self.isback(): + if ok: + start = last + else: + start = first + line, col = get_line_col(start) + res = self.search_backward(text, prog, line, col, wrap, ok) + else: + if ok: + start = first + else: + start = last + line, col = get_line_col(start) + res = self.search_forward(text, prog, line, col, wrap, ok) + return res + + def search_forward(self, text, prog, line, col, wrap, ok=0): + wrapped = 0 + startline = line + chars = text.get("%d.0" % line, "%d.0" % (line+1)) + while chars: + m = prog.search(chars[:-1], col) + if m: + if ok or m.end() > col: + return line, m + line = line + 1 + if wrapped and line > startline: + break + col = 0 + ok = 1 + chars = text.get("%d.0" % line, "%d.0" % (line+1)) + if not chars and wrap: + wrapped = 1 + wrap = 0 + line = 1 + chars = text.get("1.0", "2.0") + return None + + def search_backward(self, text, prog, line, col, wrap, ok=0): + wrapped = 0 + startline = line + chars = text.get("%d.0" % line, "%d.0" % (line+1)) + while True: + m = search_reverse(prog, chars[:-1], col) + if m: + if ok or m.start() < col: + return line, m + line = line - 1 + if wrapped and line < startline: + break + ok = 1 + if line <= 0: + if not wrap: + break + wrapped = 1 + wrap = 0 + pos = text.index("end-1c") + line, col = map(int, pos.split(".")) + chars = text.get("%d.0" % line, "%d.0" % (line+1)) + col = len(chars) - 1 + return None + + +def search_reverse(prog, chars, col): + '''Search backwards and return an re match object or None. + + This is done by searching forwards until there is no match. + Prog: compiled re object with a search method returning a match. + Chars: line of text, without \\n. + Col: stop index for the search; the limit for match.end(). + ''' + m = prog.search(chars) + if not m: + return None + found = None + i, j = m.span() # m.start(), m.end() == match slice indexes + while i < col and j <= col: + found = m + if i == j: + j = j+1 + m = prog.search(chars, j) + if not m: + break + i, j = m.span() + return found + +def get_selection(text): + '''Return tuple of 'line.col' indexes from selection or insert mark. + ''' + try: + first = text.index("sel.first") + last = text.index("sel.last") + except TclError: + first = last = None + if not first: + first = text.index("insert") + if not last: + last = first + return first, last + +def get_line_col(index): + '''Return (line, col) tuple of ints from 'line.col' string.''' + line, col = map(int, index.split(".")) # Fails on invalid index + return line, col + + +if __name__ == "__main__": + from unittest import main + main('idlelib.idle_test.test_searchengine', verbosity=2) diff --git a/Lib/idlelib/sidebar.py b/Lib/idlelib/sidebar.py new file mode 100644 index 000000000..aa19a24e3 --- /dev/null +++ b/Lib/idlelib/sidebar.py @@ -0,0 +1,543 @@ +"""Line numbering implementation for IDLE as an extension. +Includes BaseSideBar which can be extended for other sidebar based extensions +""" +import contextlib +import functools +import itertools + +import tkinter as tk +from tkinter.font import Font +from idlelib.config import idleConf +from idlelib.delegator import Delegator +from idlelib import macosx + + +def get_lineno(text, index): + """Return the line number of an index in a Tk text widget.""" + text_index = text.index(index) + return int(float(text_index)) if text_index else None + + +def get_end_linenumber(text): + """Return the number of the last line in a Tk text widget.""" + return get_lineno(text, 'end-1c') + + +def get_displaylines(text, index): + """Display height, in lines, of a logical line in a Tk text widget.""" + return text.count(f"{index} linestart", + f"{index} lineend", + "displaylines", return_ints=True) + +def get_widget_padding(widget): + """Get the total padding of a Tk widget, including its border.""" + # TODO: use also in codecontext.py + manager = widget.winfo_manager() + if manager == 'pack': + info = widget.pack_info() + elif manager == 'grid': + info = widget.grid_info() + else: + raise ValueError(f"Unsupported geometry manager: {manager}") + + # All values are passed through getint(), since some + # values may be pixel objects, which can't simply be added to ints. + padx = sum(map(widget.tk.getint, [ + info['padx'], + widget.cget('padx'), + widget.cget('border'), + ])) + pady = sum(map(widget.tk.getint, [ + info['pady'], + widget.cget('pady'), + widget.cget('border'), + ])) + return padx, pady + + +@contextlib.contextmanager +def temp_enable_text_widget(text): + text.configure(state=tk.NORMAL) + try: + yield + finally: + text.configure(state=tk.DISABLED) + + +class BaseSideBar: + """A base class for sidebars using Text.""" + def __init__(self, editwin): + self.editwin = editwin + self.parent = editwin.text_frame + self.text = editwin.text + + self.is_shown = False + + self.main_widget = self.init_widgets() + + self.bind_events() + + self.update_font() + self.update_colors() + + def init_widgets(self): + """Initialize the sidebar's widgets, returning the main widget.""" + raise NotImplementedError + + def update_font(self): + """Update the sidebar text font, usually after config changes.""" + raise NotImplementedError + + def update_colors(self): + """Update the sidebar text colors, usually after config changes.""" + raise NotImplementedError + + def grid(self): + """Layout the widget, always using grid layout.""" + raise NotImplementedError + + def show_sidebar(self): + if not self.is_shown: + self.grid() + self.is_shown = True + + def hide_sidebar(self): + if self.is_shown: + self.main_widget.grid_forget() + self.is_shown = False + + def yscroll_event(self, *args, **kwargs): + """Hook for vertical scrolling for sub-classes to override.""" + raise NotImplementedError + + def redirect_yscroll_event(self, *args, **kwargs): + """Redirect vertical scrolling to the main editor text widget. + + The scroll bar is also updated. + """ + self.editwin.vbar.set(*args) + return self.yscroll_event(*args, **kwargs) + + def redirect_focusin_event(self, event): + """Redirect focus-in events to the main editor text widget.""" + self.text.focus_set() + return 'break' + + def redirect_mousebutton_event(self, event, event_name): + """Redirect mouse button events to the main editor text widget.""" + self.text.focus_set() + self.text.event_generate(event_name, x=0, y=event.y) + return 'break' + + def redirect_mousewheel_event(self, event): + """Redirect mouse wheel events to the editwin text widget.""" + self.text.event_generate('', + x=0, y=event.y, delta=event.delta) + return 'break' + + def bind_events(self): + self.text['yscrollcommand'] = self.redirect_yscroll_event + + # Ensure focus is always redirected to the main editor text widget. + self.main_widget.bind('', self.redirect_focusin_event) + + # Redirect mouse scrolling to the main editor text widget. + # + # Note that without this, scrolling with the mouse only scrolls + # the line numbers. + self.main_widget.bind('', self.redirect_mousewheel_event) + + # Redirect mouse button events to the main editor text widget, + # except for the left mouse button (1). + # + # Note: X-11 sends Button-4 and Button-5 events for the scroll wheel. + def bind_mouse_event(event_name, target_event_name): + handler = functools.partial(self.redirect_mousebutton_event, + event_name=target_event_name) + self.main_widget.bind(event_name, handler) + + for button in [2, 3, 4, 5]: + for event_name in (f'', + f'', + f'', + ): + bind_mouse_event(event_name, target_event_name=event_name) + + # Convert double- and triple-click events to normal click events, + # since event_generate() doesn't allow generating such events. + for event_name in (f'', + f'', + ): + bind_mouse_event(event_name, + target_event_name=f'') + + # start_line is set upon to allow selecting a range of rows + # by dragging. It is cleared upon . + start_line = None + + # last_y is initially set upon and is continuously updated + # upon , until or the mouse button is released. + # It is used in text_auto_scroll(), which is called repeatedly and + # does have a mouse event available. + last_y = None + + # auto_scrolling_after_id is set whenever text_auto_scroll is + # scheduled via .after(). It is used to stop the auto-scrolling + # upon , as well as to avoid scheduling the function several + # times in parallel. + auto_scrolling_after_id = None + + def drag_update_selection_and_insert_mark(y_coord): + """Helper function for drag and selection event handlers.""" + lineno = get_lineno(self.text, f"@0,{y_coord}") + a, b = sorted([start_line, lineno]) + self.text.tag_remove("sel", "1.0", "end") + self.text.tag_add("sel", f"{a}.0", f"{b+1}.0") + self.text.mark_set("insert", + f"{lineno if lineno == a else lineno + 1}.0") + + def b1_mousedown_handler(event): + nonlocal start_line + nonlocal last_y + start_line = int(float(self.text.index(f"@0,{event.y}"))) + last_y = event.y + + drag_update_selection_and_insert_mark(event.y) + self.main_widget.bind('', b1_mousedown_handler) + + def b1_mouseup_handler(event): + # On mouse up, we're no longer dragging. Set the shared persistent + # variables to None to represent this. + nonlocal start_line + nonlocal last_y + start_line = None + last_y = None + self.text.event_generate('', x=0, y=event.y) + self.main_widget.bind('', b1_mouseup_handler) + + def b1_drag_handler(event): + nonlocal last_y + if last_y is None: # i.e. if not currently dragging + return + last_y = event.y + drag_update_selection_and_insert_mark(event.y) + self.main_widget.bind('', b1_drag_handler) + + def text_auto_scroll(): + """Mimic Text auto-scrolling when dragging outside of it.""" + # See: https://github.com/tcltk/tk/blob/064ff9941b4b80b85916a8afe86a6c21fd388b54/library/text.tcl#L670 + nonlocal auto_scrolling_after_id + y = last_y + if y is None: + self.main_widget.after_cancel(auto_scrolling_after_id) + auto_scrolling_after_id = None + return + elif y < 0: + self.text.yview_scroll(-1 + y, 'pixels') + drag_update_selection_and_insert_mark(y) + elif y > self.main_widget.winfo_height(): + self.text.yview_scroll(1 + y - self.main_widget.winfo_height(), + 'pixels') + drag_update_selection_and_insert_mark(y) + auto_scrolling_after_id = \ + self.main_widget.after(50, text_auto_scroll) + + def b1_leave_handler(event): + # Schedule the initial call to text_auto_scroll(), if not already + # scheduled. + nonlocal auto_scrolling_after_id + if auto_scrolling_after_id is None: + nonlocal last_y + last_y = event.y + auto_scrolling_after_id = \ + self.main_widget.after(0, text_auto_scroll) + self.main_widget.bind('', b1_leave_handler) + + def b1_enter_handler(event): + # Cancel the scheduling of text_auto_scroll(), if it exists. + nonlocal auto_scrolling_after_id + if auto_scrolling_after_id is not None: + self.main_widget.after_cancel(auto_scrolling_after_id) + auto_scrolling_after_id = None + self.main_widget.bind('', b1_enter_handler) + + +class EndLineDelegator(Delegator): + """Generate callbacks with the current end line number. + + The provided callback is called after every insert and delete. + """ + def __init__(self, changed_callback): + Delegator.__init__(self) + self.changed_callback = changed_callback + + def insert(self, index, chars, tags=None): + self.delegate.insert(index, chars, tags) + self.changed_callback(get_end_linenumber(self.delegate)) + + def delete(self, index1, index2=None): + self.delegate.delete(index1, index2) + self.changed_callback(get_end_linenumber(self.delegate)) + + +class LineNumbers(BaseSideBar): + """Line numbers support for editor windows.""" + def __init__(self, editwin): + super().__init__(editwin) + + end_line_delegator = EndLineDelegator(self.update_sidebar_text) + # Insert the delegator after the undo delegator, so that line numbers + # are properly updated after undo and redo actions. + self.editwin.per.insertfilterafter(end_line_delegator, + after=self.editwin.undo) + + def init_widgets(self): + _padx, pady = get_widget_padding(self.text) + self.sidebar_text = tk.Text(self.parent, width=1, wrap=tk.NONE, + padx=2, pady=pady, + borderwidth=0, highlightthickness=0) + self.sidebar_text.config(state=tk.DISABLED) + + self.prev_end = 1 + self._sidebar_width_type = type(self.sidebar_text['width']) + with temp_enable_text_widget(self.sidebar_text): + self.sidebar_text.insert('insert', '1', 'linenumber') + self.sidebar_text.config(takefocus=False, exportselection=False) + self.sidebar_text.tag_config('linenumber', justify=tk.RIGHT) + + end = get_end_linenumber(self.text) + self.update_sidebar_text(end) + + return self.sidebar_text + + def grid(self): + self.sidebar_text.grid(row=1, column=0, sticky=tk.NSEW) + + def update_font(self): + font = idleConf.GetFont(self.text, 'main', 'EditorWindow') + self.sidebar_text['font'] = font + + def update_colors(self): + """Update the sidebar text colors, usually after config changes.""" + colors = idleConf.GetHighlight(idleConf.CurrentTheme(), 'linenumber') + foreground = colors['foreground'] + background = colors['background'] + self.sidebar_text.config( + fg=foreground, bg=background, + selectforeground=foreground, selectbackground=background, + inactiveselectbackground=background, + ) + + def update_sidebar_text(self, end): + """ + Perform the following action: + Each line sidebar_text contains the linenumber for that line + Synchronize with editwin.text so that both sidebar_text and + editwin.text contain the same number of lines""" + if end == self.prev_end: + return + + width_difference = len(str(end)) - len(str(self.prev_end)) + if width_difference: + cur_width = int(float(self.sidebar_text['width'])) + new_width = cur_width + width_difference + self.sidebar_text['width'] = self._sidebar_width_type(new_width) + + with temp_enable_text_widget(self.sidebar_text): + if end > self.prev_end: + new_text = '\n'.join(itertools.chain( + [''], + map(str, range(self.prev_end + 1, end + 1)), + )) + self.sidebar_text.insert(f'end -1c', new_text, 'linenumber') + else: + self.sidebar_text.delete(f'{end+1}.0 -1c', 'end -1c') + + self.prev_end = end + + def yscroll_event(self, *args, **kwargs): + self.sidebar_text.yview_moveto(args[0]) + return 'break' + + +class WrappedLineHeightChangeDelegator(Delegator): + def __init__(self, callback): + """ + callback - Callable, will be called when an insert, delete or replace + action on the text widget may require updating the shell + sidebar. + """ + Delegator.__init__(self) + self.callback = callback + + def insert(self, index, chars, tags=None): + is_single_line = '\n' not in chars + if is_single_line: + before_displaylines = get_displaylines(self, index) + + self.delegate.insert(index, chars, tags) + + if is_single_line: + after_displaylines = get_displaylines(self, index) + if after_displaylines == before_displaylines: + return # no need to update the sidebar + + self.callback() + + def delete(self, index1, index2=None): + if index2 is None: + index2 = index1 + "+1c" + is_single_line = get_lineno(self, index1) == get_lineno(self, index2) + if is_single_line: + before_displaylines = get_displaylines(self, index1) + + self.delegate.delete(index1, index2) + + if is_single_line: + after_displaylines = get_displaylines(self, index1) + if after_displaylines == before_displaylines: + return # no need to update the sidebar + + self.callback() + + +class ShellSidebar(BaseSideBar): + """Sidebar for the PyShell window, for prompts etc.""" + def __init__(self, editwin): + self.canvas = None + self.line_prompts = {} + + super().__init__(editwin) + + change_delegator = \ + WrappedLineHeightChangeDelegator(self.change_callback) + # Insert the TextChangeDelegator after the last delegator, so that + # the sidebar reflects final changes to the text widget contents. + d = self.editwin.per.top + if d.delegate is not self.text: + while d.delegate is not self.editwin.per.bottom: + d = d.delegate + self.editwin.per.insertfilterafter(change_delegator, after=d) + + self.is_shown = True + + def init_widgets(self): + self.canvas = tk.Canvas(self.parent, width=30, + borderwidth=0, highlightthickness=0, + takefocus=False) + self.update_sidebar() + self.grid() + return self.canvas + + def bind_events(self): + super().bind_events() + + self.main_widget.bind( + # AquaTk defines <2> as the right button, not <3>. + "" if macosx.isAquaTk() else "", + self.context_menu_event, + ) + + def context_menu_event(self, event): + rmenu = tk.Menu(self.main_widget, tearoff=0) + has_selection = bool(self.text.tag_nextrange('sel', '1.0')) + def mkcmd(eventname): + return lambda: self.text.event_generate(eventname) + rmenu.add_command(label='Copy', + command=mkcmd('<>'), + state='normal' if has_selection else 'disabled') + rmenu.add_command(label='Copy with prompts', + command=mkcmd('<>'), + state='normal' if has_selection else 'disabled') + rmenu.tk_popup(event.x_root, event.y_root) + return "break" + + def grid(self): + self.canvas.grid(row=1, column=0, sticky=tk.NSEW, padx=2, pady=0) + + def change_callback(self): + if self.is_shown: + self.update_sidebar() + + def update_sidebar(self): + text = self.text + text_tagnames = text.tag_names + canvas = self.canvas + line_prompts = self.line_prompts = {} + + canvas.delete(tk.ALL) + + index = text.index("@0,0") + if index.split('.', 1)[1] != '0': + index = text.index(f'{index}+1line linestart') + while (lineinfo := text.dlineinfo(index)) is not None: + y = lineinfo[1] + prev_newline_tagnames = text_tagnames(f"{index} linestart -1c") + prompt = ( + '>>>' if "console" in prev_newline_tagnames else + '...' if "stdin" in prev_newline_tagnames else + None + ) + if prompt: + canvas.create_text(2, y, anchor=tk.NW, text=prompt, + font=self.font, fill=self.colors[0]) + lineno = get_lineno(text, index) + line_prompts[lineno] = prompt + index = text.index(f'{index}+1line') + + def yscroll_event(self, *args, **kwargs): + """Redirect vertical scrolling to the main editor text widget. + + The scroll bar is also updated. + """ + self.change_callback() + return 'break' + + def update_font(self): + """Update the sidebar text font, usually after config changes.""" + font = idleConf.GetFont(self.text, 'main', 'EditorWindow') + tk_font = Font(self.text, font=font) + char_width = max(tk_font.measure(char) for char in ['>', '.']) + self.canvas.configure(width=char_width * 3 + 4) + self.font = font + self.change_callback() + + def update_colors(self): + """Update the sidebar text colors, usually after config changes.""" + linenumbers_colors = idleConf.GetHighlight(idleConf.CurrentTheme(), 'linenumber') + prompt_colors = idleConf.GetHighlight(idleConf.CurrentTheme(), 'console') + foreground = prompt_colors['foreground'] + background = linenumbers_colors['background'] + self.colors = (foreground, background) + self.canvas.configure(background=background) + self.change_callback() + + +def _sidebar_number_scrolling(parent): # htest # + from idlelib.idle_test.test_sidebar import Dummy_editwin + + top = tk.Toplevel(parent) + text_frame = tk.Frame(top) + text_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + text_frame.rowconfigure(1, weight=1) + text_frame.columnconfigure(1, weight=1) + + font = idleConf.GetFont(top, 'main', 'EditorWindow') + text = tk.Text(text_frame, width=80, height=24, wrap=tk.NONE, font=font) + text.grid(row=1, column=1, sticky=tk.NSEW) + + editwin = Dummy_editwin(text) + editwin.vbar = tk.Scrollbar(text_frame) + + linenumbers = LineNumbers(editwin) + linenumbers.show_sidebar() + + text.insert('1.0', '\n'.join('a'*i for i in range(1, 101))) + + +if __name__ == '__main__': + from unittest import main + main('idlelib.idle_test.test_sidebar', verbosity=2, exit=False) + + from idlelib.idle_test.htest import run + run(_sidebar_number_scrolling) diff --git a/Lib/idlelib/squeezer.py b/Lib/idlelib/squeezer.py new file mode 100644 index 000000000..929c3fd3a --- /dev/null +++ b/Lib/idlelib/squeezer.py @@ -0,0 +1,345 @@ +"""An IDLE extension to avoid having very long texts printed in the shell. + +A common problem in IDLE's interactive shell is printing of large amounts of +text into the shell. This makes looking at the previous history difficult. +Worse, this can cause IDLE to become very slow, even to the point of being +completely unusable. + +This extension will automatically replace long texts with a small button. +Double-clicking this button will remove it and insert the original text instead. +Middle-clicking will copy the text to the clipboard. Right-clicking will open +the text in a separate viewing window. + +Additionally, any output can be manually "squeezed" by the user. This includes +output written to the standard error stream ("stderr"), such as exception +messages and their tracebacks. +""" +import re + +import tkinter as tk +from tkinter import messagebox + +from idlelib.config import idleConf +from idlelib.textview import view_text +from idlelib.tooltip import Hovertip +from idlelib import macosx + + +def count_lines_with_wrapping(s, linewidth=80): + """Count the number of lines in a given string. + + Lines are counted as if the string was wrapped so that lines are never over + linewidth characters long. + + Tabs are considered tabwidth characters long. + """ + tabwidth = 8 # Currently always true in Shell. + pos = 0 + linecount = 1 + current_column = 0 + + for m in re.finditer(r"[\t\n]", s): + # Process the normal chars up to tab or newline. + numchars = m.start() - pos + pos += numchars + current_column += numchars + + # Deal with tab or newline. + if s[pos] == '\n': + # Avoid the `current_column == 0` edge-case, and while we're + # at it, don't bother adding 0. + if current_column > linewidth: + # If the current column was exactly linewidth, divmod + # would give (1,0), even though a new line hadn't yet + # been started. The same is true if length is any exact + # multiple of linewidth. Therefore, subtract 1 before + # dividing a non-empty line. + linecount += (current_column - 1) // linewidth + linecount += 1 + current_column = 0 + else: + assert s[pos] == '\t' + current_column += tabwidth - (current_column % tabwidth) + + # If a tab passes the end of the line, consider the entire + # tab as being on the next line. + if current_column > linewidth: + linecount += 1 + current_column = tabwidth + + pos += 1 # After the tab or newline. + + # Process remaining chars (no more tabs or newlines). + current_column += len(s) - pos + # Avoid divmod(-1, linewidth). + if current_column > 0: + linecount += (current_column - 1) // linewidth + else: + # Text ended with newline; don't count an extra line after it. + linecount -= 1 + + return linecount + + +class ExpandingButton(tk.Button): + """Class for the "squeezed" text buttons used by Squeezer + + These buttons are displayed inside a Tk Text widget in place of text. A + user can then use the button to replace it with the original text, copy + the original text to the clipboard or view the original text in a separate + window. + + Each button is tied to a Squeezer instance, and it knows to update the + Squeezer instance when it is expanded (and therefore removed). + """ + def __init__(self, s, tags, numoflines, squeezer): + self.s = s + self.tags = tags + self.numoflines = numoflines + self.squeezer = squeezer + self.editwin = editwin = squeezer.editwin + self.text = text = editwin.text + # The base Text widget is needed to change text before iomark. + self.base_text = editwin.per.bottom + + line_plurality = "lines" if numoflines != 1 else "line" + button_text = f"Squeezed text ({numoflines} {line_plurality})." + tk.Button.__init__(self, text, text=button_text, + background="#FFFFC0", activebackground="#FFFFE0") + + button_tooltip_text = ( + "Double-click to expand, right-click for more options." + ) + Hovertip(self, button_tooltip_text, hover_delay=80) + + self.bind("", self.expand) + if macosx.isAquaTk(): + # AquaTk defines <2> as the right button, not <3>. + self.bind("", self.context_menu_event) + else: + self.bind("", self.context_menu_event) + self.selection_handle( # X windows only. + lambda offset, length: s[int(offset):int(offset) + int(length)]) + + self.is_dangerous = None + self.after_idle(self.set_is_dangerous) + + def set_is_dangerous(self): + dangerous_line_len = 50 * self.text.winfo_width() + self.is_dangerous = ( + self.numoflines > 1000 or + len(self.s) > 50000 or + any( + len(line_match.group(0)) >= dangerous_line_len + for line_match in re.finditer(r'[^\n]+', self.s) + ) + ) + + def expand(self, event=None): + """expand event handler + + This inserts the original text in place of the button in the Text + widget, removes the button and updates the Squeezer instance. + + If the original text is dangerously long, i.e. expanding it could + cause a performance degradation, ask the user for confirmation. + """ + if self.is_dangerous is None: + self.set_is_dangerous() + if self.is_dangerous: + confirm = messagebox.askokcancel( + title="Expand huge output?", + message="\n\n".join([ + "The squeezed output is very long: %d lines, %d chars.", + "Expanding it could make IDLE slow or unresponsive.", + "It is recommended to view or copy the output instead.", + "Really expand?" + ]) % (self.numoflines, len(self.s)), + default=messagebox.CANCEL, + parent=self.text) + if not confirm: + return "break" + + index = self.text.index(self) + self.base_text.insert(index, self.s, self.tags) + self.base_text.delete(self) + self.editwin.on_squeezed_expand(index, self.s, self.tags) + self.squeezer.expandingbuttons.remove(self) + + def copy(self, event=None): + """copy event handler + + Copy the original text to the clipboard. + """ + self.clipboard_clear() + self.clipboard_append(self.s) + + def view(self, event=None): + """view event handler + + View the original text in a separate text viewer window. + """ + view_text(self.text, "Squeezed Output Viewer", self.s, + modal=False, wrap='none') + + rmenu_specs = ( + # Item structure: (label, method_name). + ('copy', 'copy'), + ('view', 'view'), + ) + + def context_menu_event(self, event): + self.text.mark_set("insert", "@%d,%d" % (event.x, event.y)) + rmenu = tk.Menu(self.text, tearoff=0) + for label, method_name in self.rmenu_specs: + rmenu.add_command(label=label, command=getattr(self, method_name)) + rmenu.tk_popup(event.x_root, event.y_root) + return "break" + + +class Squeezer: + """Replace long outputs in the shell with a simple button. + + This avoids IDLE's shell slowing down considerably, and even becoming + completely unresponsive, when very long outputs are written. + """ + @classmethod + def reload(cls): + """Load class variables from config.""" + cls.auto_squeeze_min_lines = idleConf.GetOption( + "main", "PyShell", "auto-squeeze-min-lines", + type="int", default=50, + ) + + def __init__(self, editwin): + """Initialize settings for Squeezer. + + editwin is the shell's Editor window. + self.text is the editor window text widget. + self.base_test is the actual editor window Tk text widget, rather than + EditorWindow's wrapper. + self.expandingbuttons is the list of all buttons representing + "squeezed" output. + """ + self.editwin = editwin + self.text = text = editwin.text + + # Get the base Text widget of the PyShell object, used to change + # text before the iomark. PyShell deliberately disables changing + # text before the iomark via its 'text' attribute, which is + # actually a wrapper for the actual Text widget. Squeezer, + # however, needs to make such changes. + self.base_text = editwin.per.bottom + + # Twice the text widget's border width and internal padding; + # pre-calculated here for the get_line_width() method. + self.window_width_delta = 2 * ( + int(text.cget('border')) + + int(text.cget('padx')) + ) + + self.expandingbuttons = [] + + # Replace the PyShell instance's write method with a wrapper, + # which inserts an ExpandingButton instead of a long text. + def mywrite(s, tags=(), write=editwin.write): + # Only auto-squeeze text which has just the "stdout" tag. + if tags != "stdout": + return write(s, tags) + + # Only auto-squeeze text with at least the minimum + # configured number of lines. + auto_squeeze_min_lines = self.auto_squeeze_min_lines + # First, a very quick check to skip very short texts. + if len(s) < auto_squeeze_min_lines: + return write(s, tags) + # Now the full line-count check. + numoflines = self.count_lines(s) + if numoflines < auto_squeeze_min_lines: + return write(s, tags) + + # Create an ExpandingButton instance. + expandingbutton = ExpandingButton(s, tags, numoflines, self) + + # Insert the ExpandingButton into the Text widget. + text.mark_gravity("iomark", tk.RIGHT) + text.window_create("iomark", window=expandingbutton, + padx=3, pady=5) + text.see("iomark") + text.update() + text.mark_gravity("iomark", tk.LEFT) + + # Add the ExpandingButton to the Squeezer's list. + self.expandingbuttons.append(expandingbutton) + + editwin.write = mywrite + + def count_lines(self, s): + """Count the number of lines in a given text. + + Before calculation, the tab width and line length of the text are + fetched, so that up-to-date values are used. + + Lines are counted as if the string was wrapped so that lines are never + over linewidth characters long. + + Tabs are considered tabwidth characters long. + """ + return count_lines_with_wrapping(s, self.editwin.width) + + def squeeze_current_text(self): + """Squeeze the text block where the insertion cursor is. + + If the cursor is not in a squeezable block of text, give the + user a small warning and do nothing. + """ + # Set tag_name to the first valid tag found on the "insert" cursor. + tag_names = self.text.tag_names(tk.INSERT) + for tag_name in ("stdout", "stderr"): + if tag_name in tag_names: + break + else: + # The insert cursor doesn't have a "stdout" or "stderr" tag. + self.text.bell() + return "break" + + # Find the range to squeeze. + start, end = self.text.tag_prevrange(tag_name, tk.INSERT + "+1c") + s = self.text.get(start, end) + + # If the last char is a newline, remove it from the range. + if len(s) > 0 and s[-1] == '\n': + end = self.text.index("%s-1c" % end) + s = s[:-1] + + # Delete the text. + self.base_text.delete(start, end) + + # Prepare an ExpandingButton. + numoflines = self.count_lines(s) + expandingbutton = ExpandingButton(s, tag_name, numoflines, self) + + # insert the ExpandingButton to the Text + self.text.window_create(start, window=expandingbutton, + padx=3, pady=5) + + # Insert the ExpandingButton to the list of ExpandingButtons, + # while keeping the list ordered according to the position of + # the buttons in the Text widget. + i = len(self.expandingbuttons) + while i > 0 and self.text.compare(self.expandingbuttons[i-1], + ">", expandingbutton): + i -= 1 + self.expandingbuttons.insert(i, expandingbutton) + + return "break" + + +Squeezer.reload() + + +if __name__ == "__main__": + from unittest import main + main('idlelib.idle_test.test_squeezer', verbosity=2, exit=False) + + # Add htest. diff --git a/Lib/idlelib/stackviewer.py b/Lib/idlelib/stackviewer.py new file mode 100644 index 000000000..95042d4de --- /dev/null +++ b/Lib/idlelib/stackviewer.py @@ -0,0 +1,134 @@ +# Rename to stackbrowser or possibly consolidate with browser. + +import linecache +import os + +import tkinter as tk + +from idlelib.debugobj import ObjectTreeItem, make_objecttreeitem +from idlelib.tree import TreeNode, TreeItem, ScrolledCanvas + +def StackBrowser(root, exc, flist=None, top=None): + global sc, item, node # For testing. + if top is None: + top = tk.Toplevel(root) + sc = ScrolledCanvas(top, bg="white", highlightthickness=0) + sc.frame.pack(expand=1, fill="both") + item = StackTreeItem(exc, flist) + node = TreeNode(sc.canvas, None, item) + node.expand() + + +class StackTreeItem(TreeItem): + + def __init__(self, exc, flist=None): + self.flist = flist + self.stack = self.get_stack(None if exc is None else exc.__traceback__) + self.text = f"{type(exc).__name__}: {str(exc)}" + + def get_stack(self, tb): + stack = [] + if tb and tb.tb_frame is None: + tb = tb.tb_next + while tb is not None: + stack.append((tb.tb_frame, tb.tb_lineno)) + tb = tb.tb_next + return stack + + def GetText(self): # Titlecase names are overrides. + return self.text + + def GetSubList(self): + sublist = [] + for info in self.stack: + item = FrameTreeItem(info, self.flist) + sublist.append(item) + return sublist + + +class FrameTreeItem(TreeItem): + + def __init__(self, info, flist): + self.info = info + self.flist = flist + + def GetText(self): + frame, lineno = self.info + try: + modname = frame.f_globals["__name__"] + except: + modname = "?" + code = frame.f_code + filename = code.co_filename + funcname = code.co_name + sourceline = linecache.getline(filename, lineno) + sourceline = sourceline.strip() + if funcname in ("?", "", None): + item = "%s, line %d: %s" % (modname, lineno, sourceline) + else: + item = "%s.%s(...), line %d: %s" % (modname, funcname, + lineno, sourceline) + return item + + def GetSubList(self): + frame, lineno = self.info + sublist = [] + if frame.f_globals is not frame.f_locals: + item = VariablesTreeItem("", frame.f_locals, self.flist) + sublist.append(item) + item = VariablesTreeItem("", frame.f_globals, self.flist) + sublist.append(item) + return sublist + + def OnDoubleClick(self): + if self.flist: + frame, lineno = self.info + filename = frame.f_code.co_filename + if os.path.isfile(filename): + self.flist.gotofileline(filename, lineno) + + +class VariablesTreeItem(ObjectTreeItem): + + def GetText(self): + return self.labeltext + + def GetLabelText(self): + return None + + def IsExpandable(self): + return len(self.object) > 0 + + def GetSubList(self): + sublist = [] + for key in self.object.keys(): # self.object not necessarily dict. + try: + value = self.object[key] + except KeyError: + continue + def setfunction(value, key=key, object_=self.object): + object_[key] = value + item = make_objecttreeitem(key + " =", value, setfunction) + sublist.append(item) + return sublist + + +def _stackbrowser(parent): # htest # + from idlelib.pyshell import PyShellFileList + top = tk.Toplevel(parent) + top.title("Test StackViewer") + x, y = map(int, parent.geometry().split('+')[1:]) + top.geometry("+%d+%d" % (x + 50, y + 175)) + flist = PyShellFileList(top) + try: # to obtain a traceback object + intentional_name_error + except NameError as e: + StackBrowser(top, e, flist=flist, top=top) + + +if __name__ == '__main__': + from unittest import main + main('idlelib.idle_test.test_stackviewer', verbosity=2, exit=False) + + from idlelib.idle_test.htest import run + run(_stackbrowser) diff --git a/Lib/idlelib/statusbar.py b/Lib/idlelib/statusbar.py new file mode 100644 index 000000000..8445d4cc8 --- /dev/null +++ b/Lib/idlelib/statusbar.py @@ -0,0 +1,52 @@ +from tkinter.ttk import Label, Frame + + +class MultiStatusBar(Frame): + + def __init__(self, master, **kw): + Frame.__init__(self, master, **kw) + self.labels = {} + + def set_label(self, name, text='', side='left', width=0): + if name not in self.labels: + label = Label(self, borderwidth=0, anchor='w') + label.pack(side=side, pady=0, padx=4) + self.labels[name] = label + else: + label = self.labels[name] + if width != 0: + label.config(width=width) + label.config(text=text) + + +def _multistatus_bar(parent): # htest # + from tkinter import Toplevel, Text + from tkinter.ttk import Frame, Button + top = Toplevel(parent) + x, y = map(int, parent.geometry().split('+')[1:]) + top.geometry("+%d+%d" %(x, y + 175)) + top.title("Test multistatus bar") + + frame = Frame(top) + text = Text(frame, height=5, width=40) + text.pack() + msb = MultiStatusBar(frame) + msb.set_label("one", "hello") + msb.set_label("two", "world") + msb.pack(side='bottom', fill='x') + + def change(): + msb.set_label("one", "foo") + msb.set_label("two", "bar") + + button = Button(top, text="Update status", command=change) + button.pack(side='bottom') + frame.pack() + + +if __name__ == '__main__': + from unittest import main + main('idlelib.idle_test.test_statusbar', verbosity=2, exit=False) + + from idlelib.idle_test.htest import run + run(_multistatus_bar) diff --git a/Lib/idlelib/textview.py b/Lib/idlelib/textview.py new file mode 100644 index 000000000..23f0f4cb5 --- /dev/null +++ b/Lib/idlelib/textview.py @@ -0,0 +1,193 @@ +"""Simple text browser for IDLE + +""" +from tkinter import Toplevel, Text, TclError,\ + HORIZONTAL, VERTICAL, NS, EW, NSEW, NONE, WORD, SUNKEN +from tkinter.ttk import Frame, Scrollbar, Button +from tkinter.messagebox import showerror + +from idlelib.colorizer import color_config + + +class AutoHideScrollbar(Scrollbar): + """A scrollbar that is automatically hidden when not needed. + + Only the grid geometry manager is supported. + """ + def set(self, lo, hi): + if float(lo) > 0.0 or float(hi) < 1.0: + self.grid() + else: + self.grid_remove() + super().set(lo, hi) + + def pack(self, **kwargs): + raise TclError(f'{self.__class__.__name__} does not support "pack"') + + def place(self, **kwargs): + raise TclError(f'{self.__class__.__name__} does not support "place"') + + +class ScrollableTextFrame(Frame): + """Display text with scrollbar(s).""" + + def __init__(self, master, wrap=NONE, **kwargs): + """Create a frame for Textview. + + master - master widget for this frame + wrap - type of text wrapping to use ('word', 'char' or 'none') + + All parameters except for 'wrap' are passed to Frame.__init__(). + + The Text widget is accessible via the 'text' attribute. + + Note: Changing the wrapping mode of the text widget after + instantiation is not supported. + """ + super().__init__(master, **kwargs) + + text = self.text = Text(self, wrap=wrap) + text.grid(row=0, column=0, sticky=NSEW) + self.grid_rowconfigure(0, weight=1) + self.grid_columnconfigure(0, weight=1) + + # vertical scrollbar + self.yscroll = AutoHideScrollbar(self, orient=VERTICAL, + takefocus=False, + command=text.yview) + self.yscroll.grid(row=0, column=1, sticky=NS) + text['yscrollcommand'] = self.yscroll.set + + # horizontal scrollbar - only when wrap is set to NONE + if wrap == NONE: + self.xscroll = AutoHideScrollbar(self, orient=HORIZONTAL, + takefocus=False, + command=text.xview) + self.xscroll.grid(row=1, column=0, sticky=EW) + text['xscrollcommand'] = self.xscroll.set + else: + self.xscroll = None + + +class ViewFrame(Frame): + "Display TextFrame and Close button." + def __init__(self, parent, contents, wrap='word'): + """Create a frame for viewing text with a "Close" button. + + parent - parent widget for this frame + contents - text to display + wrap - type of text wrapping to use ('word', 'char' or 'none') + + The Text widget is accessible via the 'text' attribute. + """ + super().__init__(parent) + self.parent = parent + self.bind('', self.ok) + self.bind('', self.ok) + self.textframe = ScrollableTextFrame(self, relief=SUNKEN, height=700) + + text = self.text = self.textframe.text + text.insert('1.0', contents) + text.configure(wrap=wrap, highlightthickness=0, state='disabled') + color_config(text) + text.focus_set() + + self.button_ok = button_ok = Button( + self, text='Close', command=self.ok, takefocus=False) + self.textframe.pack(side='top', expand=True, fill='both') + button_ok.pack(side='bottom') + + def ok(self, event=None): + """Dismiss text viewer dialog.""" + self.parent.destroy() + + +class ViewWindow(Toplevel): + "A simple text viewer dialog for IDLE." + + def __init__(self, parent, title, contents, modal=True, wrap=WORD, + *, _htest=False, _utest=False): + """Show the given text in a scrollable window with a 'close' button. + + If modal is left True, users cannot interact with other windows + until the textview window is closed. + + parent - parent of this dialog + title - string which is title of popup dialog + contents - text to display in dialog + wrap - type of text wrapping to use ('word', 'char' or 'none') + _htest - bool; change box location when running htest. + _utest - bool; don't wait_window when running unittest. + """ + super().__init__(parent) + self['borderwidth'] = 5 + # Place dialog below parent if running htest. + x = parent.winfo_rootx() + 10 + y = parent.winfo_rooty() + (10 if not _htest else 100) + self.geometry(f'=750x500+{x}+{y}') + + self.title(title) + self.viewframe = ViewFrame(self, contents, wrap=wrap) + self.protocol("WM_DELETE_WINDOW", self.ok) + self.button_ok = button_ok = Button(self, text='Close', + command=self.ok, takefocus=False) + self.viewframe.pack(side='top', expand=True, fill='both') + + self.is_modal = modal + if self.is_modal: + self.transient(parent) + self.grab_set() + if not _utest: + self.wait_window() + + def ok(self, event=None): + """Dismiss text viewer dialog.""" + if self.is_modal: + self.grab_release() + self.destroy() + + +def view_text(parent, title, contents, modal=True, wrap='word', _utest=False): + """Create text viewer for given text. + + parent - parent of this dialog + title - string which is the title of popup dialog + contents - text to display in this dialog + wrap - type of text wrapping to use ('word', 'char' or 'none') + modal - controls if users can interact with other windows while this + dialog is displayed + _utest - bool; controls wait_window on unittest + """ + return ViewWindow(parent, title, contents, modal, wrap=wrap, _utest=_utest) + + +def view_file(parent, title, filename, encoding, modal=True, wrap='word', + _utest=False): + """Create text viewer for text in filename. + + Return error message if file cannot be read. Otherwise calls view_text + with contents of the file. + """ + try: + with open(filename, encoding=encoding) as file: + contents = file.read() + except OSError: + showerror(title='File Load Error', + message=f'Unable to load file {filename!r} .', + parent=parent) + except UnicodeDecodeError as err: + showerror(title='Unicode Decode Error', + message=str(err), + parent=parent) + else: + return view_text(parent, title, contents, modal, wrap=wrap, + _utest=_utest) + return None + + +if __name__ == '__main__': + from unittest import main + main('idlelib.idle_test.test_textview', verbosity=2, exit=False) + + from idlelib.idle_test.htest import run + run(ViewWindow) diff --git a/Lib/idlelib/tooltip.py b/Lib/idlelib/tooltip.py new file mode 100644 index 000000000..df5b1fe1d --- /dev/null +++ b/Lib/idlelib/tooltip.py @@ -0,0 +1,190 @@ +"""Tools for displaying tool-tips. + +This includes: + * an abstract base-class for different kinds of tooltips + * a simple text-only Tooltip class +""" +from tkinter import * + + +class TooltipBase: + """abstract base class for tooltips""" + + def __init__(self, anchor_widget): + """Create a tooltip. + + anchor_widget: the widget next to which the tooltip will be shown + + Note that a widget will only be shown when showtip() is called. + """ + self.anchor_widget = anchor_widget + self.tipwindow = None + + def __del__(self): + self.hidetip() + + def showtip(self): + """display the tooltip""" + if self.tipwindow: + return + self.tipwindow = tw = Toplevel(self.anchor_widget) + # show no border on the top level window + tw.wm_overrideredirect(1) + try: + # This command is only needed and available on Tk >= 8.4.0 for OSX. + # Without it, call tips intrude on the typing process by grabbing + # the focus. + tw.tk.call("::tk::unsupported::MacWindowStyle", "style", tw._w, + "help", "noActivates") + except TclError: + pass + + self.position_window() + self.showcontents() + self.tipwindow.update_idletasks() # Needed on MacOS -- see #34275. + self.tipwindow.lift() # work around bug in Tk 8.5.18+ (issue #24570) + + def position_window(self): + """(re)-set the tooltip's screen position""" + x, y = self.get_position() + root_x = self.anchor_widget.winfo_rootx() + x + root_y = self.anchor_widget.winfo_rooty() + y + self.tipwindow.wm_geometry("+%d+%d" % (root_x, root_y)) + + def get_position(self): + """choose a screen position for the tooltip""" + # The tip window must be completely outside the anchor widget; + # otherwise when the mouse enters the tip window we get + # a leave event and it disappears, and then we get an enter + # event and it reappears, and so on forever :-( + # + # Note: This is a simplistic implementation; sub-classes will likely + # want to override this. + return 20, self.anchor_widget.winfo_height() + 1 + + def showcontents(self): + """content display hook for sub-classes""" + # See ToolTip for an example + raise NotImplementedError + + def hidetip(self): + """hide the tooltip""" + # Note: This is called by __del__, so careful when overriding/extending + tw = self.tipwindow + self.tipwindow = None + if tw: + try: + tw.destroy() + except TclError: # pragma: no cover + pass + + +class OnHoverTooltipBase(TooltipBase): + """abstract base class for tooltips, with delayed on-hover display""" + + def __init__(self, anchor_widget, hover_delay=1000): + """Create a tooltip with a mouse hover delay. + + anchor_widget: the widget next to which the tooltip will be shown + hover_delay: time to delay before showing the tooltip, in milliseconds + + Note that a widget will only be shown when showtip() is called, + e.g. after hovering over the anchor widget with the mouse for enough + time. + """ + super().__init__(anchor_widget) + self.hover_delay = hover_delay + + self._after_id = None + self._id1 = self.anchor_widget.bind("", self._show_event) + self._id2 = self.anchor_widget.bind("", self._hide_event) + self._id3 = self.anchor_widget.bind("